feat(kubernetes): Add Kubernetes mock data and UI
Backend: - Add K8s cluster, node, pod, deployment mock data generation - Configurable via PULSE_MOCK_K8S_CLUSTERS, PULSE_MOCK_K8S_NODES, PULSE_MOCK_K8S_PODS, PULSE_MOCK_K8S_DEPLOYMENTS env vars - Generate realistic cluster data with versions, namespaces, pod phases - Add dynamic metric updates for K8s resources - Deep copy K8s data in cloneState to prevent race conditions Frontend: - Add KubernetesClusters component with 4 view modes: Clusters, Nodes, Pods, Deployments - Filter bar with search, status filter, show hidden toggle - Nodes view: status, roles, CPU/memory/pod capacity, kubelet version - Pods view: namespace, status, ready containers, restarts, image, age - Deployments view: replicas, ready/up-to-date status - Matches Docker/Dashboard table styling patterns
This commit is contained in:
parent
d96942596f
commit
0cd16b29cc
8 changed files with 1529 additions and 52 deletions
|
|
@ -41,6 +41,7 @@ import BoxesIcon from 'lucide-solid/icons/boxes';
|
|||
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';
|
||||
import { TokenRevealDialog } from './components/TokenRevealDialog';
|
||||
import { useAlertsActivation } from './stores/alertsActivation';
|
||||
import { UpdateProgressModal } from './components/UpdateProgressModal';
|
||||
|
|
@ -65,6 +66,11 @@ 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,
|
||||
|
|
@ -112,6 +118,14 @@ function HostsRoute() {
|
|||
return <HostsOverview />;
|
||||
}
|
||||
|
||||
function KubernetesRoute() {
|
||||
const wsContext = useContext(WebSocketContext);
|
||||
if (!wsContext) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
return <KubernetesClusters clusters={wsContext.state.kubernetesClusters ?? []} />;
|
||||
}
|
||||
|
||||
// Helper to detect if an update is actively in progress (not just checking for updates)
|
||||
function isUpdateInProgress(status: string | undefined): boolean {
|
||||
if (!status) return false;
|
||||
|
|
@ -877,6 +891,7 @@ function App() {
|
|||
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
|
||||
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
|
||||
<Route path="/docker" component={DockerRoute} />
|
||||
<Route path="/kubernetes" component={KubernetesRoute} />
|
||||
<Route path="/hosts" component={HostsRoute} />
|
||||
|
||||
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
|
||||
|
|
@ -998,6 +1013,7 @@ function AppLayout(props: {
|
|||
const path = location.pathname;
|
||||
if (path.startsWith('/proxmox')) return 'proxmox';
|
||||
if (path.startsWith('/docker')) return 'docker';
|
||||
if (path.startsWith('/kubernetes')) return 'kubernetes';
|
||||
if (path.startsWith('/hosts')) return 'hosts';
|
||||
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
|
||||
if (path.startsWith('/alerts')) return 'alerts';
|
||||
|
|
@ -1005,6 +1021,7 @@ function AppLayout(props: {
|
|||
return 'proxmox';
|
||||
};
|
||||
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 hasProxmoxHosts = createMemo(
|
||||
() =>
|
||||
|
|
@ -1019,6 +1036,12 @@ function AppLayout(props: {
|
|||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (hasKubernetesClusters()) {
|
||||
markPlatformSeen('kubernetes');
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (hasProxmoxHosts()) {
|
||||
markPlatformSeen('proxmox');
|
||||
|
|
@ -1057,6 +1080,18 @@ function AppLayout(props: {
|
|||
<BoxesIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'kubernetes' as const,
|
||||
label: 'Kubernetes',
|
||||
route: '/kubernetes',
|
||||
settingsRoute: '/settings/agents',
|
||||
tooltip: 'Monitor Kubernetes clusters and workloads',
|
||||
enabled: hasKubernetesClusters() || !!seenPlatforms()['kubernetes'],
|
||||
live: hasKubernetesClusters(),
|
||||
icon: (
|
||||
<NetworkIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'hosts' as const,
|
||||
label: 'Hosts',
|
||||
|
|
|
|||
795
frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx
Normal file
795
frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import type {
|
||||
KubernetesCluster,
|
||||
KubernetesDeployment,
|
||||
KubernetesNode,
|
||||
KubernetesPod,
|
||||
} from '@/types/api';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { formatRelativeTime, formatBytes } from '@/utils/format';
|
||||
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES, type StatusIndicator } from '@/utils/status';
|
||||
|
||||
interface KubernetesClustersProps {
|
||||
clusters: KubernetesCluster[];
|
||||
}
|
||||
|
||||
type ViewMode = 'clusters' | 'nodes' | 'pods' | 'deployments';
|
||||
type StatusFilter = 'all' | 'healthy' | 'unhealthy';
|
||||
|
||||
const normalize = (value?: string | null): string => (value || '').trim().toLowerCase();
|
||||
|
||||
const getStatusIndicator = (status: string | undefined | null): StatusIndicator => {
|
||||
const normalized = normalize(status);
|
||||
if (!normalized) return { variant: 'muted', label: 'Unknown' };
|
||||
if (OFFLINE_HEALTH_STATUSES.has(normalized)) return { variant: 'danger', label: 'Offline' };
|
||||
if (DEGRADED_HEALTH_STATUSES.has(normalized)) return { variant: 'warning', label: 'Degraded' };
|
||||
if (normalized === 'online') return { variant: 'success', label: 'Online' };
|
||||
return { variant: 'muted', label: status ?? 'Unknown' };
|
||||
};
|
||||
|
||||
const isPodHealthy = (pod: KubernetesPod): boolean => {
|
||||
const phase = normalize(pod.phase);
|
||||
if (!phase) return false;
|
||||
if (phase !== 'running') return false;
|
||||
|
||||
const containers = pod.containers ?? [];
|
||||
if (containers.length === 0) return true;
|
||||
|
||||
return containers.every((container) => {
|
||||
if (!container.ready) return false;
|
||||
const state = normalize(container.state);
|
||||
if (!state) return true;
|
||||
return state === 'running';
|
||||
});
|
||||
};
|
||||
|
||||
const isDeploymentHealthy = (d: KubernetesDeployment): boolean => {
|
||||
const desired = d.desiredReplicas ?? 0;
|
||||
if (desired <= 0) return true;
|
||||
const available = d.availableReplicas ?? 0;
|
||||
const ready = d.readyReplicas ?? 0;
|
||||
const updated = d.updatedReplicas ?? 0;
|
||||
return available >= desired && ready >= desired && updated >= desired;
|
||||
};
|
||||
|
||||
const getClusterDisplayName = (cluster: KubernetesCluster): string => {
|
||||
return cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id;
|
||||
};
|
||||
|
||||
const summarizeNodes = (nodes: KubernetesNode[] | undefined) => {
|
||||
const list = nodes ?? [];
|
||||
const notReady = list.filter((n) => !n.ready).length;
|
||||
const unschedulable = list.filter((n) => !!n.unschedulable).length;
|
||||
return { total: list.length, notReady, unschedulable };
|
||||
};
|
||||
|
||||
const summarizePods = (pods: KubernetesPod[] | undefined) => {
|
||||
const list = pods ?? [];
|
||||
const unhealthy = list.filter((p) => !isPodHealthy(p)).length;
|
||||
return { total: list.length, unhealthy };
|
||||
};
|
||||
|
||||
const summarizeDeployments = (deployments: KubernetesDeployment[] | undefined) => {
|
||||
const list = deployments ?? [];
|
||||
const unhealthy = list.filter((d) => !isDeploymentHealthy(d)).length;
|
||||
return { total: list.length, unhealthy };
|
||||
};
|
||||
|
||||
const getPodStatusBadge = (pod: KubernetesPod) => {
|
||||
if (isPodHealthy(pod)) {
|
||||
return { class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', label: 'Running' };
|
||||
}
|
||||
const phase = normalize(pod.phase);
|
||||
if (phase === 'pending') {
|
||||
return { class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', label: 'Pending' };
|
||||
}
|
||||
if (phase === 'failed') {
|
||||
return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'Failed' };
|
||||
}
|
||||
if (phase === 'succeeded') {
|
||||
return { class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', label: 'Completed' };
|
||||
}
|
||||
// Check for CrashLoopBackOff or other container issues
|
||||
const containers = pod.containers ?? [];
|
||||
const crashingContainer = containers.find((c) => c.reason?.toLowerCase().includes('crash'));
|
||||
if (crashingContainer) {
|
||||
return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'CrashLoop' };
|
||||
}
|
||||
const waitingContainer = containers.find((c) => normalize(c.state) === 'waiting');
|
||||
if (waitingContainer) {
|
||||
return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: waitingContainer.reason || 'Waiting' };
|
||||
}
|
||||
return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: pod.phase || 'Unknown' };
|
||||
};
|
||||
|
||||
// Get primary container image (first container)
|
||||
const getPrimaryImage = (pod: KubernetesPod): string => {
|
||||
const containers = pod.containers ?? [];
|
||||
if (containers.length === 0) return '—';
|
||||
const image = containers[0].image ?? '';
|
||||
// Truncate long image names, show just the image:tag part
|
||||
const parts = image.split('/');
|
||||
return parts[parts.length - 1] || image || '—';
|
||||
};
|
||||
|
||||
// Format age from timestamp
|
||||
const formatAge = (timestamp?: number | string | null): string => {
|
||||
if (!timestamp) return '—';
|
||||
const ts = typeof timestamp === 'string' ? Date.parse(timestamp) : timestamp;
|
||||
if (isNaN(ts)) return '—';
|
||||
return formatRelativeTime(ts);
|
||||
};
|
||||
|
||||
export const KubernetesClusters: Component<KubernetesClustersProps> = (props) => {
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [viewMode, setViewMode] = createSignal<ViewMode>('clusters');
|
||||
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
|
||||
const [showHidden, setShowHidden] = createSignal(false);
|
||||
|
||||
// Get all nodes flattened across clusters
|
||||
const allNodes = createMemo(() => {
|
||||
const clusters = props.clusters ?? [];
|
||||
const nodes: Array<{ cluster: KubernetesCluster; node: KubernetesNode }> = [];
|
||||
for (const cluster of clusters) {
|
||||
if (!showHidden() && cluster.hidden) continue;
|
||||
for (const node of cluster.nodes ?? []) {
|
||||
nodes.push({ cluster, node });
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
// Get all pods flattened across clusters
|
||||
const allPods = createMemo(() => {
|
||||
const clusters = props.clusters ?? [];
|
||||
const pods: Array<{ cluster: KubernetesCluster; pod: KubernetesPod }> = [];
|
||||
for (const cluster of clusters) {
|
||||
if (!showHidden() && cluster.hidden) continue;
|
||||
for (const pod of cluster.pods ?? []) {
|
||||
pods.push({ cluster, pod });
|
||||
}
|
||||
}
|
||||
return pods;
|
||||
});
|
||||
|
||||
// Get all deployments flattened across clusters
|
||||
const allDeployments = createMemo(() => {
|
||||
const clusters = props.clusters ?? [];
|
||||
const deps: Array<{ cluster: KubernetesCluster; deployment: KubernetesDeployment }> = [];
|
||||
for (const cluster of clusters) {
|
||||
if (!showHidden() && cluster.hidden) continue;
|
||||
for (const dep of cluster.deployments ?? []) {
|
||||
deps.push({ cluster, deployment: dep });
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
});
|
||||
|
||||
const visibleClusters = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const clusters = props.clusters ?? [];
|
||||
const status = statusFilter();
|
||||
|
||||
return clusters
|
||||
.filter((cluster) => showHidden() || !cluster.hidden)
|
||||
.filter((cluster) => {
|
||||
if (status === 'all') return true;
|
||||
const clusterStatus = normalize(cluster.status);
|
||||
const isHealthy = clusterStatus === 'online';
|
||||
if (status === 'healthy') return isHealthy;
|
||||
if (status === 'unhealthy') return !isHealthy;
|
||||
return true;
|
||||
})
|
||||
.filter((cluster) => {
|
||||
if (!term) return true;
|
||||
const haystack = [
|
||||
getClusterDisplayName(cluster),
|
||||
cluster.id,
|
||||
cluster.server ?? '',
|
||||
cluster.context ?? '',
|
||||
cluster.version ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(term);
|
||||
})
|
||||
.sort((a, b) => getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)));
|
||||
});
|
||||
|
||||
const filteredNodes = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const status = statusFilter();
|
||||
|
||||
return allNodes()
|
||||
.filter(({ node }) => {
|
||||
if (status === 'all') return true;
|
||||
const isHealthy = node.ready && !node.unschedulable;
|
||||
if (status === 'healthy') return isHealthy;
|
||||
if (status === 'unhealthy') return !isHealthy;
|
||||
return true;
|
||||
})
|
||||
.filter(({ cluster, node }) => {
|
||||
if (!term) return true;
|
||||
const haystack = [
|
||||
node.name,
|
||||
getClusterDisplayName(cluster),
|
||||
node.kubeletVersion ?? '',
|
||||
node.osImage ?? '',
|
||||
...(node.roles ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredPods = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const status = statusFilter();
|
||||
|
||||
return allPods()
|
||||
.filter(({ pod }) => {
|
||||
if (status === 'all') return true;
|
||||
const healthy = isPodHealthy(pod);
|
||||
if (status === 'healthy') return healthy;
|
||||
if (status === 'unhealthy') return !healthy;
|
||||
return true;
|
||||
})
|
||||
.filter(({ cluster, pod }) => {
|
||||
if (!term) return true;
|
||||
const haystack = [
|
||||
pod.name,
|
||||
pod.namespace,
|
||||
pod.nodeName ?? '',
|
||||
pod.phase ?? '',
|
||||
getClusterDisplayName(cluster),
|
||||
...(pod.containers ?? []).map(c => c.image ?? ''),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredDeployments = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const status = statusFilter();
|
||||
|
||||
return allDeployments()
|
||||
.filter(({ deployment }) => {
|
||||
if (status === 'all') return true;
|
||||
const healthy = isDeploymentHealthy(deployment);
|
||||
if (status === 'healthy') return healthy;
|
||||
if (status === 'unhealthy') return !healthy;
|
||||
return true;
|
||||
})
|
||||
.filter(({ cluster, deployment }) => {
|
||||
if (!term) return true;
|
||||
const haystack = [
|
||||
deployment.name,
|
||||
deployment.namespace,
|
||||
getClusterDisplayName(cluster),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
const isEmpty = createMemo(() => (props.clusters?.length ?? 0) === 0);
|
||||
|
||||
const hasActiveFilters = createMemo(
|
||||
() => search().trim() !== '' || statusFilter() !== 'all' || showHidden(),
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
setSearch('');
|
||||
setStatusFilter('all');
|
||||
setShowHidden(false);
|
||||
setViewMode('clusters');
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
{/* Header */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Kubernetes</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Cluster health from the unified agent Kubernetes module.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card padding="sm">
|
||||
<div class="flex flex-col lg:flex-row gap-3">
|
||||
{/* Search */}
|
||||
<div class="flex gap-2 flex-1 items-center">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search clusters, nodes, pods..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearch('');
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
class="w-full pl-9 pr-8 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"
|
||||
/>
|
||||
<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={search()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 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={() => setSearch('')}
|
||||
aria-label="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>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{/* View Mode Toggle */}
|
||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('clusters')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'clusters'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
Clusters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('nodes')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'nodes'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
Nodes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('pods')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'pods'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
Pods
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('deployments')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'deployments'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
Deployments
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
|
||||
|
||||
{/* Status Filter */}
|
||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter('all')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'all'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(statusFilter() === 'healthy' ? 'all' : 'healthy')}
|
||||
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'healthy'
|
||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm ring-1 ring-green-200 dark:ring-green-800'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span class={`w-2 h-2 rounded-full ${statusFilter() === 'healthy' ? 'bg-green-500' : 'bg-green-400/60'}`} />
|
||||
Healthy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(statusFilter() === 'unhealthy' ? 'all' : 'unhealthy')}
|
||||
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'unhealthy'
|
||||
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm ring-1 ring-amber-200 dark:ring-amber-800'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span class={`w-2 h-2 rounded-full ${statusFilter() === 'unhealthy' ? 'bg-amber-500' : 'bg-amber-400/60'}`} />
|
||||
Unhealthy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
|
||||
|
||||
{/* Show Hidden Toggle */}
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHidden()}
|
||||
onChange={(e) => setShowHidden(e.currentTarget.checked)}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500/20"
|
||||
/>
|
||||
Show hidden
|
||||
</label>
|
||||
|
||||
{/* Reset Button */}
|
||||
<Show when={hasActiveFilters()}>
|
||||
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
|
||||
<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>
|
||||
|
||||
{/* Main Table */}
|
||||
<Card padding="none" class="overflow-hidden">
|
||||
<Show
|
||||
when={!isEmpty()}
|
||||
fallback={
|
||||
<div class="p-10">
|
||||
<EmptyState
|
||||
title="No Kubernetes clusters reporting yet"
|
||||
description="Enable Kubernetes monitoring on a unified agent to start reporting cluster health."
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Clusters View */}
|
||||
<Show when={viewMode() === 'clusters'}>
|
||||
<ScrollableTable minWidth="900px" persistKey="kubernetes-clusters-table">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cluster</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nodes</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pods</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deployments</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Version</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={visibleClusters()} fallback={
|
||||
<tr><td colSpan={7} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No clusters match the current filters.</td></tr>
|
||||
}>
|
||||
{(cluster) => {
|
||||
const indicator = () => getStatusIndicator(cluster.status);
|
||||
const nodes = () => summarizeNodes(cluster.nodes);
|
||||
const pods = () => summarizePods(cluster.pods);
|
||||
const deployments = () => summarizeDeployments(cluster.deployments);
|
||||
|
||||
return (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{getClusterDisplayName(cluster)}</span>
|
||||
<Show when={cluster.pendingUninstall}>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
Pending uninstall
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={cluster.hidden}>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
Hidden
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-xs font-mono">
|
||||
{cluster.server || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusDot variant={indicator().variant} size="sm" />
|
||||
<span>{indicator().label}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class={nodes().notReady > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{nodes().total - nodes().notReady}</span>
|
||||
<span class="text-gray-400">/{nodes().total}</span>
|
||||
<Show when={nodes().notReady > 0}>
|
||||
<span class="ml-1 text-xs text-gray-400">ready</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class={pods().unhealthy > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{pods().total - pods().unhealthy}</span>
|
||||
<span class="text-gray-400">/{pods().total}</span>
|
||||
<Show when={pods().unhealthy > 0}>
|
||||
<span class="ml-1 text-xs text-gray-400">healthy</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class={deployments().unhealthy > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{deployments().total - deployments().unhealthy}</span>
|
||||
<span class="text-gray-400">/{deployments().total}</span>
|
||||
<Show when={deployments().unhealthy > 0}>
|
||||
<span class="ml-1 text-xs text-gray-400">ok</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{cluster.version || '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollableTable>
|
||||
</Show>
|
||||
|
||||
{/* Nodes View */}
|
||||
<Show when={viewMode() === 'nodes'}>
|
||||
<ScrollableTable minWidth="1000px" persistKey="kubernetes-nodes-table">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Node</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cluster</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Roles</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CPU</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Memory</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pods</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={filteredNodes()} fallback={
|
||||
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No nodes match the current filters.</td></tr>
|
||||
}>
|
||||
{({ cluster, node }) => {
|
||||
const isHealthy = () => node.ready && !node.unschedulable;
|
||||
const roles = () => (node.roles ?? []).join(', ') || 'worker';
|
||||
|
||||
return (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="font-medium">{node.name}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs">
|
||||
{node.osImage || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{getClusterDisplayName(cluster)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<Show when={isHealthy()} fallback={
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
{!node.ready ? 'NotReady' : 'Unschedulable'}
|
||||
</span>
|
||||
}>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
Ready
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">
|
||||
{roles()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{node.allocatableCpuCores ?? node.capacityCpuCores ?? '—'} cores
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{node.allocatableMemoryBytes ? formatBytes(node.allocatableMemoryBytes) : node.capacityMemoryBytes ? formatBytes(node.capacityMemoryBytes) : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{node.allocatablePods ?? node.capacityPods ?? '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{node.kubeletVersion || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollableTable>
|
||||
</Show>
|
||||
|
||||
{/* Pods View */}
|
||||
<Show when={viewMode() === 'pods'}>
|
||||
<ScrollableTable minWidth="1100px" persistKey="kubernetes-pods-table">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pod</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Namespace</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cluster</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ready</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Restarts</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Image</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={filteredPods()} fallback={
|
||||
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No pods match the current filters.</td></tr>
|
||||
}>
|
||||
{({ cluster, pod }) => {
|
||||
const statusBadge = () => getPodStatusBadge(pod);
|
||||
const containers = () => pod.containers ?? [];
|
||||
const readyContainers = () => containers().filter(c => c.ready).length;
|
||||
|
||||
return (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="font-medium truncate max-w-[200px]" title={pod.name}>{pod.name}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{pod.nodeName || 'unscheduled'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
|
||||
{pod.namespace}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{getClusterDisplayName(cluster)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge().class}`}>
|
||||
{statusBadge().label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class={readyContainers() === containers().length ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{readyContainers()}/{containers().length}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Show when={(pod.restarts ?? 0) > 0} fallback={<span class="text-gray-400">0</span>}>
|
||||
<span class="text-amber-600 dark:text-amber-400 font-medium">{pod.restarts}</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="font-mono text-xs truncate max-w-[150px] block" title={(pod.containers ?? [])[0]?.image}>
|
||||
{getPrimaryImage(pod)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{formatAge(pod.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollableTable>
|
||||
</Show>
|
||||
|
||||
{/* Deployments View */}
|
||||
<Show when={viewMode() === 'deployments'}>
|
||||
<ScrollableTable minWidth="800px" persistKey="kubernetes-deployments-table">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deployment</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Namespace</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cluster</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Replicas</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ready</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Up-to-date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={filteredDeployments()} fallback={
|
||||
<tr><td colSpan={7} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No deployments match the current filters.</td></tr>
|
||||
}>
|
||||
{({ cluster, deployment }) => {
|
||||
const healthy = () => isDeploymentHealthy(deployment);
|
||||
const desired = () => deployment.desiredReplicas ?? 0;
|
||||
const ready = () => deployment.readyReplicas ?? 0;
|
||||
const updated = () => deployment.updatedReplicas ?? 0;
|
||||
|
||||
return (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="font-medium">{deployment.name}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
|
||||
{deployment.namespace}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{getClusterDisplayName(cluster)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<Show when={healthy()} fallback={
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
Progressing
|
||||
</span>
|
||||
}>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
Available
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||
{desired()}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class={ready() >= desired() ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{ready()}/{desired()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class={updated() >= desired() ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{updated()}/{desired()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollableTable>
|
||||
</Show>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,6 +10,8 @@ import type {
|
|||
Container,
|
||||
DockerHost,
|
||||
Host,
|
||||
KubernetesCluster,
|
||||
RemovedKubernetesCluster,
|
||||
} from '@/types/api';
|
||||
import type { ActivationState as ActivationStateType } from '@/types/alerts';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
|
@ -30,6 +32,8 @@ export function createWebSocketStore(url: string) {
|
|||
vms: [],
|
||||
containers: [],
|
||||
dockerHosts: [],
|
||||
kubernetesClusters: [],
|
||||
removedKubernetesClusters: [],
|
||||
hosts: [],
|
||||
replicationJobs: [],
|
||||
storage: [],
|
||||
|
|
@ -552,6 +556,18 @@ export function createWebSocketStore(url: string) {
|
|||
setState('hosts', reconcile(processedHosts, { key: 'id' }));
|
||||
}
|
||||
}
|
||||
if (message.data.kubernetesClusters !== undefined) {
|
||||
const clusters = Array.isArray(message.data.kubernetesClusters)
|
||||
? (message.data.kubernetesClusters as KubernetesCluster[])
|
||||
: [];
|
||||
setState('kubernetesClusters', reconcile(clusters, { key: 'id' }));
|
||||
}
|
||||
if (message.data.removedKubernetesClusters !== undefined) {
|
||||
const removed = Array.isArray(message.data.removedKubernetesClusters)
|
||||
? (message.data.removedKubernetesClusters as RemovedKubernetesCluster[])
|
||||
: [];
|
||||
setState('removedKubernetesClusters', reconcile(removed, { key: 'id' }));
|
||||
}
|
||||
if (message.data.storage !== undefined) setState('storage', reconcile(message.data.storage, { key: 'id' }));
|
||||
if (message.data.cephClusters !== undefined)
|
||||
setState('cephClusters', reconcile(message.data.cephClusters, { key: 'id' }));
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ export interface KubernetesCluster {
|
|||
tokenLastUsedAt?: number;
|
||||
hidden?: boolean;
|
||||
pendingUninstall?: boolean;
|
||||
nodes?: KubernetesNode[];
|
||||
pods?: KubernetesPod[];
|
||||
deployments?: KubernetesDeployment[];
|
||||
}
|
||||
|
||||
export interface RemovedKubernetesCluster {
|
||||
|
|
@ -68,6 +71,64 @@ export interface RemovedKubernetesCluster {
|
|||
removedAt: number;
|
||||
}
|
||||
|
||||
export interface KubernetesNode {
|
||||
uid: string;
|
||||
name: string;
|
||||
ready: boolean;
|
||||
unschedulable?: boolean;
|
||||
kubeletVersion?: string;
|
||||
containerRuntimeVersion?: string;
|
||||
osImage?: string;
|
||||
kernelVersion?: string;
|
||||
architecture?: string;
|
||||
capacityCpuCores?: number;
|
||||
capacityMemoryBytes?: number;
|
||||
capacityPods?: number;
|
||||
allocatableCpuCores?: number;
|
||||
allocatableMemoryBytes?: number;
|
||||
allocatablePods?: number;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface KubernetesPodContainer {
|
||||
name: string;
|
||||
image?: string;
|
||||
ready: boolean;
|
||||
restartCount?: number;
|
||||
state?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface KubernetesPod {
|
||||
uid: string;
|
||||
name: string;
|
||||
namespace: string;
|
||||
nodeName?: string;
|
||||
phase?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
qosClass?: string;
|
||||
createdAt?: number;
|
||||
startTime?: number;
|
||||
restarts?: number;
|
||||
labels?: Record<string, string>;
|
||||
ownerKind?: string;
|
||||
ownerName?: string;
|
||||
containers?: KubernetesPodContainer[];
|
||||
}
|
||||
|
||||
export interface KubernetesDeployment {
|
||||
uid: string;
|
||||
name: string;
|
||||
namespace: string;
|
||||
desiredReplicas?: number;
|
||||
updatedReplicas?: number;
|
||||
readyReplicas?: number;
|
||||
availableReplicas?: number;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -35,31 +35,40 @@ func titleCase(s string) string {
|
|||
}
|
||||
|
||||
type MockConfig struct {
|
||||
NodeCount int
|
||||
VMsPerNode int
|
||||
LXCsPerNode int
|
||||
DockerHostCount int
|
||||
DockerContainersPerHost int
|
||||
GenericHostCount int
|
||||
RandomMetrics bool
|
||||
HighLoadNodes []string // Specific nodes to simulate high load
|
||||
StoppedPercent float64 // Percentage of guests that should be stopped
|
||||
NodeCount int
|
||||
VMsPerNode int
|
||||
LXCsPerNode int
|
||||
DockerHostCount int
|
||||
DockerContainersPerHost int
|
||||
GenericHostCount int
|
||||
K8sClusterCount int
|
||||
K8sNodesPerCluster int
|
||||
K8sPodsPerCluster int
|
||||
K8sDeploymentsPerCluster int
|
||||
RandomMetrics bool
|
||||
HighLoadNodes []string // Specific nodes to simulate high load
|
||||
StoppedPercent float64 // Percentage of guests that should be stopped
|
||||
}
|
||||
|
||||
const (
|
||||
dockerConnectionPrefix = "docker-"
|
||||
hostConnectionPrefix = "host-"
|
||||
dockerConnectionPrefix = "docker-"
|
||||
kubernetesConnectionPrefix = "kubernetes-"
|
||||
hostConnectionPrefix = "host-"
|
||||
)
|
||||
|
||||
var DefaultConfig = MockConfig{
|
||||
NodeCount: 7, // Test the 5-9 node range by default
|
||||
VMsPerNode: 5,
|
||||
LXCsPerNode: 8,
|
||||
DockerHostCount: 3,
|
||||
DockerContainersPerHost: 12,
|
||||
GenericHostCount: 4,
|
||||
RandomMetrics: true,
|
||||
StoppedPercent: 0.2,
|
||||
NodeCount: 7, // Test the 5-9 node range by default
|
||||
VMsPerNode: 5,
|
||||
LXCsPerNode: 8,
|
||||
DockerHostCount: 3,
|
||||
DockerContainersPerHost: 12,
|
||||
GenericHostCount: 4,
|
||||
K8sClusterCount: 2,
|
||||
K8sNodesPerCluster: 4,
|
||||
K8sPodsPerCluster: 30,
|
||||
K8sDeploymentsPerCluster: 12,
|
||||
RandomMetrics: true,
|
||||
StoppedPercent: 0.2,
|
||||
}
|
||||
|
||||
var appNames = []string{
|
||||
|
|
@ -163,6 +172,77 @@ var hostAgentVersions = []string{
|
|||
"0.2.0-alpha",
|
||||
}
|
||||
|
||||
var k8sClusterNames = []string{
|
||||
"production",
|
||||
"staging",
|
||||
"development",
|
||||
"edge",
|
||||
"internal",
|
||||
"platform",
|
||||
}
|
||||
|
||||
var k8sNamespaces = []string{
|
||||
"default",
|
||||
"kube-system",
|
||||
"monitoring",
|
||||
"logging",
|
||||
"ingress-nginx",
|
||||
"cert-manager",
|
||||
"argocd",
|
||||
"apps",
|
||||
"services",
|
||||
"databases",
|
||||
"cache",
|
||||
}
|
||||
|
||||
var k8sPodPrefixes = []string{
|
||||
"nginx",
|
||||
"redis",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mongodb",
|
||||
"prometheus",
|
||||
"grafana",
|
||||
"loki",
|
||||
"jaeger",
|
||||
"api",
|
||||
"auth",
|
||||
"worker",
|
||||
"cron",
|
||||
"coredns",
|
||||
"metrics-server",
|
||||
"cert-manager",
|
||||
"ingress-controller",
|
||||
"fluentd",
|
||||
}
|
||||
|
||||
var k8sVersions = []string{
|
||||
"v1.31.2",
|
||||
"v1.30.4",
|
||||
"v1.29.8",
|
||||
}
|
||||
|
||||
var k8sImages = []string{
|
||||
"nginx:1.27",
|
||||
"redis:7.4",
|
||||
"postgres:16",
|
||||
"mysql:8.4",
|
||||
"mongo:7.0",
|
||||
"prom/prometheus:v2.54",
|
||||
"grafana/grafana:11.3",
|
||||
"grafana/loki:3.0",
|
||||
"busybox:1.36",
|
||||
"alpine:3.20",
|
||||
}
|
||||
|
||||
var k8sNodeOS = []string{
|
||||
"Ubuntu 24.04.1 LTS",
|
||||
"Ubuntu 22.04.5 LTS",
|
||||
"Debian GNU/Linux 12 (bookworm)",
|
||||
"Fedora CoreOS 40",
|
||||
"Talos Linux v1.8",
|
||||
}
|
||||
|
||||
// Common tags used for VMs and containers
|
||||
var commonTags = []string{
|
||||
"production", "staging", "development", "testing",
|
||||
|
|
@ -252,17 +332,19 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
|
|||
// rand is automatically seeded in Go 1.20+
|
||||
|
||||
data := models.StateSnapshot{
|
||||
Nodes: generateNodes(config),
|
||||
DockerHosts: generateDockerHosts(config),
|
||||
Hosts: generateHosts(config),
|
||||
VMs: []models.VM{},
|
||||
Containers: []models.Container{},
|
||||
PhysicalDisks: []models.PhysicalDisk{},
|
||||
ReplicationJobs: []models.ReplicationJob{},
|
||||
LastUpdate: time.Now(),
|
||||
ConnectionHealth: make(map[string]bool),
|
||||
Stats: models.Stats{},
|
||||
ActiveAlerts: []models.Alert{},
|
||||
Nodes: generateNodes(config),
|
||||
DockerHosts: generateDockerHosts(config),
|
||||
KubernetesClusters: generateKubernetesClusters(config),
|
||||
RemovedKubernetesClusters: []models.RemovedKubernetesCluster{},
|
||||
Hosts: generateHosts(config),
|
||||
VMs: []models.VM{},
|
||||
Containers: []models.Container{},
|
||||
PhysicalDisks: []models.PhysicalDisk{},
|
||||
ReplicationJobs: []models.ReplicationJob{},
|
||||
LastUpdate: time.Now(),
|
||||
ConnectionHealth: make(map[string]bool),
|
||||
Stats: models.Stats{},
|
||||
ActiveAlerts: []models.Alert{},
|
||||
}
|
||||
|
||||
// Generate physical disks for each node
|
||||
|
|
@ -274,6 +356,10 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
|
|||
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = host.Status != "offline"
|
||||
}
|
||||
|
||||
for _, cluster := range data.KubernetesClusters {
|
||||
data.ConnectionHealth[kubernetesConnectionPrefix+cluster.ID] = cluster.Status != "offline"
|
||||
}
|
||||
|
||||
for _, host := range data.Hosts {
|
||||
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
|
||||
}
|
||||
|
|
@ -1053,6 +1139,342 @@ func generateGuestOSMetadata() (string, string) {
|
|||
return choice.Name, choice.Version
|
||||
}
|
||||
|
||||
func generateKubernetesClusters(config MockConfig) []models.KubernetesCluster {
|
||||
clusterCount := config.K8sClusterCount
|
||||
if clusterCount <= 0 {
|
||||
return []models.KubernetesCluster{}
|
||||
}
|
||||
|
||||
nodeCount := config.K8sNodesPerCluster
|
||||
if nodeCount <= 0 {
|
||||
nodeCount = 4
|
||||
}
|
||||
|
||||
podCount := config.K8sPodsPerCluster
|
||||
if podCount < 0 {
|
||||
podCount = 0
|
||||
}
|
||||
|
||||
deploymentCount := config.K8sDeploymentsPerCluster
|
||||
if deploymentCount < 0 {
|
||||
deploymentCount = 0
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
clusters := make([]models.KubernetesCluster, 0, clusterCount)
|
||||
|
||||
for i := 0; i < clusterCount; i++ {
|
||||
name := k8sClusterNames[i%len(k8sClusterNames)]
|
||||
clusterID := fmt.Sprintf("k8s-%s-%d", strings.ToLower(name), i+1)
|
||||
server := fmt.Sprintf("https://%s.k8s.local:6443", strings.ToLower(name))
|
||||
context := fmt.Sprintf("%s-context", strings.ToLower(name))
|
||||
|
||||
nodes := generateKubernetesNodes(clusterID, nodeCount)
|
||||
pods := generateKubernetesPods(clusterID, nodes, podCount)
|
||||
deployments := generateKubernetesDeployments(clusterID, deploymentCount)
|
||||
|
||||
lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second)
|
||||
status := "online"
|
||||
|
||||
// Make the last cluster offline occasionally for UI coverage.
|
||||
if clusterCount > 1 && i == clusterCount-1 && rand.Float64() < 0.55 {
|
||||
status = "offline"
|
||||
lastSeen = now.Add(-time.Duration(5+rand.Intn(20)) * time.Minute)
|
||||
} else if clusterHasIssues(nodes, pods, deployments) {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
clusters = append(clusters, models.KubernetesCluster{
|
||||
ID: clusterID,
|
||||
AgentID: fmt.Sprintf("%s-agent", clusterID),
|
||||
Name: name,
|
||||
DisplayName: titleCase(name),
|
||||
Server: server,
|
||||
Context: context,
|
||||
Version: k8sVersions[rand.Intn(len(k8sVersions))],
|
||||
Status: status,
|
||||
LastSeen: lastSeen,
|
||||
IntervalSeconds: 30,
|
||||
AgentVersion: "0.1.0-mock",
|
||||
Nodes: nodes,
|
||||
Pods: pods,
|
||||
Deployments: deployments,
|
||||
Hidden: false,
|
||||
PendingUninstall: false,
|
||||
})
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
func generateKubernetesNodes(clusterID string, count int) []models.KubernetesNode {
|
||||
if count <= 0 {
|
||||
return []models.KubernetesNode{}
|
||||
}
|
||||
|
||||
nodes := make([]models.KubernetesNode, 0, count)
|
||||
architectures := []string{"amd64", "arm64"}
|
||||
runtimes := []string{"containerd://1.7.21", "containerd://1.7.20", "cri-o://1.30.4"}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
name := fmt.Sprintf("%s-node-%d", clusterID, i+1)
|
||||
ready := rand.Float64() > 0.08
|
||||
unschedulable := rand.Float64() < 0.08
|
||||
|
||||
roles := []string{"worker"}
|
||||
if i == 0 {
|
||||
roles = []string{"control-plane"}
|
||||
} else if i == 1 && count > 3 {
|
||||
roles = []string{"worker", "gpu"}
|
||||
}
|
||||
|
||||
cpu := int64(2 + rand.Intn(30))
|
||||
memGiB := int64(8 + rand.Intn(248))
|
||||
pods := int64(110 + rand.Intn(50))
|
||||
capacityMemory := memGiB * 1024 * 1024 * 1024
|
||||
allocCPU := cpu - int64(rand.Intn(2))
|
||||
if allocCPU < 1 {
|
||||
allocCPU = 1
|
||||
}
|
||||
allocMem := capacityMemory - int64(rand.Intn(3))*1024*1024*1024
|
||||
if allocMem < 1 {
|
||||
allocMem = capacityMemory
|
||||
}
|
||||
|
||||
nodes = append(nodes, models.KubernetesNode{
|
||||
UID: fmt.Sprintf("%s-%s", name, randomHexString(8)),
|
||||
Name: name,
|
||||
Ready: ready,
|
||||
Unschedulable: unschedulable,
|
||||
KubeletVersion: k8sVersions[rand.Intn(len(k8sVersions))],
|
||||
ContainerRuntimeVersion: runtimes[rand.Intn(len(runtimes))],
|
||||
OSImage: k8sNodeOS[rand.Intn(len(k8sNodeOS))],
|
||||
KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))],
|
||||
Architecture: architectures[rand.Intn(len(architectures))],
|
||||
CapacityCPU: cpu,
|
||||
CapacityMemoryBytes: capacityMemory,
|
||||
CapacityPods: pods,
|
||||
AllocCPU: allocCPU,
|
||||
AllocMemoryBytes: allocMem,
|
||||
AllocPods: pods - int64(rand.Intn(10)),
|
||||
Roles: roles,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure at least one node issue sometimes.
|
||||
if count > 2 && rand.Float64() < 0.35 {
|
||||
idx := 1 + rand.Intn(count-1)
|
||||
nodes[idx].Ready = false
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
func generateKubernetesPods(clusterID string, nodes []models.KubernetesNode, count int) []models.KubernetesPod {
|
||||
if count <= 0 {
|
||||
return []models.KubernetesPod{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
pods := make([]models.KubernetesPod, 0, count)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
namespace := k8sNamespaces[rand.Intn(len(k8sNamespaces))]
|
||||
prefix := k8sPodPrefixes[rand.Intn(len(k8sPodPrefixes))]
|
||||
name := fmt.Sprintf("%s-%s-%d", prefix, randomHexString(5), i+1)
|
||||
nodeName := ""
|
||||
if len(nodes) > 0 && rand.Float64() > 0.08 {
|
||||
nodeName = nodes[rand.Intn(len(nodes))].Name
|
||||
}
|
||||
|
||||
createdAt := now.Add(-time.Duration(30+rand.Intn(7200)) * time.Second)
|
||||
startTime := createdAt.Add(time.Duration(10+rand.Intn(120)) * time.Second)
|
||||
qos := []string{"Guaranteed", "Burstable", "BestEffort"}[rand.Intn(3)]
|
||||
|
||||
phase := "Running"
|
||||
reason := ""
|
||||
message := ""
|
||||
containerState := "running"
|
||||
containerReason := ""
|
||||
containerMessage := ""
|
||||
containerReady := true
|
||||
restarts := 0
|
||||
|
||||
roll := rand.Float64()
|
||||
switch {
|
||||
case roll < 0.08:
|
||||
phase = "Pending"
|
||||
reason = "Unschedulable"
|
||||
message = "0/3 nodes available"
|
||||
containerState = "waiting"
|
||||
containerReason = "PodInitializing"
|
||||
containerReady = false
|
||||
case roll < 0.14:
|
||||
phase = "Running"
|
||||
containerState = "waiting"
|
||||
containerReason = "CrashLoopBackOff"
|
||||
containerMessage = "Back-off restarting failed container"
|
||||
containerReady = false
|
||||
restarts = 3 + rand.Intn(20)
|
||||
case roll < 0.18:
|
||||
phase = "Failed"
|
||||
reason = "Error"
|
||||
message = "Pod terminated with non-zero exit code"
|
||||
containerState = "terminated"
|
||||
containerReason = "Error"
|
||||
containerReady = false
|
||||
restarts = 1 + rand.Intn(6)
|
||||
case roll < 0.22:
|
||||
phase = "Unknown"
|
||||
reason = "NodeLost"
|
||||
message = "Node status is unknown"
|
||||
containerState = "unknown"
|
||||
containerReady = false
|
||||
}
|
||||
|
||||
containerCount := 1
|
||||
if rand.Float64() < 0.12 {
|
||||
containerCount = 2
|
||||
}
|
||||
|
||||
containers := make([]models.KubernetesPodContainer, 0, containerCount)
|
||||
for c := 0; c < containerCount; c++ {
|
||||
image := k8sImages[rand.Intn(len(k8sImages))]
|
||||
containers = append(containers, models.KubernetesPodContainer{
|
||||
Name: fmt.Sprintf("%s-%d", prefix, c+1),
|
||||
Image: image,
|
||||
Ready: containerReady,
|
||||
RestartCount: int32(restarts),
|
||||
State: containerState,
|
||||
Reason: containerReason,
|
||||
Message: containerMessage,
|
||||
})
|
||||
}
|
||||
|
||||
ownerKind := []string{"Deployment", "StatefulSet", "DaemonSet", "Job"}[rand.Intn(4)]
|
||||
ownerName := fmt.Sprintf("%s-%s", strings.ToLower(prefix), randomHexString(4))
|
||||
|
||||
labels := map[string]string{
|
||||
"app.kubernetes.io/name": prefix,
|
||||
"app.kubernetes.io/instance": ownerName,
|
||||
"app.kubernetes.io/managed-by": "mock",
|
||||
}
|
||||
|
||||
pod := models.KubernetesPod{
|
||||
UID: fmt.Sprintf("%s-%s", name, randomHexString(10)),
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
NodeName: nodeName,
|
||||
Phase: phase,
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
QoSClass: qos,
|
||||
CreatedAt: createdAt,
|
||||
StartTime: &startTime,
|
||||
Restarts: restarts,
|
||||
Labels: labels,
|
||||
OwnerKind: ownerKind,
|
||||
OwnerName: ownerName,
|
||||
Containers: containers,
|
||||
}
|
||||
|
||||
if phase == "Pending" {
|
||||
pod.StartTime = nil
|
||||
}
|
||||
|
||||
pods = append(pods, pod)
|
||||
}
|
||||
|
||||
return pods
|
||||
}
|
||||
|
||||
func generateKubernetesDeployments(clusterID string, count int) []models.KubernetesDeployment {
|
||||
if count <= 0 {
|
||||
return []models.KubernetesDeployment{}
|
||||
}
|
||||
|
||||
deployments := make([]models.KubernetesDeployment, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
namespace := k8sNamespaces[rand.Intn(len(k8sNamespaces))]
|
||||
prefix := k8sPodPrefixes[rand.Intn(len(k8sPodPrefixes))]
|
||||
name := fmt.Sprintf("%s-%s", prefix, randomHexString(4))
|
||||
|
||||
desired := int32(1 + rand.Intn(6))
|
||||
updated := desired
|
||||
ready := desired
|
||||
available := desired
|
||||
|
||||
// Degrade some deployments for UI coverage.
|
||||
if rand.Float64() < 0.20 && desired > 0 {
|
||||
ready = int32(rand.Intn(int(desired)))
|
||||
available = ready
|
||||
updated = int32(rand.Intn(int(desired) + 1))
|
||||
}
|
||||
|
||||
deployments = append(deployments, models.KubernetesDeployment{
|
||||
UID: fmt.Sprintf("%s-%s", name, randomHexString(10)),
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
DesiredReplicas: desired,
|
||||
UpdatedReplicas: updated,
|
||||
ReadyReplicas: ready,
|
||||
AvailableReplicas: available,
|
||||
Labels: map[string]string{
|
||||
"app.kubernetes.io/name": prefix,
|
||||
"cluster": clusterID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return deployments
|
||||
}
|
||||
|
||||
func clusterHasIssues(nodes []models.KubernetesNode, pods []models.KubernetesPod, deployments []models.KubernetesDeployment) bool {
|
||||
for _, node := range nodes {
|
||||
if !node.Ready || node.Unschedulable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, pod := range pods {
|
||||
if !kubernetesPodHealthy(pod) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range deployments {
|
||||
if !kubernetesDeploymentHealthy(d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func kubernetesPodHealthy(pod models.KubernetesPod) bool {
|
||||
if strings.ToLower(strings.TrimSpace(pod.Phase)) != "running" {
|
||||
return false
|
||||
}
|
||||
for _, c := range pod.Containers {
|
||||
if !c.Ready {
|
||||
return false
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(c.State))
|
||||
if state != "" && state != "running" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func kubernetesDeploymentHealthy(d models.KubernetesDeployment) bool {
|
||||
desired := d.DesiredReplicas
|
||||
if desired <= 0 {
|
||||
return true
|
||||
}
|
||||
return d.ReadyReplicas >= desired && d.AvailableReplicas >= desired && d.UpdatedReplicas >= desired
|
||||
}
|
||||
|
||||
func generateDockerHosts(config MockConfig) []models.DockerHost {
|
||||
hostCount := config.DockerHostCount
|
||||
if hostCount <= 0 {
|
||||
|
|
@ -3107,6 +3529,7 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
|||
// UpdateMetrics simulates changing metrics over time
|
||||
func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
|
||||
updateDockerHosts(data, config)
|
||||
updateKubernetesClusters(data, config)
|
||||
updateHosts(data, config)
|
||||
|
||||
if !config.RandomMetrics {
|
||||
|
|
@ -3316,6 +3739,74 @@ func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
|
|||
data.LastUpdate = time.Now()
|
||||
}
|
||||
|
||||
func updateKubernetesClusters(data *models.StateSnapshot, config MockConfig) {
|
||||
if len(data.KubernetesClusters) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for i := range data.KubernetesClusters {
|
||||
cluster := &data.KubernetesClusters[i]
|
||||
|
||||
if cluster.Status != "offline" {
|
||||
cluster.LastSeen = now.Add(-time.Duration(rand.Intn(12)) * time.Second)
|
||||
} else if config.RandomMetrics && rand.Float64() < 0.01 {
|
||||
cluster.Status = "online"
|
||||
cluster.LastSeen = now
|
||||
}
|
||||
|
||||
if config.RandomMetrics {
|
||||
// Small chance to flip a node Ready state.
|
||||
if len(cluster.Nodes) > 0 && rand.Float64() < 0.05 {
|
||||
idx := rand.Intn(len(cluster.Nodes))
|
||||
cluster.Nodes[idx].Ready = !cluster.Nodes[idx].Ready
|
||||
}
|
||||
|
||||
// Small chance to flip a pod into/out of CrashLoopBackOff.
|
||||
if len(cluster.Pods) > 0 && rand.Float64() < 0.07 {
|
||||
idx := rand.Intn(len(cluster.Pods))
|
||||
pod := &cluster.Pods[idx]
|
||||
if kubernetesPodHealthy(*pod) {
|
||||
pod.Phase = "Running"
|
||||
pod.Reason = ""
|
||||
pod.Message = ""
|
||||
pod.Restarts += 1 + rand.Intn(3)
|
||||
for j := range pod.Containers {
|
||||
pod.Containers[j].Ready = false
|
||||
pod.Containers[j].State = "waiting"
|
||||
pod.Containers[j].Reason = "CrashLoopBackOff"
|
||||
pod.Containers[j].Message = "Back-off restarting failed container"
|
||||
pod.Containers[j].RestartCount += int32(1 + rand.Intn(3))
|
||||
}
|
||||
} else {
|
||||
pod.Phase = "Running"
|
||||
pod.Reason = ""
|
||||
pod.Message = ""
|
||||
for j := range pod.Containers {
|
||||
pod.Containers[j].Ready = true
|
||||
pod.Containers[j].State = "running"
|
||||
pod.Containers[j].Reason = ""
|
||||
pod.Containers[j].Message = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cluster.Status != "offline" {
|
||||
if clusterHasIssues(cluster.Nodes, cluster.Pods, cluster.Deployments) {
|
||||
cluster.Status = "degraded"
|
||||
} else {
|
||||
cluster.Status = "online"
|
||||
}
|
||||
}
|
||||
|
||||
if data.ConnectionHealth != nil {
|
||||
data.ConnectionHealth[kubernetesConnectionPrefix+cluster.ID] = cluster.Status != "offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateDockerHosts(data *models.StateSnapshot, config MockConfig) {
|
||||
if len(data.DockerHosts) == 0 {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ func enableMockMode(fromInit bool) {
|
|||
Int("host_agents", config.GenericHostCount).
|
||||
Int("docker_hosts", config.DockerHostCount).
|
||||
Int("docker_containers_per_host", config.DockerContainersPerHost).
|
||||
Int("k8s_clusters", config.K8sClusterCount).
|
||||
Int("k8s_nodes_per_cluster", config.K8sNodesPerCluster).
|
||||
Int("k8s_pods_per_cluster", config.K8sPodsPerCluster).
|
||||
Int("k8s_deployments_per_cluster", config.K8sDeploymentsPerCluster).
|
||||
Bool("random_metrics", config.RandomMetrics).
|
||||
Float64("stopped_percent", config.StoppedPercent).
|
||||
Msg("Mock mode enabled")
|
||||
|
|
@ -209,6 +213,30 @@ func LoadMockConfig() MockConfig {
|
|||
}
|
||||
}
|
||||
|
||||
if val := os.Getenv("PULSE_MOCK_K8S_CLUSTERS"); val != "" {
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
|
||||
config.K8sClusterCount = n
|
||||
}
|
||||
}
|
||||
|
||||
if val := os.Getenv("PULSE_MOCK_K8S_NODES"); val != "" {
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
|
||||
config.K8sNodesPerCluster = n
|
||||
}
|
||||
}
|
||||
|
||||
if val := os.Getenv("PULSE_MOCK_K8S_PODS"); val != "" {
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
|
||||
config.K8sPodsPerCluster = n
|
||||
}
|
||||
}
|
||||
|
||||
if val := os.Getenv("PULSE_MOCK_K8S_DEPLOYMENTS"); val != "" {
|
||||
if n, err := strconv.Atoi(val); err == nil && n >= 0 {
|
||||
config.K8sDeploymentsPerCluster = n
|
||||
}
|
||||
}
|
||||
|
||||
if val := os.Getenv("PULSE_MOCK_RANDOM_METRICS"); val != "" {
|
||||
config.RandomMetrics = val == "true"
|
||||
}
|
||||
|
|
@ -239,6 +267,10 @@ func SetMockConfig(cfg MockConfig) {
|
|||
Int("lxcs_per_node", cfg.LXCsPerNode).
|
||||
Int("docker_hosts", cfg.DockerHostCount).
|
||||
Int("docker_containers_per_host", cfg.DockerContainersPerHost).
|
||||
Int("k8s_clusters", cfg.K8sClusterCount).
|
||||
Int("k8s_nodes_per_cluster", cfg.K8sNodesPerCluster).
|
||||
Int("k8s_pods_per_cluster", cfg.K8sPodsPerCluster).
|
||||
Int("k8s_deployments_per_cluster", cfg.K8sDeploymentsPerCluster).
|
||||
Bool("random_metrics", cfg.RandomMetrics).
|
||||
Float64("stopped_percent", cfg.StoppedPercent).
|
||||
Msg("Mock configuration updated")
|
||||
|
|
@ -301,27 +333,67 @@ func GetMockAlertHistory(limit int) []models.Alert {
|
|||
}
|
||||
|
||||
func cloneState(state models.StateSnapshot) models.StateSnapshot {
|
||||
kubernetesClusters := make([]models.KubernetesCluster, len(state.KubernetesClusters))
|
||||
for i, cluster := range state.KubernetesClusters {
|
||||
clusterCopy := cluster
|
||||
|
||||
clusterCopy.Nodes = append([]models.KubernetesNode(nil), cluster.Nodes...)
|
||||
|
||||
clusterCopy.Pods = make([]models.KubernetesPod, len(cluster.Pods))
|
||||
for j, pod := range cluster.Pods {
|
||||
podCopy := pod
|
||||
|
||||
if pod.Labels != nil {
|
||||
labelsCopy := make(map[string]string, len(pod.Labels))
|
||||
for k, v := range pod.Labels {
|
||||
labelsCopy[k] = v
|
||||
}
|
||||
podCopy.Labels = labelsCopy
|
||||
}
|
||||
|
||||
podCopy.Containers = append([]models.KubernetesPodContainer(nil), pod.Containers...)
|
||||
clusterCopy.Pods[j] = podCopy
|
||||
}
|
||||
|
||||
clusterCopy.Deployments = make([]models.KubernetesDeployment, len(cluster.Deployments))
|
||||
for j, dep := range cluster.Deployments {
|
||||
depCopy := dep
|
||||
if dep.Labels != nil {
|
||||
labelsCopy := make(map[string]string, len(dep.Labels))
|
||||
for k, v := range dep.Labels {
|
||||
labelsCopy[k] = v
|
||||
}
|
||||
depCopy.Labels = labelsCopy
|
||||
}
|
||||
clusterCopy.Deployments[j] = depCopy
|
||||
}
|
||||
|
||||
kubernetesClusters[i] = clusterCopy
|
||||
}
|
||||
|
||||
copyState := models.StateSnapshot{
|
||||
Nodes: append([]models.Node(nil), state.Nodes...),
|
||||
VMs: append([]models.VM(nil), state.VMs...),
|
||||
Containers: append([]models.Container(nil), state.Containers...),
|
||||
DockerHosts: append([]models.DockerHost(nil), state.DockerHosts...),
|
||||
Hosts: append([]models.Host(nil), state.Hosts...),
|
||||
PMGInstances: append([]models.PMGInstance(nil), state.PMGInstances...),
|
||||
Storage: append([]models.Storage(nil), state.Storage...),
|
||||
CephClusters: append([]models.CephCluster(nil), state.CephClusters...),
|
||||
PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...),
|
||||
PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...),
|
||||
PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...),
|
||||
PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...),
|
||||
ReplicationJobs: append([]models.ReplicationJob(nil), state.ReplicationJobs...),
|
||||
Metrics: append([]models.Metric(nil), state.Metrics...),
|
||||
Performance: state.Performance,
|
||||
Stats: state.Stats,
|
||||
ActiveAlerts: append([]models.Alert(nil), state.ActiveAlerts...),
|
||||
RecentlyResolved: append([]models.ResolvedAlert(nil), state.RecentlyResolved...),
|
||||
LastUpdate: state.LastUpdate,
|
||||
ConnectionHealth: make(map[string]bool, len(state.ConnectionHealth)),
|
||||
Nodes: append([]models.Node(nil), state.Nodes...),
|
||||
VMs: append([]models.VM(nil), state.VMs...),
|
||||
Containers: append([]models.Container(nil), state.Containers...),
|
||||
DockerHosts: append([]models.DockerHost(nil), state.DockerHosts...),
|
||||
KubernetesClusters: kubernetesClusters,
|
||||
RemovedKubernetesClusters: append([]models.RemovedKubernetesCluster(nil), state.RemovedKubernetesClusters...),
|
||||
Hosts: append([]models.Host(nil), state.Hosts...),
|
||||
PMGInstances: append([]models.PMGInstance(nil), state.PMGInstances...),
|
||||
Storage: append([]models.Storage(nil), state.Storage...),
|
||||
CephClusters: append([]models.CephCluster(nil), state.CephClusters...),
|
||||
PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...),
|
||||
PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...),
|
||||
PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...),
|
||||
PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...),
|
||||
ReplicationJobs: append([]models.ReplicationJob(nil), state.ReplicationJobs...),
|
||||
Metrics: append([]models.Metric(nil), state.Metrics...),
|
||||
Performance: state.Performance,
|
||||
Stats: state.Stats,
|
||||
ActiveAlerts: append([]models.Alert(nil), state.ActiveAlerts...),
|
||||
RecentlyResolved: append([]models.ResolvedAlert(nil), state.RecentlyResolved...),
|
||||
LastUpdate: state.LastUpdate,
|
||||
ConnectionHealth: make(map[string]bool, len(state.ConnectionHealth)),
|
||||
}
|
||||
|
||||
copyState.PVEBackups = models.PVEBackups{
|
||||
|
|
|
|||
|
|
@ -523,8 +523,8 @@ type StateFrontend struct {
|
|||
Containers []ContainerFrontend `json:"containers"`
|
||||
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
||||
RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"`
|
||||
KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters,omitempty"`
|
||||
RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters,omitempty"`
|
||||
KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters"`
|
||||
RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters"`
|
||||
Hosts []HostFrontend `json:"hosts"`
|
||||
Storage []StorageFrontend `json:"storage"`
|
||||
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
||||
|
|
|
|||
7
mock.env
7
mock.env
|
|
@ -2,5 +2,12 @@ PULSE_MOCK_MODE=false
|
|||
PULSE_MOCK_NODES=7
|
||||
PULSE_MOCK_VMS_PER_NODE=5
|
||||
PULSE_MOCK_LXCS_PER_NODE=8
|
||||
PULSE_MOCK_DOCKER_HOSTS=3
|
||||
PULSE_MOCK_DOCKER_CONTAINERS=12
|
||||
PULSE_MOCK_GENERIC_HOSTS=4
|
||||
PULSE_MOCK_K8S_CLUSTERS=2
|
||||
PULSE_MOCK_K8S_NODES=4
|
||||
PULSE_MOCK_K8S_PODS=30
|
||||
PULSE_MOCK_K8S_DEPLOYMENTS=12
|
||||
PULSE_MOCK_RANDOM_METRICS=true
|
||||
PULSE_MOCK_STOPPED_PERCENT=20
|
||||
|
|
|
|||
Loading…
Reference in a new issue