Charts: Chart.js MQTT + host traffic on the Host page
- metrics table + Metric model; metrics:sample command (scheduled every minute, pruned to 24h) records MQTT throughput (a Redis counter the ingest jobs bump) and host CPU load + memory% from /proc. - Chart.js line-chart Alpine island (themed from CSS tokens, dual axis) on the Host page shows MQTT/min, CPU load and memory %. wire:ignore so the 10s health poll doesn't re-init it. Nav check 10/10 clean (0 console errors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
7d4c9c024e
commit
af951d9f91
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Metric;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/** Samples MQTT throughput (since the last sample) + host CPU load and memory. */
|
||||
class MetricsSampleCommand extends Command
|
||||
{
|
||||
protected $signature = 'metrics:sample';
|
||||
|
||||
protected $description = 'Sample MQTT + host metrics for the charts.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$messages = 0;
|
||||
try {
|
||||
$messages = (int) (Redis::connection()->getset('metrics:mqtt:count', 0) ?? 0);
|
||||
} catch (\Throwable) {
|
||||
// counter unavailable — record 0
|
||||
}
|
||||
|
||||
Metric::create([
|
||||
'sampled_at' => now(),
|
||||
'mqtt_messages' => $messages,
|
||||
'cpu_load' => $this->cpuLoad(),
|
||||
'mem_used_pct' => $this->memUsedPct(),
|
||||
]);
|
||||
|
||||
Metric::where('sampled_at', '<', now()->subDay())->delete();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function cpuLoad(): ?float
|
||||
{
|
||||
$raw = @file_get_contents('/proc/loadavg');
|
||||
|
||||
return $raw ? (float) explode(' ', $raw)[0] : null;
|
||||
}
|
||||
|
||||
private function memUsedPct(): ?int
|
||||
{
|
||||
$raw = @file_get_contents('/proc/meminfo');
|
||||
if (! $raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
preg_match('/MemTotal:\s+(\d+)/', $raw, $total);
|
||||
preg_match('/MemAvailable:\s+(\d+)/', $raw, $available);
|
||||
|
||||
if (empty($total[1]) || ! isset($available[1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) round((((int) $total[1] - (int) $available[1]) / (int) $total[1]) * 100);
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,12 @@ class IngestShellyMessage implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
\Illuminate\Support\Facades\Redis::connection()->incr('metrics:mqtt:count');
|
||||
} catch (\Throwable) {
|
||||
// metrics are best-effort
|
||||
}
|
||||
|
||||
$parsed = ShellyTopics::parseStatus($this->topic);
|
||||
if ($parsed === null) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Metric;
|
||||
use App\Services\SystemHealth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
|
@ -38,8 +39,26 @@ class Host extends Component
|
|||
return count(array_filter($this->services, fn ($s) => $s['state'] !== 'online'));
|
||||
}
|
||||
|
||||
public function chartConfig(): array
|
||||
{
|
||||
$metrics = Metric::orderByDesc('sampled_at')->limit(60)->get()->reverse()->values();
|
||||
|
||||
return [
|
||||
'labels' => $metrics->map(fn ($m) => $m->sampled_at->format('H:i'))->all(),
|
||||
'series' => [
|
||||
['key' => 'mqtt', 'label' => __('host.chart_mqtt'), 'color' => '--color-accent', 'axis' => 'y', 'data' => $metrics->pluck('mqtt_messages')->all()],
|
||||
['key' => 'cpu', 'label' => __('host.chart_cpu'), 'color' => '--color-online', 'axis' => 'y', 'data' => $metrics->pluck('cpu_load')->all()],
|
||||
['key' => 'mem', 'label' => __('host.chart_mem'), 'color' => '--color-warning', 'axis' => 'y1', 'data' => $metrics->pluck('mem_used_pct')->all()],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.host');
|
||||
return view('livewire.host', [
|
||||
'chart' => $this->chartConfig(),
|
||||
'hasMetrics' => Metric::exists(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Metric extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['sampled_at', 'mqtt_messages', 'cpu_load', 'mem_used_pct'];
|
||||
|
||||
protected $casts = [
|
||||
'sampled_at' => 'datetime',
|
||||
'cpu_load' => 'float',
|
||||
'mem_used_pct' => 'integer',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('metrics', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamp('sampled_at')->index();
|
||||
$table->unsignedInteger('mqtt_messages')->default(0); // messages since last sample
|
||||
$table->float('cpu_load')->nullable(); // 1-min load average
|
||||
$table->unsignedTinyInteger('mem_used_pct')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('metrics');
|
||||
}
|
||||
};
|
||||
|
|
@ -21,6 +21,12 @@ return [
|
|||
'refresh' => 'Aktualisieren',
|
||||
'checked_at' => 'Geprüft um :time',
|
||||
|
||||
'chart_title' => 'Auslastung (24 h)',
|
||||
'chart_mqtt' => 'MQTT/Min',
|
||||
'chart_cpu' => 'CPU-Last',
|
||||
'chart_mem' => 'Speicher %',
|
||||
'chart_empty' => 'Noch keine Messwerte — werden jede Minute erfasst.',
|
||||
|
||||
'system_title' => 'System',
|
||||
'stack_framework' => 'Framework',
|
||||
'stack_database' => 'Datenbank',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ return [
|
|||
'refresh' => 'Refresh',
|
||||
'checked_at' => 'Checked at :time',
|
||||
|
||||
'chart_title' => 'Load (24 h)',
|
||||
'chart_mqtt' => 'MQTT/min',
|
||||
'chart_cpu' => 'CPU load',
|
||||
'chart_mem' => 'Memory %',
|
||||
'chart_empty' => 'No samples yet — collected every minute.',
|
||||
|
||||
'system_title' => 'System',
|
||||
'stack_framework' => 'Framework',
|
||||
'stack_database' => 'Database',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.5.0"
|
||||
},
|
||||
|
|
@ -100,6 +101,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
|
|
@ -732,6 +739,18 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.5.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Themed line-chart island (Livewire bundles Alpine). Usage:
|
||||
// <div x-data="lineChart(@js($config))"><canvas x-ref="canvas"></canvas></div>
|
||||
// where $config = { labels: [...], series: [{ key, label, color, data, axis }], units: {...} }
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('lineChart', (config) => ({
|
||||
chart: null,
|
||||
init() {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const token = (name, fallback) => (css.getPropertyValue(name).trim() || fallback);
|
||||
const grid = token('--color-line', '#1C2846');
|
||||
const ink = token('--color-ink-3', '#5E6C8A');
|
||||
|
||||
this.chart = new Chart(this.$refs.canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: config.labels,
|
||||
datasets: config.series.map((s) => ({
|
||||
label: s.label,
|
||||
data: s.data,
|
||||
borderColor: token(s.color, '#4FC1FF'),
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
yAxisID: s.axis || 'y',
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { labels: { color: ink, boxWidth: 10, boxHeight: 10, usePointStyle: true } } },
|
||||
scales: {
|
||||
x: { ticks: { color: ink, maxTicksLimit: 6 }, grid: { color: grid } },
|
||||
y: { position: 'left', ticks: { color: ink }, grid: { color: grid }, beginAtZero: true },
|
||||
y1: { position: 'right', ticks: { color: ink }, grid: { drawOnChartArea: false }, beginAtZero: true, max: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
this.chart?.destroy();
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Reverb (Pusher protocol), proxied same-origin through nginx (/app, /apps).
|
||||
// The browser connects to the page's own host/port — works on localhost and over the LAN
|
||||
|
|
|
|||
|
|
@ -66,6 +66,16 @@
|
|||
</div>
|
||||
</x-panel>
|
||||
|
||||
<x-panel :title="__('host.chart_title')" class="reveal">
|
||||
@if ($hasMetrics)
|
||||
<div wire:ignore x-data="lineChart(@js($chart))" class="h-56 sm:h-64">
|
||||
<canvas x-ref="canvas"></canvas>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-[13px] text-ink-3 py-6 text-center">{{ __('host.chart_empty') }}</p>
|
||||
@endif
|
||||
</x-panel>
|
||||
|
||||
<x-panel :title="__('host.system_title')" class="reveal">
|
||||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
|
|
|
|||
|
|
@ -10,3 +10,6 @@ Artisan::command('inspire', function () {
|
|||
|
||||
// Presence sweep — "home" immediately on association, "away" after a debounce (in the command).
|
||||
Schedule::command('presence:poll')->everyMinute()->withoutOverlapping();
|
||||
|
||||
// Metrics sample for the charts (MQTT throughput + host CPU/memory).
|
||||
Schedule::command('metrics:sample')->everyMinute()->withoutOverlapping();
|
||||
|
|
|
|||
Loading…
Reference in New Issue