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; }