diff --git a/app/Console/Commands/SampleMetrics.php b/app/Console/Commands/SampleMetrics.php
new file mode 100644
index 0000000..948fa13
--- /dev/null
+++ b/app/Console/Commands/SampleMetrics.php
@@ -0,0 +1,52 @@
+get(['id']) as $server) {
+ $m = Cache::get("metrics:latest:{$server->id}");
+ if (! is_array($m)) {
+ continue;
+ }
+ $rows[] = [
+ 'server_id' => $server->id,
+ // Percentages — clamp to 0..100 so a stray reading can never plot outside the chart.
+ 'cpu' => min(100, max(0, (int) ($m['cpu'] ?? 0))),
+ 'mem' => min(100, max(0, (int) ($m['mem'] ?? 0))),
+ 'disk' => min(100, max(0, (int) ($m['disk'] ?? 0))),
+ 'load' => round((float) ($m['load'] ?? 0), 2),
+ 'sampled_at' => $now,
+ ];
+ }
+
+ if ($rows !== []) {
+ MetricSample::insert($rows);
+ }
+
+ $retention = max(1, (int) $this->option('retention'));
+ MetricSample::where('sampled_at', '<', now()->subDays($retention))->delete();
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php
index ea43646..9a4d659 100644
--- a/app/Livewire/Servers/Show.php
+++ b/app/Livewire/Servers/Show.php
@@ -8,6 +8,7 @@ use App\Services\Fail2banService;
use App\Services\FirewallService;
use App\Services\FleetService;
use App\Services\HardeningService;
+use App\Services\MetricHistory;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use App\Support\Os\OsDetector;
@@ -56,6 +57,12 @@ class Show extends Component
public array $sshKeys = [];
+ /** Selected time window for the metric-history graph (one of HISTORY_RANGES). */
+ public string $historyRange = '24h';
+
+ /** History-graph range key → window in seconds. */
+ private const HISTORY_RANGES = ['1h' => 3600, '24h' => 86400, '7d' => 604800, '30d' => 2592000];
+
/**
* Pull a live snapshot over SSH and persist identity/metrics onto the row.
* On any failure (no credential, unreachable) the page renders an offline
@@ -532,8 +539,32 @@ class Show extends Component
]);
}
+ /** Switch the history-graph time window (clamped to the allowed range keys). */
+ public function setRange(string $key): void
+ {
+ if (isset(self::HISTORY_RANGES[$key])) {
+ $this->historyRange = $key;
+ }
+ }
+
+ /** historyRange is a public property the client can set directly — clamp any unknown value. */
+ public function updatedHistoryRange(string $value): void
+ {
+ if (! isset(self::HISTORY_RANGES[$value])) {
+ $this->historyRange = '24h';
+ }
+ }
+
+ /** Bucketed cpu/mem/disk history for the selected range, for the detail-page chart. */
+ private function historySeries(): array
+ {
+ $window = self::HISTORY_RANGES[$this->historyRange] ?? self::HISTORY_RANGES['24h'];
+
+ return app(MetricHistory::class)->series($this->server, $window, 80);
+ }
+
public function render()
{
- return view('livewire.servers.show')->title($this->title());
+ return view('livewire.servers.show', ['history' => $this->historySeries()])->title($this->title());
}
}
diff --git a/app/Models/MetricSample.php b/app/Models/MetricSample.php
new file mode 100644
index 0000000..4161c4f
--- /dev/null
+++ b/app/Models/MetricSample.php
@@ -0,0 +1,31 @@
+ 'integer',
+ 'mem' => 'integer',
+ 'disk' => 'integer',
+ 'load' => 'float',
+ 'sampled_at' => 'datetime',
+ ];
+
+ public function server(): BelongsTo
+ {
+ return $this->belongsTo(Server::class);
+ }
+}
diff --git a/app/Services/MetricHistory.php b/app/Services/MetricHistory.php
new file mode 100644
index 0000000..9fb48f2
--- /dev/null
+++ b/app/Services/MetricHistory.php
@@ -0,0 +1,64 @@
+}
+ */
+ public function series(Server $server, int $windowSeconds, int $buckets = 80): array
+ {
+ $windowSeconds = max(60, $windowSeconds);
+ $buckets = max(1, $buckets);
+ $now = time();
+ $from = $now - $windowSeconds;
+ $width = $windowSeconds / $buckets;
+
+ $sum = array_fill(0, $buckets, ['cpu' => 0, 'mem' => 0, 'disk' => 0, 'load' => 0.0, 'n' => 0]);
+
+ $rows = MetricSample::query()
+ ->where('server_id', $server->id)
+ ->where('sampled_at', '>=', now()->subSeconds($windowSeconds))
+ ->orderBy('sampled_at')
+ ->get(['cpu', 'mem', 'disk', 'load', 'sampled_at']);
+
+ foreach ($rows as $r) {
+ $idx = (int) floor(($r->sampled_at->getTimestamp() - $from) / $width);
+ if ($idx < 0) {
+ continue;
+ }
+ if ($idx >= $buckets) {
+ $idx = $buckets - 1; // a sample exactly on the right edge belongs to the last bucket
+ }
+ $sum[$idx]['cpu'] += (int) $r->cpu;
+ $sum[$idx]['mem'] += (int) $r->mem;
+ $sum[$idx]['disk'] += (int) $r->disk;
+ $sum[$idx]['load'] += (float) $r->load;
+ $sum[$idx]['n']++;
+ }
+
+ $points = [];
+ for ($i = 0; $i < $buckets; $i++) {
+ $n = $sum[$i]['n'];
+ $points[] = [
+ 't' => (int) round($from + ($i + 0.5) * $width),
+ 'cpu' => $n ? (int) round($sum[$i]['cpu'] / $n) : null,
+ 'mem' => $n ? (int) round($sum[$i]['mem'] / $n) : null,
+ 'disk' => $n ? (int) round($sum[$i]['disk'] / $n) : null,
+ 'load' => $n ? round($sum[$i]['load'] / $n, 2) : null,
+ ];
+ }
+
+ return ['window' => $windowSeconds, 'points' => $points];
+ }
+}
diff --git a/database/migrations/2026_06_25_000001_create_metric_samples_table.php b/database/migrations/2026_06_25_000001_create_metric_samples_table.php
new file mode 100644
index 0000000..7ae85ab
--- /dev/null
+++ b/database/migrations/2026_06_25_000001_create_metric_samples_table.php
@@ -0,0 +1,27 @@
+id();
+ $table->foreignId('server_id')->constrained()->cascadeOnDelete();
+ $table->unsignedTinyInteger('cpu')->default(0);
+ $table->unsignedTinyInteger('mem')->default(0);
+ $table->unsignedTinyInteger('disk')->default(0);
+ $table->decimal('load', 6, 2)->default(0);
+ $table->timestamp('sampled_at')->index();
+ $table->index(['server_id', 'sampled_at']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('metric_samples');
+ }
+};
diff --git a/lang/de/servers.php b/lang/de/servers.php
index b155d7d..124e800 100644
--- a/lang/de/servers.php
+++ b/lang/de/servers.php
@@ -59,6 +59,9 @@ return [
// ── Show: specifications ──────────────────────────────────────────────
'specs_title' => 'Spezifikationen',
'specs_subtitle' => 'Hardware-Profil',
+ 'history_title' => 'Verlauf',
+ 'history_subtitle' => 'CPU · RAM · Disk über Zeit',
+ 'history_empty' => 'Noch keine Verlaufsdaten — wird ab jetzt jede Minute gesammelt.',
'spec_arch' => 'Architektur',
'spec_kernel' => 'Kernel',
'spec_virt' => 'Virtualisierung',
diff --git a/lang/en/servers.php b/lang/en/servers.php
index ac96528..1f732b8 100644
--- a/lang/en/servers.php
+++ b/lang/en/servers.php
@@ -59,6 +59,9 @@ return [
// ── Show: specifications ──────────────────────────────────────────────
'specs_title' => 'Specifications',
'specs_subtitle' => 'Hardware profile',
+ 'history_title' => 'History',
+ 'history_subtitle' => 'CPU · RAM · disk over time',
+ 'history_empty' => 'No history yet — collected every minute from now on.',
'spec_arch' => 'Architecture',
'spec_kernel' => 'Kernel',
'spec_virt' => 'Virtualization',
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php
index 080dca8..fe3f856 100644
--- a/resources/views/livewire/servers/show.blade.php
+++ b/resources/views/livewire/servers/show.blade.php
@@ -162,6 +162,81 @@
@endforeach
+ {{-- Metric history graph (persisted samples, selectable range) --}}
+ @php
+ $pts = $history['points'] ?? [];
+ $np = count($pts);
+ // SVG path with gaps: start a new sub-path (M) after any null bucket so empty windows
+ // show a break instead of a fabricated straight line.
+ $histPath = function (string $key) use ($pts, $np) {
+ $d = '';
+ $pen = false;
+ foreach ($pts as $i => $p) {
+ $v = $p[$key] ?? null;
+ if ($v === null) {
+ $pen = false;
+
+ continue;
+ }
+ $x = $np > 1 ? round($i / ($np - 1) * 300, 2) : 0;
+ $y = round(100 - ($v / 100) * 100, 2);
+ $d .= ($pen ? ' L' : ' M').$x.','.$y;
+ $pen = true;
+ }
+
+ return trim($d);
+ };
+ $hasHistory = collect($pts)->contains(fn ($p) => ($p['cpu'] ?? null) !== null);
+ $histRanges = ['1h' => '1 h', '24h' => '24 h', '7d' => '7 d', '30d' => '30 d'];
+ @endphp
+ {{ __('servers.history_empty') }}