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:
parent
0cd16b29cc
commit
987f7a3822
5 changed files with 658 additions and 30 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
411
internal/monitoring/mock_metrics_history.go
Normal file
411
internal/monitoring/mock_metrics_history.go
Normal 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")
|
||||
}
|
||||
}
|
||||
102
internal/monitoring/mock_metrics_history_test.go
Normal file
102
internal/monitoring/mock_metrics_history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue