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,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.3.7",
|
||||||
|
"pusher-js": "^8.5.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
|
@ -909,6 +913,27 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz",
|
||||||
|
|
@ -1275,6 +1300,15 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -1451,6 +1485,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.16",
|
"version": "8.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,9 @@
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^8.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;
|
$avgCpu = $total ? (int) round(collect($servers)->avg('cpu')) : 0;
|
||||||
$avgMem = $total ? (int) round(collect($servers)->avg('mem')) : 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'];
|
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|
@ -42,7 +32,7 @@
|
||||||
<x-badge tone="cyan">Reverb</x-badge>
|
<x-badge tone="cyan">Reverb</x-badge>
|
||||||
</x-slot:actions>
|
</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">
|
<svg viewBox="0 0 300 80" class="h-24 w-full" preserveAspectRatio="none" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="spark" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="spark" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
|
@ -50,11 +40,17 @@
|
||||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<polyline points="{{ $area }}" fill="url(#spark)" stroke="none" />
|
<polyline :points="areaPoints" fill="url(#spark)" stroke="none" />
|
||||||
<polyline points="{{ $line }}" fill="none" stroke="currentColor" stroke-width="1.5" />
|
<polyline :points="linePoints" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
<p class="mt-2 font-mono text-[11px] text-ink-4">Mock-Serie — Live-Daten folgen über Reverb.</p>
|
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
||||||
<x-panel title="Ressourcen" subtitle="web-01">
|
<x-panel title="Ressourcen" subtitle="web-01">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue