From af951d9f91e86d36f9d66bb030682430770894a4 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 00:40:32 +0200 Subject: [PATCH] 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 --- app/Console/Commands/MetricsSampleCommand.php | 60 +++++++++++++++++++ app/Jobs/IngestShellyMessage.php | 6 ++ app/Livewire/Host.php | 21 ++++++- app/Models/Metric.php | 18 ++++++ ...2026_07_18_020001_create_metrics_table.php | 24 ++++++++ lang/de/host.php | 6 ++ lang/en/host.php | 6 ++ package-lock.json | 19 ++++++ package.json | 1 + resources/js/app.js | 49 +++++++++++++++ resources/views/livewire/host.blade.php | 10 ++++ routes/console.php | 3 + 12 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/MetricsSampleCommand.php create mode 100644 app/Models/Metric.php create mode 100644 database/migrations/2026_07_18_020001_create_metrics_table.php diff --git a/app/Console/Commands/MetricsSampleCommand.php b/app/Console/Commands/MetricsSampleCommand.php new file mode 100644 index 0000000..e5cfe53 --- /dev/null +++ b/app/Console/Commands/MetricsSampleCommand.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 91949f8..9acf9f4 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -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; diff --git a/app/Livewire/Host.php b/app/Livewire/Host.php index 26234f7..fefc228 100644 --- a/app/Livewire/Host.php +++ b/app/Livewire/Host.php @@ -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(), + ]); } } + diff --git a/app/Models/Metric.php b/app/Models/Metric.php new file mode 100644 index 0000000..60c507b --- /dev/null +++ b/app/Models/Metric.php @@ -0,0 +1,18 @@ + 'datetime', + 'cpu_load' => 'float', + 'mem_used_pct' => 'integer', + ]; +} diff --git a/database/migrations/2026_07_18_020001_create_metrics_table.php b/database/migrations/2026_07_18_020001_create_metrics_table.php new file mode 100644 index 0000000..84242ee --- /dev/null +++ b/database/migrations/2026_07_18_020001_create_metrics_table.php @@ -0,0 +1,24 @@ +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'); + } +}; diff --git a/lang/de/host.php b/lang/de/host.php index 0bf0f72..750031b 100644 --- a/lang/de/host.php +++ b/lang/de/host.php @@ -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', diff --git a/lang/en/host.php b/lang/en/host.php index 67d508c..4c2423d 100644 --- a/lang/en/host.php +++ b/lang/en/host.php @@ -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', diff --git a/package-lock.json b/package-lock.json index c16a846..c8f8e95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 71fb00e..80569a2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "vite": "^8.0.0" }, "dependencies": { + "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", "pusher-js": "^8.5.0" } diff --git a/resources/js/app.js b/resources/js/app.js index c0e76d0..a0a45a3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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: +//
+// 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 diff --git a/resources/views/livewire/host.blade.php b/resources/views/livewire/host.blade.php index d539249..203a794 100644 --- a/resources/views/livewire/host.blade.php +++ b/resources/views/livewire/host.blade.php @@ -66,6 +66,16 @@ + + @if ($hasMetrics) +
+ +
+ @else +

{{ __('host.chart_empty') }}

+ @endif +
+
diff --git a/routes/console.php b/routes/console.php index 77ccfca..eda4d86 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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();