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:
parent
bd01d3a515
commit
e52a49b5e9
5 changed files with 248 additions and 0 deletions
|
|
@ -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()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Stringable;
|
use Illuminate\Support\Stringable;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
@ -231,6 +232,7 @@ public static function flushIdentityMap(): void
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'proxy' => SchemalessAttributes::class,
|
'proxy' => SchemalessAttributes::class,
|
||||||
'traefik_outdated_info' => 'array',
|
'traefik_outdated_info' => 'array',
|
||||||
|
'server_metadata' => 'array',
|
||||||
'logdrain_axiom_api_key' => 'encrypted',
|
'logdrain_axiom_api_key' => 'encrypted',
|
||||||
'logdrain_newrelic_license_key' => 'encrypted',
|
'logdrain_newrelic_license_key' => 'encrypted',
|
||||||
'delete_unused_volumes' => 'boolean',
|
'delete_unused_volumes' => 'boolean',
|
||||||
|
|
@ -258,6 +260,7 @@ public static function flushIdentityMap(): void
|
||||||
'is_validating',
|
'is_validating',
|
||||||
'detected_traefik_version',
|
'detected_traefik_version',
|
||||||
'traefik_outdated_info',
|
'traefik_outdated_info',
|
||||||
|
'server_metadata',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $guarded = [];
|
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()
|
public function isTerminalEnabled()
|
||||||
{
|
{
|
||||||
return $this->settings->is_terminal_enabled ?? false;
|
return $this->settings->is_terminal_enabled ?? false;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -289,6 +289,58 @@ class="w-full input opacity-50 cursor-not-allowed"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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())
|
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||||
<div class="pt-6">
|
<div class="pt-6">
|
||||||
<h3>Link to Hetzner Cloud</h3>
|
<h3>Link to Hetzner Cloud</h3>
|
||||||
|
|
|
||||||
96
tests/Feature/ServerMetadataTest.php
Normal file
96
tests/Feature/ServerMetadataTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue