Add “reachable” column for Client -> Wings connections for Nodes (#2200)

This commit is contained in:
Lance Pioch
2026-02-12 17:06:38 -05:00
committed by GitHub
parent d43cb1d180
commit 8c475ed95f
6 changed files with 209 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\Nodes\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Nodes\NodeResource;
use App\Filament\Components\Tables\Columns\NodeClientHealthColumn;
use App\Filament\Components\Tables\Columns\NodeHealthColumn;
use App\Filament\Components\Tables\Filters\TagsFilter;
use App\Models\Node;
@@ -34,6 +35,7 @@ class ListNodes extends ListRecords
->searchable()
->hidden(),
NodeHealthColumn::make('health'),
NodeClientHealthColumn::make('reachable'),
TextColumn::make('name')
->label(trans('admin/node.table.name'))
->sortable()

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Components\Tables\Columns;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Columns\IconColumn;
use Illuminate\Support\Facades\Blade;
class NodeClientHealthColumn extends IconColumn
{
protected function setUp(): void
{
parent::setUp();
$this->label(trans('admin/node.table.reachable'));
$this->alignCenter();
}
public function toEmbeddedHtml(): string
{
$alignment = $this->getAlignment();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-icon',
'fi-inline' => $this->isInline(),
'fi-ta-icon-has-line-breaks' => $this->isListWithLineBreaks(),
'fi-wrapped' => $this->canWrap(),
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
])
->toHtml();
return Blade::render(<<<'BLADE'
<div <?= $attributes ?>>
@livewire('node-client-connectivity', ['node' => $record, 'lazy' => true])
</div>
BLADE, [
'attributes' => $attributes,
'record' => $this->getRecord(),
]);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Livewire;
use App\Enums\TablerIcon;
use App\Models\Node;
use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService;
use Filament\Support\Enums\IconSize;
use Filament\Tables\View\Components\Columns\IconColumnComponent\IconComponent;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\ComponentAttributeBag;
use Livewire\Attributes\Locked;
use Livewire\Component;
use function Filament\Support\generate_icon_html;
class NodeClientConnectivity extends Component
{
#[Locked]
public Node $node;
private GetUserPermissionsService $getUserPermissionsService;
private NodeJWTService $nodeJWTService;
public function boot(GetUserPermissionsService $getUserPermissionsService, NodeJWTService $nodeJWTService): void
{
$this->getUserPermissionsService = $getUserPermissionsService;
$this->nodeJWTService = $nodeJWTService;
}
public function render(): \Illuminate\Contracts\View\View
{
$httpUrl = $this->node->getConnectionAddress();
$wsUrl = null;
$wsToken = null;
$server = $this->node->servers()->first();
if ($server) {
$user = Auth::user();
$permissions = $this->getUserPermissionsService->handle($server, $user);
$wsToken = $this->nodeJWTService
->setExpiresAt(now()->addMinute()->toImmutable())
->setUser($user)
->setClaims([
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->handle($this->node, $user->id . $server->uuid)->toString();
$wsUrl = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $this->node->getConnectionAddress());
$wsUrl .= sprintf('/api/servers/%s/ws', $server->uuid);
}
return view('livewire.node-client-connectivity', [
'httpUrl' => $httpUrl,
'wsUrl' => $wsUrl,
'wsToken' => $wsToken,
'loadingIcon' => $this->makeIcon(TablerIcon::WorldQuestion, 'warning', 'Checking...'),
'offlineIcon' => $this->makeIcon(TablerIcon::WorldX, 'danger', 'Node is not reachable from your browser'),
'onlineIcon' => $this->makeIcon(TablerIcon::WorldCheck, 'success', 'Node is reachable'),
'warningIcon' => $this->makeIcon(TablerIcon::WorldExclamation, 'warning', 'Node is reachable, but WebSocket failed. Check reverse proxy config.'),
'onlineNoWsIcon' => $this->makeIcon(TablerIcon::WorldCheck, 'success', 'Node is reachable (WebSocket not tested — no servers)'),
]);
}
private function makeIcon(TablerIcon $icon, string $color, string $tooltip): string
{
return generate_icon_html($icon, attributes: (new ComponentAttributeBag())
->merge([
'x-tooltip' => '{
content: "' . $tooltip . '",
theme: $store.theme,
allowHTML: true,
placement: "bottom",
}',
'style' => 'color: var(--dark-text, var(--text))',
], escape: false)
->color(IconComponent::class, $color), size: IconSize::Large)
->toHtml();
}
public function placeholder(): string
{
return generate_icon_html(TablerIcon::WorldQuestion, attributes: (new ComponentAttributeBag())
->color(IconComponent::class, 'warning'), size: IconSize::Large)
->toHtml();
}
}

View File

@@ -255,6 +255,8 @@ class Node extends Model implements Validatable
/**
* Gets the servers associated with a node.
*
* @return HasMany<Server, $this>
*/
public function servers(): HasMany
{

View File

@@ -14,6 +14,7 @@ return [
],
'table' => [
'health' => 'Health',
'reachable' => 'Reachable',
'name' => 'Name',
'address' => 'Address',
'public' => 'Public',

View File

@@ -0,0 +1,67 @@
<div
x-data="{
status: 'loading',
async check() {
try {
await fetch('{{ $httpUrl }}', { mode: 'no-cors', signal: AbortSignal.timeout(5000) });
} catch (e) {
this.status = 'offline';
return;
}
@if ($wsUrl && $wsToken)
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error('timeout'));
}, 10000);
const ws = new WebSocket('{{ $wsUrl }}');
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error('ws_error'));
};
ws.onopen = () => {
ws.send(JSON.stringify({ event: 'auth', args: ['{{ $wsToken }}'] }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.event === 'auth success') {
clearTimeout(timeout);
ws.close();
resolve();
}
};
});
this.status = 'online';
} catch (e) {
this.status = 'warning';
}
@else
this.status = 'online-no-ws';
@endif
}
}"
x-init="check()"
>
<div x-show="status === 'loading'" x-cloak>
{!! $loadingIcon !!}
</div>
<div x-show="status === 'offline'" x-cloak>
{!! $offlineIcon !!}
</div>
<div x-show="status === 'online'" x-cloak>
{!! $onlineIcon !!}
</div>
<div x-show="status === 'warning'" x-cloak>
{!! $warningIcon !!}
</div>
<div x-show="status === 'online-no-ws'" x-cloak>
{!! $onlineNoWsIcon !!}
</div>
</div>