aboutsummaryrefslogtreecommitdiff
path: root/src/zen/cmds
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/cmds')
-rw-r--r--src/zen/cmds/sessions_cmd.cpp799
-rw-r--r--src/zen/cmds/sessions_cmd.h75
-rw-r--r--src/zen/cmds/ui_cmd.cpp2
3 files changed, 875 insertions, 1 deletions
diff --git a/src/zen/cmds/sessions_cmd.cpp b/src/zen/cmds/sessions_cmd.cpp
new file mode 100644
index 000000000..b6beaa761
--- /dev/null
+++ b/src/zen/cmds/sessions_cmd.cpp
@@ -0,0 +1,799 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "sessions_cmd.h"
+
+#include "../browser_launcher.h"
+#include "../zenserviceclient.h"
+
+#include <zencore/compactbinary.h>
+#include <zencore/compactbinaryutil.h>
+#include <zencore/fmtutils.h>
+#include <zencore/logbase.h>
+#include <zencore/logging.h>
+#include <zencore/logging/helpers.h>
+#include <zencore/string.h>
+#include <zencore/thread.h>
+#include <zencore/uid.h>
+#include <zenhttp/httpclient.h>
+#include <zenhttp/httpwsclient.h>
+#include <zenutil/consoletui.h>
+
+#include <algorithm>
+#include <atomic>
+#include <chrono>
+#include <csignal>
+#include <vector>
+
+namespace zen {
+
+using namespace std::literals;
+
+namespace {
+
+ // Render a TimeSpan as a compact human-readable duration: "12d 3h", "2h 14m",
+ // "45m 02s", or "12.345s". Picks the two most-significant non-zero units.
+ std::string FormatDuration(const TimeSpan& Span)
+ {
+ const int Days = Span.GetDays();
+ const int Hours = Span.GetHours();
+ const int Minutes = Span.GetMinutes();
+ const int Seconds = Span.GetSeconds();
+
+ ExtendableStringBuilder<32> Out;
+ if (Days > 0)
+ {
+ fmt::format_to(StringBuilderAppender(Out), "{}d {}h", Days, Hours);
+ }
+ else if (Hours > 0)
+ {
+ fmt::format_to(StringBuilderAppender(Out), "{}h {:02}m", Hours, Minutes);
+ }
+ else if (Minutes > 0)
+ {
+ fmt::format_to(StringBuilderAppender(Out), "{}m {:02}s", Minutes, Seconds);
+ }
+ else
+ {
+ fmt::format_to(StringBuilderAppender(Out), "{}.{:03}s", Seconds, Span.GetFractionMilli());
+ }
+ return Out.ToString();
+ }
+
+ // Short ISO-like timestamp without fractional seconds — matches what 'ps'-style
+ // listings expect when scanning by eye.
+ std::string FormatTimestamp(const DateTime& When) { return When.ToString("%Y-%m-%dT%H:%M:%S"); }
+
+ std::string_view OrDash(std::string_view Value) { return Value.empty() ? "-"sv : Value; }
+
+ // File-static abort flag for `sessions tail --follow`. Mirrors the pattern in
+ // projectstore_cmd.cpp: a signal handler can only touch atomic state, and we
+ // don't want to share a flag with the global SignalCounter / other tail-style
+ // commands. RAII'd by ScopedSignalHandler so the previous handler is
+ // restored when Run() returns.
+ std::atomic<bool> g_TailAbortFlag{false};
+
+ void TailSignalHandler(int SigNum)
+ {
+ if (SigNum == SIGINT)
+ {
+ g_TailAbortFlag.store(true, std::memory_order_relaxed);
+ }
+#if ZEN_PLATFORM_WINDOWS
+ if (SigNum == SIGBREAK)
+ {
+ g_TailAbortFlag.store(true, std::memory_order_relaxed);
+ }
+#endif
+ }
+
+ // Cached once on first use so we don't re-probe the console / re-toggle VT
+ // mode for every line. Mirrors the static caching in TuiIsStdoutTty.
+ bool ColorEnabledForTail()
+ {
+ static const bool Cached = []() {
+ if (!TuiIsStdoutTty())
+ {
+ return false;
+ }
+ // On Windows this flips ENABLE_VIRTUAL_TERMINAL_PROCESSING so ANSI
+ // sequences render. On POSIX it's a no-op and just returns true.
+ return logging::EnableVTMode();
+ }();
+ return Cached;
+ }
+
+ // Render a single log entry in the same shape as zen::logging::FullFormatter:
+ // [YY-MM-DD HH:MM:SS.mmm] [<lvl-color>lvl</reset>] [<bold>logger</reset>] message
+ // Continuation lines (embedded \n) are indented to the visible width of the
+ // prefix so banners and stack traces align under the message column.
+ //
+ // Wire schema (see WriteLogEntry in httpsessions.cpp): timestamp (DateTime),
+ // level (string, optional), logger (string, optional), message (string).
+ void PrintLogEntry(CbObjectView Entry)
+ {
+ const DateTime Ts = Entry["timestamp"sv].AsDateTime();
+ const std::string_view Level = Entry["level"sv].AsString();
+ const std::string_view Logger = Entry["logger"sv].AsString();
+ const std::string_view Message = Entry["message"sv].AsString();
+
+ const bool UseColor = ColorEnabledForTail();
+
+ // Resolve the level once. Wire form is the long lowercase name
+ // ("info"/"warning"/"error"/...); we render the short form to mirror
+ // FullFormatter, and use the enum to pick the ANSI color.
+ logging::LogLevel Lvl = logging::Off;
+ if (!Level.empty())
+ {
+ Lvl = logging::ParseLogLevelString(Level);
+ }
+ const bool HasLevel = (Lvl < logging::LogLevelCount && Lvl != logging::Off);
+ const std::string_view LvlShort = HasLevel ? logging::ShortToString(Lvl) : "---"sv;
+
+ ExtendableStringBuilder<256> Prefix;
+ size_t VisibleWidth = 0;
+
+ // Timestamp — full date so a tail that crosses midnight stays unambiguous.
+ fmt::format_to(StringBuilderAppender(Prefix),
+ "[{:02}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}] ",
+ Ts.GetYear() % 100,
+ Ts.GetMonth(),
+ Ts.GetDay(),
+ Ts.GetHour(),
+ Ts.GetMinute(),
+ Ts.GetSecond(),
+ Ts.GetMillisecond());
+ VisibleWidth = Prefix.Size();
+
+ // [LVL] — short form. Color goes inside the brackets (matches FullFormatter
+ // so the brackets themselves stay grep-friendly even on a colorized line).
+ Prefix.Append('[');
+ if (UseColor && HasLevel)
+ {
+ Prefix.Append(logging::helpers::AnsiColorForLevel(Lvl));
+ }
+ Prefix.Append(LvlShort);
+ if (UseColor && HasLevel)
+ {
+ Prefix.Append(logging::helpers::kAnsiReset);
+ }
+ Prefix.Append("] ");
+ VisibleWidth += 1 + LvlShort.size() + 2;
+
+ // [logger] — bold. Omitted when logger name is empty (mirrors FullFormatter).
+ if (!Logger.empty())
+ {
+ if (UseColor)
+ {
+ Prefix.Append("\033[1m"sv);
+ }
+ Prefix.Append('[');
+ Prefix.Append(Logger);
+ Prefix.Append(']');
+ if (UseColor)
+ {
+ Prefix.Append(logging::helpers::kAnsiReset);
+ }
+ Prefix.Append(' ');
+ VisibleWidth += 1 + Logger.size() + 1 + 1;
+ }
+
+ auto NextLine = [](std::string_view Text, size_t Pos) -> std::pair<std::string_view, size_t> {
+ const size_t End = Text.find('\n', Pos);
+ std::string_view Line;
+ if (End == std::string_view::npos)
+ {
+ Line = Text.substr(Pos);
+ Pos = Text.size();
+ }
+ else
+ {
+ Line = Text.substr(Pos, End - Pos);
+ Pos = End + 1;
+ }
+ // Strip a trailing CR so CRLF-terminated lines don't render the carriage
+ // return (which on terminals would slam the cursor back to column 0).
+ if (!Line.empty() && Line.back() == '\r')
+ {
+ Line.remove_suffix(1);
+ }
+ return {Line, Pos};
+ };
+
+ size_t Pos = 0;
+ bool First = true;
+ while (true)
+ {
+ auto [Line, NextPos] = NextLine(Message, Pos);
+
+ if (First)
+ {
+ ZEN_CONSOLE("{}{}", Prefix.ToView(), Line);
+ First = false;
+ }
+ else
+ {
+ // Continuation: pad with the same VISIBLE width as the prefix so
+ // the message column lines up regardless of the embedded ANSI
+ // bytes. Skip a trailing empty line so a message ending in '\n'
+ // doesn't add a stray blank row.
+ if (NextPos >= Message.size() && Line.empty())
+ {
+ break;
+ }
+ ZEN_CONSOLE("{:<{}}{}", "", VisibleWidth, Line);
+ }
+
+ if (NextPos >= Message.size())
+ {
+ break;
+ }
+ Pos = NextPos;
+ }
+ }
+
+ // WebSocket subscriber for `tail --follow`. The server pushes binary CB
+ // frames shaped exactly like the HTTP cursor response (see
+ // HttpSessionsService::BroadcastLogAppended), so we parse and hand each
+ // entry to PrintLogEntry. Used when /sessions/ws is reachable; if the
+ // upgrade fails (older server, blocked port, unix socket transport) the
+ // caller falls back to the HTTP polling loop.
+ class TailWsSubscriber : public IWsClientHandler
+ {
+ public:
+ enum class State : uint8_t
+ {
+ Connecting,
+ Open,
+ Closed
+ };
+
+ TailWsSubscriber(std::string SessionIdHex, uint64_t StartCursor) : m_SessionIdHex(std::move(SessionIdHex)), m_Cursor(StartCursor) {}
+
+ // Set after construction so OnWsOpen can SendText through the owning
+ // client. The lifetime contract: the HttpWsClient is declared after
+ // this subscriber in the caller's scope, so it tears down first and no
+ // callbacks fire on a dangling m_Client.
+ void SetClient(HttpWsClient* Client) { m_Client = Client; }
+ State GetState() const { return m_State.load(std::memory_order_acquire); }
+
+ void OnWsOpen() override
+ {
+ // Subscribe to log push for our session, starting after the cursor
+ // we already drained via the initial HTTP fetch. Wire format
+ // matches the dashboard (see frontend/html/pages/sessions.js).
+ ExtendableStringBuilder<128> Frame;
+ fmt::format_to(StringBuilderAppender(Frame), R"({{"type":"sub_log","session":"{}","cursor":{}}})", m_SessionIdHex, m_Cursor);
+ if (m_Client)
+ {
+ m_Client->SendText(Frame.ToView());
+ }
+ m_State.store(State::Open, std::memory_order_release);
+ }
+
+ void OnWsMessage(const WebSocketMessage& Msg) override
+ {
+ // The text-frame path on this socket is the periodic session-list
+ // snapshot — irrelevant for tail. We only care about the binary
+ // log-delta frames.
+ if (Msg.Opcode != WebSocketOpcode::kBinary)
+ {
+ return;
+ }
+ IoBuffer Payload = Msg.Payload;
+ CbValidateError Err = CbValidateError::None;
+ CbObject Frame = ValidateAndReadCompactBinaryObject(std::move(Payload), Err);
+ if (Err != CbValidateError::None)
+ {
+ return;
+ }
+ m_Cursor = Frame["cursor"sv].AsUInt64(m_Cursor);
+ for (CbFieldView Entry : Frame["entries"sv].AsArrayView())
+ {
+ PrintLogEntry(Entry.AsObjectView());
+ }
+ }
+
+ void OnWsClose(uint16_t /*Code*/, std::string_view /*Reason*/) override { m_State.store(State::Closed, std::memory_order_release); }
+
+ private:
+ HttpWsClient* m_Client = nullptr;
+ std::string m_SessionIdHex;
+ uint64_t m_Cursor = 0;
+ std::atomic<State> m_State{State::Connecting};
+ };
+
+} // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+// SessionsSubCmdBase
+
+SessionsSubCmdBase::SessionsSubCmdBase(std::string_view Name, std::string_view Description) : ZenSubCmdBase(Name, Description)
+{
+ m_SubOptions.add_option("", "u", "hosturl", ZenCmdBase::kHostUrlHelp, cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// SessionsLsSubCmd
+
+SessionsLsSubCmd::SessionsLsSubCmd() : SessionsSubCmdBase("ls", "List sessions known to the zenserver")
+{
+ m_SubOptions.add_option("",
+ "s",
+ "status",
+ "Filter by status: 'active', 'ended', or 'all' (default)",
+ cxxopts::value(m_StatusFilter)->default_value("all"),
+ "<status>");
+}
+
+void
+SessionsLsSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/)
+{
+ if (m_StatusFilter != "active" && m_StatusFilter != "ended" && m_StatusFilter != "all")
+ {
+ throw OptionParseException("--status must be 'active', 'ended', or 'all'", m_SubOptions.help());
+ }
+
+ ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.ls"});
+ HttpClient& Http = Service.Http();
+
+ const std::string Path = fmt::format("/sessions/?status={}", m_StatusFilter);
+
+ HttpClient::Response Result = Http.Get(Path, HttpClient::Accept(ZenContentType::kCbObject));
+ if (!Result)
+ {
+ Result.ThrowError("failed to list sessions"sv);
+ }
+
+ CbObjectView Response = Result.AsObject();
+ const Oid SelfId = Response["self_id"sv].AsObjectId();
+ CbArrayView Sessions = Response["sessions"sv].AsArrayView();
+
+ const DateTime Now = DateTime::Now();
+
+ // Buffer the entries so we can present them in a stable order: active
+ // sessions first, then ended; within each group sorted by creation time
+ // (newest first — recent sessions are usually what the operator cares
+ // about). The CbObjectView keeps a view into the response buffer, which
+ // lives as long as `Result`.
+ struct Row
+ {
+ CbObjectView Obj;
+ DateTime CreatedAt{0};
+ bool IsEnded = false;
+ };
+ std::vector<Row> Rows;
+ Rows.reserve(Sessions.Num());
+ for (CbFieldView Entry : Sessions)
+ {
+ CbObjectView Obj = Entry.AsObjectView();
+ Rows.push_back({.Obj = Obj, .CreatedAt = Obj["created_at"sv].AsDateTime(), .IsEnded = Obj["ended_at"sv].HasValue()});
+ }
+ std::sort(Rows.begin(), Rows.end(), [](const Row& A, const Row& B) {
+ if (A.IsEnded != B.IsEnded)
+ {
+ return !A.IsEnded; // active rows first
+ }
+ return A.CreatedAt > B.CreatedAt; // newest first within each group
+ });
+
+ // ID column is 25 chars wide: 24 chars of Oid plus a one-char self-marker
+ // slot ('*' for the self session, otherwise a space). Keeping the slot in
+ // every row lines up the trailing columns whether or not the marker is
+ // present.
+ ZEN_CONSOLE("{:<25} {:<6} {:<24} {:>6} {:<19} {:>10} {:>5}",
+ "ID",
+ "STATUS",
+ "APPNAME/MODE",
+ "PID",
+ "CREATED",
+ "DURATION",
+ "LOGS");
+
+ for (const Row& R : Rows)
+ {
+ const Oid Id = R.Obj["id"sv].AsObjectId();
+ const bool IsSelf = (SelfId != Oid::Zero && Id == SelfId);
+
+ ExtendableStringBuilder<32> IdField;
+ Id.ToString(IdField);
+ IdField.Append(IsSelf ? '*' : ' ');
+
+ // Combine appname and mode into a single column (e.g. "zen/sessions.tail")
+ // to save horizontal real estate. Mode is omitted when empty so a session
+ // with only an appname renders as "zenserver" rather than "zenserver/".
+ const std::string_view Appname = R.Obj["appname"sv].AsString();
+ const std::string_view Mode = R.Obj["mode"sv].AsString();
+ ExtendableStringBuilder<48> AppMode;
+ if (Appname.empty() && Mode.empty())
+ {
+ AppMode.Append('-');
+ }
+ else
+ {
+ AppMode.Append(OrDash(Appname));
+ if (!Mode.empty())
+ {
+ AppMode.Append('/');
+ AppMode.Append(Mode);
+ }
+ }
+
+ const DateTime EndAt = R.IsEnded ? R.Obj["ended_at"sv].AsDateTime() : Now;
+ const TimeSpan Duration = EndAt - R.CreatedAt;
+
+ ZEN_CONSOLE("{:<25} {:<6} {:<24} {:>6} {:<19} {:>10} {:>5}",
+ IdField.ToView(),
+ R.IsEnded ? "ended"sv : "active"sv,
+ AppMode.ToView(),
+ R.Obj["pid"sv].AsUInt32(0),
+ FormatTimestamp(R.CreatedAt),
+ FormatDuration(Duration),
+ R.Obj["log_count"sv].AsUInt64(0));
+ }
+
+ if (Rows.empty())
+ {
+ ZEN_CONSOLE("(no sessions)");
+ }
+ else if (SelfId != Oid::Zero)
+ {
+ ZEN_CONSOLE("");
+ ZEN_CONSOLE("(* = the zenserver's own session)");
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// SessionsStatusSubCmd
+
+SessionsStatusSubCmd::SessionsStatusSubCmd() : SessionsSubCmdBase("status", "Show top-level status of the sessions service")
+{
+}
+
+void
+SessionsStatusSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/)
+{
+ ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.status"});
+ HttpClient& Http = Service.Http();
+
+ // Two cheap reads: the stats handler exposes the operational counters, and the
+ // session list lets us split the count into active/ended without per-state
+ // endpoints (the service itself doesn't expose those separately).
+ HttpClient::Response StatsResult = Http.Get("/stats/sessions", HttpClient::Accept(ZenContentType::kCbObject));
+ if (!StatsResult)
+ {
+ StatsResult.ThrowError("failed to get sessions stats"sv);
+ }
+
+ HttpClient::Response ListResult = Http.Get("/sessions/?status=all", HttpClient::Accept(ZenContentType::kCbObject));
+ if (!ListResult)
+ {
+ ListResult.ThrowError("failed to list sessions"sv);
+ }
+
+ CbObjectView Stats = StatsResult.AsObject();
+ CbObjectView Sessions = Stats["sessions"sv].AsObjectView();
+ CbFieldView RequestsField = Stats["requests"sv];
+
+ CbObjectView ListObj = ListResult.AsObject();
+ const Oid SelfId = ListObj["self_id"sv].AsObjectId();
+ CbArrayView SessionsArr = ListObj["sessions"sv].AsArrayView();
+
+ uint64_t ActiveCount = 0;
+ uint64_t EndedCount = 0;
+ for (CbFieldView Entry : SessionsArr)
+ {
+ if (Entry.AsObjectView()["ended_at"sv].HasValue())
+ {
+ ++EndedCount;
+ }
+ else
+ {
+ ++ActiveCount;
+ }
+ }
+
+ ZEN_CONSOLE("Sessions service");
+ ZEN_CONSOLE(" Self id: {}", SelfId == Oid::Zero ? "(none)"s : SelfId.ToString());
+ ZEN_CONSOLE(" Active sessions: {}", ActiveCount);
+ ZEN_CONSOLE(" Ended sessions: {}", EndedCount);
+ ZEN_CONSOLE("");
+ ZEN_CONSOLE("Counters");
+ ZEN_CONSOLE(" Reads: {}", Sessions["readcount"sv].AsUInt64(0));
+ ZEN_CONSOLE(" Writes: {}", Sessions["writecount"sv].AsUInt64(0));
+ ZEN_CONSOLE(" Deletes: {}", Sessions["deletecount"sv].AsUInt64(0));
+ ZEN_CONSOLE(" Lists: {}", Sessions["listcount"sv].AsUInt64(0));
+ ZEN_CONSOLE(" Requests: {}", Sessions["requestcount"sv].AsUInt64(0));
+ ZEN_CONSOLE(" Bad requests: {}", Sessions["badrequestcount"sv].AsUInt64(0));
+
+ // Operation timing snapshot is shaped by EmitSnapshot — surface the headline
+ // fields if present without binding too tightly to its layout.
+ if (RequestsField.HasValue())
+ {
+ CbObjectView Requests = RequestsField.AsObjectView();
+ const uint64_t Total = Requests["count"sv].AsUInt64(0);
+ if (Total > 0)
+ {
+ ZEN_CONSOLE("");
+ ZEN_CONSOLE("Request timing");
+ ZEN_CONSOLE(" Total: {}", Total);
+ CbFieldView P50 = Requests["latency_p50_us"sv];
+ if (P50.HasValue())
+ {
+ ZEN_CONSOLE(" Latency p50: {} us", P50.AsUInt64(0));
+ }
+ CbFieldView P99 = Requests["latency_p99_us"sv];
+ if (P99.HasValue())
+ {
+ ZEN_CONSOLE(" Latency p99: {} us", P99.AsUInt64(0));
+ }
+ }
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// SessionsTailSubCmd
+
+SessionsTailSubCmd::SessionsTailSubCmd()
+: SessionsSubCmdBase("tail", "Tail log output from a session (defaults to the zenserver's own session)")
+{
+ m_SubOptions.add_option("",
+ "",
+ "session",
+ "Session id (24-hex). Omit to tail the zenserver's own session.",
+ cxxopts::value(m_SessionId)->default_value(""),
+ "<id>");
+ m_SubOptions.add_option("",
+ "n",
+ "lines",
+ "Number of recent lines to show before tailing. 0 = all buffered.",
+ cxxopts::value(m_Lines)->default_value("50"),
+ "<n>");
+ m_SubOptions.add_option("",
+ "f",
+ "follow",
+ "After printing initial lines, keep streaming new entries until interrupted (Ctrl-C).",
+ cxxopts::value(m_Follow),
+ "");
+ m_SubOptions.add_option("",
+ "",
+ "interval-ms",
+ "Poll interval in milliseconds for follow mode.",
+ cxxopts::value(m_PollIntervalMs)->default_value("500"),
+ "<ms>");
+ m_SubOptions.parse_positional({"session"});
+}
+
+void
+SessionsTailSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/)
+{
+ ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.tail"});
+ HttpClient& Http = Service.Http();
+
+ // Resolve the target session id. An explicit argument wins; otherwise we
+ // fetch the server's self_id from the session list (which is also what the
+ // `*` marker in `sessions ls` points at).
+ std::string TargetId = m_SessionId;
+ if (TargetId.empty())
+ {
+ HttpClient::Response ListResult = Http.Get("/sessions/list", HttpClient::Accept(ZenContentType::kCbObject));
+ if (!ListResult)
+ {
+ ListResult.ThrowError("failed to resolve self session id"sv);
+ }
+ const Oid SelfId = ListResult.AsObject()["self_id"sv].AsObjectId();
+ if (SelfId == Oid::Zero)
+ {
+ throw std::runtime_error("zenserver did not register a self-session; pass an explicit session id");
+ }
+ TargetId = SelfId.ToString();
+ }
+
+ const std::string LogPath = fmt::format("/sessions/{}/log", TargetId);
+
+ // Initial fetch: cursor=0 returns every entry currently buffered in the
+ // session, plus the cursor we should hand back to keep a follow stream
+ // gap-free. We then locally trim to the last `--lines` entries before
+ // printing — no protocol affordance for "last N" so the trim happens here.
+ HttpClient::Response InitialResult = Http.Get(fmt::format("{}?cursor=0", LogPath), HttpClient::Accept(ZenContentType::kCbObject));
+ if (!InitialResult)
+ {
+ if (InitialResult.StatusCode == HttpResponseCode::NotFound)
+ {
+ throw std::runtime_error(fmt::format("session '{}' not found", TargetId));
+ }
+ InitialResult.ThrowError("failed to fetch session log"sv);
+ }
+
+ CbObjectView Initial = InitialResult.AsObject();
+ uint64_t Cursor = Initial["cursor"sv].AsUInt64(0);
+ CbArrayView Entries = Initial["entries"sv].AsArrayView();
+ const uint64_t Total = Entries.Num();
+ const uint64_t SkipCnt = (m_Lines > 0 && Total > m_Lines) ? Total - m_Lines : 0;
+
+ uint64_t Index = 0;
+ for (CbFieldView Entry : Entries)
+ {
+ if (Index >= SkipCnt)
+ {
+ PrintLogEntry(Entry.AsObjectView());
+ }
+ ++Index;
+ }
+
+ if (!m_Follow)
+ {
+ return;
+ }
+
+ // Follow mode. Install a Ctrl-C handler that flips the abort flag rather
+ // than killing the process — RAII restores the previous handler on exit.
+ g_TailAbortFlag.store(false, std::memory_order_relaxed);
+ ScopedSignalHandler SigInt(SIGINT, TailSignalHandler);
+#if ZEN_PLATFORM_WINDOWS
+ ScopedSignalHandler SigBreak(SIGBREAK, TailSignalHandler);
+#endif
+
+ // Prefer WebSocket push (zero idle traffic, no poll lag). Fall back to the
+ // polling loop below if /sessions/ws can't be reached — older servers,
+ // blocked port, unix socket transport, etc.
+ auto TryWebSocketTail = [&]() -> bool {
+ if (Service.IsUnixSocket())
+ {
+ // HttpWsClient supports unix sockets via UnixSocketPath, but
+ // resolving and rewriting the URL is more plumbing than this
+ // fallback warrants — polling works fine over unix.
+ return false;
+ }
+
+ const std::chrono::milliseconds WsConnectTimeout{3000};
+
+ std::string WsUrl = HttpToWsUrl(Service.HostSpec(), "/sessions/ws");
+ TailWsSubscriber Subscriber(TargetId, Cursor);
+ HttpWsClientSettings Settings;
+ Settings.LogCategory = "sessions_tail_ws";
+ Settings.ConnectTimeout = WsConnectTimeout;
+
+ HttpWsClient Client(WsUrl, Subscriber, Settings);
+ Subscriber.SetClient(&Client);
+ Client.Connect();
+
+ // Wait for the upgrade to complete (or fail). Honor abort during
+ // connect so a tail-then-immediate-Ctrl-C doesn't sit in here for the
+ // full timeout. We add a small slack on top of ConnectTimeout so we
+ // give the client's own timer a chance to flip state to Closed before
+ // we declare WS unavailable.
+ const auto Deadline = std::chrono::steady_clock::now() + WsConnectTimeout + std::chrono::seconds(1);
+ while (Subscriber.GetState() == TailWsSubscriber::State::Connecting)
+ {
+ if (g_TailAbortFlag.load(std::memory_order_relaxed))
+ {
+ return true; // user aborted; Client dtor closes the socket cleanly
+ }
+ if (std::chrono::steady_clock::now() >= Deadline)
+ {
+ return false;
+ }
+ zen::Sleep(50);
+ }
+
+ if (Subscriber.GetState() != TailWsSubscriber::State::Open)
+ {
+ return false;
+ }
+
+ // WS-driven follow. OnWsMessage runs on the client's IO thread and
+ // prints incoming entries directly; the main thread just parks here
+ // until the user aborts or the server closes the socket.
+ while (Subscriber.GetState() == TailWsSubscriber::State::Open && !g_TailAbortFlag.load(std::memory_order_relaxed))
+ {
+ zen::Sleep(100);
+ }
+
+ if (Subscriber.GetState() == TailWsSubscriber::State::Closed && !g_TailAbortFlag.load(std::memory_order_relaxed))
+ {
+ ZEN_CONSOLE("(server gone)");
+ }
+ return true;
+ };
+
+ if (TryWebSocketTail())
+ {
+ return;
+ }
+
+ ZEN_DEBUG("WebSocket tail unavailable; falling back to polling at {} ms intervals", m_PollIntervalMs);
+
+ constexpr uint32_t SleepStepMs = 50;
+ while (!g_TailAbortFlag.load(std::memory_order_relaxed))
+ {
+ // Sleep in small chunks so a Ctrl-C reacts within ~50 ms even when the
+ // user picks a long --interval-ms.
+ uint32_t SleptMs = 0;
+ while (SleptMs < m_PollIntervalMs && !g_TailAbortFlag.load(std::memory_order_relaxed))
+ {
+ zen::Sleep(SleepStepMs);
+ SleptMs += SleepStepMs;
+ }
+ if (g_TailAbortFlag.load(std::memory_order_relaxed))
+ {
+ break;
+ }
+
+ HttpClient::Response Poll = Http.Get(fmt::format("{}?cursor={}", LogPath, Cursor), HttpClient::Accept(ZenContentType::kCbObject));
+ if (!Poll)
+ {
+ // Server went away mid-tail (connection refused / timeout / DNS).
+ // Treat as a clean end-of-stream rather than an error so an operator
+ // that just stopped the server with `zen down` doesn't see a stack
+ // trace from their tail.
+ if (Poll.Error.has_value() && Poll.Error->IsConnectionError())
+ {
+ ZEN_CONSOLE("(server gone)");
+ break;
+ }
+ if (Poll.StatusCode == HttpResponseCode::NotFound)
+ {
+ ZEN_CONSOLE("(session ended)");
+ break;
+ }
+ Poll.ThrowError("session log poll failed"sv);
+ }
+
+ CbObjectView P = Poll.AsObject();
+ const uint64_t NewCursor = P["cursor"sv].AsUInt64(Cursor);
+ if (NewCursor == Cursor)
+ {
+ continue;
+ }
+ Cursor = NewCursor;
+ for (CbFieldView Entry : P["entries"sv].AsArrayView())
+ {
+ PrintLogEntry(Entry.AsObjectView());
+ }
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// SessionsUiSubCmd
+
+SessionsUiSubCmd::SessionsUiSubCmd() : SessionsSubCmdBase("ui", "Open the sessions tab of the zenserver dashboard in a web browser")
+{
+}
+
+void
+SessionsUiSubCmd::Run(const ZenCliOptions& /*GlobalOptions*/)
+{
+ // Resolve through ZenServiceClient so we use the same host-discovery logic
+ // as the rest of the sessions subcommands (and so this invocation shows up
+ // as its own session in 'sessions ls', which is a feature, not a bug).
+ ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = "sessions.ui"});
+
+ if (Service.IsUnixSocket())
+ {
+ throw std::runtime_error("Cannot open browser for a Unix domain socket connection");
+ }
+
+ // Page route in the dashboard SPA — see add_service_nav() in
+ // frontend/html/pages/page.js.
+ ExtendableStringBuilder<256> Url;
+ Url << Service.HostSpec() << "/dashboard/?page=sessions";
+
+ LaunchBrowser(Url.ToView());
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// SessionsCommand
+
+SessionsCommand::SessionsCommand()
+{
+ m_Options.add_options()("h,help", "Print help");
+
+ AddSubCommand(m_LsSubCmd);
+ AddSubCommand(m_StatusSubCmd);
+ AddSubCommand(m_TailSubCmd);
+ AddSubCommand(m_UiSubCmd);
+}
+
+SessionsCommand::~SessionsCommand() = default;
+
+} // namespace zen
diff --git a/src/zen/cmds/sessions_cmd.h b/src/zen/cmds/sessions_cmd.h
new file mode 100644
index 000000000..996054e84
--- /dev/null
+++ b/src/zen/cmds/sessions_cmd.h
@@ -0,0 +1,75 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include "../zen.h"
+
+namespace zen {
+
+class SessionsSubCmdBase : public ZenSubCmdBase
+{
+public:
+ SessionsSubCmdBase(std::string_view Name, std::string_view Description);
+
+protected:
+ std::string m_HostName;
+};
+
+class SessionsLsSubCmd : public SessionsSubCmdBase
+{
+public:
+ SessionsLsSubCmd();
+ void Run(const ZenCliOptions& GlobalOptions) override;
+
+private:
+ std::string m_StatusFilter = "all"; // "active", "ended", "all"
+};
+
+class SessionsStatusSubCmd : public SessionsSubCmdBase
+{
+public:
+ SessionsStatusSubCmd();
+ void Run(const ZenCliOptions& GlobalOptions) override;
+};
+
+class SessionsTailSubCmd : public SessionsSubCmdBase
+{
+public:
+ SessionsTailSubCmd();
+ void Run(const ZenCliOptions& GlobalOptions) override;
+
+private:
+ std::string m_SessionId; // optional positional; empty = self
+ uint32_t m_Lines = 50;
+ bool m_Follow = false;
+ uint32_t m_PollIntervalMs = 500;
+};
+
+class SessionsUiSubCmd : public SessionsSubCmdBase
+{
+public:
+ SessionsUiSubCmd();
+ void Run(const ZenCliOptions& GlobalOptions) override;
+};
+
+class SessionsCommand : public ZenCmdWithSubCommands
+{
+public:
+ static constexpr char Name[] = "sessions";
+ static constexpr char Description[] = "Inspect zen client sessions - ls, status, tail, ui";
+
+ SessionsCommand();
+ ~SessionsCommand();
+
+ cxxopts::Options& Options() override { return m_Options; }
+
+private:
+ cxxopts::Options m_Options{Name, Description};
+
+ SessionsLsSubCmd m_LsSubCmd;
+ SessionsStatusSubCmd m_StatusSubCmd;
+ SessionsTailSubCmd m_TailSubCmd;
+ SessionsUiSubCmd m_UiSubCmd;
+};
+
+} // namespace zen
diff --git a/src/zen/cmds/ui_cmd.cpp b/src/zen/cmds/ui_cmd.cpp
index 5fc242541..cf9ea1492 100644
--- a/src/zen/cmds/ui_cmd.cpp
+++ b/src/zen/cmds/ui_cmd.cpp
@@ -176,7 +176,7 @@ UiCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
ZenServiceClient Service({.HostSpec = m_HostName, .CommandName = Name});
- if (IsUnixSocketSpec(Service.HostSpec()))
+ if (Service.IsUnixSocket())
{
throw std::runtime_error("Cannot open browser for a Unix domain socket connection");
}