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
parent
6302ef5e7a
commit
8df4456108
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`; },
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue