feat: add shared status indicators

Related to #677
This commit is contained in:
rcourtman 2025-11-14 12:42:08 +00:00
parent 349f5627e5
commit 65fbd72b15
9 changed files with 660 additions and 343 deletions

View file

@ -5,7 +5,8 @@ import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric'; import { IOMetric } from './IOMetric';
import { TagBadges } from './TagBadges'; import { TagBadges } from './TagBadges';
import { DiskList } from './DiskList'; import { DiskList } from './DiskList';
import { isGuestRunning } from '@/utils/status'; import { StatusDot } from '@/components/shared/StatusDot';
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
import { GuestMetadataAPI } from '@/api/guestMetadata'; import { GuestMetadataAPI } from '@/api/guestMetadata';
import { showSuccess, showError } from '@/utils/toast'; import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@ -334,6 +335,7 @@ export function GuestRow(props: GuestRowProps) {
const parentOnline = createMemo(() => props.parentNodeOnline !== false); const parentOnline = createMemo(() => props.parentNodeOnline !== false);
const isRunning = createMemo(() => isGuestRunning(props.guest, parentOnline())); const isRunning = createMemo(() => isGuestRunning(props.guest, parentOnline()));
const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline()));
const lockLabel = createMemo(() => (props.guest.lock || '').trim()); const lockLabel = createMemo(() => (props.guest.lock || '').trim());
// Get helpful tooltip for disk status // Get helpful tooltip for disk status
@ -425,96 +427,106 @@ export function GuestRow(props: GuestRowProps) {
{/* Name - Sticky column */} {/* Name - Sticky column */}
<td class={firstCellClass()}> <td class={firstCellClass()}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{/* Name - show input when editing, otherwise show name with optional link */} <div class="flex items-center gap-1.5 flex-1 min-w-0">
<Show <StatusDot
when={isEditingUrl()} variant={guestStatus().variant}
fallback={ title={guestStatus().label}
<div class="flex items-center gap-1.5 flex-1 min-w-0"> ariaLabel={guestStatus().label}
<span size="xs"
class="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis" />
style="cursor: text;" <div class="flex-1 min-w-0">
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`} {/* Name - show input when editing, otherwise show name with optional link */}
onClick={startEditingUrl} <Show
data-guest-name-editable when={isEditingUrl()}
> fallback={
{props.guest.name} <div class="flex items-center gap-1.5 min-w-0">
</span> <span
<Show when={customUrl()}> class="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis"
<a style="cursor: text;"
href={customUrl()} title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
target="_blank" onClick={startEditingUrl}
rel="noopener noreferrer" data-guest-name-editable
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path {props.guest.name}
stroke-linecap="round" </span>
stroke-linejoin="round" <Show when={customUrl()}>
stroke-width="2" <a
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" href={customUrl()}
/> target="_blank"
</svg> rel="noopener noreferrer"
</a> class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
</Show> title="Open in new tab"
</div> onClick={(event) => event.stopPropagation()}
} >
> <svg
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor> class="w-3.5 h-3.5"
<input fill="none"
ref={urlInputRef} stroke="currentColor"
type="text" viewBox="0 0 24 24"
value={editingUrlValue()} >
data-guest-id={guestId()} <path
onInput={(e) => { stroke-linecap="round"
editingValues.set(guestId(), e.currentTarget.value); stroke-linejoin="round"
setEditingValuesVersion(v => v + 1); stroke-width="2"
}} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
onKeyDown={(e) => { />
if (e.key === 'Enter') { </svg>
e.preventDefault(); </a>
saveUrl(); </Show>
} else if (e.key === 'Escape') { </div>
e.preventDefault(); }
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8006"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
> >
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
</button> <input
<button ref={urlInputRef}
type="button" type="text"
data-url-editor-button value={editingUrlValue()}
onClick={(e) => { data-guest-id={guestId()}
e.stopPropagation(); onInput={(e) => {
deleteUrl(); editingValues.set(guestId(), e.currentTarget.value);
}} setEditingValuesVersion(v => v + 1);
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors" }}
title="Delete URL" onKeyDown={(e) => {
> if (e.key === 'Enter') {
e.preventDefault();
</button> saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8006"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div> </div>
</Show> </div>
{/* Tag badges - hide when editing URL to save space */} {/* Tag badges - hide when editing URL to save space */}
<Show when={!isEditingUrl()}> <Show when={!isEditingUrl()}>

View file

@ -7,6 +7,8 @@ import { resolveHostRuntime } from './runtimeDisplay';
import { formatPercent, formatUptime } from '@/utils/format'; import { formatPercent, formatUptime } from '@/utils/format';
import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { buildMetricKey } from '@/utils/metricsKeys'; import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
import { getDockerHostStatusIndicator } from '@/utils/status';
export interface DockerHostSummary { export interface DockerHostSummary {
host: DockerHost; host: DockerHost;
@ -243,6 +245,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
const runtimeInfo = resolveHostRuntime(summary.host); const runtimeInfo = resolveHostRuntime(summary.host);
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
const metricsKey = buildMetricKey('dockerHost', summary.host.id); const metricsKey = buildMetricKey('dockerHost', summary.host.id);
const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host));
return ( return (
<tr <tr
@ -251,7 +254,13 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
onClick={() => props.onSelect(summary.host.id)} onClick={() => props.onSelect(summary.host.id)}
> >
<td class="pr-2 py-1 pl-3 align-middle"> <td class="pr-2 py-1 pl-3 align-middle">
<div class="flex flex-wrap items-center gap-1 sm:flex-nowrap sm:whitespace-nowrap sm:min-w-0"> <div class="flex flex-wrap items-center gap-1.5 sm:flex-nowrap sm:whitespace-nowrap sm:min-w-0">
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 sm:truncate sm:max-w-[200px]"> <span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 sm:truncate sm:max-w-[200px]">
{getDisplayName(summary.host)} {getDisplayName(summary.host)}
</span> </span>

View file

@ -13,16 +13,7 @@ import { formatBytes, formatRelativeTime } from '@/utils/format';
import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata'; import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage'; import { STORAGE_KEYS } from '@/utils/localStorage';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']);
const DEGRADED_HOST_STATUSES = new Set([
'degraded',
'warning',
'maintenance',
'partial',
'initializing',
'unknown',
]);
type DockerMetadataRecord = Record<string, DockerMetadata>; type DockerMetadataRecord = Record<string, DockerMetadata>;
@ -224,10 +215,8 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
if (status === 'all') return true; if (status === 'all') return true;
const normalized = host.status?.toLowerCase() ?? ''; const normalized = host.status?.toLowerCase() ?? '';
if (status === 'online') return normalized === 'online'; if (status === 'online') return normalized === 'online';
if (status === 'offline') return OFFLINE_HOST_STATUSES.has(normalized); if (status === 'offline') return OFFLINE_HEALTH_STATUSES.has(normalized);
if (status === 'degraded') { if (status === 'degraded') return DEGRADED_HEALTH_STATUSES.has(normalized);
return DEGRADED_HOST_STATUSES.has(normalized) || normalized === 'degraded';
}
return true; return true;
}; };

View file

@ -11,27 +11,16 @@ import { resolveHostRuntime } from './runtimeDisplay';
import { showSuccess, showError } from '@/utils/toast'; import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { buildMetricKey } from '@/utils/metricsKeys'; import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); import {
const DEGRADED_HOST_STATUSES = new Set([ DEGRADED_HEALTH_STATUSES,
'degraded', ERROR_CONTAINER_STATES,
'warning', OFFLINE_HEALTH_STATUSES,
'maintenance', STOPPED_CONTAINER_STATES,
'partial', getDockerContainerStatusIndicator,
'initializing', getDockerHostStatusIndicator,
'unknown', getDockerServiceStatusIndicator,
]); } from '@/utils/status';
const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']);
const ERROR_CONTAINER_STATES = new Set([
'restarting',
'dead',
'removing',
'failed',
'error',
'oomkilled',
'unhealthy',
]);
const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
switch (type) { switch (type) {
@ -347,10 +336,10 @@ const hostMatchesFilter = (filter: StatsFilter, host: DockerHost) => {
if (!filter || filter.type !== 'host-status') return true; if (!filter || filter.type !== 'host-status') return true;
const status = toLower(host.status); const status = toLower(host.status);
if (filter.value === 'offline') { if (filter.value === 'offline') {
return OFFLINE_HOST_STATUSES.has(status); return OFFLINE_HEALTH_STATUSES.has(status);
} }
if (filter.value === 'degraded') { if (filter.value === 'degraded') {
return DEGRADED_HOST_STATUSES.has(status) || status === 'degraded'; return DEGRADED_HEALTH_STATUSES.has(status);
} }
if (filter.value === 'online') { if (filter.value === 'online') {
return status === 'online'; return status === 'online';
@ -548,10 +537,21 @@ const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8';
const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => { const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => {
const displayName = props.host.customDisplayName || props.host.displayName || props.host.hostname || props.host.id; const displayName = props.host.customDisplayName || props.host.displayName || props.host.hostname || props.host.id;
const hostStatus = () => getDockerHostStatusIndicator(props.host);
const isOnline = () => hostStatus().variant === 'success';
return ( return (
<tr class="bg-gray-50 dark:bg-gray-900/40"> <tr class="bg-gray-50 dark:bg-gray-900/40">
<td colSpan={props.colspan} class="py-0.5 pr-2 pl-4"> <td colSpan={props.colspan} class="py-0.5 pr-2 pl-4">
<div class="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100"> <div
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
title={hostStatus().label}
>
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<span>{displayName}</span> <span>{displayName}</span>
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}> <Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400"> <span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
@ -858,6 +858,7 @@ const DockerContainerRow: Component<{
} }
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'; return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
}; };
const containerStatusIndicator = createMemo(() => getDockerContainerStatusIndicator(container));
const statusLabel = () => { const statusLabel = () => {
if (health()) { if (health()) {
@ -883,108 +884,116 @@ const DockerContainerRow: Component<{
> >
<td class={`${GROUPED_RESOURCE_INDENT} pr-2 py-0.5`}> <td class={`${GROUPED_RESOURCE_INDENT} pr-2 py-0.5`}>
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
{/* Name - show input when editing, otherwise show name with optional link */} <StatusDot
<Show variant={containerStatusIndicator().variant}
when={isEditingUrl()} title={statusLabel()}
fallback={ ariaLabel={containerStatusIndicator().label}
<div class="flex items-center gap-1.5 flex-1 min-w-0"> size="xs"
<span />
class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-text select-none" <div class="flex-1 min-w-0">
style="cursor: text;" {/* Name - show input when editing, otherwise show name with optional link */}
title={`${containerTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`} <Show
onClick={startEditingUrl} when={isEditingUrl()}
data-resource-name-editable fallback={
> <div class="flex items-center gap-1.5 flex-1 min-w-0">
{container.name || container.id} <span
</span> class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-text select-none"
<Show when={podName()}> style="cursor: text;"
{(name) => ( title={`${containerTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
<span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200"> onClick={startEditingUrl}
Pod: {name()} data-resource-name-editable
<Show when={isPodInfra()}>
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200">
infra
</span>
</Show>
</span>
)}
</Show>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
> >
<svg {container.name || container.id}
class="w-3.5 h-3.5" </span>
fill="none" <Show when={podName()}>
stroke="currentColor" {(name) => (
viewBox="0 0 24 24" <span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
Pod: {name()}
<Show when={isPodInfra()}>
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200">
infra
</span>
</Show>
</span>
)}
</Show>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
> >
<path <svg
stroke-linecap="round" class="w-3.5 h-3.5"
stroke-linejoin="round" fill="none"
stroke-width="2" stroke="currentColor"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" viewBox="0 0 24 24"
/> >
</svg> <path
</a> stroke-linecap="round"
</Show> stroke-linejoin="round"
</div> stroke-width="2"
} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
> />
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor> </svg>
<input </a>
ref={urlInputRef} </Show>
type="text" </div>
value={editingUrlValue()} }
data-resource-id={resourceId()} >
onInput={(e) => { <div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
dockerEditingValues.set(resourceId(), e.currentTarget.value); <input
setDockerEditingValuesVersion(v => v + 1); ref={urlInputRef}
}} type="text"
onKeyDown={(e) => { value={editingUrlValue()}
if (e.key === 'Enter') { data-resource-id={resourceId()}
e.preventDefault(); onInput={(e) => {
dockerEditingValues.set(resourceId(), e.currentTarget.value);
setDockerEditingValuesVersion(v => v + 1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://example.com:8080"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl(); saveUrl();
} else if (e.key === 'Escape') { }}
e.preventDefault(); class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
cancelEditingUrl(); title="Save (or press Enter)"
} >
}}
onClick={(e) => e.stopPropagation()} </button>
placeholder="https://example.com:8080" <button
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" type="button"
/> data-url-editor-button
<button onClick={(e) => {
type="button" e.stopPropagation();
data-url-editor-button deleteUrl();
onClick={(e) => { }}
e.stopPropagation(); class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
saveUrl(); title="Delete URL"
}} >
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)" </button>
> </div>
</Show>
</button> </div>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div> </div>
</td> </td>
<td class="px-2 py-0.5"> <td class="px-2 py-0.5">
@ -1607,6 +1616,7 @@ const DockerServiceRow: Component<{
const running = service.runningTasks ?? 0; const running = service.runningTasks ?? 0;
return desired > 0 && running >= desired; return desired > 0 && running >= desired;
}; };
const serviceStatusIndicator = createMemo(() => getDockerServiceStatusIndicator(service));
const serviceTitle = () => { const serviceTitle = () => {
const primary = service.name || service.id || 'Service'; const primary = service.name || service.id || 'Service';
@ -1629,101 +1639,109 @@ const DockerServiceRow: Component<{
> >
<td class={`${GROUPED_RESOURCE_INDENT} pr-2 py-0.5`}> <td class={`${GROUPED_RESOURCE_INDENT} pr-2 py-0.5`}>
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
{/* Name - show input when editing, otherwise show name with optional link */} <StatusDot
<Show variant={serviceStatusIndicator().variant}
when={isEditingUrl()} title={badge.label}
fallback={ ariaLabel={serviceStatusIndicator().label}
<div class="flex items-center gap-1.5 flex-1 min-w-0"> size="xs"
<span />
class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-text select-none" <div class="flex-1 min-w-0">
style="cursor: text;" {/* Name - show input when editing, otherwise show name with optional link */}
title={`${serviceTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`} <Show
onClick={startEditingUrl} when={isEditingUrl()}
data-resource-name-editable fallback={
> <div class="flex items-center gap-1.5 flex-1 min-w-0">
{service.name || service.id || 'Service'} <span
</span> class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-text select-none"
<Show when={customUrl()}> style="cursor: text;"
<a title={`${serviceTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
href={customUrl()} onClick={startEditingUrl}
target="_blank" data-resource-name-editable
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
> >
<svg {service.name || service.id || 'Service'}
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
<Show when={service.stack && !isEditingUrl()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
Stack: {service.stack}
</span> </span>
</Show> <Show when={customUrl()}>
</div> <a
} href={customUrl()}
> target="_blank"
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor> rel="noopener noreferrer"
<input class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
ref={urlInputRef} title="Open in new tab"
type="text" onClick={(event) => event.stopPropagation()}
value={editingUrlValue()} >
data-resource-id={resourceId()} <svg
onInput={(e) => { class="w-3.5 h-3.5"
dockerEditingValues.set(resourceId(), e.currentTarget.value); fill="none"
setDockerEditingValuesVersion(v => v + 1); stroke="currentColor"
}} viewBox="0 0 24 24"
onKeyDown={(e) => { >
if (e.key === 'Enter') { <path
e.preventDefault(); stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
<Show when={service.stack && !isEditingUrl()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
Stack: {service.stack}
</span>
</Show>
</div>
}
>
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
data-resource-id={resourceId()}
onInput={(e) => {
dockerEditingValues.set(resourceId(), e.currentTarget.value);
setDockerEditingValuesVersion(v => v + 1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://example.com:8080"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl(); saveUrl();
} else if (e.key === 'Escape') { }}
e.preventDefault(); class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
cancelEditingUrl(); title="Save (or press Enter)"
} >
}}
onClick={(e) => e.stopPropagation()} </button>
placeholder="https://example.com:8080" <button
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" type="button"
/> data-url-editor-button
<button onClick={(e) => {
type="button" e.stopPropagation();
data-url-editor-button deleteUrl();
onClick={(e) => { }}
e.stopPropagation(); class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
saveUrl(); title="Delete URL"
}} >
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)" </button>
> </div>
</Show>
</button> </div>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div> </div>
</td> </td>
<td class="px-2 py-0.5"> <td class="px-2 py-0.5">

View file

@ -9,6 +9,8 @@ import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar'; import { MetricBar } from '@/components/Dashboard/MetricBar';
import { HostsFilter } from './HostsFilter'; import { HostsFilter } from './HostsFilter';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import { StatusDot } from '@/components/shared/StatusDot';
import { getHostStatusIndicator } from '@/utils/status';
// Global drawer state to persist across re-renders // Global drawer state to persist across re-renders
const drawerState = new Map<string, boolean>(); const drawerState = new Map<string, boolean>();
@ -18,22 +20,6 @@ interface HostsOverviewProps {
connectionHealth: Record<string, boolean>; connectionHealth: Record<string, boolean>;
} }
const renderStatusIndicator = (status: string | undefined) => {
const normalized = (status || 'offline').toLowerCase();
const indicatorStyles: Record<string, string> = {
online: 'bg-green-500',
degraded: 'bg-amber-500',
offline: 'bg-red-500',
};
const style = indicatorStyles[normalized] || indicatorStyles.offline;
return (
<div class={`h-2 w-2 rounded-full ${style}`} title={normalized.charAt(0).toUpperCase() + normalized.slice(1)} />
);
};
export const HostsOverview: Component<HostsOverviewProps> = (props) => { export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const wsContext = useWebSocket(); const wsContext = useWebSocket();
@ -213,6 +199,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const memPercent = () => host.memory?.usage ?? 0; const memPercent = () => host.memory?.usage ?? 0;
const memUsed = () => formatBytes(host.memory?.used ?? 0, 0); const memUsed = () => formatBytes(host.memory?.used ?? 0, 0);
const memTotal = () => formatBytes(host.memory?.total ?? 0, 0); const memTotal = () => formatBytes(host.memory?.total ?? 0, 0);
const hostStatus = createMemo(() => getHostStatusIndicator(host));
// Drawer state // Drawer state
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
@ -269,7 +256,12 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
<td class="pl-4 pr-2 py-2"> <td class="pl-4 pr-2 py-2">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{renderStatusIndicator(host.status)} <StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{host.displayName || host.hostname || host.id} {host.displayName || host.hostname || host.id}
</p> </p>

View file

@ -1,6 +1,8 @@
import { Component, Show } from 'solid-js'; import { Component, Show } from 'solid-js';
import type { Node } from '@/types/api'; import type { Node } from '@/types/api';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes'; import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator } from '@/utils/status';
interface NodeGroupHeaderProps { interface NodeGroupHeaderProps {
node: Node; node: Node;
@ -8,7 +10,8 @@ interface NodeGroupHeaderProps {
} }
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => { export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
const isOnline = () => props.node.status === 'online' && (props.node.uptime || 0) > 0; const nodeStatus = () => getNodeStatusIndicator(props.node);
const isOnline = () => nodeStatus().variant === 'success';
const nodeUrl = () => props.node.guestURL || props.node.host || `https://${props.node.name}:8006`; const nodeUrl = () => props.node.guestURL || props.node.host || `https://${props.node.name}:8006`;
const displayName = () => getNodeDisplayName(props.node); const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node); const showActualName = () => hasAlternateDisplayName(props.node);
@ -20,11 +23,15 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100" class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
> >
<div <div
class={`flex flex-wrap items-center gap-3 ${ class={`flex flex-wrap items-center gap-3 ${isOnline() ? '' : 'opacity-60'}`}
isOnline() ? '' : 'opacity-60' title={nodeStatus().label}
}`}
title={isOnline() ? 'Online' : 'Offline'}
> >
<StatusDot
variant={nodeStatus().variant}
title={nodeStatus().label}
ariaLabel={nodeStatus().label}
size="xs"
/>
<a <a
href={nodeUrl()} href={nodeUrl()}
target="_blank" target="_blank"

View file

@ -9,6 +9,8 @@ import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getCpuTemperature } from '@/utils/temperature'; import { getCpuTemperature } from '@/utils/temperature';
import { useAlertsActivation } from '@/stores/alertsActivation'; import { useAlertsActivation } from '@/stores/alertsActivation';
import { buildMetricKey } from '@/utils/metricsKeys'; import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
interface NodeSummaryTableProps { interface NodeSummaryTableProps {
nodes: Node[]; nodes: Node[];
@ -426,6 +428,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const pbs = isPBS ? (item.data as PBSInstance) : null; const pbs = isPBS ? (item.data as PBSInstance) : null;
const online = isItemOnline(item); const online = isItemOnline(item);
const statusIndicator = createMemo(() =>
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
);
const cpuPercentValue = getCpuPercent(item); const cpuPercentValue = getCpuPercent(item);
const memoryPercentValue = getMemoryPercent(item); const memoryPercentValue = getMemoryPercent(item);
const diskPercentValue = getDiskPercent(item); const diskPercentValue = getDiskPercent(item);
@ -508,7 +513,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<td <td
class={`pr-2 py-0.5 whitespace-nowrap ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`} class={`pr-2 py-0.5 whitespace-nowrap ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1.5">
<StatusDot
variant={statusIndicator().variant}
title={statusIndicator().label}
ariaLabel={statusIndicator().label}
size="xs"
/>
<a <a
href={ href={
isPVE isPVE

View file

@ -0,0 +1,55 @@
import type { JSX } from 'solid-js';
import type { StatusIndicatorVariant } from '@/utils/status';
type StatusDotSize = 'xs' | 'sm' | 'md';
interface StatusDotProps {
variant?: StatusIndicatorVariant;
size?: StatusDotSize;
pulse?: boolean;
title?: string;
ariaLabel?: string;
ariaHidden?: boolean;
class?: string;
}
const VARIANT_CLASSES: Record<StatusIndicatorVariant, string> = {
success: 'bg-emerald-500 dark:bg-emerald-400',
warning: 'bg-amber-500 dark:bg-amber-400',
danger: 'bg-red-500 dark:bg-red-400',
muted: 'bg-gray-400 dark:bg-gray-500',
};
const SIZE_CLASSES: Record<StatusDotSize, string> = {
xs: 'h-1.5 w-1.5',
sm: 'h-2 w-2',
md: 'h-2.5 w-2.5',
};
export function StatusDot(props: StatusDotProps): JSX.Element {
const variant = props.variant ?? 'muted';
const size = props.size ?? 'sm';
const ariaHidden = props.ariaHidden ?? !props.ariaLabel;
const className = [
'inline-block rounded-full flex-shrink-0',
SIZE_CLASSES[size],
VARIANT_CLASSES[variant],
props.pulse ? 'animate-pulse' : '',
props.class ?? '',
]
.filter(Boolean)
.join(' ');
return (
<span
class={className}
title={props.title}
aria-label={props.ariaLabel}
aria-hidden={ariaHidden}
role={props.ariaLabel ? 'img' : undefined}
/>
);
}
export default StatusDot;

View file

@ -1,14 +1,80 @@
import type { Node, VM, Container } from '@/types/api'; import type {
Node,
VM,
Container,
PBSInstance,
Host,
DockerHost,
DockerContainer,
DockerService,
} from '@/types/api';
const ONLINE_STATUS = 'online'; const ONLINE_STATUS = 'online';
const RUNNING_STATUS = 'running'; const RUNNING_STATUS = 'running';
const normalize = (value?: string | null): string => (value || '').trim().toLowerCase();
const formatStatusLabel = (value?: string | null, fallback = 'Unknown'): string => {
if (!value) return fallback;
const normalized = value.trim();
if (!normalized) return fallback;
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
};
export type StatusIndicatorVariant = 'success' | 'warning' | 'danger' | 'muted';
export interface StatusIndicator {
variant: StatusIndicatorVariant;
label: string;
}
const defaultIndicator: StatusIndicator = { variant: 'muted', label: 'Unknown' };
export const OFFLINE_HEALTH_STATUSES = new Set([
'offline',
'error',
'failed',
'down',
'unreachable',
'disconnected',
'timeout',
'stopped',
'inactive',
]);
export const DEGRADED_HEALTH_STATUSES = new Set([
'degraded',
'warning',
'maintenance',
'syncing',
'initializing',
'starting',
'pending',
'partial',
'unknown',
'recovering',
'pausing',
'restarting',
]);
export const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']);
export const ERROR_CONTAINER_STATES = new Set([
'restarting',
'dead',
'removing',
'failed',
'error',
'oomkilled',
'unhealthy',
]);
export function isNodeOnline(node: Partial<Node> | undefined | null): boolean { export function isNodeOnline(node: Partial<Node> | undefined | null): boolean {
if (!node) return false; if (!node) return false;
if (node.status !== ONLINE_STATUS) return false; if (node.status !== ONLINE_STATUS) return false;
if ((node.uptime ?? 0) <= 0) return false; if ((node.uptime ?? 0) <= 0) return false;
const connection = (node as Node).connectionHealth; const connection = (node as Node).connectionHealth;
if (connection === 'offline' || connection === 'error') return false; const normalizedConnection = normalize(connection);
if (normalizedConnection === 'offline' || normalizedConnection === 'error') return false;
return true; return true;
} }
@ -20,3 +86,161 @@ export function isGuestRunning(
if (!parentNodeOnline) return false; if (!parentNodeOnline) return false;
return guest.status === RUNNING_STATUS; return guest.status === RUNNING_STATUS;
} }
export function getNodeStatusIndicator(node: Partial<Node> | undefined | null): StatusIndicator {
if (!node) return defaultIndicator;
const connection = normalize((node as Node).connectionHealth);
const status = normalize(node.status);
const uptime = node.uptime ?? 0;
if (OFFLINE_HEALTH_STATUSES.has(connection) || OFFLINE_HEALTH_STATUSES.has(status) || uptime <= 0) {
return { variant: 'danger', label: formatStatusLabel(connection || status, 'Offline') };
}
if (DEGRADED_HEALTH_STATUSES.has(connection) || DEGRADED_HEALTH_STATUSES.has(status)) {
return { variant: 'warning', label: formatStatusLabel(connection || status, 'Degraded') };
}
if (isNodeOnline(node)) {
return { variant: 'success', label: 'Online' };
}
return defaultIndicator;
}
export function getPBSStatusIndicator(
instance: Partial<PBSInstance> | undefined | null,
): StatusIndicator {
if (!instance) return defaultIndicator;
const connection = normalize(instance.connectionHealth);
const status = normalize(instance.status);
if (OFFLINE_HEALTH_STATUSES.has(connection) || OFFLINE_HEALTH_STATUSES.has(status)) {
return { variant: 'danger', label: formatStatusLabel(connection || status, 'Offline') };
}
if (status === 'healthy' || status === ONLINE_STATUS) {
return { variant: 'success', label: formatStatusLabel(status, 'Online') };
}
if (DEGRADED_HEALTH_STATUSES.has(connection) || DEGRADED_HEALTH_STATUSES.has(status)) {
return { variant: 'warning', label: formatStatusLabel(connection || status, 'Degraded') };
}
return defaultIndicator;
}
export function getGuestPowerIndicator(
guest: Partial<VM | Container> | undefined | null,
parentNodeOnline = true,
): StatusIndicator {
if (!guest) return defaultIndicator;
if (!parentNodeOnline) {
return { variant: 'danger', label: 'Node offline' };
}
return isGuestRunning(guest, parentNodeOnline)
? { variant: 'success', label: 'Running' }
: { variant: 'danger', label: 'Stopped' };
}
export function getHostStatusIndicator(host: Partial<Host> | undefined | null): StatusIndicator {
if (!host) return defaultIndicator;
const status = normalize(host.status);
if (OFFLINE_HEALTH_STATUSES.has(status)) {
return { variant: 'danger', label: formatStatusLabel(status, 'Offline') };
}
if (DEGRADED_HEALTH_STATUSES.has(status)) {
return { variant: 'warning', label: formatStatusLabel(status, 'Degraded') };
}
if (status === ONLINE_STATUS || status === RUNNING_STATUS) {
return { variant: 'success', label: 'Online' };
}
return status
? { variant: 'muted', label: formatStatusLabel(status, 'Unknown') }
: defaultIndicator;
}
export function getDockerHostStatusIndicator(
host: Partial<DockerHost> | string | undefined | null,
): StatusIndicator {
const rawStatus = typeof host === 'string' ? host : host?.status;
const status = normalize(rawStatus);
if (OFFLINE_HEALTH_STATUSES.has(status)) {
return { variant: 'danger', label: formatStatusLabel(status, 'Offline') };
}
if (DEGRADED_HEALTH_STATUSES.has(status)) {
return { variant: 'warning', label: formatStatusLabel(status, 'Degraded') };
}
if (status === ONLINE_STATUS || status === RUNNING_STATUS || status === 'healthy') {
return { variant: 'success', label: formatStatusLabel(status, 'Online') };
}
return status
? { variant: 'muted', label: formatStatusLabel(status, 'Unknown') }
: defaultIndicator;
}
export function getDockerContainerStatusIndicator(
container: Partial<DockerContainer> | undefined | null,
): StatusIndicator {
if (!container) return defaultIndicator;
const state = normalize(container.state);
const health = normalize(container.health);
if (state === RUNNING_STATUS && (!health || health === 'healthy')) {
return { variant: 'success', label: 'Running' };
}
if (ERROR_CONTAINER_STATES.has(state) || health === 'unhealthy') {
const label = health === 'unhealthy' ? 'Unhealthy' : formatStatusLabel(state, 'Error');
return { variant: 'danger', label };
}
if (STOPPED_CONTAINER_STATES.has(state)) {
return { variant: 'danger', label: formatStatusLabel(state, 'Stopped') };
}
if (!state && health) {
return { variant: 'warning', label: formatStatusLabel(health, 'Unknown') };
}
if (state) {
return { variant: 'warning', label: formatStatusLabel(state, 'Unknown') };
}
return defaultIndicator;
}
export function getDockerServiceStatusIndicator(
service: Partial<DockerService> | undefined | null,
): StatusIndicator {
if (!service) return defaultIndicator;
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
if (desired <= 0) {
if (running > 0) {
return { variant: 'warning', label: `Running ${running} task${running === 1 ? '' : 's'}` };
}
return { variant: 'muted', label: 'No tasks' };
}
if (running >= desired) {
return { variant: 'success', label: 'Healthy' };
}
if (running === 0) {
return { variant: 'danger', label: `Stopped (${running}/${desired})` };
}
return { variant: 'warning', label: `Degraded (${running}/${desired})` };
}