From af28ba8bbc89e6c91d0f39ff449f4bd63c2293cf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:53:54 +0100 Subject: [PATCH] feat(tables): sort tables by clicking a column header --- app/Livewire/Server/DockerImages.php | 55 +++++++++++ app/Livewire/Server/Resources.php | 47 ++++++++- app/Livewire/Tags/Show.php | 36 +++++++ app/Livewire/Team/AuditLog.php | 28 ++++++ app/Livewire/Team/Invitations.php | 43 ++++++++ resources/css/app.css | 49 +++++++++- .../components/table/sort-header.blade.php | 63 ++++++++++++ .../application/backup/index.blade.php | 42 ++++++-- .../application/deployment/index.blade.php | 3 +- .../views/livewire/project/index.blade.php | 43 ++++---- .../livewire/project/resource/index.blade.php | 41 ++++---- .../service/volume-backup/index.blade.php | 45 +++++++-- .../shared/environment-variable/all.blade.php | 3 +- .../views/livewire/project/show.blade.php | 44 +++++---- .../livewire/server/docker-images.blade.php | 18 ++-- .../views/livewire/server/resources.blade.php | 16 +-- resources/views/livewire/tags/show.blade.php | 72 ++++++++------ .../views/livewire/team/audit-log.blade.php | 14 ++- .../views/livewire/team/invitations.blade.php | 11 ++- .../livewire/team/member/index.blade.php | 39 +++++--- tests/Feature/AuditEventsTest.php | 39 ++++++++ .../Feature/DeploymentHistoryPayloadTest.php | 17 ++++ tests/Feature/ServerDockerImagesTest.php | 49 ++++++++++ tests/Feature/ServerResourcesSortTest.php | 97 +++++++++++++++++++ tests/Feature/TagsShowPageTest.php | 38 ++++++++ tests/Feature/TeamInvitationUiTest.php | 29 ++++++ .../Feature/TeamMemberTwoFactorStatusTest.php | 18 ++++ tests/v4/Browser/BackupSortTest.php | 4 + 28 files changed, 850 insertions(+), 153 deletions(-) create mode 100644 resources/views/components/table/sort-header.blade.php create mode 100644 tests/Feature/ServerResourcesSortTest.php diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php index 641a686851..f5909333db 100644 --- a/app/Livewire/Server/DockerImages.php +++ b/app/Livewire/Server/DockerImages.php @@ -32,6 +32,11 @@ class DockerImages extends Component public int $perPage = 10; + /** + * Column sort such as "size_desc". Empty keeps the order of `docker image ls` (newest first). + */ + public string $sort = ''; + public function updatedSearch(): void { $this->resetPage(); @@ -43,6 +48,32 @@ class DockerImages extends Component $this->resetPage(); } + public function setSort(string $sort): void + { + if (! array_key_exists(str($sort)->beforeLast('_')->value(), $this->sortColumns()) + || ! in_array(str($sort)->afterLast('_')->value(), ['asc', 'desc'], true)) { + return; + } + + $this->sort = $sort; + $this->resetPage(); + } + + /** + * @return array + */ + private function sortColumns(): array + { + return [ + 'repository' => fn (array $image) => $image['repository'].':'.$image['tag'], + 'tag' => fn (array $image) => $image['tag'], + 'id' => fn (array $image) => $image['short_id'], + 'created' => fn (array $image) => $image['created_at'] ?? 0, + 'size' => fn (array $image) => $image['size_bytes'] ?? 0, + 'used' => fn (array $image) => implode(', ', $image['containers']), + ]; + } + public function mount(string $server_uuid) { try { @@ -161,11 +192,27 @@ class DockerImages extends Component // removed by ID, Docker refuses with "image is referenced in multiple repositories". 'reference' => $isDangling ? $id : "{$repository}:{$tag}", 'size' => data_get($image, 'Size'), + 'size_bytes' => $this->sizeToBytes((string) data_get($image, 'Size')), 'created' => data_get($image, 'CreatedSince'), + // Docker prints "2024-05-01 10:00:00 +0000 UTC"; the trailing zone name breaks strtotime(). + 'created_at' => strtotime(str((string) data_get($image, 'CreatedAt'))->replaceMatches('/ [A-Z]+$/', '')->value()) ?: 0, 'containers' => $containersByImage[$id] ?? [], ]; } + /** + * Docker prints sizes with decimal units, for example "512kB", "52.5MB" or "1.2GB". + */ + private function sizeToBytes(string $size): int + { + if (! preg_match('/^([\d.]+)\s*([kKMGTP]?)B$/', trim($size), $matches)) { + return 0; + } + $exponent = strpos('KMGTP', strtoupper($matches[2])); + + return (int) round((float) $matches[1] * 1000 ** ($matches[2] === '' ? 0 : $exponent + 1)); + } + public function render() { $search = trim($this->search); @@ -175,6 +222,14 @@ class DockerImages extends Component ->contains($search, ignoreCase: true)); } + $column = $this->sortColumns()[str($this->sort)->beforeLast('_')->value()] ?? null; + if ($column) { + $images = $images->sortBy( + fn (array $image) => [$column($image), $image['reference']], + descending: str($this->sort)->endsWith('_desc'), + ); + } + $this->perPage = max(1, min(100, $this->perPage)); $lastPage = max(1, (int) ceil($images->count() / $this->perPage)); $page = max(1, min((int) $this->getPage(), $lastPage)); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 86f87cc13f..130d11eeea 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -22,6 +22,8 @@ class Resources extends Component public string $search = ''; + public string $sort = 'name_asc'; + public function updatedSearch(): void { $this->resetPage(); @@ -33,6 +35,38 @@ class Resources extends Component $this->resetPage(); } + public function setSort(string $sort): void + { + if (! preg_match('/^(\w+)_(asc|desc)$/', $sort, $matches) || ! array_key_exists($matches[1], $this->sortColumns())) { + return; + } + + $this->sort = $sort; + $this->resetPage(); + } + + /** + * @return array + */ + private function sortColumns(): array + { + if ($this->activeTab === 'managed') { + return [ + 'name' => fn ($resource): string => (string) data_get($resource, 'name'), + 'project' => fn ($resource): string => (string) data_get($resource->project(), 'name'), + 'environment' => fn ($resource): string => (string) data_get($resource, 'environment.name'), + 'type' => fn ($resource): string => (string) $resource->type(), + 'status' => fn ($resource): string => (string) data_get($resource, 'status'), + ]; + } + + return [ + 'name' => fn ($container): string => (string) data_get($container, 'Names'), + 'image' => fn ($container): string => (string) data_get($container, 'Image'), + 'status' => fn ($container): string => (string) data_get($container, 'State'), + ]; + } + public ?Server $server = null; public $parameters = []; @@ -115,6 +149,7 @@ class Resources extends Component try { if ($this->activeTab !== 'managed') { $this->search = ''; + $this->sort = 'name_asc'; $this->resetPage(); } $this->activeTab = 'managed'; @@ -128,6 +163,7 @@ class Resources extends Component { if ($this->activeTab !== 'unmanaged') { $this->search = ''; + $this->sort = 'name_asc'; $this->resetPage(); } $this->activeTab = 'unmanaged'; @@ -156,9 +192,14 @@ class Resources extends Component public function render() { - $resources = $this->activeTab === 'managed' - ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) - : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $columns = $this->sortColumns(); + [$column, $direction] = explode('_', $this->sort, 2) + [1 => 'asc']; + $sortValue = $columns[$column] ?? $columns['name']; + $resources = ($this->activeTab === 'managed' + ? $this->server->definedResources() + : collect($this->unmanagedContainers)) + ->sortBy($columns['name'], SORT_NATURAL | SORT_FLAG_CASE) + ->sortBy($sortValue, SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc'); $search = trim($this->search); if ($search !== '') { $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index 01450abe9a..e0627d418c 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -42,6 +42,10 @@ class Show extends Component #[Locked] public ?array $deploymentsPerTagPerServer = null; + /** Sort of the active deployments table, such as `resource-asc`; null keeps them grouped by server. */ + #[Locked] + public ?string $deploymentSort = null; + /** The "Connect to resources" modal on small screens, where the tags canvas is not shown. */ public bool $connectResourcesOpen = false; @@ -103,6 +107,37 @@ class Show extends Component } } + public function setDeploymentSort(string $sort): void + { + if (! in_array($sort, ['resource-asc', 'resource-desc', 'server-asc', 'server-desc', 'status-asc', 'status-desc'], true)) { + return; + } + + $this->deploymentSort = $sort; + } + + /** + * The active deployments as one list, in the order of the chosen table sort. + * + * @return Collection> + */ + private function activeDeployments(): Collection + { + $deployments = collect($this->deploymentsPerTagPerServer ?? [])->flatten(1); + if ($this->deploymentSort === null) { + return $deployments->values(); + } + + [$column, $direction] = explode('-', $this->deploymentSort, 2); + $field = ['resource' => 'application_name', 'server' => 'server_name', 'status' => 'status'][$column]; + $order = $direction === 'desc' ? -1 : 1; + + return $deployments + ->sort(fn (array $left, array $right): int => ($order * strcasecmp((string) $left[$field], (string) $right[$field])) + ?: $left['id'] <=> $right['id']) + ->values(); + } + public function redeployAll() { try { @@ -193,6 +228,7 @@ class Show extends Component ]; })->values()->toArray(), 'connectableResources' => $this->connectableResources(), + 'activeDeployments' => $this->activeDeployments(), ]); } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php index 9cf3ada8c8..88a6ae2800 100644 --- a/app/Livewire/Team/AuditLog.php +++ b/app/Livewire/Team/AuditLog.php @@ -5,6 +5,7 @@ namespace App\Livewire\Team; use App\Models\AuditEvent; use Illuminate\Contracts\View\View; use Illuminate\Support\Str; +use Livewire\Attributes\Locked; use Livewire\Component; use Livewire\WithPagination; @@ -20,6 +21,17 @@ class AuditLog extends Component public int $perPage = 25; + #[Locked] + public string $sort = 'time-desc'; + + /** @var array sort value prefix => audit_events column */ + private const SORT_COLUMNS = [ + 'time' => 'created_at', + 'actor' => 'actor_name', + 'activity' => 'description', + 'source' => 'source', + ]; + public function boot(): void { $team = currentTeam(); @@ -42,6 +54,18 @@ class AuditLog extends Component $this->resetPage(); } + public function setSort(string $sort): void + { + [$column, $direction] = array_pad(explode('-', $sort, 2), 2, null); + + if (! array_key_exists($column, self::SORT_COLUMNS) || ! in_array($direction, ['asc', 'desc'], true)) { + return; + } + + $this->sort = $sort; + $this->resetPage(); + } + public function updatedPerPage(): void { $this->perPage = max(10, min(100, $this->perPage)); @@ -67,6 +91,10 @@ class AuditLog extends Component $events = AuditEvent::query() ->visibleToTeam($teamId, $canViewInstanceEvents) ->filtered($search, $this->action, $this->source) + ->when($this->sort !== 'time-desc', function ($query): void { + [$column, $direction] = explode('-', $this->sort, 2); + $query->orderBy(self::SORT_COLUMNS[$column], $direction); + }) ->latestFirst() ->paginate($this->perPage); diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8550afd9e3..3de396bb13 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -2,10 +2,13 @@ namespace App\Livewire\Team; +use App\Enums\Role; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\Locked; use Livewire\Component; class Invitations extends Component @@ -14,6 +17,10 @@ class Invitations extends Component public $invitations; + /** Sort value such as `email-asc`; null keeps the loaded order. */ + #[Locked] + public ?string $sort = null; + protected $listeners = ['refreshInvitations']; public function deleteInvitation(int $invitation_id) @@ -43,6 +50,42 @@ class Invitations extends Component } } + public function setSort(string $sort): void + { + if (! in_array($sort, ['email-asc', 'email-desc', 'via-asc', 'via-desc', 'role-asc', 'role-desc'], true)) { + return; + } + + $this->sort = $sort; + } + + public function render() + { + return view('livewire.team.invitations', [ + 'sortedInvitations' => $this->sortedInvitations(), + ]); + } + + private function sortedInvitations(): Collection + { + $invitations = collect($this->invitations); + if ($this->sort === null) { + return $invitations; + } + + [$column, $direction] = explode('-', $this->sort, 2); + $sortValue = fn (TeamInvitation $invitation): int|string => $column === 'role' + ? (Role::tryFrom((string) $invitation->role)?->rank() ?? 0) + : strtolower((string) $invitation->{$column}); + + $order = $direction === 'desc' ? -1 : 1; + + return $invitations + ->sort(fn (TeamInvitation $left, TeamInvitation $right): int => ($order * ($sortValue($left) <=> $sortValue($right))) + ?: strcasecmp((string) $left->email, (string) $right->email)) + ->values(); + } + public function refreshInvitations() { $this->invitations = TeamInvitation::ownedByCurrentTeam()->get(); diff --git a/resources/css/app.css b/resources/css/app.css index 453feea16d..aae0abaf21 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3045,6 +3045,53 @@ input[type="search"]::-webkit-search-results-decoration { color: var(--color-fg-faint); } +.table-sort-header { + display: inline-flex; + min-width: 0; + max-width: 100%; + align-items: center; + justify-self: start; + gap: 0.25rem; + border-radius: 4px; + font: inherit; + color: inherit; + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: color 150ms; +} + +.table-sort-header:hover, +.table-sort-header.is-sorted { + color: #000; +} + +.dark .table-sort-header:hover, +.dark .table-sort-header.is-sorted { + color: var(--color-fg); +} + +.table-sort-header:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--color-accent, currentColor); +} + +.table-sort-header-icon { + width: 0.75rem; + height: 0.75rem; + flex-shrink: 0; + opacity: 0; + transition: opacity 150ms, transform 150ms; +} + +.table-sort-header:hover .table-sort-header-icon { + opacity: 0.5; +} + +.table-sort-header-icon.is-visible { + opacity: 1; +} + .data-table > .env-table-item { border-bottom: 1px solid var(--coollabs-fill); } @@ -3453,7 +3500,7 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 6rem; min-width: 50rem; } diff --git a/resources/views/components/table/sort-header.blade.php b/resources/views/components/table/sort-header.blade.php new file mode 100644 index 0000000000..12ce2327d3 --- /dev/null +++ b/resources/views/components/table/sort-header.blade.php @@ -0,0 +1,63 @@ +{{-- + A column header that sorts its table. The first click sorts by `first`, the next + click flips the direction. An arrow shows the active direction. + + Alpine tables set `model` (the sort variable in the enclosing x-data scope) and an + optional `then` expression that runs after the change (for example `page = 1`). + Livewire tables set `action` (a component method that takes the sort value) and + `current` (the current server-side sort value). + + @param string $label + @param string $asc sort value for ascending order + @param string $desc sort value for descending order + @param string $first 'asc' or 'desc': the direction of the first click +--}} +@props([ + 'label', + 'asc', + 'desc', + 'first' => 'asc', + 'model' => 'sortBy', + 'then' => null, + 'action' => null, + 'current' => null, +]) + +@php + $firstValue = $first === 'desc' ? $desc : $asc; + $otherValue = $first === 'desc' ? $asc : $desc; + $ariaBase = 'Sort by '.strtolower($label); +@endphp + +@if ($action) + @php + $direction = match ($current) { + $asc => 'asc', + $desc => 'desc', + default => null, + }; + $nextValue = $current === $firstValue ? $otherValue : $firstValue; + @endphp + +@else + +@endif diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index 14745c8e7c..727a647f7d 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -9,6 +9,15 @@ 'name' => strtolower($backup->targetName()), 'type' => strtolower($backup->targetType()), 'frequency' => strtolower($backup->frequency), + 'status' => match ($backup->latestExecution?->status) { + 'running' => 'in progress', + 'success' => 'success', + 'failed' => 'failed', + default => $backup->enabled ? 'waiting' : 'disabled', + }, + 's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->count() : -1, + 'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0), + 'executions' => $backup->executions_count, 'createdAt' => $backup->created_at?->timestamp ?? 0, ])->values()), filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge( @@ -30,11 +39,25 @@ const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter; return matchesSearch && matchesType; }); + const [column, direction] = this.sortBy.split('_'); + const sortValues = { + target: (backup) => backup.name, + type: (backup) => backup.type, + schedule: (backup) => backup.frequency, + status: (backup) => backup.status, + s3: (backup) => backup.s3, + lastrun: (backup) => backup.lastRunAt, + executions: (backup) => backup.executions, + }; + const sortValue = sortValues[column] || sortValues.target; + const order = direction === 'desc' ? -1 : 1; return filtered.sort((left, right) => { - if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name); if (this.sortBy === 'newest') return right.createdAt - left.createdAt; if (this.sortBy === 'oldest') return left.createdAt - right.createdAt; - return left.name.localeCompare(right.name); + const leftValue = sortValue(left); + const rightValue = sortValue(right); + return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) || + left.name.localeCompare(right.name); }); }, isVisible(id) { @@ -168,13 +191,14 @@ @if ($backups->isNotEmpty())
- Target - Type - Schedule - Status - S3 - Last run - Executions + + + + + + +
@foreach ($backups as $backup) diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index 9e72863d45..6fd10cf2bb 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -202,7 +202,8 @@ Source Commit @unless ($embedded) - Started + @endunless Duration Server diff --git a/resources/views/livewire/project/index.blade.php b/resources/views/livewire/project/index.blade.php index 82144aff7e..a46c3ad99b 100644 --- a/resources/views/livewire/project/index.blade.php +++ b/resources/views/livewire/project/index.blade.php @@ -142,10 +142,11 @@ class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
-
Project
-
Contents
-
Created
-
Description
+ + + +
@@ -233,11 +234,11 @@ label: 'Name Z–A' }, { - value: 'resources', + value: 'resources-desc', label: 'Most resources' }, { - value: 'environments', + value: 'environments-desc', label: 'Most environments' }, ], @@ -252,21 +253,23 @@ return !query || searchable.includes(query); }); - return projects.sort((first, second) => { - if (this.sortBy === 'name-desc') { - return second.name.localeCompare(first.name); - } - if (this.sortBy === 'resources') { - return second.resourceCount - first.resourceCount || - first.name.localeCompare(second.name); - } - if (this.sortBy === 'environments') { - return second.environmentCount - first.environmentCount || - first.name.localeCompare(second.name); - } + const [column, direction] = this.sortBy.split('-'); + const sortValues = { + name: (project) => project.name, + resources: (project) => Number(project.resourceCount), + environments: (project) => Number(project.environmentCount), + created: (project) => Date.parse(project.createdAt) || 0, + description: (project) => project.description || '', + }; + const sortValue = sortValues[column] || sortValues.name; + const order = direction === 'desc' ? -1 : 1; + const compare = (first, second) => typeof first === 'number' ? + first - second : + first.localeCompare(second); - return first.name.localeCompare(second.name); - }); + return projects.sort((first, second) => + order * compare(sortValue(first), sortValue(second)) || + first.name.localeCompare(second.name)); }, get totalPages() { return Math.max(1, Math.ceil(this.filteredProjects.length / this.pageSize)); diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index c15cc8d57a..5eb6759464 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -167,11 +167,11 @@ class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
-
Resource
-
Type
-
Status
-
Domain
-
Server
+ + + + +
Tags
@@ -412,11 +412,11 @@ label: 'Name Z–A' }, { - value: 'type', + value: 'type-asc', label: 'Resource type' }, { - value: 'status', + value: 'status-asc', label: 'Status' }, ], @@ -443,21 +443,20 @@ (!query || searchable.includes(query)); }); - return items.sort((first, second) => { - if (this.sortBy === 'name-desc') { - return second.name.localeCompare(first.name); - } - if (this.sortBy === 'type') { - return first.typeLabel.localeCompare(second.typeLabel) || - first.name.localeCompare(second.name); - } - if (this.sortBy === 'status') { - return this.statusLabel(first).localeCompare(this.statusLabel(second)) || - first.name.localeCompare(second.name); - } + const [column, direction] = this.sortBy.split('-'); + const sortValues = { + name: (item) => item.name, + type: (item) => item.typeLabel, + status: (item) => this.statusLabel(item), + domain: (item) => this.displayDomain(item.fqdn) || '', + server: (item) => item.destination?.server?.name || 'Unknown', + }; + const sortValue = sortValues[column] || sortValues.name; + const order = direction === 'desc' ? -1 : 1; - return first.name.localeCompare(second.name); - }); + return items.sort((first, second) => + order * (sortValue(first).localeCompare(sortValue(second)) || + first.name.localeCompare(second.name))); }, uniqueOptions(options) { return [...new Map(options diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 1140e074ec..13a95036dc 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -9,12 +9,28 @@ 'name' => strtolower($backup->targetName()), 'type' => strtolower($backup->targetType()), 'frequency' => strtolower($backup->frequency), + 'status' => match ($backup->latestExecution?->status) { + 'running' => 'in progress', + 'success' => 'success', + 'failed' => 'failed', + default => $backup->enabled ? 'waiting' : 'disabled', + }, + 's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1, + 'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0), 'createdAt' => $backup->created_at?->timestamp ?? 0, ])->concat($databaseBackups->map(fn ($backup) => [ 'id' => 'database:'.$backup->id, 'name' => strtolower($backup->database->human_name ?: $backup->database->name), 'type' => 'database', 'frequency' => strtolower($backup->frequency), + 'status' => match ($backup->latest_log?->status) { + 'running' => 'in progress', + 'success' => 'success', + 'failed' => 'failed', + default => $backup->enabled ? 'waiting' : 'disabled', + }, + 's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1, + 'lastRunAt' => $backup->latest_log?->finished_at?->timestamp ?? ($backup->latest_log?->status === 'running' ? now()->timestamp : 0), 'createdAt' => $backup->created_at?->timestamp ?? 0, ]))->values()), filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge( @@ -36,11 +52,24 @@ const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter; return matchesSearch && matchesType; }); + const [column, direction] = this.sortBy.split('_'); + const sortValues = { + target: (backup) => backup.name, + type: (backup) => backup.type, + schedule: (backup) => backup.frequency, + status: (backup) => backup.status, + s3: (backup) => backup.s3, + lastrun: (backup) => backup.lastRunAt, + }; + const sortValue = sortValues[column] || sortValues.target; + const order = direction === 'desc' ? -1 : 1; return filtered.sort((left, right) => { - if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name); if (this.sortBy === 'newest') return right.createdAt - left.createdAt; if (this.sortBy === 'oldest') return left.createdAt - right.createdAt; - return left.name.localeCompare(right.name); + const leftValue = sortValue(left); + const rightValue = sortValue(right); + return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) || + left.name.localeCompare(right.name); }); }, isVisible(id) { @@ -241,12 +270,12 @@
- Target - Type - Schedule - Status - S3 - Last run + + + + + + Actions
diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 8286bb3d79..f667f44ae3 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -222,7 +222,8 @@ wire:loading.class="pointer-events-none opacity-40 blur-[2px]" wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
- Name + Managed @if ($showEnvironmentType) Type diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php index 16f4017611..30e9a432d6 100644 --- a/resources/views/livewire/project/show.blade.php +++ b/resources/views/livewire/project/show.blade.php @@ -148,9 +148,11 @@ class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
-
Environment
-
Resources
-
Description
+ + +
@@ -230,7 +232,7 @@ label: 'Name Z–A' }, { - value: 'resources', + value: 'resources-desc', label: 'Most resources' }, ], @@ -245,21 +247,27 @@ return !query || searchable.includes(query); }); - return environments.sort((first, second) => { - if (this.sortBy === 'name-desc') { - return second.name.localeCompare(first.name); - } - if (this.sortBy === 'resources') { - return second.resourceCount - first.resourceCount || - first.name.localeCompare(second.name); - } - if (this.sortBy === 'name-asc') { - return first.name.localeCompare(second.name); - } + const [column, direction] = this.sortBy.split('-'); + const sortValues = { + name: (environment) => environment.name, + resources: (environment) => Number(environment.resourceCount), + description: (environment) => environment.description || '', + }; + const sortValue = sortValues[column]; - // Default: the server order (production, staging, development, then by name). - return 0; - }); + // Default: the server order (production, staging, development, then by name). + if (!sortValue) { + return environments; + } + + const order = direction === 'desc' ? -1 : 1; + const compare = (first, second) => typeof first === 'number' ? + first - second : + first.localeCompare(second); + + return environments.sort((first, second) => + order * compare(sortValue(first), sortValue(second)) || + first.name.localeCompare(second.name)); }, get totalPages() { return Math.max(1, Math.ceil(this.filteredEnvironments.length / this.pageSize)); diff --git a/resources/views/livewire/server/docker-images.blade.php b/resources/views/livewire/server/docker-images.blade.php index ac7eef69e1..147ea73c87 100644 --- a/resources/views/livewire/server/docker-images.blade.php +++ b/resources/views/livewire/server/docker-images.blade.php @@ -50,12 +50,18 @@ @else
- Repository - Tag - Image ID - Created - Size - Used by + + + + + + Actions
@foreach ($visibleImages as $image) diff --git a/resources/views/livewire/server/resources.blade.php b/resources/views/livewire/server/resources.blade.php index f0409cc498..7d82087e5d 100644 --- a/resources/views/livewire/server/resources.blade.php +++ b/resources/views/livewire/server/resources.blade.php @@ -56,11 +56,11 @@ @if ($resources->total() > 0)
- Name - Project - Environment - Type - Status + + + + +
@foreach ($resources as $resource) @php($resourceStatus = (string) data_get($resource, 'status', 'unknown')) @@ -109,9 +109,9 @@ @if ($resources->total() > 0)
- Name - Image - Status + + + Actions
@foreach ($resources as $resource) diff --git a/resources/views/livewire/tags/show.blade.php b/resources/views/livewire/tags/show.blade.php index 69f7be3bbe..47047e45c3 100644 --- a/resources/views/livewire/tags/show.blade.php +++ b/resources/views/livewire/tags/show.blade.php @@ -126,10 +126,12 @@ class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
-
Tag
-
Resources
-
Applications
-
Services
+ + + +