diff --git a/app/Filament/Server/Widgets/ServerConsole.php b/app/Filament/Server/Widgets/ServerConsole.php index 8853cc09a5..7f01e2e29e 100644 --- a/app/Filament/Server/Widgets/ServerConsole.php +++ b/app/Filament/Server/Widgets/ServerConsole.php @@ -113,23 +113,6 @@ public function tokenRequest(): void $this->dispatch('sendAuthRequest', token: $this->getToken()); } - #[On('store-stats')] - public function storeStats(string $data): void - { - $data = json_decode($data); - - $timestamp = now()->getTimestamp(); - - foreach ($data as $key => $value) { - $cacheKey = "servers.{$this->server->id}.$key"; - $cachedStats = cache()->get($cacheKey, []); - - $cachedStats[$timestamp] = $value; - - cache()->put($cacheKey, array_slice($cachedStats, -120), now()->addMinute()); - } - } - #[On('websocket-error')] public function websocketError(): void { diff --git a/app/Filament/Server/Widgets/ServerCpuChart.php b/app/Filament/Server/Widgets/ServerCpuChart.php index 7cc0b2e2f3..6e14b60783 100644 --- a/app/Filament/Server/Widgets/ServerCpuChart.php +++ b/app/Filament/Server/Widgets/ServerCpuChart.php @@ -2,16 +2,14 @@ namespace App\Filament\Server\Widgets; -use App\Enums\CustomizationKey; use App\Models\Server; use Filament\Facades\Filament; use Filament\Support\RawJs; use Filament\Widgets\ChartWidget; -use Illuminate\Support\Carbon; class ServerCpuChart extends ChartWidget { - protected ?string $pollingInterval = '1s'; + protected ?string $pollingInterval = null; protected ?string $maxHeight = '200px'; @@ -27,19 +25,10 @@ public static function canView(): bool protected function getData(): array { - $period = (int) user()?->getCustomization(CustomizationKey::ConsoleGraphPeriod); - $cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute")) - ->slice(-$period) - ->map(fn ($value, $key) => [ - 'cpu' => round($value, 2), - 'timestamp' => Carbon::createFromTimestamp($key, user()->timezone ?? 'UTC')->format('H:i:s'), - ]) - ->all(); - return [ 'datasets' => [ [ - 'data' => array_column($cpu, 'cpu'), + 'data' => [], 'backgroundColor' => [ 'rgba(96, 165, 250, 0.3)', ], @@ -47,7 +36,7 @@ protected function getData(): array 'fill' => true, ], ], - 'labels' => array_column($cpu, 'timestamp'), + 'labels' => [], 'locale' => user()->language ?? 'en', ]; } diff --git a/app/Filament/Server/Widgets/ServerMemoryChart.php b/app/Filament/Server/Widgets/ServerMemoryChart.php index 535d8b9f7f..99c009884d 100644 --- a/app/Filament/Server/Widgets/ServerMemoryChart.php +++ b/app/Filament/Server/Widgets/ServerMemoryChart.php @@ -2,16 +2,14 @@ namespace App\Filament\Server\Widgets; -use App\Enums\CustomizationKey; use App\Models\Server; use Filament\Facades\Filament; use Filament\Support\RawJs; use Filament\Widgets\ChartWidget; -use Illuminate\Support\Carbon; class ServerMemoryChart extends ChartWidget { - protected ?string $pollingInterval = '1s'; + protected ?string $pollingInterval = null; protected ?string $maxHeight = '200px'; @@ -27,19 +25,10 @@ public static function canView(): bool protected function getData(): array { - $period = (int) user()?->getCustomization(CustomizationKey::ConsoleGraphPeriod); - $memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes")) - ->slice(-$period) - ->map(fn ($value, $key) => [ - 'memory' => round(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, 2), - 'timestamp' => Carbon::createFromTimestamp($key, user()->timezone ?? 'UTC')->format('H:i:s'), - ]) - ->all(); - return [ 'datasets' => [ [ - 'data' => array_column($memUsed, 'memory'), + 'data' => [], 'backgroundColor' => [ 'rgba(96, 165, 250, 0.3)', ], @@ -47,7 +36,7 @@ protected function getData(): array 'fill' => true, ], ], - 'labels' => array_column($memUsed, 'timestamp'), + 'labels' => [], 'locale' => user()->language ?? 'en', ]; } diff --git a/app/Filament/Server/Widgets/ServerNetworkChart.php b/app/Filament/Server/Widgets/ServerNetworkChart.php index cbe112c758..a9a9a6a480 100644 --- a/app/Filament/Server/Widgets/ServerNetworkChart.php +++ b/app/Filament/Server/Widgets/ServerNetworkChart.php @@ -2,16 +2,15 @@ namespace App\Filament\Server\Widgets; -use App\Enums\CustomizationKey; use App\Models\Server; use Filament\Facades\Filament; use Filament\Support\RawJs; use Filament\Widgets\ChartWidget; -use Illuminate\Support\Carbon; +use Illuminate\Support\HtmlString; class ServerNetworkChart extends ChartWidget { - protected ?string $pollingInterval = '1s'; + protected ?string $pollingInterval = null; protected ?string $maxHeight = '200px'; @@ -27,33 +26,11 @@ public static function canView(): bool protected function getData(): array { - $previous = null; - - $period = (int) user()?->getCustomization(CustomizationKey::ConsoleGraphPeriod); - $net = collect(cache()->get("servers.{$this->server->id}.network")) - ->slice(-$period) - ->map(function ($current, $timestamp) use (&$previous) { - $net = null; - - if ($previous !== null) { - $net = [ - 'rx' => max(0, $current->rx_bytes - $previous->rx_bytes), - 'tx' => max(0, $current->tx_bytes - $previous->tx_bytes), - 'timestamp' => Carbon::createFromTimestamp($timestamp, user()->timezone ?? 'UTC')->format('H:i:s'), - ]; - } - - $previous = $current; - - return $net; - }) - ->all(); - return [ 'datasets' => [ [ 'label' => 'Inbound', - 'data' => array_column($net, 'rx'), + 'data' => [], 'backgroundColor' => [ 'rgba(100, 255, 105, 0.5)', ], @@ -62,7 +39,7 @@ protected function getData(): array ], [ 'label' => 'Outbound', - 'data' => array_column($net, 'tx'), + 'data' => [], 'backgroundColor' => [ 'rgba(96, 165, 250, 0.3)', ], @@ -70,7 +47,7 @@ protected function getData(): array 'fill' => true, ], ], - 'labels' => array_column($net, 'timestamp'), + 'labels' => [], ]; } @@ -109,10 +86,8 @@ protected function getOptions(): RawJs JS); } - public function getHeading(): string + public function getHeading(): HtmlString { - $lastData = collect(cache()->get("servers.{$this->server->id}.network"))->last(); - - return trans('server/console.labels.network') . ' - ↓' . convert_bytes_to_readable($lastData->rx_bytes ?? 0) . ' - ↑' . convert_bytes_to_readable($lastData->tx_bytes ?? 0); + return new HtmlString(e(trans('server/console.labels.network')) . ' '); } } diff --git a/app/Filament/Server/Widgets/ServerOverview.php b/app/Filament/Server/Widgets/ServerOverview.php index 6952d4bf03..72055c3ead 100644 --- a/app/Filament/Server/Widgets/ServerOverview.php +++ b/app/Filament/Server/Widgets/ServerOverview.php @@ -2,15 +2,16 @@ namespace App\Filament\Server\Widgets; -use App\Enums\ContainerStatus; use App\Filament\Server\Components\SmallStatBlock; use App\Models\Server; -use Carbon\CarbonInterface; use Filament\Widgets\StatsOverviewWidget; +use Illuminate\Support\HtmlString; class ServerOverview extends StatsOverviewWidget { - protected ?string $pollingInterval = '1s'; + private const UNKNOWN = '—'; + + protected ?string $pollingInterval = null; public ?Server $server = null; @@ -28,64 +29,36 @@ protected function getStats(): array ]; } - private function status(): string + private function status(): HtmlString { - $status = $this->server->condition->getLabel(); - $uptime = collect(cache()->get("servers.{$this->server->id}.uptime"))->last() ?? 0; - - if ($uptime === 0) { - return $status; - } - - $uptime = now()->subMillis($uptime)->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE, short: true, parts: 2); - - return "$status ($uptime)"; + return new HtmlString('' . e($this->statusText()) . ''); } - public function cpuUsage(): string + private function statusText(): string { - $status = $this->server->retrieveStatus(); - - if ($status->isOffline()) { - return ContainerStatus::Offline->getLabel(); - } - - $data = collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))->last(default: 0); - $cpu = format_number($data, maxPrecision: 2) . ' %'; - - return $cpu . ($this->server->cpu > 0 ? ' / ' . format_number($this->server->cpu) . ' %' : ' / ∞'); + return $this->server->condition->getLabel(); } - public function memoryUsage(): string + public function cpuUsage(): HtmlString { - $status = $this->server->retrieveStatus(); + $limit = $this->server->cpu > 0 ? ' / ' . format_number($this->server->cpu) . ' %' : ' / ∞'; - if ($status->isOffline()) { - return ContainerStatus::Offline->getLabel(); - } + return new HtmlString('' . self::UNKNOWN . '' . e($limit)); + } - $latestMemoryUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))->last(default: 0); + public function memoryUsage(): HtmlString + { $totalMemory = $this->server->memory * (config('panel.use_binary_prefix') ? 1024 * 1024 : 1000 * 1000); + $limit = $this->server->memory > 0 ? ' / ' . convert_bytes_to_readable($totalMemory) : ' / ∞'; - $used = convert_bytes_to_readable($latestMemoryUsed); - $total = convert_bytes_to_readable($totalMemory); - - return $used . ($this->server->memory > 0 ? ' / ' . $total : ' / ∞'); + return new HtmlString('' . self::UNKNOWN . '' . e($limit)); } - public function diskUsage(): string + public function diskUsage(): HtmlString { - $disk = collect(cache()->get("servers.{$this->server->id}.disk_bytes"))->last(default: 0); - - if ($disk === 0) { - return 'Unavailable'; - } - $totalBytes = $this->server->disk * (config('panel.use_binary_prefix') ? 1024 * 1024 : 1000 * 1000); + $limit = $this->server->disk > 0 ? ' / ' . convert_bytes_to_readable($totalBytes) : ' / ∞'; - $used = convert_bytes_to_readable($disk); - $total = convert_bytes_to_readable($totalBytes); - - return $used . ($this->server->disk > 0 ? ' / ' . $total : ' / ∞'); + return new HtmlString('' . self::UNKNOWN . '' . e($limit)); } } diff --git a/resources/js/console.js b/resources/js/console.js index 0f871b10cf..4d2f7430f0 100644 --- a/resources/js/console.js +++ b/resources/js/console.js @@ -13,3 +13,269 @@ window.Xterm = { SearchAddon, SearchBarAddon, }; + +const MAX_SAMPLES = 120; + +const config = { + uuid: null, + binaryPrefix: false, + period: 30, + locale: 'en', + timezone: null, + offlineLabel: 'Offline', + unknownLabel: '—', + statusLabels: {}, +}; + +const samples = []; +let currentState = null; + +// Keeps the charts from starting empty on every visit; sessionStorage can be +// unavailable (private mode) or hold junk, and either way we just start empty. +const storageKey = () => `pelican:console-stats:${config.uuid}`; + +const restoreSamples = () => { + if (!config.uuid || samples.length) { + return; + } + + try { + const horizon = Date.now() - MAX_SAMPLES * 1000; + const stored = JSON.parse(sessionStorage.getItem(storageKey()) ?? '[]'); + + samples.push(...stored.filter((sample) => Number.isFinite(sample?.t) && sample.t >= horizon)); + } catch {} +}; + +const persistSamples = () => { + if (!config.uuid) { + return; + } + + try { + sessionStorage.setItem(storageKey(), JSON.stringify(samples)); + } catch {} +}; + +// Mirrors Carbon's diffForHumans(syntax: DIFF_ABSOLUTE, short: true, parts: 2), +// localized through Intl the same way Carbon translates its unit suffixes. +const SHORT_UNITS = [ + ['year', 31536000000], + ['month', 2592000000], + ['week', 604800000], + ['day', 86400000], + ['hour', 3600000], + ['minute', 60000], + ['second', 1000], +]; + +const formatUnit = (value, unit) => { + const options = { style: 'unit', unit, unitDisplay: 'narrow' }; + + try { + return new Intl.NumberFormat(config.locale.replace('_', '-'), options).format(value); + } catch { + return new Intl.NumberFormat('en', options).format(value); + } +}; + +const humanizeShort = (milliseconds, parts = 2) => { + const found = []; + let remaining = milliseconds; + + for (const [unit, size] of SHORT_UNITS) { + if (found.length >= parts) { + break; + } + + const value = Math.floor(remaining / size); + + if (value > 0) { + found.push(formatUnit(value, unit)); + remaining -= value * size; + } + } + + return found.length ? found.join(' ') : formatUnit(0, 'second'); +}; + +const number = (value) => { + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : 0; +}; + +const round = (value, decimals) => { + const factor = 10 ** decimals; + + return Math.round(value * factor) / factor; +}; + +// Mirrors format_number() in app/helpers.php, including its fallback to the default locale. +const formatNumber = (value, decimals, minDecimals = 0) => { + const options = { + minimumFractionDigits: minDecimals, + maximumFractionDigits: decimals, + }; + + try { + return new Intl.NumberFormat(config.locale.replace('_', '-'), options).format(value); + } catch { + return new Intl.NumberFormat('en', options).format(value); + } +}; + +// Mirrors convert_bytes_to_readable() in app/helpers.php. +const bytesToReadable = (bytes, decimals = 2) => { + const suffixes = config.binaryPrefix + ? ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + : ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + if (bytes <= 0) { + return `0 ${suffixes[0]}`; + } + + const unit = config.binaryPrefix ? 1024 : 1000; + const fromBase = Math.log(bytes) / Math.log(unit); + const base = Math.min(Math.floor(fromBase), suffixes.length - 1); + + return `${formatNumber(unit ** (fromBase - base), decimals, decimals)} ${suffixes[base]}`; +}; + +const windowed = () => samples.slice(-config.period); + +// The panel timezone preference wins over the browser's, like the old PHP labels. +const labelsFor = (window) => { + try { + return window.map((sample) => + new Date(sample.t).toLocaleTimeString('en-GB', { hour12: false, timeZone: config.timezone ?? undefined }), + ); + } catch { + return window.map((sample) => new Date(sample.t).toLocaleTimeString('en-GB', { hour12: false })); + } +}; + +const areaDataset = (data, backgroundColor = 'rgba(96, 165, 250, 0.3)', label = undefined) => ({ + ...(label === undefined ? {} : { label }), + data, + backgroundColor: [backgroundColor], + tension: '0.3', + fill: true, +}); + +window.ServerStats = { + configure(options) { + const previousUuid = config.uuid; + + Object.assign(config, options); + + // Livewire.navigate() can remount the console for another server in the + // same document, so drop the previous server's in-memory history. + if (config.uuid !== previousUuid) { + samples.length = 0; + currentState = null; + } + + restoreSamples(); + }, + + push(stats) { + samples.push({ + t: Date.now(), + cpu: number(stats.cpu_absolute), + memory: number(stats.memory_bytes), + disk: number(stats.disk_bytes), + rx: number(stats.network?.rx_bytes), + tx: number(stats.network?.tx_bytes), + uptime: number(stats.uptime), + }); + + if (samples.length > MAX_SAMPLES) { + samples.splice(0, samples.length - MAX_SAMPLES); + } + + persistSamples(); + + if (typeof stats.state === 'string') { + currentState = stats.state; + } + }, + + setState(state) { + currentState = state; + }, + + latest() { + return samples.length ? samples[samples.length - 1] : null; + }, + + state() { + return currentState; + }, + + statusText() { + if (currentState === null) { + return config.unknownLabel; + } + + const label = config.statusLabels[currentState] ?? currentState; + const uptime = this.latest()?.uptime ?? 0; + + return uptime === 0 ? label : `${label} (${humanizeShort(uptime)})`; + }, + + offlineLabel() { + return config.offlineLabel; + }, + + unknownLabel() { + return config.unknownLabel; + }, + + formatNumber, + bytesToReadable, + + // Mirrors ServerCpuChart::getData(). + cpuData() { + const window = windowed(); + + return { + datasets: [areaDataset(window.map((sample) => round(sample.cpu, 2)))], + labels: labelsFor(window), + locale: config.locale, + }; + }, + + // Mirrors ServerMemoryChart::getData(). + memoryData() { + const window = windowed(); + const unit = config.binaryPrefix ? 1024 ** 3 : 1000 ** 3; + + return { + datasets: [areaDataset(window.map((sample) => round(sample.memory / unit, 2)))], + labels: labelsFor(window), + locale: config.locale, + }; + }, + + // Mirrors ServerNetworkChart::getData(). The PHP maps the first sample to null + // and array_column() drops it, so the series is one shorter than the window. + networkData() { + const window = windowed(); + const rx = []; + const tx = []; + + for (let i = 1; i < window.length; i++) { + rx.push(Math.max(0, window[i].rx - window[i - 1].rx)); + tx.push(Math.max(0, window[i].tx - window[i - 1].tx)); + } + + return { + datasets: [ + areaDataset(rx, 'rgba(100, 255, 105, 0.5)', 'Inbound'), + areaDataset(tx, 'rgba(96, 165, 250, 0.3)', 'Outbound'), + ], + labels: labelsFor(window.slice(1)), + }; + }, +}; diff --git a/resources/views/filament/components/server-console.blade.php b/resources/views/filament/components/server-console.blade.php index 159d8b257c..6ffeef4996 100644 --- a/resources/views/filament/components/server-console.blade.php +++ b/resources/views/filament/components/server-console.blade.php @@ -6,6 +6,8 @@ $userRows = (int) user()?->getCustomization(\App\Enums\CustomizationKey::ConsoleRows); $terminalPrelude = str(config('app.name'))->slug()->lower()->toString(); + + $componentName = fn (string $class) => app('livewire.finder')->normalizeName($class); @endphp @if($userFont !== "monospace") @@ -132,82 +134,160 @@ class="w-full focus:outline-none focus:ring-0 border-none dark:bg-gray-900 p-1" const handlePowerChangeEvent = (state) => terminal.writeln(TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m'); - const socket = new WebSocket("{{ $this->getSocket() }}"); + let socket; + let reconnectAttempts = 0; + + window.ServerStats.configure({ + uuid: @js($this->server->uuid), + binaryPrefix: @js((bool) config('panel.use_binary_prefix')), + period: @js((int) user()?->getCustomization(\App\Enums\CustomizationKey::ConsoleGraphPeriod)), + locale: @js(str_replace('_', '-', user()->language ?? 'en')), + timezone: @js(user()->timezone ?? 'UTC'), + offlineLabel: @js(\App\Enums\ContainerStatus::Offline->getLabel()), + statusLabels: @js(collect(\App\Enums\ContainerStatus::cases())->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()])), + }); + + let statusIsLive = @js($this->server->status === null); + + const setStatText = (id, value) => { + const element = document.getElementById(id); - socket.onerror = (event) => { - $wire.dispatchSelf('websocket-error'); + if (element) { + element.textContent = value; + } }; - socket.onmessage = function(websocketMessageEvent) { - let { event, args } = JSON.parse(websocketMessageEvent.data); - - switch (event) { - case 'console output': - case 'install output': - handleConsoleOutput(args[0]); - break; - case 'install completed': - $wire.dispatch('refresh-sidebar'); - $wire.dispatch('refresh-topbar'); - $wire.dispatch('removeAlertBanner', { id: 'server_conflict' }); - break; - case 'feature match': - Livewire.dispatch('mount-feature', { data: args[0] }); - break; - case 'status': - handlePowerChangeEvent(args[0]); - $wire.dispatch('console-status', { state: args[0] }); - break; - case 'transfer status': - handleTransferStatus(args[0]); - break; - case 'daemon error': - handleDaemonErrorOutput(args[0]); - break; - case 'stats': - $wire.dispatchSelf('store-stats', { data: args[0] }); - break; - case 'auth success': - socket.send(JSON.stringify({ - 'event': 'send logs', - 'args': [null] - })); - break; - case 'token expiring': - case 'token expired': - $wire.dispatchSelf('token-request'); - break; + const pushToWidgets = () => { + Livewire.dispatchTo(@js($componentName(\App\Filament\Server\Widgets\ServerCpuChart::class)), 'updateChartData', { data: window.ServerStats.cpuData() }); + Livewire.dispatchTo(@js($componentName(\App\Filament\Server\Widgets\ServerMemoryChart::class)), 'updateChartData', { data: window.ServerStats.memoryData() }); + Livewire.dispatchTo(@js($componentName(\App\Filament\Server\Widgets\ServerNetworkChart::class)), 'updateChartData', { data: window.ServerStats.networkData() }); + + const latest = window.ServerStats.latest(); + const state = window.ServerStats.state(); + + if (latest) { + setStatText('server-network-heading', `- ↓${window.ServerStats.bytesToReadable(latest.rx)} - ↑${window.ServerStats.bytesToReadable(latest.tx)}`); + setStatText('server-stat-disk', latest.disk === 0 ? window.ServerStats.unknownLabel() : window.ServerStats.bytesToReadable(latest.disk)); + } + + const statValue = (format) => { + if (state === 'offline') { + return window.ServerStats.offlineLabel(); + } + + return state === null || !latest ? window.ServerStats.unknownLabel() : format(latest); + }; + + setStatText('server-stat-cpu', statValue((sample) => `${window.ServerStats.formatNumber(sample.cpu, 2, 0)} %`)); + setStatText('server-stat-memory', statValue((sample) => window.ServerStats.bytesToReadable(sample.memory))); + + if (statusIsLive) { + setStatText('server-stat-status', window.ServerStats.statusText()); } }; - socket.onopen = (event) => { - $wire.dispatchSelf('token-request'); + // send() throws while a reconnect attempt is still CONNECTING; a message + // to a dead socket is dropped, exactly as it was before the retries. + const sendToSocket = (payload) => { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)); + } }; + const connect = () => { + socket = new WebSocket("{{ $this->getSocket() }}"); + + // A dropped socket would otherwise leave the page frozen at its last + // values, so reconnect quietly and only raise the banner once that fails. + socket.onclose = (event) => { + if (reconnectAttempts >= 5) { + $wire.dispatchSelf('websocket-error'); + + return; + } + + reconnectAttempts++; + setTimeout(connect, 2000); + }; + + socket.onmessage = function(websocketMessageEvent) { + let { event, args } = JSON.parse(websocketMessageEvent.data); + + switch (event) { + case 'console output': + case 'install output': + handleConsoleOutput(args[0]); + break; + case 'install completed': + statusIsLive = true; + $wire.dispatch('refresh-sidebar'); + $wire.dispatch('refresh-topbar'); + $wire.dispatch('removeAlertBanner', { id: 'server_conflict' }); + break; + case 'feature match': + Livewire.dispatch('mount-feature', { data: args[0] }); + break; + case 'status': + handlePowerChangeEvent(args[0]); + window.ServerStats.setState(args[0]); + pushToWidgets(); + $wire.dispatch('console-status', { state: args[0] }); + break; + case 'transfer status': + handleTransferStatus(args[0]); + break; + case 'daemon error': + handleDaemonErrorOutput(args[0]); + break; + case 'stats': + window.ServerStats.push(JSON.parse(args[0])); + pushToWidgets(); + break; + case 'auth success': + reconnectAttempts = 0; + sendToSocket({ + 'event': 'send logs', + 'args': [null] + }); + break; + case 'token expiring': + case 'token expired': + $wire.dispatchSelf('token-request'); + break; + } + }; + + socket.onopen = (event) => { + $wire.dispatchSelf('token-request'); + }; + }; + + connect(); + Livewire.on('setServerState', ({ state, uuid }) => { const serverUuid = "{{ $this->server->uuid }}"; if (uuid !== serverUuid) { return; } - socket.send(JSON.stringify({ + sendToSocket({ 'event': 'set state', 'args': [state] - })); + }); }); $wire.on('sendAuthRequest', ({ token }) => { - socket.send(JSON.stringify({ + sendToSocket({ 'event': 'auth', 'args': [token] - })); + }); }); $wire.on('sendServerCommand', ({ command }) => { - socket.send(JSON.stringify({ + sendToSocket({ 'event': 'send command', 'args': [command] - })); + }); }); @endscript diff --git a/tests/Filament/ServerConsoleChartDispatchTest.php b/tests/Filament/ServerConsoleChartDispatchTest.php new file mode 100644 index 0000000000..3c51121aa2 --- /dev/null +++ b/tests/Filament/ServerConsoleChartDispatchTest.php @@ -0,0 +1,28 @@ +toBe(1); + expect(preg_match_all("/Livewire\.dispatchTo\((.+?), 'updateChartData'/", $blade, $matches))->toBe(3); + + $targets = array_map( + fn (string $expression) => Blade::render("@php {$prelude[1]} @endphp{$expression}"), + $matches[1], + ); + + $finder = app('livewire.finder'); + $expected = array_map( + fn (string $class) => Blade::render('@js($name)', ['name' => $finder->normalizeName($class)]), + [ServerCpuChart::class, ServerMemoryChart::class, ServerNetworkChart::class], + ); + + expect($targets)->toBe($expected); +}); diff --git a/tests/Unit/Filament/ServerConsoleWidgetsTest.php b/tests/Unit/Filament/ServerConsoleWidgetsTest.php new file mode 100644 index 0000000000..6f05273438 --- /dev/null +++ b/tests/Unit/Filament/ServerConsoleWidgetsTest.php @@ -0,0 +1,39 @@ +getDefaultValue())->toBeNull(); +})->with([ + ServerOverview::class, + ServerCpuChart::class, + ServerMemoryChart::class, + ServerNetworkChart::class, +]); + +it('does not listen for stats over livewire', function () { + $listeners = collect((new ReflectionClass(ServerConsole::class))->getMethods()) + ->flatMap(fn (ReflectionMethod $method) => $method->getAttributes(On::class)) + ->flatMap(fn (ReflectionAttribute $attribute) => $attribute->getArguments()) + ->all(); + + expect($listeners)->not->toContain('store-stats'); +}); + +// The console pushes chart data by dispatching 'updateChartData' straight to +// Filament's client-side listener; if a Filament upgrade renames it, the charts +// would freeze silently. This fails CI instead. +it('still finds filament listening for updateChartData in the browser', function () { + $chartJs = file_get_contents(dirname(__DIR__, 3) . '/vendor/filament/widgets/dist/components/chart.js'); + + expect($chartJs)->toMatch('/\$on\((["\'])updateChartData\1/'); +});