diff options
| author | Stefan Boberg <[email protected]> | 2026-02-25 19:12:09 +0100 |
|---|---|---|
| committer | Stefan Boberg <[email protected]> | 2026-02-25 19:12:09 +0100 |
| commit | 95c7ae6bdc71e1451b9a5073591634036852f359 (patch) | |
| tree | 4cfb956ffb17939d7b5962abc4e1361efeab42bb | |
| parent | updated CLAUDE.md (diff) | |
| download | zen-95c7ae6bdc71e1451b9a5073591634036852f359.tar.xz zen-95c7ae6bdc71e1451b9a5073591634036852f359.zip | |
initial top command sketch
| -rw-r--r-- | src/zen/cmds/top_cmd.cpp | 513 | ||||
| -rw-r--r-- | src/zen/cmds/top_cmd.h | 2 | ||||
| -rw-r--r-- | src/zenutil/consoletui.cpp | 83 | ||||
| -rw-r--r-- | src/zenutil/include/zenutil/consoletui.h | 24 |
4 files changed, 575 insertions, 47 deletions
diff --git a/src/zen/cmds/top_cmd.cpp b/src/zen/cmds/top_cmd.cpp index f674db6cd..0d74e96be 100644 --- a/src/zen/cmds/top_cmd.cpp +++ b/src/zen/cmds/top_cmd.cpp @@ -2,20 +2,186 @@ #include "top_cmd.h" +#include <zencore/compactbinary.h> #include <zencore/fmtutils.h> #include <zencore/logging.h> +#include <zencore/scopeguard.h> +#include <zencore/string.h> #include <zencore/system.h> +#include <zencore/thread.h> #include <zencore/uid.h> +#include <zenhttp/httpclient.h> +#include <zenutil/consoletui.h> #include <zenutil/zenserverprocess.h> +#include <chrono> +#include <ctime> #include <memory> ////////////////////////////////////////////////////////////////////////// namespace zen { +namespace { + + struct RunningInstance + { + uint16_t Port; + uint32_t Pid; + std::string SessionId; + }; + + static std::vector<RunningInstance> CollectInstances(ZenServerState& State) + { + std::vector<RunningInstance> Instances; + State.Snapshot([&](const ZenServerState::ZenServerEntry& Entry) { + StringBuilder<25> SessionSB; + Entry.GetSessionId().ToString(SessionSB); + Instances.push_back({Entry.EffectiveListenPort.load(), Entry.Pid.load(), std::string(SessionSB.c_str())}); + }); + return Instances; + } + + static std::string FormatCurrentTime() + { + std::time_t Now = std::time(nullptr); + std::tm Local{}; +#if ZEN_PLATFORM_WINDOWS + localtime_s(&Local, &Now); +#else + localtime_r(&Now, &Local); +#endif + char Buffer[20]; + std::strftime(Buffer, sizeof(Buffer), "%Y-%m-%d %H:%M:%S", &Local); + return Buffer; + } + + static void PrintHorizontalRule(uint32_t Cols) + { + // U+2500 BOX DRAWINGS LIGHT HORIZONTAL = \xe2\x94\x80 (3 bytes per glyph) + ExtendableStringBuilder<512> Line; + for (uint32_t i = 0; i < Cols; ++i) + { + Line.Append("\xe2\x94\x80"); + } + printf("%s\n", Line.c_str()); + } + + struct ServerStats + { + // /health/info + bool InfoAvailable = false; + std::string_view DataRoot; + std::string_view BuildVersion; + + // /stats/z$ -> cache + bool CacheAvailable = false; + uint64_t CacheHits = 0; + uint64_t CacheMisses = 0; + double CacheHitRatio = 0.0; + uint64_t CacheWrites = 0; + uint64_t CacheDiskSize = 0; + uint64_t CacheMemSize = 0; + uint64_t CacheRpcCount = 0; + uint64_t CacheRpcOps = 0; + + // /stats/builds -> store + bool BuildsAvailable = false; + uint64_t BlobReadCount = 0; + uint64_t BlobWriteCount = 0; + uint64_t BlobHitCount = 0; + uint64_t BuildDiskSize = 0; + + // Owned buffers to keep string_views alive + CbObject InfoObject; + CbObject CacheObject; + CbObject BuildsObject; + }; + + static void FetchServerStats(HttpClient& Http, ServerStats& Stats) + { + // Fetch /health/info + try + { + if (HttpClient::Response Response = Http.Get("/health/info")) + { + Stats.InfoObject = Response.AsObject(); + Stats.DataRoot = Stats.InfoObject["DataRoot"].AsString(); + Stats.BuildVersion = Stats.InfoObject["BuildVersion"].AsString(); + Stats.InfoAvailable = true; + } + } + catch (...) + { + } + + // Fetch /stats/z$ + try + { + if (HttpClient::Response Response = Http.Get("/stats/z$")) + { + Stats.CacheObject = Response.AsObject(); + CbObjectView CacheView = Stats.CacheObject["cache"].AsObjectView(); + Stats.CacheHits = CacheView["hits"].AsUInt64(0); + Stats.CacheMisses = CacheView["misses"].AsUInt64(0); + Stats.CacheHitRatio = CacheView["hit_ratio"].AsDouble(0.0); + Stats.CacheWrites = CacheView["writes"].AsUInt64(0); + + CbObjectView SizeView = CacheView["size"].AsObjectView(); + Stats.CacheDiskSize = SizeView["disk"].AsUInt64(0); + Stats.CacheMemSize = SizeView["memory"].AsUInt64(0); + + CbObjectView RpcView = CacheView["rpc"].AsObjectView(); + Stats.CacheRpcCount = RpcView["count"].AsUInt64(0); + Stats.CacheRpcOps = RpcView["ops"].AsUInt64(0); + + Stats.CacheAvailable = true; + } + } + catch (...) + { + } + + // Fetch /stats/builds + try + { + if (HttpClient::Response Response = Http.Get("/stats/builds")) + { + Stats.BuildsObject = Response.AsObject(); + CbObjectView StoreView = Stats.BuildsObject["store"].AsObjectView(); + + CbObjectView BlobView = StoreView["blobs"].AsObjectView(); + Stats.BlobReadCount = BlobView["readcount"].AsUInt64(0); + Stats.BlobWriteCount = BlobView["writecount"].AsUInt64(0); + Stats.BlobHitCount = BlobView["hitcount"].AsUInt64(0); + + CbObjectView BuildSizeView = StoreView["size"].AsObjectView(); + Stats.BuildDiskSize = BuildSizeView["disk"].AsUInt64(0); + + Stats.BuildsAvailable = true; + } + } + catch (...) + { + } + } + + static HttpClientSettings MakeQuickSettings() + { + HttpClientSettings Settings; + Settings.ConnectTimeout = std::chrono::milliseconds(2000); + Settings.Timeout = std::chrono::milliseconds(3000); + return Settings; + } + +} // namespace + TopCommand::TopCommand() { + m_Options.add_options()("h,help", "Print help"); + m_Options.add_option("", "u", "hosturl", "Host URL", cxxopts::value(m_HostName)->default_value(""), "<hosturl>"); + m_Options + .add_option("", "i", "interval", "Refresh interval in milliseconds", cxxopts::value(m_IntervalMs)->default_value("1000"), "<ms>"); } TopCommand::~TopCommand() = default; @@ -23,79 +189,332 @@ TopCommand::~TopCommand() = default; void TopCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) { - ZEN_UNUSED(GlobalOptions, argc, argv); + ZEN_UNUSED(GlobalOptions); - SystemMetrics Metrics = GetSystemMetrics(); - - struct SystemMetric + if (!ParseOptions(argc, argv)) { - const char* Name; - uint64_t Value; - } MetricValues[] = { - {"Cpus", Metrics.CpuCount}, - {"Cores", Metrics.CoreCount}, - {"LPs", Metrics.LogicalProcessorCount}, - {"SysMemMiB", Metrics.SystemMemoryMiB}, - {"AvailSysMemMiB", Metrics.AvailSystemMemoryMiB}, - {"VirtMemMiB", Metrics.VirtualMemoryMiB}, - {"AvailVirtMemMiB", Metrics.AvailVirtualMemoryMiB}, - {"PageFileMiB", Metrics.PageFileMiB}, - {"AvailPageFileMiB", Metrics.AvailPageFileMiB}, - }; + return; + } - std::vector<size_t> ColumnWidths; - for (const SystemMetric& Metric : MetricValues) + if (m_IntervalMs < 100) { - size_t NameLen = strlen(Metric.Name); - size_t ValueLen = fmt::formatted_size("{}", Metric.Value); - ColumnWidths.push_back(std::max(NameLen, ValueLen)); + m_IntervalMs = 100; } - ExtendableStringBuilder<128> Header; - for (size_t i = 0; i < sizeof(MetricValues) / sizeof(MetricValues[0]); ++i) + // Gather system metrics + SystemMetrics SysMetrics = GetSystemMetrics(); + + // Gather running instances + ZenServerState State; + bool HasState = State.InitializeReadOnly(); + + // Collect instances and determine initial target + std::vector<RunningInstance> Instances; + if (HasState) { - Header << fmt::format("{:<{}} ", MetricValues[i].Name, ColumnWidths[i]); + Instances = CollectInstances(State); } - ZEN_CONSOLE("{}", Header); - // Print values with same adaptive widths - ExtendableStringBuilder<128> Values; - for (size_t i = 0; i < sizeof(MetricValues) / sizeof(MetricValues[0]); ++i) + // Resolve the host to connect to. If -u was given, use that directly. + // Otherwise auto-discover from running instances. + std::string ActiveHost = m_HostName; + int SelectedIdx = -1; // index into Instances; -1 = explicit host or none + + if (ActiveHost.empty() && !Instances.empty()) { - Values << fmt::format("{:>{}} ", MetricValues[i].Value, ColumnWidths[i]); + SelectedIdx = 0; + ActiveHost = fmt::format("http://localhost:{}", Instances[0].Port); + } + else if (!ActiveHost.empty()) + { + ActiveHost = ResolveTargetHostSpec(ActiveHost); } - ZEN_CONSOLE("{}\n", Values); - ZenServerState State; - if (!State.InitializeReadOnly()) + // If not an interactive terminal, print a single snapshot and exit + if (!IsTuiAvailable()) { - ZEN_CONSOLE("No Zen state found"); + printf("zen top"); + if (!ActiveHost.empty()) + { + printf(" - %s", ActiveHost.c_str()); + } + printf(" - %s\n", FormatCurrentTime().c_str()); + + printf("System: %u CPU, %u cores, %u LPs | Memory: %llu MiB (%llu avail)\n", + SysMetrics.CpuCount, + SysMetrics.CoreCount, + SysMetrics.LogicalProcessorCount, + (unsigned long long)SysMetrics.SystemMemoryMiB, + (unsigned long long)SysMetrics.AvailSystemMemoryMiB); + + if (!Instances.empty()) + { + printf("\nInstances:\n"); + printf(" %5s %7s %s\n", "PORT", "PID", "SESSION"); + for (const auto& Inst : Instances) + { + printf(" %5u %7u %s\n", Inst.Port, Inst.Pid, Inst.SessionId.c_str()); + } + } + + if (!ActiveHost.empty()) + { + HttpClient Http(ActiveHost, MakeQuickSettings()); + ServerStats Stats; + FetchServerStats(Http, Stats); + + if (Stats.CacheAvailable) + { + printf("\nCache (z$):\n"); + printf(" Hits: %llu Misses: %llu Hit Ratio: %.1f%% Writes: %llu\n", + (unsigned long long)Stats.CacheHits, + (unsigned long long)Stats.CacheMisses, + Stats.CacheHitRatio * 100.0, + (unsigned long long)Stats.CacheWrites); + printf(" Disk: %s Memory: %s\n", NiceBytes(Stats.CacheDiskSize).c_str(), NiceBytes(Stats.CacheMemSize).c_str()); + printf(" RPC: %llu requests, %llu ops\n", (unsigned long long)Stats.CacheRpcCount, (unsigned long long)Stats.CacheRpcOps); + } + + if (Stats.BuildsAvailable) + { + printf("\nBuild Store:\n"); + printf(" Blobs: %llu reads, %llu writes, %llu hits | %s on disk\n", + (unsigned long long)Stats.BlobReadCount, + (unsigned long long)Stats.BlobWriteCount, + (unsigned long long)Stats.BlobHitCount, + NiceBytes(Stats.BuildDiskSize).c_str()); + } + } return; } - int n = 0; - const int HeaderPeriod = 20; + // Enter fullscreen alternate-screen mode + TuiEnterAlternateScreen(); + auto ExitGuard = MakeGuard([]() { TuiExitAlternateScreen(); }); + + // Create HTTP client for the active host + std::unique_ptr<HttpClient> Http; + auto RecreateClient = [&]() { + if (!ActiveHost.empty()) + { + Http = std::make_unique<HttpClient>(ActiveHost, MakeQuickSettings()); + } + else + { + Http.reset(); + } + }; + RecreateClient(); + + bool NeedsRedraw = true; + // Main refresh loop for (;;) { - if ((n++ % HeaderPeriod) == 0) + if (NeedsRedraw) { - ZEN_CONSOLE("{:>5} {:>6} {:>24}", "port", "pid", "session"); - } + uint32_t Cols = TuiConsoleColumns(); - State.Snapshot([&](const ZenServerState::ZenServerEntry& Entry) { - StringBuilder<25> SessionStringBuilder; - Entry.GetSessionId().ToString(SessionStringBuilder); - ZEN_CONSOLE("{:>5} {:>6} {:>24}", Entry.EffectiveListenPort.load(), Entry.Pid.load(), SessionStringBuilder); - }); + TuiCursorHome(); + + // Fetch fresh system metrics each iteration + SysMetrics = GetSystemMetrics(); + + // Refresh instance list + if (HasState) + { + if (!State.IsReadOnly()) + { + State.Sweep(); + } + Instances = CollectInstances(State); + + // Clamp selection + if (SelectedIdx >= static_cast<int>(Instances.size())) + { + SelectedIdx = static_cast<int>(Instances.size()) - 1; + } + } + + // Fetch server stats + ServerStats Stats; + if (Http) + { + FetchServerStats(*Http, Stats); + } + + // Header line + { + ExtendableStringBuilder<256> Header; + Header << "zen top"; + if (!ActiveHost.empty()) + { + Header << " - " << ActiveHost; + } + if (Stats.InfoAvailable && !Stats.BuildVersion.empty()) + { + Header << " - " << Stats.BuildVersion; + } + Header << " - " << FormatCurrentTime(); + printf("%-*s\n", Cols, Header.c_str()); + } - zen::Sleep(1000); + PrintHorizontalRule(Cols); - if (!State.IsReadOnly()) + // System metrics + printf("System: %u CPU, %u cores, %u LPs | Memory: %llu MiB (%llu avail)\033[K\n", + SysMetrics.CpuCount, + SysMetrics.CoreCount, + SysMetrics.LogicalProcessorCount, + (unsigned long long)SysMetrics.SystemMemoryMiB, + (unsigned long long)SysMetrics.AvailSystemMemoryMiB); + + // Running instances + printf("\033[K\n"); + if (!Instances.empty()) + { + bool MultipleInstances = (Instances.size() > 1 && SelectedIdx >= 0); + if (MultipleInstances) + { + printf("\033[1mInstances:\033[0m \033[2m(\xe2\x86\x91/\xe2\x86\x93 to switch)\033[0m\033[K\n"); + } + else + { + printf("\033[1mInstances:\033[0m\033[K\n"); + } + printf(" %5s %7s %s\033[K\n", "PORT", "PID", "SESSION"); + for (int i = 0; i < static_cast<int>(Instances.size()); ++i) + { + const auto& Inst = Instances[i]; + bool IsSelected = (i == SelectedIdx); + if (IsSelected) + { + // Highlight: bold + reverse video + printf("\033[1;7m"); + } + printf(" %5u %7u %s\033[K", Inst.Port, Inst.Pid, Inst.SessionId.c_str()); + if (IsSelected) + { + printf("\033[0m"); + } + printf("\n"); + } + } + else + { + printf(" No Zen instances found\033[K\n"); + } + + // Cache stats + printf("\033[K\n"); + if (Stats.CacheAvailable) + { + printf("\033[1mCache (z$):\033[0m\033[K\n"); + printf(" Hits: %-10llu Misses: %-10llu Hit Ratio: %-7.1f%% Writes: %llu\033[K\n", + (unsigned long long)Stats.CacheHits, + (unsigned long long)Stats.CacheMisses, + Stats.CacheHitRatio * 100.0, + (unsigned long long)Stats.CacheWrites); + printf(" Disk: %-12s Memory: %s\033[K\n", NiceBytes(Stats.CacheDiskSize).c_str(), NiceBytes(Stats.CacheMemSize).c_str()); + printf(" RPC: %llu requests, %llu ops\033[K\n", + (unsigned long long)Stats.CacheRpcCount, + (unsigned long long)Stats.CacheRpcOps); + } + else if (Http) + { + printf("\033[1mCache (z$):\033[0m unavailable\033[K\n"); + } + + // Build store stats + printf("\033[K\n"); + if (Stats.BuildsAvailable) + { + printf("\033[1mBuild Store:\033[0m\033[K\n"); + printf(" Blobs: %llu reads, %llu writes, %llu hits | %s on disk\033[K\n", + (unsigned long long)Stats.BlobReadCount, + (unsigned long long)Stats.BlobWriteCount, + (unsigned long long)Stats.BlobHitCount, + NiceBytes(Stats.BuildDiskSize).c_str()); + } + else if (Http) + { + printf("\033[1mBuild Store:\033[0m unavailable\033[K\n"); + } + + // Server info + if (Stats.InfoAvailable && !Stats.DataRoot.empty()) + { + printf("\033[K\n"); + printf(" Data root: %.*s\033[K\n", static_cast<int>(Stats.DataRoot.size()), Stats.DataRoot.data()); + } + + // Clear any stale content below + TuiClearToBottom(); + + // Footer (print at bottom by moving cursor) + { + uint32_t Rows = TuiConsoleRows(); + printf("\033[%u;1H", Rows); // Move to last row + if (Instances.size() > 1 && SelectedIdx >= 0) + { + printf("\033[2mq quit | \xe2\x86\x91/\xe2\x86\x93 switch instance | Refreshing every %.1fs\033[0m\033[K", + static_cast<double>(m_IntervalMs) / 1000.0); + } + else + { + printf("\033[2mPress q to quit | Refreshing every %.1fs\033[0m\033[K", static_cast<double>(m_IntervalMs) / 1000.0); + } + } + + fflush(stdout); + NeedsRedraw = false; + } + + // Wait for input or next tick, whichever comes first. + // Uses TuiWaitForInput() which blocks on the OS input handle, so keypresses + // are detected immediately rather than waiting for a sleep interval to elapse. + auto TickStart = std::chrono::steady_clock::now(); + for (;;) { - State.Sweep(); + TuiInput Input = TuiPollInput(); + if (Input == TuiInput::Quit) + { + return; + } + if (Input == TuiInput::ArrowUp || Input == TuiInput::ArrowDown) + { + // Only handle arrow keys when we have auto-discovered instances to switch between + if (SelectedIdx >= 0 && Instances.size() > 1) + { + int Count = static_cast<int>(Instances.size()); + if (Input == TuiInput::ArrowUp) + { + SelectedIdx = (SelectedIdx - 1 + Count) % Count; + } + else + { + SelectedIdx = (SelectedIdx + 1) % Count; + } + ActiveHost = fmt::format("http://localhost:{}", Instances[SelectedIdx].Port); + RecreateClient(); + NeedsRedraw = true; + break; + } + } + + auto Now = std::chrono::steady_clock::now(); + uint32_t ElapsedMs = static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::milliseconds>(Now - TickStart).count()); + if (ElapsedMs >= m_IntervalMs) + { + break; + } + + TuiWaitForInput(m_IntervalMs - ElapsedMs); } + + // Schedule a full redraw for the next iteration + NeedsRedraw = true; } } diff --git a/src/zen/cmds/top_cmd.h b/src/zen/cmds/top_cmd.h index aeb196558..2e4012a57 100644 --- a/src/zen/cmds/top_cmd.h +++ b/src/zen/cmds/top_cmd.h @@ -20,6 +20,8 @@ public: private: cxxopts::Options m_Options{Name, Description}; + std::string m_HostName; + uint32_t m_IntervalMs = 1000; }; class PsCommand : public ZenCmdBase diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp index 4410d463d..777819224 100644 --- a/src/zenutil/consoletui.cpp +++ b/src/zenutil/consoletui.cpp @@ -480,4 +480,87 @@ TuiPollQuit() #endif } +TuiInput +TuiPollInput() +{ +#if ZEN_PLATFORM_WINDOWS + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + DWORD dwCount = 0; + if (!GetNumberOfConsoleInputEvents(hStdin, &dwCount) || dwCount == 0) + { + return TuiInput::None; + } + INPUT_RECORD Record{}; + DWORD dwRead = 0; + while (PeekConsoleInputA(hStdin, &Record, 1, &dwRead) && dwRead > 0) + { + ReadConsoleInputA(hStdin, &Record, 1, &dwRead); + if (Record.EventType == KEY_EVENT && Record.Event.KeyEvent.bKeyDown) + { + WORD vk = Record.Event.KeyEvent.wVirtualKeyCode; + char ch = Record.Event.KeyEvent.uChar.AsciiChar; + if (vk == VK_ESCAPE || ch == 'q' || ch == 'Q') + { + return TuiInput::Quit; + } + if (vk == VK_UP) + { + return TuiInput::ArrowUp; + } + if (vk == VK_DOWN) + { + return TuiInput::ArrowDown; + } + } + } + return TuiInput::None; +#else + int b = ReadByteWithTimeout(0); + if (b == 3 || b == 'q' || b == 'Q') + { + return TuiInput::Quit; + } + if (b == 27) + { + // Could be bare Esc or start of an escape sequence (e.g. \033[A) + int Next = ReadByteWithTimeout(50); + if (Next == '[') + { + int Final = ReadByteWithTimeout(50); + if (Final == 'A') + { + return TuiInput::ArrowUp; + } + if (Final == 'B') + { + return TuiInput::ArrowDown; + } + } + return TuiInput::Quit; // bare Esc + } + return TuiInput::None; +#endif +} + +bool +TuiWaitForInput(uint32_t TimeoutMs) +{ +#if ZEN_PLATFORM_WINDOWS + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + return WaitForSingleObject(hStdin, TimeoutMs) == WAIT_OBJECT_0; +#else + struct pollfd Pfd + { + STDIN_FILENO, POLLIN, 0 + }; + return poll(&Pfd, 1, static_cast<int>(TimeoutMs)) > 0; +#endif +} + +void +TuiClearToBottom() +{ + printf("\033[J"); +} + } // namespace zen diff --git a/src/zenutil/include/zenutil/consoletui.h b/src/zenutil/include/zenutil/consoletui.h index 7dc68c126..edbcd6862 100644 --- a/src/zenutil/include/zenutil/consoletui.h +++ b/src/zenutil/include/zenutil/consoletui.h @@ -56,4 +56,28 @@ uint32_t TuiConsoleRows(uint32_t Default = 40); // Should only be called while in alternate screen mode. bool TuiPollQuit(); +// Input events returned by TuiPollInput(). +enum class TuiInput +{ + None, + Quit, // Esc, 'q', 'Q', Ctrl+C + ArrowUp, + ArrowDown, +}; + +// Non-blocking check: consumes at most one pending input event and returns it. +// Returns TuiInput::None if no event is pending. +// Should only be called while in alternate screen mode. +TuiInput TuiPollInput(); + +// Blocks until console input is available or the timeout (in milliseconds) expires. +// Returns true if input is ready to be read, false on timeout. +// More efficient than polling in a loop with Sleep() -- uses the OS wait primitive +// (WaitForSingleObject on Windows, poll() on POSIX) so keypresses are detected immediately. +// Should only be called while in alternate screen mode. +bool TuiWaitForInput(uint32_t TimeoutMs); + +// Erase from the current cursor position to the end of the screen. +void TuiClearToBottom(); + } // namespace zen |