F websockets:watch -v — per-connection rows (socket id, user, duration)
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_<slug(socketId)> 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) <noreply@anthropic.com>
This commit is contained in:
parent
e1abef4194
commit
e45d8dff20
|
|
@ -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('<fg=cyan;options=bold>WebSocket Server — Live Stats</>');
|
||||
$modeLabel = $verbose ? ' [verbose]' : '';
|
||||
$this->line('<fg=cyan;options=bold>WebSocket Server — Live Stats' . $modeLabel . '</>');
|
||||
$this->line('<fg=gray>' . 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', "<fg=white;options=bold>{$uniqueUsers}</>");
|
||||
$this->components->twoColumnDetail('Active channels', '<fg=white;options=bold>' . count($channels) . '</>');
|
||||
|
||||
if (count($channelData) > 0) {
|
||||
$this->newLine();
|
||||
$this->table(['Channel', 'Connections'], $channelData);
|
||||
} else {
|
||||
if (empty($perChannel)) {
|
||||
$this->newLine();
|
||||
$this->line(' <fg=gray>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[] = [
|
||||
"<fg=white;options=bold>{$channel}</>",
|
||||
"<fg=white;options=bold>" . count($sockets) . "</>",
|
||||
'-',
|
||||
'-',
|
||||
];
|
||||
|
||||
foreach ($sockets as $socketId) {
|
||||
$rows[] = [
|
||||
' <fg=gray>↳</>',
|
||||
"<fg=gray>{$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 '<fg=gray>Guest</>';
|
||||
}
|
||||
|
||||
$user = WebsocketService::getAuth($socketId);
|
||||
if (! $user) {
|
||||
// Authed but the per-socket user blob expired / was evicted.
|
||||
return '<fg=yellow>#' . $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 '<fg=gray>—</>';
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue