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 MonitorIcon from 'lucide-solid/icons/monitor';
|
||||||
import BellIcon from 'lucide-solid/icons/bell';
|
import BellIcon from 'lucide-solid/icons/bell';
|
||||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||||
|
import NetworkIcon from 'lucide-solid/icons/network';
|
||||||
import { TokenRevealDialog } from './components/TokenRevealDialog';
|
import { TokenRevealDialog } from './components/TokenRevealDialog';
|
||||||
import { useAlertsActivation } from './stores/alertsActivation';
|
import { useAlertsActivation } from './stores/alertsActivation';
|
||||||
import { UpdateProgressModal } from './components/UpdateProgressModal';
|
import { UpdateProgressModal } from './components/UpdateProgressModal';
|
||||||
|
|
@ -65,6 +66,11 @@ const SettingsPage = lazy(() => import('./components/Settings/Settings'));
|
||||||
const DockerHosts = lazy(() =>
|
const DockerHosts = lazy(() =>
|
||||||
import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })),
|
import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })),
|
||||||
);
|
);
|
||||||
|
const KubernetesClusters = lazy(() =>
|
||||||
|
import('./components/Kubernetes/KubernetesClusters').then((module) => ({
|
||||||
|
default: module.KubernetesClusters,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const HostsOverview = lazy(() =>
|
const HostsOverview = lazy(() =>
|
||||||
import('./components/Hosts/HostsOverview').then((module) => ({
|
import('./components/Hosts/HostsOverview').then((module) => ({
|
||||||
default: module.HostsOverview,
|
default: module.HostsOverview,
|
||||||
|
|
@ -112,6 +118,14 @@ function HostsRoute() {
|
||||||
return <HostsOverview />;
|
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)
|
// Helper to detect if an update is actively in progress (not just checking for updates)
|
||||||
function isUpdateInProgress(status: string | undefined): boolean {
|
function isUpdateInProgress(status: string | undefined): boolean {
|
||||||
if (!status) return false;
|
if (!status) return false;
|
||||||
|
|
@ -877,6 +891,7 @@ function App() {
|
||||||
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
|
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
|
||||||
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
|
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
|
||||||
<Route path="/docker" component={DockerRoute} />
|
<Route path="/docker" component={DockerRoute} />
|
||||||
|
<Route path="/kubernetes" component={KubernetesRoute} />
|
||||||
<Route path="/hosts" component={HostsRoute} />
|
<Route path="/hosts" component={HostsRoute} />
|
||||||
|
|
||||||
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
|
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
|
||||||
|
|
@ -998,6 +1013,7 @@ function AppLayout(props: {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
if (path.startsWith('/proxmox')) return 'proxmox';
|
if (path.startsWith('/proxmox')) return 'proxmox';
|
||||||
if (path.startsWith('/docker')) return 'docker';
|
if (path.startsWith('/docker')) return 'docker';
|
||||||
|
if (path.startsWith('/kubernetes')) return 'kubernetes';
|
||||||
if (path.startsWith('/hosts')) return 'hosts';
|
if (path.startsWith('/hosts')) return 'hosts';
|
||||||
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
|
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
|
||||||
if (path.startsWith('/alerts')) return 'alerts';
|
if (path.startsWith('/alerts')) return 'alerts';
|
||||||
|
|
@ -1005,6 +1021,7 @@ function AppLayout(props: {
|
||||||
return 'proxmox';
|
return 'proxmox';
|
||||||
};
|
};
|
||||||
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
|
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 hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
|
||||||
const hasProxmoxHosts = createMemo(
|
const hasProxmoxHosts = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -1019,6 +1036,12 @@ function AppLayout(props: {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (hasKubernetesClusters()) {
|
||||||
|
markPlatformSeen('kubernetes');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (hasProxmoxHosts()) {
|
if (hasProxmoxHosts()) {
|
||||||
markPlatformSeen('proxmox');
|
markPlatformSeen('proxmox');
|
||||||
|
|
@ -1057,6 +1080,18 @@ function AppLayout(props: {
|
||||||
<BoxesIcon class="w-4 h-4 shrink-0" />
|
<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,
|
id: 'hosts' as const,
|
||||||
label: 'Hosts',
|
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,
|
Container,
|
||||||
DockerHost,
|
DockerHost,
|
||||||
Host,
|
Host,
|
||||||
|
KubernetesCluster,
|
||||||
|
RemovedKubernetesCluster,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import type { ActivationState as ActivationStateType } from '@/types/alerts';
|
import type { ActivationState as ActivationStateType } from '@/types/alerts';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
|
|
@ -30,6 +32,8 @@ export function createWebSocketStore(url: string) {
|
||||||
vms: [],
|
vms: [],
|
||||||
containers: [],
|
containers: [],
|
||||||
dockerHosts: [],
|
dockerHosts: [],
|
||||||
|
kubernetesClusters: [],
|
||||||
|
removedKubernetesClusters: [],
|
||||||
hosts: [],
|
hosts: [],
|
||||||
replicationJobs: [],
|
replicationJobs: [],
|
||||||
storage: [],
|
storage: [],
|
||||||
|
|
@ -552,6 +556,18 @@ export function createWebSocketStore(url: string) {
|
||||||
setState('hosts', reconcile(processedHosts, { key: 'id' }));
|
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.storage !== undefined) setState('storage', reconcile(message.data.storage, { key: 'id' }));
|
||||||
if (message.data.cephClusters !== undefined)
|
if (message.data.cephClusters !== undefined)
|
||||||
setState('cephClusters', reconcile(message.data.cephClusters, { key: 'id' }));
|
setState('cephClusters', reconcile(message.data.cephClusters, { key: 'id' }));
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ export interface KubernetesCluster {
|
||||||
tokenLastUsedAt?: number;
|
tokenLastUsedAt?: number;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
pendingUninstall?: boolean;
|
pendingUninstall?: boolean;
|
||||||
|
nodes?: KubernetesNode[];
|
||||||
|
pods?: KubernetesPod[];
|
||||||
|
deployments?: KubernetesDeployment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemovedKubernetesCluster {
|
export interface RemovedKubernetesCluster {
|
||||||
|
|
@ -68,6 +71,64 @@ export interface RemovedKubernetesCluster {
|
||||||
removedAt: number;
|
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 {
|
export interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -35,31 +35,40 @@ func titleCase(s string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockConfig struct {
|
type MockConfig struct {
|
||||||
NodeCount int
|
NodeCount int
|
||||||
VMsPerNode int
|
VMsPerNode int
|
||||||
LXCsPerNode int
|
LXCsPerNode int
|
||||||
DockerHostCount int
|
DockerHostCount int
|
||||||
DockerContainersPerHost int
|
DockerContainersPerHost int
|
||||||
GenericHostCount int
|
GenericHostCount int
|
||||||
RandomMetrics bool
|
K8sClusterCount int
|
||||||
HighLoadNodes []string // Specific nodes to simulate high load
|
K8sNodesPerCluster int
|
||||||
StoppedPercent float64 // Percentage of guests that should be stopped
|
K8sPodsPerCluster int
|
||||||
|
K8sDeploymentsPerCluster int
|
||||||
|
RandomMetrics bool
|
||||||
|
HighLoadNodes []string // Specific nodes to simulate high load
|
||||||
|
StoppedPercent float64 // Percentage of guests that should be stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
dockerConnectionPrefix = "docker-"
|
dockerConnectionPrefix = "docker-"
|
||||||
hostConnectionPrefix = "host-"
|
kubernetesConnectionPrefix = "kubernetes-"
|
||||||
|
hostConnectionPrefix = "host-"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DefaultConfig = MockConfig{
|
var DefaultConfig = MockConfig{
|
||||||
NodeCount: 7, // Test the 5-9 node range by default
|
NodeCount: 7, // Test the 5-9 node range by default
|
||||||
VMsPerNode: 5,
|
VMsPerNode: 5,
|
||||||
LXCsPerNode: 8,
|
LXCsPerNode: 8,
|
||||||
DockerHostCount: 3,
|
DockerHostCount: 3,
|
||||||
DockerContainersPerHost: 12,
|
DockerContainersPerHost: 12,
|
||||||
GenericHostCount: 4,
|
GenericHostCount: 4,
|
||||||
RandomMetrics: true,
|
K8sClusterCount: 2,
|
||||||
StoppedPercent: 0.2,
|
K8sNodesPerCluster: 4,
|
||||||
|
K8sPodsPerCluster: 30,
|
||||||
|
K8sDeploymentsPerCluster: 12,
|
||||||
|
RandomMetrics: true,
|
||||||
|
StoppedPercent: 0.2,
|
||||||
}
|
}
|
||||||
|
|
||||||
var appNames = []string{
|
var appNames = []string{
|
||||||
|
|
@ -163,6 +172,77 @@ var hostAgentVersions = []string{
|
||||||
"0.2.0-alpha",
|
"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
|
// Common tags used for VMs and containers
|
||||||
var commonTags = []string{
|
var commonTags = []string{
|
||||||
"production", "staging", "development", "testing",
|
"production", "staging", "development", "testing",
|
||||||
|
|
@ -252,17 +332,19 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
|
||||||
// rand is automatically seeded in Go 1.20+
|
// rand is automatically seeded in Go 1.20+
|
||||||
|
|
||||||
data := models.StateSnapshot{
|
data := models.StateSnapshot{
|
||||||
Nodes: generateNodes(config),
|
Nodes: generateNodes(config),
|
||||||
DockerHosts: generateDockerHosts(config),
|
DockerHosts: generateDockerHosts(config),
|
||||||
Hosts: generateHosts(config),
|
KubernetesClusters: generateKubernetesClusters(config),
|
||||||
VMs: []models.VM{},
|
RemovedKubernetesClusters: []models.RemovedKubernetesCluster{},
|
||||||
Containers: []models.Container{},
|
Hosts: generateHosts(config),
|
||||||
PhysicalDisks: []models.PhysicalDisk{},
|
VMs: []models.VM{},
|
||||||
ReplicationJobs: []models.ReplicationJob{},
|
Containers: []models.Container{},
|
||||||
LastUpdate: time.Now(),
|
PhysicalDisks: []models.PhysicalDisk{},
|
||||||
ConnectionHealth: make(map[string]bool),
|
ReplicationJobs: []models.ReplicationJob{},
|
||||||
Stats: models.Stats{},
|
LastUpdate: time.Now(),
|
||||||
ActiveAlerts: []models.Alert{},
|
ConnectionHealth: make(map[string]bool),
|
||||||
|
Stats: models.Stats{},
|
||||||
|
ActiveAlerts: []models.Alert{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate physical disks for each node
|
// Generate physical disks for each node
|
||||||
|
|
@ -274,6 +356,10 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
|
||||||
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = host.Status != "offline"
|
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 {
|
for _, host := range data.Hosts {
|
||||||
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
|
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
|
||||||
}
|
}
|
||||||
|
|
@ -1053,6 +1139,342 @@ func generateGuestOSMetadata() (string, string) {
|
||||||
return choice.Name, choice.Version
|
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 {
|
func generateDockerHosts(config MockConfig) []models.DockerHost {
|
||||||
hostCount := config.DockerHostCount
|
hostCount := config.DockerHostCount
|
||||||
if hostCount <= 0 {
|
if hostCount <= 0 {
|
||||||
|
|
@ -3107,6 +3529,7 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
||||||
// UpdateMetrics simulates changing metrics over time
|
// UpdateMetrics simulates changing metrics over time
|
||||||
func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
|
func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
|
||||||
updateDockerHosts(data, config)
|
updateDockerHosts(data, config)
|
||||||
|
updateKubernetesClusters(data, config)
|
||||||
updateHosts(data, config)
|
updateHosts(data, config)
|
||||||
|
|
||||||
if !config.RandomMetrics {
|
if !config.RandomMetrics {
|
||||||
|
|
@ -3316,6 +3739,74 @@ func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
|
||||||
data.LastUpdate = time.Now()
|
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) {
|
func updateDockerHosts(data *models.StateSnapshot, config MockConfig) {
|
||||||
if len(data.DockerHosts) == 0 {
|
if len(data.DockerHosts) == 0 {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ func enableMockMode(fromInit bool) {
|
||||||
Int("host_agents", config.GenericHostCount).
|
Int("host_agents", config.GenericHostCount).
|
||||||
Int("docker_hosts", config.DockerHostCount).
|
Int("docker_hosts", config.DockerHostCount).
|
||||||
Int("docker_containers_per_host", config.DockerContainersPerHost).
|
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).
|
Bool("random_metrics", config.RandomMetrics).
|
||||||
Float64("stopped_percent", config.StoppedPercent).
|
Float64("stopped_percent", config.StoppedPercent).
|
||||||
Msg("Mock mode enabled")
|
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 != "" {
|
if val := os.Getenv("PULSE_MOCK_RANDOM_METRICS"); val != "" {
|
||||||
config.RandomMetrics = val == "true"
|
config.RandomMetrics = val == "true"
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +267,10 @@ func SetMockConfig(cfg MockConfig) {
|
||||||
Int("lxcs_per_node", cfg.LXCsPerNode).
|
Int("lxcs_per_node", cfg.LXCsPerNode).
|
||||||
Int("docker_hosts", cfg.DockerHostCount).
|
Int("docker_hosts", cfg.DockerHostCount).
|
||||||
Int("docker_containers_per_host", cfg.DockerContainersPerHost).
|
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).
|
Bool("random_metrics", cfg.RandomMetrics).
|
||||||
Float64("stopped_percent", cfg.StoppedPercent).
|
Float64("stopped_percent", cfg.StoppedPercent).
|
||||||
Msg("Mock configuration updated")
|
Msg("Mock configuration updated")
|
||||||
|
|
@ -301,27 +333,67 @@ func GetMockAlertHistory(limit int) []models.Alert {
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneState(state models.StateSnapshot) models.StateSnapshot {
|
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{
|
copyState := models.StateSnapshot{
|
||||||
Nodes: append([]models.Node(nil), state.Nodes...),
|
Nodes: append([]models.Node(nil), state.Nodes...),
|
||||||
VMs: append([]models.VM(nil), state.VMs...),
|
VMs: append([]models.VM(nil), state.VMs...),
|
||||||
Containers: append([]models.Container(nil), state.Containers...),
|
Containers: append([]models.Container(nil), state.Containers...),
|
||||||
DockerHosts: append([]models.DockerHost(nil), state.DockerHosts...),
|
DockerHosts: append([]models.DockerHost(nil), state.DockerHosts...),
|
||||||
Hosts: append([]models.Host(nil), state.Hosts...),
|
KubernetesClusters: kubernetesClusters,
|
||||||
PMGInstances: append([]models.PMGInstance(nil), state.PMGInstances...),
|
RemovedKubernetesClusters: append([]models.RemovedKubernetesCluster(nil), state.RemovedKubernetesClusters...),
|
||||||
Storage: append([]models.Storage(nil), state.Storage...),
|
Hosts: append([]models.Host(nil), state.Hosts...),
|
||||||
CephClusters: append([]models.CephCluster(nil), state.CephClusters...),
|
PMGInstances: append([]models.PMGInstance(nil), state.PMGInstances...),
|
||||||
PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...),
|
Storage: append([]models.Storage(nil), state.Storage...),
|
||||||
PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...),
|
CephClusters: append([]models.CephCluster(nil), state.CephClusters...),
|
||||||
PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...),
|
PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...),
|
||||||
PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...),
|
PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...),
|
||||||
ReplicationJobs: append([]models.ReplicationJob(nil), state.ReplicationJobs...),
|
PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...),
|
||||||
Metrics: append([]models.Metric(nil), state.Metrics...),
|
PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...),
|
||||||
Performance: state.Performance,
|
ReplicationJobs: append([]models.ReplicationJob(nil), state.ReplicationJobs...),
|
||||||
Stats: state.Stats,
|
Metrics: append([]models.Metric(nil), state.Metrics...),
|
||||||
ActiveAlerts: append([]models.Alert(nil), state.ActiveAlerts...),
|
Performance: state.Performance,
|
||||||
RecentlyResolved: append([]models.ResolvedAlert(nil), state.RecentlyResolved...),
|
Stats: state.Stats,
|
||||||
LastUpdate: state.LastUpdate,
|
ActiveAlerts: append([]models.Alert(nil), state.ActiveAlerts...),
|
||||||
ConnectionHealth: make(map[string]bool, len(state.ConnectionHealth)),
|
RecentlyResolved: append([]models.ResolvedAlert(nil), state.RecentlyResolved...),
|
||||||
|
LastUpdate: state.LastUpdate,
|
||||||
|
ConnectionHealth: make(map[string]bool, len(state.ConnectionHealth)),
|
||||||
}
|
}
|
||||||
|
|
||||||
copyState.PVEBackups = models.PVEBackups{
|
copyState.PVEBackups = models.PVEBackups{
|
||||||
|
|
|
||||||
|
|
@ -523,8 +523,8 @@ type StateFrontend struct {
|
||||||
Containers []ContainerFrontend `json:"containers"`
|
Containers []ContainerFrontend `json:"containers"`
|
||||||
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
DockerHosts []DockerHostFrontend `json:"dockerHosts"`
|
||||||
RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"`
|
RemovedDockerHosts []RemovedDockerHostFrontend `json:"removedDockerHosts"`
|
||||||
KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters,omitempty"`
|
KubernetesClusters []KubernetesClusterFrontend `json:"kubernetesClusters"`
|
||||||
RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters,omitempty"`
|
RemovedKubernetesClusters []RemovedKubernetesClusterFrontend `json:"removedKubernetesClusters"`
|
||||||
Hosts []HostFrontend `json:"hosts"`
|
Hosts []HostFrontend `json:"hosts"`
|
||||||
Storage []StorageFrontend `json:"storage"`
|
Storage []StorageFrontend `json:"storage"`
|
||||||
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
CephClusters []CephClusterFrontend `json:"cephClusters"`
|
||||||
|
|
|
||||||
7
mock.env
7
mock.env
|
|
@ -2,5 +2,12 @@ PULSE_MOCK_MODE=false
|
||||||
PULSE_MOCK_NODES=7
|
PULSE_MOCK_NODES=7
|
||||||
PULSE_MOCK_VMS_PER_NODE=5
|
PULSE_MOCK_VMS_PER_NODE=5
|
||||||
PULSE_MOCK_LXCS_PER_NODE=8
|
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_RANDOM_METRICS=true
|
||||||
PULSE_MOCK_STOPPED_PERCENT=20
|
PULSE_MOCK_STOPPED_PERCENT=20
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue