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
HomeOS Bootstrap 2026-07-18 00:40:32 +02:00
parent 7d4c9c024e
commit af951d9f91
12 changed files with 222 additions and 1 deletions

View File

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

View File

@ -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;

View File

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

18
app/Models/Metric.php Normal file
View File

@ -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',
];
}

View File

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

View File

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

View File

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

19
package-lock.json generated
View File

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

View File

@ -14,6 +14,7 @@
"vite": "^8.0.0"
},
"dependencies": {
"chart.js": "^4.5.1",
"laravel-echo": "^2.4.0",
"pusher-js": "^8.5.0"
}

View File

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

View File

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

View File

@ -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();