From 2a5197fb30365c7100e0ca5998c60b33fe6f1c76 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:37:30 +0200 Subject: [PATCH] feat(ui): add shared table controls and multi-select filters Introduce x-table toolbar/search/filter/sort/loading components and multi-select deployment history filters with OR within groups and AND across groups. Add live database/service status for breadcrumbs, shared variables layout controls, logs viewer toolbar polish, and infrastructure list filter consistency. Document patterns in UI_REDESIGN.md and cover with feature tests. --- UI_REDESIGN.md | 45 +++++ .../Project/Application/Deployment/Index.php | 68 +++++--- app/Livewire/Project/Application/Heading.php | 8 +- app/Livewire/Project/Application/Status.php | 1 + app/Livewire/Project/Database/Status.php | 31 ++++ app/Livewire/Project/Service/Status.php | 32 ++++ app/Models/Application.php | 59 ++++--- resources/css/app.css | 56 +++++- .../components/dashboard/navbar.blade.php | 34 +--- .../views/components/server/sidebar.blade.php | 10 +- .../server/status-summary.blade.php | 10 +- .../shared-variables/editor.blade.php | 5 +- .../shared-variables/layout.blade.php | 36 ++++ .../shared-variables/view-controls.blade.php | 31 ++++ .../views/components/table/filter.blade.php | 24 +++ .../views/components/table/loading.blade.php | 6 + .../views/components/table/search.blade.php | 33 ++++ .../views/components/table/sort.blade.php | 10 ++ .../views/components/table/toolbar.blade.php | 6 + .../views/components/top-breadcrumb.blade.php | 28 ++- resources/views/layouts/app.blade.php | 7 +- .../livewire/destination/index.blade.php | 47 ++++- .../application/deployment/index.blade.php | 161 +++++++++--------- .../application/deployment/show.blade.php | 132 +++++++------- .../project/database/heading.blade.php | 12 -- .../project/database/status.blade.php | 17 ++ .../project/service/domains.blade.php | 4 - .../project/service/heading.blade.php | 12 -- .../livewire/project/service/status.blade.php | 17 ++ .../shared/environment-variable/all.blade.php | 80 ++------- .../project/shared/get-logs.blade.php | 70 ++++---- .../proxy/dynamic-configurations.blade.php | 2 +- .../livewire/server/proxy/logs.blade.php | 2 +- .../livewire/server/proxy/show.blade.php | 2 +- .../environment/index.blade.php | 116 ++++++------- .../livewire/shared-variables/index.blade.php | 4 +- .../shared-variables/project/index.blade.php | 89 +++++----- .../shared-variables/server/index.blade.php | 79 ++++----- .../shared/list-search-controls.blade.php | 14 ++ .../shared/list-search-empty.blade.php | 5 + .../views/livewire/storage/index.blade.php | 30 +++- .../views/livewire/switch-team.blade.php | 4 +- resources/views/source/all.blade.php | 42 ++++- .../ApplicationHeadingActiveTabTest.php | 3 + .../Feature/BreadcrumbSelectionStyleTest.php | 24 +++ .../Feature/DeploymentHistoryFiltersTest.php | 90 ++++++++++ tests/Feature/DeploymentLogsLayoutTest.php | 67 ++++++-- .../EnvironmentVariableTableLayoutTest.php | 28 ++- .../Feature/InfrastructureListFiltersTest.php | 30 ++++ .../Feature/ResourceBreadcrumbStatusTest.php | 88 ++++++++++ .../Feature/ServerNavbarStatusLayoutTest.php | 4 +- tests/Feature/ServiceDomainsTest.php | 1 + .../SharedVariablesTitleOnDesktopTest.php | 66 +++++-- tests/Feature/SidebarNavigationMarkupTest.php | 35 +++- tests/Feature/StandardTableComponentsTest.php | 63 +++++++ tests/Feature/StatusBadgeComponentsTest.php | 4 +- .../Unit/ApplicationHeadingNavigationTest.php | 10 ++ 57 files changed, 1415 insertions(+), 579 deletions(-) create mode 100644 app/Livewire/Project/Database/Status.php create mode 100644 app/Livewire/Project/Service/Status.php create mode 100644 resources/views/components/shared-variables/layout.blade.php create mode 100644 resources/views/components/shared-variables/view-controls.blade.php create mode 100644 resources/views/components/table/filter.blade.php create mode 100644 resources/views/components/table/loading.blade.php create mode 100644 resources/views/components/table/search.blade.php create mode 100644 resources/views/components/table/sort.blade.php create mode 100644 resources/views/components/table/toolbar.blade.php create mode 100644 resources/views/livewire/project/database/status.blade.php create mode 100644 resources/views/livewire/project/service/status.blade.php create mode 100644 resources/views/livewire/shared/list-search-controls.blade.php create mode 100644 resources/views/livewire/shared/list-search-empty.blade.php create mode 100644 tests/Feature/DeploymentHistoryFiltersTest.php create mode 100644 tests/Feature/InfrastructureListFiltersTest.php create mode 100644 tests/Feature/ResourceBreadcrumbStatusTest.php create mode 100644 tests/Feature/StandardTableComponentsTest.php create mode 100644 tests/Unit/ApplicationHeadingNavigationTest.php diff --git a/UI_REDESIGN.md b/UI_REDESIGN.md index eef087bda..46b605ebc 100644 --- a/UI_REDESIGN.md +++ b/UI_REDESIGN.md @@ -398,6 +398,50 @@ do not create an unnecessarily wide menu. Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The selected option is indicated inside the menu, not repeated on the trigger. +#### Multi-select filter dropdowns + +Toolbar filters that can combine criteria use one multi-select listbox rather +than separate dropdowns or a single selected value. Follow the deployment +history filter in +`resources/views/livewire/project/application/deployment/index.blade.php`: + +- set `aria-multiselectable="true"` on the listbox; +- group related options under compact uppercase labels; +- keep the dropdown open while options are toggled; +- use the shared 16px custom checkbox treatment: purple checked fill in light + mode, yellow checked fill in dark mode, and a high-contrast check mark; +- show the number of active selections in a small count pill on the static + `Filter` trigger; +- combine selections within one group with OR logic and combine different + groups with AND logic; +- constrain only the options area with `max-h-80 overflow-y-auto`; +- place a persistent `Reset filters` action in a separate footer below the + scrollable options, divided by a top border; +- disable the reset action when no filter is active, and close the dropdown + after resetting. + +Do not represent the empty state as a selectable `All` option. The footer reset +action is the single way to return the multi-select to its unfiltered state. + +### Standard table controls + +Dense tables use the shared `x-table.*` components so search, filters, sorting, +and backend loading states remain visually and behaviorally consistent: + +- `` owns the responsive search-left/actions-right layout; +- `` owns the search icon, optional loading indicator, clear + action, sizing, and input anatomy; +- `` owns the static Filter trigger, active-count pill, + multi-select panel, scrollable options area, and Reset filters footer; +- `` owns the static Sort trigger and single-select panel; +- `` overlays only the changing table data for backend search, + filter, sort, and pagination requests. + +Tables continue to own their filter options, sort choices, headers, rows, +queries, permissions, and empty states. Backend-filtered or paginated tables +must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar +and control anatomy but do not show an artificial loading state. + ### Buttons - neutral actions use the shared `.button`; @@ -590,6 +634,7 @@ Use these as implementation references: | Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` | | Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` | | Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | +| Standard table toolbar controls | `resources/views/components/table/*` | | Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | | Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | | Layer card | `resources/views/components/application/settings-section.blade.php` | diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index cb36a8f61..cf23e304e 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -34,7 +34,7 @@ class Index extends Component public string $search = ''; - public string $deploymentFilter = 'all'; + public array $deploymentFilters = []; public string $deploymentSort = 'newest'; @@ -42,6 +42,8 @@ class Index extends Component public array $sourceFilterOptions = []; + public array $serverFilterOptions = []; + public bool $embedded = false; public ?string $selectedDeploymentUuid = null; @@ -90,7 +92,7 @@ class Index extends Component $this->loadDeploymentFilterOptions(); ['deployments' => $deployments, 'count' => $count] = $application->deployments( search: $this->search, - filter: $this->deploymentFilter, + filters: $this->deploymentFilters, sort: $this->deploymentSort, take: $this->defaultTake, pullRequestId: $this->pull_request_id, @@ -123,28 +125,17 @@ class Index extends Component $this->loadDeployments(); } - public function previousPage(?int $take = null) + public function previousPage(): void { - if ($take) { - $this->skip = $this->skip - $take; - } - $this->skip = $this->skip - $this->defaultTake; - if ($this->skip < 0) { - $this->showPrev = false; - $this->skip = 0; - } + $this->skip = max(0, $this->skip - $this->defaultTake); + $this->showPrev = $this->skip > 0; $this->updateCurrentPage(); $this->loadDeployments(); } - public function nextPage(?int $take = null) + public function nextPage(): void { - if ($take) { - $this->skip = $this->skip + $take; - } - $this->showPrev = true; - $this->updateCurrentPage(); - $this->loadDeployments(); + $this->goToPage($this->currentPage + 1); } public function goToPage(int $page): void @@ -164,7 +155,7 @@ class Index extends Component take: $this->defaultTake, pullRequestId: $this->pull_request_id, search: $this->search, - filter: $this->deploymentFilter, + filters: $this->deploymentFilters, sort: $this->deploymentSort, ); $this->deployments = $deployments; @@ -177,18 +168,24 @@ class Index extends Component $this->resetPaginationAndLoad(); } - public function setDeploymentFilter(string $filter): void + public function toggleDeploymentFilter(string $filter): void { $validFilters = collect($this->statusFilterOptions) ->concat($this->sourceFilterOptions) + ->concat($this->serverFilterOptions) ->pluck('value') - ->push('all'); + ->values(); if (! $validFilters->contains($filter)) { return; } - $this->deploymentFilter = $filter; + if (in_array($filter, $this->deploymentFilters, true)) { + $this->deploymentFilters = array_values(array_diff($this->deploymentFilters, [$filter])); + } else { + $this->deploymentFilters[] = $filter; + } + $this->pull_request_id = null; $this->resetPaginationAndLoad(); } @@ -202,7 +199,7 @@ class Index extends Component } $this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId; - $this->deploymentFilter = 'all'; + $this->deploymentFilters = []; $this->resetPaginationAndLoad(); } @@ -233,14 +230,14 @@ class Index extends Component $this->pull_request_id = null; } - $this->deploymentFilter = 'all'; + $this->deploymentFilters = []; $this->resetPaginationAndLoad(); } public function clearFilter() { $this->pull_request_id = null; - $this->deploymentFilter = 'all'; + $this->deploymentFilters = []; $this->resetPaginationAndLoad(); } @@ -328,6 +325,27 @@ class Index extends Component ]) ->values() ->all(); + + $servers = ApplicationDeploymentQueue::query() + ->where('application_id', $this->application->id) + ->whereNotNull('server_id') + ->select(['server_id', 'server_name']) + ->distinct() + ->orderBy('server_name') + ->get() + ->unique('server_id'); + + $this->serverFilterOptions = $servers + ->map(function (ApplicationDeploymentQueue $deployment): array { + $serverId = (int) $deployment->server_id; + + return [ + 'value' => "server:{$serverId}", + 'label' => $deployment->server_name ?: "Server #{$serverId}", + ]; + }) + ->values() + ->all(); } private function resetPaginationAndLoad(): void diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 658f20277..6c75cd7a6 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -132,12 +132,12 @@ class Heading extends Component return; } - return $this->redirectRoute('project.application.deployment.show', [ + return redirectRoute($this, 'project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], 'deployment_uuid' => $this->deploymentUuid, 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); + ]); } catch (\Throwable $e) { return handleError($e, $this); } @@ -189,12 +189,12 @@ class Heading extends Component return; } - return $this->redirectRoute('project.application.deployment.show', [ + return redirectRoute($this, 'project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], 'deployment_uuid' => $this->deploymentUuid, 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Status.php b/app/Livewire/Project/Application/Status.php index ec17b8679..eba5dca3b 100644 --- a/app/Livewire/Project/Application/Status.php +++ b/app/Livewire/Project/Application/Status.php @@ -16,6 +16,7 @@ class Status extends Component return [ "echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } diff --git a/app/Livewire/Project/Database/Status.php b/app/Livewire/Project/Database/Status.php new file mode 100644 index 000000000..4101227f6 --- /dev/null +++ b/app/Livewire/Project/Database/Status.php @@ -0,0 +1,31 @@ +user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', + ]; + } + + public function refreshStatus(): void + { + $this->database->refresh(); + } + + public function render(): View + { + return view('livewire.project.database.status'); + } +} diff --git a/app/Livewire/Project/Service/Status.php b/app/Livewire/Project/Service/Status.php new file mode 100644 index 000000000..192d7ca80 --- /dev/null +++ b/app/Livewire/Project/Service/Status.php @@ -0,0 +1,32 @@ +user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', + ]; + } + + public function refreshStatus(): void + { + $this->service->refresh()->load(['applications', 'databases']); + } + + public function render(): View + { + return view('livewire.project.service.status'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index aa872d0d6..f595f76b5 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1101,6 +1101,7 @@ class Application extends BaseModel } /** + * @param array $filters * @return array{count: int, deployments: Collection} */ public function deployments( @@ -1108,7 +1109,7 @@ class Application extends BaseModel int $take = 10, ?string $pullRequestId = null, ?string $search = null, - string $filter = 'all', + array $filters = [], string $sort = 'newest', ): array { $deployments = ApplicationDeploymentQueue::query() @@ -1160,32 +1161,40 @@ class Application extends BaseModel }); } - if (Str::startsWith($filter, 'status:')) { - $deployments->where('status', Str::after($filter, 'status:')); + $statusFilters = collect($filters) + ->filter(fn (string $filter) => Str::startsWith($filter, 'status:')) + ->map(fn (string $filter) => Str::after($filter, 'status:')); + if ($statusFilters->isNotEmpty()) { + $deployments->whereIn('status', $statusFilters); } - if (Str::startsWith($filter, 'source:')) { - match (Str::after($filter, 'source:')) { - 'pull-request' => $deployments->where('pull_request_id', '>', 0), - 'webhook' => $deployments - ->where('pull_request_id', '<=', 0) - ->where('is_webhook', true), - 'rollback' => $deployments - ->where('pull_request_id', '<=', 0) - ->where('is_webhook', false) - ->where('rollback', true), - 'api' => $deployments - ->where('pull_request_id', '<=', 0) - ->where('is_webhook', false) - ->where('rollback', false) - ->where('is_api', true), - 'manual' => $deployments - ->where('pull_request_id', '<=', 0) - ->where('is_webhook', false) - ->where('rollback', false) - ->where('is_api', false), - default => null, - }; + $sourceFilters = collect($filters) + ->filter(fn (string $filter) => Str::startsWith($filter, 'source:')) + ->map(fn (string $filter) => Str::after($filter, 'source:')); + if ($sourceFilters->isNotEmpty()) { + $deployments->where(function ($query) use ($sourceFilters) { + foreach ($sourceFilters as $source) { + $query->orWhere(function ($sourceQuery) use ($source) { + match ($source) { + 'pull-request' => $sourceQuery->where('pull_request_id', '>', 0), + 'webhook' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', true), + 'rollback' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', true), + 'api' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', false)->where('is_api', true), + 'manual' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', false)->where('is_api', false), + default => $sourceQuery->where('id', -1), + }; + }); + } + }); + } + + $serverFilters = collect($filters) + ->filter(fn (string $filter) => Str::startsWith($filter, 'server:')) + ->map(fn (string $filter) => Str::after($filter, 'server:')) + ->filter(fn (string $serverId) => ctype_digit($serverId)) + ->map(fn (string $serverId) => (int) $serverId); + if ($serverFilters->isNotEmpty()) { + $deployments->whereIn('server_id', $serverFilters); } $count = $deployments->count(); diff --git a/resources/css/app.css b/resources/css/app.css index c5485b224..2ec644306 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2489,6 +2489,14 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.5rem; } +.logs-viewer-primary { + display: flex; + min-width: 0; + width: 100%; + align-items: center; + gap: 0.5rem; +} + .logs-viewer-search { position: relative; min-width: 0; @@ -2612,7 +2620,7 @@ input[type="search"]::-webkit-search-results-decoration { flex-shrink: 0; } -/* Status badge + cancel/force-start — right-aligned as a group */ +/* Cancel/force-start + search — right-aligned as a group */ .logs-viewer-end { display: flex; min-width: 0; @@ -2623,6 +2631,15 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.5rem; } +.logs-viewer-end .logs-viewer-meta { + width: auto; +} + +.runtime-logs-viewer-end { + align-items: stretch; + flex-direction: column; +} + .logs-viewer-deployment-actions { display: flex; min-width: 0; @@ -2725,14 +2742,19 @@ input[type="search"]::-webkit-search-results-decoration { } @media (min-width: 640px) { - /* Desktop: [meta] [search] [actions] single row */ + /* Desktop: [actions + status] ... [cancel + search] */ .logs-viewer-toolbar-controls { flex-direction: row; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 0.5rem 0.75rem; } + .logs-viewer-primary { + width: auto; + flex: 0 1 auto; + } + .logs-viewer-meta { order: 0; width: auto; @@ -2747,7 +2769,7 @@ input[type="search"]::-webkit-search-results-decoration { } .logs-viewer-actions { - order: 2; + order: 0; width: auto; flex: 0 0 auto; max-width: 100%; @@ -2756,12 +2778,21 @@ input[type="search"]::-webkit-search-results-decoration { } .logs-viewer-end { - order: 3; + order: 1; width: auto; flex: 0 0 auto; margin-left: auto; } + .logs-viewer-end .logs-viewer-meta { + order: 2; + } + + .runtime-logs-viewer-end { + align-items: center; + flex-direction: row; + } + .logs-viewer-deployment-actions { width: auto; flex: 0 0 auto; @@ -2847,6 +2878,8 @@ input[type="search"]::-webkit-search-results-decoration { } .runtime-log-toolbar { + position: relative; + z-index: 20; border-bottom-color: var(--coollabs-fill); background: transparent; } @@ -2886,13 +2919,18 @@ input[type="search"]::-webkit-search-results-decoration { } .runtime-log-icon-button-active { - background: color-mix(in oklab, var(--color-coollabs) 10%, transparent); - color: var(--color-coollabs); + background: var(--color-coollabs); + color: #fff; } .dark .runtime-log-icon-button-active { - background: color-mix(in oklab, var(--color-warning) 14%, transparent); - color: var(--color-warning); + background: var(--color-coollabs); + color: #fff; +} + +.runtime-log-menu { + background: var(--coollabs-elevated) !important; + opacity: 1 !important; } .runtime-log-viewport { diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index 0cb7520f4..db6242f56 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -12,13 +12,6 @@ @php $items = match ($section) { - 'shared-variables' => [ - ['label' => 'Overview', 'route' => 'shared-variables.index', 'active' => request()->routeIs('shared-variables.index')], - ['label' => 'Team', 'route' => 'shared-variables.team.index', 'active' => request()->routeIs('shared-variables.team.*')], - ['label' => 'Projects', 'route' => 'shared-variables.project.index', 'active' => request()->routeIs('shared-variables.project.*')], - ['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'active' => request()->routeIs('shared-variables.environment.*')], - ['label' => 'Servers', 'route' => 'shared-variables.server.index', 'active' => request()->routeIs('shared-variables.server.*')], - ], 'team' => [ ['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')], ], @@ -79,14 +72,6 @@ $hasTitle = filled($title); $hasActions = isset($actions); $showNav = $showTabs || $hasActions; - $stackTabsOnMobile = $section === 'shared-variables'; - $sharedVariableIcons = [ - 'Overview' => 'dashboard', - 'Team' => 'teams', - 'Projects' => 'projects', - 'Environments' => 'layers', - 'Servers' => 'servers', - ]; @endphp @if ($hasTitle) @@ -131,24 +116,7 @@
@if ($showTabs) - @if ($stackTabsOnMobile) - - @endif -
$stackTabsOnMobile, - ])> +
@foreach ($items as $item) null]) + @php $serverRouteParameters = ['server_uuid' => $server->uuid]; $serverMenuItems = [ @@ -49,14 +51,14 @@ [ 'label' => 'Proxy', 'route' => 'server.proxy', - 'active' => request()->routeIs('server.proxy', 'server.proxy.*'), + 'active' => $activeMenu === 'proxy', 'icon' => 'network', 'group' => 'Platform', 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, 'children' => [ - ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => request()->routeIs('server.proxy'), 'icon' => 'settings'], - ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => request()->routeIs('server.proxy.dynamic-confs'), 'icon' => 'sliders', 'visible' => $server->proxySet()], - ['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => request()->routeIs('server.proxy.logs'), 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false], + ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'], + ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()], + ['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false], ], ], [ diff --git a/resources/views/components/server/status-summary.blade.php b/resources/views/components/server/status-summary.blade.php index b18781ef2..d35e231ad 100644 --- a/resources/views/components/server/status-summary.blade.php +++ b/resources/views/components/server/status-summary.blade.php @@ -45,7 +45,8 @@ {{ $serverReady ? 'Ready' : 'Unavailable' }}
@if ($server->proxySet()) - + @endif @if ($showSentinelStatus) - + @endif @if ($server->proxySet()) +
+ +
+ + +
+
diff --git a/resources/views/components/table/filter.blade.php b/resources/views/components/table/filter.blade.php new file mode 100644 index 000000000..b2385b995 --- /dev/null +++ b/resources/views/components/table/filter.blade.php @@ -0,0 +1,24 @@ +@props(['activeCount' => 0, 'activeText' => null, 'resetAction', 'resetLabel' => 'Reset filters']) + +
+ +
+
{{ $slot }}
+
+ +
+
+
diff --git a/resources/views/components/table/loading.blade.php b/resources/views/components/table/loading.blade.php new file mode 100644 index 000000000..1ea72c0da --- /dev/null +++ b/resources/views/components/table/loading.blade.php @@ -0,0 +1,6 @@ +@props(['target', 'text' => 'Loading records...']) + +
class(['table-loading-overlay absolute inset-0 z-30 hidden items-center justify-center bg-white/70 backdrop-blur-[1px] dark:bg-black/20']) }}> + +
diff --git a/resources/views/components/table/search.blade.php b/resources/views/components/table/search.blade.php new file mode 100644 index 000000000..a5a044235 --- /dev/null +++ b/resources/views/components/table/search.blade.php @@ -0,0 +1,33 @@ +@props([ + 'placeholder' => 'Search', + 'loadingTarget' => null, + 'disabled' => false, + 'clearAction' => null, + 'clearWhen' => null, +]) + + diff --git a/resources/views/components/table/sort.blade.php b/resources/views/components/table/sort.blade.php new file mode 100644 index 000000000..46a54d566 --- /dev/null +++ b/resources/views/components/table/sort.blade.php @@ -0,0 +1,10 @@ +
+ +
+ {{ $slot }} +
+
diff --git a/resources/views/components/table/toolbar.blade.php b/resources/views/components/table/toolbar.blade.php new file mode 100644 index 000000000..b8b3d6e2b --- /dev/null +++ b/resources/views/components/table/toolbar.blade.php @@ -0,0 +1,6 @@ +
class(['table-toolbar flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center']) }}> + @isset($search) +
{{ $search }}
+ @endisset +
{{ $slot }}
+
diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index c521d3b94..b341bb0d3 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -13,6 +13,14 @@ $currentApplication = $currentEnvironment && $applicationUuid ? $currentEnvironment->applications()->where('uuid', $applicationUuid)->first() : null; + $databaseUuid = request()->route('database_uuid'); + $currentDatabase = $currentEnvironment && $databaseUuid + ? $currentEnvironment->databases()->firstWhere('uuid', $databaseUuid) + : null; + $serviceUuid = request()->route('service_uuid'); + $currentService = $currentEnvironment && $serviceUuid + ? $currentEnvironment->services()->where('uuid', $serviceUuid)->first() + : null; $storageUuid = request()->route('storage_uuid'); $storages = $storageUuid && $team ? \App\Models\S3Storage::ownedByCurrentTeam()->orderBy('name')->get() : collect(); $currentStorage = $storages->firstWhere('uuid', $storageUuid); @@ -79,7 +87,7 @@ ])->filter() : collect(); @endphp -
+
{{-- Team --}}
@@ -251,4 +259,22 @@ :wire:key="'application-status-'.$currentApplication->uuid" /> @endif + + @if ($currentDatabase) + / + + {{ $currentDatabase->name }} + + + @endif + + @if ($currentService) + / + + {{ $currentService->name }} + + + @endif
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 69c619d9c..1c8e91dca 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -51,9 +51,10 @@
{{-- Collapse toggle + team switcher --}}
- -
-
+
+ +
+
{{-- Dev Server-Timing HUD docks here (local only; empty in production) --}} {{-- Resource actions dock here on desktop. --}} diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index 9ad8e5676..cd07c8fd5 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -33,9 +33,36 @@ description="Add a Docker network endpoint to choose where your resources are deployed." icon-name="destinations" /> @else -
+ @php + $items = $destinations->map(fn ($destination) => [ + 'name' => $destination->name, + 'server' => $destination->server->name, + 'type' => $destination->getMorphClass() === 'App\\Models\\SwarmDocker' ? 'Docker Swarm' : 'Standalone Docker', + ])->values(); + @endphp +
+ @include('livewire.shared.list-search-controls', ['placeholder' => 'Search destinations', 'singular' => 'destination', 'plural' => 'destinations']) + +
@foreach ($destinations as $destination) - +
+
+
Destination
Server
Type
+
+ @foreach ($destinations as $destination) + @php($isSwarm = $destination->getMorphClass() === 'App\\Models\\SwarmDocker') + +
{{ $destination->name }}
+
{{ $destination->server->name }}
+
+
+ @endforeach +
+ @include('livewire.shared.list-search-empty', ['label' => 'destinations']) +
@endif
diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index 56a20ccc4..450da3894 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -9,7 +9,7 @@ $lastPage = max(1, (int) ceil($deployments_count / $defaultTake)); $firstVisibleRow = $deployments_count === 0 ? 0 : $skip + 1; $lastVisibleRow = min($skip + $deployments->count(), $deployments_count); - $hasActiveFilter = $deploymentFilter !== 'all' || filled($pull_request_id); + $hasActiveFilter = count($deploymentFilters) > 0 || filled($pull_request_id); $hasActiveQuery = trim($search) !== '' || $hasActiveFilter; @endphp @@ -30,85 +30,90 @@ @if (!$skip) wire:poll.5000ms="reloadDeployments" @endif> -
-
- -
- - -
-
- -
-
- -
- - + + + + + @if (count($statusFilterOptions) > 0) Status @foreach ($statusFilterOptions as $option) @endforeach @endif - @if (count($sourceFilterOptions) > 1) + @if (count($sourceFilterOptions) > 0) Source @foreach ($sourceFilterOptions as $option) + @endforeach + @endif + + @if (count($serverFilterOptions) > 0) + Server + @foreach ($serverFilterOptions as $option) + @endforeach @endif @@ -133,17 +138,8 @@ @endforeach @endif -
-
- -
- -
+ + @foreach ([ 'newest' => 'Newest first', 'oldest' => 'Oldest first', @@ -162,15 +158,16 @@ @endif @endforeach -
-
-
-
+ + @if ($deployments->isNotEmpty()) -
+ wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter"> +
Status Source diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 49e6a12ca..cdc0dbe56 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -290,23 +290,55 @@ }; @endphp
- -
- -
-
- - @can('update', $application) - - @endcan - - - -
-
+
+
+
+ +
+ +
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index 9f8be9338..66e20fb3c 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -64,18 +64,6 @@ - @teleport('#server-topbar-context') -
- / - - - {{ $database->name }} - - - -
- @endteleport -
diff --git a/resources/views/livewire/project/database/status.blade.php b/resources/views/livewire/project/database/status.blade.php new file mode 100644 index 000000000..37b8d1c2d --- /dev/null +++ b/resources/views/livewire/project/database/status.blade.php @@ -0,0 +1,17 @@ +@php + $databaseStatus = str($database->status ?? 'exited'); + [$statusDotClass, $statusLabel] = match (true) { + $databaseStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'], + $databaseStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'], + $databaseStatus->startsWith('restarting'), + $databaseStatus->startsWith('starting') => ['bg-warning', 'Restarting'], + default => ['bg-neutral-400 dark:bg-fg-faint', 'Stopped'], + }; +@endphp + + + + {{ $statusLabel }} + diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 50ff75c4f..fad431a05 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -156,7 +156,6 @@ @php $app = collect($serviceApps)->firstWhere('id', (int) $appId); $heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service'); - $appDomainCount = $rows->where('is_suggested', false)->count(); $redirect = $serviceRedirects[$appId] ?? 'both'; $redirectLabel = match ($redirect) { 'www' => 'Redirect to www', @@ -168,9 +167,6 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
{{ $heading }} - @can('update', $service)
diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index fcd6afd0b..5ff9d74fa 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -61,18 +61,6 @@ - @teleport('#server-topbar-context') -
- / - - - {{ $service->name }} - - - -
- @endteleport -
diff --git a/resources/views/livewire/project/service/status.blade.php b/resources/views/livewire/project/service/status.blade.php new file mode 100644 index 000000000..e93753cf5 --- /dev/null +++ b/resources/views/livewire/project/service/status.blade.php @@ -0,0 +1,17 @@ +@php + $serviceStatus = str($service->status ?? 'exited'); + [$statusDotClass, $statusLabel] = match (true) { + $serviceStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'], + $serviceStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'], + $serviceStatus->startsWith('restarting'), + $serviceStatus->startsWith('starting') => ['bg-warning', 'Restarting'], + default => ['bg-neutral-400 dark:bg-fg-faint', 'Stopped'], + }; +@endphp + + + + {{ $statusLabel }} + 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 dbe8dc674..6994a61a2 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -72,40 +72,14 @@ {{-- Toolbar: search left; filter and add right --}} @if ($view === 'normal') -
-
- -
- - -
-
-
-
- -
-
+ + + + + @foreach ([ 'managed' => 'Managed', 'user' => 'User-defined', @@ -174,31 +148,15 @@ @endforeach @endif -
-
- -
-
-
-
- -
+ + @foreach (['default' => 'Default order', 'name_asc' => 'Name A–Z', 'name_desc' => 'Name Z–A'] as $value => $label) @endforeach -
-
+ @can('manageEnvironment', $resource) {{-- Do not disable Add based on readyToLoad: modal-input uses wire:ignore, so a disabled attribute painted on first load would never re-enable. --}} @@ -213,8 +171,7 @@ @endcan -
-
+ @endif @if ($view === 'normal') @@ -266,11 +223,9 @@ @endif @endforeach
- +
- +
@endif
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index a2698f381..2d5d8b818 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -284,36 +284,14 @@ :class="fullscreen ? 'h-full w-full' : ''">
- -
-
- Lines - -
- -
-
+
+
+
+
+ Lines + +
+ +
+ +
- +
@if ($server->isFunctional()) diff --git a/resources/views/livewire/server/proxy/logs.blade.php b/resources/views/livewire/server/proxy/logs.blade.php index 8950ebbaa..be461ef75 100644 --- a/resources/views/livewire/server/proxy/logs.blade.php +++ b/resources/views/livewire/server/proxy/logs.blade.php @@ -5,7 +5,7 @@
- +
- + @if ($server->isFunctional())
diff --git a/resources/views/livewire/shared-variables/environment/index.blade.php b/resources/views/livewire/shared-variables/environment/index.blade.php index 5157b117e..4a6d2244a 100644 --- a/resources/views/livewire/shared-variables/environment/index.blade.php +++ b/resources/views/livewire/shared-variables/environment/index.blade.php @@ -1,63 +1,65 @@
- - Environment Variables | Coolify - + Environment Variables | Coolify - + +
+ @if ($projects->isEmpty()) + + @else + -
- @if ($projects->isEmpty()) - - @else -
- @foreach ($projects as $project) -
-
-

- {{ $project->name }} -

-

- {{ $project->description ?: 'Project environments' }} -

-
+
+ @foreach ($projects as $project) +
+
+

{{ $project->name }}

+

{{ $project->description ?: 'Project environments' }}

+
+ @if ($project->environments->isEmpty()) + + @else +
+ @foreach ($project->environments as $environment) + +
+
+

{{ $environment->name }}

{{ $environment->description ?: 'No description' }}

+
+
+ @endforeach +
+ @endif +
+ @endforeach +
- @if ($project->environments->isEmpty()) - - @else - - @endif -
- @endforeach +
+ @foreach ($projects as $project) + @foreach ($project->environments as $environment) + + +
{{ $environment->name }}
{{ $environment->description ?: 'No description' }}
+ {{ $project->name }} +
+ @endforeach + @endforeach +
+ @endif
- @endif -
+
diff --git a/resources/views/livewire/shared-variables/index.blade.php b/resources/views/livewire/shared-variables/index.blade.php index f6ecf9752..0565e4027 100644 --- a/resources/views/livewire/shared-variables/index.blade.php +++ b/resources/views/livewire/shared-variables/index.blade.php @@ -3,8 +3,7 @@ Shared Variables | Coolify - +
@@ -65,4 +64,5 @@
+
diff --git a/resources/views/livewire/shared-variables/project/index.blade.php b/resources/views/livewire/shared-variables/project/index.blade.php index f93da50e6..802dfe371 100644 --- a/resources/views/livewire/shared-variables/project/index.blade.php +++ b/resources/views/livewire/shared-variables/project/index.blade.php @@ -1,45 +1,54 @@
- - Project Variables | Coolify - + Project Variables | Coolify - + + diff --git a/resources/views/livewire/shared-variables/server/index.blade.php b/resources/views/livewire/shared-variables/server/index.blade.php index c3197bcbd..8c58792af 100644 --- a/resources/views/livewire/shared-variables/server/index.blade.php +++ b/resources/views/livewire/shared-variables/server/index.blade.php @@ -1,43 +1,46 @@
- - Server Variables | Coolify - + Server Variables | Coolify - + +
+ @if ($servers->isEmpty()) + + @else + -
- @if ($servers->isEmpty()) - - @else -
- @foreach ($servers as $server) - -
-
- -
-
-

- {{ $server->name }} -

-

- {{ $server->description ?: $server->ip }} -

-
-
-
- -
-
- @endforeach +
+ @foreach ($servers as $server) + +
+
+

{{ $server->name }}

{{ $server->description ?: $server->ip }}

+
+
+
+ @endforeach +
+ +
+ @foreach ($servers as $server) + + +
{{ $server->name }}
{{ $server->description ?: $server->ip }}
+ +
+ @endforeach +
+ @endif
- @endif -
+
diff --git a/resources/views/livewire/shared/list-search-controls.blade.php b/resources/views/livewire/shared/list-search-controls.blade.php new file mode 100644 index 000000000..b025fc0e4 --- /dev/null +++ b/resources/views/livewire/shared/list-search-controls.blade.php @@ -0,0 +1,14 @@ +
+
+ +
+
+ +
+ + +
+
+
diff --git a/resources/views/livewire/shared/list-search-empty.blade.php b/resources/views/livewire/shared/list-search-empty.blade.php new file mode 100644 index 000000000..904739483 --- /dev/null +++ b/resources/views/livewire/shared/list-search-empty.blade.php @@ -0,0 +1,5 @@ +
+ +

No matching {{ $label }}

+

Try a different search.

+
diff --git a/resources/views/livewire/storage/index.blade.php b/resources/views/livewire/storage/index.blade.php index 0d2c48855..7d10a0b0d 100644 --- a/resources/views/livewire/storage/index.blade.php +++ b/resources/views/livewire/storage/index.blade.php @@ -31,9 +31,23 @@ description="Add an S3-compatible destination to store backups outside your servers." icon-name="storages" /> @else -
+ @php + $items = $s3->map(fn ($storage) => [ + 'name' => $storage->name, + 'description' => $storage->description ?: 'S3-compatible storage', + 'status' => $storage->is_usable ? 'Connected' : 'Not usable', + ])->values(); + @endphp +
+ @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages']) +
@foreach ($s3 as $storage) - @endif
diff --git a/resources/views/livewire/switch-team.blade.php b/resources/views/livewire/switch-team.blade.php index f61c54a3e..31ea8d308 100644 --- a/resources/views/livewire/switch-team.blade.php +++ b/resources/views/livewire/switch-team.blade.php @@ -8,8 +8,8 @@ x-data="{ open: false }" @keydown.escape.window="open = false"> + + + + + + BLADE); + + expect($html) + ->toContain('table-toolbar') + ->toContain('table-search') + ->toContain('wire:model.live="search"') + ->toContain('aria-multiselectable="true"') + ->toContain('Reset filters') + ->toContain('wire:click="clearFilters"') + ->toContain('Sort'); +}); + +it('renders the standard table loading overlay', function () { + $html = Blade::render('
'); + + expect($html) + ->toContain('table-loading-overlay') + ->toContain('wire:loading.flex') + ->toContain('wire:target="applyFilters"') + ->toContain('[&_.loading-indicator]:size-5') + ->toContain('aria-label="Loading records..."') + ->not->toContain('Loading records...'); +}); + +it('uses shared table controls on backend filtered tables', function () { + $deployments = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php')); + $environmentVariables = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); + + foreach ([$deployments, $environmentVariables] as $view) { + expect($view) + ->toContain('toContain('toContain('toContain('toContain('toContain('toContain('clear-when="search"') + ->toContain("clear-action=\"search = ''\""); +}); diff --git a/tests/Feature/StatusBadgeComponentsTest.php b/tests/Feature/StatusBadgeComponentsTest.php index d47414535..4d8389f48 100644 --- a/tests/Feature/StatusBadgeComponentsTest.php +++ b/tests/Feature/StatusBadgeComponentsTest.php @@ -45,11 +45,13 @@ it('uses bordered status badges in the top breadcrumb', function () { ->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0); }); -it('renders application status through a reactive livewire component', function () { +it('renders resource statuses through reactive livewire components', function () { $breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php')); expect($breadcrumb) ->toContain('toContain('toContain('not->toContain('$applicationStatus = str($currentApplication->status'); }); diff --git a/tests/Unit/ApplicationHeadingNavigationTest.php b/tests/Unit/ApplicationHeadingNavigationTest.php new file mode 100644 index 000000000..3240cdd36 --- /dev/null +++ b/tests/Unit/ApplicationHeadingNavigationTest.php @@ -0,0 +1,10 @@ +toBe(2) + ->and($heading) + ->not->toContain('navigate: false'); +});