aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver
diff options
context:
space:
mode:
authorzousar <[email protected]>2026-02-18 23:19:14 -0700
committerzousar <[email protected]>2026-02-18 23:19:14 -0700
commit2ba28acaf034722452f82cfb07afc0a4bb90eeab (patch)
treec00dea385597180673be6e02aca6c07d9ef6ec00 /src/zenserver
parentupdatefrontend (diff)
parentstructured compute basics (#714) (diff)
downloadzen-2ba28acaf034722452f82cfb07afc0a4bb90eeab.tar.xz
zen-2ba28acaf034722452f82cfb07afc0a4bb90eeab.zip
Merge branch 'main' into zs/web-ui-improvements
Diffstat (limited to 'src/zenserver')
-rw-r--r--src/zenserver/compute/computeserver.cpp330
-rw-r--r--src/zenserver/compute/computeserver.h106
-rw-r--r--src/zenserver/compute/computeservice.cpp100
-rw-r--r--src/zenserver/compute/computeservice.h36
-rw-r--r--src/zenserver/frontend/html/compute.html991
-rw-r--r--src/zenserver/main.cpp55
-rw-r--r--src/zenserver/storage/storageconfig.cpp1
-rw-r--r--src/zenserver/storage/storageconfig.h1
-rw-r--r--src/zenserver/storage/zenstorageserver.cpp21
-rw-r--r--src/zenserver/storage/zenstorageserver.h26
-rw-r--r--src/zenserver/xmake.lua4
-rw-r--r--src/zenserver/zenserver.cpp8
-rw-r--r--src/zenserver/zenserver.h13
13 files changed, 1673 insertions, 19 deletions
diff --git a/src/zenserver/compute/computeserver.cpp b/src/zenserver/compute/computeserver.cpp
new file mode 100644
index 000000000..173f56386
--- /dev/null
+++ b/src/zenserver/compute/computeserver.cpp
@@ -0,0 +1,330 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "computeserver.h"
+#include <zencompute/httpfunctionservice.h>
+#include "computeservice.h"
+
+#if ZEN_WITH_COMPUTE_SERVICES
+
+# include <zencore/fmtutils.h>
+# include <zencore/memory/llm.h>
+# include <zencore/memory/memorytrace.h>
+# include <zencore/memory/tagtrace.h>
+# include <zencore/scopeguard.h>
+# include <zencore/sentryintegration.h>
+# include <zencore/system.h>
+# include <zencore/windows.h>
+# include <zenhttp/httpapiservice.h>
+# include <zenstore/cidstore.h>
+# include <zenutil/service.h>
+
+ZEN_THIRD_PARTY_INCLUDES_START
+# include <cxxopts.hpp>
+ZEN_THIRD_PARTY_INCLUDES_END
+
+namespace zen {
+
+void
+ZenComputeServerConfigurator::AddCliOptions(cxxopts::Options& Options)
+{
+ Options.add_option("compute",
+ "",
+ "upstream-notification-endpoint",
+ "Endpoint URL for upstream notifications",
+ cxxopts::value<std::string>(m_ServerOptions.UpstreamNotificationEndpoint)->default_value(""),
+ "");
+
+ Options.add_option("compute",
+ "",
+ "instance-id",
+ "Instance ID for use in notifications",
+ cxxopts::value<std::string>(m_ServerOptions.InstanceId)->default_value(""),
+ "");
+}
+
+void
+ZenComputeServerConfigurator::AddConfigOptions(LuaConfig::Options& Options)
+{
+ ZEN_UNUSED(Options);
+}
+
+void
+ZenComputeServerConfigurator::ApplyOptions(cxxopts::Options& Options)
+{
+ ZEN_UNUSED(Options);
+}
+
+void
+ZenComputeServerConfigurator::OnConfigFileParsed(LuaConfig::Options& LuaOptions)
+{
+ ZEN_UNUSED(LuaOptions);
+}
+
+void
+ZenComputeServerConfigurator::ValidateOptions()
+{
+}
+
+///////////////////////////////////////////////////////////////////////////
+
+ZenComputeServer::ZenComputeServer()
+{
+}
+
+ZenComputeServer::~ZenComputeServer()
+{
+ Cleanup();
+}
+
+int
+ZenComputeServer::Initialize(const ZenComputeServerConfig& ServerConfig, ZenServerState::ZenServerEntry* ServerEntry)
+{
+ ZEN_TRACE_CPU("ZenComputeServer::Initialize");
+ ZEN_MEMSCOPE(GetZenserverTag());
+
+ ZEN_INFO(ZEN_APP_NAME " initializing in HUB server mode");
+
+ const int EffectiveBasePort = ZenServerBase::Initialize(ServerConfig, ServerEntry);
+ if (EffectiveBasePort < 0)
+ {
+ return EffectiveBasePort;
+ }
+
+ // This is a workaround to make sure we can have automated tests. Without
+ // this the ranges for different child zen hub processes could overlap with
+ // the main test range.
+ ZenServerEnvironment::SetBaseChildId(1000);
+
+ m_DebugOptionForcedCrash = ServerConfig.ShouldCrash;
+
+ InitializeState(ServerConfig);
+ InitializeServices(ServerConfig);
+ RegisterServices(ServerConfig);
+
+ ZenServerBase::Finalize();
+
+ return EffectiveBasePort;
+}
+
+void
+ZenComputeServer::Cleanup()
+{
+ ZEN_TRACE_CPU("ZenStorageServer::Cleanup");
+ ZEN_INFO(ZEN_APP_NAME " cleaning up");
+ try
+ {
+ m_IoContext.stop();
+ if (m_IoRunner.joinable())
+ {
+ m_IoRunner.join();
+ }
+
+ if (m_Http)
+ {
+ m_Http->Close();
+ }
+ }
+ catch (const std::exception& Ex)
+ {
+ ZEN_ERROR("exception thrown during Cleanup() in {}: '{}'", ZEN_APP_NAME, Ex.what());
+ }
+}
+
+void
+ZenComputeServer::InitializeState(const ZenComputeServerConfig& ServerConfig)
+{
+ ZEN_UNUSED(ServerConfig);
+}
+
+void
+ZenComputeServer::InitializeServices(const ZenComputeServerConfig& ServerConfig)
+{
+ ZEN_INFO("initializing storage");
+
+ CidStoreConfiguration Config;
+ Config.RootDirectory = m_DataRoot / "cas";
+
+ m_CidStore = std::make_unique<CidStore>(m_GcManager);
+ m_CidStore->Initialize(Config);
+
+ ZEN_INFO("instantiating API service");
+ m_ApiService = std::make_unique<zen::HttpApiService>(*m_Http);
+
+ ZEN_INFO("instantiating compute service");
+ m_ComputeService = std::make_unique<HttpComputeService>(ServerConfig.DataDir / "compute");
+
+ // Ref<zen::compute::FunctionRunner> Runner;
+ // Runner = zen::compute::CreateLocalRunner(*m_CidStore, ServerConfig.DataDir / "runner");
+
+ // TODO: (re)implement default configuration here
+
+ ZEN_INFO("instantiating function service");
+ m_FunctionService =
+ std::make_unique<zen::compute::HttpFunctionService>(*m_CidStore, m_StatsService, ServerConfig.DataDir / "functions");
+}
+
+void
+ZenComputeServer::RegisterServices(const ZenComputeServerConfig& ServerConfig)
+{
+ ZEN_UNUSED(ServerConfig);
+
+ if (m_ComputeService)
+ {
+ m_Http->RegisterService(*m_ComputeService);
+ }
+
+ if (m_ApiService)
+ {
+ m_Http->RegisterService(*m_ApiService);
+ }
+
+ if (m_FunctionService)
+ {
+ m_Http->RegisterService(*m_FunctionService);
+ }
+}
+
+void
+ZenComputeServer::Run()
+{
+ if (m_ProcessMonitor.IsActive())
+ {
+ CheckOwnerPid();
+ }
+
+ if (!m_TestMode)
+ {
+ // clang-format off
+ ZEN_INFO( R"(__________ _________ __ )" "\n"
+ R"(\____ /____ ____ \_ ___ \ ____ _____ ______ __ ___/ |_ ____ )" "\n"
+ R"( / // __ \ / \/ \ \/ / _ \ / \\____ \| | \ __\/ __ \ )" "\n"
+ R"( / /\ ___/| | \ \___( <_> ) Y Y \ |_> > | /| | \ ___/ )" "\n"
+ R"(/_______ \___ >___| /\______ /\____/|__|_| / __/|____/ |__| \___ >)" "\n"
+ R"( \/ \/ \/ \/ \/|__| \/ )");
+ // clang-format on
+
+ ExtendableStringBuilder<256> BuildOptions;
+ GetBuildOptions(BuildOptions, '\n');
+ ZEN_INFO("Build options ({}/{}):\n{}", GetOperatingSystemName(), GetCpuName(), BuildOptions);
+ }
+
+ ZEN_INFO(ZEN_APP_NAME " now running as COMPUTE (pid: {})", GetCurrentProcessId());
+
+# if ZEN_PLATFORM_WINDOWS
+ if (zen::windows::IsRunningOnWine())
+ {
+ ZEN_INFO("detected Wine session - " ZEN_APP_NAME " is not formally tested on Wine and may therefore not work or perform well");
+ }
+# endif
+
+# if ZEN_USE_SENTRY
+ ZEN_INFO("sentry crash handler {}", m_UseSentry ? "ENABLED" : "DISABLED");
+ if (m_UseSentry)
+ {
+ SentryIntegration::ClearCaches();
+ }
+# endif
+
+ if (m_DebugOptionForcedCrash)
+ {
+ ZEN_DEBUG_BREAK();
+ }
+
+ const bool IsInteractiveMode = IsInteractiveSession(); // &&!m_TestMode;
+
+ SetNewState(kRunning);
+
+ OnReady();
+
+ m_Http->Run(IsInteractiveMode);
+
+ SetNewState(kShuttingDown);
+
+ ZEN_INFO(ZEN_APP_NAME " exiting");
+}
+
+//////////////////////////////////////////////////////////////////////////////////
+
+ZenComputeServerMain::ZenComputeServerMain(ZenComputeServerConfig& ServerOptions)
+: ZenServerMain(ServerOptions)
+, m_ServerOptions(ServerOptions)
+{
+}
+
+void
+ZenComputeServerMain::DoRun(ZenServerState::ZenServerEntry* Entry)
+{
+ ZenComputeServer Server;
+ Server.SetDataRoot(m_ServerOptions.DataDir);
+ Server.SetContentRoot(m_ServerOptions.ContentDir);
+ Server.SetTestMode(m_ServerOptions.IsTest);
+ Server.SetDedicatedMode(m_ServerOptions.IsDedicated);
+
+ const int EffectiveBasePort = Server.Initialize(m_ServerOptions, Entry);
+ if (EffectiveBasePort == -1)
+ {
+ // Server.Initialize has already logged what the issue is - just exit with failure code here.
+ std::exit(1);
+ }
+
+ Entry->EffectiveListenPort = uint16_t(EffectiveBasePort);
+ if (EffectiveBasePort != m_ServerOptions.BasePort)
+ {
+ ZEN_INFO(ZEN_APP_NAME " - relocated to base port {}", EffectiveBasePort);
+ m_ServerOptions.BasePort = EffectiveBasePort;
+ }
+
+ std::unique_ptr<std::thread> ShutdownThread;
+ std::unique_ptr<NamedEvent> ShutdownEvent;
+
+ ExtendableStringBuilder<64> ShutdownEventName;
+ ShutdownEventName << "Zen_" << m_ServerOptions.BasePort << "_Shutdown";
+ ShutdownEvent.reset(new NamedEvent{ShutdownEventName});
+
+ // Monitor shutdown signals
+
+ ShutdownThread.reset(new std::thread{[&] {
+ SetCurrentThreadName("shutdown_mon");
+
+ ZEN_INFO("shutdown monitor thread waiting for shutdown signal '{}' for process {}", ShutdownEventName, zen::GetCurrentProcessId());
+
+ if (ShutdownEvent->Wait())
+ {
+ ZEN_INFO("shutdown signal for pid {} received", zen::GetCurrentProcessId());
+ Server.RequestExit(0);
+ }
+ else
+ {
+ ZEN_INFO("shutdown signal wait() failed");
+ }
+ }});
+
+ auto CleanupShutdown = MakeGuard([&ShutdownEvent, &ShutdownThread] {
+ ReportServiceStatus(ServiceStatus::Stopping);
+
+ if (ShutdownEvent)
+ {
+ ShutdownEvent->Set();
+ }
+ if (ShutdownThread && ShutdownThread->joinable())
+ {
+ ShutdownThread->join();
+ }
+ });
+
+ // If we have a parent process, establish the mechanisms we need
+ // to be able to communicate readiness with the parent
+
+ Server.SetIsReadyFunc([&] {
+ std::error_code Ec;
+ m_LockFile.Update(MakeLockData(true), Ec);
+ ReportServiceStatus(ServiceStatus::Running);
+ NotifyReady();
+ });
+
+ Server.Run();
+}
+
+} // namespace zen
+
+#endif // ZEN_WITH_COMPUTE_SERVICES
diff --git a/src/zenserver/compute/computeserver.h b/src/zenserver/compute/computeserver.h
new file mode 100644
index 000000000..625140b23
--- /dev/null
+++ b/src/zenserver/compute/computeserver.h
@@ -0,0 +1,106 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include "zenserver.h"
+
+#if ZEN_WITH_COMPUTE_SERVICES
+
+# include <zenstore/gc.h>
+
+namespace cxxopts {
+class Options;
+}
+namespace zen::LuaConfig {
+struct Options;
+}
+
+namespace zen::compute {
+class HttpFunctionService;
+}
+
+namespace zen {
+
+class CidStore;
+class HttpApiService;
+class HttpComputeService;
+
+struct ZenComputeServerConfig : public ZenServerConfig
+{
+ std::string UpstreamNotificationEndpoint;
+ std::string InstanceId; // For use in notifications
+};
+
+struct ZenComputeServerConfigurator : public ZenServerConfiguratorBase
+{
+ ZenComputeServerConfigurator(ZenComputeServerConfig& ServerOptions)
+ : ZenServerConfiguratorBase(ServerOptions)
+ , m_ServerOptions(ServerOptions)
+ {
+ }
+
+ ~ZenComputeServerConfigurator() = default;
+
+private:
+ virtual void AddCliOptions(cxxopts::Options& Options) override;
+ virtual void AddConfigOptions(LuaConfig::Options& Options) override;
+ virtual void ApplyOptions(cxxopts::Options& Options) override;
+ virtual void OnConfigFileParsed(LuaConfig::Options& LuaOptions) override;
+ virtual void ValidateOptions() override;
+
+ ZenComputeServerConfig& m_ServerOptions;
+};
+
+class ZenComputeServerMain : public ZenServerMain
+{
+public:
+ ZenComputeServerMain(ZenComputeServerConfig& ServerOptions);
+ virtual void DoRun(ZenServerState::ZenServerEntry* Entry) override;
+
+ ZenComputeServerMain(const ZenComputeServerMain&) = delete;
+ ZenComputeServerMain& operator=(const ZenComputeServerMain&) = delete;
+
+ typedef ZenComputeServerConfig Config;
+ typedef ZenComputeServerConfigurator Configurator;
+
+private:
+ ZenComputeServerConfig& m_ServerOptions;
+};
+
+/**
+ * The compute server handles DDC build function execution requests
+ * only. It's intended to be used on a pure compute resource and does
+ * not handle any storage tasks. The actual scheduling happens upstream
+ * in a storage server instance.
+ */
+
+class ZenComputeServer : public ZenServerBase
+{
+ ZenComputeServer& operator=(ZenComputeServer&&) = delete;
+ ZenComputeServer(ZenComputeServer&&) = delete;
+
+public:
+ ZenComputeServer();
+ ~ZenComputeServer();
+
+ int Initialize(const ZenComputeServerConfig& ServerConfig, ZenServerState::ZenServerEntry* ServerEntry);
+ void Run();
+ void Cleanup();
+
+private:
+ HttpStatsService m_StatsService;
+ GcManager m_GcManager;
+ GcScheduler m_GcScheduler{m_GcManager};
+ std::unique_ptr<CidStore> m_CidStore;
+ std::unique_ptr<HttpComputeService> m_ComputeService;
+ std::unique_ptr<HttpApiService> m_ApiService;
+ std::unique_ptr<zen::compute::HttpFunctionService> m_FunctionService;
+
+ void InitializeState(const ZenComputeServerConfig& ServerConfig);
+ void InitializeServices(const ZenComputeServerConfig& ServerConfig);
+ void RegisterServices(const ZenComputeServerConfig& ServerConfig);
+};
+
+} // namespace zen
+
+#endif // ZEN_WITH_COMPUTE_SERVICES
diff --git a/src/zenserver/compute/computeservice.cpp b/src/zenserver/compute/computeservice.cpp
new file mode 100644
index 000000000..2c0bc0ae9
--- /dev/null
+++ b/src/zenserver/compute/computeservice.cpp
@@ -0,0 +1,100 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "computeservice.h"
+
+#if ZEN_WITH_COMPUTE_SERVICES
+
+# include <zencore/compactbinarybuilder.h>
+# include <zencore/filesystem.h>
+# include <zencore/fmtutils.h>
+# include <zencore/logging.h>
+# include <zencore/system.h>
+# include <zenutil/zenserverprocess.h>
+
+ZEN_THIRD_PARTY_INCLUDES_START
+# include <EASTL/fixed_vector.h>
+# include <asio.hpp>
+ZEN_THIRD_PARTY_INCLUDES_END
+
+# include <unordered_map>
+
+namespace zen {
+
+//////////////////////////////////////////////////////////////////////////
+
+struct ResourceMetrics
+{
+ uint64_t DiskUsageBytes = 0;
+ uint64_t MemoryUsageBytes = 0;
+};
+
+//////////////////////////////////////////////////////////////////////////
+
+struct HttpComputeService::Impl
+{
+ Impl(const Impl&) = delete;
+ Impl& operator=(const Impl&) = delete;
+
+ Impl();
+ ~Impl();
+
+ void Initialize(std::filesystem::path BaseDir) { ZEN_UNUSED(BaseDir); }
+
+ void Cleanup() {}
+
+private:
+};
+
+HttpComputeService::Impl::Impl()
+{
+}
+
+HttpComputeService::Impl::~Impl()
+{
+}
+
+///////////////////////////////////////////////////////////////////////////
+
+HttpComputeService::HttpComputeService(std::filesystem::path BaseDir) : m_Impl(std::make_unique<Impl>())
+{
+ using namespace std::literals;
+
+ m_Impl->Initialize(BaseDir);
+
+ m_Router.RegisterRoute(
+ "status",
+ [this](HttpRouterRequest& Req) {
+ CbObjectWriter Obj;
+ Obj.BeginArray("modules");
+ Obj.EndArray();
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, Obj.Save());
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "stats",
+ [this](HttpRouterRequest& Req) {
+ CbObjectWriter Obj;
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, Obj.Save());
+ },
+ HttpVerb::kGet);
+}
+
+HttpComputeService::~HttpComputeService()
+{
+}
+
+const char*
+HttpComputeService::BaseUri() const
+{
+ return "/compute/";
+}
+
+void
+HttpComputeService::HandleRequest(zen::HttpServerRequest& Request)
+{
+ m_Router.HandleRequest(Request);
+}
+
+} // namespace zen
+#endif // ZEN_WITH_COMPUTE_SERVICES
diff --git a/src/zenserver/compute/computeservice.h b/src/zenserver/compute/computeservice.h
new file mode 100644
index 000000000..339200dd8
--- /dev/null
+++ b/src/zenserver/compute/computeservice.h
@@ -0,0 +1,36 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include <zenhttp/httpserver.h>
+
+#if ZEN_WITH_COMPUTE_SERVICES
+namespace zen {
+
+/** ZenServer Compute Service
+ *
+ * Manages a set of compute workers for use in UEFN content worker
+ *
+ */
+class HttpComputeService : public zen::HttpService
+{
+public:
+ HttpComputeService(std::filesystem::path BaseDir);
+ ~HttpComputeService();
+
+ HttpComputeService(const HttpComputeService&) = delete;
+ HttpComputeService& operator=(const HttpComputeService&) = delete;
+
+ virtual const char* BaseUri() const override;
+ virtual void HandleRequest(zen::HttpServerRequest& Request) override;
+
+private:
+ HttpRequestRouter m_Router;
+
+ struct Impl;
+
+ std::unique_ptr<Impl> m_Impl;
+};
+
+} // namespace zen
+#endif // ZEN_WITH_COMPUTE_SERVICES
diff --git a/src/zenserver/frontend/html/compute.html b/src/zenserver/frontend/html/compute.html
new file mode 100644
index 000000000..668189fe5
--- /dev/null
+++ b/src/zenserver/frontend/html/compute.html
@@ -0,0 +1,991 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <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>
+ <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 {
+ position: relative;
+ height: 300px;
+ margin-top: 20px;
+ }
+
+ .stats-row {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ padding: 8px 0;
+ border-bottom: 1px solid #21262d;
+ }
+
+ .stats-row:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+ }
+
+ .stats-label {
+ color: #8b949e;
+ font-size: 13px;
+ }
+
+ .stats-value {
+ color: #f0f6fc;
+ font-weight: 600;
+ font-size: 13px;
+ }
+
+ .rate-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ margin-top: 16px;
+ }
+
+ .rate-item {
+ text-align: center;
+ }
+
+ .rate-value {
+ font-size: 20px;
+ font-weight: 600;
+ color: #58a6ff;
+ }
+
+ .rate-label {
+ font-size: 11px;
+ color: #8b949e;
+ 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;
+ }
+
+ .worker-row.selected {
+ background: #1f2d3d;
+ }
+
+ .worker-detail {
+ margin-top: 20px;
+ border-top: 1px solid #30363d;
+ padding-top: 16px;
+ }
+
+ .worker-detail-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: #f0f6fc;
+ margin-bottom: 12px;
+ }
+
+ .detail-section {
+ margin-bottom: 16px;
+ }
+
+ .detail-section-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: #8b949e;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 6px;
+ }
+
+ .detail-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ }
+
+ .detail-table td {
+ padding: 4px 8px;
+ color: #c9d1d9;
+ border-bottom: 1px solid #21262d;
+ vertical-align: top;
+ }
+
+ .detail-table td:first-child {
+ color: #8b949e;
+ width: 40%;
+ font-family: monospace;
+ }
+
+ .detail-table tr:last-child td {
+ border-bottom: none;
+ }
+
+ .detail-mono {
+ font-family: monospace;
+ font-size: 11px;
+ color: #8b949e;
+ }
+
+ .detail-tag {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ background: #21262d;
+ color: #c9d1d9;
+ 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">
+ <div class="header">
+ <div>
+ <h1>Zen Compute Dashboard</h1>
+ <div class="timestamp">Last updated: <span id="last-update">Never</span></div>
+ </div>
+ <div class="health-indicator" id="health-indicator">
+ <div class="status-dot"></div>
+ <span id="health-text">Checking...</span>
+ </div>
+ </div>
+
+ <div id="error-container"></div>
+
+ <!-- Action Queue Stats -->
+ <div class="section-title">Action Queue</div>
+ <div class="grid">
+ <div class="card">
+ <div class="card-title">Pending Actions</div>
+ <div class="metric-value" id="actions-pending">-</div>
+ <div class="metric-label">Waiting to be scheduled</div>
+ </div>
+ <div class="card">
+ <div class="card-title">Running Actions</div>
+ <div class="metric-value" id="actions-running">-</div>
+ <div class="metric-label">Currently executing</div>
+ </div>
+ <div class="card">
+ <div class="card-title">Completed Actions</div>
+ <div class="metric-value" id="actions-complete">-</div>
+ <div class="metric-label">Results available</div>
+ </div>
+ </div>
+
+ <!-- Action Queue Chart -->
+ <div class="card" style="margin-bottom: 30px;">
+ <div class="card-title">Action Queue History</div>
+ <div class="chart-container">
+ <canvas id="queue-chart"></canvas>
+ </div>
+ </div>
+
+ <!-- Performance Metrics -->
+ <div class="section-title">Performance Metrics</div>
+ <div class="card" style="margin-bottom: 30px;">
+ <div class="card-title">Completion Rate</div>
+ <div class="rate-stats">
+ <div class="rate-item">
+ <div class="rate-value" id="rate-1">-</div>
+ <div class="rate-label">1 min rate</div>
+ </div>
+ <div class="rate-item">
+ <div class="rate-value" id="rate-5">-</div>
+ <div class="rate-label">5 min rate</div>
+ </div>
+ <div class="rate-item">
+ <div class="rate-value" id="rate-15">-</div>
+ <div class="rate-label">15 min rate</div>
+ </div>
+ </div>
+ <div style="margin-top: 20px;">
+ <div class="stats-row">
+ <span class="stats-label">Total Retired</span>
+ <span class="stats-value" id="retired-count">-</span>
+ </div>
+ <div class="stats-row">
+ <span class="stats-label">Mean Rate</span>
+ <span class="stats-value" id="rate-mean">-</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Workers -->
+ <div class="section-title">Workers</div>
+ <div class="card" style="margin-bottom: 30px;">
+ <div class="card-title">Worker Status</div>
+ <div class="stats-row">
+ <span class="stats-label">Registered Workers</span>
+ <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;">
+ <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>
+ </tr>
+ </thead>
+ <tbody id="worker-table-body"></tbody>
+ </table>
+ <div id="worker-detail" class="worker-detail" style="display: none;"></div>
+ </div>
+ </div>
+
+ <!-- Action History -->
+ <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-container" style="display: none;">
+ <table id="action-history-table" style="width: 100%; border-collapse: collapse; font-size: 13px;">
+ <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: 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>
+ </tr>
+ </thead>
+ <tbody id="action-history-body"></tbody>
+ </table>
+ </div>
+ </div>
+
+ <!-- System Resources -->
+ <div class="section-title">System Resources</div>
+ <div class="grid">
+ <div class="card">
+ <div class="card-title">CPU Usage</div>
+ <div class="metric-value" id="cpu-usage">-</div>
+ <div class="metric-label">Percent</div>
+ <div class="progress-bar">
+ <div class="progress-fill" id="cpu-progress" style="width: 0%"></div>
+ </div>
+ <div style="position: relative; height: 60px; margin-top: 12px;">
+ <canvas id="cpu-chart"></canvas>
+ </div>
+ <div style="margin-top: 12px;">
+ <div class="stats-row">
+ <span class="stats-label">Packages</span>
+ <span class="stats-value" id="cpu-packages">-</span>
+ </div>
+ <div class="stats-row">
+ <span class="stats-label">Physical Cores</span>
+ <span class="stats-value" id="cpu-cores">-</span>
+ </div>
+ <div class="stats-row">
+ <span class="stats-label">Logical Processors</span>
+ <span class="stats-value" id="cpu-lp">-</span>
+ </div>
+ </div>
+ </div>
+ <div class="card">
+ <div class="card-title">Memory</div>
+ <div class="stats-row">
+ <span class="stats-label">Used</span>
+ <span class="stats-value" id="memory-used">-</span>
+ </div>
+ <div class="stats-row">
+ <span class="stats-label">Total</span>
+ <span class="stats-value" id="memory-total">-</span>
+ </div>
+ <div class="progress-bar">
+ <div class="progress-fill" id="memory-progress" style="width: 0%"></div>
+ </div>
+ </div>
+ <div class="card">
+ <div class="card-title">Disk</div>
+ <div class="stats-row">
+ <span class="stats-label">Used</span>
+ <span class="stats-value" id="disk-used">-</span>
+ </div>
+ <div class="stats-row">
+ <span class="stats-label">Total</span>
+ <span class="stats-value" id="disk-total">-</span>
+ </div>
+ <div class="progress-bar">
+ <div class="progress-fill" id="disk-progress" style="width: 0%"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ // Configuration
+ const BASE_URL = window.location.origin;
+ const REFRESH_INTERVAL = 2000; // 2 seconds
+ const MAX_HISTORY_POINTS = 60; // Show last 2 minutes
+
+ // Data storage
+ const history = {
+ timestamps: [],
+ pending: [],
+ running: [],
+ completed: [],
+ cpu: []
+ };
+
+ // CPU sparkline chart
+ const cpuCtx = document.getElementById('cpu-chart').getContext('2d');
+ const cpuChart = new Chart(cpuCtx, {
+ 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 }
+ }
+ }
+ });
+
+ // Queue chart setup
+ const ctx = document.getElementById('queue-chart').getContext('2d');
+ const chart = new Chart(ctx, {
+ 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'
+ }
+ }
+ }
+ }
+ });
+
+ // Helper functions
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ function formatRate(rate) {
+ return rate.toFixed(2) + '/s';
+ }
+
+ function showError(message) {
+ const container = document.getElementById('error-container');
+ container.innerHTML = `<div class="error">Error: ${message}</div>`;
+ }
+
+ function clearError() {
+ document.getElementById('error-container').innerHTML = '';
+ }
+
+ function updateTimestamp() {
+ const now = new Date();
+ document.getElementById('last-update').textContent = now.toLocaleTimeString();
+ }
+
+ // Fetch functions
+ async function fetchJSON(endpoint) {
+ const response = await fetch(`${BASE_URL}${endpoint}`, {
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ return await response.json();
+ }
+
+ async function fetchHealth() {
+ try {
+ const response = await fetch(`${BASE_URL}/apply/ready`);
+ const isHealthy = response.status === 200;
+
+ const indicator = document.getElementById('health-indicator');
+ const text = document.getElementById('health-text');
+
+ if (isHealthy) {
+ indicator.classList.add('healthy');
+ indicator.classList.remove('unhealthy');
+ text.textContent = 'Healthy';
+ } else {
+ indicator.classList.add('unhealthy');
+ indicator.classList.remove('healthy');
+ text.textContent = 'Unhealthy';
+ }
+
+ return isHealthy;
+ } catch (error) {
+ const indicator = document.getElementById('health-indicator');
+ const text = document.getElementById('health-text');
+ indicator.classList.add('unhealthy');
+ indicator.classList.remove('healthy');
+ text.textContent = 'Error';
+ throw error;
+ }
+ }
+
+ async function fetchStats() {
+ const data = await fetchJSON('/stats/apply');
+
+ // Update action counts
+ document.getElementById('actions-pending').textContent = data.actions_pending || 0;
+ document.getElementById('actions-running').textContent = data.actions_submitted || 0;
+ document.getElementById('actions-complete').textContent = data.actions_complete || 0;
+
+ // Update completion rates
+ if (data.actions_retired) {
+ document.getElementById('rate-1').textContent = formatRate(data.actions_retired.rate_1 || 0);
+ document.getElementById('rate-5').textContent = formatRate(data.actions_retired.rate_5 || 0);
+ document.getElementById('rate-15').textContent = formatRate(data.actions_retired.rate_15 || 0);
+ document.getElementById('retired-count').textContent = data.actions_retired.count || 0;
+ document.getElementById('rate-mean').textContent = formatRate(data.actions_retired.rate_mean || 0);
+ }
+
+ // Update chart
+ const now = new Date().toLocaleTimeString();
+ history.timestamps.push(now);
+ history.pending.push(data.actions_pending || 0);
+ history.running.push(data.actions_submitted || 0);
+ history.completed.push(data.actions_complete || 0);
+
+ // Keep only last N points
+ if (history.timestamps.length > MAX_HISTORY_POINTS) {
+ history.timestamps.shift();
+ history.pending.shift();
+ history.running.shift();
+ history.completed.shift();
+ }
+
+ chart.data.labels = history.timestamps;
+ chart.data.datasets[0].data = history.pending;
+ chart.data.datasets[1].data = history.running;
+ chart.data.datasets[2].data = history.completed;
+ chart.update('none');
+ }
+
+ async function fetchSysInfo() {
+ const data = await fetchJSON('/apply/sysinfo');
+
+ // Update CPU
+ const cpuUsage = data.cpu_usage || 0;
+ document.getElementById('cpu-usage').textContent = cpuUsage.toFixed(1) + '%';
+ document.getElementById('cpu-progress').style.width = cpuUsage + '%';
+
+ history.cpu.push(cpuUsage);
+ if (history.cpu.length > MAX_HISTORY_POINTS) history.cpu.shift();
+ cpuChart.data.labels = history.cpu.map(() => '');
+ cpuChart.data.datasets[0].data = history.cpu;
+ cpuChart.update('none');
+
+ document.getElementById('cpu-packages').textContent = data.cpu_count ?? '-';
+ document.getElementById('cpu-cores').textContent = data.core_count ?? '-';
+ document.getElementById('cpu-lp').textContent = data.lp_count ?? '-';
+
+ // Update Memory
+ const memUsed = data.memory_used || 0;
+ const memTotal = data.memory_total || 1;
+ const memPercent = (memUsed / memTotal) * 100;
+ document.getElementById('memory-used').textContent = formatBytes(memUsed);
+ document.getElementById('memory-total').textContent = formatBytes(memTotal);
+ document.getElementById('memory-progress').style.width = memPercent + '%';
+
+ // Update Disk
+ const diskUsed = data.disk_used || 0;
+ const diskTotal = data.disk_total || 1;
+ const diskPercent = (diskUsed / diskTotal) * 100;
+ document.getElementById('disk-used').textContent = formatBytes(diskUsed);
+ document.getElementById('disk-total').textContent = formatBytes(diskTotal);
+ document.getElementById('disk-progress').style.width = diskPercent + '%';
+ }
+
+ // Persists the selected worker ID across refreshes
+ let selectedWorkerId = null;
+
+ function renderWorkerDetail(id, desc) {
+ const panel = document.getElementById('worker-detail');
+
+ if (!desc) {
+ panel.style.display = 'none';
+ return;
+ }
+
+ function field(label, value) {
+ return `<tr><td>${label}</td><td>${value ?? '-'}</td></tr>`;
+ }
+
+ function monoField(label, value) {
+ return `<tr><td>${label}</td><td class="detail-mono">${value ?? '-'}</td></tr>`;
+ }
+
+ // Functions
+ const functions = desc.functions || [];
+ const functionsHtml = functions.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' :
+ `<table class="detail-table">${functions.map(f =>
+ `<tr><td>${f.name || '-'}</td><td class="detail-mono">${f.version || '-'}</td></tr>`
+ ).join('')}</table>`;
+
+ // 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>' :
+ `<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>
+ </tr>
+ ${executables.map(e =>
+ `<tr>
+ <td>${e.name || '-'}</td>
+ <td class="detail-mono">${e.hash || '-'}</td>
+ <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>
+ <td></td>
+ <td style="text-align:right;white-space:nowrap;padding-top:6px;color:#f0f6fc;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>' :
+ `<table class="detail-table">${files.map(f =>
+ `<tr><td>${f.name || f}</td><td class="detail-mono">${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>' :
+ dirs.map(d => `<span class="detail-tag">${d}</span>`).join('');
+
+ // Environment
+ const env = desc.environment || [];
+ const envHtml = env.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' :
+ env.map(e => `<span class="detail-tag">${e}</span>`).join('');
+
+ panel.innerHTML = `
+ <div class="worker-detail-title">${desc.name || id}</div>
+ <div class="detail-section">
+ <table class="detail-table">
+ ${field('Worker ID', `<span class="detail-mono">${id}</span>`)}
+ ${field('Path', desc.path)}
+ ${field('Platform', desc.host)}
+ ${monoField('Build System', desc.buildsystem_version)}
+ ${field('Cores', desc.cores)}
+ ${field('Timeout', desc.timeout != null ? desc.timeout + 's' : null)}
+ </table>
+ </div>
+ <div class="detail-section">
+ <div class="detail-section-label">Functions</div>
+ ${functionsHtml}
+ </div>
+ <div class="detail-section">
+ <div class="detail-section-label">Executables</div>
+ ${execHtml}
+ </div>
+ <div class="detail-section">
+ <div class="detail-section-label">Files</div>
+ ${filesHtml}
+ </div>
+ <div class="detail-section">
+ <div class="detail-section-label">Directories</div>
+ ${dirsHtml}
+ </div>
+ <div class="detail-section">
+ <div class="detail-section-label">Environment</div>
+ ${envHtml}
+ </div>
+ `;
+ panel.style.display = 'block';
+ }
+
+ async function fetchWorkers() {
+ const data = await fetchJSON('/apply/workers');
+ const workerIds = data.workers || [];
+
+ document.getElementById('worker-count').textContent = workerIds.length;
+
+ const container = document.getElementById('worker-table-container');
+ const tbody = document.getElementById('worker-table-body');
+
+ if (workerIds.length === 0) {
+ container.style.display = 'none';
+ selectedWorkerId = null;
+ return;
+ }
+
+ const descriptors = await Promise.all(
+ workerIds.map(id => fetchJSON(`/apply/workers/${id}`).catch(() => null))
+ );
+
+ // Build a map for quick lookup by ID
+ const descriptorMap = {};
+ workerIds.forEach((id, i) => { descriptorMap[id] = descriptors[i]; });
+
+ tbody.innerHTML = '';
+ descriptors.forEach((desc, i) => {
+ const id = workerIds[i];
+ 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 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;">${name}</td>
+ <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d;">${host}</td>
+ <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${cores}</td>
+ <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${timeout}</td>
+ <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${functions}</td>
+ <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${id}</td>
+ `;
+ tr.addEventListener('click', () => {
+ document.querySelectorAll('.worker-row').forEach(r => r.classList.remove('selected'));
+ if (selectedWorkerId === id) {
+ // Toggle off
+ selectedWorkerId = null;
+ document.getElementById('worker-detail').style.display = 'none';
+ } else {
+ selectedWorkerId = id;
+ tr.classList.add('selected');
+ renderWorkerDetail(id, descriptorMap[id]);
+ }
+ });
+ tbody.appendChild(tr);
+ });
+
+ // Re-render detail if selected worker is still present
+ if (selectedWorkerId && descriptorMap[selectedWorkerId]) {
+ renderWorkerDetail(selectedWorkerId, descriptorMap[selectedWorkerId]);
+ } else if (selectedWorkerId && !descriptorMap[selectedWorkerId]) {
+ selectedWorkerId = null;
+ document.getElementById('worker-detail').style.display = 'none';
+ }
+
+ container.style.display = 'block';
+ }
+
+ // Windows FILETIME: 100ns ticks since 1601-01-01. Convert to JS Date.
+ 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`;
+ }
+
+ async function fetchActionHistory() {
+ const data = await fetchJSON('/apply/jobs/history?limit=50');
+ const entries = data.history || [];
+
+ const empty = document.getElementById('action-history-empty');
+ const container = document.getElementById('action-history-container');
+ const tbody = document.getElementById('action-history-body');
+
+ if (entries.length === 0) {
+ empty.style.display = '';
+ container.style.display = 'none';
+ return;
+ }
+
+ empty.style.display = 'none';
+ tbody.innerHTML = '';
+
+ // Entries arrive oldest-first; reverse to show newest at top
+ for (const entry of [...entries].reverse()) {
+ const lsn = entry.lsn ?? '-';
+ const succeeded = entry.succeeded;
+ const badge = succeeded == null
+ ? '<span class="status-badge" style="background:#21262d;color:#8b949e;">unknown</span>'
+ : succeeded
+ ? '<span class="status-badge success">ok</span>'
+ : '<span class="status-badge failure">failed</span>';
+ const desc = entry.actionDescriptor || {};
+ const fn = desc.Function || '-';
+ const workerId = entry.workerId || '-';
+ const actionId = entry.actionId || '-';
+
+ const startDate = filetimeToDate(entry.time_Running);
+ const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed);
+
+ 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;">${lsn}</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;">${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;">${workerId}</td>
+ <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${actionId}</td>
+ `;
+ tbody.appendChild(tr);
+ }
+
+ container.style.display = 'block';
+ }
+
+ async function updateDashboard() {
+ try {
+ await Promise.all([
+ fetchHealth(),
+ fetchStats(),
+ fetchSysInfo(),
+ fetchWorkers(),
+ fetchActionHistory()
+ ]);
+
+ clearError();
+ updateTimestamp();
+ } catch (error) {
+ console.error('Error updating dashboard:', error);
+ showError(error.message);
+ }
+ }
+
+ // Start updating
+ updateDashboard();
+ setInterval(updateDashboard, REFRESH_INTERVAL);
+ </script>
+</body>
+</html>
diff --git a/src/zenserver/main.cpp b/src/zenserver/main.cpp
index 1a929b026..ee783d2a6 100644
--- a/src/zenserver/main.cpp
+++ b/src/zenserver/main.cpp
@@ -23,6 +23,9 @@
#include <zenutil/service.h>
#include "diag/logging.h"
+
+#include "compute/computeserver.h"
+
#include "storage/storageconfig.h"
#include "storage/zenstorageserver.h"
@@ -61,11 +64,19 @@ namespace zen {
#if ZEN_PLATFORM_WINDOWS
-template<class T>
+/** Windows Service wrapper for Zen servers
+ *
+ * This class wraps a Zen server main entry point (the Main template parameter)
+ * into a Windows Service by implementing the WindowsService interface.
+ *
+ * The Main type needs to implement the virtual functions from the ZenServerMain
+ * base class, which provides the actual server logic.
+ */
+template<class Main>
class ZenWindowsService : public WindowsService
{
public:
- ZenWindowsService(typename T::Config& ServerOptions) : m_EntryPoint(ServerOptions) {}
+ ZenWindowsService(typename Main::Config& ServerOptions) : m_EntryPoint(ServerOptions) {}
ZenWindowsService(const ZenWindowsService&) = delete;
ZenWindowsService& operator=(const ZenWindowsService&) = delete;
@@ -73,7 +84,7 @@ public:
virtual int Run() override { return m_EntryPoint.Run(); }
private:
- T m_EntryPoint;
+ Main m_EntryPoint;
};
#endif // ZEN_PLATFORM_WINDOWS
@@ -84,6 +95,23 @@ private:
namespace zen {
+/** Application main entry point template
+ *
+ * This function handles common application startup tasks while allowing
+ * different server types to be plugged in via the Main template parameter.
+ *
+ * On Windows, this function also handles platform-specific service
+ * installation and uninstallation.
+ *
+ * The Main type needs to implement the virtual functions from the ZenServerMain
+ * base class, which provides the actual server logic.
+ *
+ * The Main type is also expected to provide the following members:
+ *
+ * typedef Config -- Server configuration type, derived from ZenServerConfig
+ * typedef Configurator -- Server configuration handler type, implements ZenServerConfiguratorBase
+ *
+ */
template<class Main>
int
AppMain(int argc, char* argv[])
@@ -241,7 +269,12 @@ main(int argc, char* argv[])
auto _ = zen::MakeGuard([] {
// Allow some time for worker threads to unravel, in an effort
- // to prevent shutdown races in TLS object destruction
+ // to prevent shutdown races in TLS object destruction, mainly due to
+ // threads which we don't directly control (Windows thread pool) and
+ // therefore can't join.
+ //
+ // This isn't a great solution, but for now it seems to help reduce
+ // shutdown crashes observed in some situations.
WaitForThreads(1000);
});
@@ -249,6 +282,7 @@ main(int argc, char* argv[])
{
kHub,
kStore,
+ kCompute,
kTest
} ServerMode = kStore;
@@ -258,10 +292,14 @@ main(int argc, char* argv[])
{
ServerMode = kHub;
}
- else if (argv[1] == "store"sv)
+ else if ((argv[1] == "store"sv) || (argv[1] == "storage"sv))
{
ServerMode = kStore;
}
+ else if (argv[1] == "compute"sv)
+ {
+ ServerMode = kCompute;
+ }
else if (argv[1] == "test"sv)
{
ServerMode = kTest;
@@ -280,6 +318,13 @@ main(int argc, char* argv[])
break;
case kHub:
return AppMain<ZenHubServerMain>(argc, argv);
+ case kCompute:
+#if ZEN_WITH_COMPUTE_SERVICES
+ return AppMain<ZenComputeServerMain>(argc, argv);
+#else
+ fprintf(stderr, "compute services are not compiled in!\n");
+ exit(5);
+#endif
default:
case kStore:
return AppMain<ZenStorageServerMain>(argc, argv);
diff --git a/src/zenserver/storage/storageconfig.cpp b/src/zenserver/storage/storageconfig.cpp
index 0f8ab1e98..089b6b572 100644
--- a/src/zenserver/storage/storageconfig.cpp
+++ b/src/zenserver/storage/storageconfig.cpp
@@ -797,6 +797,7 @@ ZenStorageServerCmdLineOptions::AddCacheOptions(cxxopts::Options& options, ZenSt
cxxopts::value<uint64_t>(ServerOptions.StructuredCacheConfig.MemMaxAgeSeconds)->default_value("86400"),
"");
+ options.add_option("compute", "", "lie-cpus", "Lie to upstream about CPU capabilities", cxxopts::value<int>(ServerOptions.LieCpu), "");
options.add_option("cache",
"",
"cache-bucket-maxblocksize",
diff --git a/src/zenserver/storage/storageconfig.h b/src/zenserver/storage/storageconfig.h
index d59d05cf6..b408b0c26 100644
--- a/src/zenserver/storage/storageconfig.h
+++ b/src/zenserver/storage/storageconfig.h
@@ -156,6 +156,7 @@ struct ZenStorageServerConfig : public ZenServerConfig
ZenWorkspacesConfig WorksSpacesConfig;
std::filesystem::path PluginsConfigFile; // Path to plugins config file
bool ObjectStoreEnabled = false;
+ bool ComputeEnabled = true;
std::string ScrubOptions;
};
diff --git a/src/zenserver/storage/zenstorageserver.cpp b/src/zenserver/storage/zenstorageserver.cpp
index 2b74395c3..ff854b72d 100644
--- a/src/zenserver/storage/zenstorageserver.cpp
+++ b/src/zenserver/storage/zenstorageserver.cpp
@@ -182,6 +182,13 @@ ZenStorageServer::RegisterServices()
#endif // ZEN_WITH_VFS
m_Http->RegisterService(*m_AdminService);
+
+#if ZEN_WITH_COMPUTE_SERVICES
+ if (m_HttpFunctionService)
+ {
+ m_Http->RegisterService(*m_HttpFunctionService);
+ }
+#endif
}
void
@@ -267,6 +274,16 @@ ZenStorageServer::InitializeServices(const ZenStorageServerConfig& ServerOptions
m_BuildStoreService = std::make_unique<HttpBuildStoreService>(m_StatusService, m_StatsService, *m_BuildStore);
}
+#if ZEN_WITH_COMPUTE_SERVICES
+ if (ServerOptions.ComputeEnabled)
+ {
+ ZEN_OTEL_SPAN("InitializeComputeService");
+
+ m_HttpFunctionService =
+ std::make_unique<compute::HttpFunctionService>(*m_CidStore, m_StatsService, ServerOptions.DataDir / "functions");
+ }
+#endif
+
#if ZEN_WITH_VFS
m_VfsServiceImpl = std::make_unique<VfsServiceImpl>();
m_VfsServiceImpl->AddService(Ref<ProjectStore>(m_ProjectStore));
@@ -805,6 +822,10 @@ ZenStorageServer::Cleanup()
Flush();
+#if ZEN_WITH_COMPUTE_SERVICES
+ m_HttpFunctionService.reset();
+#endif
+
m_AdminService.reset();
m_VfsService.reset();
m_VfsServiceImpl.reset();
diff --git a/src/zenserver/storage/zenstorageserver.h b/src/zenserver/storage/zenstorageserver.h
index 5ccb587d6..456447a2a 100644
--- a/src/zenserver/storage/zenstorageserver.h
+++ b/src/zenserver/storage/zenstorageserver.h
@@ -6,6 +6,7 @@
#include <zenhttp/auth/authmgr.h>
#include <zenhttp/auth/authservice.h>
+#include <zenhttp/httpapiservice.h>
#include <zenhttp/httptest.h>
#include <zenstore/cache/structuredcachestore.h>
#include <zenstore/gc.h>
@@ -23,6 +24,10 @@
#include "vfs/vfsservice.h"
#include "workspaces/httpworkspaces.h"
+#if ZEN_WITH_COMPUTE_SERVICES
+# include <zencompute/httpfunctionservice.h>
+#endif
+
namespace zen {
class ZenStorageServer : public ZenServerBase
@@ -34,11 +39,6 @@ public:
ZenStorageServer();
~ZenStorageServer();
- void SetDedicatedMode(bool State) { m_IsDedicatedMode = State; }
- void SetTestMode(bool State) { m_TestMode = State; }
- void SetDataRoot(std::filesystem::path Root) { m_DataRoot = Root; }
- void SetContentRoot(std::filesystem::path Root) { m_ContentRoot = Root; }
-
int Initialize(const ZenStorageServerConfig& ServerOptions, ZenServerState::ZenServerEntry* ServerEntry);
void Run();
void Cleanup();
@@ -48,14 +48,9 @@ private:
void InitializeStructuredCache(const ZenStorageServerConfig& ServerOptions);
void Flush();
- bool m_IsDedicatedMode = false;
- bool m_TestMode = false;
- bool m_DebugOptionForcedCrash = false;
- std::string m_StartupScrubOptions;
- CbObject m_RootManifest;
- std::filesystem::path m_DataRoot;
- std::filesystem::path m_ContentRoot;
- asio::steady_timer m_StateMarkerTimer{m_IoContext};
+ std::string m_StartupScrubOptions;
+ CbObject m_RootManifest;
+ asio::steady_timer m_StateMarkerTimer{m_IoContext};
void EnqueueStateMarkerTimer();
void CheckStateMarker();
@@ -95,6 +90,11 @@ private:
std::unique_ptr<HttpBuildStoreService> m_BuildStoreService;
std::unique_ptr<VfsService> m_VfsService;
std::unique_ptr<HttpAdminService> m_AdminService;
+ std::unique_ptr<HttpApiService> m_ApiService;
+
+#if ZEN_WITH_COMPUTE_SERVICES
+ std::unique_ptr<compute::HttpFunctionService> m_HttpFunctionService;
+#endif
};
struct ZenStorageServerConfigurator;
diff --git a/src/zenserver/xmake.lua b/src/zenserver/xmake.lua
index 6ee80dc62..9ab51beb2 100644
--- a/src/zenserver/xmake.lua
+++ b/src/zenserver/xmake.lua
@@ -2,7 +2,11 @@
target("zenserver")
set_kind("binary")
+ if enable_unity then
+ add_rules("c++.unity_build", {batchsize = 4})
+ end
add_deps("zencore",
+ "zencompute",
"zenhttp",
"zennet",
"zenremotestore",
diff --git a/src/zenserver/zenserver.cpp b/src/zenserver/zenserver.cpp
index 7f9bf56a9..7bf6126df 100644
--- a/src/zenserver/zenserver.cpp
+++ b/src/zenserver/zenserver.cpp
@@ -18,6 +18,7 @@
#include <zencore/sentryintegration.h>
#include <zencore/session.h>
#include <zencore/string.h>
+#include <zencore/system.h>
#include <zencore/thread.h>
#include <zencore/timer.h>
#include <zencore/trace.h>
@@ -145,6 +146,13 @@ ZenServerBase::Initialize(const ZenServerConfig& ServerOptions, ZenServerState::
InitializeSecuritySettings(ServerOptions);
+ if (ServerOptions.LieCpu)
+ {
+ SetCpuCountForReporting(ServerOptions.LieCpu);
+
+ ZEN_INFO("Reporting concurrency: {}", ServerOptions.LieCpu);
+ }
+
m_StatusService.RegisterHandler("status", *this);
m_Http->RegisterService(m_StatusService);
diff --git a/src/zenserver/zenserver.h b/src/zenserver/zenserver.h
index efa46f361..5a8a079c0 100644
--- a/src/zenserver/zenserver.h
+++ b/src/zenserver/zenserver.h
@@ -43,6 +43,11 @@ public:
void SetIsReadyFunc(std::function<void()>&& IsReadyFunc) { m_IsReadyFunc = std::move(IsReadyFunc); }
+ void SetDataRoot(std::filesystem::path Root) { m_DataRoot = Root; }
+ void SetContentRoot(std::filesystem::path Root) { m_ContentRoot = Root; }
+ void SetDedicatedMode(bool State) { m_IsDedicatedMode = State; }
+ void SetTestMode(bool State) { m_TestMode = State; }
+
protected:
int Initialize(const ZenServerConfig& ServerOptions, ZenServerState::ZenServerEntry* ServerEntry);
void Finalize();
@@ -55,6 +60,10 @@ protected:
bool m_UseSentry = false;
bool m_IsPowerCycle = false;
+ bool m_IsDedicatedMode = false;
+ bool m_TestMode = false;
+ bool m_DebugOptionForcedCrash = false;
+
std::thread m_IoRunner;
asio::io_context m_IoContext;
void EnsureIoRunner();
@@ -72,6 +81,9 @@ protected:
std::function<void()> m_IsReadyFunc;
void OnReady();
+ std::filesystem::path m_DataRoot; // Root directory for server state
+ std::filesystem::path m_ContentRoot; // Root directory for frontend content
+
Ref<HttpServer> m_Http;
std::unique_ptr<IHttpRequestFilter> m_HttpRequestFilter;
@@ -114,7 +126,6 @@ protected:
private:
void InitializeSecuritySettings(const ZenServerConfig& ServerOptions);
};
-
class ZenServerMain
{
public: