mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 >
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user