feat(ui): polish domains, storage, env vars and resource nav

Improve project resource UIs: sort domains by DNS failure, stop re-adding www pairs on refresh, lazy-load storage tabs with counts, tighten env-var tables, keep application tabs active across Livewire polls, unify database type labels, and update related CSS/JS and tests.
This commit is contained in:
Andras Bacsai committed 2026-08-05 13:50:11 +02:00
1 parent dbc6f5e08c
commit a7a06aa6c8
75 files changed
+3909 -1173

No files matched your search

@@ -43,11 +43,6 @@ class ConfigurationChecker extends Component
return view('livewire.project.shared.configuration-checker');
}
public function refreshConfigurationChanges(): void
{
$this->configurationChanged();
}
/**
* Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser.
@@ -80,18 +75,42 @@ class ConfigurationChecker extends Component
}
public function configurationChanged(): void
{
// Banner only needs a lightweight summary in the Livewire snapshot.
$this->loadConfigurationState(includeChanges: false);
}
public function refreshConfigurationChanges(): void
{
// Full change list is only needed when the user opens "View changes".
$this->loadConfigurationState(includeChanges: true);
}
/**
* @param bool $includeChanges When false, only summary keys are stored (smaller HTML/snapshots).
*/
private function loadConfigurationState(bool $includeChanges = false): void
{
$this->resource->refresh();
if ($this->resource instanceof Application) {
$diff = $this->resource->pendingDeploymentConfigurationDiff();
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$this->isConfigurationChanged = $diff->isChanged();
$array = $diff->toArray();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->isConfigurationChanged = $diff->isChanged();
if (! $includeChanges) {
$this->configurationDiff = [
'count' => data_get($array, 'count', 0),
'requires_build' => (bool) data_get($array, 'requires_build', false),
];
return;
}
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->configurationDiff = $array;
return;
@@ -32,6 +32,14 @@ class All extends Component
public string $environmentFilter = 'all';
/** @var list<string> */
public array $variableFilters = [];
/** @var list<string> */
public array $serviceFilters = [];
public string $tableSort = 'default';
public int $page = 1;
public int $perPage = 10;
@@ -79,7 +87,8 @@ class All extends Component
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$hasGitRepository = filled(data_get($this->resource, 'git_repository'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && $hasGitRepository && ! $simpleDockerfile) {
$this->showPreview = true;
}
// Intentionally skip loading env vars / developer-view bulk text here.
@@ -357,6 +366,78 @@ class All extends Component
$this->clearEnvironmentVariableCaches();
}
public function toggleVariableFilter(string $filter): void
{
if (! in_array($filter, ['all', 'managed', 'user', 'buildtime', 'runtime', 'multiline', 'literal'], true)) {
return;
}
if ($filter === 'all') {
$this->variableFilters = [];
} elseif (in_array($filter, $this->variableFilters, true)) {
$this->variableFilters = array_values(array_diff($this->variableFilters, [$filter]));
} else {
if ($filter === 'managed') {
$this->variableFilters = array_values(array_diff($this->variableFilters, ['user']));
} elseif ($filter === 'user') {
$this->variableFilters = array_values(array_diff($this->variableFilters, ['managed']));
}
$this->variableFilters[] = $filter;
}
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function setTableSort(string $sort): void
{
if (! in_array($sort, ['default', 'name_asc', 'name_desc'], true)) {
return;
}
$this->tableSort = $sort;
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function toggleServiceFilter(string $service): void
{
if (! in_array($service, $this->serviceFilterOptions, true)) {
return;
}
$this->serviceFilters = in_array($service, $this->serviceFilters, true)
? array_values(array_diff($this->serviceFilters, [$service]))
: [...$this->serviceFilters, $service];
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function clearFilters(): void
{
$this->variableFilters = [];
$this->serviceFilters = [];
$this->environmentFilter = 'all';
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function getServiceFilterOptionsProperty(): array
{
$compose = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
if (blank($compose)) {
return [];
}
return extractHardcodedEnvironmentVariables($compose)
->pluck('service_name')
->filter()
->unique()
->sort()
->values()
->all();
}
public function setEnvironmentVariablePage(int $page): void
{
$this->page = max(1, min($page, $this->environmentVariableLastPage));
@@ -388,35 +469,35 @@ class All extends Component
$segments = [];
if ($includeProduction) {
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
if ($this->showsHardcodedEnvironmentVariables()) {
if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => false,
'count' => $this->hardcodedEnvironmentVariables->count(),
];
}
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
}
if ($includePreview) {
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
if ($this->showsHardcodedEnvironmentVariables()) {
if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => true,
'count' => $this->hardcodedEnvironmentVariablesPreview->count(),
];
}
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
}
return $segments;
@@ -429,6 +510,12 @@ class All extends Component
->where('resourceable_id', $this->resource->id)
->where('is_preview', $isPreview);
if ($this->serviceFilters !== []) {
$query->whereRaw('1 = 0');
}
$query->orderByRaw("CASE WHEN key LIKE 'SERVICE_FQDN%' OR key LIKE 'SERVICE_URL%' OR key LIKE 'SERVICE_NAME%' THEN 0 ELSE 1 END");
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->searchTerm() !== '') {
@@ -436,7 +523,26 @@ class All extends Component
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
}
if ($this->is_env_sorting_enabled) {
if (in_array('managed', $this->variableFilters, true) || in_array('user', $this->variableFilters, true)) {
$method = in_array('managed', $this->variableFilters, true) ? 'where' : 'whereNot';
$query->{$method}(function (Builder $query): void {
$query->where('key', 'like', 'SERVICE_FQDN%')
->orWhere('key', 'like', 'SERVICE_URL%')
->orWhere('key', 'like', 'SERVICE_NAME%');
});
}
foreach (['buildtime', 'runtime', 'multiline', 'literal'] as $filter) {
if (in_array($filter, $this->variableFilters, true)) {
$query->where('is_'.$filter, true);
}
}
if ($this->tableSort === 'name_asc') {
$query->orderBy('key');
} elseif ($this->tableSort === 'name_desc') {
$query->orderByDesc('key');
} elseif ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order')->orderBy('id');
@@ -553,6 +659,12 @@ class All extends Component
return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose';
}
private function includesHardcodedVariables(): bool
{
return ! in_array('user', $this->variableFilters, true)
&& collect($this->variableFilters)->intersect(['buildtime', 'runtime', 'multiline', 'literal'])->isEmpty();
}
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
@@ -600,6 +712,12 @@ class All extends Component
});
}
if ($this->serviceFilters !== []) {
$hardcodedVars = $hardcodedVars->filter(
fn ($var) => in_array($var['service_name'] ?? '', $this->serviceFilters, true)
);
}
// Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
@@ -19,6 +19,8 @@ use Livewire\Component;
class Show extends Component
{
public bool $showEnvironmentType = true;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
public $parameters;
@@ -6,6 +6,8 @@ use Livewire\Component;
class ShowHardcoded extends Component
{
public bool $showEnvironmentType = true;
public array $env;
public string $key;
+269 -7
View File
@@ -2,22 +2,284 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ScheduledVolumeBackup;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class All extends Component
{
use AuthorizesRequests;
public $resource;
protected $listeners = ['refreshStorages' => '$refresh'];
/**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool}>
*/
public array $forms = [];
public function getFirstStorageIdProperty()
/**
* Precomputed per-volume backup badge/link data.
*
* @var array<int, array{enabled: bool, url: ?string}>
*/
public array $volumeBackupMeta = [];
public bool $supportsPreviewSuffix = false;
public bool $showActionsColumn = false;
public bool $isComposeOrService = false;
public bool $canUpdate = false;
/** Storage id for the single shared backup modal (null = closed / unmounted). */
public ?int $backupModalStorageId = null;
protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
public function mount(): void
{
if ($this->resource->persistentStorages->isEmpty()) {
return null;
$this->canUpdate = (bool) auth()->user()?->can('update', $this->resource);
$this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based();
$this->showActionsColumn = $this->resource instanceof Application;
$this->isComposeOrService = $this->resource->type() === 'service'
|| data_get($this->resource, 'build_pack') === 'dockercompose';
$this->refreshList();
}
public function refreshList(): void
{
$this->resource->refresh();
$this->resource->unsetRelation('persistentStorages');
$this->resource->load(['persistentStorages' => fn ($query) => $query->orderBy('id')]);
foreach ($this->resource->persistentStorages as $storage) {
$storage->setRelation('resource', $this->resource);
}
// Use the storage with the smallest ID as the "first" one
// This ensures stability even when storages are deleted
return $this->resource->persistentStorages->sortBy('id')->first()->id;
if ($this->resource instanceof Application) {
$this->resource->loadMissing('environment.project');
}
$this->rebuildForms();
$this->rebuildVolumeBackupMeta();
}
public function submit(int $storageId): void
{
$this->authorize('update', $this->resource);
$this->validateStorage($storageId);
$storage = $this->findStorageOrFail($storageId);
if ($storage->shouldBeReadOnlyInUI()) {
$this->dispatch('error', 'This volume is read-only.');
return;
}
$form = $this->forms[$storageId];
$storage->name = $form['name'];
$storage->mount_path = $form['mountPath'];
$storage->host_path = $form['hostPath'] ?: null;
$storage->is_preview_suffix_enabled = (bool) $form['isPreviewSuffixEnabled'];
$storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
public function instantSave(int $storageId): void
{
$this->submit($storageId);
}
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
public function updatedForms($value, string $key): void
{
if (! str_ends_with($key, '.isPreviewSuffixEnabled')) {
return;
}
$storageId = (int) explode('.', $key)[0];
if ($storageId > 0 && isset($this->forms[$storageId]) && ! $this->forms[$storageId]['isReadOnly']) {
$this->instantSave($storageId);
}
}
public function delete(int $storageId, $password = '', $selectedActions = [])
{
$this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$storage = $this->findStorageOrFail($storageId);
if ($storage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
return false;
}
$storage->delete();
$this->backupModalStorageId = null;
$this->refreshList();
$this->dispatch('refreshStorages');
$this->dispatch('configurationChanged');
return true;
}
public function openBackupModal(int $storageId): void
{
$this->authorize('update', $this->resource);
$this->backupModalStorageId = $storageId;
}
public function closeBackupModal(): void
{
$this->backupModalStorageId = null;
}
public function render()
{
return view('livewire.project.shared.storages.all');
}
/**
* @return array<int, LocalPersistentVolume>
*/
public function getStoragesProperty(): array
{
return $this->resource->persistentStorages
->sortBy('id')
->values()
->all();
}
private function rebuildForms(): void
{
$forms = [];
foreach ($this->resource->persistentStorages->sortBy('id') as $storage) {
$forms[$storage->id] = [
'name' => $storage->name,
'mountPath' => $storage->mount_path,
'hostPath' => $storage->host_path,
'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true),
'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate,
];
}
$this->forms = $forms;
}
private function rebuildVolumeBackupMeta(): void
{
$this->volumeBackupMeta = [];
if (! $this->resource instanceof Application) {
return;
}
$storages = $this->resource->persistentStorages;
if ($storages->isEmpty()) {
return;
}
$volumeMorph = (new LocalPersistentVolume)->getMorphClass();
$directoryMorph = (new LocalFileVolume)->getMorphClass();
$volumeIds = $storages->pluck('id');
$volumeBackups = ScheduledVolumeBackup::query()
->where('backupable_type', $volumeMorph)
->whereIn('backupable_id', $volumeIds)
->get()
->keyBy('backupable_id');
$directoryIds = LocalFileVolume::query()
->where('resource_id', $this->resource->id)
->where('resource_type', $this->resource->getMorphClass())
->where('is_directory', true)
->where('is_host_file', false)
->pluck('id');
$totalApplicationBackups = ScheduledVolumeBackup::query()
->where(function ($query) use ($volumeMorph, $volumeIds, $directoryMorph, $directoryIds): void {
$query->where(function ($query) use ($volumeMorph, $volumeIds): void {
$query->where('backupable_type', $volumeMorph)
->whereIn('backupable_id', $volumeIds);
})->orWhere(function ($query) use ($directoryMorph, $directoryIds): void {
$query->where('backupable_type', $directoryMorph)
->whereIn('backupable_id', $directoryIds);
});
})
->count();
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
foreach ($storages as $storage) {
$backup = $volumeBackups->get($storage->id);
$enabled = (bool) ($backup?->enabled);
$url = null;
if ($enabled && $backup) {
$url = $totalApplicationBackups > 1
? route('project.application.backup.index', [...$parameters, 'search' => $storage->name])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
$this->volumeBackupMeta[(int) $storage->id] = [
'enabled' => $enabled,
'url' => $url,
];
}
}
private function validateStorage(int $storageId): void
{
$this->validate([
"forms.{$storageId}.name" => ValidationPatterns::volumeNameRules(),
"forms.{$storageId}.mountPath" => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.hostPath" => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.isPreviewSuffixEnabled" => 'required|boolean',
], array_merge(
ValidationPatterns::volumeNameMessages(),
[
"forms.{$storageId}.mountPath.regex" => 'Mount path must start with / and only contain safe path characters.',
"forms.{$storageId}.hostPath.regex" => 'Host path must start with / and only contain safe path characters.',
]
), [
"forms.{$storageId}.name" => 'name',
"forms.{$storageId}.mountPath" => 'mount',
"forms.{$storageId}.hostPath" => 'host',
]);
}
private function findStorageOrFail(int $storageId): LocalPersistentVolume
{
$storage = $this->resource->persistentStorages->firstWhere('id', $storageId);
if (! $storage) {
$storage = LocalPersistentVolume::query()
->whereKey($storageId)
->where('resource_id', $this->resource->id)
->where('resource_type', $this->resource->getMorphClass())
->firstOrFail();
$storage->setRelation('resource', $this->resource);
}
return $storage;
}
}
+35 -1
View File
@@ -26,6 +26,8 @@ class Show extends Component
public ?string $startedAt = null;
public bool $supportsPreviewSuffix = false;
// Explicit properties
public string $name;
@@ -39,6 +41,14 @@ class Show extends Component
public ?string $backupUrl = null;
/**
* When true, parent already batched badge/url data — skip per-row queries on mount.
*/
public bool $backupMetaHydrated = false;
/** When true, the Backup Configure Livewire modal is mounted (lazy). */
public bool $showBackupModal = false;
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
@@ -92,7 +102,14 @@ class Show extends Component
{
$this->syncData(false);
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
$this->refreshBackupStatus();
// PR deployment volume suffixes only apply to git-based applications.
$this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based()
&& ! $this->isService;
// Parent All batches badge/url; isolated embeds still hydrate themselves.
if (! $this->backupMetaHydrated) {
$this->refreshBackupStatus();
}
}
#[On('refreshVolumeBackups')]
@@ -107,6 +124,8 @@ class Show extends Component
return;
}
$this->resource->loadMissing('environment.project');
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
@@ -122,6 +141,21 @@ class Show extends Component
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function openBackupModal(): void
{
$this->authorize('update', $this->resource);
$this->showBackupModal = true;
}
#[On('modalClosed')]
public function onModalClosed(): void
{
// Drop the nested Create component from the DOM after close to free snapshot weight.
if ($this->showBackupModal) {
$this->showBackupModal = false;
}
}
public function instantSave(): void
{
$this->authorize('update', $this->resource);