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/');
+});