parent
349f5627e5
commit
65fbd72b15
9 changed files with 660 additions and 343 deletions
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
55
frontend-modern/src/components/shared/StatusDot.tsx
Normal file
55
frontend-modern/src/components/shared/StatusDot.tsx
Normal 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;
|
||||||
|
|
@ -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})` };
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue