chore: Phase 7 legacy cleanup - remove ~8,500 lines

## Removed Legacy Components
- Delete Docker page (DockerHosts, DockerFilter, DockerSummaryStats, etc.)
- Delete Hosts page (HostsOverview, HostsFilter, HostDrawer)
- Delete ProxmoxSectionNav component and Proxmox folder

## Cleaned Up Navigation
- Remove legacy nav items (Proxmox Overview, Docker, Hosts) from sidebar
- Remove legacy redirect routes from App.tsx
- Update remaining route redirects to use Navigate

## UI Fixes
- Hide NodeSummaryTable on Storage, Backups, and Workloads pages
- Remove ProxmoxSectionNav from Ceph, Replication, MailGateway pages

## Result
- Cleaner, unified navigation experience
- Reduced bundle size
- All builds pass
This commit is contained in:
rcourtman
2026-02-05 16:09:40 +00:00
parent 1edfa4311e
commit d8d8481284
24 changed files with 70 additions and 8508 deletions

View File

@@ -41,7 +41,6 @@ import { CommandPaletteModal } from './components/shared/CommandPaletteModal';
import { MobileNavBar } from './components/shared/MobileNavBar';
import { createTooltipSystem } from './components/shared/Tooltip';
import type { State, Alert } from '@/types/api';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { startMetricsSampler } from './stores/metricsSampler';
import { seedFromBackend } from './stores/metricsHistory';
import { getMetricsViewMode } from './stores/metricsViewMode';
@@ -50,7 +49,6 @@ import ServerIcon from 'lucide-solid/icons/server';
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
import ArchiveIcon from 'lucide-solid/icons/archive';
import WrenchIcon from 'lucide-solid/icons/wrench';
import MonitorIcon from 'lucide-solid/icons/monitor';
import BellIcon from 'lucide-solid/icons/bell';
import SettingsIcon from 'lucide-solid/icons/settings';
import NetworkIcon from 'lucide-solid/icons/network';
@@ -67,7 +65,6 @@ import { useResourcesAsLegacy } from './hooks/useResources';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { updateSystemSettingsFromResponse, markSystemSettingsLoadedWithDefaults } from './stores/systemSettings';
import { initKioskMode, isKioskMode, setKioskMode, subscribeToKioskMode, getKioskModePreference } from './utils/url';
import { showToast } from '@/utils/toast';
import { GlobalSearch } from '@/components/shared/GlobalSearch';
@@ -84,19 +81,11 @@ const AlertsPage = lazy(() =>
import('./pages/Alerts').then((module) => ({ default: module.Alerts })),
);
const SettingsPage = lazy(() => import('./components/Settings/Settings'));
const DockerHosts = lazy(() =>
import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })),
);
const KubernetesClusters = lazy(() =>
import('./components/Kubernetes/KubernetesClusters').then((module) => ({
default: module.KubernetesClusters,
})),
);
const HostsOverview = lazy(() =>
import('./components/Hosts/HostsOverview').then((module) => ({
default: module.HostsOverview,
})),
);
const InfrastructurePage = lazy(() => import('./pages/Infrastructure'));
const AIIntelligencePage = lazy(() =>
import('./pages/AIIntelligence').then((module) => ({ default: module.AIIntelligence })),
@@ -126,23 +115,6 @@ export const useDarkMode = () => {
return context;
};
// Docker route component - uses unified resources via useResourcesAsLegacy hook
function DockerRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
return <div>Loading...</div>;
}
const { activeAlerts } = wsContext;
const { asDockerHosts } = useResourcesAsLegacy();
return <DockerHosts hosts={asDockerHosts() as any} activeAlerts={activeAlerts} />;
}
// Hosts route component - HostsOverview uses useResourcesAsLegacy directly for proper reactivity
function HostsRoute() {
return <HostsOverview />;
}
function KubernetesRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
@@ -287,11 +259,8 @@ function App() {
() => import('./components/Backups/Backups'),
() => import('./components/Replication/Replication'),
() => import('./components/PMG/MailGateway'),
() => import('./components/Hosts/HostsOverview'),
() => import('./pages/Alerts'),
() => import('./components/Settings/Settings'),
() => import('./components/Docker/DockerHosts'),
];
loaders.forEach((load) => {
@@ -848,17 +817,6 @@ function App() {
);
};
const LegacyRedirect = (props: { to: string; toast?: { type: 'info' | 'success' | 'warning' | 'error'; title: string; message?: string } }) => {
const navigate = useNavigate();
onMount(() => {
if (props.toast) {
showToast(props.toast.type, props.toast.title, props.toast.message);
}
navigate(props.to, { replace: true });
});
return null;
};
const SettingsRoute = () => (
<SettingsPage darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
);
@@ -1000,40 +958,21 @@ function App() {
// Use Router with routes
return (
<Router root={RootLayout}>
<Route path="/" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route
path="/proxmox/overview"
component={() => (
<LegacyRedirect
to="/infrastructure"
toast={{ type: 'info', title: 'Dashboard moved to Infrastructure' }}
/>
)}
/>
<Route path="/" component={() => <Navigate href="/infrastructure" />} />
<Route path="/proxmox" component={() => <Navigate href="/infrastructure" />} />
<Route path="/workloads" component={WorkloadsView} />
<Route path="/proxmox/storage" component={() => <LegacyRedirect to="/storage" />} />
<Route path="/proxmox/storage" component={() => <Navigate href="/storage" />} />
<Route path="/proxmox/ceph" component={CephPage} />
<Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} />
<Route path="/proxmox/backups" component={() => <LegacyRedirect to="/backups" />} />
<Route path="/proxmox/backups" component={() => <Navigate href="/backups" />} />
<Route path="/storage" component={StorageComponent} />
<Route path="/backups" component={UnifiedBackups} />
<Route path="/services" component={Services} />
<Route
path="/docker"
component={() => (
<LegacyRedirect
to="/infrastructure?source=docker"
toast={{ type: 'info', title: 'Docker hosts moved to Infrastructure' }}
/>
)}
/>
<Route path="/kubernetes" component={KubernetesRoute} />
<Route path="/hosts" component={() => <LegacyRedirect to="/infrastructure?source=agent" />} />
<Route path="/infrastructure" component={InfrastructurePage} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
<Route path="/servers" component={() => <Navigate href="/infrastructure" />} />
<Route path="/alerts/*" component={AlertsPage} />
<Route path="/ai/*" component={AIIntelligencePage} />
<Route path="/settings/*" component={SettingsRoute} />
@@ -1111,24 +1050,6 @@ function AppLayout(props: {
const navigate = useNavigate();
const location = useLocation();
const readSeenPlatforms = (): Record<string, boolean> => {
if (typeof window === 'undefined') return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEYS.PLATFORMS_SEEN);
if (stored) {
const parsed = JSON.parse(stored) as Record<string, boolean>;
if (parsed && typeof parsed === 'object') {
return parsed;
}
}
} catch (error) {
logger.warn('Failed to parse stored platform visibility preferences', error);
}
return {};
};
const [seenPlatforms, setSeenPlatforms] = createSignal<Record<string, boolean>>(readSeenPlatforms());
// Reactive kiosk mode state
const [kioskMode, setKioskModeSignal] = createSignal(isKioskMode());
@@ -1160,26 +1081,6 @@ function AppLayout(props: {
setKioskModeSignal(newValue);
};
const persistSeenPlatforms = (map: Record<string, boolean>) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEYS.PLATFORMS_SEEN, JSON.stringify(map));
} catch (error) {
logger.warn('Failed to persist platform visibility preferences', error);
}
};
const markPlatformSeen = (platformId: string) => {
setSeenPlatforms((current) => {
if (current[platformId]) {
return current;
}
const updated = { ...current, [platformId]: true };
persistSeenPlatforms(updated);
return updated;
});
};
// Determine active tab from current path
const getActiveTab = () => {
const path = location.pathname;
@@ -1188,50 +1089,19 @@ function AppLayout(props: {
if (path.startsWith('/storage')) return 'storage';
if (path.startsWith('/backups')) return 'backups';
if (path.startsWith('/services')) return 'services';
if (path.startsWith('/proxmox')) return 'proxmox';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/proxmox/ceph') || path.startsWith('/proxmox/storage')) return 'storage';
if (path.startsWith('/proxmox/replication') || path.startsWith('/proxmox/backups')) return 'backups';
if (path.startsWith('/proxmox/mail')) return 'services';
if (path.startsWith('/proxmox')) return 'infrastructure';
if (path.startsWith('/kubernetes')) return 'kubernetes';
if (path.startsWith('/hosts')) return 'hosts';
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
if (path.startsWith('/servers')) return 'infrastructure';
if (path.startsWith('/alerts')) return 'alerts';
if (path.startsWith('/ai')) return 'ai';
if (path.startsWith('/settings')) return 'settings';
return 'proxmox';
return 'infrastructure';
};
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
const hasKubernetesClusters = createMemo(() => (props.state().kubernetesClusters?.length ?? 0) > 0);
const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
const hasPMGServices = createMemo(() => (props.state().pmg?.length ?? 0) > 0);
const hasProxmoxHosts = createMemo(
() =>
(props.state().nodes?.length ?? 0) > 0 ||
(props.state().vms?.length ?? 0) > 0 ||
(props.state().containers?.length ?? 0) > 0,
);
createEffect(() => {
if (hasDockerHosts()) {
markPlatformSeen('docker');
}
});
createEffect(() => {
if (hasKubernetesClusters()) {
markPlatformSeen('kubernetes');
}
});
createEffect(() => {
if (hasProxmoxHosts()) {
markPlatformSeen('proxmox');
}
});
createEffect(() => {
if (hasHosts()) {
markPlatformSeen('hosts');
}
});
type PlatformTab = {
id: string;
@@ -1313,34 +1183,6 @@ function AppLayout(props: {
),
alwaysShow: false,
},
{
id: 'proxmox' as const,
label: 'Proxmox Overview',
route: '/proxmox/overview',
settingsRoute: '/settings',
tooltip: 'Legacy Proxmox dashboard',
enabled: hasProxmoxHosts() || !!seenPlatforms()['proxmox'],
live: hasProxmoxHosts(),
icon: (
<ProxmoxIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Proxmox is the default, always show
badge: 'Legacy',
},
{
id: 'docker' as const,
label: 'Docker',
route: '/docker',
settingsRoute: '/settings/docker',
tooltip: 'Legacy Docker hosts and containers',
enabled: hasDockerHosts() || !!seenPlatforms()['docker'],
live: hasDockerHosts(),
icon: (
<BoxesIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Docker is commonly used, keep visible
badge: 'Legacy',
},
{
id: 'kubernetes' as const,
label: 'Kubernetes',
@@ -1354,20 +1196,6 @@ function AppLayout(props: {
),
alwaysShow: false, // Only show when clusters exist
},
{
id: 'hosts' as const,
label: 'Hosts',
route: '/hosts',
settingsRoute: '/settings/host-agents',
tooltip: 'Legacy hosts view',
enabled: hasHosts() || !!seenPlatforms()['hosts'],
live: hasHosts(),
icon: (
<MonitorIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Hosts is commonly used, keep visible
badge: 'Legacy',
},
];
// Filter out platforms that should be hidden when not configured

View File

@@ -3,7 +3,6 @@ import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { useWebSocket } from '@/App';
import UnifiedBackups from './UnifiedBackups';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { useInstanceWarnings } from '@/hooks/useInstanceWarnings';
import AlertTriangle from 'lucide-solid/icons/alert-triangle';
@@ -31,8 +30,6 @@ const Backups: Component = () => {
return (
<div class="space-y-3">
<ProxmoxSectionNav current="backups" />
{/* Permission Warnings Banner */}
<Show when={allWarnings().length > 0}>
<Card padding="md" tone="warning">

View File

@@ -1200,6 +1200,7 @@ const UnifiedBackups: Component = () => {
setIsSearchLocked(namespaceFilter !== '');
}}
searchTerm={searchTerm()}
showNodeSummary={false}
/>
{/* PBS Enhancement Banner - shown when PBS storage exists via PVE but no direct PBS connection */}

View File

@@ -17,7 +17,7 @@ import type { GuestMetadata } from '@/api/guestMetadata';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status';
import { getNodeDisplayName } from '@/utils/nodes';
import { logger } from '@/utils/logger';
@@ -215,6 +215,7 @@ export function Dashboard(props: DashboardProps) {
const { isMobile } = useBreakpoint();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const isWorkloadsRoute = () => location.pathname === '/workloads';
// Kiosk mode - hide filter panel for clean dashboard display
// Usage: Add ?kiosk=1 to URL or use the toggle button in the header
@@ -291,6 +292,9 @@ export function Dashboard(props: DashboardProps) {
const v2Enabled = createMemo(() => props.useV2Workloads === true);
const v2Workloads = useV2Workloads(v2Enabled);
const v2Loaded = createMemo(() => v2Enabled() && !v2Workloads.loading() && !v2Workloads.error());
const sortedNodes = createMemo(() =>
[...props.nodes].sort((a, b) => getNodeDisplayName(a).localeCompare(getNodeDisplayName(b))),
);
const legacyGuests = createMemo<WorkloadGuest[]>(() => [
...props.vms.map((vm) => ({ ...vm, workloadType: 'vm', displayId: String(vm.vmid) })),
@@ -933,12 +937,40 @@ export function Dashboard(props: DashboardProps) {
return (
<div class="space-y-3">
{/* Section nav - hidden in kiosk mode */}
<Show when={!kioskMode()}>
<ProxmoxSectionNav current="overview" />
<Show when={isWorkloadsRoute()}>
<SectionHeader title="Workloads" size="lg" />
</Show>
{/* Unified Node Selector - always visible (this is the main dashboard content) */}
<Show when={isWorkloadsRoute()}>
<Card padding="sm">
<div class="flex flex-wrap items-center gap-2">
<label
for="workloads-node-filter"
class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Host
</label>
<select
id="workloads-node-filter"
value={selectedNode() ?? ''}
onChange={(e) => {
const value = e.currentTarget.value;
handleNodeSelect(value || null, value ? 'pve' : null);
}}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<option value="">All nodes</option>
<For each={sortedNodes()}>
{(node) => (
<option value={node.id}>{getNodeDisplayName(node)}</option>
)}
</For>
</select>
</div>
</Card>
</Show>
{/* Unified Node Selector - infrastructure summary (hidden on workloads) */}
<UnifiedNodeSelector
currentTab="dashboard"
globalTemperatureMonitoringEnabled={ws.state.temperatureMonitoringEnabled}
@@ -947,6 +979,7 @@ export function Dashboard(props: DashboardProps) {
filteredVms={filteredGuests().filter((g) => resolveWorkloadType(g) === 'vm') as VM[]}
filteredContainers={filteredGuests().filter((g) => resolveWorkloadType(g) === 'lxc') as Container[]}
searchTerm={search()}
showNodeSummary={!isWorkloadsRoute()}
/>
{/* Dashboard Filter - hidden in kiosk mode */}

View File

@@ -1,426 +0,0 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import { formatRelativeTime, getShortImageName } from '@/utils/format';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import {
groupHostsByCluster,
aggregateClusterServices,
formatNodeDistribution,
getServiceHealthStatus,
type SwarmCluster,
type ClusterService,
} from './swarmClusterHelpers';
interface DockerClusterServicesTableProps {
hosts: DockerHost[];
searchTerm?: string;
}
type SortKey = 'name' | 'stack' | 'mode' | 'replicas' | 'nodes' | 'status';
type SortDirection = 'asc' | 'desc';
const SORT_KEYS: SortKey[] = ['name', 'stack', 'mode', 'replicas', 'nodes', 'status'];
const SORT_DEFAULT_DIRECTION: Record<SortKey, SortDirection> = {
name: 'asc',
stack: 'asc',
mode: 'asc',
replicas: 'desc',
nodes: 'desc',
status: 'desc',
};
const STATUS_PRIORITY: Record<string, number> = {
critical: 3,
degraded: 2,
healthy: 1,
};
const parseSearchTerm = (term?: string): string[] => {
if (!term) return [];
return term
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
};
const serviceMatchesSearch = (service: ClusterService, tokens: string[]): boolean => {
if (tokens.length === 0) return true;
const searchableText = [
service.service.name || '',
service.service.stack || '',
service.service.image || '',
service.service.mode || '',
...service.nodes.map((n) => n.hostname),
]
.join(' ')
.toLowerCase();
return tokens.every((token) => searchableText.includes(token));
};
const ClusterGroupHeader: Component<{
cluster: SwarmCluster;
serviceCount: number;
isExpanded: boolean;
onToggle: () => void;
}> = (props) => {
const managerCount = createMemo(
() => props.cluster.hosts.filter((h) => h.swarm?.nodeRole === 'manager').length
);
const workerCount = createMemo(
() => props.cluster.hosts.filter((h) => h.swarm?.nodeRole === 'worker').length
);
return (
<button
type="button"
onClick={props.onToggle}
class="w-full flex items-center gap-3 px-4 py-3 bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 dark:hover:bg-purple-900/30 border-b border-purple-200 dark:border-purple-800 transition-colors"
>
<svg
class={`w-4 h-4 text-purple-600 dark:text-purple-400 transition-transform ${props.isExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<div class="flex items-center gap-2">
<svg
class="w-5 h-5 text-purple-600 dark:text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<span class="font-medium text-purple-900 dark:text-purple-100">
{props.cluster.clusterName || 'Swarm Cluster'}
</span>
</div>
<div class="flex items-center gap-4 text-sm text-purple-700 dark:text-purple-300">
<span class="flex items-center gap-1">
<span class="font-medium">{props.cluster.hosts.length}</span>
<span class="text-purple-500 dark:text-purple-400">
{props.cluster.hosts.length === 1 ? 'node' : 'nodes'}
</span>
</span>
<Show when={managerCount() > 0}>
<span class="text-purple-500 dark:text-purple-400">
({managerCount()} manager{managerCount() !== 1 ? 's' : ''}, {workerCount()} worker
{workerCount() !== 1 ? 's' : ''})
</span>
</Show>
<span class="text-purple-500 dark:text-purple-400">|</span>
<span>
<span class="font-medium">{props.serviceCount}</span>{' '}
<span class="text-purple-500 dark:text-purple-400">
{props.serviceCount === 1 ? 'service' : 'services'}
</span>
</span>
</div>
</button>
);
};
const ServiceRow: Component<{ service: ClusterService }> = (props) => {
const healthStatus = createMemo(() => getServiceHealthStatus(props.service));
const statusVariant = createMemo(() => {
switch (healthStatus()) {
case 'critical':
return 'danger';
case 'degraded':
return 'warning';
default:
return 'success';
}
});
const statusLabel = createMemo(() => {
const { totalDesired, totalRunning } = props.service;
if (totalDesired === 0) return 'No replicas';
return `${totalRunning}/${totalDesired} running`;
});
const updatedAt = createMemo(() => {
const ts = props.service.service.updatedAt;
if (!ts) return null;
return typeof ts === 'number' ? ts : Date.parse(ts as unknown as string);
});
return (
<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
{/* Service Name */}
<td class="px-4 py-3">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-gray-100">
{props.service.service.name}
</span>
<Show when={props.service.service.image}>
<span class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]" title={props.service.service.image || undefined}>
{getShortImageName(props.service.service.image)}
</span>
</Show>
</div>
</td>
{/* Stack */}
<td class="px-4 py-3">
<Show when={props.service.service.stack} fallback={<span class="text-gray-400"></span>}>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{props.service.service.stack}
</span>
</Show>
</td>
{/* Mode */}
<td class="px-4 py-3">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize">
{props.service.service.mode || 'replicated'}
</span>
</td>
{/* Replicas */}
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<StatusDot variant={statusVariant()} size="sm" />
<span class="text-sm text-gray-700 dark:text-gray-300">{statusLabel()}</span>
</div>
</td>
{/* Nodes */}
<td class="px-4 py-3">
<span class="text-sm text-gray-600 dark:text-gray-400">
{formatNodeDistribution(props.service.nodes)}
</span>
</td>
{/* Updated */}
<td class="px-4 py-3 text-right">
<Show when={updatedAt()} fallback={<span class="text-gray-400"></span>}>
<span class="text-sm text-gray-500 dark:text-gray-400">
{formatRelativeTime(updatedAt()!)}
</span>
</Show>
</td>
</tr>
);
};
const ClusterSection: Component<{
cluster: SwarmCluster;
services: ClusterService[];
sortKey: SortKey;
sortDirection: SortDirection;
}> = (props) => {
const [isExpanded, setIsExpanded] = createSignal(true);
const sortedServices = createMemo(() => {
const services = [...props.services];
const key = props.sortKey;
const dir = props.sortDirection;
services.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name':
cmp = (a.service.name || '').localeCompare(b.service.name || '');
break;
case 'stack':
cmp = (a.service.stack || '').localeCompare(b.service.stack || '');
break;
case 'mode':
cmp = (a.service.mode || '').localeCompare(b.service.mode || '');
break;
case 'replicas':
cmp = a.totalRunning - b.totalRunning;
break;
case 'nodes':
cmp = a.nodes.length - b.nodes.length;
break;
case 'status':
cmp =
(STATUS_PRIORITY[getServiceHealthStatus(a)] || 0) -
(STATUS_PRIORITY[getServiceHealthStatus(b)] || 0);
break;
}
return dir === 'asc' ? cmp : -cmp;
});
return services;
});
return (
<div class="mb-4">
<ClusterGroupHeader
cluster={props.cluster}
serviceCount={props.services.length}
isExpanded={isExpanded()}
onToggle={() => setIsExpanded(!isExpanded())}
/>
<Show when={isExpanded()}>
<div class="overflow-x-auto">
<table class="w-full">
<tbody>
<For each={sortedServices()}>{(service) => <ServiceRow service={service} />}</For>
</tbody>
</table>
</div>
</Show>
</div>
);
};
export const DockerClusterServicesTable: Component<DockerClusterServicesTableProps> = (props) => {
const [sortKey, setSortKey] = usePersistentSignal<SortKey>(
'dockerClusterSortKey',
'name',
{ deserialize: (v) => (SORT_KEYS.includes(v as SortKey) ? (v as SortKey) : 'name') }
);
const [sortDirection, setSortDirection] = usePersistentSignal<SortDirection>(
'dockerClusterSortDirection',
'asc',
{ deserialize: (v) => (v === 'asc' || v === 'desc' ? v : 'asc') }
);
const handleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection(SORT_DEFAULT_DIRECTION[key]);
}
};
const renderSortIndicator = (key: SortKey) => {
if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? ' ▲' : ' ▼';
};
const searchTokens = createMemo(() => parseSearchTerm(props.searchTerm));
const clusters = createMemo(() => groupHostsByCluster(props.hosts));
const clustersWithServices = createMemo(() => {
const tokens = searchTokens();
return clusters()
.map((cluster) => {
const allServices = aggregateClusterServices(cluster);
const filteredServices = allServices.filter((s) => serviceMatchesSearch(s, tokens));
return { cluster, services: filteredServices };
})
.filter((c) => c.services.length > 0);
});
const totalServices = createMemo(() =>
clustersWithServices().reduce((sum, c) => sum + c.services.length, 0)
);
return (
<Card class="docker-cluster-services-table" padding="none">
<Show
when={clustersWithServices().length > 0}
fallback={
<EmptyState
title="No Swarm clusters found"
description="Deploy Docker agents to multiple nodes in the same Swarm cluster to see the cluster view."
icon={
<svg
class="w-12 h-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
}
/>
}
>
{/* Header row */}
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<table class="w-full">
<thead>
<tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th
class="px-4 py-3 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
onClick={() => handleSort('name')}
>
Service{renderSortIndicator('name')}
</th>
<th
class="px-4 py-3 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
onClick={() => handleSort('stack')}
>
Stack{renderSortIndicator('stack')}
</th>
<th
class="px-4 py-3 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
onClick={() => handleSort('mode')}
>
Mode{renderSortIndicator('mode')}
</th>
<th
class="px-4 py-3 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
onClick={() => handleSort('replicas')}
>
Replicas{renderSortIndicator('replicas')}
</th>
<th
class="px-4 py-3 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
onClick={() => handleSort('nodes')}
>
Nodes{renderSortIndicator('nodes')}
</th>
<th class="px-4 py-3 text-right">Updated</th>
</tr>
</thead>
</table>
</div>
{/* Cluster sections */}
<div>
<For each={clustersWithServices()}>
{({ cluster, services }) => (
<ClusterSection
cluster={cluster}
services={services}
sortKey={sortKey()}
sortDirection={sortDirection()}
/>
)}
</For>
</div>
{/* Footer summary */}
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400">
{clustersWithServices().length} cluster{clustersWithServices().length !== 1 ? 's' : ''},{' '}
{totalServices()} service{totalServices() !== 1 ? 's' : ''}
</div>
</Show>
</Card>
);
};

View File

@@ -1,568 +0,0 @@
import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
export type DockerViewMode = 'grouped' | 'flat' | 'cluster';
interface DockerFilterProps {
search: () => string;
setSearch: (value: string) => void;
groupingMode?: () => DockerViewMode;
setGroupingMode?: (mode: DockerViewMode) => void;
hasSwarmClusters?: boolean;
statusFilter?: () => 'all' | 'online' | 'degraded' | 'offline';
setStatusFilter?: (value: 'all' | 'online' | 'degraded' | 'offline') => void;
searchInputRef?: (el: HTMLInputElement) => void;
onReset?: () => void;
activeHostName?: string;
onClearHost?: () => void;
updateAvailableCount?: number;
onUpdateAll?: () => void;
onCheckUpdates?: (hostId: string) => void;
activeHostId?: string | null;
checkingUpdatesStatus?: string; // 'queued' | 'dispatched' | 'acknowledged' | 'in_progress' | undefined
}
const UpdateAllButton: Component<{ count: number; onUpdate: () => void }> = (props) => {
const [confirming, setConfirming] = createSignal(false);
return (
<button
type="button"
onClick={() => {
if (confirming()) {
props.onUpdate();
setConfirming(false);
} else {
setConfirming(true);
// Auto-reset confirmation after 3s
setTimeout(() => setConfirming(false), 3000);
}
}}
class={`flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg transition-colors ${confirming()
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/60 dark:text-amber-200 hover:bg-amber-200'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200 hover:bg-blue-200'
}`}
title={confirming() ? "Click again to confirm" : `Update ${props.count} containers`}
>
<Show when={!confirming()} fallback={
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
}>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</Show>
<span>{confirming() ? 'Confirm Update All?' : `Update All (${props.count})`}</span>
</button>
);
};
const RefreshButton: Component<{ onRefresh: () => void }> = (props) => {
const [loading, setLoading] = createSignal(false);
const handleClick = async () => {
if (loading()) return;
setLoading(true);
try {
await props.onRefresh();
} finally {
// Keep it loading for a bit to show it happened
setTimeout(() => setLoading(false), 1000);
}
};
return (
<button
type="button"
onClick={handleClick}
disabled={loading()}
class={`flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg transition-all ${loading()
? 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed'
: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/60'
}`}
title="Force check for container updates on this host"
>
<svg
class={`h-3.5 w-3.5 ${loading() ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{loading() ? 'Checking...' : 'Check Updates'}</span>
</button>
);
};
export const DockerFilter: Component<DockerFilterProps> = (props) => {
const historyManager = createSearchHistoryManager(STORAGE_KEYS.DOCKER_SEARCH_HISTORY);
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = createSignal(false);
let searchInputEl: HTMLInputElement | undefined;
let historyMenuRef: HTMLDivElement | undefined;
let historyToggleRef: HTMLButtonElement | undefined;
onMount(() => {
setSearchHistory(historyManager.read());
});
const commitSearchToHistory = (term: string) => {
const trimmed = term.trim();
if (!trimmed) return;
const updated = historyManager.add(trimmed);
setSearchHistory(updated);
};
const deleteHistoryEntry = (term: string) => {
setSearchHistory(historyManager.remove(term));
};
const clearHistory = () => {
setSearchHistory(historyManager.clear());
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const closeHistory = () => {
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as Node;
const clickedMenu = historyMenuRef?.contains(target) ?? false;
const clickedToggle = historyToggleRef?.contains(target) ?? false;
if (!clickedMenu && !clickedToggle) {
closeHistory();
}
};
createEffect(() => {
if (isHistoryOpen()) {
document.addEventListener('mousedown', handleDocumentClick);
} else {
document.removeEventListener('mousedown', handleDocumentClick);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
let suppressBlurCommit = false;
const markSuppressCommit = () => {
suppressBlurCommit = true;
queueMicrotask(() => {
suppressBlurCommit = false;
});
};
const hasActiveFilters = createMemo(
() =>
props.search().trim() !== '' ||
(!!props.groupingMode && props.groupingMode() !== 'grouped') ||
(!!props.statusFilter && props.statusFilter() !== 'all') ||
Boolean(props.activeHostName),
);
const handleReset = () => {
props.setSearch('');
props.setGroupingMode?.('grouped');
props.setStatusFilter?.('all');
props.onClearHost?.();
props.onReset?.();
closeHistory();
focusSearchInput();
};
return (
<Card class="docker-filter mb-3" padding="sm">
<div class="flex flex-col gap-3">
{/* Search - full width on its own row */}
<div class="relative">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search containers or host:hostname"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'Escape') {
props.setSearch('');
closeHistory();
e.currentTarget.blur();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'container-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search containers by name, image, ID, or host"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="container-search-help"
intro="Filter containers quickly"
tips={[
{ code: 'name:api', description: 'Match containers with "api" in the name' },
{ code: 'image:postgres', description: 'Find containers running a specific image' },
{ code: 'host:prod', description: 'Show containers on a host' },
{ code: 'state:running', description: 'Running containers only' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
{/* Filters - second row */}
<div class="flex flex-wrap items-center gap-2">
<Show when={props.statusFilter && props.setStatusFilter}>
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
<button
type="button"
aria-pressed={props.statusFilter?.() === 'all'}
onClick={() => props.setStatusFilter?.('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show all hosts"
>
All
</button>
<button
type="button"
aria-pressed={props.statusFilter?.() === 'online'}
onClick={() =>
props.setStatusFilter?.(props.statusFilter?.() === 'online' ? 'all' : 'online')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show online hosts only"
>
Online
</button>
<button
type="button"
aria-pressed={props.statusFilter?.() === 'degraded'}
onClick={() =>
props.setStatusFilter?.(
props.statusFilter?.() === 'degraded' ? 'all' : 'degraded',
)
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show degraded hosts only"
>
Degraded
</button>
<button
type="button"
aria-pressed={props.statusFilter?.() === 'offline'}
onClick={() =>
props.setStatusFilter?.(
props.statusFilter?.() === 'offline' ? 'all' : 'offline',
)
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show offline hosts only"
>
Offline
</button>
</div>
</Show>
<Show when={props.groupingMode && props.setGroupingMode}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setGroupingMode?.('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode?.() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Group containers by host"
>
Grouped
</button>
<button
type="button"
onClick={() => props.setGroupingMode?.('flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode?.() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show all containers in a flat list"
>
List
</button>
<Show when={props.hasSwarmClusters}>
<button
type="button"
onClick={() => props.setGroupingMode?.('cluster')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode?.() === 'cluster'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show Swarm services grouped by cluster"
>
Cluster
</button>
</Show>
</div>
</Show>
<Show when={props.activeHostName}>
<div class="flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
<span>Host: {props.activeHostName}</span>
<button
type="button"
class="text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-100"
onClick={() => props.onClearHost?.()}
title="Clear host filter"
>
×
</button>
</div>
</Show>
{/* Metrics View Toggle */}
{/* Metrics View Toggle */}
<MetricsViewToggle />
<Show when={props.updateAvailableCount && props.updateAvailableCount > 1}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<UpdateAllButton
count={props.updateAvailableCount!}
onUpdate={props.onUpdateAll!}
/>
</Show>
<Show when={props.onCheckUpdates && props.activeHostId}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<Show when={props.checkingUpdatesStatus && ['queued', 'dispatched', 'acknowledged', 'in_progress'].includes(props.checkingUpdatesStatus)}>
<div class="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg bg-amber-50 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Checking updates...</span>
</div>
</Show>
<Show when={!props.checkingUpdatesStatus || !['queued', 'dispatched', 'acknowledged', 'in_progress'].includes(props.checkingUpdatesStatus)}>
<RefreshButton
onRefresh={() => props.onCheckUpdates!(props.activeHostId!)}
/>
</Show>
</Show>
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button
type="button"
onClick={handleReset}
class="flex items-center justify-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
title="Reset filters"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="hidden sm:inline">Reset</span>
</button>
</Show>
</div>
</div>
</Card >
);
};

View File

@@ -1,388 +0,0 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { resolveHostRuntime } from './runtimeDisplay';
import { formatUptime } from '@/utils/format';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
import { getDockerHostStatusIndicator } from '@/utils/status';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { isAgentOutdated, getAgentVersionTooltip } from '@/utils/agentVersion';
export interface DockerHostSummary {
host: DockerHost;
cpuPercent: number;
memoryPercent: number;
memoryLabel?: string;
diskPercent: number;
diskLabel?: string;
runningPercent: number;
runningCount: number;
stoppedCount: number;
errorCount: number;
totalCount: number;
uptimeSeconds: number;
lastSeenRelative: string;
lastSeenAbsolute: string;
}
interface DockerHostSummaryTableProps {
summaries: () => DockerHostSummary[];
selectedHostId: () => string | null;
onSelect: (hostId: string) => void;
}
type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent';
type SortDirection = 'asc' | 'desc';
const isHostOnline = (host: DockerHost) => {
const status = host.status?.toLowerCase() ?? '';
return status === 'online' || status === 'running' || status === 'healthy' || status === 'degraded';
};
const getDisplayName = (host: DockerHost) => {
return host.customDisplayName || host.displayName || host.hostname || host.id;
};
export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (props) => {
const [sortKey, setSortKey] = createSignal<SortKey>('name');
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
const { isMobile } = useBreakpoint();
const handleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection(key === 'name' ? 'asc' : 'desc');
}
};
const formatLastSeenValue = (lastSeen?: number) => {
if (!lastSeen) return 0;
return lastSeen;
};
const sortedSummaries = createMemo(() => {
const list = [...props.summaries()];
const key = sortKey();
const dir = sortDirection();
list.sort((a, b) => {
const hostA = a.host;
const hostB = b.host;
let value = 0;
switch (key) {
case 'name':
value = getDisplayName(hostA).localeCompare(getDisplayName(hostB));
break;
case 'uptime':
value = (a.uptimeSeconds || 0) - (b.uptimeSeconds || 0);
break;
case 'cpu':
value = a.cpuPercent - b.cpuPercent;
break;
case 'memory':
value = a.memoryPercent - b.memoryPercent;
break;
case 'disk':
value = a.diskPercent - b.diskPercent;
break;
case 'running':
value = a.runningPercent - b.runningPercent;
break;
case 'lastSeen':
value = formatLastSeenValue(hostA.lastSeen) - formatLastSeenValue(hostB.lastSeen);
break;
case 'agent':
// Sort by version, putting outdated versions first (for easy identification)
const aOutdated = isAgentOutdated(hostA.agentVersion);
const bOutdated = isAgentOutdated(hostB.agentVersion);
if (aOutdated !== bOutdated) {
value = aOutdated ? -1 : 1;
} else {
value = (hostA.agentVersion || '').localeCompare(hostB.agentVersion || '');
}
break;
}
if (value === 0) {
value = getDisplayName(hostA).localeCompare(getDisplayName(hostB));
}
return dir === 'asc' ? value : -value;
});
return list;
});
const renderSortIndicator = (key: SortKey) => {
if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? '▲' : '▼';
};
// Agent version checking is now done via the shared utility that compares against server version
return (
<>
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<ScrollableTable persistKey="docker-host-summary" minWidth={isMobile() ? '100%' : '800px'}>
<table class="w-full border-collapse whitespace-nowrap" style={{ "table-layout": "fixed" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<th
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
style={{ width: "20%" }}
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabIndex={0}
role="button"
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
Host {renderSortIndicator('name')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "80px" }}
onClick={() => handleSort('uptime')}
>
Uptime {renderSortIndicator('uptime')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "140px" }}
onClick={() => handleSort('cpu')}
>
CPU {renderSortIndicator('cpu')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "140px" }}
onClick={() => handleSort('memory')}
>
Memory {renderSortIndicator('memory')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "140px" }}
onClick={() => handleSort('disk')}
>
Disk {renderSortIndicator('disk')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "100px" }}
onClick={() => handleSort('running')}
>
Containers {renderSortIndicator('running')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "80px" }}
onClick={() => handleSort('lastSeen')}
>
Last Update {renderSortIndicator('lastSeen')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={{ width: "70px" }}
onClick={() => handleSort('agent')}
>
Agent {renderSortIndicator('agent')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedSummaries()}>
{(summary) => {
const selected = props.selectedHostId() === summary.host.id;
const online = isHostOnline(summary.host);
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
const rowStyle = () => {
const styles: Record<string, string> = {};
const shadows: string[] = [];
if (selected) {
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
}
if (shadows.length > 0) {
styles['box-shadow'] = shadows.join(', ');
}
return styles;
};
const rowClass = () => {
const baseHover = 'group cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
if (selected) {
return 'group cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10';
}
let className = baseHover;
if (!online) {
className += ' opacity-60';
}
return className;
};
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
const runtimeInfo = resolveHostRuntime(summary.host);
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
const metricsKey = buildMetricKey('dockerHost', summary.host.id);
const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host));
return (
<tr
class={rowClass()}
style={rowStyle()}
onClick={() => props.onSelect(summary.host.id)}
>
<td class="pr-2 py-1 pl-3 align-middle relative">
<div class="flex items-center gap-1.5 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 truncate" title={getDisplayName(summary.host)}>
{getDisplayName(summary.host)}
</span>
<Show when={getDisplayName(summary.host) !== summary.host.hostname}>
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
({summary.host.hostname})
</span>
</Show>
<div class="hidden xl:flex items-center gap-1.5 ml-1">
<span
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
title={runtimeInfo.raw || runtimeInfo.label}
>
{runtimeInfo.label}
</span>
<Show when={runtimeVersion}>
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
v{runtimeVersion}
</span>
</Show>
</div>
</div>
</td>
<td class="px-2 py-1 align-middle whitespace-nowrap">
<div class="flex justify-center items-center h-full">
<span class="text-xs text-gray-600 dark:text-gray-400">
{uptimeLabel}
</span>
</div>
</td>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4 w-full">
<EnhancedCPUBar
usage={summary.cpuPercent}
loadAverage={summary.host.loadAverage?.[0]}
cores={isMobile() ? undefined : summary.host.cpus}
resourceId={metricsKey}
/>
</div>
</td>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4">
<ResponsiveMetricCell
value={summary.memoryPercent}
type="memory"
sublabel={summary.memoryLabel}
resourceId={metricsKey}
isRunning={online}
showMobile={false}
/>
</div>
</td>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4">
<ResponsiveMetricCell
value={summary.diskPercent}
type="disk"
sublabel={summary.diskLabel}
resourceId={metricsKey}
isRunning={!!summary.diskLabel}
showMobile={false}
/>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
<Show
when={summary.totalCount > 0}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
>
<span class="text-xs font-semibold text-gray-700 dark:text-gray-200">
{summary.totalCount}
</span>
</Show>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full">
<Show
when={summary.lastSeenRelative}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
>
{(relative) => (
<span
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
title={summary.lastSeenAbsolute || undefined}
>
{relative()}
</span>
)}
</Show>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
<Show
when={summary.host.agentVersion}
fallback={<span class="text-gray-400 dark:text-gray-500 text-xs"></span>}
>
{(version) => (
<span
class={
agentOutdated
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
}
title={`${getAgentVersionTooltip(summary.host.agentVersion)}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
>
{version()}
</span>
)}
</Show>
<Show when={agentOutdated}>
<span class="text-[10px] text-yellow-600 dark:text-yellow-500 font-medium" title="Update recommended">
</span>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Card>
</>
);
};

View File

@@ -1,526 +0,0 @@
import type { Component } from 'solid-js';
import { Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
import { createStore } from 'solid-js/store';
import { useNavigate } from '@solidjs/router';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { DockerFilter, type DockerViewMode } from './DockerFilter';
import { DockerHostSummaryTable, type DockerHostSummary } from './DockerHostSummaryTable';
import { DockerUnifiedTable } from './DockerUnifiedTable';
import { DockerClusterServicesTable } from './DockerClusterServicesTable';
import { hasSwarmClusters } from './swarmClusterHelpers';
import { useWebSocket } from '@/App';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import { logger } from '@/utils/logger';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
import { MonitoringAPI } from '@/api/monitoring';
import { showSuccess, showError, showToast } from '@/utils/toast';
import { isKioskMode, subscribeToKioskMode } from '@/utils/url';
interface DockerHostsProps {
hosts: DockerHost[];
activeAlerts?: Record<string, unknown> | any;
}
export const DockerHosts: Component<DockerHostsProps> = (props) => {
const navigate = useNavigate();
const { initialDataReceived, reconnecting, connected, reconnect } = useWebSocket();
// Load docker metadata from localStorage or API
const sortedHosts = createMemo(() => {
const hosts = props.hosts || [];
return [...hosts].sort((a, b) => {
const aName = a.customDisplayName || a.displayName || a.hostname || a.id || '';
const bName = b.customDisplayName || b.displayName || b.hostname || b.id || '';
return aName.localeCompare(bName);
});
});
const isLoading = createMemo(() => {
if (typeof initialDataReceived === 'function') {
const hostCount = Array.isArray(props.hosts) ? props.hosts.length : 0;
return !initialDataReceived() && hostCount === 0;
}
return false;
});
const [search, setSearch] = createSignal('');
const debouncedSearch = useDebouncedValue(search, 250);
const [selectedHostId, setSelectedHostId] = createSignal<string | null>(null);
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
const [groupingMode, setGroupingMode] = usePersistentSignal<DockerViewMode>('dockerGroupingMode', 'grouped', {
deserialize: (v) => (['grouped', 'flat', 'cluster'].includes(v) ? v as DockerViewMode : 'grouped'),
});
// Detect if any Swarm clusters exist (2+ hosts sharing a clusterId)
const hasSwarmClustersDetected = createMemo(() => hasSwarmClusters(sortedHosts()));
// Kiosk mode - hide filter panel for clean dashboard display
const [kioskMode, setKioskMode] = createSignal(isKioskMode());
// Subscribe to kiosk mode changes from toggle button
onMount(() => {
const unsubscribe = subscribeToKioskMode((enabled) => {
setKioskMode(enabled);
});
return unsubscribe;
});
const clampPercent = (value: number | undefined | null) => {
if (value === undefined || value === null || Number.isNaN(value)) return 0;
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
};
// Cache for stable summary objects to prevent re-animations
const summaryCache = new Map<string, [DockerHostSummary, any]>();
const hostSummaries = createMemo<DockerHostSummary[]>(() => {
const usedKeys = new Set<string>();
const result = sortedHosts().map((host) => {
const totalContainers = host.containers?.length ?? 0;
const runningContainers =
host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0;
const stoppedContainers =
host.containers?.filter((container) =>
['exited', 'stopped', 'created'].includes(container.state?.toLowerCase() || '')
).length ?? 0;
// Count anything that isn't running or stopped/created as an error/warning state (restarting, dead, paused, etc)
const errorContainers = totalContainers - runningContainers - stoppedContainers;
const runningPercent = totalContainers > 0 ? clampPercent((runningContainers / totalContainers) * 100) : 0;
const cpuPercent = clampPercent(host.cpuUsagePercent ?? 0);
const memoryUsed = host.memory?.used ?? 0;
const memoryTotal = host.memory?.total ?? host.totalMemoryBytes ?? 0;
const memoryPercent = host.memory?.usage
? clampPercent(host.memory.usage)
: memoryTotal > 0
? clampPercent((memoryUsed / memoryTotal) * 100)
: 0;
const memoryLabel =
memoryTotal > 0 ? `${formatBytes(memoryUsed)} / ${formatBytes(memoryTotal)}` : undefined;
let diskPercent = 0;
let diskLabel: string | undefined;
if (host.disks && host.disks.length > 0) {
const totals = host.disks.reduce(
(acc, disk) => {
acc.used += disk.used ?? 0;
acc.total += disk.total ?? 0;
return acc;
},
{ used: 0, total: 0 },
);
if (totals.total > 0) {
diskPercent = clampPercent((totals.used / totals.total) * 100);
diskLabel = `${formatBytes(totals.used)} / ${formatBytes(totals.total)}`;
}
}
const uptimeSeconds = host.uptimeSeconds ?? 0;
const lastSeenRelative = host.lastSeen ? formatRelativeTime(host.lastSeen) : '—';
const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : '';
const newSummary: DockerHostSummary = {
host,
cpuPercent,
memoryPercent,
memoryLabel,
diskPercent,
diskLabel,
runningPercent,
runningCount: runningContainers,
stoppedCount: stoppedContainers,
errorCount: errorContainers,
totalCount: totalContainers,
uptimeSeconds,
lastSeenRelative,
lastSeenAbsolute,
};
const key = host.id;
usedKeys.add(key);
let entry = summaryCache.get(key);
if (!entry) {
entry = createStore(newSummary);
summaryCache.set(key, entry);
} else {
const [_, setState] = entry;
setState(newSummary);
}
return entry[0];
});
// Prune cache
for (const key of summaryCache.keys()) {
if (!usedKeys.has(key)) {
summaryCache.delete(key);
}
}
return result;
});
let searchInputRef: HTMLInputElement | undefined;
const focusSearchInput = () => {
queueMicrotask(() => searchInputRef?.focus());
};
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (event.key.length === 1 && searchInputRef) {
event.preventDefault();
focusSearchInput();
setSearch((prev) => prev + event.key);
}
};
onMount(() => {
document.addEventListener('keydown', handleKeyDown);
});
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
createEffect(() => {
const hostId = selectedHostId();
if (!hostId) {
return;
}
if (!sortedHosts().some((host) => host.id === hostId)) {
setSelectedHostId(null);
}
});
const hostMatchesStatus = (host: DockerHost) => {
const status = statusFilter();
if (status === 'all') return true;
const normalized = host.status?.toLowerCase() ?? '';
if (status === 'online') return normalized === 'online';
if (status === 'offline') return OFFLINE_HEALTH_STATUSES.has(normalized);
if (status === 'degraded') return DEGRADED_HEALTH_STATUSES.has(normalized);
return true;
};
const filteredHostSummaries = createMemo(() => {
const summaries = hostSummaries();
if (statusFilter() === 'all') return summaries;
return summaries.filter((summary) => hostMatchesStatus(summary.host));
});
createEffect(() => {
const hostId = selectedHostId();
if (!hostId) return;
if (!filteredHostSummaries().some((summary) => summary.host.id === hostId)) {
setSelectedHostId(null);
}
});
const statsFilter = createMemo(() => {
const status = statusFilter();
if (status === 'all') return null;
return { type: 'host-status' as const, value: status };
});
const handleHostSelect = (hostId: string) => {
setSelectedHostId((current) => (current === hostId ? null : hostId));
};
const updateableContainers = createMemo(() => {
const containers: { hostId: string; containerId: string; containerName: string }[] = [];
sortedHosts().forEach((host) => {
if (!hostMatchesStatus(host)) return;
host.containers?.forEach((c) => {
if (c.updateStatus?.updateAvailable) {
containers.push({
hostId: host.id,
containerId: c.id,
containerName: c.name || c.id,
});
}
});
});
return containers;
});
// Track batch update status: key is hostId:containerId
const [batchUpdateState, setBatchUpdateState] = createStore<Record<string, 'updating' | 'queued' | 'error'>>({});
const handleUpdateAll = async () => {
const targets = updateableContainers();
if (targets.length === 0) return;
// Initial toast
showToast(
'info',
'Batch Update Started',
`Preparing to update ${targets.length} containers...`,
10000,
);
// Mark all as updating
targets.forEach(t => {
setBatchUpdateState(`${t.hostId}:${t.containerId}`, 'updating');
});
let successCount = 0;
let failCount = 0;
// Process in chunks of 5 to avoid overloading the browser/network
const chunkSize = 5;
for (let i = 0; i < targets.length; i += chunkSize) {
const chunk = targets.slice(i, i + chunkSize);
await Promise.all(chunk.map(async (target) => {
const key = `${target.hostId}:${target.containerId}`;
try {
await MonitoringAPI.updateDockerContainer(
target.hostId,
target.containerId,
target.containerName,
);
setBatchUpdateState(key, 'queued');
successCount++;
} catch (err) {
failCount++;
setBatchUpdateState(key, 'error');
logger.error(`Failed to trigger update for ${target.containerName}`, err);
}
}));
}
if (failCount === 0) {
showSuccess(`Successfully queued updates for all ${targets.length} containers.`);
// Clear success states after delay
setTimeout(() => {
targets.forEach(t => {
const key = `${t.hostId}:${t.containerId}`;
if (batchUpdateState[key] === 'queued') {
setBatchUpdateState(key, undefined as any);
}
});
}, 5000);
} else if (successCount === 0) {
showError(`Failed to queue any updates. Check console for details.`);
} else {
showToast('warning', 'Batch Update Completed', `Queued ${successCount} updates. ${failCount} failed.`);
}
};
const handleCheckUpdates = async (hostId: string) => {
try {
await MonitoringAPI.checkDockerUpdates(hostId);
showSuccess('Update check triggered. The host will refresh container information shortly.');
} catch (err) {
showError(`Failed to trigger update check: ${err instanceof Error ? err.message : String(err)}`);
}
};
// Get the command status for the selected host to show checking indicator
const selectedHostCommandStatus = createMemo(() => {
const hostId = selectedHostId();
if (!hostId) return undefined;
const host = props.hosts.find(h => h.id === hostId);
if (!host?.command) return undefined;
// Only show status for check_updates commands
if (host.command.type !== 'check_updates') return undefined;
return host.command.status;
});
const renderFilter = () => (
<DockerFilter
search={search}
setSearch={setSearch}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
groupingMode={groupingMode}
setGroupingMode={setGroupingMode}
hasSwarmClusters={hasSwarmClustersDetected()}
onReset={() => {
setSearch('');
setSelectedHostId(null);
setStatusFilter('all');
setGroupingMode('grouped');
}}
searchInputRef={(el) => {
searchInputRef = el;
}}
updateAvailableCount={updateableContainers().length}
onUpdateAll={handleUpdateAll}
onCheckUpdates={handleCheckUpdates}
activeHostId={selectedHostId()}
checkingUpdatesStatus={selectedHostCommandStatus()}
/>
);
return (
<div class="space-y-0">
<Show when={isLoading()}>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
title={reconnecting() ? 'Reconnecting to container agents...' : 'Loading container data...'}
description={
reconnecting()
? 'Re-establishing metrics from the monitoring service.'
: connected()
? 'Waiting for the first container update.'
: 'Connecting to the monitoring service.'
}
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected() && !isLoading()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg
class="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
title="Connection lost"
description={
reconnecting()
? 'Attempting to reconnect…'
: 'Unable to connect to the backend server'
}
tone="danger"
actions={
!reconnecting() ? (
<button
onClick={() => reconnect()}
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Reconnect now
</button>
) : undefined
}
/>
</Card>
</Show>
<Show when={!isLoading()}>
<Show
when={sortedHosts().length > 0}
fallback={
<>
<Show when={!kioskMode()}>{renderFilter()}</Show>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title="No container runtimes reporting"
description="Deploy the Pulse container agent (Docker or Podman) on at least one host to light up this tab. As soon as an agent reports in, runtime metrics appear automatically."
actions={
<button
type="button"
onClick={() => navigate('/settings/docker')}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<span>Set up container agent</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
}
/>
</Card>
</>
}
>
<Show when={hostSummaries().length > 0}>
<DockerHostSummaryTable
summaries={filteredHostSummaries}
selectedHostId={selectedHostId}
onSelect={handleHostSelect}
/>
</Show>
<Show when={!kioskMode()}>{renderFilter()}</Show>
<Show
when={groupingMode() === 'cluster'}
fallback={
<DockerUnifiedTable
hosts={sortedHosts()}
searchTerm={debouncedSearch()}
statsFilter={statsFilter()}
selectedHostId={selectedHostId}
batchUpdateState={batchUpdateState}
groupingMode={groupingMode() === 'flat' ? 'flat' : 'grouped'}
/>
}
>
<DockerClusterServicesTable
hosts={sortedHosts()}
searchTerm={debouncedSearch()}
/>
</Show>
</Show>
</Show>
</div>
);
};

View File

@@ -1,38 +0,0 @@
import type { JSX } from 'solid-js';
const badgeClasses = (
status: string,
healthyClass: string,
warningClass: string,
dangerClass: string,
) => {
switch (status.toLowerCase()) {
case 'online':
case 'running':
case 'healthy':
return healthyClass;
case 'offline':
case 'exited':
case 'failed':
case 'unhealthy':
return dangerClass;
default:
return warningClass;
}
};
export const renderDockerStatusBadge = (status?: string): JSX.Element => {
const value = (status || 'unknown').toLowerCase();
return (
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${badgeClasses(
value,
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
)}`}
>
{status || 'unknown'}
</span>
);
};

View File

@@ -1,370 +0,0 @@
import type { Component } from 'solid-js';
import { Show, createMemo } from 'solid-js';
import type { DockerHost } from '@/types/api';
interface StatCardProps {
label: string;
value: number | string;
sublabel?: string;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
onClick?: () => void;
isActive?: boolean;
}
const StatCard: Component<StatCardProps> = (props) => {
const baseClass = 'flex flex-col gap-1 px-4 py-3 rounded-lg border transition-all duration-200';
const variantClass = () => {
if (props.isActive) {
return 'bg-blue-100 dark:bg-blue-900/40 border-blue-300 dark:border-blue-700 ring-2 ring-blue-500/20';
}
switch (props.variant) {
case 'success':
return 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800';
case 'warning':
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
case 'error':
return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
case 'info':
return 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800';
default:
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
};
const hoverClass = () => props.onClick ? 'cursor-pointer hover:shadow-md hover:scale-[1.02]' : '';
return (
<button
type="button"
class={`${baseClass} ${variantClass()} ${hoverClass()}`}
onClick={props.onClick}
disabled={!props.onClick}
>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{props.value}
</div>
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
{props.label}
</div>
<Show when={props.sublabel}>
<div class="text-xs text-gray-500 dark:text-gray-500">
{props.sublabel}
</div>
</Show>
</button>
);
};
export interface DockerSummaryStats {
hosts: {
total: number;
online: number;
degraded: number;
offline: number;
};
containers: {
total: number;
running: number;
degraded: number;
stopped: number;
error: number;
};
services?: {
total: number;
healthy: number;
degraded: number;
};
resources?: {
avgCpu: number | null;
avgMemory: number | null;
};
}
type SummaryFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null;
interface DockerSummaryStatsProps {
hosts: DockerHost[];
onFilterChange?: (filter: SummaryFilter) => void;
activeFilter?: { type: string; value: string } | null;
}
export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props) => {
const stats = (): DockerSummaryStats => {
const hosts = props.hosts || [];
// Host stats
let onlineHosts = 0;
let degradedHosts = 0;
let offlineHosts = 0;
hosts.forEach((host) => {
const status = host.status?.toLowerCase() ?? 'unknown';
switch (status) {
case 'online':
onlineHosts++;
break;
case 'degraded':
case 'warning':
case 'maintenance':
degradedHosts++;
break;
case 'offline':
case 'error':
case 'unreachable':
offlineHosts++;
break;
default:
degradedHosts++;
break;
}
});
// Container stats
let totalContainers = 0;
let runningContainers = 0;
let degradedContainers = 0;
let stoppedContainers = 0;
let errorContainers = 0;
// Service stats
let totalServices = 0;
let healthyServices = 0;
let degradedServices = 0;
// Resource stats
let totalCpu = 0;
let totalMemory = 0;
let cpuSamples = 0;
let memorySamples = 0;
hosts.forEach(host => {
// Count containers
const containers = host.containers || [];
totalContainers += containers.length;
containers.forEach(container => {
const state = container.state?.toLowerCase();
const health = container.health?.toLowerCase();
if (state === 'running') {
if (health === 'unhealthy') {
degradedContainers++;
} else {
runningContainers++;
}
// Sum CPU/Memory for running containers
if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) {
totalCpu += container.cpuPercent;
cpuSamples++;
}
if (typeof container.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent)) {
totalMemory += container.memoryPercent;
memorySamples++;
}
} else if (state === 'exited' || state === 'stopped' || state === 'created' || state === 'paused') {
stoppedContainers++;
} else if (state === 'restarting' || state === 'dead' || state === 'removing' || state === 'error' || state === 'failed') {
errorContainers++;
}
});
// Count services
const services = host.services || [];
totalServices += services.length;
services.forEach(service => {
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
if (desired > 0 && running >= desired) {
healthyServices++;
} else if (desired > 0) {
degradedServices++;
}
});
});
return {
hosts: {
total: hosts.length,
online: onlineHosts,
degraded: degradedHosts,
offline: offlineHosts,
},
containers: {
total: totalContainers,
running: runningContainers,
degraded: degradedContainers,
stopped: stoppedContainers,
error: errorContainers,
},
services: totalServices > 0 ? {
total: totalServices,
healthy: healthyServices,
degraded: degradedServices,
} : undefined,
resources: (cpuSamples > 0 || memorySamples > 0) ? {
avgCpu: cpuSamples > 0 ? totalCpu / cpuSamples : null,
avgMemory: memorySamples > 0 ? totalMemory / memorySamples : null,
} : undefined,
};
};
const summary = createMemo(stats);
const hostSublabel = () => {
const hosts = summary().hosts;
const parts = [`${hosts.online} online`];
if (hosts.degraded > 0) {
parts.push(`${hosts.degraded} degraded`);
}
if (hosts.offline > 0) {
parts.push(`${hosts.offline} offline`);
}
return parts.join(', ');
};
const servicesSublabel = () => {
const services = summary().services;
if (!services) return '';
const parts = [`${services.healthy} healthy`];
if (services.degraded > 0) {
parts.push(`${services.degraded} degraded`);
}
return parts.join(', ');
};
const resourceValue = () => {
const avgCpu = summary().resources?.avgCpu ?? null;
return avgCpu !== null ? Math.round(avgCpu) : '—';
};
const resourceSublabel = () => {
const avgMemory = summary().resources?.avgMemory ?? null;
if (avgMemory === null) return undefined;
return `${Math.round(avgMemory)}% mem`;
};
const resourceVariant = (): StatCardProps['variant'] => {
const avgCpu = summary().resources?.avgCpu ?? null;
if (avgCpu === null) return 'info';
if (avgCpu > 80) return 'error';
if (avgCpu > 60) return 'warning';
return 'info';
};
const isActive = (type: string, value: string) => {
return props.activeFilter?.type === type && props.activeFilter?.value === value;
};
return (
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Overview
</h2>
<Show when={props.activeFilter}>
<button
type="button"
onClick={() => props.onFilterChange?.(null)}
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Clear filter
</button>
</Show>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{/* Hosts */}
<StatCard
label="Total Hosts"
value={summary().hosts.total}
sublabel={hostSublabel()}
variant="default"
/>
<Show when={summary().hosts.degraded > 0}>
<StatCard
label="Degraded Hosts"
value={summary().hosts.degraded}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'host-status', value: 'degraded' })}
isActive={isActive('host-status', 'degraded')}
/>
</Show>
<Show when={summary().hosts.offline > 0}>
<StatCard
label="Offline Hosts"
value={summary().hosts.offline}
variant="error"
onClick={() => props.onFilterChange?.({ type: 'host-status', value: 'offline' })}
isActive={isActive('host-status', 'offline')}
/>
</Show>
{/* Containers */}
<StatCard
label="Running"
value={summary().containers.running}
sublabel={`of ${summary().containers.total} containers`}
variant="success"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'running' })}
isActive={isActive('container-state', 'running')}
/>
<Show when={summary().containers.degraded > 0}>
<StatCard
label="Degraded"
value={summary().containers.degraded}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'degraded' })}
isActive={isActive('container-state', 'degraded')}
/>
</Show>
<Show when={summary().containers.stopped > 0}>
<StatCard
label="Stopped"
value={summary().containers.stopped}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'stopped' })}
isActive={isActive('container-state', 'stopped')}
/>
</Show>
<Show when={summary().containers.error > 0}>
<StatCard
label="Error"
value={summary().containers.error}
variant="error"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'error' })}
isActive={isActive('container-state', 'error')}
/>
</Show>
{/* Services */}
<Show when={summary().services}>
<StatCard
label="Services"
value={summary().services!.total}
sublabel={servicesSublabel()}
variant={summary().services!.degraded > 0 ? 'warning' : 'success'}
/>
</Show>
{/* Resources */}
<Show when={summary().resources}>
<StatCard
label="Avg CPU"
value={resourceValue()}
sublabel={resourceSublabel()}
variant={resourceVariant()}
/>
</Show>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,376 +0,0 @@
import { Component, Show, createSignal, createEffect, createMemo } from 'solid-js';
import type { DockerContainerUpdateStatus } from '@/types/api';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import { MonitoringAPI } from '@/api/monitoring';
import {
getContainerUpdateState,
markContainerQueued,
markContainerUpdateSuccess,
markContainerUpdateError,
clearContainerUpdateState,
updateStates
} from '@/stores/containerUpdates';
import { shouldHideDockerUpdateActions, areSystemSettingsLoaded } from '@/stores/systemSettings';
interface UpdateBadgeProps {
updateStatus?: DockerContainerUpdateStatus;
compact?: boolean;
}
/**
* UpdateBadge displays a visual indicator when a container image has an update available.
* Uses a blue color scheme to differentiate from health/status badges.
*/
export const UpdateBadge: Component<UpdateBadgeProps> = (props) => {
const hasUpdate = () => props.updateStatus?.updateAvailable === true;
const hasError = () => Boolean(props.updateStatus?.error);
return (
<Show when={hasUpdate() || hasError()}>
<Show
when={hasUpdate()}
fallback={
// Show subtle error indicator if check failed
<Show when={hasError()}>
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(`Update check failed: ${props.updateStatus?.error}`, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up'
});
}}
onMouseLeave={() => hideTooltip()}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<Show when={!props.compact}>
<span>Check failed</span>
</Show>
</span>
</Show>
}
>
<span
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const current = props.updateStatus?.currentDigest?.slice(0, 19) || 'unknown';
const latest = props.updateStatus?.latestDigest?.slice(0, 19) || 'unknown';
const content = `Image update available\nCurrent: ${current}...\nLatest: ${latest}...`;
showTooltip(content, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up'
});
}}
onMouseLeave={() => hideTooltip()}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<Show when={!props.compact}>
<span>Update</span>
</Show>
</span>
</Show>
</Show>
);
};
/**
* Compact version of UpdateBadge - just an icon with no text.
* Use this in table cells where space is limited.
*/
export const UpdateIcon: Component<{ updateStatus?: DockerContainerUpdateStatus }> = (props) => {
const hasUpdate = () => props.updateStatus?.updateAvailable === true;
const getTooltip = () => {
if (!props.updateStatus) return 'Image update available';
const current = props.updateStatus.currentDigest?.slice(0, 12) || 'unknown';
const latest = props.updateStatus.latestDigest?.slice(0, 12) || 'unknown';
return `Update available\nCurrent: ${current}...\nLatest: ${latest}...`;
};
return (
<Show when={hasUpdate()}>
<span
class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(getTooltip(), rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up'
});
}}
onMouseLeave={() => hideTooltip()}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</span>
</Show>
);
};
interface UpdateButtonProps {
updateStatus?: DockerContainerUpdateStatus;
hostId: string;
containerId: string;
containerName: string;
compact?: boolean;
onUpdateTriggered?: () => void;
externalState?: 'updating' | 'queued' | 'error';
}
type UpdateState = 'idle' | 'confirming' | 'updating' | 'success' | 'error';
/**
* UpdateButton displays a clickable button to trigger container updates.
* Uses a persistent store to maintain state across WebSocket refreshes.
*
* If the server has disabled Docker update actions (via PULSE_DISABLE_DOCKER_UPDATE_ACTIONS
* or the Settings UI), this component will render a read-only UpdateBadge instead,
* allowing users to see that updates are available without being able to trigger them.
*
* While system settings are loading, the button displays in a disabled/loading state
* to prevent premature clicks before the server configuration is known.
*/
export const UpdateButton: Component<UpdateButtonProps> = (props) => {
const [localState, setLocalState] = createSignal<'idle' | 'confirming'>('idle');
const [errorMessage, setErrorMessage] = createSignal<string>('');
// Reactive check for whether settings are loaded and what they say
const settingsLoaded = () => areSystemSettingsLoaded();
const shouldHideButton = () => shouldHideDockerUpdateActions();
// Get persistent state from store - this survives WebSocket updates
const storeState = createMemo(() => {
// Access updateStates() to create reactive dependency
updateStates();
return getContainerUpdateState(props.hostId, props.containerId);
});
// Derived state: check store first, then external prop, then local state
const currentState = (): UpdateState => {
const stored = storeState();
if (stored) {
switch (stored.state) {
case 'queued':
case 'updating':
return 'updating';
case 'success':
return 'success';
case 'error':
return 'error';
}
}
if (props.externalState === 'updating') return 'updating';
if (props.externalState === 'queued') return 'updating';
if (props.externalState === 'error') return 'error';
return localState();
};
// Watch for update completion - when updateAvailable becomes false, the update succeeded
createEffect(() => {
const stored = storeState();
if (stored && (stored.state === 'queued' || stored.state === 'updating')) {
// If the container no longer has an update available, the update succeeded!
if (props.updateStatus?.updateAvailable === false) {
markContainerUpdateSuccess(props.hostId, props.containerId);
}
}
});
const hasUpdate = () => props.updateStatus?.updateAvailable === true || currentState() !== 'idle';
const handleClick = async (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const state = currentState();
// Prevent clicking if already updating
if (state === 'updating' || state === 'success' || state === 'error') return;
if (state === 'idle') {
// Show confirmation
setLocalState('confirming');
return;
}
if (state === 'confirming') {
// User confirmed, trigger update
// Immediately set store state so it persists
markContainerQueued(props.hostId, props.containerId);
setLocalState('idle'); // Reset local state
try {
await MonitoringAPI.updateDockerContainer(
props.hostId,
props.containerId,
props.containerName
);
// Command queued successfully - store already has 'queued' state
// The effect above will detect when updateAvailable becomes false
props.onUpdateTriggered?.();
} catch (err) {
const message = (err as Error).message || 'Failed to trigger update';
setErrorMessage(message);
markContainerUpdateError(props.hostId, props.containerId, message);
}
}
};
const handleCancel = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setLocalState('idle');
// Also clear any store state if canceling
clearContainerUpdateState(props.hostId, props.containerId);
};
const getButtonClass = () => {
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-all';
switch (currentState()) {
case 'confirming':
return `${base} bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900/60`;
case 'updating':
return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 cursor-wait`;
case 'success':
return `${base} bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300`;
case 'error':
return `${base} bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 cursor-help`;
default:
return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60`;
}
};
const getTooltip = () => {
const stored = storeState();
switch (currentState()) {
case 'confirming':
return 'Click again to confirm update';
case 'updating': {
const elapsed = stored ? Math.round((Date.now() - stored.startedAt) / 1000) : 0;
// Show the current step if available from backend
const step = stored?.message || 'Processing...';
if (elapsed > 60) {
return `${step} (${Math.floor(elapsed / 60)}m ${elapsed % 60}s)`;
}
return `${step} (${elapsed}s)`;
}
case 'success':
return '✓ Update completed successfully!';
case 'error':
return `✗ Update failed: ${stored?.message || errorMessage() || 'Unknown error'}`;
default:
if (!props.updateStatus) return 'Update container';
const current = props.updateStatus.currentDigest?.slice(0, 12) || 'unknown';
const latest = props.updateStatus.latestDigest?.slice(0, 12) || 'unknown';
return `Click to update\nCurrent: ${current}...\nLatest: ${latest}...`;
}
};
// Compute if the button should be disabled due to loading or settings
const isButtonDisabled = () => currentState() === 'updating' || !settingsLoaded();
return (
<Show when={hasUpdate()}>
{/* Case 1: Settings loaded and updates are disabled - show read-only badge */}
<Show when={settingsLoaded() && shouldHideButton()}>
<UpdateBadge updateStatus={props.updateStatus} compact={props.compact} />
</Show>
{/* Case 2: Settings loading OR settings loaded with updates enabled - show button */}
<Show when={!settingsLoaded() || !shouldHideButton()}>
<div class="inline-flex items-center gap-1" data-prevent-toggle>
<button
type="button"
class={getButtonClass()}
onClick={handleClick}
onMouseDown={(e) => { e.stopPropagation(); }}
disabled={isButtonDisabled()}
data-prevent-toggle
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltip = !settingsLoaded()
? 'Loading settings...'
: getTooltip();
showTooltip(tooltip, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up'
});
}}
onMouseLeave={() => hideTooltip()}
>
{/* Loading state - settings haven't loaded yet */}
<Show when={!settingsLoaded()}>
<svg class="w-3 h-3 animate-pulse opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</Show>
{/* Normal states - settings loaded */}
<Show when={settingsLoaded()}>
<Show when={currentState() === 'updating'}>
{/* Spinner */}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</Show>
<Show when={currentState() === 'success'}>
{/* Check icon */}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</Show>
<Show when={currentState() === 'error'}>
{/* X icon */}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</Show>
<Show when={currentState() === 'idle' || currentState() === 'confirming'}>
{/* Upload/update icon */}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</Show>
</Show>
<Show when={!props.compact}>
<span class={!settingsLoaded() ? 'opacity-50' : ''}>
{!settingsLoaded() ? 'Update' :
currentState() === 'confirming' ? 'Confirm?' :
currentState() === 'updating' ? 'Updating...' :
currentState() === 'success' ? 'Queued!' :
currentState() === 'error' ? 'Failed' : 'Update'}
</span>
</Show>
</button>
<Show when={settingsLoaded() && currentState() === 'confirming'}>
<button
type="button"
class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
onClick={handleCancel}
title="Cancel"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</Show>
</Show>
);
};
export default UpdateBadge;

View File

@@ -1,196 +0,0 @@
import type { DockerContainer, DockerHost } from '@/types/api';
export type RuntimeKind = 'docker' | 'podman' | 'containerd' | 'cri-o' | 'nerdctl' | 'unknown';
export interface RuntimeDisplayInfo {
id: RuntimeKind;
label: string;
badgeClass: string;
raw: string;
}
export interface RuntimeDisplayOptions {
hint?: string | null;
defaultId?: Exclude<RuntimeKind, 'unknown'> | 'unknown';
}
const BADGE_CLASSES: Record<Exclude<RuntimeKind, 'unknown'>, string> = {
docker: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
podman: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
containerd: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
'cri-o': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
nerdctl: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
};
const UNKNOWN_BADGE_CLASS = 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
const LABEL_BY_KIND: Record<RuntimeKind, string> = {
docker: 'Docker',
podman: 'Podman',
containerd: 'containerd',
'cri-o': 'CRI-O',
nerdctl: 'nerdctl',
unknown: 'Container runtime',
};
const normalize = (value?: string | null) => value?.trim() ?? '';
const MATCHERS: Array<{ id: Exclude<RuntimeKind, 'unknown'>; keywords: string[] }> = [
{ id: 'podman', keywords: ['podman', 'libpod'] },
{ id: 'nerdctl', keywords: ['nerdctl'] },
{ id: 'containerd', keywords: ['containerd'] },
{ id: 'cri-o', keywords: ['cri-o', 'crio'] },
{ id: 'docker', keywords: ['docker', 'moby engine', 'moby'] },
];
const detectRuntime = (value?: string | null) => {
const raw = normalize(value);
if (!raw) {
return null;
}
const normalized = raw.toLowerCase();
for (const matcher of MATCHERS) {
if (matcher.keywords.some((keyword) => normalized.includes(keyword))) {
return { id: matcher.id, raw };
}
}
return null;
};
const formatRuntimeLabel = (value: string): string => {
if (!value) return LABEL_BY_KIND.unknown;
const cleaned = value.replace(/\s+/g, ' ').trim();
if (/^cri-?o$/i.test(cleaned)) {
return LABEL_BY_KIND['cri-o'];
}
return cleaned
.split(/[-_\s]+/)
.filter(Boolean)
.map((segment) => {
if (segment.toLowerCase() === 'cri' || segment.toLowerCase() === 'crio') {
return 'CRI';
}
if (segment.toLowerCase() === 'o') {
return 'O';
}
if (segment === segment.toUpperCase()) {
return segment;
}
return segment.charAt(0).toUpperCase() + segment.slice(1);
})
.join(' ');
};
export const getRuntimeDisplay = (
runtime?: string | null,
options: RuntimeDisplayOptions = {},
): RuntimeDisplayInfo => {
const candidates = [
{ value: runtime, raw: normalize(runtime) },
{ value: options.hint, raw: normalize(options.hint) },
];
for (const candidate of candidates) {
const detected = detectRuntime(candidate.value);
if (detected) {
const label = LABEL_BY_KIND[detected.id];
const badgeClass = BADGE_CLASSES[detected.id];
return {
id: detected.id,
label,
badgeClass,
raw: detected.raw,
};
}
}
const firstRaw = candidates.find((candidate) => candidate.raw !== '')?.raw ?? '';
const defaultKind: RuntimeKind = options.defaultId ?? 'unknown';
if (defaultKind !== 'unknown') {
return {
id: defaultKind,
label: LABEL_BY_KIND[defaultKind],
badgeClass: BADGE_CLASSES[defaultKind],
raw: firstRaw,
};
}
const label = firstRaw ? formatRuntimeLabel(firstRaw) : LABEL_BY_KIND.unknown;
return {
id: 'unknown',
label,
badgeClass: UNKNOWN_BADGE_CLASS,
raw: firstRaw,
};
};
const PODMAN_LABEL_PREFIXES = ['io.podman.', 'io.containers.', 'net.containers.podman'];
const hasPodmanSignature = (container?: DockerContainer | null) => {
if (!container?.labels) return false;
const labels = container.labels;
return Object.keys(labels).some((key) =>
PODMAN_LABEL_PREFIXES.some((prefix) => key.startsWith(prefix)),
) || Object.values(labels).some((value) => value?.toLowerCase().includes('podman'));
};
const hostHasPodmanSignature = (host?: Pick<DockerHost, 'containers'> | null) => {
if (!host?.containers || host.containers.length === 0) return false;
return host.containers.some((container) => hasPodmanSignature(container));
};
export interface ResolveRuntimeOptions extends RuntimeDisplayOptions {
requireSignatureForGuess?: boolean;
}
const stripVersion = (value?: string | null) => {
if (!value) return '';
const match = value.match(/\d+(?:\.\d+){0,2}/);
return match ? match[0] : value.trim();
};
const isLikelyPodmanVersion = (value?: string | null) => {
const clean = stripVersion(value);
if (!clean) return false;
const parts = clean.split('.');
const major = Number.parseInt(parts[0] || '', 10);
if (!Number.isFinite(major)) return false;
return major >= 2 && major <= 6;
};
export const resolveHostRuntime = (
host: Pick<DockerHost, 'runtime' | 'runtimeVersion' | 'dockerVersion' | 'containers'>,
options: ResolveRuntimeOptions = {},
): RuntimeDisplayInfo => {
const hint = options.hint ?? host.runtimeVersion ?? host.dockerVersion ?? null;
const base = getRuntimeDisplay(host.runtime, {
...options,
hint,
defaultId: options.defaultId ?? 'docker',
});
if (base.id !== 'docker' && base.id !== 'unknown') {
return base;
}
const hasSignature = hostHasPodmanSignature(host);
if (hasSignature) {
return getRuntimeDisplay('podman', { hint });
}
if (!options.requireSignatureForGuess && isLikelyPodmanVersion(hint)) {
return getRuntimeDisplay('podman', { hint });
}
if (base.id === 'unknown') {
return getRuntimeDisplay('docker', { hint });
}
return base;
};

View File

@@ -1,197 +0,0 @@
import type { DockerHost, DockerService } from '@/types/api';
/**
* Represents a Docker Swarm cluster with its member hosts
*/
export interface SwarmCluster {
clusterId: string;
clusterName: string;
hosts: DockerHost[];
}
/**
* Represents an aggregated service across a Swarm cluster
*/
export interface ClusterService {
service: DockerService;
clusterId: string;
clusterName: string;
nodes: Array<{
hostId: string;
hostname: string;
taskCount: number;
runningCount: number;
}>;
totalDesired: number;
totalRunning: number;
}
/**
* Groups Docker hosts by their Swarm cluster ID.
* Hosts without a clusterId are returned as individual "clusters" with empty clusterId.
*/
export function groupHostsByCluster(hosts: DockerHost[]): SwarmCluster[] {
const clusterMap = new Map<string, SwarmCluster>();
const standaloneHosts: DockerHost[] = [];
for (const host of hosts) {
const clusterId = host.swarm?.clusterId;
if (!clusterId) {
// Non-Swarm host or no cluster info
standaloneHosts.push(host);
continue;
}
if (!clusterMap.has(clusterId)) {
clusterMap.set(clusterId, {
clusterId,
clusterName: host.swarm?.clusterName || clusterId.slice(0, 12),
hosts: [],
});
}
clusterMap.get(clusterId)!.hosts.push(host);
}
// Sort hosts within each cluster by hostname
for (const cluster of clusterMap.values()) {
cluster.hosts.sort((a, b) => {
const aName = a.customDisplayName || a.displayName || a.hostname || '';
const bName = b.customDisplayName || b.displayName || b.hostname || '';
return aName.localeCompare(bName);
});
}
// Convert to array and sort by cluster name
const clusters = Array.from(clusterMap.values()).sort((a, b) =>
a.clusterName.localeCompare(b.clusterName)
);
return clusters;
}
/**
* Aggregates services across all hosts in a cluster, deduplicating by service ID.
* Returns services with node distribution information.
*/
export function aggregateClusterServices(cluster: SwarmCluster): ClusterService[] {
const serviceMap = new Map<string, ClusterService>();
for (const host of cluster.hosts) {
const services = host.services || [];
const tasks = host.tasks || [];
for (const service of services) {
const serviceId = service.id;
if (!serviceMap.has(serviceId)) {
serviceMap.set(serviceId, {
service: { ...service },
clusterId: cluster.clusterId,
clusterName: cluster.clusterName,
nodes: [],
totalDesired: service.desiredTasks || 0,
totalRunning: 0,
});
}
const aggregated = serviceMap.get(serviceId)!;
// Count tasks for this service on this host
const serviceTasks = tasks.filter(
(t) => t.serviceId === serviceId || t.serviceName === service.name
);
const runningTasks = serviceTasks.filter(
(t) => t.currentState?.toLowerCase() === 'running'
);
if (serviceTasks.length > 0) {
aggregated.nodes.push({
hostId: host.id,
hostname: host.customDisplayName || host.displayName || host.hostname,
taskCount: serviceTasks.length,
runningCount: runningTasks.length,
});
aggregated.totalRunning += runningTasks.length;
}
// Update desired count (take max in case of inconsistencies)
aggregated.totalDesired = Math.max(
aggregated.totalDesired,
service.desiredTasks || 0
);
}
}
// Sort services by stack then name
return Array.from(serviceMap.values()).sort((a, b) => {
const stackA = a.service.stack || '';
const stackB = b.service.stack || '';
if (stackA !== stackB) {
return stackA.localeCompare(stackB);
}
return (a.service.name || '').localeCompare(b.service.name || '');
});
}
/**
* Checks if there are any Swarm clusters with multiple hosts.
* Used to determine whether to show the cluster view toggle.
*/
export function hasSwarmClusters(hosts: DockerHost[]): boolean {
const clusterIds = new Set<string>();
for (const host of hosts) {
const clusterId = host.swarm?.clusterId;
if (clusterId) {
if (clusterIds.has(clusterId)) {
// Found at least 2 hosts in the same cluster
return true;
}
clusterIds.add(clusterId);
}
}
return false;
}
/**
* Gets a display-friendly node list string for a service.
* Example: "node1 (2), node2 (1), node3 (1)"
*/
export function formatNodeDistribution(
nodes: ClusterService['nodes'],
maxNodes: number = 3
): string {
if (nodes.length === 0) return 'No nodes';
const sorted = [...nodes].sort((a, b) => b.taskCount - a.taskCount);
const display = sorted.slice(0, maxNodes);
const remaining = sorted.length - maxNodes;
const parts = display.map((n) => {
if (n.taskCount === 1) return n.hostname;
return `${n.hostname} (${n.taskCount})`;
});
if (remaining > 0) {
parts.push(`+${remaining} more`);
}
return parts.join(', ');
}
/**
* Gets the overall health status for a cluster service.
*/
export function getServiceHealthStatus(
service: ClusterService
): 'healthy' | 'degraded' | 'critical' {
const { totalDesired, totalRunning } = service;
if (totalDesired === 0) return 'healthy';
if (totalRunning === 0) return 'critical';
if (totalRunning < totalDesired) return 'degraded';
return 'healthy';
}

View File

@@ -1,263 +0,0 @@
import { Component, Show, Suspense, createSignal } from 'solid-js';
import { Host } from '@/types/api';
import { HistoryChart } from '../shared/HistoryChart';
import { ResourceType, HistoryTimeRange } from '@/api/charts';
import { hasFeature } from '@/stores/license';
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard';
import { HardwareCard } from '@/components/shared/cards/HardwareCard';
import { NetworkInterfacesCard } from '@/components/shared/cards/NetworkInterfacesCard';
import { DisksCard } from '@/components/shared/cards/DisksCard';
import { TemperaturesCard } from '@/components/shared/cards/TemperaturesCard';
import { formatTemperature } from '@/utils/temperature';
interface HostDrawerProps {
host: Host;
onClose: () => void;
customUrl?: string;
onCustomUrlChange?: (hostId: string, url: string) => void;
}
export const HostDrawer: Component<HostDrawerProps> = (props) => {
const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview');
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>('1h');
// For unified host agents, the backend stores metrics with resourceType 'host'
const metricsResource = { type: 'host' as ResourceType, id: props.host.id };
const switchTab = (tab: 'overview' | 'discovery') => {
setActiveTab(tab);
};
const isHistoryLocked = () => !hasFeature('long_term_metrics') && (historyRange() === '30d' || historyRange() === '90d');
const formatSensorName = (name: string) => {
let clean = name.replace(/^[a-z]+\d*_/i, '');
clean = clean.replace(/_/g, ' ');
return clean.replace(/\b\w/g, c => c.toUpperCase());
};
const temperatureRows = () => {
const rows: { label: string; value: string; valueTitle?: string }[] = [];
const temps = props.host.sensors?.temperatureCelsius;
if (temps) {
const entries = Object.entries(temps).sort(([a], [b]) => a.localeCompare(b));
entries.forEach(([name, temp]) => {
rows.push({
label: formatSensorName(name),
value: formatTemperature(temp),
valueTitle: `${temp.toFixed(1)}°C`,
});
});
}
const smart = props.host.sensors?.smart;
if (smart) {
smart
.filter(disk => !disk.standby && Number.isFinite(disk.temperature))
.sort((a, b) => a.device.localeCompare(b.device))
.forEach(disk => {
rows.push({
label: `Disk ${disk.device}`,
value: formatTemperature(disk.temperature),
valueTitle: `${disk.temperature.toFixed(1)}°C`,
});
});
}
return rows;
};
return (
<div class="space-y-3">
{/* Tabs */}
<div class="flex items-center gap-6 border-b border-gray-200 dark:border-gray-700 px-1 mb-1">
<button
onClick={() => switchTab('overview')}
class={`pb-2 text-sm font-medium transition-colors relative ${activeTab() === 'overview'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Overview
{activeTab() === 'overview' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
<button
onClick={() => switchTab('discovery')}
class={`pb-2 text-sm font-medium transition-colors relative ${activeTab() === 'discovery'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Discovery
{activeTab() === 'discovery' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
</div>
{/* Overview Tab */}
<div class={activeTab() === 'overview' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
<SystemInfoCard variant="host" host={props.host} />
<HardwareCard variant="host" host={props.host} />
<NetworkInterfacesCard interfaces={props.host.networkInterfaces} />
<DisksCard disks={props.host.disks} />
<TemperaturesCard rows={temperatureRows()} />
</div>
{/* Performance Charts */}
<div class="mt-3 space-y-3">
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path stroke-linecap="round" d="M12 6v6l4 2" />
</svg>
<select
value={historyRange()}
onChange={(e) => setHistoryRange(e.currentTarget.value as HistoryTimeRange)}
class="text-[11px] font-medium pl-2 pr-6 py-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 cursor-pointer focus:ring-1 focus:ring-blue-500 focus:border-blue-500 appearance-none"
style={{ "background-image": "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")", "background-repeat": "no-repeat", "background-position": "right 6px center" }}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="12h">Last 12 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div class="relative">
<div class="space-y-3">
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(33.333%-0.5rem)] [&>*]:min-w-[250px]">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource.type}
resourceId={metricsResource.id}
metric="cpu"
height={120}
color="#8b5cf6"
label="CPU"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource.type}
resourceId={metricsResource.id}
metric="memory"
height={120}
color="#f59e0b"
label="Memory"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource.type}
resourceId={metricsResource.id}
metric="disk"
height={120}
color="#10b981"
label="Disk"
unit="%"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
</div>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(50%-0.375rem)] [&>*]:min-w-[250px]">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource.type}
resourceId={metricsResource.id}
metric="netin"
height={120}
color="#3b82f6"
label="Net In"
unit="B/s"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<HistoryChart
resourceType={metricsResource.type}
resourceId={metricsResource.id}
metric="netout"
height={120}
color="#6366f1"
label="Net Out"
unit="B/s"
range={historyRange()}
hideSelector={true}
compact={true}
hideLock={true}
/>
</div>
</div>
</div>
{/* Lock Overlay */}
<Show when={isHistoryLocked()}>
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-gray-900/60 rounded-lg">
<div class="bg-indigo-500 rounded-full p-3 shadow-lg mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">{historyRange() === '30d' ? '30' : '90'}-Day History</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 text-center max-w-[260px] mb-4">
Upgrade to Pulse Pro to unlock {historyRange() === '30d' ? '30' : '90'} days of historical data retention.
</p>
<a
href="https://pulserelay.pro/pricing"
target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors"
>
Unlock Pro Features
</a>
</div>
</Show>
</div>
</div>
</div>
{/* Discovery Tab */}
<div class={activeTab() === 'discovery' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<Suspense fallback={
<div class="flex items-center justify-center py-8">
<div class="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading discovery...</span>
</div>
}>
<DiscoveryTab
resourceType="host"
hostId={props.host.id}
resourceId={props.host.id} /* For hosts, typically same as hostId */
hostname={props.host.hostname}
guestId={props.host.id}
customUrl={props.customUrl}
onCustomUrlChange={(url) => props.onCustomUrlChange?.(props.host.id, url)}
commandsEnabled={props.host.commandsEnabled}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -1,416 +0,0 @@
import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
interface HostsFilterProps {
search: () => string;
setSearch: (value: string) => void;
statusFilter: () => 'all' | 'online' | 'degraded' | 'offline';
setStatusFilter: (value: 'all' | 'online' | 'degraded' | 'offline') => void;
searchInputRef?: (el: HTMLInputElement) => void;
onReset?: () => void;
activeHostName?: string;
onClearHost?: () => void;
// Column visibility
availableColumns?: ColumnDef[];
isColumnHidden?: (id: string) => boolean;
onColumnToggle?: (id: string) => void;
onColumnReset?: () => void;
}
export const HostsFilter: Component<HostsFilterProps> = (props) => {
const historyManager = createSearchHistoryManager(STORAGE_KEYS.HOSTS_SEARCH_HISTORY);
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = createSignal(false);
let searchInputEl: HTMLInputElement | undefined;
let historyMenuRef: HTMLDivElement | undefined;
let historyToggleRef: HTMLButtonElement | undefined;
onMount(() => {
setSearchHistory(historyManager.read());
});
const commitSearchToHistory = (term: string) => {
const trimmed = term.trim();
if (!trimmed) return;
const updated = historyManager.add(trimmed);
setSearchHistory(updated);
};
const deleteHistoryEntry = (term: string) => {
setSearchHistory(historyManager.remove(term));
};
const clearHistory = () => {
setSearchHistory(historyManager.clear());
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const closeHistory = () => {
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as Node;
const clickedMenu = historyMenuRef?.contains(target) ?? false;
const clickedToggle = historyToggleRef?.contains(target) ?? false;
if (!clickedMenu && !clickedToggle) {
closeHistory();
}
};
createEffect(() => {
if (isHistoryOpen()) {
document.addEventListener('mousedown', handleDocumentClick);
} else {
document.removeEventListener('mousedown', handleDocumentClick);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
let suppressBlurCommit = false;
const markSuppressCommit = () => {
suppressBlurCommit = true;
queueMicrotask(() => {
suppressBlurCommit = false;
});
};
const hasActiveFilters = createMemo(
() =>
props.search().trim() !== '' ||
Boolean(props.activeHostName) ||
props.statusFilter() !== 'all',
);
const handleReset = () => {
props.setSearch('');
props.setStatusFilter('all');
props.onClearHost?.();
props.onReset?.();
closeHistory();
focusSearchInput();
};
return (
<Card class="hosts-filter mb-3" padding="sm">
<div class="flex flex-col gap-3">
{/* Search - full width on its own row */}
<div class="relative">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search hosts by hostname, platform, or OS..."
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'Escape') {
props.setSearch('');
closeHistory();
e.currentTarget.blur();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'hosts-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search hosts by hostname, platform, or OS"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="hosts-search-help"
intro="Filter hosts quickly"
tips={[
{ code: 'hostname', description: 'Match hosts by hostname' },
{ code: 'linux', description: 'Find Linux hosts' },
{ code: 'darwin', description: 'Find macOS hosts' },
{ code: 'windows', description: 'Find Windows hosts' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
{/* Filters - second row */}
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
<button
type="button"
aria-pressed={props.statusFilter() === 'all'}
onClick={() => props.setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show all hosts"
>
All
</button>
<button
type="button"
aria-pressed={props.statusFilter() === 'online'}
onClick={() =>
props.setStatusFilter(props.statusFilter() === 'online' ? 'all' : 'online')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show online hosts only"
>
Online
</button>
<button
type="button"
aria-pressed={props.statusFilter() === 'degraded'}
onClick={() =>
props.setStatusFilter(props.statusFilter() === 'degraded' ? 'all' : 'degraded')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show degraded hosts only"
>
Degraded
</button>
<button
type="button"
aria-pressed={props.statusFilter() === 'offline'}
onClick={() =>
props.setStatusFilter(props.statusFilter() === 'offline' ? 'all' : 'offline')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show offline hosts only"
>
Offline
</button>
</div>
<Show when={props.activeHostName}>
<div class="flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
<span>Host: {props.activeHostName}</span>
<button
type="button"
class="text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-100"
onClick={() => props.onClearHost?.()}
title="Clear host filter"
>
×
</button>
</div>
</Show>
{/* Column Picker */}
<Show when={props.availableColumns && props.isColumnHidden && props.onColumnToggle}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<ColumnPicker
columns={props.availableColumns!}
isHidden={props.isColumnHidden!}
onToggle={props.onColumnToggle!}
onReset={props.onColumnReset}
/>
</Show>
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button
type="button"
onClick={handleReset}
class="flex items-center justify-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
title="Reset filters"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="hidden sm:inline">Reset</span>
</button>
</Show>
</div>
</div>
</Card >
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,478 +0,0 @@
/**
* Tests for RAID status display logic
*
* These tests cover the RAID status analysis and display logic used in HostsOverview.
*/
import { describe, expect, it } from 'vitest';
// Mock types matching HostRAIDArray from api.ts
interface HostRAIDDevice {
device: string;
state: string;
slot: number;
}
interface HostRAIDArray {
device: string;
name?: string;
level: string;
state: string;
totalDevices: number;
activeDevices: number;
workingDevices: number;
failedDevices: number;
spareDevices: number;
uuid?: string;
devices: HostRAIDDevice[];
rebuildPercent: number;
rebuildSpeed?: string;
}
// Status analysis logic matching HostRAIDStatusCell
type RAIDStatusType = 'none' | 'ok' | 'degraded' | 'rebuilding';
interface RAIDStatus {
type: RAIDStatusType;
label: string;
color: string;
}
function analyzeRAIDStatus(raid: HostRAIDArray[] | undefined): RAIDStatus {
if (!raid || raid.length === 0) {
return { type: 'none', label: '-', color: 'text-gray-400' };
}
let hasDegraded = false;
let hasRebuilding = false;
let maxRebuildPercent = 0;
for (const array of raid) {
const state = array.state.toLowerCase();
if (state.includes('degraded') || array.failedDevices > 0) {
hasDegraded = true;
}
if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) {
hasRebuilding = true;
maxRebuildPercent = Math.max(maxRebuildPercent, array.rebuildPercent);
}
}
if (hasDegraded) {
return { type: 'degraded', label: 'Degraded', color: 'text-red-600 dark:text-red-400' };
}
if (hasRebuilding) {
return {
type: 'rebuilding',
label: `${Math.round(maxRebuildPercent)}%`,
color: 'text-amber-600 dark:text-amber-400'
};
}
return { type: 'ok', label: 'OK', color: 'text-green-600 dark:text-green-400' };
}
function getDeviceStateColor(state: string): string {
const s = state.toLowerCase();
if (s.includes('active') || s.includes('sync')) return 'text-green-400';
if (s.includes('spare')) return 'text-blue-400';
if (s.includes('faulty') || s.includes('removed')) return 'text-red-400';
if (s.includes('rebuilding')) return 'text-amber-400';
return 'text-gray-400';
}
function getArrayStateColor(array: HostRAIDArray): string {
const state = array.state.toLowerCase();
if (state.includes('degraded') || array.failedDevices > 0) return 'text-red-400';
if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) return 'text-amber-400';
if (state.includes('clean') || state.includes('active')) return 'text-green-400';
return 'text-gray-400';
}
describe('RAID Status Analysis', () => {
describe('analyzeRAIDStatus', () => {
it('returns none status for undefined raid', () => {
const status = analyzeRAIDStatus(undefined);
expect(status.type).toBe('none');
expect(status.label).toBe('-');
});
it('returns none status for empty raid array', () => {
const status = analyzeRAIDStatus([]);
expect(status.type).toBe('none');
});
it('returns ok status for healthy RAID1', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, active',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'active sync', slot: 1 },
],
rebuildPercent: 0,
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('ok');
expect(status.label).toBe('OK');
expect(status.color).toContain('green');
});
it('returns degraded status when array has failed devices', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, degraded',
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'faulty', slot: 1 },
],
rebuildPercent: 0,
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('degraded');
expect(status.label).toBe('Degraded');
expect(status.color).toContain('red');
});
it('returns degraded status when state includes degraded', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid5',
state: 'degraded, recovering',
totalDevices: 3,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0, // No failed but degraded state
spareDevices: 0,
devices: [],
rebuildPercent: 50,
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('degraded');
});
it('returns rebuilding status with percentage', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 45.5,
rebuildSpeed: '100MB/s',
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('rebuilding');
expect(status.label).toBe('46%'); // Rounded
expect(status.color).toContain('amber');
});
it('returns max rebuild percentage when multiple arrays rebuilding', () => {
const raid: HostRAIDArray[] = [
{
device: '/dev/md0',
level: 'raid1',
state: 'recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 30,
},
{
device: '/dev/md1',
level: 'raid1',
state: 'recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 75,
},
];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('rebuilding');
expect(status.label).toBe('75%');
});
it('prioritizes degraded over rebuilding', () => {
const raid: HostRAIDArray[] = [
{
device: '/dev/md0',
level: 'raid1',
state: 'clean, degraded, recovering',
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1, // Failed device
spareDevices: 0,
devices: [],
rebuildPercent: 50, // Also rebuilding
},
];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('degraded'); // Degraded takes priority
});
it('handles resync state as rebuilding', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, resyncing',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0, // resync state triggers rebuilding even without percent
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('rebuilding');
});
it('handles multiple healthy arrays', () => {
const raid: HostRAIDArray[] = [
{
device: '/dev/md0',
level: 'raid1',
state: 'clean',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
},
{
device: '/dev/md1',
level: 'raid5',
state: 'active',
totalDevices: 3,
activeDevices: 3,
workingDevices: 3,
failedDevices: 0,
spareDevices: 1,
devices: [],
rebuildPercent: 0,
},
];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('ok');
});
});
describe('getDeviceStateColor', () => {
it('returns green for active devices', () => {
expect(getDeviceStateColor('active sync')).toContain('green');
expect(getDeviceStateColor('Active Sync')).toContain('green');
});
it('returns blue for spare devices', () => {
expect(getDeviceStateColor('spare')).toContain('blue');
expect(getDeviceStateColor('hot spare')).toContain('blue');
});
it('returns red for faulty devices', () => {
expect(getDeviceStateColor('faulty')).toContain('red');
expect(getDeviceStateColor('removed')).toContain('red');
});
it('returns amber for rebuilding devices', () => {
expect(getDeviceStateColor('rebuilding')).toContain('amber');
});
it('returns gray for unknown states', () => {
expect(getDeviceStateColor('')).toContain('gray');
expect(getDeviceStateColor('unknown')).toContain('gray');
});
});
describe('getArrayStateColor', () => {
it('returns green for clean/active arrays', () => {
const cleanArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
expect(getArrayStateColor(cleanArray)).toContain('green');
const activeArray: HostRAIDArray = { ...cleanArray, state: 'active' };
expect(getArrayStateColor(activeArray)).toContain('green');
});
it('returns red for degraded arrays', () => {
const degradedArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'degraded',
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
expect(getArrayStateColor(degradedArray)).toContain('red');
});
it('returns red for arrays with failed devices even if state is clean', () => {
const failedDeviceArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean', // State says clean but...
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1, // Has a failed device
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
expect(getArrayStateColor(failedDeviceArray)).toContain('red');
});
it('returns amber for recovering/resyncing arrays', () => {
const recoveringArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 50,
};
expect(getArrayStateColor(recoveringArray)).toContain('amber');
const resyncArray: HostRAIDArray = { ...recoveringArray, state: 'resyncing' };
expect(getArrayStateColor(resyncArray)).toContain('amber');
});
it('returns amber for arrays with rebuild percent > 0', () => {
const rebuildingArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean', // State is clean but...
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 25, // Rebuilding
};
expect(getArrayStateColor(rebuildingArray)).toContain('amber');
});
});
});
describe('RAID Level Support', () => {
const raidLevels = ['raid0', 'raid1', 'raid5', 'raid6', 'raid10'];
it.each(raidLevels)('supports %s level', (level) => {
const array: HostRAIDArray = {
device: '/dev/md0',
level: level,
state: 'clean',
totalDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3,
activeDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3,
workingDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
const status = analyzeRAIDStatus([array]);
expect(status.type).toBe('ok');
});
});
describe('RAID Device Counts', () => {
it('displays correct device counts for RAID1 with spares', () => {
const array: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean',
totalDevices: 3,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 1,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'active sync', slot: 1 },
{ device: 'sdc1', state: 'spare', slot: -1 },
],
rebuildPercent: 0,
};
expect(array.activeDevices).toBe(2);
expect(array.workingDevices).toBe(2);
expect(array.spareDevices).toBe(1);
expect(array.failedDevices).toBe(0);
expect(array.devices).toHaveLength(3);
});
it('displays degraded state for RAID5 missing one device', () => {
const array: HostRAIDArray = {
device: '/dev/md0',
level: 'raid5',
state: 'clean, degraded',
totalDevices: 4,
activeDevices: 3,
workingDevices: 3,
failedDevices: 1,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'active sync', slot: 1 },
{ device: 'sdc1', state: 'active sync', slot: 2 },
{ device: 'sdd1', state: 'removed', slot: 3 },
],
rebuildPercent: 0,
};
const status = analyzeRAIDStatus([array]);
expect(status.type).toBe('degraded');
expect(array.failedDevices).toBe(1);
});
});

View File

@@ -1,7 +1,6 @@
import { Component, Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useWebSocket } from '@/App';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { formatRelativeTime, formatBytes } from '@/utils/format';
@@ -278,8 +277,6 @@ const MailGateway: Component = () => {
return (
<div class="space-y-4">
<ProxmoxSectionNav current="mail" />
{/* Loading State */}
<Show when={isLoading()}>
<Card padding="lg">

View File

@@ -1,95 +0,0 @@
import type { Component } from 'solid-js';
import { createMemo, For } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useWebSocket } from '@/App';
type ProxmoxSection = 'overview' | 'storage' | 'ceph' | 'replication' | 'backups' | 'mail';
interface ProxmoxSectionNavProps {
current: ProxmoxSection;
class?: string;
}
const allSections: Array<{
id: ProxmoxSection;
label: string;
path: string;
}> = [
{
id: 'overview',
label: 'Overview',
path: '/proxmox/overview',
},
{
id: 'storage',
label: 'Storage',
path: '/proxmox/storage',
},
{
id: 'ceph',
label: 'Ceph',
path: '/proxmox/ceph',
},
{
id: 'replication',
label: 'Replication',
path: '/proxmox/replication',
},
{
id: 'mail',
label: 'Mail Gateway',
path: '/proxmox/mail',
},
{
id: 'backups',
label: 'Backups',
path: '/proxmox/backups',
},
];
export const ProxmoxSectionNav: Component<ProxmoxSectionNavProps> = (props) => {
const navigate = useNavigate();
const { state } = useWebSocket();
// Only show tabs if the corresponding feature has data:
// - Mail Gateway: requires PMG instances
// - Ceph: requires Ceph clusters (from agent or Proxmox API)
// - Replication: requires replication jobs
const sections = createMemo(() => {
const hasPMG = state.pmg && state.pmg.length > 0;
const hasCeph = state.cephClusters && state.cephClusters.length > 0;
const hasReplication = state.replicationJobs && state.replicationJobs.length > 0;
return allSections.filter((section) =>
section.id === props.current || (
(section.id !== 'mail' || hasPMG) &&
(section.id !== 'ceph' || hasCeph) &&
(section.id !== 'replication' || hasReplication)
)
);
});
const baseClasses =
'inline-flex items-center px-1.5 sm:px-3 py-1 text-[11px] sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900';
return (
<div class={`flex flex-wrap items-center gap-1.5 sm:gap-4 ${props.class ?? ''}`} aria-label="Proxmox sections">
<For each={sections()}>{(section) => {
const isActive = section.id === props.current;
const classes = isActive
? `${baseClasses} text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400`
: `${baseClasses} hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50`;
return (
<button
type="button"
class={classes}
onClick={() => navigate(section.path)}
aria-pressed={isActive}
>
<span>{section.label}</span>
</button>
);
}}</For>
</div>
);
};

View File

@@ -1,7 +1,6 @@
import type { Component } from 'solid-js';
import { Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
@@ -220,7 +219,6 @@ const Replication: Component = () => {
return (
<div class="space-y-4">
<ProxmoxSectionNav current="replication" />
{/* Loading State */}
<Show when={isLoading()}>

View File

@@ -15,7 +15,6 @@ import { EnhancedStorageBar } from './EnhancedStorageBar';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { getNodeDisplayName } from '@/utils/nodes';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
@@ -681,8 +680,6 @@ const Storage: Component = () => {
return (
<div class="space-y-3">
<ProxmoxSectionNav current="storage" />
{/* Node Selector */}
<UnifiedNodeSelector
currentTab="storage"
@@ -690,6 +687,7 @@ const Storage: Component = () => {
onNodeSelect={handleNodeSelect}
filteredStorage={sortedStorage()}
searchTerm={searchTerm()}
showNodeSummary={false}
/>
<Show

View File

@@ -1,4 +1,4 @@
import { Component, createSignal, createEffect, createMemo, onMount, onCleanup } from 'solid-js';
import { Component, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from 'solid-js';
import { useWebSocket } from '@/App';
import { NodeSummaryTable } from './NodeSummaryTable';
import type { Node, VM, Container, Storage } from '@/types/api';
@@ -13,6 +13,7 @@ interface UnifiedNodeSelectorProps {
filteredContainers?: Container[];
filteredStorage?: Storage[];
searchTerm?: string;
showNodeSummary?: boolean;
}
export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props) => {
@@ -97,21 +98,24 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
// Parent components now handle conditional rendering, so we can render directly
const nodes = createMemo(() => props.nodes || state.nodes || []);
const showNodeSummary = () => props.showNodeSummary ?? true;
return (
<div class="space-y-2 mb-4">
<NodeSummaryTable
nodes={nodes()}
pbsInstances={props.currentTab === 'backups' ? state.pbs : undefined}
vms={state.vms} // Always use unfiltered data for counts
containers={state.containers} // Always use unfiltered data for counts
storage={state.storage} // Always use unfiltered data for counts
backupCounts={backupCounts()}
currentTab={props.currentTab}
selectedNode={selectedNode()}
globalTemperatureMonitoringEnabled={props.globalTemperatureMonitoringEnabled}
onNodeClick={handleNodeClick}
/>
</div>
<Show when={showNodeSummary()}>
<div class="space-y-2 mb-4">
<NodeSummaryTable
nodes={nodes()}
pbsInstances={props.currentTab === 'backups' ? state.pbs : undefined}
vms={state.vms} // Always use unfiltered data for counts
containers={state.containers} // Always use unfiltered data for counts
storage={state.storage} // Always use unfiltered data for counts
backupCounts={backupCounts()}
currentTab={props.currentTab}
selectedNode={selectedNode()}
globalTemperatureMonitoringEnabled={props.globalTemperatureMonitoringEnabled}
onNodeClick={handleNodeClick}
/>
</div>
</Show>
);
};

View File

@@ -1,7 +1,6 @@
import { Component, For, Show, createMemo, createSignal, createEffect, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useWebSocket } from '@/App';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import type { CephPool, CephServiceStatus } from '@/types/api';
@@ -340,7 +339,6 @@ const Ceph: Component = () => {
return (
<div class="space-y-4">
{/* Navigation */}
<ProxmoxSectionNav current="ceph" />
{/* Loading State */}
<Show when={isLoading()}>