feat(server): add server metadata collection and display

Add ability to gather and display server system information including OS, architecture, kernel version, CPU count, memory, and uptime. Includes:
- New gatherServerMetadata() method to collect system details via remote commands
- New refreshServerMetadata() Livewire action with authorization and error handling
- Server Details UI section showing collected metadata with refresh capability
- Database migration to add server_metadata JSON column
- Comprehensive test suite for metadata collection and persistence
This commit is contained in:
Andras Bacsai 2026-03-11 16:21:05 +01:00
parent bd01d3a515
commit e52a49b5e9
5 changed files with 248 additions and 0 deletions

View file

@ -483,6 +483,22 @@ public function startHetznerServer()
}
}
public function refreshServerMetadata(): void
{
try {
$this->authorize('update', $this->server);
$result = $this->server->gatherServerMetadata();
if ($result) {
$this->server->refresh();
$this->dispatch('success', 'Server details refreshed.');
} else {
$this->dispatch('error', 'Could not fetch server details. Is the server reachable?');
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function submit()
{
try {

View file

@ -25,6 +25,7 @@
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Stringable;
use OpenApi\Attributes as OA;
@ -231,6 +232,7 @@ public static function flushIdentityMap(): void
protected $casts = [
'proxy' => SchemalessAttributes::class,
'traefik_outdated_info' => 'array',
'server_metadata' => 'array',
'logdrain_axiom_api_key' => 'encrypted',
'logdrain_newrelic_license_key' => 'encrypted',
'delete_unused_volumes' => 'boolean',
@ -258,6 +260,7 @@ public static function flushIdentityMap(): void
'is_validating',
'detected_traefik_version',
'traefik_outdated_info',
'server_metadata',
];
protected $guarded = [];
@ -1074,6 +1077,55 @@ public function validateOS(): bool|Stringable
}
}
public function gatherServerMetadata(): ?array
{
if (! $this->isFunctional()) {
return null;
}
try {
$output = instant_remote_process([
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s',
], $this, false);
if (! $output) {
return null;
}
$sections = [];
$currentKey = null;
foreach (explode("\n", trim($output)) as $line) {
$line = trim($line);
if (preg_match('/^---(\w+)---$/', $line, $m)) {
$currentKey = $m[1];
} elseif ($currentKey) {
$sections[$currentKey] = $line;
}
}
$metadata = [
'os' => $sections['PRETTY_NAME'] ?? 'Unknown',
'arch' => $sections['ARCH'] ?? 'Unknown',
'kernel' => $sections['KERNEL'] ?? 'Unknown',
'cpus' => (int) ($sections['CPUS'] ?? 0),
'memory_bytes' => (int) ($sections['MEMORY'] ?? 0),
'uptime_since' => $sections['UPTIME_SINCE'] ?? null,
'collected_at' => now()->toIso8601String(),
];
$this->update(['server_metadata' => $metadata]);
return $metadata;
} catch (\Throwable $e) {
Log::debug('Failed to gather server metadata', [
'server_id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}
public function isTerminalEnabled()
{
return $this->settings->is_terminal_enabled ?? false;

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('servers', 'server_metadata')) {
Schema::table('servers', function (Blueprint $table) {
$table->json('server_metadata')->nullable();
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('servers', 'server_metadata')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('server_metadata');
});
}
}
};

View file

@ -289,6 +289,58 @@ class="w-full input opacity-50 cursor-not-allowed"
</div>
</div>
</form>
@if ($server->isFunctional())
<div class="pt-6">
<div class="flex items-center gap-2 mb-3">
<h3>Server Details</h3>
@if ($server->server_metadata)
<button wire:click="refreshServerMetadata" wire:loading.attr="disabled"
wire:target="refreshServerMetadata" title="Refresh server details"
class="dark:hover:fill-white fill-black dark:fill-warning">
<svg wire:loading.remove wire:target="refreshServerMetadata" class="w-4 h-4"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
<svg wire:loading wire:target="refreshServerMetadata" class="w-4 h-4 animate-spin"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
@endif
</div>
@if ($server->server_metadata)
@php $meta = $server->server_metadata; @endphp
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm lg:grid-cols-3">
<div><span class="font-medium dark:text-neutral-400">OS:</span>
{{ $meta['os'] ?? 'N/A' }}</div>
<div><span class="font-medium dark:text-neutral-400">Arch:</span>
{{ $meta['arch'] ?? 'N/A' }}</div>
<div><span class="font-medium dark:text-neutral-400">Kernel:</span>
{{ $meta['kernel'] ?? 'N/A' }}</div>
<div><span class="font-medium dark:text-neutral-400">CPU Cores:</span>
{{ $meta['cpus'] ?? 'N/A' }}</div>
<div><span class="font-medium dark:text-neutral-400">RAM:</span>
{{ isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A' }}
</div>
<div><span class="font-medium dark:text-neutral-400">Up Since:</span>
{{ $meta['uptime_since'] ?? 'N/A' }}</div>
</div>
@if (isset($meta['collected_at']))
<p class="mt-2 text-xs dark:text-neutral-500">Last updated:
{{ \Carbon\Carbon::parse($meta['collected_at'])->diffForHumans() }}</p>
@endif
@else
<x-forms.button wire:click="refreshServerMetadata" canGate="update"
:canResource="$server">
<span wire:loading.remove wire:target="refreshServerMetadata">Fetch Server
Details</span>
<span wire:loading wire:target="refreshServerMetadata">Fetching...</span>
</x-forms.button>
@endif
</div>
@endif
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Hetzner Cloud</h3>

View file

@ -0,0 +1,96 @@
<?php
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = Team::factory()->create();
$user->teams()->attach($this->team);
$this->actingAs($user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
it('casts server_metadata as array', function () {
$metadata = [
'os' => 'Ubuntu 22.04.3 LTS',
'arch' => 'x86_64',
'kernel' => '5.15.0-91-generic',
'cpus' => 4,
'memory_bytes' => 8589934592,
'uptime_since' => '2024-01-15 10:30:00',
'collected_at' => now()->toIso8601String(),
];
$this->server->update(['server_metadata' => $metadata]);
$this->server->refresh();
expect($this->server->server_metadata)->toBeArray()
->and($this->server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS')
->and($this->server->server_metadata['cpus'])->toBe(4)
->and($this->server->server_metadata['memory_bytes'])->toBe(8589934592);
});
it('stores null server_metadata by default', function () {
expect($this->server->server_metadata)->toBeNull();
});
it('includes server_metadata in fillable', function () {
$this->server->fill(['server_metadata' => ['os' => 'Test']]);
expect($this->server->server_metadata)->toBe(['os' => 'Test']);
});
it('persists and retrieves full server metadata structure', function () {
$metadata = [
'os' => 'Debian GNU/Linux 12 (bookworm)',
'arch' => 'aarch64',
'kernel' => '6.1.0-17-arm64',
'cpus' => 8,
'memory_bytes' => 17179869184,
'uptime_since' => '2024-03-01 08:00:00',
'collected_at' => '2024-03-10T12:00:00+00:00',
];
$this->server->update(['server_metadata' => $metadata]);
$this->server->refresh();
expect($this->server->server_metadata)
->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at'])
->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)')
->and($this->server->server_metadata['arch'])->toBe('aarch64')
->and($this->server->server_metadata['cpus'])->toBe(8)
->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0);
});
it('returns null from gatherServerMetadata when server is not functional', function () {
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->refresh();
expect($this->server->gatherServerMetadata())->toBeNull();
});
it('can overwrite server_metadata with new values', function () {
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]);
$this->server->refresh();
expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04');
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]);
$this->server->refresh();
expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04')
->and($this->server->server_metadata['cpus'])->toBe(4);
});