mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
- `<x-table.toolbar>` owns the responsive search-left/actions-right layout;
|
||||
- `<x-table.search>` owns the search icon, optional loading indicator, clear
|
||||
action, sizing, and input anatomy;
|
||||
- `<x-table.filter>` owns the static Filter trigger, active-count pill,
|
||||
multi-select panel, scrollable options area, and Reset filters footer;
|
||||
- `<x-table.sort>` owns the static Sort trigger and single-select panel;
|
||||
- `<x-table.loading>` 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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class Status extends Component
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Status extends Component
|
||||
{
|
||||
public $database;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\Service;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Status extends Component
|
||||
{
|
||||
public Service $service;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->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');
|
||||
}
|
||||
}
|
||||
+34
-25
@@ -1101,6 +1101,7 @@ class Application extends BaseModel
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $filters
|
||||
* @return array{count: int, deployments: Collection<int, ApplicationDeploymentQueue>}
|
||||
*/
|
||||
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();
|
||||
|
||||
+47
-9
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
<div
|
||||
class="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 lg:h-full lg:gap-4">
|
||||
@if ($showTabs)
|
||||
@if ($stackTabsOnMobile)
|
||||
<div
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:hidden dark:border-white/[0.06]">
|
||||
@foreach ($items as $item)
|
||||
<a @class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $item['active'],
|
||||
]) {{ wireNavigate() }} href="{{ route($item['route'], $parameters) }}">
|
||||
<x-reicon :name="$sharedVariableIcons[$item['label']]" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $item['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<div @class([
|
||||
'flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]',
|
||||
'hidden lg:flex' => $stackTabsOnMobile,
|
||||
])>
|
||||
<div class="flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
@foreach ($items as $item)
|
||||
<a @class([
|
||||
'app-tab shrink-0',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@props(['server', 'activeMenu', 'activeSubMenu' => 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],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
<span>{{ $serverReady ? 'Ready' : 'Unavailable' }}</span>
|
||||
</div>
|
||||
@if ($server->proxySet())
|
||||
<div class="listbox-option cursor-default! gap-2.5!">
|
||||
<a href="{{ route('server.proxy', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
|
||||
class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
|
||||
<span @class([
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
'bg-success' => $proxyStatus === 'running',
|
||||
@@ -54,14 +55,15 @@
|
||||
])></span>
|
||||
<span class="flex-1">Proxy</span>
|
||||
<span>{{ str($proxyStatus ?: 'unknown')->headline() }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
@if ($showSentinelStatus)
|
||||
<div class="listbox-option cursor-default! gap-2.5!">
|
||||
<a href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
|
||||
class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
|
||||
<span class="size-1.5 shrink-0 rounded-full {{ $server->isSentinelLive() ? 'bg-success' : 'bg-error' }}"></span>
|
||||
<span class="flex-1">Sentinel</span>
|
||||
<span>{{ $server->isSentinelLive() ? 'In sync' : 'Out of sync' }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
@if ($server->proxySet())
|
||||
<button type="button"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<x-shared-variables.layout>
|
||||
<div class="application-settings-form w-full" x-data="{
|
||||
sharedSearch: '',
|
||||
sharedSort: 'alphabetical',
|
||||
@@ -29,9 +30,6 @@
|
||||
return this.rows.filter(row => [row.key, row.comment, row.scope].some(value => value.includes(query))).length;
|
||||
}
|
||||
}">
|
||||
<x-dashboard.navbar section="shared-variables" title="Shared variables"
|
||||
subtitle="Reusable environment variables across resources" :titleOnDesktop="false" />
|
||||
|
||||
<x-application.settings-section :title="$title" flush>
|
||||
<x-slot:actions>
|
||||
<x-forms.button type="button" wire:click="switch" class="whitespace-nowrap">
|
||||
@@ -136,3 +134,4 @@
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</x-shared-variables.layout>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
@php
|
||||
$sharedVariablesMenuItems = [
|
||||
['label' => 'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')],
|
||||
['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')],
|
||||
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')],
|
||||
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')],
|
||||
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section class="w-full max-w-[1180px]">
|
||||
<header class="mb-6 xl:hidden">
|
||||
<h1 class="text-[24px]! leading-7! font-semibold! tracking-tight!">Shared variables</h1>
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">Reusable environment variables across resources</p>
|
||||
</header>
|
||||
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="min-w-0 xl:self-start">
|
||||
<nav aria-label="Shared variables"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($sharedVariablesMenuItems as $menuItem)
|
||||
<a wire:key="shared-variables-{{ str($menuItem['label'])->slug() }}"
|
||||
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
|
||||
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
@props(['label', 'storageKey'])
|
||||
|
||||
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative w-full sm:max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.150ms="search" type="search" placeholder="Search {{ strtolower($label) }}"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
<button x-cloak x-show="search" @click="search = ''" type="button"
|
||||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
aria-label="List view" title="List view">
|
||||
<x-reicon name="unordered-list" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')"
|
||||
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
||||
:class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
aria-label="Grid view" title="Grid view">
|
||||
<x-reicon name="grid" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
@props(['activeCount' => 0, 'activeText' => null, 'resetAction', 'resetLabel' => 'Reset filters'])
|
||||
|
||||
<div class="table-filter relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" aria-haspopup="listbox"
|
||||
:aria-expanded="open" @if ($activeText) title="{{ $activeText }}" @endif
|
||||
@class(['button max-w-80 min-w-0', 'button-highlighted' => $activeCount > 0])>
|
||||
<x-reicon name="filter" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">Filter</span>
|
||||
@if ($activeCount > 0)
|
||||
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeCount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open"
|
||||
x-cloak role="listbox" aria-multiselectable="true">
|
||||
<div class="max-h-80 overflow-y-auto p-1">{{ $slot }}</div>
|
||||
<div class="border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-raised">
|
||||
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
|
||||
wire:click="{{ $resetAction }}" @click="open = false" @disabled($activeCount === 0)>
|
||||
<span>{{ $resetLabel }}</span>
|
||||
<x-reicon name="x" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
@props(['target', 'text' => 'Loading records...'])
|
||||
|
||||
<div wire:loading.flex wire:target="{{ $target }}"
|
||||
{{ $attributes->class(['table-loading-overlay absolute inset-0 z-30 hidden items-center justify-center bg-white/70 backdrop-blur-[1px] dark:bg-black/20']) }}>
|
||||
<x-loading aria-label="{{ $text }}" class="[&_.loading-indicator]:size-5" />
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
@props([
|
||||
'placeholder' => 'Search',
|
||||
'loadingTarget' => null,
|
||||
'disabled' => false,
|
||||
'clearAction' => null,
|
||||
'clearWhen' => null,
|
||||
])
|
||||
|
||||
<div class="table-search relative min-w-0 w-full">
|
||||
<input type="search" placeholder="{{ $placeholder }}" aria-label="{{ $placeholder }}"
|
||||
{{ $attributes->class(['input w-full pl-8!'])->except(['loading-target']) }} @disabled($disabled) />
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
|
||||
@if ($loadingTarget)
|
||||
<x-reicon name="search" class="size-3.5 text-neutral-400 dark:text-fg-faint"
|
||||
wire:loading.remove wire:target="{{ $loadingTarget }}" />
|
||||
<svg wire:loading wire:target="{{ $loadingTarget }}" aria-hidden="true"
|
||||
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
@else
|
||||
<x-reicon name="search" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
|
||||
@endif
|
||||
</div>
|
||||
@if ($clearAction && $clearWhen)
|
||||
<button x-cloak x-show="{{ $clearWhen }}" x-on:click="{{ $clearAction }}" type="button"
|
||||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="table-sort relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" @click.outside="open = false"
|
||||
aria-haspopup="listbox" :aria-expanded="open">
|
||||
<x-reicon name="sort-direction" class="size-3.5" />
|
||||
Sort
|
||||
</button>
|
||||
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak role="listbox">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div {{ $attributes->class(['table-toolbar flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center']) }}>
|
||||
@isset($search)
|
||||
<div class="min-w-0 w-full flex-1 sm:max-w-md">{{ $search }}</div>
|
||||
@endisset
|
||||
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">{{ $slot }}</div>
|
||||
</div>
|
||||
@@ -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
|
||||
<div class="flex items-center gap-0.5 min-w-0 text-[13px]">
|
||||
<div class="flex min-w-0 items-center gap-0.5 text-[13px]">
|
||||
{{-- Team --}}
|
||||
<div class="shrink-0" x-data="{ collapsed: false }">
|
||||
<livewire:switch-team />
|
||||
@@ -251,4 +259,22 @@
|
||||
:wire:key="'application-status-'.$currentApplication->uuid" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($currentDatabase)
|
||||
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
|
||||
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
|
||||
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentDatabase->name }}</span>
|
||||
<livewire:project.database.status :database="$currentDatabase"
|
||||
:wire:key="'database-status-'.$currentDatabase->uuid" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($currentService)
|
||||
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
|
||||
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
|
||||
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentService->name }}</span>
|
||||
<livewire:project.service.status :service="$currentService"
|
||||
:wire:key="'service-status-'.$currentService->uuid" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -51,9 +51,10 @@
|
||||
</div>
|
||||
{{-- Collapse toggle + team switcher --}}
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4">
|
||||
<x-top-breadcrumb />
|
||||
<div id="server-topbar-context" class="min-w-0"></div>
|
||||
<div class="flex-1"></div>
|
||||
<div class="relative flex min-w-0 flex-1 items-center">
|
||||
<x-top-breadcrumb />
|
||||
<div id="server-topbar-context" class="min-w-0"></div>
|
||||
</div>
|
||||
{{-- Dev Server-Timing HUD docks here (local only; empty in production) --}}
|
||||
<div id="server-timing-hud-slot" data-server-timing-hud-slot class="hidden shrink-0 items-center"></div>
|
||||
{{-- Resource actions dock here on desktop. --}}
|
||||
|
||||
@@ -33,9 +33,36 @@
|
||||
description="Add a Docker network endpoint to choose where your resources are deployed."
|
||||
icon-name="destinations" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@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
|
||||
<div x-data="{
|
||||
search: '',
|
||||
viewMode: localStorage.getItem('coolify-destinations-view') || 'table',
|
||||
items: @js($items),
|
||||
get filteredItems() {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
if (!query) return this.items;
|
||||
return this.items.filter(item => Object.values(item).some(value => String(value || '').toLowerCase().includes(query)));
|
||||
},
|
||||
matches(values) {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
|
||||
},
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
localStorage.setItem('coolify-destinations-view', mode);
|
||||
}
|
||||
}">
|
||||
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search destinations', 'singular' => 'destination', 'plural' => 'destinations'])
|
||||
|
||||
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($destinations as $destination)
|
||||
<a class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
<a x-show="matches(@js([$destination->name, $destination->server->name, $destination->getMorphClass() === 'App\\Models\\SwarmDocker' ? 'Docker Swarm' : 'Standalone Docker']))" class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -63,5 +90,21 @@
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
<div x-show="viewMode === 'table'" class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_10rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<div>Destination</div><div>Server</div><div>Type</div>
|
||||
</div>
|
||||
@foreach ($destinations as $destination)
|
||||
@php($isSwarm = $destination->getMorphClass() === 'App\\Models\\SwarmDocker')
|
||||
<a x-show="matches(@js([$destination->name, $destination->server->name, $isSwarm ? 'Docker Swarm' : 'Standalone Docker']))" {{ wireNavigate() }} href="{{ route('destination.show', ['destination_uuid' => $destination->uuid]) }}"
|
||||
class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_10rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<div class="truncate font-semibold text-black dark:text-fg">{{ $destination->name }}</div>
|
||||
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $destination->server->name }}</div>
|
||||
<div><x-status-badge :label="$isSwarm ? 'Docker Swarm' : 'Standalone Docker'" :type="$isSwarm ? 'warning' : 'success'" /></div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.shared.list-search-empty', ['label' => 'destinations'])
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
<x-application.settings-section title="Deployment history"
|
||||
helper="Search, filter, and open a deployment to inspect its build logs." flush>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="relative min-w-0 max-w-md flex-1">
|
||||
<input type="search" placeholder="Search deployments" aria-label="Search deployments"
|
||||
wire:model.live.debounce.300ms="search" class="input w-full pl-8!" />
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
|
||||
<x-reicon name="search" wire:loading.remove wire:target="search"
|
||||
class="size-3.5 text-neutral-400 dark:text-fg-faint" />
|
||||
<svg wire:loading wire:target="search" aria-hidden="true"
|
||||
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open"
|
||||
@click.outside="open = false" aria-haspopup="listbox" :aria-expanded="open">
|
||||
<x-reicon name="filter" class="size-3.5" />
|
||||
Filter
|
||||
</button>
|
||||
<div class="listbox-panel left-auto! right-0! z-[90]! w-52! min-w-52!" x-show="open"
|
||||
x-cloak role="listbox">
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
aria-selected="{{ !$hasActiveFilter ? 'true' : 'false' }}"
|
||||
wire:click="clearFilter" @click="open = false">
|
||||
<span>All deployments</span>
|
||||
@if (!$hasActiveFilter)
|
||||
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24" fill="none">
|
||||
<path d="m4.5 12.75 6 6 9-13.5" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
@endif
|
||||
</button>
|
||||
|
||||
<x-table.toolbar class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<x-slot:search>
|
||||
<x-table.search placeholder="Search deployments" loading-target="search"
|
||||
wire:model.live.debounce.300ms="search" />
|
||||
</x-slot:search>
|
||||
<x-table.filter :active-count="count($deploymentFilters) + (filled($pull_request_id) ? 1 : 0)"
|
||||
reset-action="clearFilter">
|
||||
@if (count($statusFilterOptions) > 0)
|
||||
<span
|
||||
class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-fg-faint">Status</span>
|
||||
@foreach ($statusFilterOptions as $option)
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
aria-selected="{{ $deploymentFilter === $option['value'] ? 'true' : 'false' }}"
|
||||
wire:click="setDeploymentFilter('{{ $option['value'] }}')"
|
||||
@click="open = false">
|
||||
aria-selected="{{ in_array($option['value'], $deploymentFilters, true) ? 'true' : 'false' }}"
|
||||
wire:click="toggleDeploymentFilter('{{ $option['value'] }}')">
|
||||
<span>{{ $option['label'] }}</span>
|
||||
@if ($deploymentFilter === $option['value'] && !$pull_request_id)
|
||||
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24"
|
||||
fill="none">
|
||||
<path d="m4.5 12.75 6 6 9-13.5" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
@endif
|
||||
@php
|
||||
$selected = in_array($option['value'], $deploymentFilters, true);
|
||||
@endphp
|
||||
<span @class([
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
|
||||
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
|
||||
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
|
||||
])>
|
||||
@if ($selected)
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
@endif
|
||||
</span>
|
||||
</button>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@if (count($sourceFilterOptions) > 1)
|
||||
@if (count($sourceFilterOptions) > 0)
|
||||
<span
|
||||
class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-fg-faint">Source</span>
|
||||
@foreach ($sourceFilterOptions as $option)
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
aria-selected="{{ $deploymentFilter === $option['value'] ? 'true' : 'false' }}"
|
||||
wire:click="setDeploymentFilter('{{ $option['value'] }}')"
|
||||
@click="open = false">
|
||||
aria-selected="{{ in_array($option['value'], $deploymentFilters, true) ? 'true' : 'false' }}"
|
||||
wire:click="toggleDeploymentFilter('{{ $option['value'] }}')">
|
||||
<span>{{ $option['label'] }}</span>
|
||||
@if ($deploymentFilter === $option['value'] && !$pull_request_id)
|
||||
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24"
|
||||
fill="none">
|
||||
<path d="m4.5 12.75 6 6 9-13.5" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
@endif
|
||||
@php
|
||||
$selected = in_array($option['value'], $deploymentFilters, true);
|
||||
@endphp
|
||||
<span @class([
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
|
||||
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
|
||||
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
|
||||
])>
|
||||
@if ($selected)
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
@endif
|
||||
</span>
|
||||
</button>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@if (count($serverFilterOptions) > 0)
|
||||
<span
|
||||
class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-fg-faint">Server</span>
|
||||
@foreach ($serverFilterOptions as $option)
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
aria-selected="{{ in_array($option['value'], $deploymentFilters, true) ? 'true' : 'false' }}"
|
||||
wire:click="toggleDeploymentFilter('{{ $option['value'] }}')">
|
||||
<span class="truncate">{{ $option['label'] }}</span>
|
||||
@php
|
||||
$selected = in_array($option['value'], $deploymentFilters, true);
|
||||
@endphp
|
||||
<span @class([
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
|
||||
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
|
||||
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
|
||||
])>
|
||||
@if ($selected)
|
||||
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
@endif
|
||||
</span>
|
||||
</button>
|
||||
@endforeach
|
||||
@endif
|
||||
@@ -133,17 +138,8 @@
|
||||
</button>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open"
|
||||
@click.outside="open = false" aria-haspopup="listbox" :aria-expanded="open">
|
||||
<x-reicon name="sort-direction" class="size-3.5" />
|
||||
Sort
|
||||
</button>
|
||||
<div class="listbox-panel left-auto! right-0! z-[90]! w-44! min-w-44!" x-show="open"
|
||||
x-cloak role="listbox">
|
||||
</x-table.filter>
|
||||
<x-table.sort>
|
||||
@foreach ([
|
||||
'newest' => 'Newest first',
|
||||
'oldest' => 'Oldest first',
|
||||
@@ -162,15 +158,16 @@
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-table.sort>
|
||||
</x-table.toolbar>
|
||||
|
||||
@if ($deployments->isNotEmpty())
|
||||
<div class="data-table w-full transition-opacity"
|
||||
<div class="data-table relative w-full transition-opacity"
|
||||
wire:loading.class="opacity-50 pointer-events-none"
|
||||
wire:target="goToPage,previousPage,nextPage">
|
||||
wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter">
|
||||
<x-table.loading id="deployment-table-filter-loading"
|
||||
target="toggleDeploymentFilter,clearFilter,setPullRequestFilter"
|
||||
text="Filtering deployments..." class="rounded-lg" />
|
||||
<div class="data-table-header deployment-table-grid rounded-none!">
|
||||
<span>Status</span>
|
||||
<span>Source</span>
|
||||
|
||||
@@ -290,23 +290,55 @@
|
||||
};
|
||||
@endphp
|
||||
<div class="logs-viewer-toolbar-controls">
|
||||
<div class="logs-viewer-search relative">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
|
||||
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
|
||||
aria-label="Find in logs"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! text-neutral-800! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.05]! dark:text-white! dark:placeholder:text-neutral-500" />
|
||||
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
||||
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-800 dark:text-neutral-500 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
aria-label="Clear search">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
<div class="logs-viewer-primary">
|
||||
<div class="logs-viewer-actions">
|
||||
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
|
||||
:class="showTimestamps ? 'logs-viewer-btn-active' : ''"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button title="Follow Logs" :class="alwaysScroll ? 'logs-viewer-btn-active' : ''"
|
||||
x-on:click="toggleScroll"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
@can('update', $application)
|
||||
<button wire:click="toggleDebug"
|
||||
title="{{ $is_debug_enabled ? 'Hide Debug Logs' : 'Show Debug Logs' }}"
|
||||
class="logs-viewer-btn {{ $is_debug_enabled ? 'logs-viewer-btn-active' : '' }}">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
|
||||
</svg>
|
||||
</button>
|
||||
@endcan
|
||||
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path
|
||||
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
|
||||
<path fill="currentColor"
|
||||
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-viewer-meta">
|
||||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-neutral-500 whitespace-nowrap"></span>
|
||||
</div>
|
||||
<div class="logs-viewer-actions">
|
||||
<button
|
||||
x-on:click="copyLogs()"
|
||||
title="Copy Logs"
|
||||
@@ -374,59 +406,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
|
||||
:class="showTimestamps ? 'logs-viewer-btn-active' : ''"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
@can('update', $application)
|
||||
<button wire:click="toggleDebug"
|
||||
title="{{ $is_debug_enabled ? 'Hide Debug Logs' : 'Show Debug Logs' }}"
|
||||
class="logs-viewer-btn {{ $is_debug_enabled ? 'logs-viewer-btn-active' : '' }}">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
|
||||
</svg>
|
||||
</button>
|
||||
@endcan
|
||||
<button title="Follow Logs" :class="alwaysScroll ? 'logs-viewer-btn-active' : ''"
|
||||
x-on:click="toggleScroll"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path
|
||||
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
|
||||
<path fill="currentColor"
|
||||
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
|
||||
class="logs-viewer-btn">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-viewer-end">
|
||||
</div>
|
||||
<x-status-badge :status="$deploymentStatusLabel" :type="$deploymentStatusType"
|
||||
class="logs-viewer-status-badge" />
|
||||
</div>
|
||||
<div class="logs-viewer-end">
|
||||
<livewire:project.application.deployment-navbar
|
||||
:application_deployment_queue="$application_deployment_queue" />
|
||||
<div class="logs-viewer-search relative">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
|
||||
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
|
||||
aria-label="Find in logs"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! text-neutral-800! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.05]! dark:text-white! dark:placeholder:text-neutral-500" />
|
||||
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
||||
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-800 dark:text-neutral-500 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
aria-label="Clear search">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-viewer-meta">
|
||||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-neutral-500 whitespace-nowrap"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,18 +64,6 @@
|
||||
</x-slot:content>
|
||||
</x-process-dialog>
|
||||
|
||||
@teleport('#server-topbar-context')
|
||||
<div class="flex min-w-0 items-center gap-1 text-[13px]">
|
||||
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
|
||||
<span class="flex min-w-0 shrink items-center gap-2 px-1">
|
||||
<span class="max-w-48 min-w-0 truncate font-semibold text-black dark:text-fg xl:max-w-64">
|
||||
{{ $database->name }}
|
||||
</span>
|
||||
<x-status-badge :status="$databaseStatusLabel" :type="$databaseStatusType" />
|
||||
</span>
|
||||
</div>
|
||||
@endteleport
|
||||
|
||||
<div x-data>
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
<span wire:poll.10000ms="refreshStatus"
|
||||
class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2.5 text-xs font-medium text-black dark:border-white/[0.12] dark:bg-white/[0.08] dark:text-fg"
|
||||
title="{{ $database->status }}">
|
||||
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
@@ -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">
|
||||
<div class="flex w-full items-center gap-3 px-4 py-3">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
|
||||
<span class="hidden shrink-0 text-xs text-neutral-500 sm:inline dark:text-fg-dim">
|
||||
{{ $appDomainCount }} domain{{ $appDomainCount === 1 ? '' : 's' }}
|
||||
</span>
|
||||
@can('update', $service)
|
||||
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
|
||||
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
|
||||
|
||||
@@ -61,18 +61,6 @@
|
||||
</x-slot:content>
|
||||
</x-process-dialog>
|
||||
|
||||
@teleport('#server-topbar-context')
|
||||
<div class="flex min-w-0 items-center gap-1 text-[13px]">
|
||||
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
|
||||
<span class="flex min-w-0 shrink items-center gap-2 px-1">
|
||||
<span class="max-w-48 min-w-0 truncate font-semibold text-black dark:text-fg xl:max-w-64">
|
||||
{{ $service->name }}
|
||||
</span>
|
||||
<x-status-badge :status="$serviceStatusLabel" :type="$serviceStatusType" />
|
||||
</span>
|
||||
</div>
|
||||
@endteleport
|
||||
|
||||
<div x-data>
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
<span wire:poll.10000ms="refreshStatus"
|
||||
class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2.5 text-xs font-medium text-black dark:border-white/[0.12] dark:bg-white/[0.08] dark:text-fg"
|
||||
title="{{ $service->status }}">
|
||||
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
@@ -72,40 +72,14 @@
|
||||
|
||||
{{-- Toolbar: search left; filter and add right --}}
|
||||
@if ($view === 'normal')
|
||||
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
|
||||
@if (! $readyToLoad) aria-busy="true" @endif>
|
||||
<div class="relative min-w-0 w-full flex-1 sm:max-w-md">
|
||||
<input type="search" placeholder="Search environment variables"
|
||||
aria-label="Search environment variables" wire:model.live.debounce.300ms="search"
|
||||
class="input w-full pl-8!" @disabled(! $readyToLoad) />
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
|
||||
<x-reicon name="search" wire:loading.remove wire:target="search,loadEnvironmentVariables"
|
||||
class="size-3.5 text-neutral-400 dark:text-fg-faint" />
|
||||
<svg wire:loading wire:target="search,loadEnvironmentVariables" aria-hidden="true"
|
||||
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false"
|
||||
@if ($activeFilterCount > 0) title="{{ $activeFilterText }}" @endif
|
||||
@class([
|
||||
'button max-w-80 min-w-0',
|
||||
'button-highlighted' => $activeFilterCount > 0,
|
||||
])>
|
||||
<x-reicon name="filter" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ $activeFilterCount > 0 ? $activeFilterText : 'Filter' }}</span>
|
||||
@if ($activeFilterCount > 0)
|
||||
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeFilterCount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open" x-cloak>
|
||||
<div class="max-h-80 overflow-y-auto p-1">
|
||||
<x-table.toolbar class="mt-2" aria-busy="{{ ! $readyToLoad ? 'true' : 'false' }}">
|
||||
<x-slot:search>
|
||||
<x-table.search placeholder="Search environment variables"
|
||||
loading-target="search,loadEnvironmentVariables" wire:model.live.debounce.300ms="search"
|
||||
:disabled="! $readyToLoad" />
|
||||
</x-slot:search>
|
||||
<x-table.filter :active-count="$activeFilterCount" :active-text="$activeFilterText"
|
||||
reset-action="clearFilters">
|
||||
@foreach ([
|
||||
'managed' => 'Managed',
|
||||
'user' => 'User-defined',
|
||||
@@ -174,31 +148,15 @@
|
||||
</button>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
<div class="relative z-20 border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-[#171717]">
|
||||
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
|
||||
wire:click="clearFilters" @click="open = false" @disabled($activeFilterCount === 0)>
|
||||
<span>Clear filters</span>
|
||||
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" d="m6 6 12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" @click.outside="open = false">
|
||||
Sort
|
||||
</button>
|
||||
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak>
|
||||
</x-table.filter>
|
||||
<x-table.sort>
|
||||
@foreach (['default' => 'Default order', 'name_asc' => 'Name A–Z', 'name_desc' => 'Name Z–A'] as $value => $label)
|
||||
<button type="button" class="listbox-option" wire:click="setTableSort('{{ $value }}')" @click="open = false">
|
||||
<span>{{ $label }}</span>
|
||||
@if ($tableSort === $value)<span>✓</span>@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</x-table.sort>
|
||||
@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 @@
|
||||
<livewire:project.shared.environment-variable.add />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</x-table.toolbar>
|
||||
@endif
|
||||
|
||||
@if ($view === 'normal')
|
||||
@@ -266,11 +223,9 @@
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
<div wire:loading.flex
|
||||
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
|
||||
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
|
||||
<x-loading text="Loading environment variables..." />
|
||||
</div>
|
||||
<x-table.loading
|
||||
target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
|
||||
text="Loading environment variables..." />
|
||||
</div>
|
||||
<x-table-pagination :from="$firstVisibleRow" :to="$lastVisibleRow" :total="$totalRows"
|
||||
:current-page="$currentPage" :last-page="$lastPage"
|
||||
@@ -287,10 +242,7 @@
|
||||
description="Add your first variable with the + Add button above."
|
||||
icon-name="variables" />
|
||||
</div>
|
||||
<div wire:loading.flex wire:target="clearFilters"
|
||||
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
|
||||
<x-loading text="Loading environment variables..." />
|
||||
</div>
|
||||
<x-table.loading target="clearFilters" text="Loading environment variables..." />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -284,36 +284,14 @@
|
||||
:class="fullscreen ? 'h-full w-full' : ''">
|
||||
<div class="runtime-log-toolbar logs-viewer-toolbar">
|
||||
<div class="logs-viewer-toolbar-controls">
|
||||
<div class="logs-viewer-search relative">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
|
||||
aria-label="Find in logs"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
|
||||
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
||||
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-viewer-meta">
|
||||
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
|
||||
<span class="logs-viewer-lines-label">Lines</span>
|
||||
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
|
||||
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
|
||||
class="input logs-viewer-lines-input" />
|
||||
</form>
|
||||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
||||
</div>
|
||||
<div class="logs-viewer-actions">
|
||||
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
|
||||
class="runtime-log-icon-button">
|
||||
class="runtime-log-icon-button order-8">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
</button>
|
||||
<button wire:click="toggleStreamLogs"
|
||||
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
|
||||
class="runtime-log-icon-button {{ $streamLogs ? 'runtime-log-icon-button-active' : '' }}">
|
||||
class="runtime-log-icon-button order-9 {{ $streamLogs ? 'runtime-log-icon-button-active' : '' }}">
|
||||
@if ($streamLogs)
|
||||
{{-- Pause icon --}}
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -345,14 +323,14 @@
|
||||
});
|
||||
"
|
||||
title="Copy Logs"
|
||||
class="runtime-log-icon-button">
|
||||
class="runtime-log-icon-button order-6">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative shrink-0">
|
||||
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative order-7 shrink-0">
|
||||
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"
|
||||
class="runtime-log-icon-button">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
@@ -368,7 +346,7 @@
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="listbox-panel left-auto! right-0! z-[90]! min-w-52!">
|
||||
class="runtime-log-menu listbox-panel left-auto! right-0! z-[90]! min-w-52!">
|
||||
<div>
|
||||
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
|
||||
class="listbox-option">
|
||||
@@ -408,7 +386,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button wire:click="toggleTimestamps" title="Toggle Timestamps"
|
||||
class="runtime-log-icon-button {{ $showTimeStamps ? 'runtime-log-icon-button-active' : '' }}">
|
||||
class="runtime-log-icon-button order-1 {{ $showTimeStamps ? 'runtime-log-icon-button-active' : '' }}">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -417,14 +395,14 @@
|
||||
</button>
|
||||
<button title="Toggle Log Colors" x-on:click="toggleColorLogs"
|
||||
:class="colorLogs ? 'runtime-log-icon-button-active' : ''"
|
||||
class="runtime-log-icon-button">
|
||||
class="runtime-log-icon-button order-3">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-data="{ filterOpen: false }" class="relative shrink-0">
|
||||
<div x-data="{ filterOpen: false }" class="relative order-4 shrink-0">
|
||||
<button x-on:click="filterOpen = !filterOpen" title="Filter Log Levels"
|
||||
:class="Object.values(logFilters).some(v => !v) ? 'runtime-log-icon-button-active' : ''"
|
||||
class="runtime-log-icon-button">
|
||||
@@ -441,7 +419,7 @@
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="listbox-panel left-auto! right-0! z-[90]! min-w-40!">
|
||||
class="runtime-log-menu listbox-panel left-auto! right-0! z-[90]! min-w-40!">
|
||||
<div>
|
||||
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('error')">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-red-500"></span>
|
||||
@@ -468,14 +446,14 @@
|
||||
</div>
|
||||
<button title="Follow Logs" :class="alwaysScroll ? 'runtime-log-icon-button-active' : ''"
|
||||
x-on:click="toggleScroll"
|
||||
class="runtime-log-icon-button">
|
||||
class="runtime-log-icon-button order-2">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
|
||||
class="runtime-log-icon-button">
|
||||
class="runtime-log-icon-button order-5">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path
|
||||
@@ -486,13 +464,37 @@
|
||||
</svg>
|
||||
</button>
|
||||
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
|
||||
class="runtime-log-icon-button">
|
||||
class="runtime-log-icon-button order-5">
|
||||
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="logs-viewer-end runtime-logs-viewer-end">
|
||||
<div class="logs-viewer-meta">
|
||||
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
|
||||
<span class="logs-viewer-lines-label">Lines</span>
|
||||
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
|
||||
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
|
||||
class="input logs-viewer-lines-input" />
|
||||
</form>
|
||||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-gray-500 whitespace-nowrap dark:text-gray-400"></span>
|
||||
</div>
|
||||
<div class="logs-viewer-search relative">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
|
||||
aria-label="Find in logs"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
|
||||
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
||||
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" />
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="dynamic-confs" />
|
||||
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
@if ($server->isFunctional())
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" />
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="logs" />
|
||||
<div class="application-settings-form w-full">
|
||||
<x-application.settings-section title="Proxy logs"
|
||||
helper="Search, filter, follow, copy, or download recent output from the Coolify proxy container."
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" />
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="configuration" />
|
||||
@if ($server->isFunctional())
|
||||
<div class="w-full">
|
||||
<livewire:server.proxy :server="$server" />
|
||||
|
||||
@@ -1,63 +1,65 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Environment Variables | Coolify
|
||||
</x-slot>
|
||||
<x-slot:title>Environment Variables | Coolify</x-slot>
|
||||
|
||||
<x-dashboard.navbar section="shared-variables" title="Shared variables"
|
||||
subtitle="Environment-wide variables for resources in one environment" :titleOnDesktop="false" />
|
||||
<x-shared-variables.layout>
|
||||
<div class="w-full" x-data="{
|
||||
search: '',
|
||||
viewMode: localStorage.getItem('shared-variables-environments-view') || 'grid',
|
||||
matches(values) {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
|
||||
}
|
||||
}">
|
||||
@if ($projects->isEmpty())
|
||||
<x-empty title="No environments yet" description="Create a project environment before adding environment-wide variables." icon-name="layers" />
|
||||
@else
|
||||
<x-shared-variables.view-controls label="environments" storage-key="shared-variables-environments-view" />
|
||||
|
||||
<div class="w-full">
|
||||
@if ($projects->isEmpty())
|
||||
<x-empty title="No environments yet"
|
||||
description="Create a project environment before adding environment-wide variables."
|
||||
icon-name="layers" />
|
||||
@else
|
||||
<div class="flex flex-col gap-6">
|
||||
@foreach ($projects as $project)
|
||||
<section>
|
||||
<div class="mb-3">
|
||||
<h2 class="text-[14px]! leading-5! font-semibold! text-black dark:text-fg">
|
||||
{{ $project->name }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $project->description ?: 'Project environments' }}
|
||||
</p>
|
||||
</div>
|
||||
<div x-cloak x-show="viewMode === 'grid'" class="flex flex-col gap-6">
|
||||
@foreach ($projects as $project)
|
||||
<section x-show="matches(@js([
|
||||
$project->name,
|
||||
$project->description,
|
||||
...$project->environments->flatMap(fn ($environment) => [$environment->name, $environment->description])->all(),
|
||||
]))">
|
||||
<div class="mb-3">
|
||||
<h2 class="text-[14px]! leading-5! font-semibold! text-black dark:text-fg">{{ $project->name }}</h2>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">{{ $project->description ?: 'Project environments' }}</p>
|
||||
</div>
|
||||
@if ($project->environments->isEmpty())
|
||||
<x-empty title="No environments in this project." size="sm" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($project->environments as $environment)
|
||||
<a x-show="matches(@js([$environment->name, $environment->description, $project->name]))"
|
||||
class="group flex min-h-24 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
href="{{ route('shared-variables.environment.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim"><x-reicon name="layers" class="size-4" /></div>
|
||||
<div class="min-w-0 flex-1"><h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">{{ $environment->name }}</h3><p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $environment->description ?: 'No description' }}</p></div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($project->environments->isEmpty())
|
||||
<x-empty title="No environments in this project." size="sm" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($project->environments as $environment)
|
||||
<a class="group flex min-h-24 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
href="{{ route('shared-variables.environment.show', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
]) }}"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="layers" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3
|
||||
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||
{{ $environment->name }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $environment->description ?: 'No description' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endforeach
|
||||
<div x-show="viewMode === 'list'" class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
@foreach ($projects as $project)
|
||||
@foreach ($project->environments as $environment)
|
||||
<a x-show="matches(@js([$environment->name, $environment->description, $project->name]))"
|
||||
href="{{ route('shared-variables.environment.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}" {{ wireNavigate() }}
|
||||
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<x-reicon name="layers" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
|
||||
<div class="min-w-0 flex-1"><div class="truncate text-[13px] font-medium">{{ $environment->name }}</div><div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $environment->description ?: 'No description' }}</div></div>
|
||||
<span class="shrink-0 text-[11px] text-neutral-500 dark:text-fg-dim">{{ $project->name }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-shared-variables.layout>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
Shared Variables | Coolify
|
||||
</x-slot>
|
||||
|
||||
<x-dashboard.navbar section="shared-variables" title="Shared variables"
|
||||
subtitle="Reusable environment variables across resources" :titleOnDesktop="false" />
|
||||
<x-shared-variables.layout>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -65,4 +64,5 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-shared-variables.layout>
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,54 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Project Variables | Coolify
|
||||
</x-slot>
|
||||
<x-slot:title>Project Variables | Coolify</x-slot>
|
||||
|
||||
<x-dashboard.navbar section="shared-variables" title="Shared variables"
|
||||
subtitle="Project-wide variables for every environment in a project" :titleOnDesktop="false" />
|
||||
<x-shared-variables.layout>
|
||||
<div class="w-full" x-data="{
|
||||
search: '',
|
||||
viewMode: localStorage.getItem('shared-variables-projects-view') || 'grid',
|
||||
matches(values) {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
|
||||
}
|
||||
}">
|
||||
@if ($projects->isEmpty())
|
||||
<x-empty title="No projects yet" description="Create a project before adding project-wide variables."
|
||||
icon-name="projects" />
|
||||
@else
|
||||
<x-shared-variables.view-controls label="projects" storage-key="shared-variables-projects-view" />
|
||||
|
||||
<div class="w-full">
|
||||
@if ($projects->isEmpty())
|
||||
<x-empty title="No projects yet"
|
||||
description="Create a project before adding project-wide variables."
|
||||
icon-name="projects" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($projects as $project)
|
||||
<a class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
href="{{ route('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="projects" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||
{{ $project->name }}
|
||||
</h2>
|
||||
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $project->description ?: 'No description' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto flex items-center pt-4">
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $project->environment_variables()->count() }}
|
||||
{{ Str::plural('variable', $project->environment_variables()->count()) }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($projects as $project)
|
||||
<a x-show="matches(@js([$project->name, $project->description]))"
|
||||
class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
href="{{ route('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="projects" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">{{ $project->name }}</h2>
|
||||
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $project->description ?: 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto pt-4 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $project->environment_variables()->count() }} {{ Str::plural('variable', $project->environment_variables()->count()) }}
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div x-show="viewMode === 'list'" class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
@foreach ($projects as $project)
|
||||
<a x-show="matches(@js([$project->name, $project->description]))"
|
||||
href="{{ route('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
|
||||
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<x-reicon name="projects" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
|
||||
<div class="min-w-0 flex-1"><div class="truncate text-[13px] font-medium">{{ $project->name }}</div><div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $project->description ?: 'No description' }}</div></div>
|
||||
<span class="shrink-0 text-[11px] text-neutral-500 dark:text-fg-dim">{{ $project->environment_variables()->count() }} {{ Str::plural('variable', $project->environment_variables()->count()) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-shared-variables.layout>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Server Variables | Coolify
|
||||
</x-slot>
|
||||
<x-slot:title>Server Variables | Coolify</x-slot>
|
||||
|
||||
<x-dashboard.navbar section="shared-variables" title="Shared variables"
|
||||
subtitle="Server-wide variables available to resources on a server" :titleOnDesktop="false" />
|
||||
<x-shared-variables.layout>
|
||||
<div class="w-full" x-data="{
|
||||
search: '',
|
||||
viewMode: localStorage.getItem('shared-variables-servers-view') || 'grid',
|
||||
matches(values) {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
|
||||
}
|
||||
}">
|
||||
@if ($servers->isEmpty())
|
||||
<x-empty title="No servers yet" description="Add a server before creating server-wide variables." icon-name="servers" />
|
||||
@else
|
||||
<x-shared-variables.view-controls label="servers" storage-key="shared-variables-servers-view" />
|
||||
|
||||
<div class="w-full">
|
||||
@if ($servers->isEmpty())
|
||||
<x-empty title="No servers yet"
|
||||
description="Add a server before creating server-wide variables."
|
||||
icon-name="servers" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($servers as $server)
|
||||
<a class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
href="{{ route('shared-variables.server.show', ['server_uuid' => $server->uuid]) }}"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||
{{ $server->name }}
|
||||
</h2>
|
||||
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $server->description ?: $server->ip }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto flex items-center pt-4">
|
||||
<x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'"
|
||||
:type="$server->isFunctional() ? 'success' : 'warning'" />
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($servers as $server)
|
||||
<a x-show="matches(@js([$server->name, $server->description, $server->ip]))"
|
||||
class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
href="{{ route('shared-variables.server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim"><x-reicon name="servers" class="size-4" /></div>
|
||||
<div class="min-w-0 flex-1"><h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">{{ $server->name }}</h2><p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $server->description ?: $server->ip }}</p></div>
|
||||
</div>
|
||||
<div class="mt-auto pt-4"><x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'" :type="$server->isFunctional() ? 'success' : 'warning'" /></div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div x-show="viewMode === 'list'" class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
@foreach ($servers as $server)
|
||||
<a x-show="matches(@js([$server->name, $server->description, $server->ip]))"
|
||||
href="{{ route('shared-variables.server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
|
||||
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<x-reicon name="servers" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
|
||||
<div class="min-w-0 flex-1"><div class="truncate text-[13px] font-medium">{{ $server->name }}</div><div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $server->description ?: $server->ip }}</div></div>
|
||||
<x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'" :type="$server->isFunctional() ? 'success' : 'warning'" />
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-shared-variables.layout>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="w-full sm:max-w-sm">
|
||||
<x-table.search :placeholder="$placeholder" x-model.debounce.150ms="search"
|
||||
clear-when="search" clear-action="search = ''"
|
||||
class="h-8! rounded-lg! border-neutral-200! bg-white! py-0! pr-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint"><span x-text="filteredItems.length"></span> <span x-text="filteredItems.length === 1 ? '{{ $singular }}' : '{{ $plural }}'"></span></span>
|
||||
<div class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
|
||||
<button type="button" x-on:click="setViewMode('table')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
|
||||
<button type="button" x-on:click="setViewMode('grid')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div x-show="filteredItems.length === 0" class="flex min-h-52 flex-col items-center justify-center rounded-xl border border-neutral-200 bg-white px-6 text-center dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<x-reicon name="search" class="mb-3 size-6 text-neutral-300 dark:text-fg-faint" />
|
||||
<p class="text-[13px] font-medium">No matching {{ $label }}</p>
|
||||
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">Try a different search.</p>
|
||||
</div>
|
||||
@@ -31,9 +31,23 @@
|
||||
description="Add an S3-compatible destination to store backups outside your servers."
|
||||
icon-name="storages" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@php
|
||||
$items = $s3->map(fn ($storage) => [
|
||||
'name' => $storage->name,
|
||||
'description' => $storage->description ?: 'S3-compatible storage',
|
||||
'status' => $storage->is_usable ? 'Connected' : 'Not usable',
|
||||
])->values();
|
||||
@endphp
|
||||
<div x-data="{
|
||||
search: '', viewMode: localStorage.getItem('coolify-s3-storages-view') || 'table', items: @js($items),
|
||||
get filteredItems() { const query = this.search.trim().toLowerCase(); return query ? this.items.filter(item => Object.values(item).some(value => String(value || '').toLowerCase().includes(query))) : this.items; },
|
||||
matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); },
|
||||
setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-s3-storages-view', mode); }
|
||||
}">
|
||||
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages'])
|
||||
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($s3 as $storage)
|
||||
<a {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}"
|
||||
<a x-show="matches(@js([$storage->name, $storage->description ?: 'S3-compatible storage', $storage->is_usable ? 'Connected' : 'Not usable']))" {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}"
|
||||
class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
@@ -60,5 +74,17 @@
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
<div x-show="viewMode === 'table'" class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.8fr)_9rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint"><div>Storage</div><div>Description</div><div>Status</div></div>
|
||||
@foreach ($s3 as $storage)
|
||||
<a x-show="matches(@js([$storage->name, $storage->description ?: 'S3-compatible storage', $storage->is_usable ? 'Connected' : 'Not usable']))" {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}" class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.8fr)_9rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<div class="truncate font-semibold text-black dark:text-fg">{{ $storage->name }}</div>
|
||||
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $storage->description ?: 'S3-compatible storage' }}</div>
|
||||
<div><x-status-badge :label="$storage->is_usable ? 'Connected' : 'Not usable'" :type="$storage->is_usable ? 'success' : 'error'" /></div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.shared.list-search-empty', ['label' => 'S3 storages'])
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false"
|
||||
title="Switch team"
|
||||
class="group/team flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2 -ml-1 text-left opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
|
||||
<span class="min-w-0 truncate text-[13px] font-semibold text-black dark:text-fg">{{ $currentTeam->name }}</span>
|
||||
class="group/team flex h-8 items-center gap-1.5 rounded-lg px-2 -ml-1 text-left opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
|
||||
<span class="whitespace-nowrap text-[13px] font-semibold text-black dark:text-fg">{{ $currentTeam->name }}</span>
|
||||
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
|
||||
@@ -54,10 +54,28 @@
|
||||
description="Connect a Git provider to deploy applications directly from your repositories."
|
||||
icon-name="sources" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@php
|
||||
$items = $sources->map(function ($source) {
|
||||
$isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp';
|
||||
return [
|
||||
'name' => $source->name,
|
||||
'provider' => $isGithub ? 'GitHub' : 'GitLab',
|
||||
'organization' => $isGithub ? $source->organization : $source->group_name,
|
||||
'status' => $source->isConnected() ? 'Connected' : 'Setup incomplete',
|
||||
];
|
||||
})->values();
|
||||
@endphp
|
||||
<div x-data="{
|
||||
search: '', viewMode: localStorage.getItem('coolify-sources-view') || 'table', items: @js($items),
|
||||
get filteredItems() { const query = this.search.trim().toLowerCase(); return query ? this.items.filter(item => Object.values(item).some(value => String(value || '').toLowerCase().includes(query))) : this.items; },
|
||||
matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); },
|
||||
setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-sources-view', mode); }
|
||||
}">
|
||||
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources', 'singular' => 'source', 'plural' => 'sources'])
|
||||
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($sources as $source)
|
||||
@if ($source->getMorphClass() === 'App\Models\GithubApp')
|
||||
<a class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
<a x-show="matches(@js([$source->name, 'GitHub', $source->organization, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -84,7 +102,7 @@
|
||||
</div>
|
||||
</a>
|
||||
@elseif ($source->getMorphClass() === 'App\Models\GitlabApp')
|
||||
<a class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
<a x-show="matches(@js([$source->name, 'GitLab', $source->group_name, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('source.gitlab.show', ['gitlab_app_uuid' => data_get($source, 'uuid')]) }}">
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -113,6 +131,24 @@
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
<div x-show="viewMode === 'table'" class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint"><div>Source</div><div>Provider</div><div>Status</div></div>
|
||||
@foreach ($sources as $source)
|
||||
@php
|
||||
$isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp';
|
||||
$provider = $isGithub ? 'GitHub' : 'GitLab';
|
||||
$organization = $isGithub ? $source->organization : $source->group_name;
|
||||
$href = $isGithub ? route('source.github.show', ['github_app_uuid' => $source->uuid]) : route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]);
|
||||
@endphp
|
||||
<a x-show="matches(@js([$source->name, $provider, $organization, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" {{ wireNavigate() }} href="{{ $href }}" class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<div class="truncate font-semibold text-black dark:text-fg">{{ $source->name }}</div>
|
||||
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $organization ? "{$provider} · {$organization}" : $provider }}</div>
|
||||
<div><x-status-badge :label="$source->isConnected() ? 'Connected' : 'Setup incomplete'" :type="$source->isConnected() ? 'success' : 'warning'" /></div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.shared.list-search-empty', ['label' => 'sources'])
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-layout>
|
||||
|
||||
@@ -180,6 +180,9 @@ it('refreshes the breadcrumb application status after it changes', function () {
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
|
||||
it('uses app-tab-active utility for resource heading active styles', function () {
|
||||
|
||||
@@ -20,3 +20,27 @@ test('teleported resource breadcrumbs use the same spacing as application breadc
|
||||
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
|
||||
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
|
||||
});
|
||||
|
||||
test('top breadcrumbs shrink and clip instead of overlapping on narrower screens', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('class="flex w-full min-w-0 items-center gap-0.5 text-[13px]"')
|
||||
->not->toContain('overflow-hidden text-[13px]')
|
||||
->toContain('class="min-w-0 shrink" x-data="{ collapsed: false }"')
|
||||
->not->toContain('class="shrink-0" x-data="{ collapsed: false }"')
|
||||
->and($layout)
|
||||
->toContain('class="relative flex min-w-0 flex-1 items-center"')
|
||||
->not->toContain('flex min-w-0 flex-1 items-center overflow-hidden')
|
||||
->not->toContain('<div class="flex-1"></div>');
|
||||
});
|
||||
|
||||
test('resource headings do not duplicate database and service breadcrumbs', function () {
|
||||
$headings = collect([
|
||||
resource_path('views/livewire/project/database/heading.blade.php'),
|
||||
resource_path('views/livewire/project/service/heading.blade.php'),
|
||||
])->map(fn (string $path): string => file_get_contents($path));
|
||||
|
||||
expect($headings->implode("\n"))->not->toContain("@teleport('#server-topbar-context')");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Livewire\Project\Application\Deployment\Index;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('moves deployment history pagination by one page per action', function () {
|
||||
expect((new ReflectionMethod(Index::class, 'nextPage'))->getNumberOfParameters())->toBe(0)
|
||||
->and((new ReflectionMethod(Index::class, 'previousPage'))->getNumberOfParameters())->toBe(0);
|
||||
});
|
||||
|
||||
it('filters deployment history by server', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$firstServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Primary']);
|
||||
$secondServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Secondary']);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $firstServer->id)->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
foreach ([$firstServer, $secondServer] as $server) {
|
||||
ApplicationDeploymentQueue::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => "deployment-{$server->id}",
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
]);
|
||||
}
|
||||
|
||||
ApplicationDeploymentQueue::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => 'webhook-deployment',
|
||||
'server_id' => $secondServer->id,
|
||||
'server_name' => $secondServer->name,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
'is_webhook' => true,
|
||||
]);
|
||||
|
||||
$result = $application->deployments(filters: [
|
||||
'status:finished',
|
||||
'source:manual',
|
||||
'source:webhook',
|
||||
"server:{$secondServer->id}",
|
||||
]);
|
||||
|
||||
expect($result['count'])->toBe(2)
|
||||
->and($result['deployments']->pluck('server_id')->unique()->sole())->toBe($secondServer->id);
|
||||
});
|
||||
|
||||
it('always shows source filters and includes server filters', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$filterComponent = file_get_contents(resource_path('views/components/table/filter.blade.php'));
|
||||
$loadingComponent = file_get_contents(resource_path('views/components/table/loading.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('public array $serverFilterOptions = [];')
|
||||
->toContain('public array $deploymentFilters = [];')
|
||||
->toContain('public function toggleDeploymentFilter(string $filter): void')
|
||||
->toContain("'value' => \"server:{\$serverId}\"")
|
||||
->and($view)
|
||||
->toContain('@if (count($sourceFilterOptions) > 0)')
|
||||
->toContain('count($serverFilterOptions) > 0')
|
||||
->toContain('>Server</span>')
|
||||
->toContain('<x-table.filter')
|
||||
->toContain("wire:click=\"toggleDeploymentFilter('{{ \$option['value'] }}')\"")
|
||||
->toContain("in_array(\$option['value'], \$deploymentFilters, true)")
|
||||
->toContain('<x-table.loading id="deployment-table-filter-loading"')
|
||||
->not->toContain('class="size-3.5" wire:loading.remove')
|
||||
->not->toContain('<span>All deployments</span>')
|
||||
->and($filterComponent)
|
||||
->toContain('aria-multiselectable="true"')
|
||||
->toContain('Reset filters')
|
||||
->and($loadingComponent)
|
||||
->toContain('wire:loading.flex');
|
||||
});
|
||||
@@ -89,6 +89,7 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
|
||||
expect($deploymentView)
|
||||
->toContain('logs-viewer-toolbar')
|
||||
->toContain('logs-viewer-toolbar-controls')
|
||||
->toContain('logs-viewer-primary')
|
||||
->toContain('logs-viewer-meta')
|
||||
->toContain('logs-viewer-end')
|
||||
->toContain('logs-viewer-status-badge')
|
||||
@@ -119,22 +120,60 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
|
||||
->toContain('flex-direction: column')
|
||||
->toContain('@media (min-width: 640px)');
|
||||
|
||||
// Status badge lives in the right-side end group, immediately before cancel controls.
|
||||
$primaryGroup = str($deploymentView)
|
||||
->after('class="logs-viewer-primary"')
|
||||
->before('class="logs-viewer-end"')
|
||||
->toString();
|
||||
$endGroup = str($deploymentView)
|
||||
->after('class="logs-viewer-end"')
|
||||
->before('logs-viewer-viewport')
|
||||
->toString();
|
||||
|
||||
expect($endGroup)
|
||||
expect($primaryGroup)
|
||||
->toContain('logs-viewer-status-badge')
|
||||
->toContain('livewire:project.application.deployment-navbar');
|
||||
->not->toContain('livewire:project.application.deployment-navbar');
|
||||
|
||||
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
|
||||
$navbarPos = strpos($endGroup, 'livewire:project.application.deployment-navbar');
|
||||
$timestampPos = strpos($primaryGroup, 'Toggle Timestamps');
|
||||
$followPos = strpos($primaryGroup, 'Follow Logs');
|
||||
$debugPos = strpos($primaryGroup, 'wire:click="toggleDebug"');
|
||||
$fullscreenPos = strpos($primaryGroup, 'title="Fullscreen"');
|
||||
$copyPos = strpos($primaryGroup, 'title="Copy Logs"');
|
||||
$downloadPos = strpos($primaryGroup, 'title="Download Logs"');
|
||||
$badgePos = strpos($primaryGroup, 'logs-viewer-status-badge');
|
||||
|
||||
expect($badgePos)->not->toBeFalse()
|
||||
->and($navbarPos)->not->toBeFalse()
|
||||
->and($badgePos)->toBeLessThan($navbarPos);
|
||||
expect([$timestampPos, $followPos, $debugPos, $fullscreenPos, $copyPos, $downloadPos, $badgePos])
|
||||
->not->toContain(false)
|
||||
->and($timestampPos)->toBeLessThan($followPos)
|
||||
->and($followPos)->toBeLessThan($debugPos)
|
||||
->and($debugPos)->toBeLessThan($fullscreenPos)
|
||||
->and($fullscreenPos)->toBeLessThan($copyPos)
|
||||
->and($copyPos)->toBeLessThan($downloadPos)
|
||||
->and($downloadPos)->toBeLessThan($badgePos)
|
||||
->and($endGroup)->toContain('livewire:project.application.deployment-navbar')
|
||||
->toContain('Find in logs');
|
||||
|
||||
$runtimeActionsPos = strpos($sharedLogsView, 'class="logs-viewer-actions"');
|
||||
$runtimeEndPos = strpos($sharedLogsView, 'class="logs-viewer-end runtime-logs-viewer-end"');
|
||||
$runtimeLinesPos = strpos($sharedLogsView, 'class="logs-viewer-lines"');
|
||||
$runtimeSearchPos = strpos($sharedLogsView, 'placeholder="Find in logs"');
|
||||
|
||||
expect([$runtimeActionsPos, $runtimeEndPos, $runtimeLinesPos, $runtimeSearchPos])
|
||||
->not->toContain(false)
|
||||
->and($runtimeActionsPos)->toBeLessThan($runtimeEndPos)
|
||||
->and($runtimeEndPos)->toBeLessThan($runtimeLinesPos)
|
||||
->and($runtimeLinesPos)->toBeLessThan($runtimeSearchPos)
|
||||
->and($appCss)->toContain('.runtime-logs-viewer-end')
|
||||
->toContain('.dark .runtime-log-icon-button-active')
|
||||
->toContain('background: var(--color-coollabs)')
|
||||
->toContain(".dark .runtime-log-icon-button {\n color: var(--color-fg-faint);")
|
||||
->toContain(".dark .runtime-log-icon-button-active {\n background: var(--color-coollabs);\n color: #fff;")
|
||||
->toContain(".runtime-log-toolbar {\n position: relative;\n z-index: 20;")
|
||||
->and($sharedLogsView)
|
||||
->toContain('runtime-log-icon-button order-1')
|
||||
->toContain('runtime-log-icon-button order-2')
|
||||
->toContain('runtime-log-icon-button order-5')
|
||||
->toContain('runtime-log-icon-button order-6')
|
||||
->toContain('relative order-7 shrink-0');
|
||||
});
|
||||
|
||||
it('places cancel deployment controls inside the deployment logs toolbar', function () {
|
||||
@@ -180,15 +219,15 @@ it('places cancel deployment controls inside the deployment logs toolbar', funct
|
||||
->and($endPos)->toBeGreaterThan($toolbarPos)
|
||||
->and($endPos)->toBeLessThan($viewportPos);
|
||||
|
||||
// Within the right-side end group: status badge, then cancel controls.
|
||||
// Within the right-side end group: cancel controls, then log search.
|
||||
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
|
||||
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
|
||||
$cancelPos = strpos($endGroup, 'Cancel deployment');
|
||||
$searchPos = strpos($endGroup, 'Find in logs');
|
||||
|
||||
expect($badgePos)->not->toBeFalse()
|
||||
->and($cancelPos)->not->toBeFalse()
|
||||
->and($badgePos)->toBeLessThan($cancelPos)
|
||||
->and($endGroup)->toContain('In progress');
|
||||
expect($cancelPos)->not->toBeFalse()
|
||||
->and($searchPos)->not->toBeFalse()
|
||||
->and($cancelPos)->toBeLessThan($searchPos)
|
||||
->and($content)->toContain('In progress');
|
||||
});
|
||||
|
||||
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
|
||||
|
||||
@@ -9,6 +9,8 @@ test('resource environment variables table has a Managed column and no name-cell
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
$filter = file_get_contents(resource_path('views/components/table/filter.blade.php'));
|
||||
$loading = file_get_contents(resource_path('views/components/table/loading.blade.php'));
|
||||
|
||||
// Header includes Managed between Name and Type.
|
||||
expect($all)
|
||||
@@ -17,13 +19,8 @@ test('resource environment variables table has a Managed column and no name-cell
|
||||
->toContain('toggleServiceFilter(@js($serviceName))')
|
||||
->toContain('$this->serviceFilterOptions')
|
||||
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
|
||||
->toContain('wire:loading.flex wire:target="clearFilters"')
|
||||
->toContain('wire:click="clearFilters"')
|
||||
->toContain('Clear filters')
|
||||
->toContain('max-h-80 overflow-y-auto p-1')
|
||||
->toContain('min-w-44! overflow-hidden! p-0!')
|
||||
->toContain('relative z-20 border-t')
|
||||
->toContain('dark:bg-[#171717]')
|
||||
->toContain('<x-table.loading target="clearFilters"')
|
||||
->toContain('reset-action="clearFilters"')
|
||||
->not->toContain("'all' => 'All variables'")
|
||||
->toContain("setTableSort('{{ \$value }}')")
|
||||
->toContain('Loading environment variables...')
|
||||
@@ -35,12 +32,17 @@ test('resource environment variables table has a Managed column and no name-cell
|
||||
->toContain("'literal' => 'Literal'")
|
||||
->toContain('$activeFilterCount')
|
||||
->toContain('$activeFilterText')
|
||||
->toContain("'button max-w-80 min-w-0'")
|
||||
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
|
||||
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
|
||||
->toContain('>Name</span>')
|
||||
->toContain('>Managed</span>')
|
||||
->toContain('>Type</span>');
|
||||
->toContain('>Type</span>')
|
||||
->and($filter)
|
||||
->toContain('Reset filters')
|
||||
->toContain('max-h-80 overflow-y-auto p-1')
|
||||
->toContain('min-w-44! overflow-hidden! p-0!')
|
||||
->and($loading)
|
||||
->toContain('wire:loading.flex');
|
||||
|
||||
// Name cell does not repeat the environment type; Type owns Production/Preview.
|
||||
expect($show)
|
||||
@@ -104,3 +106,11 @@ test('managed environment variables are ordered first', function () {
|
||||
->toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
|
||||
->toMatch("/'kind' => 'hardcoded',[\\s\\S]+?'kind' => 'managed'/");
|
||||
});
|
||||
|
||||
test('environment variable toolbar does not use blade directives inside component attributes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
|
||||
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
it('provides search and persisted grid and table views for infrastructure lists', function (string $view, string $item, string $storageKey) {
|
||||
$contents = file_get_contents(resource_path($view));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'placeholder' => 'Search {$item}'")
|
||||
->toContain("localStorage.getItem('{$storageKey}') || 'table'")
|
||||
->toContain("localStorage.setItem('{$storageKey}', mode)")
|
||||
->toContain("@include('livewire.shared.list-search-empty'");
|
||||
})->with([
|
||||
'sources' => ['views/source/all.blade.php', 'sources', 'coolify-sources-view'],
|
||||
'destinations' => ['views/livewire/destination/index.blade.php', 'destinations', 'coolify-destinations-view'],
|
||||
'S3 storages' => ['views/livewire/storage/index.blade.php', 'S3 storages', 'coolify-s3-storages-view'],
|
||||
]);
|
||||
|
||||
it('renders shared search controls with result counts and view switchers', function () {
|
||||
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
|
||||
$emptyState = file_get_contents(resource_path('views/livewire/shared/list-search-empty.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('x-model.debounce.150ms="search"')
|
||||
->toContain('filteredItems.length')
|
||||
->toContain("setViewMode('table')")
|
||||
->toContain("setViewMode('grid')")
|
||||
->toContain('aria-label="Table view"')
|
||||
->toContain('aria-label="Grid view"')
|
||||
->toContain('control-selected')
|
||||
->and($emptyState)->toContain('filteredItems.length === 0');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Status as DatabaseStatus;
|
||||
use App\Livewire\Project\Service\Status as ServiceStatus;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
});
|
||||
|
||||
it('refreshes the breadcrumb database status after it changes', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(DatabaseStatus::class, ['database' => $database])
|
||||
->assertSee('Running');
|
||||
|
||||
$database->update(['status' => 'exited']);
|
||||
|
||||
$component
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
|
||||
it('refreshes the breadcrumb service status after a child status changes', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$application = ServiceApplication::forceCreate([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'service_id' => $service->id,
|
||||
'name' => 'web',
|
||||
'human_name' => 'Web',
|
||||
'image' => 'nginx:alpine',
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ServiceStatus::class, ['service' => $service->fresh(['applications', 'databases'])])
|
||||
->assertSee('Running');
|
||||
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
$component
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
@@ -23,7 +23,9 @@ it('collapses server subsystem badges into one status summary', function () {
|
||||
->toContain('<x-loading compact />')
|
||||
->toContain('name="refresh" class="size-2.5 opacity-70"')
|
||||
->toContain('min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px]')
|
||||
->not->toContain('@click="open = false" role="menuitem"');
|
||||
->toContain("href=\"{{ route('server.proxy', ['server_uuid' => \$server->uuid]) }}\"")
|
||||
->toContain("href=\"{{ route('server.sentinel', ['server_uuid' => \$server->uuid]) }}\"")
|
||||
->toContain('@click="open = false" role="menuitem"');
|
||||
|
||||
expect($badgeView)
|
||||
->not->toContain('text-neutral-500')
|
||||
|
||||
@@ -109,6 +109,7 @@ it('groups configured domains with their service redirect and excludes services
|
||||
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
|
||||
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
|
||||
->not->toContain("service-domain-group-{$this->webApp->id}")
|
||||
->and(substr_count($html, '2 domains'))->toBe(1)
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared variables family: hide the page H1 on desktop; editor view toggle lives in layer-2 nav.
|
||||
*/
|
||||
test('shared variables pages hide the family title on desktop', function () {
|
||||
test('shared variables pages use the shared variables submenu instead of horizontal tabs', function () {
|
||||
$paths = [
|
||||
resource_path('views/livewire/shared-variables/index.blade.php'),
|
||||
resource_path('views/livewire/shared-variables/project/index.blade.php'),
|
||||
@@ -14,9 +11,54 @@ test('shared variables pages hide the family title on desktop', function () {
|
||||
|
||||
foreach ($paths as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('section="shared-variables"')
|
||||
->toContain(':titleOnDesktop="false"');
|
||||
->toContain('<x-shared-variables.layout')
|
||||
->not->toContain('section="shared-variables"');
|
||||
}
|
||||
|
||||
$layout = file_get_contents(resource_path('views/components/shared-variables/layout.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain("'Overview'")
|
||||
->toContain("'Team'")
|
||||
->toContain("'Projects'")
|
||||
->toContain("'Environments'")
|
||||
->toContain("'Servers'")
|
||||
->toContain("request()->routeIs('shared-variables.project.*')")
|
||||
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
|
||||
->toContain("'menu-item-active' => \$menuItem['active']");
|
||||
});
|
||||
|
||||
test('shared variables are not registered as dashboard tabs', function () {
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($navbar)
|
||||
->not->toContain("'shared-variables' => [")
|
||||
->not->toContain('$stackTabsOnMobile')
|
||||
->not->toContain('$sharedVariableIcons');
|
||||
});
|
||||
|
||||
test('shared variable collection pages provide search and persistent grid and list views', function () {
|
||||
$paths = [
|
||||
resource_path('views/livewire/shared-variables/project/index.blade.php'),
|
||||
resource_path('views/livewire/shared-variables/environment/index.blade.php'),
|
||||
resource_path('views/livewire/shared-variables/server/index.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('<x-shared-variables.view-controls')
|
||||
->toContain("x-show=\"viewMode === 'grid'\"")
|
||||
->toContain("x-show=\"viewMode === 'list'\"")
|
||||
->toContain('matches(');
|
||||
}
|
||||
|
||||
$controls = file_get_contents(resource_path('views/components/shared-variables/view-controls.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('placeholder="Search {{ strtolower($label) }}"')
|
||||
->toContain('aria-label="List view"')
|
||||
->toContain('aria-label="Grid view"')
|
||||
->toContain("localStorage.setItem('{{ \$storageKey }}'");
|
||||
});
|
||||
|
||||
test('shared variables editor places the view toggle in the variables section title', function () {
|
||||
@@ -32,18 +74,6 @@ test('shared variables editor places the view toggle in the variables section ti
|
||||
->not->toContain('actionsInTitle');
|
||||
});
|
||||
|
||||
test('shared variables navigation uses the standard mobile settings menu', function () {
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($navbar)
|
||||
->toContain("\$stackTabsOnMobile = \$section === 'shared-variables'")
|
||||
->toContain('grid grid-cols-2 gap-0.5 border-y')
|
||||
->toContain("'menu-item'")
|
||||
->toContain("'menu-item-active' => \$item['active']")
|
||||
->toContain("\$sharedVariableIcons[\$item['label']]")
|
||||
->toContain('hidden lg:flex');
|
||||
});
|
||||
|
||||
test('shared variables table omits resource-only flag columns', function () {
|
||||
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('keeps server submenu state independent from the Livewire update route', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
$dynamicConfigurations = file_get_contents(resource_path('views/livewire/server/proxy/dynamic-configurations.blade.php'));
|
||||
$proxyConfiguration = file_get_contents(resource_path('views/livewire/server/proxy/show.blade.php'));
|
||||
$proxyLogs = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'active' => \$activeMenu === 'proxy'")
|
||||
->toContain("'active' => \$activeSubMenu === 'dynamic-confs'")
|
||||
->not->toContain("'active' => request()->routeIs('server.proxy.dynamic-confs')")
|
||||
->and($dynamicConfigurations)->toContain('activeSubMenu="dynamic-confs"')
|
||||
->and($proxyConfiguration)->toContain('activeSubMenu="configuration"')
|
||||
->and($proxyLogs)->toContain('activeSubMenu="logs"');
|
||||
});
|
||||
|
||||
it('initializes persisted sidebar state before enabling layout transitions', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
@@ -40,7 +55,7 @@ it('separates the mobile sidebar from the page with a visible border', function
|
||||
expect($layout)->toContain('max-w-56 min-w-0 flex-col border-l border-neutral-200 bg-white shadow-xl dark:border-white/[0.12] dark:bg-panel');
|
||||
});
|
||||
|
||||
it('truncates long team names within the mobile header', function () {
|
||||
it('shows the full team name in the header', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));
|
||||
|
||||
@@ -49,8 +64,22 @@ it('truncates long team names within the mobile header', function () {
|
||||
->toContain('size-8 shrink-0 items-center justify-center rounded-lg')
|
||||
->toContain('flex shrink-0 items-center gap-1')
|
||||
->and($switcher)
|
||||
->toContain('group/team flex h-8 min-w-0 max-w-full items-center')
|
||||
->toContain('min-w-0 truncate text-[13px]');
|
||||
->toContain('group/team flex h-8 items-center')
|
||||
->toContain('whitespace-nowrap text-[13px]')
|
||||
->not->toContain('max-w-56 truncate text-[13px]');
|
||||
});
|
||||
|
||||
it('allows more of the team name to display in the desktop breadcrumb', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('class="shrink-0" x-data="{ collapsed: false }"')
|
||||
->toContain('<div class="flex min-w-0 items-center gap-0.5 text-[13px]">')
|
||||
->not->toContain('<div class="flex w-full min-w-0 items-center gap-0.5 text-[13px]">');
|
||||
|
||||
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));
|
||||
|
||||
expect($switcher)->toContain('whitespace-nowrap text-[13px]');
|
||||
});
|
||||
|
||||
it('shows section titles and descriptions above settings navigation on smaller screens', function (string $view, string $title, string $description) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('renders the standard table toolbar controls', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-table.toolbar>
|
||||
<x-slot:search>
|
||||
<x-table.search placeholder="Search deployments" wire:model.live="search" />
|
||||
</x-slot:search>
|
||||
<x-table.filter :active-count="2" reset-action="clearFilters">
|
||||
<button class="listbox-option">Success</button>
|
||||
</x-table.filter>
|
||||
<x-table.sort>
|
||||
<button class="listbox-option">Newest first</button>
|
||||
</x-table.sort>
|
||||
</x-table.toolbar>
|
||||
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('<div class="relative"><x-table.loading target="applyFilters" text="Loading records..." /></div>');
|
||||
|
||||
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('<span>Loading records...</span>');
|
||||
});
|
||||
|
||||
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('<x-table.toolbar')
|
||||
->toContain('<x-table.search')
|
||||
->toContain('<x-table.filter')
|
||||
->toContain('<x-table.sort')
|
||||
->toContain('<x-table.loading');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the standard search control for frontend filtered infrastructure tables', function () {
|
||||
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('<x-table.search')
|
||||
->toContain('clear-when="search"')
|
||||
->toContain("clear-action=\"search = ''\"");
|
||||
});
|
||||
@@ -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('<livewire:project.application.status')
|
||||
->toContain('<livewire:project.database.status')
|
||||
->toContain('<livewire:project.service.status')
|
||||
->not->toContain('$applicationStatus = str($currentApplication->status');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('respects the instance SPA navigation setting after application actions', function () {
|
||||
$heading = file_get_contents(__DIR__.'/../../app/Livewire/Project/Application/Heading.php');
|
||||
|
||||
expect(substr_count($heading, "return redirectRoute(\$this, 'project.application.deployment.show'"))
|
||||
->toBe(2)
|
||||
->and($heading)
|
||||
->not->toContain('navigate: false');
|
||||
});
|
||||
Reference in New Issue
Block a user