mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-20 02:17:09 +00: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:
1 parent
66b202adfe
commit
2a5197fb30
57 files changed
+1415
-579
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user