mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(dashboard): sort projects and servers by clicking a column header
This commit is contained in:
@@ -8,18 +8,32 @@ use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\TeamInvitation;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
use RemembersCanvasView;
|
||||
|
||||
/** The dashboard shows only the first projects and servers of the sorted lists. */
|
||||
public const ITEM_LIMIT = 8;
|
||||
|
||||
private const PROJECT_SORT_COLUMNS = ['name', 'resources', 'description'];
|
||||
|
||||
private const SERVER_SORT_COLUMNS = ['name', 'description', 'status'];
|
||||
|
||||
public Collection $projects;
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
public Collection $privateKeys;
|
||||
|
||||
#[Locked]
|
||||
public string $projectSort = 'name-asc';
|
||||
|
||||
#[Locked]
|
||||
public string $serverSort = 'name-asc';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if ($this->prefersCanvasView('dashboard')) {
|
||||
@@ -50,11 +64,107 @@ class Dashboard extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
$dashboardProjects = $this->sortedProjects()->take(self::ITEM_LIMIT);
|
||||
$dashboardServers = $this->sortedServers()->take(self::ITEM_LIMIT);
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'pendingInvitations' => $this->pendingInvitations(),
|
||||
'dashboardProjects' => $dashboardProjects,
|
||||
'dashboardServers' => $dashboardServers,
|
||||
'projectResourceCounts' => $dashboardProjects->mapWithKeys(fn (Project $project) => [$project->id => $this->projectResourceCount($project)]),
|
||||
'serverStatuses' => $dashboardServers->mapWithKeys(fn (Server $server) => [$server->id => $this->serverStatus($server)]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function setProjectSort(string $sort): void
|
||||
{
|
||||
if ($this->isValidSort($sort, self::PROJECT_SORT_COLUMNS)) {
|
||||
$this->projectSort = $sort;
|
||||
}
|
||||
}
|
||||
|
||||
public function setServerSort(string $sort): void
|
||||
{
|
||||
if ($this->isValidSort($sort, self::SERVER_SORT_COLUMNS)) {
|
||||
$this->serverSort = $sort;
|
||||
}
|
||||
}
|
||||
|
||||
private function projectResourceCount(Project $project): int
|
||||
{
|
||||
return $project->applications_count + $project->services_count + $project->postgresqls_count
|
||||
+ $project->redis_count + $project->keydbs_count + $project->dragonflies_count
|
||||
+ $project->clickhouses_count + $project->mongodbs_count + $project->mysqls_count
|
||||
+ $project->mariadbs_count + $project->sqlites_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: 'success'|'warning'|'error'} label and badge type
|
||||
*/
|
||||
private function serverStatus(Server $server): array
|
||||
{
|
||||
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
|
||||
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
|
||||
|
||||
return match (true) {
|
||||
$server->settings->force_disabled => ['Disabled', 'error'],
|
||||
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
|
||||
! $server->settings->is_reachable => ['Unreachable', 'error'],
|
||||
! $server->settings->is_usable => ['Not ready', 'warning'],
|
||||
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
|
||||
default => ['Ready', 'success'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
private function sortedProjects(): Collection
|
||||
{
|
||||
return $this->sortItems($this->projects, $this->projectSort, fn (Project $project, string $column): int|string => match ($column) {
|
||||
'resources' => $this->projectResourceCount($project),
|
||||
'description' => (string) $project->description,
|
||||
default => $project->name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Server>
|
||||
*/
|
||||
private function sortedServers(): Collection
|
||||
{
|
||||
return $this->sortItems($this->servers, $this->serverSort, fn (Server $server, string $column): string => match ($column) {
|
||||
'description' => (string) $server->description,
|
||||
'status' => $this->serverStatus($server)[0],
|
||||
default => $server->name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts by the column of a `column-direction` sort value, with the name as tie-breaker.
|
||||
*
|
||||
* @param callable(mixed, string): (int|string) $value
|
||||
*/
|
||||
private function sortItems(Collection $items, string $sort, callable $value): Collection
|
||||
{
|
||||
[$column, $direction] = explode('-', $sort, 2);
|
||||
|
||||
return $items
|
||||
->sortBy(fn ($item) => $item->name, SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->sortBy(fn ($item) => $value($item, $column), SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function isValidSort(string $sort, array $columns): bool
|
||||
{
|
||||
[$column, $direction] = array_pad(explode('-', $sort, 2), 2, null);
|
||||
|
||||
return in_array($column, $columns, true) && in_array($direction, ['asc', 'desc'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unexpired invitations for the user's email to teams the user has not joined yet.
|
||||
*
|
||||
|
||||
@@ -8,36 +8,8 @@
|
||||
@endif
|
||||
|
||||
@php
|
||||
$dashboardItemLimit = 8;
|
||||
$dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
|
||||
$dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
|
||||
$dashboardItemLimit = \App\Livewire\Dashboard::ITEM_LIMIT;
|
||||
$hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled());
|
||||
$projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([
|
||||
$project->applications_count,
|
||||
$project->services_count,
|
||||
$project->postgresqls_count,
|
||||
$project->redis_count,
|
||||
$project->keydbs_count,
|
||||
$project->dragonflies_count,
|
||||
$project->clickhouses_count,
|
||||
$project->mongodbs_count,
|
||||
$project->mysqls_count,
|
||||
$project->mariadbs_count,
|
||||
$project->sqlites_count,
|
||||
])->sum()]);
|
||||
$serverStatuses = $dashboardServers->mapWithKeys(function ($server) {
|
||||
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
|
||||
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
|
||||
|
||||
return [$server->id => match (true) {
|
||||
$server->settings->force_disabled => ['Disabled', 'error'],
|
||||
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
|
||||
! $server->settings->is_reachable => ['Unreachable', 'error'],
|
||||
! $server->settings->is_usable => ['Not ready', 'warning'],
|
||||
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
|
||||
default => ['Ready', 'success'],
|
||||
}];
|
||||
});
|
||||
$iconButton = 'card-icon-button flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black';
|
||||
// Same look as $iconButton, sized to the 20px section title it sits next to.
|
||||
$titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
|
||||
@@ -190,9 +162,14 @@
|
||||
|
||||
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableHead }}">
|
||||
<div>Project</div>
|
||||
<div>Contents</div>
|
||||
<div class="hidden @2xl:block">Description</div>
|
||||
<x-table.sort-header label="Project" asc="name-asc" desc="name-desc"
|
||||
action="setProjectSort" :current="$projectSort" />
|
||||
<x-table.sort-header label="Contents" asc="resources-asc" desc="resources-desc" first="desc"
|
||||
action="setProjectSort" :current="$projectSort" title="Sort by resource count" />
|
||||
<div class="hidden @2xl:block">
|
||||
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc"
|
||||
action="setProjectSort" :current="$projectSort" />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
@foreach ($dashboardProjects as $project)
|
||||
@@ -369,12 +346,17 @@
|
||||
@endphp
|
||||
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
|
||||
<div class="grid {{ $serverTableColumns }} gap-4 @2xl:min-w-[480px] {{ $tableHead }}">
|
||||
<div>Server</div>
|
||||
<div class="hidden @2xl:block">Description</div>
|
||||
<x-table.sort-header label="Server" asc="name-asc" desc="name-desc"
|
||||
action="setServerSort" :current="$serverSort" />
|
||||
<div class="hidden @2xl:block">
|
||||
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc"
|
||||
action="setServerSort" :current="$serverSort" />
|
||||
</div>
|
||||
@if ($showServerUsage)
|
||||
<div class="hidden @xl:block">Usage</div>
|
||||
@endif
|
||||
<div>Status</div>
|
||||
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc"
|
||||
action="setServerSort" :current="$serverSort" />
|
||||
</div>
|
||||
@foreach ($dashboardServers as $server)
|
||||
@php
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('sorts all projects before it limits the dashboard list', function () {
|
||||
foreach (range(1, Dashboard::ITEM_LIMIT + 1) as $number) {
|
||||
Project::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => sprintf('sort-project-%02d', $number),
|
||||
'description' => 'description '.(10 - $number),
|
||||
]);
|
||||
}
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertDontSee('sort-project-09')
|
||||
->call('setProjectSort', 'name-desc')
|
||||
->assertSet('projectSort', 'name-desc')
|
||||
->assertSeeInOrder(['sort-project-09', 'sort-project-08', 'sort-project-02'])
|
||||
->assertDontSee('sort-project-01')
|
||||
->call('setProjectSort', 'description-asc')
|
||||
->assertSeeInOrder(['sort-project-09', 'sort-project-08'])
|
||||
->call('setProjectSort', 'environments-desc')
|
||||
->assertSet('projectSort', 'description-asc');
|
||||
});
|
||||
|
||||
it('sorts servers by name and status', function () {
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$servers = collect(['alpha-host', 'bravo-host', 'charlie-host'])->map(fn (string $name) => Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => $name,
|
||||
]));
|
||||
$servers[1]->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
$servers[2]->settings->update(['force_disabled' => true]);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSeeInOrder(['alpha-host', 'bravo-host', 'charlie-host'])
|
||||
->call('setServerSort', 'name-desc')
|
||||
->assertSeeInOrder(['charlie-host', 'bravo-host', 'alpha-host'])
|
||||
->call('setServerSort', 'status-asc')
|
||||
->assertSeeHtml('aria-label="Sort by status, sorted ascending"')
|
||||
// Disabled, Ready, Unavailable
|
||||
->assertSeeInOrder(['charlie-host', 'bravo-host', 'alpha-host'])
|
||||
->call('setServerSort', 'status')
|
||||
->assertSet('serverSort', 'status-asc');
|
||||
});
|
||||
Reference in New Issue
Block a user