PHP Classes

File: scripts/binktop.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/binktop.php   Download  
File: scripts/binktop.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 35,794 bytes
 

Contents

Class file image Download
#!/usr/bin/env php <?php require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Auth; use BinktermPHP\Config; use BinktermPHP\Database; use BinktermPHP\DoorSessionManager; use BinktermPHP\GuestUser; use BinktermPHP\Version; use BinktermPHP\Binkp\Config\BinkpConfig; use BinktermPHP\Binkp\Queue\InboundQueue; use BinktermPHP\Binkp\Queue\OutboundQueue; function showUsage(): void { echo "Usage: php scripts/binktop.php [options]\n"; echo "Options:\n"; echo " --json Output machine-readable JSON\n"; echo " --minutes=N Online-user activity window in minutes (default: 15)\n"; echo " --interval=N Refresh interval in seconds (default: 2)\n"; echo " --once Render once and exit\n"; echo " --help Show this help message\n"; } function parseArgs(array $argv): array { $args = []; foreach ($argv as $arg) { if (strpos($arg, '--') !== 0) { continue; } $option = substr($arg, 2); if (strpos($option, '=') !== false) { [$key, $value] = explode('=', $option, 2); $args[$key] = $value; continue; } $args[$option] = true; } return $args; } function safeSection(callable $callback, mixed $fallback = null): mixed { try { return $callback(); } catch (Throwable $e) { return $fallback; } } function formatBytes(float|int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $value = (float)$bytes; $unitIndex = 0; while ($value >= 1024 && $unitIndex < count($units) - 1) { $value /= 1024; $unitIndex++; } return number_format($value, $value >= 10 ? 1 : 2) . ' ' . $units[$unitIndex]; } function formatUsedTotalBytes(float|int $usedBytes, float|int $totalBytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $usedValue = (float)$usedBytes; $totalValue = (float)$totalBytes; $unitIndex = 0; while ($totalValue >= 1024 && $unitIndex < count($units) - 1) { $usedValue /= 1024; $totalValue /= 1024; $unitIndex++; } $precision = $totalValue >= 10 ? 1 : 2; return number_format($usedValue, $precision) . ' / ' . number_format($totalValue, $precision) . ' ' . $units[$unitIndex]; } function formatKb(int $kilobytes): string { return formatBytes($kilobytes * 1024); } function stripAnsi(string $value): string { return (string)preg_replace('/\x1B\[[0-9;]*m/', '', $value); } function visibleLength(string $value): int { return strlen(stripAnsi($value)); } function padVisible(string $value, int $width): string { $padding = max(0, $width - visibleLength($value)); return $value . str_repeat(' ', $padding); } function colorize(string $value, string $color): string { $codes = [ 'bold' => '1', 'white' => '1;37', 'green' => '0;32', 'red' => '0;31', 'yellow' => '1;33', 'cyan' => '0;36', 'blue' => '0;34', 'magenta' => '0;35', 'bright_cyan' => '1;36', 'bright_blue' => '1;34', 'bright_magenta' => '1;35', ]; if (!isset($codes[$color])) { return $value; } return "\033[" . $codes[$color] . 'm' . $value . "\033[0m"; } function renderHeaderMetric(string $label, string $value, string $labelColor = 'bright_blue', string $valueColor = 'white'): string { return colorize($label, $labelColor) . colorize($value, $valueColor); } function formatDuration(int $seconds): string { if ($seconds < 0) { $seconds = 0; } $days = intdiv($seconds, 86400); $seconds %= 86400; $hours = intdiv($seconds, 3600); $seconds %= 3600; $minutes = intdiv($seconds, 60); $seconds %= 60; $parts = []; if ($days > 0) { $parts[] = $days . 'd'; } if ($hours > 0 || $days > 0) { $parts[] = $hours . 'h'; } if ($minutes > 0 || $hours > 0 || $days > 0) { $parts[] = $minutes . 'm'; } $parts[] = $seconds . 's'; return implode(' ', $parts); } function formatTimestamp(?string $timestamp): string { if ($timestamp === null || $timestamp === '') { return '-'; } try { return (new DateTime($timestamp))->format('Y-m-d H:i:s'); } catch (Throwable $e) { return $timestamp; } } function formatCompactTimestamp(?string $timestamp): string { if ($timestamp === null || $timestamp === '') { return '-'; } try { return (new DateTime($timestamp))->format('m-d H:i'); } catch (Throwable $e) { return truncateCell($timestamp, 8); } } function runCommand(string $command): ?string { $output = @shell_exec($command); if (!is_string($output)) { return null; } $trimmed = trim($output); return $trimmed === '' ? null : $trimmed; } function getSystemUptimeSeconds(): ?int { if (PHP_OS_FAMILY === 'Windows') { $output = runCommand('powershell -NoProfile -Command "try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop; if ($os -and $os.LastBootUpTime) { $os.LastBootUpTime.ToUniversalTime().ToString(' . "'o'" . ') } } catch {}" 2>$null'); if ($output === null) { return null; } try { $bootTime = new DateTime($output, new DateTimeZone('UTC')); return max(0, time() - $bootTime->getTimestamp()); } catch (Throwable $e) { return null; } } $uptimeFile = '/proc/uptime'; if (is_file($uptimeFile)) { $content = trim((string)@file_get_contents($uptimeFile)); if ($content !== '') { $parts = preg_split('/\s+/', $content); if (isset($parts[0]) && is_numeric($parts[0])) { return (int)floor((float)$parts[0]); } } } $output = runCommand('uptime -s'); if ($output === null) { return null; } try { $bootTime = new DateTime($output); return max(0, time() - $bootTime->getTimestamp()); } catch (Throwable $e) { return null; } } function getSystemMemorySummary(): ?array { if (PHP_OS_FAMILY === 'Windows') { $output = runCommand('powershell -NoProfile -Command "try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop; if ($os) { $total = [int64]$os.TotalVisibleMemorySize * 1024; $free = [int64]$os.FreePhysicalMemory * 1024; if ($total -gt 0) { [pscustomobject]@{total=$total; free=$free; used=($total-$free)} | ConvertTo-Json -Compress } } } catch {}" 2>$null'); if ($output === null) { return null; } $data = json_decode($output, true); if (!is_array($data) || !isset($data['total'], $data['used'], $data['free'])) { return null; } return [ 'total_bytes' => (int)$data['total'], 'used_bytes' => (int)$data['used'], 'free_bytes' => (int)$data['free'], ]; } $meminfoFile = '/proc/meminfo'; if (!is_file($meminfoFile)) { return null; } $meminfo = (string)@file_get_contents($meminfoFile); if ($meminfo === '') { return null; } if (!preg_match('/^MemTotal:\s+(\d+)\s+kB$/mi', $meminfo, $totalMatch)) { return null; } $totalKb = (int)$totalMatch[1]; $availableKb = 0; if (preg_match('/^MemAvailable:\s+(\d+)\s+kB$/mi', $meminfo, $availableMatch)) { $availableKb = (int)$availableMatch[1]; } elseif (preg_match('/^MemFree:\s+(\d+)\s+kB$/mi', $meminfo, $freeMatch)) { $availableKb = (int)$freeMatch[1]; } $usedKb = max(0, $totalKb - $availableKb); return [ 'total_bytes' => $totalKb * 1024, 'used_bytes' => $usedKb * 1024, 'free_bytes' => $availableKb * 1024, ]; } function getLoadAverage(): ?array { if (!function_exists('sys_getloadavg')) { return null; } $loads = @sys_getloadavg(); if (!is_array($loads) || count($loads) < 3) { return null; } return [ '1m' => (float)$loads[0], '5m' => (float)$loads[1], '15m' => (float)$loads[2], ]; } function getDiskSummary(string $path): ?array { $total = @disk_total_space($path); $free = @disk_free_space($path); if (!is_numeric($total) || !is_numeric($free)) { return null; } return [ 'path' => $path, 'total_bytes' => (int)$total, 'used_bytes' => (int)$total - (int)$free, 'free_bytes' => (int)$free, ]; } function getRamUsageReport(): ?array { if (PHP_OS_FAMILY === 'Windows') { return null; } $scriptPath = realpath(__DIR__ . '/ram_usage.sh'); if ($scriptPath === false || !is_file($scriptPath)) { return null; } $output = runCommand('bash ' . escapeshellarg($scriptPath) . ' --json 2>&1'); if ($output === null) { return null; } $data = json_decode($output, true); return is_array($data) ? $data : null; } function isPidRunning(int $pid): bool { if ($pid <= 0) { return false; } if (PHP_OS_FAMILY === 'Windows') { $output = runCommand('powershell -NoProfile -Command "Get-Process -Id ' . $pid . ' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id" 2>$null'); return $output !== null && trim($output) === (string)$pid; } if (is_dir('/proc/' . $pid)) { return true; } return function_exists('posix_kill') ? @posix_kill($pid, 0) : false; } function getProcessMemoryKb(?string $pid): ?int { if ($pid === null || !ctype_digit($pid)) { return null; } $pidInt = (int)$pid; if ($pidInt <= 0) { return null; } if (PHP_OS_FAMILY === 'Windows') { $output = runCommand('powershell -NoProfile -Command "Get-Process -Id ' . $pidInt . ' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty WorkingSet64" 2>$null'); if ($output === null || !is_numeric($output)) { return null; } return (int)round(((float)$output) / 1024); } $statusFile = '/proc/' . $pidInt . '/status'; if (!is_file($statusFile)) { return null; } $content = (string)@file_get_contents($statusFile); if ($content === '') { return null; } if (preg_match('/^VmRSS:\s+(\d+)\s+kB$/mi', $content, $matches)) { return (int)$matches[1]; } return null; } function findPidsByProcessName(string $name): array { if ($name === '') { return []; } if (PHP_OS_FAMILY === 'Windows') { $output = runCommand('powershell -NoProfile -Command "Get-Process -Name ' . escapeshellarg($name) . ' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id" 2>$null'); if ($output === null) { return []; } $pids = preg_split('/\r?\n/', trim($output)) ?: []; return array_values(array_filter(array_map('trim', $pids), static fn(string $pid): bool => ctype_digit($pid))); } $output = runCommand('pgrep -x ' . escapeshellarg($name) . ' 2>/dev/null'); if ($output === null) { return []; } $pids = preg_split('/\r?\n/', trim($output)) ?: []; return array_values(array_filter(array_map('trim', $pids), static fn(string $pid): bool => ctype_digit($pid))); } function findPidsByCommandPattern(string $pattern): array { if ($pattern === '') { return []; } if (PHP_OS_FAMILY === 'Windows') { $escapedPattern = str_replace("'", "''", $pattern); $output = runCommand('powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like ' . "'*" . $escapedPattern . "*'" . ' } | Select-Object -ExpandProperty ProcessId" 2>$null'); if ($output === null) { return []; } $pids = preg_split('/\r?\n/', trim($output)) ?: []; return array_values(array_filter(array_map('trim', $pids), static fn(string $pid): bool => ctype_digit($pid))); } $output = runCommand('pgrep -f ' . escapeshellarg($pattern) . ' 2>/dev/null'); if ($output === null) { return []; } $pids = preg_split('/\r?\n/', trim($output)) ?: []; return array_values(array_filter(array_map('trim', $pids), static fn(string $pid): bool => ctype_digit($pid))); } function buildProcessGroupStatusRow(string $matchType, string $pattern): array { $pids = $matchType === 'cmdline' ? findPidsByCommandPattern($pattern) : findPidsByProcessName($pattern); $rssKb = 0; foreach ($pids as $pid) { $pidRssKb = getProcessMemoryKb($pid); if ($pidRssKb !== null) { $rssKb += $pidRssKb; } } return [ 'pid_file' => null, 'pid' => count($pids) === 1 ? $pids[0] : null, 'pid_display' => count($pids) > 1 ? count($pids) . 'x' : (count($pids) === 1 ? $pids[0] : '-'), 'running' => !empty($pids), 'optional' => true, 'configured' => true, 'rss_kb' => !empty($pids) ? $rssKb : null, 'process_count' => count($pids), ]; } function getDaemonStatusSnapshot(): array { $runDir = __DIR__ . '/../data/run'; $coreDaemons = [ 'admin_daemon' => Config::env('ADMIN_DAEMON_PID_FILE', $runDir . '/admin_daemon.pid'), 'binkp_scheduler' => Config::env('BINKP_SCHEDULER_PID_FILE', $runDir . '/binkp_scheduler.pid'), 'binkp_server' => Config::env('BINKP_SERVER_PID_FILE', $runDir . '/binkp_server.pid'), 'realtime_server' => Config::env('BINKSTREAM_WS_PID_FILE', Config::env('REALTIME_WS_PID_FILE', $runDir . '/realtime_server.pid')), ]; $optionalDaemons = [ 'ftp_daemon' => Config::env('FTPD_PID_FILE', $runDir . '/ftpd.pid'), 'telnetd' => Config::env('TELNETD_PID_FILE', $runDir . '/telnetd.pid'), 'ssh_daemon' => Config::env('SSHD_PID_FILE', $runDir . '/sshd.pid'), 'gemini_daemon' => Config::env('GEMINI_PID_FILE', $runDir . '/gemini_daemon.pid'), 'mrc_daemon' => Config::env('MRC_PID_FILE', $runDir . '/mrc_daemon.pid'), 'multiplexing_server' => Config::env('MULTIPLEX_PID_FILE', $runDir . '/multiplexing-server.pid'), 'mcp_server' => Config::env('MCP_SERVER_PID_FILE', $runDir . '/mcp-server.pid'), ]; $status = []; foreach ($coreDaemons as $name => $pidFile) { $status[$name] = buildDaemonStatusRow($pidFile, false); } foreach ($optionalDaemons as $name => $pidFile) { $status[$name] = buildDaemonStatusRow($pidFile, true); } $extraProcessDaemons = [ 'postgres' => ['type' => 'name', 'pattern' => 'postgres'], 'httpd' => ['type' => 'name', 'pattern' => 'httpd'], 'apache2' => ['type' => 'name', 'pattern' => 'apache2'], 'caddy' => ['type' => 'name', 'pattern' => 'caddy'], 'php-fpm' => ['type' => 'name', 'pattern' => 'php-fpm'], 'php-fpm:*' => ['type' => 'cmdline', 'pattern' => 'php-fpm:'], ]; foreach ($extraProcessDaemons as $name => $definition) { $status[$name] = buildProcessGroupStatusRow($definition['type'], $definition['pattern']); } return $status; } function buildDaemonStatusRow(string $pidFile, bool $optional): array { $configured = file_exists($pidFile); $pid = null; $running = false; if ($configured) { $rawPid = trim((string)@file_get_contents($pidFile)); if ($rawPid !== '' && ctype_digit($rawPid)) { $pid = $rawPid; $running = isPidRunning((int)$rawPid); } } return [ 'pid_file' => $pidFile, 'pid' => $pid, 'running' => $running, 'optional' => $optional, 'configured' => $configured || !$optional, ]; } function getSessionSummary(\PDO $db): array { $summary = [ 'valid_user_sessions' => null, 'total_sessions' => null, ]; try { $row = $db->query(" SELECT COUNT(*) FILTER (WHERE u.is_active = TRUE AND COALESCE(u.is_system, FALSE) = FALSE) AS valid_user_sessions, COUNT(*) FILTER (WHERE u.is_active = TRUE) AS total_sessions FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.expires_at > NOW() ")->fetch(PDO::FETCH_ASSOC); if ($row) { $summary['valid_user_sessions'] = (int)$row['valid_user_sessions']; $summary['total_sessions'] = (int)$row['total_sessions']; } } catch (Throwable $e) { try { $count = $db->query("SELECT COUNT(*) AS total_sessions FROM user_sessions WHERE expires_at > NOW()")->fetch(PDO::FETCH_ASSOC); $summary['valid_user_sessions'] = isset($count['total_sessions']) ? (int)$count['total_sessions'] : null; $summary['total_sessions'] = isset($count['total_sessions']) ? (int)$count['total_sessions'] : null; } catch (Throwable $inner) { } } return $summary; } function getOnlineUserSummary(array $onlineUsers): array { $guestUserId = GuestUser::getId(); $summary = [ 'users' => 0, 'guests' => 0, ]; foreach ($onlineUsers as $user) { $userId = isset($user['user_id']) ? (int)$user['user_id'] : null; if ($guestUserId !== null && $userId === $guestUserId) { $summary['guests']++; continue; } $summary['users']++; } return $summary; } function getPostgresConnectionSummary(\PDO $db): array { $summary = [ 'total' => null, 'active' => null, 'idle' => null, ]; $row = $db->query(" SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE state = 'active') AS active, COUNT(*) FILTER (WHERE state = 'idle') AS idle FROM pg_stat_activity WHERE datname = current_database() ")->fetch(PDO::FETCH_ASSOC); if ($row) { $summary['total'] = (int)$row['total']; $summary['active'] = (int)$row['active']; $summary['idle'] = (int)$row['idle']; } return $summary; } function getTerminalSize(): array { $columns = 120; $lines = 40; if (PHP_OS_FAMILY === 'Windows') { $output = runCommand('powershell -NoProfile -Command "$h = $Host.UI.RawUI.WindowSize.Height; $w = $Host.UI.RawUI.WindowSize.Width; Write-Output ($w.ToString() + ' . "'x'" . ' + $h.ToString())" 2>$null'); if ($output !== null && preg_match('/^(\d+)x(\d+)$/', trim($output), $matches)) { return [ 'columns' => max(40, (int)$matches[1]), 'lines' => max(20, (int)$matches[2]), ]; } } else { $output = runCommand('stty size 2>/dev/null'); if ($output !== null && preg_match('/^(\d+)\s+(\d+)$/', trim($output), $matches)) { return [ 'columns' => max(40, (int)$matches[2]), 'lines' => max(20, (int)$matches[1]), ]; } } return ['columns' => $columns, 'lines' => $lines]; } function truncateCell(string $value, int $width): string { if ($width < 1) { return ''; } if (visibleLength($value) <= $width) { return $value; } if ($width <= 3) { return substr(stripAnsi($value), 0, $width); } return substr(stripAnsi($value), 0, $width - 3) . '...'; } function buildTableLines(array $rows, array $widthHints = [], ?int $maxRows = null): array { if (empty($rows)) { return ['(none)']; } if ($maxRows !== null) { $rows = array_slice($rows, 0, max(0, $maxRows)); } $headers = array_keys($rows[0]); $widths = []; foreach ($headers as $header) { $widths[$header] = max(strlen($header), (int)($widthHints[$header] ?? 0)); } foreach ($rows as $row) { foreach ($headers as $header) { $widths[$header] = max($widths[$header], visibleLength((string)($row[$header] ?? ''))); } } $lines = []; $headerLine = ''; $separatorLine = ''; foreach ($headers as $header) { $headerLine .= str_pad($header, $widths[$header] + 2); $separatorLine .= str_repeat('-', $widths[$header]) . ' '; } $lines[] = rtrim($headerLine); $lines[] = rtrim($separatorLine); foreach ($rows as $row) { $line = ''; foreach ($headers as $header) { $line .= padVisible(truncateCell((string)($row[$header] ?? ''), $widths[$header]), $widths[$header] + 2); } $lines[] = rtrim($line); } return $lines; } function wrapSection(string $title, array $bodyLines): array { return array_merge([$title], $bodyLines, ['']); } function fitLineToWidth(string $line, int $width): string { return truncateCell($line, max(1, $width)); } function fitTableToHeight(array $rows, array $widthHints, int $availableHeight): array { if ($availableHeight <= 0) { return []; } if ($availableHeight === 1) { return ['(truncated)']; } if (empty($rows)) { return ['(none)']; } $needsOverflowLine = count($rows) > max(0, $availableHeight - 2); $maxRows = $needsOverflowLine ? max(0, $availableHeight - 3) : max(0, $availableHeight - 2); $visibleRows = array_slice($rows, 0, $maxRows); $lines = buildTableLines($visibleRows, $widthHints); if (count($rows) > $maxRows) { $remaining = count($rows) - $maxRows; $lines[] = '(' . $remaining . ' more)'; } return array_slice($lines, 0, $availableHeight); } function buildTwoColumnTableLines(array $rows, array $widthHints, int $availableHeight, int $terminalWidth): array { if ($availableHeight <= 0) { return []; } if (empty($rows)) { return ['(none)']; } $perColumn = (int)ceil(count($rows) / 2); $leftRows = array_slice($rows, 0, $perColumn); $rightRows = array_slice($rows, $perColumn); $leftLines = buildTableLines($leftRows, $widthHints); $rightLines = buildTableLines($rightRows, $widthHints); $leftWidth = 0; foreach ($leftLines as $line) { $leftWidth = max($leftWidth, visibleLength($line)); } $gap = 4; if ($leftWidth + $gap + 20 > $terminalWidth) { return fitTableToHeight($rows, $widthHints, $availableHeight); } $maxLineCount = max(count($leftLines), count($rightLines)); $combined = []; for ($i = 0; $i < $maxLineCount; $i++) { $left = $leftLines[$i] ?? ''; $right = $rightLines[$i] ?? ''; if ($right === '') { $combined[] = rtrim($left); continue; } $combined[] = rtrim(padVisible($left, $leftWidth + $gap) . $right); } if (count($combined) > $availableHeight) { return array_slice($combined, 0, $availableHeight - 1) + ['(truncated)']; } return $combined; } function clearScreen(): void { // Move cursor to home without erasing first; content is overwritten in place. // \033[J clears from cursor to end of screen after the new content is written, // avoiding the full-screen flash that \033[2J causes. echo "\033[H"; } /** * Put/restore the terminal in raw (non-blocking) mode so we can poll for keypresses. * On Windows stream_set_blocking is used; on POSIX stty is used. */ function setRawMode(bool $raw): void { if (PHP_OS_FAMILY === 'Windows') { stream_set_blocking(STDIN, !$raw); return; } if ($raw) { @system('stty -echo -icanon min 0 time 0 2>/dev/null'); stream_set_blocking(STDIN, false); } else { @system('stty sane 2>/dev/null'); stream_set_blocking(STDIN, true); } } /** * Return the next character from STDIN without blocking, or null if none is available. */ function readChar(): ?string { $read = [STDIN]; $write = $except = []; if (@stream_select($read, $write, $except, 0, 0) < 1) { return null; } $ch = @fread(STDIN, 1); return ($ch === false || $ch === '') ? null : $ch; } function buildDaemonRows(array $daemonStatus): array { $rows = []; foreach ($daemonStatus as $name => $info) { $color = 'red'; if (!($info['configured'] ?? true)) { $color = 'yellow'; } elseif (!empty($info['running'])) { $color = 'green'; } $rows[] = [ 'daemon' => colorize((string)$name, $color), 'pid' => (string)($info['pid_display'] ?? $info['pid'] ?? '-'), 'rss' => isset($info['rss_kb']) && $info['rss_kb'] !== null ? formatKb((int)$info['rss_kb']) : '-', ]; } return $rows; } function getDaemonTotalRssKb(array $daemonStatus): int { $total = 0; foreach ($daemonStatus as $info) { if (isset($info['rss_kb']) && is_numeric($info['rss_kb'])) { $total += (int)$info['rss_kb']; } } return $total; } function buildUserRows(array $onlineUsers): array { return array_map(static function (array $user): array { return [ 'user' => (string)($user['username'] ?? '-'), 'svc' => (string)($user['service'] ?? '-'), 'activity' => truncateCell((string)($user['activity'] ?? '-'), 25), 'ip' => (string)($user['ip_address'] ?? '-'), 'last' => formatCompactTimestamp($user['last_activity'] ?? null), ]; }, $onlineUsers); } function buildDoorRows(array $doorSessions): array { return array_map(static function (array $session): array { return [ 'door' => (string)($session['door_name'] ?? $session['door_id'] ?? '-'), 'uid' => (string)($session['user_id'] ?? '-'), 'node' => (string)($session['node'] ?? '-'), 'ws' => (string)($session['ws_port'] ?? '-'), 'bridge' => (string)($session['bridge_pid'] ?? '-'), 'proc' => (string)($session['dosbox_pid'] ?? '-'), ]; }, $doorSessions); } function buildHeaderLines(array $snapshot, int $interval, int $columns): array { $loadAverage = $snapshot['load_average']; $memory = $snapshot['memory']; $disk = $snapshot['disk']; $postgres = $snapshot['postgres']; $queues = $snapshot['queues']; $onlineSummary = $snapshot['online_summary']; $title = colorize($snapshot['app_version'], 'bright_magenta'); $os = colorize(PHP_OS_FAMILY, 'bright_cyan'); $host = renderHeaderMetric('host:', (string)$snapshot['host'], 'blue', 'yellow'); $now = renderHeaderMetric('now:', date('Y-m-d H:i:s'), 'blue', 'white'); $refresh = renderHeaderMetric('ref:', $interval . 's', 'blue', 'green'); $uptime = renderHeaderMetric( 'up:', $snapshot['uptime_seconds'] !== null ? formatDuration((int)$snapshot['uptime_seconds']) : 'n/a', 'cyan', 'white' ); $load = renderHeaderMetric( 'load:', $loadAverage !== null ? number_format($loadAverage['1m'], 2) . ' ' . number_format($loadAverage['5m'], 2) . ' ' . number_format($loadAverage['15m'], 2) : 'n/a', 'cyan', 'yellow' ); $ram = renderHeaderMetric( 'ram:', $memory !== null ? formatUsedTotalBytes($memory['used_bytes'], $memory['total_bytes']) : 'n/a', 'cyan', 'green' ); $diskMetric = renderHeaderMetric( 'disk:', $disk !== null ? formatUsedTotalBytes($disk['used_bytes'], $disk['total_bytes']) : 'n/a', 'cyan', 'green' ); $users = renderHeaderMetric('users:', (string)count($snapshot['online_users']), 'magenta', 'white'); $sess = renderHeaderMetric( 'sess:', isset($onlineSummary['users'], $onlineSummary['guests']) ? $onlineSummary['users'] . '/' . $onlineSummary['guests'] : 'n/a', 'magenta', 'white' ); $doors = renderHeaderMetric('doors:', (string)count($snapshot['door_sessions']), 'magenta', 'white'); $pg = renderHeaderMetric( 'pg:', $postgres['total'] !== null ? $postgres['total'] . ' (' . $postgres['active'] . 'a/' . $postgres['idle'] . 'i)' : 'n/a', 'magenta', 'yellow' ); $inbound = renderHeaderMetric('in:', (string)($queues['inbound']['pending_files'] ?? 'n/a'), 'magenta', 'white'); $outbound = renderHeaderMetric( 'out:', (string)($queues['outbound']['pending_files'] ?? 'n/a') . '/' . (isset($queues['outbound']['total_size']) ? formatBytes((int)$queues['outbound']['total_size']) : 'n/a'), 'magenta', 'white' ); return [ fitLineToWidth($title . ' ' . $os . ' ' . $host . ' ' . $now . ' ' . $refresh, $columns), fitLineToWidth($uptime . ' ' . $load . ' ' . $ram . ' ' . $diskMetric, $columns), fitLineToWidth($users . ' ' . $sess . ' ' . $doors . ' ' . $pg . ' ' . $inbound . ' ' . $outbound, $columns), ]; } function assembleScreen(array $snapshot, int $interval, bool $interactive = false): string { $terminal = getTerminalSize(); $lines = $terminal['lines']; $columns = $terminal['columns']; $headerLines = buildHeaderLines($snapshot, $interval, $columns); $userRows = buildUserRows($snapshot['online_users']); $daemonRows = buildDaemonRows($snapshot['daemons']); $daemonTotalRssKb = getDaemonTotalRssKb($snapshot['daemons']); $doorRows = buildDoorRows($snapshot['door_sessions']); $daemonWidthHints = $columns <= 80 ? ['daemon' => 14, 'pid' => 5, 'rss' => 8] : ['daemon' => 18, 'pid' => 6, 'rss' => 10]; $userWidthHints = $columns <= 80 ? ['user' => 10, 'svc' => 4, 'activity' => 14, 'ip' => 10, 'last' => 8] : ['user' => 14, 'svc' => 6, 'activity' => 24, 'ip' => 18, 'last' => 19]; $doorWidthHints = $columns <= 80 ? ['door' => 12, 'uid' => 4, 'node' => 4, 'ws' => 4, 'bridge' => 6, 'proc' => 6] : ['door' => 20, 'uid' => 5, 'node' => 4, 'ws' => 5, 'bridge' => 8, 'proc' => 8]; $userLines = buildTableLines($userRows, $userWidthHints); $remaining = max(4, $lines - count($headerLines) - count($userLines) - 3); $daemonSectionHeight = min(count($daemonRows) + 3, max(4, min(14, (int)floor($remaining / 2)))); $doorSectionHeight = max(4, $remaining - $daemonSectionHeight); $daemonLines = ($columns >= 80 && count($daemonRows) >= 6) ? buildTwoColumnTableLines($daemonRows, $daemonWidthHints, $daemonSectionHeight, $columns) : fitTableToHeight($daemonRows, $daemonWidthHints, $daemonSectionHeight); if (!empty($daemonLines)) { $daemonLines[] = 'total rss: ' . formatKb($daemonTotalRssKb); } $screenLines = $headerLines; $screenLines[] = ''; $screenLines = array_merge($screenLines, $userLines); $screenLines[] = ''; $screenLines = array_merge($screenLines, $daemonLines); $screenLines[] = ''; $screenLines = array_merge($screenLines, wrapSection('Door Sessions', fitTableToHeight($doorRows, $doorWidthHints, $doorSectionHeight))); $output = implode("\n", $screenLines) . "\n"; if ($interactive) { $output .= colorize('[q]', 'bright_cyan') . ' quit ' . colorize('[i]', 'bright_cyan') . ' set interval'; } // Erase from cursor to end of screen so leftover lines from a taller previous // render don't bleed through (replaces the full \033[2J clear that causes blink). $output .= "\033[J"; return $output; } $args = parseArgs($argv); if (isset($args['help'])) { showUsage(); exit(0); } $json = isset($args['json']); $onlineMinutes = isset($args['minutes']) && ctype_digit((string)$args['minutes']) ? max(1, (int)$args['minutes']) : 15; $interval = isset($args['interval']) && is_numeric((string)$args['interval']) ? max(1, (int)$args['interval']) : 2; $renderOnce = isset($args['once']) || $json; $interactive = !$renderOnce && !$json; if ($interactive) { setRawMode(true); register_shutdown_function(static fn() => setRawMode(false)); } $keepRunning = true; $firstRender = true; do { $db = Database::reconnect()->getPdo(); $auth = new Auth(); $onlineUsers = safeSection(static fn() => $auth->getOnlineSessions($onlineMinutes), []); $doorSessions = array_values(array_filter( safeSection(static fn() => (new DoorSessionManager())->getActiveSessions(), []), static function (array $session): bool { $expiresAt = $session['expires_at'] ?? null; if (!is_string($expiresAt) || $expiresAt === '') { return true; } try { return (new DateTime($expiresAt)) > new DateTime('now', new DateTimeZone('UTC')); } catch (Throwable $e) { return true; } } )); $daemonStatus = getDaemonStatusSnapshot(); foreach ($daemonStatus as $name => $info) { if (array_key_exists('rss_kb', $info)) { continue; } $daemonStatus[$name]['rss_kb'] = !empty($info['running']) ? getProcessMemoryKb($info['pid'] ?? null) : null; } $snapshot = [ 'generated_at' => gmdate('c'), 'host' => php_uname('n'), 'app_version' => Version::getFullVersion(), 'os' => [ 'family' => PHP_OS_FAMILY, 'description' => php_uname(), ], 'uptime_seconds' => getSystemUptimeSeconds(), 'load_average' => getLoadAverage(), 'memory' => getSystemMemorySummary(), 'disk' => getDiskSummary(realpath(__DIR__ . '/..') ?: __DIR__ . '/..'), 'sessions' => safeSection(static fn() => getSessionSummary($db), ['valid_user_sessions' => null, 'total_sessions' => null]), 'online_window_minutes' => $onlineMinutes, 'online_users' => $onlineUsers, 'online_summary' => getOnlineUserSummary($onlineUsers), 'door_sessions' => $doorSessions, 'daemons' => $daemonStatus, 'queues' => safeSection(static function (): array { $config = BinkpConfig::getInstance(); return [ 'inbound' => (new InboundQueue($config))->getStats(), 'outbound' => (new OutboundQueue($config))->getStats(), ]; }, ['inbound' => null, 'outbound' => null]), 'postgres' => safeSection(static fn() => getPostgresConnectionSummary($db), ['total' => null, 'active' => null, 'idle' => null]), ]; if ($json) { echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; exit(0); } if ($firstRender) { echo "\033[H\033[2J"; $firstRender = false; } clearScreen(); echo assembleScreen($snapshot, $interval, $interactive); if ($renderOnce) { break; } // Wait $interval seconds in 100 ms slices so we can react to keypresses promptly. $waitedUs = 0; $sliceUs = 100000; // 100 ms while ($waitedUs < $interval * 1000000) { usleep($sliceUs); $waitedUs += $sliceUs; if (!$interactive) { continue; } $ch = readChar(); if ($ch === null) { continue; } $ch = strtolower($ch); if ($ch === 'q') { $keepRunning = false; break; } if ($ch === 'i') { setRawMode(false); echo "\n" . colorize('New interval (seconds): ', 'bright_cyan'); $line = trim((string)fgets(STDIN)); if (ctype_digit($line) && (int)$line >= 1) { $interval = (int)$line; } setRawMode(true); break; // refresh immediately with new interval } } } while ($keepRunning); if ($interactive) { setRawMode(false); }