feat(ui): dashboard refinements + wire:navigate setting fixes

- Sidebar: keep nav icons at the same x-position when collapsed (muscle memory)
- Database URLs: add copy button to internal/public URL fields
- Project cards + list: compact icon stats for env/resource counts, add Created column
- Server card graph: add CPU/Memory color legend to the tooltip
- Server list: add IP address, resource count, and status dot columns
- Respect is_wire_navigate_enabled on scheduled-backups links (use wireNavigate helper)
This commit is contained in:
Aditya Tripathi
2026-09-11 06:33:39 +00:00
parent 9faa4f4b7c
commit 01337c0cd6
12 changed files with 135 additions and 51 deletions
+1
View File
@@ -57,6 +57,7 @@ class Index extends Component
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,
'createdAt' => $project->created_at?->format('M j, Y') ?? '-',
'settingsHref' => auth()->user()->can('update', $project)
? route('project.edit', ['project_uuid' => $project->uuid])
: null,
+8 -1
View File
@@ -25,6 +25,7 @@ class Input extends Component
public bool $readonly = false,
public ?string $helper = null,
public bool $allowToPeak = true,
public bool $copyable = false,
public bool $isMultiline = false,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
@@ -72,9 +73,15 @@ class Input extends Component
}
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
// and settings-workspace CSS otherwise overrides utility padding-right.
if ($this->type === 'password' && $this->allowToPeak) {
$hasPeek = $this->type === 'password' && $this->allowToPeak;
if ($hasPeek) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
if ($this->copyable) {
// Reserve clearance for a single copy button, or for both the peek eye
// and the copy button when the field is a maskable password.
$this->defaultClass = $this->defaultClass.($hasPeek ? ' input-with-copy-and-peek' : ' input-with-copy-button');
}
// $this->label = Str::title($this->label);
return view('components.forms.input');
+16 -6
View File
@@ -522,6 +522,11 @@ tr td:first-child {
padding-right: 2.5rem;
}
/* Room for both the peek eye and the copy button on maskable read-only fields. */
.input.input-with-copy-and-peek {
padding-right: 4.25rem;
}
.lds-heart {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
@@ -1958,6 +1963,11 @@ html[data-theme="custom"] textarea:disabled {
padding-right: 2.5rem;
}
.application-settings-workspace .input.input-with-copy-and-peek,
.application-settings-form .input.input-with-copy-and-peek {
padding-right: 4.25rem;
}
.application-settings-workspace .input:focus-visible,
.application-settings-workspace .select:focus-visible,
.application-settings-form .input:focus-visible,
@@ -4372,11 +4382,11 @@ html[data-theme="custom"] .runtime-log-columns {
.projects-table-grid {
display: grid;
grid-template-columns:
minmax(220px, 1.7fr)
minmax(100px, 0.65fr)
minmax(90px, 0.6fr)
minmax(220px, 1.5fr)
6rem;
minmax(200px, 1.7fr)
8rem
7rem
minmax(200px, 1.6fr)
5rem;
column-gap: 1rem;
}
@@ -4423,7 +4433,7 @@ html[data-theme="custom"] .runtime-log-columns {
@media (max-width: 1050px) {
.projects-table-grid {
grid-template-columns: minmax(220px, 1fr) 7rem 6rem 6rem;
grid-template-columns: minmax(200px, 1fr) 8rem 7rem 5rem;
}
.projects-table-grid .project-description {
+9 -9
View File
@@ -415,15 +415,15 @@
}
@media (min-width: 1024px) {
.sidebar-collapsed .menu-item {
justify-content: center;
width: var(--button-h, 2rem);
height: var(--button-h, 2rem);
min-height: var(--button-h, 2rem);
padding-left: 0;
padding-right: 0;
gap: 0;
margin-inline: auto;
/* Collapsed rail keeps every nav icon at its expanded x-position (left-aligned,
same 10px inset) so muscle memory holds when the sidebar is toggled; only the
label is hidden. Unlayered rule outranks the inline lg:justify-center/lg:px-0
utilities on each row. The footer collapse toggle keeps its own centered
square via the .sidebar-toggle exclusion. */
.sidebar-collapsed .menu-item:not(.sidebar-toggle) {
justify-content: flex-start;
padding-left: 0.625rem;
padding-right: 0.625rem;
}
.sidebar-collapsed .sidebar-collapsed-label {
@@ -23,10 +23,10 @@
<x-forms.input :label="$label . ' URL (internal)'" disabled value="Hidden (only admins can view)" />
<x-forms.input :label="$label . ' URL (public)'" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly copyable
wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" type="password" readonly
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" type="password" readonly copyable
wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@elseif ($showPublicUrlPlaceholder)
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" readonly
@@ -1,3 +1,12 @@
@php
// Copy affordance reads the live Livewire value. The bound property comes
// either from the `id`-derived modelBinding or from a passthrough
// `wire:model` attribute (used by read-only fields like DB URLs).
$copyModel = $copyable
? ($modelBinding !== 'null' ? $modelBinding : $attributes->get('wire:model'))
: null;
@endphp
<div @class([
'flex-1' => $isMultiline,
'w-full' => !$isMultiline,
@@ -48,9 +57,18 @@
<x-reicon name="eye-off2" x-cloak x-show="type === 'text'" class="size-[18px]" />
</button>
@endif
@if ($copyable)
<x-copy-button
@if ($copyModel) resolve="$wire.get('{{ $copyModel }}')" @else :value="$value" @endif
label="Copy to clipboard"
class="absolute top-1/2 z-10 -translate-y-1/2 {{ $allowToPeak ? 'right-8' : 'right-1' }}" />
@endif
</div>
@else
@if ($copyable)
<div class="relative">
@endif
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@@ -61,6 +79,13 @@
@if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" autofocus @endif>
@if ($copyable)
<x-copy-button
@if ($copyModel) resolve="$wire.get('{{ $copyModel }}')" @else :value="$value" @endif
label="Copy to clipboard"
class="absolute top-1/2 right-1 z-10 -translate-y-1/2" />
</div>
@endif
@endif
@if (!$label && $helper)
<x-helper :helper="$helper" />
+3 -4
View File
@@ -52,11 +52,10 @@
}">
{{-- Search is only useful when workspace resources are available --}}
@if (isSubscribed() || ! isCloud())
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0 lg:flex lg:justify-center'">
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0'">
<button @click="$dispatch('open-global-search')" type="button"
:title="'Search (Press / or ' + modKeyLabel + 'K)'"
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint"
:class="collapsed && 'lg:w-8 lg:justify-center lg:px-0'">
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
<span class="flex items-center gap-2.5 min-w-0">
<x-reicon name="search" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Search</span>
@@ -239,7 +238,7 @@
:class="collapsed ? 'flex-col-reverse justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
class="menu-item w-8 shrink-0 justify-center px-0">
class="menu-item sidebar-toggle w-8 shrink-0 justify-center px-0">
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
+13 -7
View File
@@ -76,13 +76,19 @@
</div>
</div>
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $project->environments->count() }}
{{ str('env')->plural($project->environments->count()) }}
<span class="px-1 text-neutral-300 dark:text-white/15">·</span>
{{ $resourceCount }} {{ str('resource')->plural($resourceCount) }}
</p>
<div class="mt-auto flex items-center justify-between gap-3 border-t border-neutral-100 pt-2.5 dark:border-white/[0.06]">
<div class="flex min-w-0 items-center gap-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<span class="inline-flex items-center gap-1"
title="{{ $project->environments->count() }} {{ str('environment')->plural($project->environments->count()) }}">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $project->environments->count() }}
</span>
<span class="inline-flex items-center gap-1"
title="{{ $resourceCount }} {{ str('resource')->plural($resourceCount) }}">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $resourceCount }}
</span>
</div>
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
@if ($firstEnvironment)
@@ -102,9 +102,11 @@
timeZoneName: 'short',
});
const dot = color => `<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;margin-right:6px;flex:none;background:${color}"></span>`;
return `<div class="apexcharts-tooltip-custom">
<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">${formatPercent(cpu)}</span></div>
<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">${formatPercent(memory)}</span></div>
<div class="apexcharts-tooltip-custom-value" style="display:flex;align-items:center">${dot('rgb(30, 144, 255)')}CPU: <span class="apexcharts-tooltip-value-bold" style="margin-left:4px">${formatPercent(cpu)}</span></div>
<div class="apexcharts-tooltip-custom-value" style="display:flex;align-items:center">${dot('rgb(168, 85, 247)')}Memory: <span class="apexcharts-tooltip-value-bold" style="margin-left:4px">${formatPercent(memory)}</span></div>
<div class="apexcharts-tooltip-custom-title">Your time: ${formatLocalTimestamp(timestamp)}</div>
<div class="apexcharts-tooltip-custom-title">UTC: ${formatUtcTimestamp(timestamp)}</div>
</div>`;
@@ -100,13 +100,13 @@
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
<a wire:navigate href="{{ $backupExecutionsRoute }}"
<a {{ wireNavigate() }} href="{{ $backupExecutionsRoute }}"
class="font-medium hover:underline hover:text-black dark:hover:text-fg">
{{ $backup->executions_count ?? $backup->executions()->count() }}
</a>
</div>
<div class="flex justify-end">
<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
</div>
</div>
@endforeach
@@ -125,14 +125,19 @@
</div>
</div>
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim">
<span
x-text="`${project.environmentCount} ${project.environmentCount === 1 ? 'env' : 'envs'}`"></span>
<span class="px-1 text-neutral-300 dark:text-white/15">·</span>
<span
x-text="`${project.resourceCount} ${project.resourceCount === 1 ? 'resource' : 'resources'}`"></span>
</p>
<div class="mt-auto flex items-center justify-between gap-3 border-t border-neutral-100 pt-2.5 dark:border-white/[0.06]">
<div class="flex min-w-0 items-center gap-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<span class="inline-flex items-center gap-1"
:title="`${project.environmentCount} ${project.environmentCount === 1 ? 'environment' : 'environments'}`">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.environmentCount"></span>
</span>
<span class="inline-flex items-center gap-1"
:title="`${project.resourceCount} ${project.resourceCount === 1 ? 'resource' : 'resources'}`">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.resourceCount"></span>
</span>
</div>
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
<a x-show="project.addResourceHref" :href="project.addResourceHref"
@@ -161,8 +166,8 @@
<div
class="projects-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Project</div>
<div>Environments</div>
<div>Resources</div>
<div>Contents</div>
<div>Created</div>
<div class="project-description">Description</div>
<div></div>
</div>
@@ -185,10 +190,20 @@
x-text="project.name"></a>
</div>
<div class="text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="project.environmentCount"></div>
<div class="text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="project.resourceCount"></div>
<div class="flex items-center gap-3 text-[12px] font-medium text-neutral-600 dark:text-fg-dim">
<span class="inline-flex items-center gap-1"
:title="`${project.environmentCount} ${project.environmentCount === 1 ? 'environment' : 'environments'}`">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.environmentCount"></span>
</span>
<span class="inline-flex items-center gap-1"
:title="`${project.resourceCount} ${project.resourceCount === 1 ? 'resource' : 'resources'}`">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.resourceCount"></span>
</span>
</div>
<div class="truncate text-[12px] text-neutral-500 dark:text-fg-dim"
x-text="project.createdAt"></div>
<p class="project-description truncate text-[12px] text-neutral-500 dark:text-fg-dim"
x-text="project.description || '-'"></p>
@@ -59,6 +59,8 @@
'href' => route('server.show', ['server_uuid' => $server->uuid]),
'status' => $status,
'statusType' => $statusType,
'ip' => $server->isLocalhost() ? 'localhost' : ($server->ip ?: '-'),
'resourceCount' => $server->definedResources()->count(),
];
})->values();
@endphp
@@ -172,13 +174,15 @@
<div x-show="viewMode === 'table'"
class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.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">
class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 md:min-w-[640px] md:grid-cols-[minmax(0,1fr)_11rem_6rem_9.5rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Server</div>
<div class="hidden md:block">IP address</div>
<div class="hidden md:block">Resources</div>
<div>Status</div>
</div>
<template x-for="server in filteredServers" :key="server.uuid">
<a :href="server.href" {{ wireNavigate() }}
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline md:min-w-[640px] md:grid-cols-[minmax(0,1fr)_11rem_6rem_9.5rem] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.035] dark:text-fg-dim">
@@ -192,12 +196,27 @@
</div>
<span x-show="server.statusType !== 'success'" :data-tooltip="server.status"
:aria-label="`Server status: ${server.status}`"
class="ml-auto flex size-6 shrink-0 items-center justify-center rounded-md"
class="ml-auto flex size-6 shrink-0 items-center justify-center rounded-md md:hidden"
:class="server.statusType === 'warning' ? 'text-orange-500 dark:text-warning' : 'text-red-500 dark:text-red-400'">
<x-reicon name="alert-triangle" class="size-4" />
</span>
</div>
<div class="text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<div class="hidden min-w-0 items-center gap-1.5 truncate font-mono text-[12px] text-neutral-600 md:flex dark:text-fg-dim">
<x-reicon name="network" class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" />
<span class="truncate" x-text="server.ip"></span>
</div>
<div class="hidden text-[12px] font-medium text-neutral-600 md:block dark:text-fg-dim">
<span class="inline-flex items-center gap-1" :title="`${server.resourceCount} ${server.resourceCount === 1 ? 'resource' : 'resources'}`">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="server.resourceCount"></span>
</span>
</div>
<div class="flex items-center gap-2 text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<span x-show="server.statusType !== 'success'"
class="hidden size-2 shrink-0 rounded-full md:inline-block"
:class="server.statusType === 'warning' ? 'bg-orange-500 dark:bg-warning' : 'bg-red-500 dark:bg-red-400'"></span>
<span x-show="server.statusType === 'success'"
class="hidden size-2 shrink-0 rounded-full bg-green-500 md:inline-block dark:bg-green-400"></span>
<span x-text="server.status"></span>
</div>
</a>