fix(ui): consolidate table row actions into one menu pattern

Rows with two or more actions use a shared "..." menu (x-table.row-actions);
rows with one action keep a single icon button. Every actions column has an
"Actions" header. Removing a member and revoking an invitation now ask for
confirmation. Row and card icons are neutral in dark mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-30 16:14:46 +01:00
co-authored by Claude Opus 5.5
parent 1bacfd1966
commit 8fcff6ebd1
41 changed files with 710 additions and 562 deletions
+23 -16
View File
@@ -2806,6 +2806,12 @@ html[data-theme="custom"]:not(.dark) :is(.application-settings-section, .applica
background: transparent;
}
/* Destructive items of a table row action menu (x-table.row-actions). */
.row-actions-danger .listbox-option,
.dark .row-actions-danger .listbox-option {
color: var(--color-error);
}
/* Searchable listbox: sticky search field + scrollable options */
.searchable-listbox-panel {
max-height: 18rem;
@@ -3150,7 +3156,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.env-table-grid {
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 4.5rem;
}
/* File browser: icon, name, perms, size, modified, actions.
@@ -3198,11 +3204,11 @@ input[type="search"]::-webkit-search-results-decoration {
}
.environment-table-scroll .env-table-grid {
min-width: 53rem;
min-width: 54.5rem;
}
.env-table-grid.env-table-grid-no-type {
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 4.5rem;
}
@media (max-width: 768px) {
@@ -3225,20 +3231,20 @@ input[type="search"]::-webkit-search-results-decoration {
}
.data-table-row.env-table-grid > :last-child {
display: block;
display: flex;
justify-self: end;
}
}
/* Shared variables only store value shape (multiline), not per-resource flags. */
.env-table-grid-shared {
grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4.5rem 3rem;
grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4.5rem 4.5rem;
}
/* Shared env vars collapse secondary columns on tablet and use cards on phone. */
@media (max-width: 1100px) {
.env-table-grid-shared {
grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 3rem;
grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 4.5rem;
gap: 0.75rem;
}
@@ -3250,7 +3256,7 @@ input[type="search"]::-webkit-search-results-decoration {
@media (max-width: 900px) {
.env-table-grid-shared {
grid-template-columns: minmax(0, 1fr) 6rem 3rem;
grid-template-columns: minmax(0, 1fr) 6rem 4.5rem;
}
.env-table-grid-shared > :nth-child(3) {
@@ -3505,7 +3511,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.service-backup-table-grid {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 12.5rem;
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 4.5rem;
width: 100%;
}
@@ -3533,11 +3539,11 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-table-grid {
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 15rem;
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 4.5rem;
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 4.5rem;
}
.volumes-table-grid-readonly.has-source {
@@ -3545,11 +3551,11 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-table-grid.has-source {
grid-template-columns: minmax(9rem, 1.1fr) minmax(14rem, 1.5fr) minmax(6rem, 1fr) 5rem 15rem;
grid-template-columns: minmax(9rem, 1.1fr) minmax(14rem, 1.5fr) minmax(6rem, 1fr) 5rem 4.5rem;
}
.volumes-table-grid-with-pr.has-source {
grid-template-columns: minmax(9rem, 1fr) minmax(14rem, 1.4fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
grid-template-columns: minmax(9rem, 1fr) minmax(14rem, 1.4fr) minmax(6rem, 1fr) 8.5rem 5rem 4.5rem;
}
.volumes-mobile-label {
@@ -3795,7 +3801,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.server-resources-unmanaged-table-grid {
grid-template-columns: minmax(9rem, 1fr) minmax(12rem, 1.5fr) 7rem minmax(9rem, auto);
grid-template-columns: minmax(9rem, 1fr) minmax(12rem, 1.5fr) 7rem 4.5rem;
}
.server-images-table-grid {
@@ -6821,13 +6827,14 @@ html.dark .resource-glyph {
color: var(--color-brand-light);
}
/* Row and card actions stay neutral: the brand color is for the primary action of an area. */
html.dark .card-icon-button {
color: var(--color-brand-light);
color: var(--color-fg-faint);
}
html.dark .card-icon-button:hover {
background-color: color-mix(in srgb, var(--color-brand) 16%, transparent);
color: #fff;
background-color: rgb(255 255 255 / 0.06);
color: var(--color-fg);
}
html.dark .app-main :is(article.group, a.group).rounded-xl {
@@ -116,7 +116,7 @@
<span>Scope</span>
<span>{{ count($readOnlyKeys) ? 'Value / comment' : 'Comment' }}</span>
<span class="text-center">Multiline</span>
<span></span>
<span class="text-right">Actions</span>
</div>
@foreach ($variables as $env)
@if (in_array($env->key, $readOnlyKeys))
@@ -0,0 +1,10 @@
{{-- One item of <x-table.row-actions>. Renders a link (with wire:navigate when enabled) when href is set. --}}
@props([
'href' => null,
])
@if ($href)
<a href="{{ $href }}" {{ wireNavigate() }} role="menuitem" {{ $attributes->class('listbox-option justify-start!') }}>{{ $slot }}</a>
@else
<button type="button" role="menuitem" {{ $attributes->class('listbox-option justify-start!') }}>{{ $slot }}</button>
@endif
@@ -0,0 +1,27 @@
{{-- Row action menu for tables: a "..." icon button at the far right of the row.
Put <x-table.row-action> items in the default slot. Put destructive items in
the danger slot: they show in red, below a divider. A click on any item
closes the menu, also when the item opens a modal. --}}
@props([
'label' => 'Actions',
])
<x-table.dropdown panel-class="w-44!" role="menu">
<x-slot:trigger>
<button type="button" class="icon-button" title="{{ $label }}" aria-label="{{ $label }}"
aria-haspopup="menu" :aria-expanded="open">
<x-reicon name="dots-horizontal" class="size-4" />
</button>
</x-slot:trigger>
<div class="contents" @click="if ($event.target.closest('[role=menuitem]')) close()">
{{ $slot }}
@if (isset($danger) && $danger->isNotEmpty())
@if ($slot->isNotEmpty())
<div class="my-1 border-t border-neutral-200 dark:border-white/[0.08]"></div>
@endif
<div class="row-actions-danger contents">
{{ $danger }}
</div>
@endif
</div>
</x-table.dropdown>
@@ -64,7 +64,7 @@
<span>Name</span>
<span>Email</span>
<span>Subscription</span>
<span class="text-right">Action</span>
<span class="text-right">Actions</span>
</div>
@foreach ($foundUsers as $user)
@php
+3 -3
View File
@@ -162,7 +162,7 @@
<div x-show="viewMode === 'table'" class="{{ $tableShell }} transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="setProjectSort">
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableHead }}">
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4.5rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4.5rem] {{ $tableHead }}">
<x-table.sort-header label="Project" asc="name-asc" desc="name-desc"
action="setProjectSort" :current="$projectSort" />
<x-table.sort-header label="Contents" asc="resources-asc" desc="resources-desc" first="desc"
@@ -171,14 +171,14 @@
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc"
action="setProjectSort" :current="$projectSort" />
</div>
<div></div>
<span class="text-right">Actions</span>
</div>
@foreach ($dashboardProjects as $project)
@php
$firstEnvironment = $project->environments->first();
@endphp
<div wire:key="dashboard-project-row-{{ $project->uuid }}"
class="relative grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableRow }}">
class="relative grid grid-cols-[minmax(0,1fr)_7rem_4.5rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4.5rem] {{ $tableRow }}">
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }}
class="absolute inset-0" aria-label="Open {{ $project->name }}"></a>
<div class="flex min-w-0 items-center gap-3">
@@ -131,14 +131,14 @@
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" />
</button>
@unless ($labelsAreWritable)
@if ($isSuggested)
@if ($labelsAreWritable || $isSuggested)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" />
</button>
@if ($isSuggested && ! $labelsAreWritable)
@if ($row['needs_force_add'] ?? false)
<x-forms.button wire:click="addSuggestedDomain({{ $index }})" isError class="h-7! px-2! text-[12px]!">
Continue
@@ -148,29 +148,34 @@
Add domain
</x-forms.button>
@endif
@else
<button type="button"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection), @js($row['inherited_port'] ?? null))"
class="icon-button shrink-0"
title="Domain settings" aria-label="Settings for {{ $publicUrl }}">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?" buttonTitle="Remove"
isErrorButton canGate="update" :canResource="$application"
submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
'This domain will be removed from the application.',
'Redeploy or restart may be required for proxy changes.',
]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
:confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button" class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove domain" aria-label="Remove domain">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
@endunless
@else
<x-table.row-actions label="Domain actions">
<x-table.row-action wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns">
Check DNS
</x-table.row-action>
<x-table.row-action
x-on:click="openEditDomain({{ \Illuminate\Support\Js::from($index) }}, {{ \Illuminate\Support\Js::from($row['url']) }}, {{ \Illuminate\Support\Js::from($editingParts) }}, {{ \Illuminate\Support\Js::from($row['service']) }}, {{ \Illuminate\Support\Js::from($isNoindexed ? 'noindex' : 'index') }}, {{ \Illuminate\Support\Js::from($rowDirection) }}, {{ \Illuminate\Support\Js::from($row['inherited_port'] ?? null) }})"
aria-label="Edit {{ $publicUrl }}">
Edit
</x-table.row-action>
<x-slot:danger>
<x-modal-confirmation buttonFullWidth title="Remove domain?" buttonTitle="Remove"
isErrorButton canGate="update" :canResource="$application"
submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
'This domain will be removed from the application.',
'Redeploy or restart may be required for proxy changes.',
]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
:confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
@endif
@endcan
</div>
</div>
@@ -177,34 +177,33 @@
</div>
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $preview->application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" />
</button>
<button type="button"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']))"
class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ getFqdnWithoutPort($row['url']) }}">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
buttonTitle="Remove" isErrorButton
submitAction="removeDomainByKey({{ $domainKey }})"
:actions="[
'This domain will be removed from the preview deployment.',
'Redeploy the preview to apply proxy changes.',
]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove domain" aria-label="Remove domain">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
<x-table.row-actions label="Domain actions">
<x-table.row-action wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns">
Check DNS
</x-table.row-action>
<x-table.row-action
x-on:click="openEditDomain({{ \Illuminate\Support\Js::from($index) }}, {{ \Illuminate\Support\Js::from($row['url']) }}, {{ \Illuminate\Support\Js::from($editingParts) }}, {{ \Illuminate\Support\Js::from($row['service']) }})"
aria-label="Edit {{ getFqdnWithoutPort($row['url']) }}">
Edit
</x-table.row-action>
<x-slot:danger>
<x-modal-confirmation buttonFullWidth title="Remove domain?"
buttonTitle="Remove" isErrorButton
submitAction="removeDomainByKey({{ $domainKey }})"
:actions="[
'This domain will be removed from the preview deployment.',
'Redeploy the preview to apply proxy changes.',
]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
@endcan
</div>
</div>
@@ -62,7 +62,7 @@
<span>Latest run</span>
<span>S3 storage</span>
<span>Executions</span>
<span class="text-right">Action</span>
<span class="text-right">Actions</span>
</div>
@foreach ($database->scheduledBackups as $backup)
@php
@@ -87,9 +87,10 @@
|| @js(strtolower($s3StorageNames)).includes(search.toLowerCase())"
class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
<div class="min-w-0">
<span class="block truncate text-[12px] font-semibold text-black dark:text-fg">
<a {{ wireNavigate() }} href="{{ $backupRoute }}"
class="block truncate text-[12px] font-semibold text-black hover:underline dark:text-fg">
{{ $backup->frequency }}
</span>
</a>
</div>
<div class="flex items-center gap-2">
<x-status-badge :status="$statusLabel" :type="$statusType" />
@@ -107,7 +108,10 @@
</a>
</div>
<div class="flex justify-end">
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
<a class="icon-button" {{ wireNavigate() }} href="{{ $backupRoute }}"
title="Manage backup schedule" aria-label="Manage backup schedule">
<x-reicon name="settings" class="size-3.5" />
</a>
</div>
</div>
@endforeach
@@ -149,7 +149,7 @@
<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>
<span class="text-right">Actions</span>
</div>
<template x-for="project in paginatedProjects" :key="project.uuid">
@@ -41,7 +41,7 @@
@else
<div class="data-table relative w-full overflow-x-auto">
<x-table.loading target="previousPage,nextPage,setPage,perPage" text="Loading executions..." />
<div class="data-table-header grid min-w-[900px] grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_190px_90px_48px]">
<div class="data-table-header grid min-w-[900px] grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_190px_90px_4.5rem]">
<span>Target</span><span>Type</span><span>Schedule</span><span>Status</span><span>Started</span><span>Size</span><span class="text-right">Actions</span>
</div>
@foreach ($executions as $execution)
@@ -56,7 +56,7 @@
<div wire:key="service-backup-execution-{{ $execution['id'] }}"
wire:click="openExecution('{{ $execution['uuid'] }}')"
wire:keydown.enter="openExecution('{{ $execution['uuid'] }}')" role="button" tabindex="0"
class="data-table-row grid min-w-[900px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_190px_90px_48px] text-left text-[13px] text-neutral-700 dark:text-fg-dim">
class="data-table-row grid min-w-[900px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_190px_90px_4.5rem] text-left text-[13px] text-neutral-700 dark:text-fg-dim">
<span class="flex min-w-0 items-center gap-2 font-medium text-neutral-950 dark:text-fg">
<span class="truncate" title="{{ $execution['target'] }}">{{ $execution['target'] }}</span>
<span tabindex="0" data-tooltip="{{ $execution['s3_tooltip'] }}"
@@ -51,7 +51,7 @@
<div>Resource</div>
<div>Image</div>
<div class="justify-self-start">Status</div>
<div></div>
<span class="text-right">Actions</span>
</div>
@endif
@@ -165,13 +165,13 @@
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $service)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" />
</button>
@if ($isSuggested)
<button type="button" wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" />
</button>
@if ($row['needs_force_add'] ?? false)
<x-forms.button canGate="update" :canResource="$service"
wire:click="addSuggestedDomain({{ $index }})" isError
@@ -186,27 +186,32 @@
</x-forms.button>
@endif
@else
<button type="button" class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ $publicUrl }}"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js((int) $row['service_application_id']), @js($serviceLabel), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
buttonTitle="Remove" isErrorButton
canGate="update" :canResource="$service"
submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
'This domain will be removed from the service application.',
'Redeploy or restart may be required for proxy changes.',
]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove domain" aria-label="Remove domain">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
<x-table.row-actions label="Domain actions">
<x-table.row-action wire:click="checkDomainDns({{ $index }})"
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns">
Check DNS
</x-table.row-action>
<x-table.row-action aria-label="Edit {{ $publicUrl }}"
x-on:click="openEditDomain({{ \Illuminate\Support\Js::from($index) }}, {{ \Illuminate\Support\Js::from($row['url']) }}, {{ \Illuminate\Support\Js::from($editingParts) }}, {{ \Illuminate\Support\Js::from((int) $row['service_application_id']) }}, {{ \Illuminate\Support\Js::from($serviceLabel) }}, {{ \Illuminate\Support\Js::from($isNoindexed ? 'noindex' : 'index') }}, {{ \Illuminate\Support\Js::from($rowDirection) }})">
Edit
</x-table.row-action>
<x-slot:danger>
<x-modal-confirmation buttonFullWidth title="Remove domain?"
buttonTitle="Remove" isErrorButton
canGate="update" :canResource="$service"
submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
'This domain will be removed from the service application.',
'Redeploy or restart may be required for proxy changes.',
]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
:confirmWithPassword="false" :confirmWithText="false"
step2ButtonText="Remove domain">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
@endif
@endcan
</div>
@@ -15,7 +15,7 @@
<x-slot:content>
<div x-data="{
openSettings(event) {
if (event.target.closest('a, button')) {
if (event.target.closest('a, button, [role=menu]')) {
return;
}
modalOpen = true;
@@ -108,25 +108,27 @@
<x-status-badge :status="$statusLabel" :type="$statusType" />
<x-application.restart-limit-warning :application="$resource" />
</div>
<div class="flex items-center justify-end gap-1">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
<a class="icon-button" title="Service backups" aria-label="Service backups" {{ wireNavigate() }}
href="{{ route('project.service.volume-backups.index', $parameters) }}">
<x-reicon name="database" class="size-4" />
</a>
@endif
@if ($isApplication && $resource->fqdn)
@can('update', $service)
<a class="icon-button" title="Manage domains" aria-label="Manage domains" {{ wireNavigate() }}
href="{{ route('project.service.domains', $parameters) }}">
<x-reicon name="globe" class="size-4" />
</a>
@endcan
@endif
@php
$hasBackupsAction = $isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated);
$hasDomainsAction = $isApplication && $resource->fqdn && auth()->user()?->can('update', $service);
@endphp
<div class="flex items-center justify-end">
@if ($hasBackupsAction || $hasDomainsAction)
<x-table.row-actions label="Resource actions">
@if ($hasBackupsAction)
<x-table.row-action :href="route('project.service.volume-backups.index', $parameters)">Backups</x-table.row-action>
@endif
@if ($hasDomainsAction)
<x-table.row-action :href="route('project.service.domains', $parameters)">Domains</x-table.row-action>
@endif
<x-table.row-action @click="modalOpen = true">Settings</x-table.row-action>
</x-table.row-actions>
@else
<button type="button" class="icon-button" title="Resource settings" aria-label="Resource settings"
@click="modalOpen = true">
<x-reicon name="settings" class="size-4" />
</button>
@endif
</div>
</div>
</div>
@@ -290,18 +290,17 @@
:data-tooltip="$databaseS3Tooltip" :aria-label="$databaseS3Tooltip" tabindex="0" />
</span>
<span>{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}</span>
<span class="flex justify-end gap-2" x-on:keydown.enter.stop>
<x-forms.button type="button" canGate="update" :canResource="$service"
:disabled="! str($databaseBackup->database->status)->startsWith('running')"
:tooltip="! str($databaseBackup->database->status)->startsWith('running') ? 'The database must be running to start a backup.' : null"
wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')"
wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
<span class="flex justify-end" x-on:click.stop x-on:keydown.enter.stop>
@can('update', $service)
<x-table.row-actions label="Backup schedule actions">
<x-table.row-action
:disabled="! str($databaseBackup->database->status)->startsWith('running')"
:title="! str($databaseBackup->database->status)->startsWith('running') ? 'The database must be running to start a backup.' : null"
wire:click="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-table.row-action>
<x-table.row-action
wire:click="openSchedule('{{ $databaseBackup->uuid }}')">Edit</x-table.row-action>
</x-table.row-actions>
@endcan
</span>
</div>
@endforeach
@@ -351,16 +350,15 @@
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
</span>
<span class="flex justify-end gap-2" x-on:keydown.enter.stop>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')"
wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
<span class="flex justify-end" x-on:click.stop x-on:keydown.enter.stop>
@can('update', $service)
<x-table.row-actions label="Backup schedule actions">
<x-table.row-action
wire:click="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-table.row-action>
<x-table.row-action
wire:click="openSchedule('{{ $backup->uuid }}')">Edit</x-table.row-action>
</x-table.row-actions>
@endcan
</span>
</div>
@endforeach
@@ -235,7 +235,7 @@
<span class="text-center">Multiline</span>
<span class="text-center">Buildtime</span>
<span class="text-center">Runtime</span>
<span></span>
<span class="text-right">Actions</span>
</div>
@php $renderedScope = null; @endphp
@foreach ($this->environmentVariablePageRows as $row)
@@ -29,7 +29,7 @@
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<div class="flex items-center gap-0.5 justify-self-end">
<div class="flex items-center justify-end gap-0.5 justify-self-end">
@unless ($isValueHidden)
<x-copy-button resolve="$wire.copyValue()" label="Copy value" />
@endunless
@@ -87,7 +87,7 @@
@endif
@endforeach
@endif
<div class="flex items-center gap-0.5 justify-self-end">
<div class="flex items-center justify-end gap-0.5 justify-self-end">
@if ($this->connectLink)
<a href="{{ $this->connectLink }}" class="icon-button shrink-0"
title="Connect {{ $key }} to resources" aria-label="Connect {{ $key }} to resources">
@@ -269,81 +269,56 @@
<div class="text-coollabs-subtle tabular-nums">
{{ $entry['mtime'] ? \Illuminate\Support\Carbon::createFromTimestamp($entry['mtime'])->toDateTimeString() : '-' }}
</div>
{{-- Actions kebab. The entry name lives in this
row's x-data as a JS string so the menu can
call $wire.* with it directly - passing @js()
through a component attribute double-encodes
the quotes and breaks the handler. --}}
{{-- The entry name lives in this row's x-data as a JS
string so the menu can call $wire.* with it directly -
passing @js() through a component attribute
double-encodes the quotes and breaks the handler. --}}
<div class="flex items-center justify-end"
x-data="{ open: false, name: @js($entry['name']), isDir: @js($entry['type'] === 'dir') }"
@keydown.escape.window="open = false">
<div class="relative">
<button type="button" title="Actions" aria-label="Actions" x-ref="trigger"
x-on:click="open = !open" x-bind:aria-expanded="open"
x-bind:class="open && 'bg-black/5 text-current dark:bg-white/[0.08]'"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-coollabs-subtle hover:bg-black/5 hover:text-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs/40 dark:hover:bg-white/[0.06] dark:focus-visible:ring-white/30">
<x-reicon name="dots-horizontal" class="size-4" />
</button>
<template x-teleport="body">
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
x-anchor.bottom-end.fixed.offset.4="$refs.trigger"
@click.outside="open = false"
class="listbox-panel min-w-52! w-52! z-50!">
@if ($entry['type'] !== 'dir')
<button type="button" class="listbox-option"
x-on:click="open = false; run('Opening file...', () => $wire.openEditor(name))">
<x-reicon name="file-content" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">Edit</span>
</button>
@endif
<button type="button" class="listbox-option"
x-on:click="open = false; run(isDir ? 'Preparing folder download...' : 'Preparing file download...', () => $wire.download(name))">
<x-reicon name="download" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">Download</span>
</button>
{{-- Rename --}}
<x-modal-input title="Rename">
<x-slot:content>
<button type="button" class="listbox-option" x-on:click="open = false">
<x-reicon name="edit" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">Rename</span>
</button>
</x-slot:content>
<form x-data="{ newName: @js($entry['name']) }"
@submit.prevent="run('Renaming...', () => $wire.renameEntry(name, newName)); $dispatch('close-modal')"
class="flex flex-col gap-4">
<x-forms.input id="renameName-{{ $loop->index }}"
label="New name" x-model="newName" required />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Rename</x-forms.button>
</div>
</form>
</x-modal-input>
{{-- Delete --}}
<x-modal-input title="Delete this item?">
<x-slot:content>
<button type="button" class="listbox-option" x-on:click="open = false">
<x-reicon name="trash" class="size-3.5 shrink-0 text-red-600 dark:text-red-500" />
<span class="min-w-0 flex-1 text-left text-red-600 dark:text-red-500">Delete</span>
</button>
</x-slot:content>
<div class="flex flex-col gap-4">
<p class="text-sm text-coollabs-subtle">
This permanently deletes
<span class="font-medium">{{ $entry['name'] }}</span>
inside the container. This cannot be undone.
</p>
<div class="flex justify-end">
<x-forms.button isError type="button" canGate="canAccessTerminal" :canResource="$resource"
x-on:click="run(isDir ? 'Deleting folder...' : 'Deleting file...', () => $wire.deleteEntry(name)); $dispatch('close-modal')">Delete</x-forms.button>
</div>
x-data="{ name: @js($entry['name']), isDir: @js($entry['type'] === 'dir') }">
<x-table.row-actions label="File actions">
@if ($entry['type'] !== 'dir')
<x-table.row-action
x-on:click="run('Opening file...', () => $wire.openEditor(name))">
Edit
</x-table.row-action>
@endif
<x-table.row-action
x-on:click="run(isDir ? 'Preparing folder download...' : 'Preparing file download...', () => $wire.download(name))">
Download
</x-table.row-action>
<x-modal-input title="Rename">
<x-slot:content>
<x-table.row-action>Rename</x-table.row-action>
</x-slot:content>
<form x-data="{ newName: @js($entry['name']) }"
@submit.prevent="run('Renaming...', () => $wire.renameEntry(name, newName)); $dispatch('close-modal')"
class="flex flex-col gap-4">
<x-forms.input id="renameName-{{ $loop->index }}"
label="New name" x-model="newName" required />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted canGate="canAccessTerminal" :canResource="$resource">Rename</x-forms.button>
</div>
</form>
</x-modal-input>
<x-slot:danger>
<x-modal-input title="Delete this item?">
<x-slot:content>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:content>
<div class="flex flex-col gap-4">
<p class="text-sm text-coollabs-subtle">
This permanently deletes
<span class="font-medium">{{ $entry['name'] }}</span>
inside the container. This cannot be undone.
</p>
<div class="flex justify-end">
<x-forms.button isError type="button" canGate="canAccessTerminal" :canResource="$resource"
x-on:click="run(isDir ? 'Deleting folder...' : 'Deleting file...', () => $wire.deleteEntry(name)); $dispatch('close-modal')">Delete</x-forms.button>
</div>
</x-modal-input>
</div>
</template>
</div>
</div>
</x-modal-input>
</x-slot:danger>
</x-table.row-actions>
</div>
</div>
@endforeach
@@ -126,13 +126,13 @@
@if ($showBackupAction)
<div
class="volumes-col-actions volumes-cell-actions flex flex-nowrap items-center justify-end gap-1.5">
@if ($canUpdate)
@if ($canUpdate && ! $form['canDeleteStale'] && ! $form['canDeleteToApplyDriverOptions'])
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs" canGate="update"
:canResource="$resource">
Backup
</x-forms.button>
<button type="button" class="icon-button shrink-0" title="Configure volume backup"
aria-label="Configure backup for {{ $form['name'] }}">
<x-reicon name="database" class="size-3.5" />
</button>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
@@ -144,43 +144,72 @@
wire:key="configure-service-volume-backup-{{ $id }}" />
@endif
</x-modal-input>
@elseif ($canUpdate || $form['canDeleteStale'] || $form['canDeleteToApplyDriverOptions'])
<x-table.row-actions label="Storage actions">
@if ($canUpdate)
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-table.row-action>Backup</x-table.row-action>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $id"
wire:key="configure-volume-backup-{{ $id }}" />
@else
<livewire:project.service.volume-backup.create :service="$resource->service"
:selected-target-key="'volume:' . $id"
wire:key="configure-service-volume-backup-{{ $id }}" />
@endif
</x-modal-input>
@endif
@if ($form['canDeleteStale'] || $form['canDeleteToApplyDriverOptions'])
<x-slot:danger>
@if ($form['canDeleteStale'])
<x-modal-confirmation title="Remove stale volume entry?" buttonFullWidth
submitAction="delete({{ $id }})"
:checkboxes="[[
'id' => 'deleteDockerVolume',
'label' => 'Also permanently delete the Docker volume and all its data.',
'default_warning' => 'The Docker volume and its data will not be deleted.',
]]"
:actions="array_values(array_filter([
'This removes only the stale volume entry from Coolify.',
$form['replacedExternalVolume']
? 'The next deployment uses the external volume \''.$form['replacedExternalVolume'].'\' instead of this volume.'
: null,
]))" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name">
<x-slot:trigger>
<x-table.row-action>Delete stale entry</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
@endif
@if ($form['canDeleteToApplyDriverOptions'])
<x-modal-confirmation title="Delete volume entry to apply driver options?" buttonFullWidth
submitAction="delete({{ $id }})"
:checkboxes="[[
'id' => 'deleteDockerVolume',
'label' => 'Permanently delete the Docker volume and all data in it. The next deployment creates the volume again with the driver options.',
'default_warning' => 'The Docker volume and its data stay on the server. The driver options are not applied, because Docker keeps using the existing volume.',
]]"
:actions="[
'Remove the storage entry from Coolify.',
]"
warningMessage="Stop the resource first: Docker cannot delete a volume that a running container uses. If the volume contains data you need, use Backup before you delete it." confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</x-slot:danger>
@endif
</x-table.row-actions>
@else
<span class="text-neutral-400 dark:text-fg-faint">—</span>
@endif
@if ($form['canDeleteStale'])
<x-modal-confirmation title="Remove stale volume entry?" isErrorButton
buttonTitle="Delete stale volume entry" submitAction="delete({{ $id }})"
:checkboxes="[[
'id' => 'deleteDockerVolume',
'label' => 'Also permanently delete the Docker volume and all its data.',
'default_warning' => 'The Docker volume and its data will not be deleted.',
]]"
:actions="array_values(array_filter([
'This removes only the stale volume entry from Coolify.',
$form['replacedExternalVolume']
? 'The next deployment uses the external volume \''.$form['replacedExternalVolume'].'\' instead of this volume.'
: null,
]))" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
@endif
@if ($form['canDeleteToApplyDriverOptions'])
<x-modal-confirmation title="Delete volume entry to apply driver options?" isErrorButton
buttonTitle="Delete" submitAction="delete({{ $id }})"
:checkboxes="[[
'id' => 'deleteDockerVolume',
'label' => 'Permanently delete the Docker volume and all data in it. The next deployment creates the volume again with the driver options.',
'default_warning' => 'The Docker volume and its data stay on the server. The driver options are not applied, because Docker keeps using the existing volume.',
]]"
:actions="[
'Remove the storage entry from Coolify.',
]"
warningMessage="Stop the resource first: Docker cannot delete a volume that a running container uses. If the volume contains data you need, use Backup before you delete it." confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
@endif
</div>
@endif
</div>
@@ -258,50 +287,63 @@
<div
class="volumes-col-actions volumes-cell-actions flex flex-nowrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs">
Update
</x-forms.button>
@if ($showBackupAction && ! $form['isShared'])
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs" canGate="update"
:canResource="$resource">
Backup
</x-forms.button>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $id"
wire:key="configure-volume-backup-{{ $id }}" />
@else
<livewire:project.service.volume-backup.create :service="$resource->service"
:selected-target-key="'volume:' . $id"
wire:key="configure-service-volume-backup-{{ $id }}" />
@endif
</x-modal-input>
@elseif (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable())
<x-modal-input title="New Scheduled Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs" canGate="update"
:canResource="$resource">
Backup
</x-forms.button>
</x-slot:content>
<livewire:project.database.create-scheduled-backup :database="$resource"
wire:key="configure-database-backup-{{ $id }}" />
</x-modal-input>
<button type="submit" class="sr-only" tabindex="-1" aria-hidden="true">Update</button>
@php
$hasBackupAction = auth()->user()?->can('update', $resource)
&& (($showBackupAction && ! $form['isShared'])
|| (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable()));
@endphp
@if ($form['isShared'] && ! $hasBackupAction)
<button type="button" wire:click="submit({{ $id }})" class="icon-button shrink-0"
title="Update storage" aria-label="Update {{ $form['name'] }}">
<x-reicon name="check" class="size-3.5" />
</button>
@else
<x-table.row-actions label="Storage actions">
<x-table.row-action wire:click="submit({{ $id }})">Update</x-table.row-action>
@can('update', $resource)
@if ($showBackupAction && ! $form['isShared'])
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-table.row-action>Backup</x-table.row-action>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $id"
wire:key="configure-volume-backup-{{ $id }}" />
@else
<livewire:project.service.volume-backup.create :service="$resource->service"
:selected-target-key="'volume:' . $id"
wire:key="configure-service-volume-backup-{{ $id }}" />
@endif
</x-modal-input>
@elseif (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable())
<x-modal-input title="New Scheduled Backup" :wireIgnore="false">
<x-slot:content>
<x-table.row-action>Backup</x-table.row-action>
</x-slot:content>
<livewire:project.database.create-scheduled-backup :database="$resource"
wire:key="configure-database-backup-{{ $id }}" />
</x-modal-input>
@endif
@endcan
@unless ($form['isShared'])
<x-slot:danger>
<x-modal-confirmation title="Confirm persistent storage deletion?" buttonFullWidth
submitAction="delete({{ $id }})" :actions="[
'The selected persistent storage/volume will be permanently deleted.',
'If the persistent storage/volume is actvily used by a resource data will be lost.',
]" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
@endunless
</x-table.row-actions>
@endif
@unless ($form['isShared'])
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete({{ $id }})" :actions="[
'The selected persistent storage/volume will be permanently deleted.',
'If the persistent storage/volume is actvily used by a resource data will be lost.',
]" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
@endunless
</div>
</div>
</form>
@@ -146,15 +146,32 @@
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@endif
@if ($recoveryPending)
@if ($recoveryPending && $execution->status !== 'running')
<x-table.row-actions label="Backup actions">
<x-table.row-action wire:click="retryRecovery({{ $execution->id }})"
wire:loading.attr="disabled"
wire:target="retryRecovery({{ $execution->id }})">Retry recovery</x-table.row-action>
<x-slot:danger>
<x-modal-confirmation title="Confirm Backup Deletion?" buttonFullWidth
submitAction="deleteBackup({{ $execution->id }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ $execution->filename }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
@elseif ($recoveryPending)
<button type="button" class="icon-button shrink-0"
wire:click="retryRecovery({{ $execution->id }})"
wire:loading.attr="disabled" wire:target="retryRecovery({{ $execution->id }})"
title="Retry recovery" aria-label="Retry recovery">
<x-reicon name="refresh" class="size-3.5" />
</button>
@endif
@if ($execution->status !== 'running')
@elseif ($execution->status !== 'running')
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ $execution->id }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
@@ -155,7 +155,7 @@
class="environment-resource-count" />
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
class="environment-description" />
<div></div>
<span class="text-right">Actions</span>
</div>
<template x-for="environment in paginatedEnvironments" :key="environment.uuid">
@@ -187,23 +187,15 @@
</div>
<div class="flex items-center justify-end gap-1">
@if (auth()->id() === $token->tokenable_id)
<x-table.dropdown panel-class="w-36!" role="menu">
<x-slot:trigger>
<button type="button" class="icon-button" title="Token actions"
aria-label="Token actions" aria-haspopup="menu" :aria-expanded="open">
<x-reicon name="dots-horizontal" class="size-4" />
</button>
</x-slot:trigger>
<button type="button" role="menuitem"
x-on:click="close(); editToken(@js(App\Livewire\Forms\ApiTokenForm::valuesOf($token)))"
class="listbox-option justify-start!">
<x-table.row-actions label="Token actions">
<x-table.row-action
x-on:click="editToken({{ \Illuminate\Support\Js::from(App\Livewire\Forms\ApiTokenForm::valuesOf($token)) }})">
Edit
</button>
<button type="button" role="menuitem" x-on:click="close()"
wire:click="setPaused({{ $token->id }}, {{ $token->isPaused() ? 'false' : 'true' }})"
class="listbox-option justify-start!">
</x-table.row-action>
<x-table.row-action
wire:click="setPaused({{ $token->id }}, {{ $token->isPaused() ? 'false' : 'true' }})">
{{ $token->isPaused() ? 'Resume' : 'Pause' }}
</button>
</x-table.row-action>
<x-modal-confirmation title="Roll API Token?" buttonFullWidth
submitAction="roll({{ $token->id }})" :actions="[
'A new secret replaces this token. The current secret stops working at once.',
@@ -214,13 +206,10 @@
shortConfirmationLabel="Token description"
:confirmWithPassword="false" step2ButtonText="Roll token">
<x-slot:trigger>
<button type="button" role="menuitem" x-on:click="close()"
class="listbox-option justify-start!">
Roll
</button>
<x-table.row-action>Roll</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
<div class="my-1 border-t border-neutral-200 dark:border-white/[0.08]"></div>
<x-slot:danger>
<x-modal-confirmation title="Confirm API Token Revocation?" buttonFullWidth
submitAction="revoke({{ $token->id }})" :actions="[
'This API token will be permanently revoked.',
@@ -230,13 +219,11 @@
shortConfirmationLabel="Token description"
:confirmWithPassword="false" step2ButtonText="Revoke token">
<x-slot:trigger>
<button type="button" role="menuitem" x-on:click="close()"
class="listbox-option justify-start! text-error! hover:text-error!">
Revoke
</button>
<x-table.row-action>Revoke</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-table.dropdown>
</x-slot:danger>
</x-table.row-actions>
@endif
</div>
</div>
@@ -28,10 +28,10 @@
icon-name="file-content" size="sm" />
@else
<div>
<div class="grid grid-cols-[minmax(0,1fr)_12rem_1.75rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div class="grid grid-cols-[minmax(0,1fr)_12rem_4.5rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div class="pl-11">Script</div>
<div>Last updated</div>
<div class="w-7"></div>
<div class="text-right">Actions</div>
</div>
@foreach ($scripts as $script)
@can('view', $script)
@@ -40,7 +40,7 @@
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_12rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]"
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_12rem_4.5rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]"
>
<div class="flex min-w-0 items-center gap-3">
<div
@@ -59,10 +59,12 @@
Updated {{ $script->updated_at->diffForHumans() }}
</span>
</div>
<button type="button" class="icon-button" title="Edit cloud-init script"
aria-label="Edit {{ $script->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
<div class="flex justify-end">
<button type="button" class="icon-button" title="Edit cloud-init script"
aria-label="Edit {{ $script->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</div>
</x-slot:content>
<livewire:security.cloud-init-script.show :cloud_init_script_uuid="$script->uuid"
@@ -20,11 +20,11 @@
description="Add a provider token to provision new cloud servers." icon-name="keys" size="sm" />
@else
<div>
<div class="grid grid-cols-[minmax(0,1fr)_8rem_1.75rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_1.75rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div class="grid grid-cols-[minmax(0,1fr)_8rem_4.5rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_4.5rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div class="pl-11">Token</div>
<div class="text-center">Provider</div>
<div class="hidden sm:block">Description</div>
<div class="w-7"></div>
<div class="text-right">Actions</div>
</div>
@foreach ($tokens as $savedToken)
<x-modal-input title="Edit Cloud Token" isFullWidth :wireIgnore="false" :contentClicks="false"
@@ -32,7 +32,7 @@
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_8rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_1.75rem] dark:hover:bg-white/[0.025]"
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_8rem_4.5rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_4.5rem] dark:hover:bg-white/[0.025]"
>
<div class="flex min-w-0 items-center gap-3">
<div
@@ -52,10 +52,12 @@
</span>
</div>
<p class="hidden truncate text-[12px] text-neutral-500 sm:block dark:text-fg-dim">{{ $savedToken->description ?: '-' }}</p>
<button type="button" class="icon-button" title="Edit cloud token"
aria-label="Edit {{ $savedToken->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
<div class="flex justify-end">
<button type="button" class="icon-button" title="Edit cloud token"
aria-label="Edit {{ $savedToken->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</div>
</x-slot:content>
<livewire:security.cloud-provider-token.show :cloud_token_uuid="$savedToken->uuid"
@@ -74,10 +74,12 @@
</span>
@endif
</div>
<button type="button" class="icon-button" title="Edit integration token"
:aria-label="`Edit ${tokenName}`" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
<div class="flex justify-end">
<button type="button" class="icon-button" title="Edit integration token"
:aria-label="`Edit ${tokenName}`" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</div>
</x-slot:content>
<livewire:security.integration-token-editor
@@ -6,7 +6,7 @@
<x-forms.input required id="{{ $form }}.description" label="Description"
placeholder="CI deployment token" />
<x-forms.listbox id="{{ $form }}.expiresInDays" label="Expires in" :options="$expiryOptions"
x-effect="{{ $form === 'editForm' ? 'options[0].label = editingToken?.keepLabel ?? options[0].label' : '' }}" />
:x-effect="$form === 'editForm' ? 'options[0].label = editingToken?.keepLabel ?? options[0].label' : null" />
</div>
<div class="mt-4 border-t border-neutral-200 pt-3 dark:border-white/[0.08]">
@@ -68,18 +68,18 @@
icon-name="keys" />
@else
<div>
<div class="grid grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4.5rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_4.5rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div class="pl-11">Private key</div>
<div class="hidden sm:block">Description</div>
<div class="text-center">Status</div>
<div class="w-7"></div>
<div class="text-right">Actions</div>
</div>
@foreach ($privateKeys as $key)
@can('view', $key)
<div wire:key="private-key-{{ $key->id }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:hover:bg-white/[0.025]"
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_7rem_4.5rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_4.5rem] dark:hover:bg-white/[0.025]"
>
<div class="flex min-w-0 items-center gap-3">
<div
@@ -102,16 +102,18 @@
<x-status-badge label="Unused" type="warning" />
@endif
</div>
<button type="button" class="icon-button" title="Edit private key"
aria-label="Edit {{ $key->name }}"
@click="$dispatch('open-private-key-editor', { name: @js($key->name), description: @js($key->description ?? '') })"
wire:click="openEditor('{{ $key->uuid }}')">
<x-reicon name="settings" class="size-3.5" />
</button>
<div class="flex justify-end">
<button type="button" class="icon-button" title="Edit private key"
aria-label="Edit {{ $key->name }}"
@click="$dispatch('open-private-key-editor', { name: @js($key->name), description: @js($key->description ?? '') })"
wire:click="openEditor('{{ $key->uuid }}')">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</div>
</div>
@else
<div class="grid min-h-14 cursor-not-allowed grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 opacity-65 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:border-white/[0.07]"
<div class="grid min-h-14 cursor-not-allowed grid-cols-[minmax(0,1fr)_7rem_4.5rem] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 opacity-65 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_4.5rem] dark:border-white/[0.07]"
title="You do not have permission to view this private key">
<div class="flex items-start gap-3">
<div
@@ -62,7 +62,7 @@
action="setSort" :current="$sort" />
<x-table.sort-header label="Used by" asc="used_asc" desc="used_desc" action="setSort"
:current="$sort" />
<span>Actions</span>
<span class="text-right">Actions</span>
</div>
@foreach ($visibleImages as $image)
<div wire:key="image-{{ $image['reference'] }}"
@@ -94,10 +94,10 @@
</span>
@endif
</div>
<div>
<div class="flex justify-end">
@if ($image['containers'] === [])
@can('update', $server)
<x-modal-confirmation title="Delete Docker Image?" buttonTitle="Delete"
<x-modal-confirmation title="Delete Docker Image?"
submitAction="delete({{ $image['reference'] }})" :actions="[
'Delete ' . $image['reference'] . ' from this server.',
'It has to be pulled or built again before it can be used.',
@@ -105,7 +105,15 @@
confirmationText="{{ $image['reference'] }}"
confirmationLabel="Confirm by entering the image reference"
shortConfirmationLabel="Image" step2ButtonText="Delete Image"
isErrorButton :confirmWithPassword="false" />
:confirmWithPassword="false">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete image" aria-label="Delete {{ $image['reference'] }}">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endcan
@endif
</div>
@@ -38,11 +38,11 @@
@else
<div class="overflow-x-auto">
<div
class="grid min-w-[760px] grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)_14rem] border-b border-neutral-200 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
class="grid min-w-[760px] grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)_4.5rem] border-b border-neutral-200 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<div>Registry</div>
<div>Status</div>
<div>Used by</div>
<div></div>
<div class="text-right">Actions</div>
</div>
@foreach ($visibleRows as $row)
@php
@@ -53,7 +53,7 @@
@endphp
<div wire:key="{{ $server->uuid }}-{{ $row['registry'] }}" x-data="{ showUsers: false }"
class="min-w-[760px] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="grid min-h-11 grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)_14rem] items-center px-4 py-2 text-[12px]">
<div class="grid min-h-11 grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)_4.5rem] items-center px-4 py-2 text-[12px]">
<div class="truncate font-mono">{{ $row['registry'] }}</div>
<div>
@if ($row['logged_in'] && ($loginChecks[$row['registry']]['ok'] ?? true) === false)
@@ -83,8 +83,34 @@
</button>
@endif
</div>
<div class="flex justify-end gap-2">
@if ($row['logged_in'])
<div class="flex justify-end">
@if ($row['logged_in'] && $row['source'] === 'auths')
<x-table.row-actions label="Registry actions">
<x-table.row-action wire:click="testLogin({{ \Illuminate\Support\Js::from($row['registry']) }})" wire:loading.attr="disabled"
wire:target="testLogin" title="Run docker login with the saved credentials">Test</x-table.row-action>
<x-modal-input title="Edit login for {{ $row['registry'] }}"
subtitle="Runs docker login on {{ $server->name }} again. The new values replace the old login.">
<x-slot:content>
<x-table.row-action>Edit</x-table.row-action>
</x-slot:content>
<livewire:server.docker-registries.login :server="$server" :editRegistry="$row['registry']"
:currentUsername="$row['username']"
:key="'registry-edit-'.$server->uuid.'-'.$row['registry']" />
</x-modal-input>
<x-slot:danger>
<x-modal-confirmation title="Delete the login for {{ $row['registry'] }}?" buttonFullWidth
submitAction="logout({{ $row['registry'] }})" :actions="[
'Runs docker logout on '.$server->name.' and removes the saved credentials.',
'The server can no longer pull private images from this registry.',
'Deployments that need this registry fail until you log in again.',
]" :confirmWithText="false" :confirmWithPassword="false">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
@elseif ($row['logged_in'])
<button type="button" wire:click="testLogin(@js($row['registry']))" wire:loading.attr="disabled"
wire:target="testLogin" class="button w-fit"
title="Run docker login with the saved credentials">Test</button>
@@ -95,20 +121,6 @@
:key="'registry-login-'.$server->uuid.'-'.$row['registry']" />
</x-modal-input>
@endif
@if ($row['logged_in'] && $row['source'] === 'auths')
<x-modal-input buttonTitle="Edit" title="Edit login for {{ $row['registry'] }}"
subtitle="Runs docker login on {{ $server->name }} again. The new values replace the old login.">
<livewire:server.docker-registries.login :server="$server" :editRegistry="$row['registry']"
:currentUsername="$row['username']"
:key="'registry-edit-'.$server->uuid.'-'.$row['registry']" />
</x-modal-input>
<x-modal-confirmation title="Delete the login for {{ $row['registry'] }}?" buttonTitle="Delete" isErrorButton
submitAction="logout({{ $row['registry'] }})" :actions="[
'Runs docker logout on '.$server->name.' and removes the saved credentials.',
'The server can no longer pull private images from this registry.',
'Deployments that need this registry fail until you log in again.',
]" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($usedByCount > 1)
@@ -37,7 +37,7 @@
<th>Resolver</th>
<th>Alternative names</th>
<th>Expires</th>
<th><span class="sr-only">Actions</span></th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@@ -71,22 +71,31 @@
<td>
{{ $certificate['expires_at'] ?? 'Unknown' }}
</td>
<td class="text-right">
<td>
@can('update', $server)
<x-modal-confirmation title="Delete TLS Certificate?"
buttonTitle="Delete"
submitAction="deleteTraefikCertificate({{ $certificate['id'] }})"
:actions="[
'Save the current acme.json as a backup that you can restore below.',
'Delete the certificate for '.$certificate['main_domain'].' from acme.json.',
]"
warningMessage="The proxy keeps using the certificate until you restart it. If a route still uses the domain, Traefik requests a new certificate after the restart. You can restore the backup below to undo this."
confirmationText="{{ $certificate['main_domain'] }}"
confirmationLabel="Confirm by entering the domain"
shortConfirmationLabel="Domain"
step2ButtonText="Delete Certificate"
isErrorButton :confirmWithPassword="false"
:confirmWithText="true" />
<div class="flex justify-end">
<x-modal-confirmation title="Delete TLS Certificate?"
submitAction="deleteTraefikCertificate({{ $certificate['id'] }})"
:actions="[
'Save the current acme.json as a backup that you can restore below.',
'Delete the certificate for '.$certificate['main_domain'].' from acme.json.',
]"
warningMessage="The proxy keeps using the certificate until you restart it. If a route still uses the domain, Traefik requests a new certificate after the restart. You can restore the backup below to undo this."
confirmationText="{{ $certificate['main_domain'] }}"
confirmationLabel="Confirm by entering the domain"
shortConfirmationLabel="Domain"
step2ButtonText="Delete Certificate"
:confirmWithPassword="false"
:confirmWithText="true">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete certificate" aria-label="Delete certificate for {{ $certificate['main_domain'] }}">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@endcan
</td>
</tr>
@@ -115,7 +124,7 @@
<th>Backup</th>
<th>Created</th>
<th>Size</th>
<th><span class="sr-only">Actions</span></th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@@ -127,28 +136,40 @@
<td>{{ $backup['created_at'] }}</td>
<td>{{ formatBytes($backup['size']) }}</td>
<td>
<div class="flex justify-end gap-2">
<x-modal-confirmation title="Restore acme.json Backup?"
buttonTitle="Restore"
submitAction="restoreTraefikAcmeBackup('{{ $backup['name'] }}')"
:checkboxes="[
['id' => 'restartProxyAfterAcmeRestore', 'label' => 'Restart the proxy now. Sites on this server are unavailable for a few seconds.'],
]"
:actions="[
'Save the current acme.json as a new backup.',
'Replace acme.json with the backup from '.$backup['created_at'].'.',
]"
warningMessage="Until the proxy restarts, it keeps its loaded certificates and can write them back to acme.json. Certificates issued after this backup are removed from acme.json."
step2ButtonText="Restore Backup"
:confirmWithPassword="false"
:confirmWithText="false" />
<x-modal-confirmation title="Delete acme.json Backup?"
buttonTitle="Delete"
submitAction="deleteTraefikAcmeBackup('{{ $backup['name'] }}')"
:actions="['Delete the backup '.$backup['name'].'.']"
step2ButtonText="Delete Backup"
isErrorButton :confirmWithPassword="false"
:confirmWithText="false" />
<div class="flex justify-end">
<x-table.row-actions label="Backup actions">
<x-modal-confirmation title="Restore acme.json Backup?"
buttonFullWidth
submitAction="restoreTraefikAcmeBackup('{{ $backup['name'] }}')"
:checkboxes="[
['id' => 'restartProxyAfterAcmeRestore', 'label' => 'Restart the proxy now. Sites on this server are unavailable for a few seconds.'],
]"
:actions="[
'Save the current acme.json as a new backup.',
'Replace acme.json with the backup from '.$backup['created_at'].'.',
]"
warningMessage="Until the proxy restarts, it keeps its loaded certificates and can write them back to acme.json. Certificates issued after this backup are removed from acme.json."
step2ButtonText="Restore Backup"
:confirmWithPassword="false"
:confirmWithText="false">
<x-slot:trigger>
<x-table.row-action>Restore</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
<x-slot:danger>
<x-modal-confirmation title="Delete acme.json Backup?"
buttonFullWidth
submitAction="deleteTraefikAcmeBackup('{{ $backup['name'] }}')"
:actions="['Delete the backup '.$backup['name'].'.']"
step2ButtonText="Delete Backup"
:confirmWithPassword="false"
:confirmWithText="false">
<x-slot:trigger>
<x-table.row-action>Delete</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
</div>
</td>
</tr>
@@ -112,7 +112,7 @@
<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>
<span class="text-right">Actions</span>
</div>
@foreach ($resources as $resource)
@php($containerState = (string) data_get($resource, 'State', 'unknown'))
@@ -130,30 +130,39 @@
? 'success'
: ($containerState === 'exited' ? 'error' : 'warning')" />
</div>
<div class="flex flex-wrap items-center gap-2">
@if ($containerState === 'running')
<x-forms.button canGate="update" :canResource="$server"
wire:click="restartUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="restart-{{ data_get($resource, 'ID') }}">
Restart
</x-forms.button>
<x-forms.button canGate="update" :canResource="$server" isError
wire:click="stopUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="stop-{{ data_get($resource, 'ID') }}">
Stop
</x-forms.button>
@elseif ($containerState === 'exited')
<x-forms.button canGate="update" :canResource="$server"
wire:click="startUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="start-{{ data_get($resource, 'ID') }}">
Start
</x-forms.button>
@elseif ($containerState === 'restarting')
<x-forms.button canGate="update" :canResource="$server"
wire:click="stopUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="stop-restarting-{{ data_get($resource, 'ID') }}">
Stop
</x-forms.button>
<div class="flex justify-end">
@if (in_array($containerState, ['running', 'exited', 'restarting'], true))
@can('update', $server)
@if ($containerState === 'running')
<x-table.row-actions label="Container actions">
<x-table.row-action
wire:click="restartUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="restart-{{ data_get($resource, 'ID') }}">
Restart
</x-table.row-action>
<x-table.row-action
wire:click="stopUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="stop-{{ data_get($resource, 'ID') }}">
Stop
</x-table.row-action>
</x-table.row-actions>
@elseif ($containerState === 'exited')
<button type="button" class="icon-button shrink-0"
wire:click="startUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="start-{{ data_get($resource, 'ID') }}"
title="Start container" aria-label="Start container">
<x-reicon name="play-circle" class="size-3.5" />
</button>
@else
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
wire:click="stopUnmanaged('{{ data_get($resource, 'ID') }}')"
wire:key="stop-restarting-{{ data_get($resource, 'ID') }}"
title="Stop container" aria-label="Stop container">
<x-reicon name="stop-circle" class="size-3.5" />
</button>
@endif
@endcan
@endif
</div>
</div>
@@ -223,7 +223,7 @@
<div class="data-table-header package-updates-table-grid">
<span>Package</span>
<span>New version</span>
<span class="text-right">Action</span>
<span class="text-right">Actions</span>
</div>
@foreach ($updates as $update)
<div wire:key="package-update-{{ data_get($update, 'package') }}-{{ data_get($update, 'architecture') }}"
@@ -17,11 +17,12 @@
<div class="overflow-x-auto">
<div
class="grid min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] 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">
class="grid min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(12rem,1.2fr)_4.5rem] 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>Backup target</div>
<div>Frequency</div>
<div>Status</div>
<div>Storage</div>
<div class="text-right">Actions</div>
</div>
@foreach ($groupedBackups as $backups)
@php
@@ -77,7 +78,7 @@
])->values()->all();
@endphp
<div
class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]"
class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(12rem,1.2fr)_4.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]"
x-show="search === '' || '{{ strtolower(addslashes($databaseName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
<div class="min-w-0">
@if ($resourceLink)
@@ -104,16 +105,20 @@
portal />
<button type="button" class="button shrink-0"
wire:click="moveBackup({{ $backup->id }})">Move</button>
<div wire:key="disableS3-{{ $backup->id }}">
<x-modal-confirmation title="Remove this storage from the backup schedule?"
submitAction="disableS3({{ $backup->id }})" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Remove storage"
:actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']">
<x-slot:trigger>
<button type="button" class="button shrink-0 text-error">Disable</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
<div class="flex justify-end" wire:key="disableS3-{{ $backup->id }}">
<x-modal-confirmation title="Remove this storage from the backup schedule?"
submitAction="disableS3({{ $backup->id }})" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Remove storage"
:actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Disable storage" aria-label="Remove this storage from the backup schedule">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
@endforeach
@@ -131,7 +136,7 @@
])->values()->all();
@endphp
<div
class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]"
class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(12rem,1.2fr)_4.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]"
x-show="search === '' || '{{ strtolower(addslashes($targetName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($targetType)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($resourceName ?? '')) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
<div class="min-w-0">
<div class="truncate font-medium text-black dark:text-fg">{{ $targetName }}</div>
@@ -149,16 +154,20 @@
portal />
<button type="button" class="button shrink-0"
wire:click="moveVolumeBackup({{ $backup->id }})">Move</button>
<div wire:key="disableVolumeS3-{{ $backup->id }}">
<x-modal-confirmation title="Remove this storage from the backup schedule?"
submitAction="disableVolumeS3({{ $backup->id }})" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Remove storage"
:actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']">
<x-slot:trigger>
<button type="button" class="button shrink-0 text-error">Disable</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
<div class="flex justify-end" wire:key="disableVolumeS3-{{ $backup->id }}">
<x-modal-confirmation title="Remove this storage from the backup schedule?"
submitAction="disableVolumeS3({{ $backup->id }})" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Remove storage"
:actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Disable storage" aria-label="Remove this storage from the backup schedule">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
@endforeach
@@ -74,8 +74,9 @@
shortConfirmationLabel="User name">
<x-slot:trigger>
<button type="button"
class="text-[12px] font-medium text-red-600 transition-colors hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
Delete
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete user" aria-label="Delete {{ $user->name }}">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@@ -7,7 +7,7 @@
<div class="data-table min-w-[760px] transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="setSort">
<div
class="data-table-header grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem]">
class="data-table-header grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_4.5rem]">
<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"
@@ -19,7 +19,7 @@
</div>
@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]">
class="data-table-row grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_4.5rem] 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">
{{ $invite->email }}
</div>
@@ -35,12 +35,20 @@
title="{{ $invite->link }}">{{ $invite->link }}</span>
<x-copy-button :value="$invite->link" label="Copy invitation link" />
</div>
<div class="text-right">
<button type="button"
class="button h-7! px-2.5! text-[11px]! text-error! hover:text-error!"
wire:click.prevent="deleteInvitation({{ $invite->id }})">
Revoke
</button>
<div class="flex justify-end">
<x-modal-confirmation title="Revoke invitation?"
submitAction="deleteInvitation({{ $invite->id }})"
:actions="['The invitation link for ' . $invite->email . ' stops working.']"
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Revoke invitation">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Revoke invitation" aria-label="Revoke invitation for {{ $invite->email }}">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
@endforeach
+49 -54
View File
@@ -52,61 +52,56 @@
<div class="flex justify-end">
@can('manageMembers', currentTeam())
@if ($member->id !== Auth::id())
<div x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button h-7! px-2.5! text-[11px]!" x-ref="trigger"
@click="open = !open" aria-haspopup="menu" :aria-expanded="open">
Manage
</button>
<template x-teleport="body">
<div x-show="open" x-cloak role="menu" x-anchor.bottom-end.fixed.offset.4="$refs.trigger"
@click.outside="if (! $refs.trigger.contains($event.target)) open = false"
class="listbox-panel w-36! min-w-0! z-50!">
@if (Auth::user()->isOwner())
@if (data_get($member, 'pivot.role') !== 'owner')
<button type="button" class="listbox-option justify-start!" wire:click="makeOwner"
@click="open = false">
Make owner
</button>
@endif
@if (data_get($member, 'pivot.role') !== 'admin')
<button type="button" class="listbox-option justify-start!" wire:click="makeAdmin"
@click="open = false">
Make admin
</button>
@endif
@if (data_get($member, 'pivot.role') !== 'member')
<button type="button" class="listbox-option justify-start!"
wire:click="makeReadonly" @click="open = false">
Make member
</button>
@endif
@elseif (Auth::user()->isAdmin())
@if (data_get($member, 'pivot.role') === 'admin')
<button type="button" class="listbox-option justify-start!"
wire:click="makeReadonly" @click="open = false">
Make member
</button>
@elseif (data_get($member, 'pivot.role') === 'member')
<button type="button" class="listbox-option justify-start!" wire:click="makeAdmin"
@click="open = false">
Make admin
</button>
@endif
@endif
@if (data_get($member, 'pivot.role') === 'member')
<button type="button" class="listbox-option justify-start!"
wire:click="$dispatch('open-member-access', { memberId: {{ $member->id }} })" @click="open = false">
Edit access
</button>
@endif
<div class="my-1 border-t border-neutral-200 dark:border-white/[0.08]"></div>
<button type="button" class="listbox-option justify-start! text-error! hover:text-error!"
wire:click="remove" @click="open = false">
Remove member
@if (data_get($member, 'pivot.role') === 'owner' && ! Auth::user()->isOwner())
<x-modal-confirmation title="Remove team member?" submitAction="remove"
:actions="['This user loses access to all resources of this team.']"
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Remove member">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Remove member" aria-label="Remove {{ $member->name }}">
<x-reicon name="trash" class="size-3.5" />
</button>
</div>
</template>
</div>
</x-slot:trigger>
</x-modal-confirmation>
@else
<x-table.row-actions label="Member actions">
@if (Auth::user()->isOwner())
@if (data_get($member, 'pivot.role') !== 'owner')
<x-table.row-action wire:click="makeOwner">Make owner</x-table.row-action>
@endif
@if (data_get($member, 'pivot.role') !== 'admin')
<x-table.row-action wire:click="makeAdmin">Make admin</x-table.row-action>
@endif
@if (data_get($member, 'pivot.role') !== 'member')
<x-table.row-action wire:click="makeReadonly">Make member</x-table.row-action>
@endif
@elseif (Auth::user()->isAdmin())
@if (data_get($member, 'pivot.role') === 'admin')
<x-table.row-action wire:click="makeReadonly">Make member</x-table.row-action>
@elseif (data_get($member, 'pivot.role') === 'member')
<x-table.row-action wire:click="makeAdmin">Make admin</x-table.row-action>
@endif
@endif
@if (data_get($member, 'pivot.role') === 'member')
<x-table.row-action
wire:click="$dispatch('open-member-access', { memberId: {{ $member->id }} })">
Edit access
</x-table.row-action>
@endif
<x-slot:danger>
<x-modal-confirmation title="Remove team member?" buttonFullWidth submitAction="remove"
:actions="['This user loses access to all resources of this team.']"
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Remove member">
<x-slot:trigger>
<x-table.row-action>Remove member</x-table.row-action>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:danger>
</x-table.row-actions>
@endif
@endif
@endcan
</div>
+1 -1
View File
@@ -121,7 +121,7 @@ it('refreshes service backup buttons when the service status changes', function
? ['service' => $this->service]
: ['backup' => $this->backups['service']->fresh(), 'availableS3Storages' => collect()];
$component = Livewire::test($componentClass, $parameters);
$disabledButton = '/<button\s+disabled[^>]*wire:click(\.stop)?="backupNow/s';
$disabledButton = '/<button(?=[^>]*\sdisabled\b)(?=[^>]*wire:click(\.stop)?="backupNow)[^>]*>/s';
$database->update(['status' => 'running:healthy']);
$component->dispatch('echo-private:team.'.currentTeam()->id.','.$event);
@@ -105,8 +105,8 @@ it('declares update authorization on service backup mutation controls', function
expect($volumeBackupView)
->toMatch('/<x-modal-input(?=[^>]*:title="\'Edit backup schedule\'")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/')
->toMatch('/<x-forms\.button\s+type="button" canGate="update" :canResource="\$service"[^\n]*\n(?:[^\n]*\n){0,3}?\s*wire:click\.stop="backupNow\(\'database\',/')
->toMatch('/<x-forms\.button(?=[^>]*wire:click\.stop="backupNow\(\'storage\',[^"]+")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/');
->toMatch('/@can\(\'update\', \$service\)\s*<x-table\.row-actions\b(?:(?!@endcan).)*?wire:click="backupNow\(\'database\',/s')
->toMatch('/@can\(\'update\', \$service\)\s*<x-table\.row-actions\b(?:(?!@endcan).)*?wire:click="backupNow\(\'storage\',/s');
});
it('declares update authorization on application port controls', function () {
@@ -131,15 +131,13 @@ it('declares explicit authorization on the changed storage controls', function (
->toContain('canGate="update"')
->toContain(':canResource="$resource"');
preg_match_all('/<x-forms\.button\b[^>]*>\s*Backup\s*<\/x-forms\.button>/s', $view, $backupButtons);
$backupAction = '/<x-table\.row-action>Backup<\/x-table\.row-action>/';
preg_match('/@if \(\$canUpdate\)\s*<x-modal-input\b.*?<\/x-modal-input>/s', $view, $canUpdateBlock);
preg_match('/@can\(\'update\', \$resource\)(.*?)@endcan/s', $view, $canBlock);
expect($backupButtons[0])->toHaveCount(3);
foreach ($backupButtons[0] as $backupButton) {
expect($backupButton)
->toContain('canGate="update"')
->toContain(':canResource="$resource"');
}
expect(preg_match_all($backupAction, $view))->toBe(3)
->and(preg_match_all($backupAction, $canUpdateBlock[0] ?? ''))->toBe(1)
->and(preg_match_all($backupAction, $canBlock[0] ?? ''))->toBe(2);
});
it('creates named volumes without a host path in swarm mode', function () {
@@ -329,7 +327,8 @@ YAML,
]);
Livewire::test(All::class, ['resource' => $application])
->assertSee('Delete stale volume entry')
->assertSee('Remove stale volume entry?')
->assertSee('Delete stale entry')
->call('delete', $volume->id, 'password');
expect($volume->fresh())->toBeNull();
+7 -7
View File
@@ -463,11 +463,11 @@ test('service storage backups page includes schedules from all compose databases
'service_uuid' => $this->ownService->uuid,
]))
->assertOk()
->assertSee('>Database</span>', false)
->assertSee('>Database</button>', false)
->assertSee('own-db')
->assertSee('analytics-db')
->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false)
->assertSee("wire:click.stop=\"backupNow('database', '{$backups->first()->uuid}')\"", false);
->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false);
});
test('service backup settings open automatically from the creation redirect', function () {
@@ -521,18 +521,18 @@ test('service backups have explicit settings actions for database and storage sc
$dom = new DOMDocument;
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$buttons = $xpath->query('//button[@aria-label="Edit backup schedule"]');
$buttons = $xpath->query('//button[@role="menuitem"][normalize-space()="Edit"]');
$actions = [];
foreach ($buttons as $button) {
$actions[] = $button->getAttribute('wire:click.stop');
$actions[] = $button->getAttribute('wire:click');
}
expect($actions)->toContain("openSchedule('{$databaseBackup->uuid}')", "openSchedule('{$volumeBackup->uuid}')");
foreach (['database' => $databaseBackup, 'storage' => $volumeBackup] as $type => $backup) {
$backupAction = "wire:click.stop=\"backupNow('{$type}', '{$backup->uuid}')\"";
$settingsAction = "wire:click.stop=\"openSchedule('{$backup->uuid}')\"";
expect(strpos($html, $backupAction))->toBeLessThan(strpos($html, $settingsAction));
$backupAction = "wire:click=\"backupNow('{$type}', '{$backup->uuid}')\"";
$settingsAction = "wire:click=\"openSchedule('{$backup->uuid}')\"";
expect(strpos($html, $backupAction))->toBeLessThan(strrpos($html, $settingsAction));
}
});