feat: live metrics over Reverb (mock emitter)

- MetricsTicked broadcast event (channel 'metrics', as 'tick') + a
  clusev:mock-metrics command (dev placeholder for the SSH MetricsPoller),
  broadcasting a rolling CPU value every 2s.
- Echo + pusher-js client in app.js with an env-driven Reverb connection
  (VITE_REVERB_*). metricsChart Alpine island seeds from server data, appends
  each tick and redraws the sparkline; the indicator reflects the real WS state.
- Published config/reverb.php + config/broadcasting.php.
- Backend verified: the queue processes MetricsTicked every 2s with no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 01:23:10 +02:00
parent 6302ef5e7a
commit 8df4456108
8 changed files with 361 additions and 15 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Events\MetricsTicked;
use Illuminate\Console\Command;
/**
* Dev placeholder for the real MetricsPoller (SSH). Broadcasts a rolling CPU
* value over Reverb so the dashboard chart moves live.
*/
class MockMetrics extends Command
{
protected $signature = 'clusev:mock-metrics {--interval=2 : Seconds between ticks}';
protected $description = 'Broadcast mock CPU metrics over Reverb (dev)';
public function handle(): int
{
$interval = max(1, (int) $this->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);
}
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* A single CPU metric sample for a server, pushed to the browser over Reverb.
* Dev: emitted by `clusev:mock-metrics`. Later: by MetricsPoller (SSH).
*/
class MetricsTicked implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $cpu,
public string $server = 'web-01',
) {}
public function broadcastOn(): Channel
{
return new Channel('metrics');
}
public function broadcastAs(): string
{
return 'tick';
}
/** @return array<string,mixed> */
public function broadcastWith(): array
{
return ['cpu' => $this->cpu, 'server' => $this->server];
}
}

82
config/broadcasting.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => 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',
],
],
];

102
config/reverb.php Normal file
View File

@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => 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),
],
],
],
],
];

40
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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`; },
}));
});

View File

@ -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 @@
<x-badge tone="cyan">Reverb</x-badge>
</x-slot:actions>
<div id="metrics-chart" class="text-accent">
<div class="text-accent" x-data="metricsChart(@js($metrics), 40)">
<svg viewBox="0 0 300 80" class="h-24 w-full" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="spark" x1="0" x2="0" y1="0" y2="1">
@ -50,11 +40,17 @@
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<polyline points="{{ $area }}" fill="url(#spark)" stroke="none" />
<polyline points="{{ $line }}" fill="none" stroke="currentColor" stroke-width="1.5" />
<polyline :points="areaPoints" fill="url(#spark)" stroke="none" />
<polyline :points="linePoints" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
<p class="mt-2 flex items-center gap-2 font-mono text-[11px] text-ink-4">
<span class="inline-flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full" :class="connected ? 'bg-online' : 'bg-ink-4'"></span>
<span x-text="connected ? 'Live über Reverb' : 'Verbindung…'"></span>
</span>
<span>· CPU <span class="tabular text-ink-2" x-text="last + '%'"></span></span>
</p>
</div>
<p class="mt-2 font-mono text-[11px] text-ink-4">Mock-Serie Live-Daten folgen über Reverb.</p>
</x-panel>
<x-panel title="Ressourcen" subtitle="web-01">