diff --git a/app/Console/Commands/MockMetrics.php b/app/Console/Commands/MockMetrics.php
new file mode 100644
index 0000000..9a79338
--- /dev/null
+++ b/app/Console/Commands/MockMetrics.php
@@ -0,0 +1,32 @@
+option('interval'));
+ $cpu = 40;
+
+ $this->info("Broadcasting mock metrics every {$interval}s on channel 'metrics' …");
+
+ while (true) {
+ $cpu = max(2, min(98, $cpu + random_int(-12, 12)));
+ broadcast(new MetricsTicked($cpu));
+ $this->line('tick cpu='.$cpu);
+ sleep($interval);
+ }
+ }
+}
diff --git a/app/Events/MetricsTicked.php b/app/Events/MetricsTicked.php
new file mode 100644
index 0000000..43c26aa
--- /dev/null
+++ b/app/Events/MetricsTicked.php
@@ -0,0 +1,39 @@
+ */
+ public function broadcastWith(): array
+ {
+ return ['cpu' => $this->cpu, 'server' => $this->server];
+ }
+}
diff --git a/config/broadcasting.php b/config/broadcasting.php
new file mode 100644
index 0000000..ebc3fb9
--- /dev/null
+++ b/config/broadcasting.php
@@ -0,0 +1,82 @@
+ env('BROADCAST_CONNECTION', 'null'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Broadcast Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define all of the broadcast connections that will be used
+ | to broadcast events to other systems or over WebSockets. Samples of
+ | each available type of connection are provided inside this array.
+ |
+ */
+
+ 'connections' => [
+
+ 'reverb' => [
+ 'driver' => 'reverb',
+ 'key' => env('REVERB_APP_KEY'),
+ 'secret' => env('REVERB_APP_SECRET'),
+ 'app_id' => env('REVERB_APP_ID'),
+ 'options' => [
+ 'host' => env('REVERB_HOST'),
+ 'port' => env('REVERB_PORT', 443),
+ 'scheme' => env('REVERB_SCHEME', 'https'),
+ 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
+ ],
+ 'client_options' => [
+ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
+ ],
+ ],
+
+ 'pusher' => [
+ 'driver' => 'pusher',
+ 'key' => env('PUSHER_APP_KEY'),
+ 'secret' => env('PUSHER_APP_SECRET'),
+ 'app_id' => env('PUSHER_APP_ID'),
+ 'options' => [
+ 'cluster' => env('PUSHER_APP_CLUSTER'),
+ 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
+ 'port' => env('PUSHER_PORT', 443),
+ 'scheme' => env('PUSHER_SCHEME', 'https'),
+ 'encrypted' => true,
+ 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
+ ],
+ 'client_options' => [
+ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
+ ],
+ ],
+
+ 'ably' => [
+ 'driver' => 'ably',
+ 'key' => env('ABLY_KEY'),
+ ],
+
+ 'log' => [
+ 'driver' => 'log',
+ ],
+
+ 'null' => [
+ 'driver' => 'null',
+ ],
+
+ ],
+
+];
diff --git a/config/reverb.php b/config/reverb.php
new file mode 100644
index 0000000..91f3880
--- /dev/null
+++ b/config/reverb.php
@@ -0,0 +1,102 @@
+ env('REVERB_SERVER', 'reverb'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Reverb Servers
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define details for each of the supported Reverb servers.
+ | Each server has its own configuration options that are defined in
+ | the array below. You should ensure all the options are present.
+ |
+ */
+
+ 'servers' => [
+
+ 'reverb' => [
+ 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
+ 'port' => env('REVERB_SERVER_PORT', 8080),
+ 'path' => env('REVERB_SERVER_PATH', ''),
+ 'hostname' => env('REVERB_HOST'),
+ 'options' => [
+ 'tls' => [],
+ ],
+ 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
+ 'scaling' => [
+ 'enabled' => env('REVERB_SCALING_ENABLED', false),
+ 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
+ 'server' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'username' => env('REDIS_USERNAME'),
+ 'password' => env('REDIS_PASSWORD'),
+ 'database' => env('REDIS_DB', '0'),
+ 'timeout' => env('REDIS_TIMEOUT', 60),
+ ],
+ ],
+ 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
+ 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Reverb Applications
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define how Reverb applications are managed. If you choose
+ | to use the "config" provider, you may define an array of apps which
+ | your server will support, including their connection credentials.
+ |
+ */
+
+ 'apps' => [
+
+ 'provider' => 'config',
+
+ 'apps' => [
+ [
+ 'key' => env('REVERB_APP_KEY'),
+ 'secret' => env('REVERB_APP_SECRET'),
+ 'app_id' => env('REVERB_APP_ID'),
+ 'options' => [
+ 'host' => env('REVERB_HOST'),
+ 'port' => env('REVERB_PORT', 443),
+ 'scheme' => env('REVERB_SCHEME', 'https'),
+ 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
+ ],
+ 'allowed_origins' => ['*'],
+ 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
+ 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
+ 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
+ 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
+ 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
+ 'rate_limiting' => [
+ 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
+ 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
+ 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
+ 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
+ ],
+ ],
+ ],
+
+ ],
+
+];
diff --git a/package-lock.json b/package-lock.json
index f2ef569..653fa19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4,6 +4,10 @@
"requires": true,
"packages": {
"": {
+ "dependencies": {
+ "laravel-echo": "^2.3.7",
+ "pusher-js": "^8.5.0"
+ },
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
@@ -909,6 +913,27 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/laravel-echo": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.7.tgz",
+ "integrity": "sha512-6NoPtOk16PuaykVgcV1MV5665VPtrbyvacBD6AJ8NJdRjTwrOwKrgOYHgq4bz5E1zUbbVi2UJfMN7P7D/yceyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "pusher-js": "*",
+ "socket.io-client": "*"
+ },
+ "peerDependenciesMeta": {
+ "pusher-js": {
+ "optional": true
+ },
+ "socket.io-client": {
+ "optional": true
+ }
+ }
+ },
"node_modules/laravel-vite-plugin": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz",
@@ -1275,6 +1300,15 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/pusher-js": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
+ "integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
+ "license": "MIT",
+ "dependencies": {
+ "tweetnacl": "^1.0.3"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1451,6 +1485,12 @@
"dev": true,
"license": "0BSD"
},
+ "node_modules/tweetnacl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
+ "license": "Unlicense"
+ },
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
diff --git a/package.json b/package.json
index 49c869e..4b59dcb 100644
--- a/package.json
+++ b/package.json
@@ -12,5 +12,9 @@
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
+ },
+ "dependencies": {
+ "laravel-echo": "^2.3.7",
+ "pusher-js": "^8.5.0"
}
}
diff --git a/resources/js/app.js b/resources/js/app.js
index 8337712..ce419ed 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1 +1,52 @@
-//
+import Echo from 'laravel-echo';
+import Pusher from 'pusher-js';
+
+window.Pusher = Pusher;
+
+// Reverb (Pusher protocol). Host/port/scheme are env-driven (derived from APP_DOMAIN later).
+window.Echo = new Echo({
+ broadcaster: 'reverb',
+ key: import.meta.env.VITE_REVERB_APP_KEY,
+ wsHost: import.meta.env.VITE_REVERB_HOST,
+ wsPort: Number(import.meta.env.VITE_REVERB_PORT ?? 80),
+ wssPort: Number(import.meta.env.VITE_REVERB_PORT ?? 443),
+ forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
+ enabledTransports: ['ws', 'wss'],
+});
+
+// Live CPU chart island: seeds from server-rendered data, then appends each
+// MetricsTicked broadcast and redraws the sparkline (viewBox 0 0 300 80).
+document.addEventListener('alpine:init', () => {
+ window.Alpine.data('metricsChart', (seed = [], max = 40) => ({
+ points: seed.slice(-max),
+ max,
+ last: seed.length ? seed[seed.length - 1] : 0,
+ connected: false,
+
+ init() {
+ // reflect the REAL websocket state, not just "Echo exists"
+ const conn = window.Echo?.connector?.pusher?.connection;
+ this.connected = conn?.state === 'connected';
+ conn?.bind('state_change', (s) => { this.connected = s.current === 'connected'; });
+ window.Echo?.channel('metrics').listen('.tick', (e) => this.push(Number(e.cpu)));
+ },
+
+ push(v) {
+ this.last = v;
+ this.points.push(v);
+ if (this.points.length > this.max) this.points.shift();
+ },
+
+ coords() {
+ const n = this.points.length;
+ const w = 300, h = 80, pad = 6;
+ return this.points.map((v, i) => {
+ const x = n > 1 ? (i / (n - 1)) * w : 0;
+ const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+ },
+ get linePoints() { return this.coords().join(' '); },
+ get areaPoints() { return `0,80 ${this.coords().join(' ')} 300,80`; },
+ }));
+});
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php
index af0e790..29dafa2 100644
--- a/resources/views/livewire/dashboard.blade.php
+++ b/resources/views/livewire/dashboard.blade.php
@@ -4,16 +4,6 @@
$avgCpu = $total ? (int) round(collect($servers)->avg('cpu')) : 0;
$avgMem = $total ? (int) round(collect($servers)->avg('mem')) : 0;
- // static sparkline path from $metrics (live chart island replaces this in v1)
- $n = count($metrics); $w = 300; $h = 80; $pad = 6; $coords = [];
- foreach ($metrics as $i => $v) {
- $x = $n > 1 ? round($i / ($n - 1) * $w, 1) : 0;
- $y = round($h - $pad - ($v / 100) * ($h - 2 * $pad), 1);
- $coords[] = "$x,$y";
- }
- $line = implode(' ', $coords);
- $area = "0,$h " . $line . " $w,$h";
-
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
@endphp
@@ -42,7 +32,7 @@
+ + + + + · CPU +
Mock-Serie — Live-Daten folgen über Reverb.