mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(tables): sort tables by clicking a column header
This commit is contained in:
@@ -32,6 +32,11 @@ class DockerImages extends Component
|
||||
|
||||
public int $perPage = 10;
|
||||
|
||||
/**
|
||||
* Column sort such as "size_desc". Empty keeps the order of `docker image ls` (newest first).
|
||||
*/
|
||||
public string $sort = '';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
@@ -43,6 +48,32 @@ class DockerImages extends Component
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setSort(string $sort): void
|
||||
{
|
||||
if (! array_key_exists(str($sort)->beforeLast('_')->value(), $this->sortColumns())
|
||||
|| ! in_array(str($sort)->afterLast('_')->value(), ['asc', 'desc'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sort = $sort;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Closure(array): mixed>
|
||||
*/
|
||||
private function sortColumns(): array
|
||||
{
|
||||
return [
|
||||
'repository' => fn (array $image) => $image['repository'].':'.$image['tag'],
|
||||
'tag' => fn (array $image) => $image['tag'],
|
||||
'id' => fn (array $image) => $image['short_id'],
|
||||
'created' => fn (array $image) => $image['created_at'] ?? 0,
|
||||
'size' => fn (array $image) => $image['size_bytes'] ?? 0,
|
||||
'used' => fn (array $image) => implode(', ', $image['containers']),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
@@ -161,11 +192,27 @@ class DockerImages extends Component
|
||||
// removed by ID, Docker refuses with "image is referenced in multiple repositories".
|
||||
'reference' => $isDangling ? $id : "{$repository}:{$tag}",
|
||||
'size' => data_get($image, 'Size'),
|
||||
'size_bytes' => $this->sizeToBytes((string) data_get($image, 'Size')),
|
||||
'created' => data_get($image, 'CreatedSince'),
|
||||
// Docker prints "2024-05-01 10:00:00 +0000 UTC"; the trailing zone name breaks strtotime().
|
||||
'created_at' => strtotime(str((string) data_get($image, 'CreatedAt'))->replaceMatches('/ [A-Z]+$/', '')->value()) ?: 0,
|
||||
'containers' => $containersByImage[$id] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker prints sizes with decimal units, for example "512kB", "52.5MB" or "1.2GB".
|
||||
*/
|
||||
private function sizeToBytes(string $size): int
|
||||
{
|
||||
if (! preg_match('/^([\d.]+)\s*([kKMGTP]?)B$/', trim($size), $matches)) {
|
||||
return 0;
|
||||
}
|
||||
$exponent = strpos('KMGTP', strtoupper($matches[2]));
|
||||
|
||||
return (int) round((float) $matches[1] * 1000 ** ($matches[2] === '' ? 0 : $exponent + 1));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$search = trim($this->search);
|
||||
@@ -175,6 +222,14 @@ class DockerImages extends Component
|
||||
->contains($search, ignoreCase: true));
|
||||
}
|
||||
|
||||
$column = $this->sortColumns()[str($this->sort)->beforeLast('_')->value()] ?? null;
|
||||
if ($column) {
|
||||
$images = $images->sortBy(
|
||||
fn (array $image) => [$column($image), $image['reference']],
|
||||
descending: str($this->sort)->endsWith('_desc'),
|
||||
);
|
||||
}
|
||||
|
||||
$this->perPage = max(1, min(100, $this->perPage));
|
||||
$lastPage = max(1, (int) ceil($images->count() / $this->perPage));
|
||||
$page = max(1, min((int) $this->getPage(), $lastPage));
|
||||
|
||||
@@ -22,6 +22,8 @@ class Resources extends Component
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $sort = 'name_asc';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
@@ -33,6 +35,38 @@ class Resources extends Component
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setSort(string $sort): void
|
||||
{
|
||||
if (! preg_match('/^(\w+)_(asc|desc)$/', $sort, $matches) || ! array_key_exists($matches[1], $this->sortColumns())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sort = $sort;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Closure(mixed): string>
|
||||
*/
|
||||
private function sortColumns(): array
|
||||
{
|
||||
if ($this->activeTab === 'managed') {
|
||||
return [
|
||||
'name' => fn ($resource): string => (string) data_get($resource, 'name'),
|
||||
'project' => fn ($resource): string => (string) data_get($resource->project(), 'name'),
|
||||
'environment' => fn ($resource): string => (string) data_get($resource, 'environment.name'),
|
||||
'type' => fn ($resource): string => (string) $resource->type(),
|
||||
'status' => fn ($resource): string => (string) data_get($resource, 'status'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => fn ($container): string => (string) data_get($container, 'Names'),
|
||||
'image' => fn ($container): string => (string) data_get($container, 'Image'),
|
||||
'status' => fn ($container): string => (string) data_get($container, 'State'),
|
||||
];
|
||||
}
|
||||
|
||||
public ?Server $server = null;
|
||||
|
||||
public $parameters = [];
|
||||
@@ -115,6 +149,7 @@ class Resources extends Component
|
||||
try {
|
||||
if ($this->activeTab !== 'managed') {
|
||||
$this->search = '';
|
||||
$this->sort = 'name_asc';
|
||||
$this->resetPage();
|
||||
}
|
||||
$this->activeTab = 'managed';
|
||||
@@ -128,6 +163,7 @@ class Resources extends Component
|
||||
{
|
||||
if ($this->activeTab !== 'unmanaged') {
|
||||
$this->search = '';
|
||||
$this->sort = 'name_asc';
|
||||
$this->resetPage();
|
||||
}
|
||||
$this->activeTab = 'unmanaged';
|
||||
@@ -156,9 +192,14 @@ class Resources extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
$resources = $this->activeTab === 'managed'
|
||||
? $this->server->definedResources()->sortBy('name', SORT_NATURAL)
|
||||
: collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL);
|
||||
$columns = $this->sortColumns();
|
||||
[$column, $direction] = explode('_', $this->sort, 2) + [1 => 'asc'];
|
||||
$sortValue = $columns[$column] ?? $columns['name'];
|
||||
$resources = ($this->activeTab === 'managed'
|
||||
? $this->server->definedResources()
|
||||
: collect($this->unmanagedContainers))
|
||||
->sortBy($columns['name'], SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->sortBy($sortValue, SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc');
|
||||
$search = trim($this->search);
|
||||
if ($search !== '') {
|
||||
$nameKey = $this->activeTab === 'managed' ? 'name' : 'Names';
|
||||
|
||||
@@ -42,6 +42,10 @@ class Show extends Component
|
||||
#[Locked]
|
||||
public ?array $deploymentsPerTagPerServer = null;
|
||||
|
||||
/** Sort of the active deployments table, such as `resource-asc`; null keeps them grouped by server. */
|
||||
#[Locked]
|
||||
public ?string $deploymentSort = null;
|
||||
|
||||
/** The "Connect to resources" modal on small screens, where the tags canvas is not shown. */
|
||||
public bool $connectResourcesOpen = false;
|
||||
|
||||
@@ -103,6 +107,37 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function setDeploymentSort(string $sort): void
|
||||
{
|
||||
if (! in_array($sort, ['resource-asc', 'resource-desc', 'server-asc', 'server-desc', 'status-asc', 'status-desc'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deploymentSort = $sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active deployments as one list, in the order of the chosen table sort.
|
||||
*
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function activeDeployments(): Collection
|
||||
{
|
||||
$deployments = collect($this->deploymentsPerTagPerServer ?? [])->flatten(1);
|
||||
if ($this->deploymentSort === null) {
|
||||
return $deployments->values();
|
||||
}
|
||||
|
||||
[$column, $direction] = explode('-', $this->deploymentSort, 2);
|
||||
$field = ['resource' => 'application_name', 'server' => 'server_name', 'status' => 'status'][$column];
|
||||
$order = $direction === 'desc' ? -1 : 1;
|
||||
|
||||
return $deployments
|
||||
->sort(fn (array $left, array $right): int => ($order * strcasecmp((string) $left[$field], (string) $right[$field]))
|
||||
?: $left['id'] <=> $right['id'])
|
||||
->values();
|
||||
}
|
||||
|
||||
public function redeployAll()
|
||||
{
|
||||
try {
|
||||
@@ -193,6 +228,7 @@ class Show extends Component
|
||||
];
|
||||
})->values()->toArray(),
|
||||
'connectableResources' => $this->connectableResources(),
|
||||
'activeDeployments' => $this->activeDeployments(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Team;
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
@@ -20,6 +21,17 @@ class AuditLog extends Component
|
||||
|
||||
public int $perPage = 25;
|
||||
|
||||
#[Locked]
|
||||
public string $sort = 'time-desc';
|
||||
|
||||
/** @var array<string, string> sort value prefix => audit_events column */
|
||||
private const SORT_COLUMNS = [
|
||||
'time' => 'created_at',
|
||||
'actor' => 'actor_name',
|
||||
'activity' => 'description',
|
||||
'source' => 'source',
|
||||
];
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$team = currentTeam();
|
||||
@@ -42,6 +54,18 @@ class AuditLog extends Component
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setSort(string $sort): void
|
||||
{
|
||||
[$column, $direction] = array_pad(explode('-', $sort, 2), 2, null);
|
||||
|
||||
if (! array_key_exists($column, self::SORT_COLUMNS) || ! in_array($direction, ['asc', 'desc'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sort = $sort;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedPerPage(): void
|
||||
{
|
||||
$this->perPage = max(10, min(100, $this->perPage));
|
||||
@@ -67,6 +91,10 @@ class AuditLog extends Component
|
||||
$events = AuditEvent::query()
|
||||
->visibleToTeam($teamId, $canViewInstanceEvents)
|
||||
->filtered($search, $this->action, $this->source)
|
||||
->when($this->sort !== 'time-desc', function ($query): void {
|
||||
[$column, $direction] = explode('-', $this->sort, 2);
|
||||
$query->orderBy(self::SORT_COLUMNS[$column], $direction);
|
||||
})
|
||||
->latestFirst()
|
||||
->paginate($this->perPage);
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Team;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class Invitations extends Component
|
||||
@@ -14,6 +17,10 @@ class Invitations extends Component
|
||||
|
||||
public $invitations;
|
||||
|
||||
/** Sort value such as `email-asc`; null keeps the loaded order. */
|
||||
#[Locked]
|
||||
public ?string $sort = null;
|
||||
|
||||
protected $listeners = ['refreshInvitations'];
|
||||
|
||||
public function deleteInvitation(int $invitation_id)
|
||||
@@ -43,6 +50,42 @@ class Invitations extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function setSort(string $sort): void
|
||||
{
|
||||
if (! in_array($sort, ['email-asc', 'email-desc', 'via-asc', 'via-desc', 'role-asc', 'role-desc'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sort = $sort;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.team.invitations', [
|
||||
'sortedInvitations' => $this->sortedInvitations(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function sortedInvitations(): Collection
|
||||
{
|
||||
$invitations = collect($this->invitations);
|
||||
if ($this->sort === null) {
|
||||
return $invitations;
|
||||
}
|
||||
|
||||
[$column, $direction] = explode('-', $this->sort, 2);
|
||||
$sortValue = fn (TeamInvitation $invitation): int|string => $column === 'role'
|
||||
? (Role::tryFrom((string) $invitation->role)?->rank() ?? 0)
|
||||
: strtolower((string) $invitation->{$column});
|
||||
|
||||
$order = $direction === 'desc' ? -1 : 1;
|
||||
|
||||
return $invitations
|
||||
->sort(fn (TeamInvitation $left, TeamInvitation $right): int => ($order * ($sortValue($left) <=> $sortValue($right)))
|
||||
?: strcasecmp((string) $left->email, (string) $right->email))
|
||||
->values();
|
||||
}
|
||||
|
||||
public function refreshInvitations()
|
||||
{
|
||||
$this->invitations = TeamInvitation::ownedByCurrentTeam()->get();
|
||||
|
||||
+48
-1
@@ -3045,6 +3045,53 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
color: var(--color-fg-faint);
|
||||
}
|
||||
|
||||
.table-sort-header {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 0.25rem;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
.table-sort-header:hover,
|
||||
.table-sort-header.is-sorted {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.dark .table-sort-header:hover,
|
||||
.dark .table-sort-header.is-sorted {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.table-sort-header:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--color-accent, currentColor);
|
||||
}
|
||||
|
||||
.table-sort-header-icon {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms, transform 150ms;
|
||||
}
|
||||
|
||||
.table-sort-header:hover .table-sort-header-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.table-sort-header-icon.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.data-table > .env-table-item {
|
||||
border-bottom: 1px solid var(--coollabs-fill);
|
||||
}
|
||||
@@ -3453,7 +3500,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem;
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 6rem;
|
||||
min-width: 50rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{--
|
||||
A column header that sorts its table. The first click sorts by `first`, the next
|
||||
click flips the direction. An arrow shows the active direction.
|
||||
|
||||
Alpine tables set `model` (the sort variable in the enclosing x-data scope) and an
|
||||
optional `then` expression that runs after the change (for example `page = 1`).
|
||||
Livewire tables set `action` (a component method that takes the sort value) and
|
||||
`current` (the current server-side sort value).
|
||||
|
||||
@param string $label
|
||||
@param string $asc sort value for ascending order
|
||||
@param string $desc sort value for descending order
|
||||
@param string $first 'asc' or 'desc': the direction of the first click
|
||||
--}}
|
||||
@props([
|
||||
'label',
|
||||
'asc',
|
||||
'desc',
|
||||
'first' => 'asc',
|
||||
'model' => 'sortBy',
|
||||
'then' => null,
|
||||
'action' => null,
|
||||
'current' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$firstValue = $first === 'desc' ? $desc : $asc;
|
||||
$otherValue = $first === 'desc' ? $asc : $desc;
|
||||
$ariaBase = 'Sort by '.strtolower($label);
|
||||
@endphp
|
||||
|
||||
@if ($action)
|
||||
@php
|
||||
$direction = match ($current) {
|
||||
$asc => 'asc',
|
||||
$desc => 'desc',
|
||||
default => null,
|
||||
};
|
||||
$nextValue = $current === $firstValue ? $otherValue : $firstValue;
|
||||
@endphp
|
||||
<button type="button" wire:click="{{ $action }}(@js($nextValue))"
|
||||
aria-label="{{ $ariaBase }}{{ $direction ? ', sorted '.($direction === 'asc' ? 'ascending' : 'descending') : '' }}"
|
||||
{{ $attributes->class(['table-sort-header group', 'is-sorted' => $direction]) }}>
|
||||
<span class="truncate">{{ $label }}</span>
|
||||
<svg @class(['table-sort-header-icon', 'rotate-180' => $direction === 'desc', 'is-visible' => $direction]) viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 15l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
@else
|
||||
<button type="button"
|
||||
x-on:click="{{ $model }} = ({{ $model }} === @js($firstValue) ? @js($otherValue) : @js($firstValue)){{ $then ? '; '.$then : '' }}"
|
||||
x-bind:aria-label="@js($ariaBase) + ({{ $model }} === @js($asc) ? ', sorted ascending' : ({{ $model }} === @js($desc) ? ', sorted descending' : ''))"
|
||||
x-bind:class="{ 'is-sorted': [@js($asc), @js($desc)].includes({{ $model }}) }"
|
||||
{{ $attributes->class(['table-sort-header group']) }}>
|
||||
<span class="truncate">{{ $label }}</span>
|
||||
<svg class="table-sort-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
x-bind:class="{ 'is-visible': [@js($asc), @js($desc)].includes({{ $model }}), 'rotate-180': {{ $model }} === @js($desc) }">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 15l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
@@ -9,6 +9,15 @@
|
||||
'name' => strtolower($backup->targetName()),
|
||||
'type' => strtolower($backup->targetType()),
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
'status' => match ($backup->latestExecution?->status) {
|
||||
'running' => 'in progress',
|
||||
'success' => 'success',
|
||||
'failed' => 'failed',
|
||||
default => $backup->enabled ? 'waiting' : 'disabled',
|
||||
},
|
||||
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->count() : -1,
|
||||
'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0),
|
||||
'executions' => $backup->executions_count,
|
||||
'createdAt' => $backup->created_at?->timestamp ?? 0,
|
||||
])->values()),
|
||||
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
|
||||
@@ -30,11 +39,25 @@
|
||||
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
const [column, direction] = this.sortBy.split('_');
|
||||
const sortValues = {
|
||||
target: (backup) => backup.name,
|
||||
type: (backup) => backup.type,
|
||||
schedule: (backup) => backup.frequency,
|
||||
status: (backup) => backup.status,
|
||||
s3: (backup) => backup.s3,
|
||||
lastrun: (backup) => backup.lastRunAt,
|
||||
executions: (backup) => backup.executions,
|
||||
};
|
||||
const sortValue = sortValues[column] || sortValues.target;
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
return filtered.sort((left, right) => {
|
||||
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
|
||||
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
|
||||
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
|
||||
return left.name.localeCompare(right.name);
|
||||
const leftValue = sortValue(left);
|
||||
const rightValue = sortValue(right);
|
||||
return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) ||
|
||||
left.name.localeCompare(right.name);
|
||||
});
|
||||
},
|
||||
isVisible(id) {
|
||||
@@ -168,13 +191,14 @@
|
||||
@if ($backups->isNotEmpty())
|
||||
<div class="data-table flex w-full flex-col overflow-x-auto" x-show="filteredBackups.length > 0">
|
||||
<div class="data-table-header backup-table-grid">
|
||||
<span>Target</span>
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
<x-table.sort-header label="Target" asc="target_asc" desc="target_desc" />
|
||||
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" />
|
||||
<x-table.sort-header label="Schedule" asc="schedule_asc" desc="schedule_desc" />
|
||||
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" />
|
||||
<x-table.sort-header label="S3" asc="s3_asc" desc="s3_desc" />
|
||||
<x-table.sort-header label="Last run" asc="lastrun_asc" desc="lastrun_desc" first="desc" />
|
||||
<x-table.sort-header label="Executions" asc="executions_asc" desc="executions_desc" first="desc"
|
||||
class="justify-self-end" />
|
||||
</div>
|
||||
|
||||
@foreach ($backups as $backup)
|
||||
|
||||
@@ -202,7 +202,8 @@
|
||||
<span>Source</span>
|
||||
<span>Commit</span>
|
||||
@unless ($embedded)
|
||||
<span>Started</span>
|
||||
<x-table.sort-header label="Started" asc="oldest" desc="newest" first="desc"
|
||||
action="setDeploymentSort" :current="$deploymentSort" />
|
||||
@endunless
|
||||
<span>Duration</span>
|
||||
<span>Server</span>
|
||||
|
||||
@@ -142,10 +142,11 @@
|
||||
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
|
||||
<div
|
||||
class="projects-table-grid 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.05] dark:text-fg-faint">
|
||||
<div>Project</div>
|
||||
<div>Contents</div>
|
||||
<div>Created</div>
|
||||
<div class="project-description">Description</div>
|
||||
<x-table.sort-header label="Project" asc="name-asc" desc="name-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Contents" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1" />
|
||||
<x-table.sort-header label="Created" asc="created-asc" desc="created-desc" first="desc" then="page = 1" />
|
||||
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
|
||||
class="project-description" />
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
@@ -233,11 +234,11 @@
|
||||
label: 'Name Z–A'
|
||||
},
|
||||
{
|
||||
value: 'resources',
|
||||
value: 'resources-desc',
|
||||
label: 'Most resources'
|
||||
},
|
||||
{
|
||||
value: 'environments',
|
||||
value: 'environments-desc',
|
||||
label: 'Most environments'
|
||||
},
|
||||
],
|
||||
@@ -252,21 +253,23 @@
|
||||
return !query || searchable.includes(query);
|
||||
});
|
||||
|
||||
return projects.sort((first, second) => {
|
||||
if (this.sortBy === 'name-desc') {
|
||||
return second.name.localeCompare(first.name);
|
||||
}
|
||||
if (this.sortBy === 'resources') {
|
||||
return second.resourceCount - first.resourceCount ||
|
||||
first.name.localeCompare(second.name);
|
||||
}
|
||||
if (this.sortBy === 'environments') {
|
||||
return second.environmentCount - first.environmentCount ||
|
||||
first.name.localeCompare(second.name);
|
||||
}
|
||||
const [column, direction] = this.sortBy.split('-');
|
||||
const sortValues = {
|
||||
name: (project) => project.name,
|
||||
resources: (project) => Number(project.resourceCount),
|
||||
environments: (project) => Number(project.environmentCount),
|
||||
created: (project) => Date.parse(project.createdAt) || 0,
|
||||
description: (project) => project.description || '',
|
||||
};
|
||||
const sortValue = sortValues[column] || sortValues.name;
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
const compare = (first, second) => typeof first === 'number' ?
|
||||
first - second :
|
||||
first.localeCompare(second);
|
||||
|
||||
return first.name.localeCompare(second.name);
|
||||
});
|
||||
return projects.sort((first, second) =>
|
||||
order * compare(sortValue(first), sortValue(second)) ||
|
||||
first.name.localeCompare(second.name));
|
||||
},
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.filteredProjects.length / this.pageSize));
|
||||
|
||||
@@ -167,11 +167,11 @@
|
||||
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
|
||||
<div
|
||||
class="environment-resource-grid 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.05] dark:text-fg-faint">
|
||||
<div>Resource</div>
|
||||
<div class="resource-type">Type</div>
|
||||
<div>Status</div>
|
||||
<div class="resource-domain">Domain</div>
|
||||
<div class="resource-server">Server</div>
|
||||
<x-table.sort-header label="Resource" asc="name-asc" desc="name-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Type" asc="type-asc" desc="type-desc" then="page = 1" class="resource-type" />
|
||||
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Domain" asc="domain-asc" desc="domain-desc" then="page = 1" class="resource-domain" />
|
||||
<x-table.sort-header label="Server" asc="server-asc" desc="server-desc" then="page = 1" class="resource-server" />
|
||||
<div class="resource-tags">Tags</div>
|
||||
</div>
|
||||
|
||||
@@ -412,11 +412,11 @@
|
||||
label: 'Name Z–A'
|
||||
},
|
||||
{
|
||||
value: 'type',
|
||||
value: 'type-asc',
|
||||
label: 'Resource type'
|
||||
},
|
||||
{
|
||||
value: 'status',
|
||||
value: 'status-asc',
|
||||
label: 'Status'
|
||||
},
|
||||
],
|
||||
@@ -443,21 +443,20 @@
|
||||
(!query || searchable.includes(query));
|
||||
});
|
||||
|
||||
return items.sort((first, second) => {
|
||||
if (this.sortBy === 'name-desc') {
|
||||
return second.name.localeCompare(first.name);
|
||||
}
|
||||
if (this.sortBy === 'type') {
|
||||
return first.typeLabel.localeCompare(second.typeLabel) ||
|
||||
first.name.localeCompare(second.name);
|
||||
}
|
||||
if (this.sortBy === 'status') {
|
||||
return this.statusLabel(first).localeCompare(this.statusLabel(second)) ||
|
||||
first.name.localeCompare(second.name);
|
||||
}
|
||||
const [column, direction] = this.sortBy.split('-');
|
||||
const sortValues = {
|
||||
name: (item) => item.name,
|
||||
type: (item) => item.typeLabel,
|
||||
status: (item) => this.statusLabel(item),
|
||||
domain: (item) => this.displayDomain(item.fqdn) || '',
|
||||
server: (item) => item.destination?.server?.name || 'Unknown',
|
||||
};
|
||||
const sortValue = sortValues[column] || sortValues.name;
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
|
||||
return first.name.localeCompare(second.name);
|
||||
});
|
||||
return items.sort((first, second) =>
|
||||
order * (sortValue(first).localeCompare(sortValue(second)) ||
|
||||
first.name.localeCompare(second.name)));
|
||||
},
|
||||
uniqueOptions(options) {
|
||||
return [...new Map(options
|
||||
|
||||
@@ -9,12 +9,28 @@
|
||||
'name' => strtolower($backup->targetName()),
|
||||
'type' => strtolower($backup->targetType()),
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
'status' => match ($backup->latestExecution?->status) {
|
||||
'running' => 'in progress',
|
||||
'success' => 'success',
|
||||
'failed' => 'failed',
|
||||
default => $backup->enabled ? 'waiting' : 'disabled',
|
||||
},
|
||||
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1,
|
||||
'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0),
|
||||
'createdAt' => $backup->created_at?->timestamp ?? 0,
|
||||
])->concat($databaseBackups->map(fn ($backup) => [
|
||||
'id' => 'database:'.$backup->id,
|
||||
'name' => strtolower($backup->database->human_name ?: $backup->database->name),
|
||||
'type' => 'database',
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
'status' => match ($backup->latest_log?->status) {
|
||||
'running' => 'in progress',
|
||||
'success' => 'success',
|
||||
'failed' => 'failed',
|
||||
default => $backup->enabled ? 'waiting' : 'disabled',
|
||||
},
|
||||
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1,
|
||||
'lastRunAt' => $backup->latest_log?->finished_at?->timestamp ?? ($backup->latest_log?->status === 'running' ? now()->timestamp : 0),
|
||||
'createdAt' => $backup->created_at?->timestamp ?? 0,
|
||||
]))->values()),
|
||||
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
|
||||
@@ -36,11 +52,24 @@
|
||||
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
const [column, direction] = this.sortBy.split('_');
|
||||
const sortValues = {
|
||||
target: (backup) => backup.name,
|
||||
type: (backup) => backup.type,
|
||||
schedule: (backup) => backup.frequency,
|
||||
status: (backup) => backup.status,
|
||||
s3: (backup) => backup.s3,
|
||||
lastrun: (backup) => backup.lastRunAt,
|
||||
};
|
||||
const sortValue = sortValues[column] || sortValues.target;
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
return filtered.sort((left, right) => {
|
||||
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
|
||||
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
|
||||
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
|
||||
return left.name.localeCompare(right.name);
|
||||
const leftValue = sortValue(left);
|
||||
const rightValue = sortValue(right);
|
||||
return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) ||
|
||||
left.name.localeCompare(right.name);
|
||||
});
|
||||
},
|
||||
isVisible(id) {
|
||||
@@ -241,12 +270,12 @@
|
||||
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
|
||||
<div class="flex min-w-[64rem] flex-col">
|
||||
<div class="data-table-header backup-table-grid service-backup-table-grid">
|
||||
<span>Target</span>
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<x-table.sort-header label="Target" asc="target_asc" desc="target_desc" />
|
||||
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" />
|
||||
<x-table.sort-header label="Schedule" asc="schedule_asc" desc="schedule_desc" />
|
||||
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" />
|
||||
<x-table.sort-header label="S3" asc="s3_asc" desc="s3_desc" />
|
||||
<x-table.sort-header label="Last run" asc="lastrun_asc" desc="lastrun_desc" first="desc" />
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -222,7 +222,8 @@
|
||||
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
|
||||
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
|
||||
<div class="data-table-header env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
|
||||
<span>Name</span>
|
||||
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc"
|
||||
action="setTableSort" :current="$tableSort" />
|
||||
<span class="text-center">Managed</span>
|
||||
@if ($showEnvironmentType)
|
||||
<span>Type</span>
|
||||
|
||||
@@ -148,9 +148,11 @@
|
||||
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
|
||||
<div
|
||||
class="environments-table-grid 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.05] dark:text-fg-faint">
|
||||
<div>Environment</div>
|
||||
<div class="environment-resource-count">Resources</div>
|
||||
<div class="environment-description">Description</div>
|
||||
<x-table.sort-header label="Environment" asc="name-asc" desc="name-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Resources" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1"
|
||||
class="environment-resource-count" />
|
||||
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
|
||||
class="environment-description" />
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
@@ -230,7 +232,7 @@
|
||||
label: 'Name Z–A'
|
||||
},
|
||||
{
|
||||
value: 'resources',
|
||||
value: 'resources-desc',
|
||||
label: 'Most resources'
|
||||
},
|
||||
],
|
||||
@@ -245,21 +247,27 @@
|
||||
return !query || searchable.includes(query);
|
||||
});
|
||||
|
||||
return environments.sort((first, second) => {
|
||||
if (this.sortBy === 'name-desc') {
|
||||
return second.name.localeCompare(first.name);
|
||||
}
|
||||
if (this.sortBy === 'resources') {
|
||||
return second.resourceCount - first.resourceCount ||
|
||||
first.name.localeCompare(second.name);
|
||||
}
|
||||
if (this.sortBy === 'name-asc') {
|
||||
return first.name.localeCompare(second.name);
|
||||
}
|
||||
const [column, direction] = this.sortBy.split('-');
|
||||
const sortValues = {
|
||||
name: (environment) => environment.name,
|
||||
resources: (environment) => Number(environment.resourceCount),
|
||||
description: (environment) => environment.description || '',
|
||||
};
|
||||
const sortValue = sortValues[column];
|
||||
|
||||
// Default: the server order (production, staging, development, then by name).
|
||||
return 0;
|
||||
});
|
||||
// Default: the server order (production, staging, development, then by name).
|
||||
if (!sortValue) {
|
||||
return environments;
|
||||
}
|
||||
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
const compare = (first, second) => typeof first === 'number' ?
|
||||
first - second :
|
||||
first.localeCompare(second);
|
||||
|
||||
return environments.sort((first, second) =>
|
||||
order * compare(sortValue(first), sortValue(second)) ||
|
||||
first.name.localeCompare(second.name));
|
||||
},
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.filteredEnvironments.length / this.pageSize));
|
||||
|
||||
@@ -50,12 +50,18 @@
|
||||
@else
|
||||
<div class="data-table">
|
||||
<div class="data-table-header server-images-table-grid">
|
||||
<span>Repository</span>
|
||||
<span>Tag</span>
|
||||
<span>Image ID</span>
|
||||
<span>Created</span>
|
||||
<span>Size</span>
|
||||
<span>Used by</span>
|
||||
<x-table.sort-header label="Repository" asc="repository_asc" desc="repository_desc"
|
||||
action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Tag" asc="tag_asc" desc="tag_desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Image ID" asc="id_asc" desc="id_desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Created" asc="created_asc" desc="created_desc" first="desc"
|
||||
action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Size" asc="size_asc" desc="size_desc" first="desc"
|
||||
action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Used by" asc="used_asc" desc="used_desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
@foreach ($visibleImages as $image)
|
||||
|
||||
@@ -56,11 +56,11 @@
|
||||
@if ($resources->total() > 0)
|
||||
<div class="data-table">
|
||||
<div class="data-table-header server-resources-managed-table-grid">
|
||||
<span>Name</span>
|
||||
<span>Project</span>
|
||||
<span>Environment</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc" action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Project" asc="project_asc" desc="project_desc" action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Environment" asc="environment_asc" desc="environment_desc" action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" action="setSort" :current="$sort" />
|
||||
</div>
|
||||
@foreach ($resources as $resource)
|
||||
@php($resourceStatus = (string) data_get($resource, 'status', 'unknown'))
|
||||
@@ -109,9 +109,9 @@
|
||||
@if ($resources->total() > 0)
|
||||
<div class="data-table">
|
||||
<div class="data-table-header server-resources-unmanaged-table-grid">
|
||||
<span>Name</span>
|
||||
<span>Image</span>
|
||||
<span>Status</span>
|
||||
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc" action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Image" asc="image_asc" desc="image_desc" action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" action="setSort" :current="$sort" />
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
@foreach ($resources as $resource)
|
||||
|
||||
@@ -126,10 +126,12 @@
|
||||
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
|
||||
<div
|
||||
class="tags-table-grid 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.05] dark:text-fg-faint">
|
||||
<div>Tag</div>
|
||||
<div>Resources</div>
|
||||
<div>Applications</div>
|
||||
<div class="tag-services">Services</div>
|
||||
<x-table.sort-header label="Tag" asc="name-asc" desc="name-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Resources" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1" />
|
||||
<x-table.sort-header label="Applications" asc="applications-asc" desc="applications-desc" first="desc"
|
||||
then="page = 1" />
|
||||
<x-table.sort-header label="Services" asc="services-asc" desc="services-desc" first="desc" then="page = 1"
|
||||
class="tag-services" />
|
||||
</div>
|
||||
|
||||
<template x-for="tag in paginatedTags" :key="tag.id">
|
||||
@@ -185,7 +187,7 @@
|
||||
label: 'Name Z–A'
|
||||
},
|
||||
{
|
||||
value: 'resources',
|
||||
value: 'resources-desc',
|
||||
label: 'Most resources'
|
||||
},
|
||||
],
|
||||
@@ -195,17 +197,22 @@
|
||||
return !query || tag.name.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
return tags.sort((first, second) => {
|
||||
if (this.sortBy === 'name-desc') {
|
||||
return second.name.localeCompare(first.name);
|
||||
}
|
||||
if (this.sortBy === 'resources') {
|
||||
return second.resourceCount - first.resourceCount ||
|
||||
first.name.localeCompare(second.name);
|
||||
}
|
||||
const [column, direction] = this.sortBy.split('-');
|
||||
const sortValues = {
|
||||
name: (tag) => tag.name,
|
||||
resources: (tag) => Number(tag.resourceCount),
|
||||
applications: (tag) => Number(tag.applicationsCount),
|
||||
services: (tag) => Number(tag.servicesCount),
|
||||
};
|
||||
const sortValue = sortValues[column] || sortValues.name;
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
const compare = (first, second) => typeof first === 'number' ?
|
||||
first - second :
|
||||
first.localeCompare(second);
|
||||
|
||||
return first.name.localeCompare(second.name);
|
||||
});
|
||||
return tags.sort((first, second) =>
|
||||
order * compare(sortValue(first), sortValue(second)) ||
|
||||
first.name.localeCompare(second.name));
|
||||
},
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.filteredTags.length / this.pageSize));
|
||||
@@ -421,25 +428,26 @@
|
||||
@else
|
||||
<div
|
||||
class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] 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.05] dark:text-fg-faint">
|
||||
<div>Resource</div>
|
||||
<div>Server</div>
|
||||
<div>Status</div>
|
||||
<x-table.sort-header label="Resource" asc="resource-asc" desc="resource-desc"
|
||||
action="setDeploymentSort" :current="$deploymentSort" />
|
||||
<x-table.sort-header label="Server" asc="server-asc" desc="server-desc"
|
||||
action="setDeploymentSort" :current="$deploymentSort" />
|
||||
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc"
|
||||
action="setDeploymentSort" :current="$deploymentSort" />
|
||||
</div>
|
||||
|
||||
@foreach ($deploymentsPerTagPerServer as $serverName => $deployments)
|
||||
@foreach ($deployments as $deployment)
|
||||
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
|
||||
class="grid min-h-13 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] 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-medium text-black dark:text-fg">
|
||||
{{ data_get($deployment, 'application_name') }}
|
||||
</div>
|
||||
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $serverName }}</div>
|
||||
<div>
|
||||
<x-status-badge :status="str(data_get($deployment, 'status'))->headline()"
|
||||
:type="data_get($deployment, 'status') === 'in_progress' ? 'warning' : 'neutral'" />
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@foreach ($activeDeployments as $deployment)
|
||||
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
|
||||
class="grid min-h-13 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] 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-medium text-black dark:text-fg">
|
||||
{{ data_get($deployment, 'application_name') }}
|
||||
</div>
|
||||
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ data_get($deployment, 'server_name') }}</div>
|
||||
<div>
|
||||
<x-status-badge :status="str(data_get($deployment, 'status'))->headline()"
|
||||
:type="data_get($deployment, 'status') === 'in_progress' ? 'warning' : 'neutral'" />
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
@if ($events->isNotEmpty())
|
||||
<div class="overflow-x-auto">
|
||||
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
|
||||
wire:target="search,action,source,setPage,previousPage,nextPage">
|
||||
wire:target="search,action,source,setSort,setPage,previousPage,nextPage">
|
||||
<div class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:border-white/[0.07] dark:text-fg-faint">
|
||||
<span>Actor</span>
|
||||
<span>Activity</span>
|
||||
<span>Source</span>
|
||||
<span class="text-right">Time</span>
|
||||
<x-table.sort-header label="Actor" asc="actor-asc" desc="actor-desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Activity" asc="activity-asc" desc="activity-desc"
|
||||
action="setSort" :current="$sort" />
|
||||
<x-table.sort-header label="Source" asc="source-asc" desc="source-desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Time" asc="time-asc" desc="time-desc" first="desc"
|
||||
action="setSort" :current="$sort" class="justify-self-end" />
|
||||
</div>
|
||||
@foreach ($events as $event)
|
||||
<div wire:key="audit-event-{{ $event->id }}" x-data="{ expanded: false }"
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
<div class="data-table min-w-[760px]">
|
||||
<div
|
||||
class="data-table-header grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem]">
|
||||
<span>Email</span>
|
||||
<span>Method</span>
|
||||
<span>Role</span>
|
||||
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Method" asc="via-asc" desc="via-desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc"
|
||||
action="setSort" :current="$sort" />
|
||||
<span>Invitation link</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
@foreach ($invitations as $invite)
|
||||
@foreach ($sortedInvitations as $invite)
|
||||
<div wire:key="team-invitation-{{ $invite->id }}"
|
||||
class="data-table-row grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div x-data="{
|
||||
search: '',
|
||||
roleFilter: 'all',
|
||||
sortBy: 'name_asc',
|
||||
sortBy: 'name-asc',
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
members: @js($members->map(fn ($member) => [
|
||||
@@ -9,6 +9,8 @@
|
||||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
'role' => data_get($member, 'pivot.role'),
|
||||
'roleRank' => \App\Enums\Role::tryFrom((string) data_get($member, 'pivot.role'))?->rank() ?? 0,
|
||||
'twoFactor' => auth()->user()?->can('manageMembers', currentTeam()) ? filled($member->two_factor_confirmed_at) : null,
|
||||
])->values()),
|
||||
get filteredMembers() {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
@@ -21,13 +23,17 @@
|
||||
return matchesSearch && matchesRole;
|
||||
});
|
||||
|
||||
return filtered.sort((left, right) => {
|
||||
if (this.sortBy === 'name_desc') return right.name.localeCompare(left.name);
|
||||
if (this.sortBy === 'email_asc') return left.email.localeCompare(right.email);
|
||||
if (this.sortBy === 'role') return left.role.localeCompare(right.role);
|
||||
const [column, direction] = this.sortBy.split('-');
|
||||
const order = direction === 'desc' ? -1 : 1;
|
||||
const byName = (left, right) => String(left.name || '').localeCompare(String(right.name || ''));
|
||||
const compare = {
|
||||
name: byName,
|
||||
email: (left, right) => String(left.email || '').localeCompare(String(right.email || '')),
|
||||
role: (left, right) => left.roleRank - right.roleRank,
|
||||
twofa: (left, right) => Number(left.twoFactor) - Number(right.twoFactor),
|
||||
}[column] || byName;
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
return filtered.sort((left, right) => order * compare(left, right) || byName(left, right));
|
||||
},
|
||||
get lastPage() {
|
||||
return Math.max(1, Math.ceil(this.filteredMembers.length / this.perPage));
|
||||
@@ -85,11 +91,12 @@
|
||||
</div>
|
||||
<div class="w-full sm:w-40">
|
||||
<x-forms.listbox id="member-sort" :wire="false" x-model="sortBy"
|
||||
x-effect="page = 1" :value="'name_asc'" :options="[
|
||||
['value' => 'name_asc', 'label' => 'Name A–Z'],
|
||||
['value' => 'name_desc', 'label' => 'Name Z–A'],
|
||||
['value' => 'email_asc', 'label' => 'Email A–Z'],
|
||||
['value' => 'role', 'label' => 'Role'],
|
||||
x-effect="page = 1" :value="'name-asc'" :options="[
|
||||
['value' => 'name-asc', 'label' => 'Name A–Z'],
|
||||
['value' => 'name-desc', 'label' => 'Name Z–A'],
|
||||
['value' => 'email-asc', 'label' => 'Email A–Z'],
|
||||
['value' => 'email-desc', 'label' => 'Email Z–A'],
|
||||
['value' => 'role-desc', 'label' => 'Role'],
|
||||
]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,11 +118,11 @@
|
||||
'data-table-header team-members-table-grid',
|
||||
'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()),
|
||||
])>
|
||||
<span>Name</span>
|
||||
<span>Email</span>
|
||||
<span>Role</span>
|
||||
<x-table.sort-header label="Name" asc="name-asc" desc="name-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" then="page = 1" />
|
||||
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc" then="page = 1" />
|
||||
@can('manageMembers', currentTeam())
|
||||
<span>2FA</span>
|
||||
<x-table.sort-header label="2FA" asc="twofa-asc" desc="twofa-desc" then="page = 1" />
|
||||
@endcan
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
@@ -872,6 +872,45 @@ test('audit log page only shows events for the current team', function () {
|
||||
->assertDontSee('Private app deleted');
|
||||
});
|
||||
|
||||
test('audit log sorts by the chosen column and ignores unknown sort values', function () {
|
||||
AuditEvent::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'actor_name' => 'Zoe',
|
||||
'description' => 'Older event by Zoe',
|
||||
'created_at' => now()->subHour(),
|
||||
]);
|
||||
AuditEvent::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'actor_name' => 'Adam',
|
||||
'description' => 'Newer event by Adam',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(AuditLog::class)
|
||||
->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
|
||||
->call('setSort', 'time-asc')
|
||||
->assertSet('sort', 'time-asc')
|
||||
->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
|
||||
->call('setSort', 'actor-desc')
|
||||
->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
|
||||
->call('setSort', 'activity-asc')
|
||||
->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
|
||||
->call('setSort', 'resource_uuid-asc')
|
||||
->assertSet('sort', 'activity-asc')
|
||||
->call('setSort', 'time-sideways')
|
||||
->assertSet('sort', 'activity-asc');
|
||||
});
|
||||
|
||||
test('changing the audit log sort returns to the first page', function () {
|
||||
AuditEvent::factory()->count(11)->create(['team_id' => $this->team->id]);
|
||||
|
||||
Livewire::test(AuditLog::class)
|
||||
->set('perPage', 10)
|
||||
->call('setPage', 2)
|
||||
->call('setSort', 'time-asc')
|
||||
->assertSet('paginators.page', 1);
|
||||
});
|
||||
|
||||
test('audit log expands encrypted model changes for team admins and owners', function () {
|
||||
AuditEvent::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
|
||||
@@ -164,6 +164,23 @@ it('does not load deployment payload columns for the history list on any Livewir
|
||||
($this->assertNoPayloadQueries)();
|
||||
});
|
||||
|
||||
it('flips the deployment sort from the started column header', function () {
|
||||
foreach (range(1, 3) as $number) {
|
||||
($this->createDeployment)("header-d{$number}", "2026-10-0{$number} 10:00:00");
|
||||
}
|
||||
|
||||
Livewire::test(Index::class, [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
])
|
||||
->assertSeeHtml('aria-label="Sort by started, sorted descending"')
|
||||
->assertSeeInOrder(['header-d3', 'header-d2', 'header-d1'])
|
||||
->call('setDeploymentSort', 'oldest')
|
||||
->assertSeeHtml('aria-label="Sort by started, sorted ascending"')
|
||||
->assertSeeInOrder(['header-d1', 'header-d2', 'header-d3']);
|
||||
});
|
||||
|
||||
it('does not show the deployment history of another team', function () {
|
||||
$otherTeam = otherTeamDeployment();
|
||||
unset($otherTeam['deployment_uuid']);
|
||||
|
||||
@@ -130,3 +130,52 @@ it('paginates images and returns to the first page when the search changes', fun
|
||||
->assertSee('1–5 of 9')
|
||||
->assertDontSee('app-06');
|
||||
});
|
||||
|
||||
it('sorts images by the chosen column and ignores unknown sort values', function () {
|
||||
fakeRemoteProcesses([
|
||||
'*docker image ls*' => Process::result(output: implode("\n", [
|
||||
json_encode(['Repository' => 'beta', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('1', 64), 'Size' => '9MB', 'CreatedAt' => '2024-03-01 10:00:00 +0000 UTC', 'CreatedSince' => '7 months ago']),
|
||||
json_encode(['Repository' => 'alpha', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('2', 64), 'Size' => '100MB', 'CreatedAt' => '2024-01-01 10:00:00 +0000 UTC', 'CreatedSince' => '9 months ago']),
|
||||
json_encode(['Repository' => 'gamma', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('3', 64), 'Size' => '1.2GB', 'CreatedAt' => '2024-02-01 10:00:00 +0000 UTC', 'CreatedSince' => '8 months ago']),
|
||||
])),
|
||||
'*docker ps -a*' => Process::result(output: ''),
|
||||
'*docker system df*' => Process::result(output: ''),
|
||||
]);
|
||||
|
||||
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('load')
|
||||
->assertSeeInOrder(['beta', 'alpha', 'gamma'])
|
||||
->call('setSort', 'repository_asc')
|
||||
->assertSet('sort', 'repository_asc')
|
||||
->assertSeeInOrder(['alpha', 'beta', 'gamma'])
|
||||
->call('setSort', 'size_asc')
|
||||
->assertSeeInOrder(['9MB', '100MB', '1.2GB'])
|
||||
->call('setSort', 'size_desc')
|
||||
->assertSeeInOrder(['1.2GB', '100MB', '9MB'])
|
||||
->call('setSort', 'created_desc')
|
||||
->assertSeeInOrder(['beta', 'gamma', 'alpha'])
|
||||
->call('setSort', 'containers_desc')
|
||||
->assertSet('sort', 'created_desc')
|
||||
->assertSeeInOrder(['beta', 'gamma', 'alpha']);
|
||||
});
|
||||
|
||||
it('returns to the first page when the sort changes', function () {
|
||||
fakeRemoteProcesses([
|
||||
'*docker image ls*' => Process::result(output: collect(range(1, 12))
|
||||
->map(fn (int $i) => json_encode([
|
||||
'Repository' => sprintf('app-%02d', $i), 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat(dechex($i), 64),
|
||||
'Size' => '1MB', 'CreatedSince' => '1 day ago',
|
||||
]))
|
||||
->implode("\n")),
|
||||
'*docker ps -a*' => Process::result(output: ''),
|
||||
'*docker system df*' => Process::result(output: ''),
|
||||
]);
|
||||
|
||||
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('load')
|
||||
->call('nextPage')
|
||||
->call('setSort', 'repository_desc')
|
||||
->assertSee('1–10 of 12')
|
||||
->assertSee('app-12')
|
||||
->assertDontSee('app-02');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Resources;
|
||||
use App\Models\Server;
|
||||
|
||||
function sortableManagedResource(string $name, string $project, string $environment, string $type, string $status): object
|
||||
{
|
||||
return new class($name, $project, $environment, $type, $status)
|
||||
{
|
||||
public array $environment;
|
||||
|
||||
public function __construct(public string $name, public string $projectName, string $environment, public string $resourceType, public string $status)
|
||||
{
|
||||
$this->environment = ['name' => $environment];
|
||||
}
|
||||
|
||||
public function project(): array
|
||||
{
|
||||
return ['name' => $this->projectName];
|
||||
}
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return $this->resourceType;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->component = new Resources;
|
||||
$this->component->server = Mockery::mock(Server::class)->makePartial();
|
||||
});
|
||||
|
||||
function sortedNames(Resources $component, string $nameKey = 'name'): array
|
||||
{
|
||||
return $component->render()->getData()['resources']->pluck($nameKey)->all();
|
||||
}
|
||||
|
||||
it('sorts managed resources by each column in both directions with name as tie-breaker', function () {
|
||||
$this->component->server->shouldReceive('definedResources')->andReturn(collect([
|
||||
sortableManagedResource('web 10', 'Beta', 'production', 'application', 'running:healthy'),
|
||||
sortableManagedResource('Web 2', 'alpha', 'staging', 'service', 'exited:unhealthy'),
|
||||
sortableManagedResource('db', 'Beta', 'dev', 'standalone-postgresql', 'running:healthy'),
|
||||
]));
|
||||
|
||||
expect(sortedNames($this->component))->toBe(['db', 'Web 2', 'web 10']);
|
||||
|
||||
$this->component->setSort('name_desc');
|
||||
expect(sortedNames($this->component))->toBe(['web 10', 'Web 2', 'db']);
|
||||
|
||||
$this->component->setSort('project_asc');
|
||||
expect(sortedNames($this->component))->toBe(['Web 2', 'db', 'web 10']);
|
||||
|
||||
$this->component->setSort('environment_desc');
|
||||
expect(sortedNames($this->component))->toBe(['Web 2', 'web 10', 'db']);
|
||||
|
||||
$this->component->setSort('type_desc');
|
||||
expect(sortedNames($this->component))->toBe(['db', 'Web 2', 'web 10']);
|
||||
|
||||
$this->component->setSort('status_desc');
|
||||
expect(sortedNames($this->component))->toBe(['db', 'web 10', 'Web 2']);
|
||||
});
|
||||
|
||||
it('sorts unmanaged containers by image and status', function () {
|
||||
$this->component->activeTab = 'unmanaged';
|
||||
$this->component->unmanagedContainers = [
|
||||
['Names' => 'cache', 'Image' => 'redis:7', 'State' => 'running'],
|
||||
['Names' => 'proxy', 'Image' => 'nginx:1', 'State' => 'exited'],
|
||||
['Names' => 'app', 'Image' => 'redis:7', 'State' => 'running'],
|
||||
];
|
||||
|
||||
$this->component->setSort('image_asc');
|
||||
expect(sortedNames($this->component, 'Names'))->toBe(['proxy', 'app', 'cache']);
|
||||
|
||||
$this->component->setSort('status_asc');
|
||||
expect(sortedNames($this->component, 'Names'))->toBe(['proxy', 'app', 'cache']);
|
||||
});
|
||||
|
||||
it('ignores unknown sort values and resets the page on a valid sort', function () {
|
||||
$this->component->setPage(3);
|
||||
foreach (['bogus', 'name_sideways', 'image_asc', 'name_asc; drop'] as $invalid) {
|
||||
$this->component->setSort($invalid);
|
||||
}
|
||||
expect($this->component->sort)->toBe('name_asc')
|
||||
->and($this->component->getPage())->toBe(3);
|
||||
|
||||
$this->component->setSort('status_desc');
|
||||
expect($this->component->sort)->toBe('status_desc')
|
||||
->and($this->component->getPage())->toBe(1);
|
||||
});
|
||||
|
||||
it('renders livewire sort headers for both tabs', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
|
||||
foreach (['name', 'project', 'environment', 'type', 'status', 'image'] as $column) {
|
||||
expect($view)->toContain("asc=\"{$column}_asc\" desc=\"{$column}_desc\" action=\"setSort\"");
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\Tags\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
@@ -167,3 +168,40 @@ it('does not let a member change tags from the connect modal', function () {
|
||||
|
||||
expect($application->tags()->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('sorts active deployments by the chosen column and ignores unknown sort values', function () {
|
||||
$tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
foreach ([['zeta-app', 'alpha-server', 'queued'], ['alpha-app', 'zeta-server', 'in_progress'], ['mid-app', 'alpha-server', 'in_progress']] as [$name, $serverName, $status]) {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'name' => $name,
|
||||
]);
|
||||
$application->tags()->attach($tag->id);
|
||||
ApplicationDeploymentQueue::create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => $name,
|
||||
'application_name' => $name,
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $serverName,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
Livewire::test(Show::class, ['tagName' => 'hello'])
|
||||
->assertSeeInOrder(['Active deployments', 'zeta-app', 'mid-app', 'alpha-app'])
|
||||
->call('setDeploymentSort', 'resource-asc')
|
||||
->assertSeeInOrder(['Active deployments', 'alpha-app', 'mid-app', 'zeta-app'])
|
||||
->call('setDeploymentSort', 'server-desc')
|
||||
->assertSeeInOrder(['Active deployments', 'alpha-app', 'zeta-app', 'mid-app'])
|
||||
->call('setDeploymentSort', 'status-desc')
|
||||
->assertSeeInOrder(['Active deployments', 'zeta-app', 'alpha-app', 'mid-app'])
|
||||
->call('setDeploymentSort', 'deployment_url-asc')
|
||||
->assertSet('deploymentSort', 'status-desc');
|
||||
});
|
||||
|
||||
@@ -49,3 +49,32 @@ it('preserves a provisional user when revoking their invitation fails', function
|
||||
$this->assertDatabaseHas('users', ['id' => $provisionalUser->id]);
|
||||
$this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]);
|
||||
});
|
||||
|
||||
it('sorts pending invitations by the chosen column and ignores unknown sort values', function () {
|
||||
$invite = fn (string $email, string $role, string $via) => TeamInvitation::create([
|
||||
'team_id' => $this->team->id,
|
||||
'uuid' => $email,
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
'link' => "http://example.test/invitations/{$email}",
|
||||
'via' => $via,
|
||||
]);
|
||||
$invitations = collect([
|
||||
$invite('carol@example.com', 'member', 'email'),
|
||||
$invite('alice@example.com', 'owner', 'link'),
|
||||
$invite('bob@example.com', 'admin', 'email'),
|
||||
]);
|
||||
|
||||
Livewire::test(Invitations::class, ['invitations' => $invitations])
|
||||
->assertSeeInOrder(['carol@example.com', 'alice@example.com', 'bob@example.com'])
|
||||
->call('setSort', 'email-asc')
|
||||
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
|
||||
->call('setSort', 'role-desc')
|
||||
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
|
||||
->call('setSort', 'role-asc')
|
||||
->assertSeeInOrder(['carol@example.com', 'bob@example.com', 'alice@example.com'])
|
||||
->call('setSort', 'via-desc')
|
||||
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
|
||||
->call('setSort', 'link-asc')
|
||||
->assertSet('sort', 'via-desc');
|
||||
});
|
||||
|
||||
@@ -143,3 +143,21 @@ test('the summary confirms when every member has two factor authentication', fun
|
||||
->assertSee('All members have two-factor authentication enabled')
|
||||
->assertDontSee('do not have two-factor authentication enabled');
|
||||
});
|
||||
|
||||
test('the members table sorts by role rank and only sends two factor status to member managers', function () {
|
||||
$owner = createTeamMember($this->team, 'owner', twoFactorEnabled: true);
|
||||
$member = createTeamMember($this->team, 'member');
|
||||
|
||||
actAsTeamMember($owner, $this->team);
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('Sort by role', false)
|
||||
->assertSee('Sort by 2fa', false)
|
||||
->assertSee('\\u0022roleRank\\u0022:3', false)
|
||||
->assertSee('\\u0022twoFactor\\u0022:true', false);
|
||||
|
||||
actAsTeamMember($member, $this->team);
|
||||
Livewire::test(Index::class)
|
||||
->assertDontSee('Sort by 2fa', false)
|
||||
->assertSee('\\u0022twoFactor\\u0022:null', false)
|
||||
->assertDontSee('\\u0022twoFactor\\u0022:true', false);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,10 @@ function assertBackupSortOrders(mixed $page, string $screenshot): void
|
||||
->click('Sort')
|
||||
->click('Newest first')
|
||||
->assertScript(visibleBackupOrderScript(), 'charlie-data,alpha-data,bravo-data')
|
||||
->click('[aria-label^="Sort by target"]')
|
||||
->assertScript(visibleBackupOrderScript(), 'alpha-data,bravo-data,charlie-data')
|
||||
->click('[aria-label^="Sort by target"]')
|
||||
->assertScript(visibleBackupOrderScript(), 'charlie-data,bravo-data,alpha-data')
|
||||
->screenshot(filename: $screenshot);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user