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 @@ Reverb -
+
+

+ + + + + · CPU +

-

Mock-Serie — Live-Daten folgen über Reverb.