From e45d8dff20a45a8d1d9c03a92c3462872e644571 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 27 Apr 2026 13:42:26 +0200 Subject: [PATCH] =?UTF-8?q?F=20websockets:watch=20-v=20=E2=80=94=20per-con?= =?UTF-8?q?nection=20rows=20(socket=20id,=20user,=20duration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbose mode reuses Symfony's built-in -v flag (the short option is reserved, so a custom one can't be added). Each channel summary is followed by sub-rows showing the socket id, the authed user (falling back to "Guest" or the user id if the user blob expired), and how long the connection has been open. Connection start times are written by Handler::establishConnection() into ws_connection_ on open and forgotten on close. Also fixed a latent bug in finalizeConnectionClose() where the forget key was missing the slug() call that the reader has always used — previously invisible because nothing wrote the key, now load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Console/Commands/WatchStats.php | 129 ++++++++++++++++++++++------ src/Websocket/Handler.php | 13 ++- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/src/Console/Commands/WatchStats.php b/src/Console/Commands/WatchStats.php index 3e850b5..fa60721 100644 --- a/src/Console/Commands/WatchStats.php +++ b/src/Console/Commands/WatchStats.php @@ -14,23 +14,31 @@ use Illuminate\Console\Command; * authenticated users, active channels, per-channel breakdown) and refreshes * every second by default. * + * With -v / --verbose, the channels table expands to include one sub-row per + * connection showing the socket id, the authenticated user (or "Guest"), and + * how long the connection has been open. Connection start times are read from + * the per-socket cache key written by Handler::establishConnection(). + * * Polling vs. event-driven: stats live in the cache store (Redis in prod) and * are written by the running websocket process. There is no pub/sub channel - * that emits a "stats changed" event, so a true event-driven approach would - * require either tailing the cache writes or adding pub/sub plumbing into - * WebsocketService. A 1-second poll is well under the granularity at which - * connection counts are interesting and avoids that infrastructure cost. + * that emits a "stats changed" event today, so a true event-driven approach + * would require threading pub/sub publishes through every connection-mutation + * call site. A 1-second poll is well under the granularity at which connection + * counts are interesting and avoids that infrastructure cost. */ class WatchStats extends Command { protected $signature = 'websockets:watch {--interval=1 : Refresh interval in seconds (minimum 1)}'; - protected $description = 'Live WebSocket stats display — refreshes every second until Ctrl+C.'; + protected $description = 'Live WebSocket stats display — refreshes every second until Ctrl+C. Pass -v to expand each channel into per-connection rows (socket id, user, duration).'; public function handle(): int { $interval = max(1, (int) $this->option('interval')); + // Verbose mode piggybacks on Symfony's built-in -v / --verbose flag + // because that short option is reserved and can't be redeclared. + $verbose = $this->output->isVerbose(); // Restore cursor visibility on Ctrl+C / kill so the terminal isn't // left in a broken state if the user interrupts mid-render. @@ -51,11 +59,12 @@ class WatchStats extends Command while (true) { $this->output->write("\033[2J\033[H"); // clear screen + home cursor - $this->line('WebSocket Server — Live Stats'); + $modeLabel = $verbose ? ' [verbose]' : ''; + $this->line('WebSocket Server — Live Stats' . $modeLabel . ''); $this->line('' . now()->format('Y-m-d H:i:s') . ' — refreshing every ' . $interval . 's (Ctrl+C to exit)'); $this->newLine(); - $this->renderStats(); + $this->renderStats($verbose); sleep($interval); } @@ -66,25 +75,18 @@ class WatchStats extends Command return 0; // @phpstan-ignore-line } - /** - * Identical to ServerInfo::renderStats — duplicated rather than shared via - * trait so that the two commands can evolve independently if needed (e.g. - * the live view may eventually want sparkline-style deltas that the - * one-shot view doesn't). - */ - protected function renderStats(): void + protected function renderStats(bool $verbose): void { - $channels = WebsocketService::getActiveChannels(); - $authedUsers = WebsocketService::getAuthedUsers(); + $channels = WebsocketService::getActiveChannels(); + $authedUsers = WebsocketService::getAuthedUsers(); // [socketId => userId] $totalConnections = 0; - $channelData = []; + $perChannel = []; // [channelName => [socketId, ...]] foreach ($channels as $channel) { - $connections = WebsocketService::getChannelConnections($channel); - $count = count($connections); - $totalConnections += $count; - $channelData[] = [$channel, $count]; + $sockets = WebsocketService::getChannelConnections($channel); + $perChannel[$channel] = $sockets; + $totalConnections += count($sockets); } $uniqueUsers = count(array_unique(array_values($authedUsers))); @@ -94,12 +96,89 @@ class WatchStats extends Command $this->components->twoColumnDetail('Authenticated users', "{$uniqueUsers}"); $this->components->twoColumnDetail('Active channels', '' . count($channels) . ''); - if (count($channelData) > 0) { - $this->newLine(); - $this->table(['Channel', 'Connections'], $channelData); - } else { + if (empty($perChannel)) { $this->newLine(); $this->line(' No active channels.'); + return; } + + $this->newLine(); + + if (! $verbose) { + $rows = []; + foreach ($perChannel as $channel => $sockets) { + $rows[] = [$channel, count($sockets)]; + } + $this->table(['Channel', 'Connections'], $rows); + return; + } + + // Verbose: one summary row per channel, then one sub-row per connection. + // The sub-rows fill the User and Duration columns; the summary fills + // Channel and Connections. Dashes mark "not applicable on this row". + $now = time(); + $rows = []; + foreach ($perChannel as $channel => $sockets) { + $rows[] = [ + "{$channel}", + "" . count($sockets) . "", + '-', + '-', + ]; + + foreach ($sockets as $socketId) { + $rows[] = [ + ' ↳', + "{$socketId}", + $this->formatUser($socketId, $authedUsers), + $this->formatDuration($socketId, $now), + ]; + } + } + + $this->table(['Channel', 'Connections', 'User', 'Duration'], $rows); + } + + private function formatUser(string $socketId, array $authedUsers): string + { + if (! isset($authedUsers[$socketId])) { + return 'Guest'; + } + + $user = WebsocketService::getAuth($socketId); + if (! $user) { + // Authed but the per-socket user blob expired / was evicted. + return '#' . $authedUsers[$socketId] . ''; + } + + // Try common identity fields in order of usefulness. Fall back to the + // user id so we always have something to display. + $label = $user->name + ?? $user->display_name + ?? $user->username + ?? $user->email + ?? ('#' . ($user->id ?? '?')); + + return (string) $label; + } + + private function formatDuration(string $socketId, int $now): string + { + $info = WebsocketService::getConnection($socketId); + $connectedAt = is_array($info) ? ($info['connected_at'] ?? null) : null; + + if (! is_int($connectedAt)) { + // Connection predates the connected_at tracking, or the cache + // entry was lost. Show a placeholder rather than 00:00:00 which + // would falsely imply a brand-new connection. + return '—'; + } + + $secs = max(0, $now - $connectedAt); + $h = intdiv($secs, 3600); + $m = intdiv($secs % 3600, 60); + $s = $secs % 60; + + return sprintf('%02d:%02d:%02d', $h, $m, $s); } } diff --git a/src/Websocket/Handler.php b/src/Websocket/Handler.php index 03213db..515c1fb 100644 --- a/src/Websocket/Handler.php +++ b/src/Websocket/Handler.php @@ -1015,7 +1015,7 @@ class Handler implements MessageComponentInterface $this->channelManager->unsubscribeFromApp($connection->app->id); ConnectionClosed::dispatch($connection->app->id, $connection->socketId); - cache()->forget('ws_connection_' . $connection->socketId); + cache()->forget('ws_connection_' . str()->slug($connection->socketId)); }); } @@ -1139,6 +1139,17 @@ class Handler implements MessageComponentInterface ]), ])); + // Track connection start time so admin tooling (e.g. + // `php artisan websockets:watch -v`) can report how long a socket + // has been open. Slug the socketId to match WebsocketService::getConnection() + // (which has always slugged its reads). Cleaned up by + // finalizeConnectionClose(). + cache()->forever('ws_connection_' . str()->slug($connection->socketId), [ + 'socket_id' => $connection->socketId, + 'connected_at' => time(), + 'remote_addr' => $connection->remoteAddress ?? null, + ]); + return $this; }