ui: align docker filters with proxmox

This commit is contained in:
rcourtman 2025-10-28 19:06:01 +00:00
parent 52b0c65977
commit 995c04652c
3 changed files with 179 additions and 56 deletions

View file

@ -9,6 +9,8 @@ interface DockerFilterProps {
setSearch: (value: string) => void;
groupingMode?: () => 'grouped' | 'flat';
setGroupingMode?: (mode: 'grouped' | 'flat') => void;
statusFilter?: () => 'all' | 'online' | 'degraded' | 'offline';
setStatusFilter?: (value: 'all' | 'online' | 'degraded' | 'offline') => void;
searchInputRef?: (el: HTMLInputElement) => void;
onReset?: () => void;
activeHostName?: string;
@ -88,12 +90,14 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
() =>
props.search().trim() !== '' ||
(!!props.groupingMode && props.groupingMode() === 'flat') ||
(!!props.statusFilter && props.statusFilter() !== 'all') ||
Boolean(props.activeHostName),
);
const handleReset = () => {
props.setSearch('');
props.setGroupingMode?.('grouped');
props.setStatusFilter?.('all');
props.onClearHost?.();
props.onReset?.();
closeHistory();
@ -296,6 +300,73 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.statusFilter && props.setStatusFilter}>
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
<button
type="button"
aria-pressed={props.statusFilter?.() === 'all'}
onClick={() => props.setStatusFilter?.('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show all hosts"
>
All
</button>
<button
type="button"
aria-pressed={props.statusFilter?.() === 'online'}
onClick={() =>
props.setStatusFilter?.(props.statusFilter?.() === 'online' ? 'all' : 'online')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show online hosts only"
>
Online
</button>
<button
type="button"
aria-pressed={props.statusFilter?.() === 'degraded'}
onClick={() =>
props.setStatusFilter?.(
props.statusFilter?.() === 'degraded' ? 'all' : 'degraded',
)
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show degraded hosts only"
>
Degraded
</button>
<button
type="button"
aria-pressed={props.statusFilter?.() === 'offline'}
onClick={() =>
props.setStatusFilter?.(
props.statusFilter?.() === 'offline' ? 'all' : 'offline',
)
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show offline hosts only"
>
Offline
</button>
</div>
</Show>
<Show when={props.groupingMode && props.setGroupingMode}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button

View file

@ -11,6 +11,16 @@ import { useWebSocket } from '@/App';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { formatBytes, formatRelativeTime } from '@/utils/format';
const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']);
const DEGRADED_HOST_STATUSES = new Set([
'degraded',
'warning',
'maintenance',
'partial',
'initializing',
'unknown',
]);
interface DockerHostsProps {
hosts: DockerHost[];
activeAlerts?: Record<string, unknown> | any;
@ -40,6 +50,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
const [search, setSearch] = createSignal('');
const debouncedSearch = useDebouncedValue(search, 250);
const [selectedHostId, setSelectedHostId] = createSignal<string | null>(null);
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
const clampPercent = (value: number | undefined | null) => {
if (value === undefined || value === null || Number.isNaN(value)) return 0;
@ -143,10 +154,59 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
}
});
const hostMatchesStatus = (host: DockerHost) => {
const status = statusFilter();
if (status === 'all') return true;
const normalized = host.status?.toLowerCase() ?? '';
if (status === 'online') return normalized === 'online';
if (status === 'offline') return OFFLINE_HOST_STATUSES.has(normalized);
if (status === 'degraded') {
return DEGRADED_HOST_STATUSES.has(normalized) || normalized === 'degraded';
}
return true;
};
const filteredHostSummaries = createMemo(() => {
const summaries = hostSummaries();
if (statusFilter() === 'all') return summaries;
return summaries.filter((summary) => hostMatchesStatus(summary.host));
});
createEffect(() => {
const hostId = selectedHostId();
if (!hostId) return;
if (!filteredHostSummaries().some((summary) => summary.host.id === hostId)) {
setSelectedHostId(null);
}
});
const statsFilter = createMemo(() => {
const status = statusFilter();
if (status === 'all') return null;
return { type: 'host-status', value: status };
});
const handleHostSelect = (hostId: string) => {
setSelectedHostId((current) => (current === hostId ? null : hostId));
};
const renderFilter = () => (
<DockerFilter
search={search}
setSearch={setSearch}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
onReset={() => {
setSearch('');
setSelectedHostId(null);
setStatusFilter('all');
}}
searchInputRef={(el) => {
searchInputRef = el;
}}
/>
);
return (
<div class="space-y-0">
<Show when={isLoading()}>
@ -176,65 +236,57 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
<Show when={!isLoading()}>
<Show
when={sortedHosts().length === 0}
when={sortedHosts().length > 0}
fallback={
<>
<DockerFilter
search={search}
setSearch={setSearch}
onReset={() => {
setSearch('');
setSelectedHostId(null);
}}
searchInputRef={(el) => {
searchInputRef = el;
}}
/>
<Show when={hostSummaries().length > 0}>
<DockerHostSummaryTable
summaries={hostSummaries}
selectedHostId={selectedHostId}
onSelect={handleHostSelect}
{renderFilter()}
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title="No Docker hosts configured"
description="Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically."
actions={
<button
type="button"
onClick={() => navigate('/settings/docker')}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<span>Set up Docker agent</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
}
/>
</Show>
<DockerUnifiedTable
hosts={sortedHosts()}
searchTerm={debouncedSearch()}
selectedHostId={selectedHostId}
/>
</Card>
</>
}
>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title="No Docker hosts configured"
description="Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically."
actions={
<button
type="button"
onClick={() => navigate('/settings/docker')}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<span>Set up Docker agent</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
}
<Show when={hostSummaries().length > 0}>
<DockerHostSummaryTable
summaries={filteredHostSummaries}
selectedHostId={selectedHostId}
onSelect={handleHostSelect}
/>
</Card>
</Show>
{renderFilter()}
<DockerUnifiedTable
hosts={sortedHosts()}
searchTerm={debouncedSearch()}
statsFilter={statsFilter()}
selectedHostId={selectedHostId}
/>
</Show>
</Show>
</div>

View file

@ -648,8 +648,8 @@ const DockerServiceRow: Component<{ row: Extract<DockerRow, { kind: 'service' }>
<th class="py-1 pr-2 text-left font-medium">Task</th>
<th class="py-1 px-2 text-left font-medium">Node</th>
<th class="py-1 px-2 text-left font-medium">State</th>
<th class="py-1 px-2 text-left font-medium">CPU</th>
<th class="py-1 px-2 text-left font-medium">Memory</th>
<th class="py-1 px-2 text-left font-medium w-[120px]">CPU</th>
<th class="py-1 px-2 text-left font-medium w-[140px]">Memory</th>
<th class="py-1 px-2 text-left font-medium">Updated</th>
</tr>
</thead>
@ -710,12 +710,12 @@ const DockerServiceRow: Component<{ row: Extract<DockerRow, { kind: 'service' }>
{task.currentState || task.desiredState || 'Unknown'}
</span>
</td>
<td class="py-1 px-2">
<td class="py-1 px-2 w-[120px]">
<Show when={cpu > 0} fallback={<span class="text-gray-400"></span>}>
<MetricBar value={Math.min(100, cpu)} label={formatPercent(cpu)} type="cpu" />
</Show>
</td>
<td class="py-1 px-2">
<td class="py-1 px-2 w-[140px]">
<Show when={mem > 0} fallback={<span class="text-gray-400"></span>}>
<MetricBar value={Math.min(100, mem)} label={formatPercent(mem)} type="memory" />
</Show>