feat(kubernetes): Add sorting and namespace filter to K8s UI

- Add sortable table headers for Pod and Deployment views
- Click column headers to toggle sort direction
- Sort state persists across sessions
- Add namespace dropdown filter for Pods/Deployments views
- Auto-populates from available namespaces
- Include namespace filter in reset and active filters check
This commit is contained in:
rcourtman 2025-12-12 23:24:04 +00:00
parent 0cd16b29cc
commit 987f7a3822
5 changed files with 658 additions and 30 deletions

View file

@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal } from 'solid-js';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import type {
KubernetesCluster,
KubernetesDeployment,
@ -129,6 +130,39 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const [viewMode, setViewMode] = createSignal<ViewMode>('clusters');
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
const [showHidden, setShowHidden] = createSignal(false);
const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all');
// Sorting state with persistence
type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas';
type SortDir = 'asc' | 'desc';
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('k8s-sort-key', 'name');
const [sortDirection, setSortDirection] = usePersistentSignal<SortDir>('k8s-sort-dir', 'asc');
const toggleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection('asc');
}
};
const sortIndicator = (key: SortKey) => sortKey() === key ? (sortDirection() === 'asc' ? ' ▲' : ' ▼') : '';
// Get all unique namespaces for the filter dropdown
const allNamespaces = createMemo(() => {
const namespaces = new Set<string>();
for (const cluster of props.clusters ?? []) {
if (!showHidden() && cluster.hidden) continue;
for (const pod of cluster.pods ?? []) {
if (pod.namespace) namespaces.add(pod.namespace);
}
for (const dep of cluster.deployments ?? []) {
if (dep.namespace) namespaces.add(dep.namespace);
}
}
return Array.from(namespaces).sort();
});
// Get all nodes flattened across clusters
const allNodes = createMemo(() => {
@ -230,9 +264,13 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const filteredPods = createMemo(() => {
const term = search().trim().toLowerCase();
const status = statusFilter();
const ns = namespaceFilter();
const key = sortKey();
const dir = sortDirection();
return allPods()
const filtered = allPods()
.filter(({ pod }) => {
if (ns !== 'all' && pod.namespace !== ns) return false;
if (status === 'all') return true;
const healthy = isPodHealthy(pod);
if (status === 'healthy') return healthy;
@ -253,14 +291,33 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
.toLowerCase();
return haystack.includes(term);
});
// Sort
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); break;
case 'namespace': cmp = (a.pod.namespace ?? '').localeCompare(b.pod.namespace ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'restarts': cmp = (a.pod.restarts ?? 0) - (b.pod.restarts ?? 0); break;
case 'age': cmp = (a.pod.createdAt ?? 0) - (b.pod.createdAt ?? 0); break;
case 'status': cmp = (isPodHealthy(a.pod) ? 0 : 1) - (isPodHealthy(b.pod) ? 0 : 1); break;
default: cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredDeployments = createMemo(() => {
const term = search().trim().toLowerCase();
const status = statusFilter();
const ns = namespaceFilter();
const key = sortKey();
const dir = sortDirection();
return allDeployments()
const filtered = allDeployments()
.filter(({ deployment }) => {
if (ns !== 'all' && deployment.namespace !== ns) return false;
if (status === 'all') return true;
const healthy = isDeploymentHealthy(deployment);
if (status === 'healthy') return healthy;
@ -278,18 +335,34 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
.toLowerCase();
return haystack.includes(term);
});
// Sort
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); break;
case 'namespace': cmp = (a.deployment.namespace ?? '').localeCompare(b.deployment.namespace ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'replicas': cmp = (a.deployment.desiredReplicas ?? 0) - (b.deployment.desiredReplicas ?? 0); break;
case 'ready': cmp = (a.deployment.readyReplicas ?? 0) - (b.deployment.readyReplicas ?? 0); break;
case 'status': cmp = (isDeploymentHealthy(a.deployment) ? 0 : 1) - (isDeploymentHealthy(b.deployment) ? 0 : 1); break;
default: cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const isEmpty = createMemo(() => (props.clusters?.length ?? 0) === 0);
const hasActiveFilters = createMemo(
() => search().trim() !== '' || statusFilter() !== 'all' || showHidden(),
() => search().trim() !== '' || statusFilter() !== 'all' || showHidden() || namespaceFilter() !== 'all',
);
const handleReset = () => {
setSearch('');
setStatusFilter('all');
setShowHidden(false);
setNamespaceFilter('all');
setViewMode('clusters');
};
@ -358,8 +431,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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
@ -368,8 +441,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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
@ -378,8 +451,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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
@ -388,8 +461,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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
@ -404,8 +477,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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
@ -414,8 +487,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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'}`} />
@ -425,8 +498,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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'
? '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'}`} />
@ -434,6 +507,21 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
</button>
</div>
{/* Namespace Filter - only show for pods/deployments */}
<Show when={(viewMode() === 'pods' || viewMode() === 'deployments') && allNamespaces().length > 1}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<select
value={namespaceFilter()}
onChange={(e) => setNamespaceFilter(e.currentTarget.value)}
class="px-2.5 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<option value="all">All namespaces</option>
<For each={allNamespaces()}>
{(ns) => <option value={ns}>{ns}</option>}
</For>
</select>
</Show>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
{/* Show Hidden Toggle */}
@ -649,14 +737,14 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
<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 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Pod{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('namespace')}>Namespace{sortIndicator('namespace')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('ready')}>Ready{sortIndicator('ready')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('restarts')}>Restarts{sortIndicator('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>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('age')}>Age{sortIndicator('age')}</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@ -722,12 +810,12 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
<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 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Deployment{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('namespace')}>Namespace{sortIndicator('namespace')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('replicas')}>Replicas{sortIndicator('replicas')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('ready')}>Ready{sortIndicator('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>

View file

@ -50,6 +50,16 @@ func NewMetricsHistory(maxDataPoints int, retentionTime time.Duration) *MetricsH
}
}
// Reset clears all historical metrics data.
func (mh *MetricsHistory) Reset() {
mh.mu.Lock()
defer mh.mu.Unlock()
mh.guestMetrics = make(map[string]*GuestMetrics)
mh.nodeMetrics = make(map[string]*GuestMetrics)
mh.storageMetrics = make(map[string]*StorageMetrics)
}
// AddGuestMetric adds a metric value for a guest
func (mh *MetricsHistory) AddGuestMetric(guestID string, metricType string, value float64, timestamp time.Time) {
mh.mu.Lock()

View file

@ -0,0 +1,411 @@
package monitoring
import (
"context"
"hash/fnv"
"math"
"math/rand"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
const (
defaultMockSeedDuration = time.Hour
defaultMockSampleInterval = 30 * time.Second
)
type mockMetricsSamplerConfig struct {
SeedDuration time.Duration
SampleInterval time.Duration
}
func mockMetricsSamplerConfigFromEnv() mockMetricsSamplerConfig {
seedDuration := parseDurationEnv("PULSE_MOCK_TRENDS_SEED_DURATION", defaultMockSeedDuration)
sampleInterval := parseDurationEnv("PULSE_MOCK_TRENDS_SAMPLE_INTERVAL", defaultMockSampleInterval)
// Guardrails to keep memory and CPU bounded in demo mode.
if seedDuration < 5*time.Minute {
seedDuration = 5 * time.Minute
}
if seedDuration > 12*time.Hour {
seedDuration = 12 * time.Hour
}
if sampleInterval < 5*time.Second {
sampleInterval = 5 * time.Second
}
if sampleInterval > 5*time.Minute {
sampleInterval = 5 * time.Minute
}
// Ensure we can generate at least 2 points.
if seedDuration < sampleInterval {
seedDuration = sampleInterval
}
return mockMetricsSamplerConfig{
SeedDuration: seedDuration,
SampleInterval: sampleInterval,
}
}
func hashSeed(parts ...string) uint64 {
h := fnv.New64a()
for _, p := range parts {
_, _ = h.Write([]byte(p))
_, _ = h.Write([]byte{0})
}
return h.Sum64()
}
type seededTrendClass int
const (
trendStable seededTrendClass = iota
trendGrowing
trendDeclining
trendVolatile
)
func pickTrendClass(seed uint64) seededTrendClass {
switch seed % 4 {
case 0:
return trendStable
case 1:
return trendGrowing
case 2:
return trendDeclining
default:
return trendVolatile
}
}
func generateSeededSeries(current float64, points int, seed uint64, min, max float64) []float64 {
current = clampFloat(current, min, max)
if points <= 1 {
return []float64{current}
}
class := pickTrendClass(seed)
rng := rand.New(rand.NewSource(int64(seed))) // Deterministic per resource/metric
span := math.Max(1, max-min)
var totalSlope float64
var amplitude float64
var volatility float64
switch class {
case trendGrowing:
totalSlope = span * (0.06 + float64(seed%6)*0.01) // 6-11% of span over window
amplitude = span * 0.02
volatility = span * 0.01
case trendDeclining:
totalSlope = -span * (0.06 + float64(seed%6)*0.01)
amplitude = span * 0.02
volatility = span * 0.01
case trendVolatile:
totalSlope = 0
amplitude = span * (0.06 + float64(seed%6)*0.01)
volatility = span * 0.03
default:
totalSlope = 0
amplitude = span * 0.015
volatility = span * 0.007
}
cycles := int(seed%3) + 1
lastIdx := float64(points - 1)
slopePerStep := totalSlope / lastIdx
raw := make([]float64, points)
for i := 0; i < points; i++ {
progress := float64(i) / lastIdx
// 0 at both ends (progress 0 and 1), so the end value can align cleanly.
wave := math.Sin(2 * math.Pi * float64(cycles) * progress)
noiseScale := 1 - progress
noise := rng.NormFloat64() * volatility * noiseScale
raw[i] = current + slopePerStep*float64(i-(points-1)) + amplitude*wave + noise
}
// Shift so the last point exactly matches current.
offset := current - raw[points-1]
for i := range raw {
raw[i] = clampFloat(raw[i]+offset, min, max)
}
raw[points-1] = current
return raw
}
func seedMockMetricsHistory(mh *MetricsHistory, state models.StateSnapshot, now time.Time, seedDuration, interval time.Duration) {
if mh == nil {
return
}
if seedDuration <= 0 || interval <= 0 {
return
}
points := int(seedDuration/interval) + 1
if points < 2 {
points = 2
}
start := now.Add(-time.Duration(points-1) * interval)
recordNode := func(node models.Node) {
if node.ID == "" {
return
}
cpuSeries := generateSeededSeries(node.CPU*100, points, hashSeed("node", node.ID, "cpu"), 5, 85)
memSeries := generateSeededSeries(node.Memory.Usage, points, hashSeed("node", node.ID, "memory"), 10, 85)
diskSeries := generateSeededSeries(node.Disk.Usage, points, hashSeed("node", node.ID, "disk"), 5, 95)
for i := 0; i < points; i++ {
ts := start.Add(time.Duration(i) * interval)
mh.AddNodeMetric(node.ID, "cpu", cpuSeries[i], ts)
mh.AddNodeMetric(node.ID, "memory", memSeries[i], ts)
mh.AddNodeMetric(node.ID, "disk", diskSeries[i], ts)
}
}
recordGuest := func(id string, cpuPercent, memPercent, diskPercent float64) {
if id == "" {
return
}
cpuSeries := generateSeededSeries(cpuPercent, points, hashSeed("guest", id, "cpu"), 0, 100)
memSeries := generateSeededSeries(memPercent, points, hashSeed("guest", id, "memory"), 0, 100)
diskSeries := generateSeededSeries(diskPercent, points, hashSeed("guest", id, "disk"), 0, 100)
for i := 0; i < points; i++ {
ts := start.Add(time.Duration(i) * interval)
mh.AddGuestMetric(id, "cpu", cpuSeries[i], ts)
mh.AddGuestMetric(id, "memory", memSeries[i], ts)
mh.AddGuestMetric(id, "disk", diskSeries[i], ts)
}
}
for _, node := range state.Nodes {
recordNode(node)
}
for _, vm := range state.VMs {
if vm.Status != "running" {
continue
}
recordGuest(vm.ID, vm.CPU*100, vm.Memory.Usage, vm.Disk.Usage)
}
for _, ct := range state.Containers {
if ct.Status != "running" {
continue
}
recordGuest(ct.ID, ct.CPU*100, ct.Memory.Usage, ct.Disk.Usage)
}
for _, storage := range state.Storage {
if storage.ID == "" {
continue
}
usageSeries := generateSeededSeries(storage.Usage, points, hashSeed("storage", storage.ID, "usage"), 0, 100)
for i := 0; i < points; i++ {
ts := start.Add(time.Duration(i) * interval)
mh.AddStorageMetric(storage.ID, "usage", usageSeries[i], ts)
mh.AddStorageMetric(storage.ID, "used", float64(storage.Used), ts)
mh.AddStorageMetric(storage.ID, "total", float64(storage.Total), ts)
mh.AddStorageMetric(storage.ID, "avail", float64(storage.Free), ts)
}
}
for _, host := range state.DockerHosts {
if host.ID == "" {
continue
}
var diskPercent float64
var usedTotal, totalTotal int64
for _, d := range host.Disks {
if d.Total > 0 {
usedTotal += d.Used
totalTotal += d.Total
}
}
if totalTotal > 0 {
diskPercent = float64(usedTotal) / float64(totalTotal) * 100
}
recordGuest("dockerHost:"+host.ID, host.CPUUsage, host.Memory.Usage, diskPercent)
for _, container := range host.Containers {
if container.ID == "" || container.State != "running" {
continue
}
var containerDisk float64
if container.RootFilesystemBytes > 0 && container.WritableLayerBytes > 0 {
containerDisk = float64(container.WritableLayerBytes) / float64(container.RootFilesystemBytes) * 100
containerDisk = clampFloat(containerDisk, 0, 100)
}
recordGuest("docker:"+container.ID, container.CPUPercent, container.MemoryPercent, containerDisk)
}
}
}
func recordMockStateToMetricsHistory(mh *MetricsHistory, state models.StateSnapshot, ts time.Time) {
if mh == nil {
return
}
for _, node := range state.Nodes {
if node.ID == "" || node.Status != "online" {
continue
}
mh.AddNodeMetric(node.ID, "cpu", node.CPU*100, ts)
mh.AddNodeMetric(node.ID, "memory", node.Memory.Usage, ts)
mh.AddNodeMetric(node.ID, "disk", node.Disk.Usage, ts)
}
for _, vm := range state.VMs {
if vm.ID == "" || vm.Status != "running" {
continue
}
mh.AddGuestMetric(vm.ID, "cpu", vm.CPU*100, ts)
mh.AddGuestMetric(vm.ID, "memory", vm.Memory.Usage, ts)
mh.AddGuestMetric(vm.ID, "disk", vm.Disk.Usage, ts)
mh.AddGuestMetric(vm.ID, "diskread", float64(vm.DiskRead), ts)
mh.AddGuestMetric(vm.ID, "diskwrite", float64(vm.DiskWrite), ts)
mh.AddGuestMetric(vm.ID, "netin", float64(vm.NetworkIn), ts)
mh.AddGuestMetric(vm.ID, "netout", float64(vm.NetworkOut), ts)
}
for _, ct := range state.Containers {
if ct.ID == "" || ct.Status != "running" {
continue
}
mh.AddGuestMetric(ct.ID, "cpu", ct.CPU*100, ts)
mh.AddGuestMetric(ct.ID, "memory", ct.Memory.Usage, ts)
mh.AddGuestMetric(ct.ID, "disk", ct.Disk.Usage, ts)
mh.AddGuestMetric(ct.ID, "diskread", float64(ct.DiskRead), ts)
mh.AddGuestMetric(ct.ID, "diskwrite", float64(ct.DiskWrite), ts)
mh.AddGuestMetric(ct.ID, "netin", float64(ct.NetworkIn), ts)
mh.AddGuestMetric(ct.ID, "netout", float64(ct.NetworkOut), ts)
}
for _, storage := range state.Storage {
if storage.ID == "" || storage.Status != "available" {
continue
}
mh.AddStorageMetric(storage.ID, "usage", storage.Usage, ts)
mh.AddStorageMetric(storage.ID, "used", float64(storage.Used), ts)
mh.AddStorageMetric(storage.ID, "total", float64(storage.Total), ts)
mh.AddStorageMetric(storage.ID, "avail", float64(storage.Free), ts)
}
for _, host := range state.DockerHosts {
if host.ID == "" || host.Status != "online" {
continue
}
var diskPercent float64
var usedTotal, totalTotal int64
for _, d := range host.Disks {
if d.Total > 0 {
usedTotal += d.Used
totalTotal += d.Total
}
}
if totalTotal > 0 {
diskPercent = float64(usedTotal) / float64(totalTotal) * 100
}
hostKey := "dockerHost:" + host.ID
mh.AddGuestMetric(hostKey, "cpu", host.CPUUsage, ts)
mh.AddGuestMetric(hostKey, "memory", host.Memory.Usage, ts)
mh.AddGuestMetric(hostKey, "disk", diskPercent, ts)
for _, container := range host.Containers {
if container.ID == "" || container.State != "running" {
continue
}
var containerDisk float64
if container.RootFilesystemBytes > 0 && container.WritableLayerBytes > 0 {
containerDisk = float64(container.WritableLayerBytes) / float64(container.RootFilesystemBytes) * 100
containerDisk = clampFloat(containerDisk, 0, 100)
}
metricKey := "docker:" + container.ID
mh.AddGuestMetric(metricKey, "cpu", container.CPUPercent, ts)
mh.AddGuestMetric(metricKey, "memory", container.MemoryPercent, ts)
mh.AddGuestMetric(metricKey, "disk", containerDisk, ts)
}
}
}
func (m *Monitor) startMockMetricsSampler(ctx context.Context) {
if ctx == nil || m == nil {
return
}
if !mock.IsMockEnabled() {
return
}
cfg := mockMetricsSamplerConfigFromEnv()
m.mu.Lock()
if m.mockMetricsCancel != nil {
m.mu.Unlock()
return
}
samplerCtx, cancel := context.WithCancel(ctx)
m.mockMetricsCancel = cancel
m.mu.Unlock()
m.metricsHistory.Reset()
state := mock.GetMockState()
seedMockMetricsHistory(m.metricsHistory, state, time.Now(), cfg.SeedDuration, cfg.SampleInterval)
recordMockStateToMetricsHistory(m.metricsHistory, state, time.Now())
m.mockMetricsWg.Add(1)
go func() {
defer m.mockMetricsWg.Done()
ticker := time.NewTicker(cfg.SampleInterval)
defer ticker.Stop()
for {
select {
case <-samplerCtx.Done():
return
case <-ticker.C:
if !mock.IsMockEnabled() {
continue
}
recordMockStateToMetricsHistory(m.metricsHistory, mock.GetMockState(), time.Now())
}
}
}()
log.Info().
Dur("seedDuration", cfg.SeedDuration).
Dur("sampleInterval", cfg.SampleInterval).
Msg("Mock metrics history sampler started")
}
func (m *Monitor) stopMockMetricsSampler() {
if m == nil {
return
}
m.mu.Lock()
cancel := m.mockMetricsCancel
m.mockMetricsCancel = nil
m.mu.Unlock()
if cancel != nil {
cancel()
m.mockMetricsWg.Wait()
log.Info().Msg("Mock metrics history sampler stopped")
}
}

View file

@ -0,0 +1,102 @@
package monitoring
import (
"math"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestSeedMockMetricsHistory_PopulatesSeries(t *testing.T) {
now := time.Now()
state := models.StateSnapshot{
Nodes: []models.Node{
{
ID: "node-1",
Status: "online",
CPU: 0.33,
Memory: models.Memory{Usage: 62, Total: 128 * 1024 * 1024 * 1024},
Disk: models.Disk{Usage: 41, Total: 1024, Used: 512},
},
},
VMs: []models.VM{
{
ID: "vm-100",
Status: "running",
CPU: 0.21,
Memory: models.Memory{Usage: 47, Total: 8 * 1024 * 1024 * 1024},
Disk: models.Disk{Usage: 28, Total: 1024, Used: 256},
},
},
Containers: []models.Container{
{
ID: "ct-200",
Status: "running",
CPU: 0.09,
Memory: models.Memory{Usage: 53, Total: 2 * 1024 * 1024 * 1024},
Disk: models.Disk{Usage: 17, Total: 512, Used: 128},
},
},
Storage: []models.Storage{
{
ID: "local",
Status: "available",
Total: 1000,
Used: 420,
Free: 580,
Usage: 42,
},
},
DockerHosts: []models.DockerHost{
{
ID: "host-1",
Status: "online",
CPUUsage: 22.5,
Memory: models.Memory{Usage: 58, Total: 16 * 1024 * 1024 * 1024},
Disks: []models.Disk{
{Total: 1000, Used: 600, Usage: 60},
},
Containers: []models.DockerContainer{
{
ID: "cont-1",
State: "running",
CPUPercent: 3.3,
MemoryPercent: 11.2,
WritableLayerBytes: 10,
RootFilesystemBytes: 100,
},
},
},
},
}
mh := NewMetricsHistory(1000, 24*time.Hour)
seedMockMetricsHistory(mh, state, now, time.Hour, 30*time.Second)
nodeCPU := mh.GetNodeMetrics("node-1", "cpu", time.Hour)
if len(nodeCPU) < 10 {
t.Fatalf("expected seeded node cpu points, got %d", len(nodeCPU))
}
if got, want := nodeCPU[len(nodeCPU)-1].Value, state.Nodes[0].CPU*100; math.Abs(got-want) > 1e-9 {
t.Fatalf("expected last node cpu point to match current, got=%v want=%v", got, want)
}
vmCPU := mh.GetGuestMetrics("vm-100", "cpu", time.Hour)
if len(vmCPU) < 10 {
t.Fatalf("expected seeded vm cpu points, got %d", len(vmCPU))
}
if got, want := vmCPU[len(vmCPU)-1].Value, state.VMs[0].CPU*100; math.Abs(got-want) > 1e-9 {
t.Fatalf("expected last vm cpu point to match current, got=%v want=%v", got, want)
}
dockerCPU := mh.GetGuestMetrics("docker:cont-1", "cpu", time.Hour)
if len(dockerCPU) < 10 {
t.Fatalf("expected seeded docker container cpu points, got %d", len(dockerCPU))
}
if got, want := dockerCPU[len(dockerCPU)-1].Value, state.DockerHosts[0].Containers[0].CPUPercent; math.Abs(got-want) > 1e-9 {
t.Fatalf("expected last docker cpu point to match current, got=%v want=%v", got, want)
}
}

View file

@ -655,6 +655,8 @@ type Monitor struct {
dlqInsightMap map[string]*dlqInsight
nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period)
resourceStore ResourceStoreInterface // Optional unified resource store for polling optimization
mockMetricsCancel context.CancelFunc
mockMetricsWg sync.WaitGroup
}
type rrdMemCacheEntry struct {
@ -3403,6 +3405,11 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
m.runtimeCtx = ctx
m.wsHub = wsHub
m.mu.Unlock()
defer m.stopMockMetricsSampler()
if mock.IsMockEnabled() {
m.startMockMetricsSampler(ctx)
}
// Initialize and start discovery service if enabled
if mock.IsMockEnabled() {
@ -7259,18 +7266,28 @@ func (m *Monitor) SetMockMode(enable bool) {
}
if enable {
m.stopMockMetricsSampler()
mock.SetEnabled(true)
m.alertManager.ClearActiveAlerts()
m.mu.Lock()
m.resetStateLocked()
m.metricsHistory.Reset()
m.mu.Unlock()
m.StopDiscoveryService()
m.mu.RLock()
ctx := m.runtimeCtx
m.mu.RUnlock()
if ctx != nil {
m.startMockMetricsSampler(ctx)
}
log.Info().Msg("Switched monitor to mock mode")
} else {
m.stopMockMetricsSampler()
mock.SetEnabled(false)
m.alertManager.ClearActiveAlerts()
m.mu.Lock()
m.resetStateLocked()
m.metricsHistory.Reset()
m.mu.Unlock()
log.Info().Msg("Switched monitor to real data mode")
}