diff --git a/ui/src/components/Dashboard/PanelOptionsDialog.vue b/ui/src/components/Dashboard/PanelOptionsDialog.vue index 05857a5f6b07..0850e7276c4e 100644 --- a/ui/src/components/Dashboard/PanelOptionsDialog.vue +++ b/ui/src/components/Dashboard/PanelOptionsDialog.vue @@ -97,7 +97,71 @@ License. - + + + Entity + + Entities that have data for the selected metric. + + + Metric + + + + + + + Rank by (KPI) + + + + Order + + + Descending (highest first) + + + + Ascending (lowest first) + + + + How many (N) + + + @@ -116,16 +180,20 @@ License. + + diff --git a/ui/src/components/Dashboard/panels/TopnPanel.vue b/ui/src/components/Dashboard/panels/TopnPanel.vue new file mode 100644 index 000000000000..8c18d0ff8542 --- /dev/null +++ b/ui/src/components/Dashboard/panels/TopnPanel.vue @@ -0,0 +1,152 @@ + + + + + + + Loading… + + + No data for {{ kpiLabel }} in this timeframe. + + + + {{ idx + 1 }} + {{ row.label }} + {{ format(row.value) }} {{ row.unit }} + + + + {{ kpiLabel }} · {{ direction === 'desc' ? 'highest' : 'lowest' }} {{ n }} + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index 6439a43f1e93..97a51289173b 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -87,6 +87,29 @@ export const panelRegistry: Record = { supportedFilters: ['surveillanceCategories', 'ipMatch'], renamable: true, collapsible: true + }, + topn: { + type: 'topn', + title: 'Top N', + category: 'status', + component: defineAsyncComponent(() => import('./panels/TopnPanel.vue')), + defaultSize: { w: 3, h: 4 }, + minSize: { w: 2, h: 2 }, + supportsTimeframe: true, + renamable: true, + collapsible: true + }, + 'metric-chart': { + type: 'metric-chart', + defaultHeightMode: 'fixed', + title: 'Metric Chart', + category: 'status', + component: defineAsyncComponent(() => import('./panels/MetricChartPanel.vue')), + defaultSize: { w: 6, h: 5 }, + minSize: { w: 3, h: 3 }, + supportsTimeframe: true, + renamable: true, + collapsible: true } } diff --git a/ui/src/services/metricChartService.ts b/ui/src/services/metricChartService.ts new file mode 100644 index 000000000000..9cf9b8daa4dc --- /dev/null +++ b/ui/src/services/metricChartService.ts @@ -0,0 +1,95 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import { rest } from './axiosInstances' +import type { Timeframe } from '@/types/dashboard' +import { TOPN_KPIS, listKpiSources } from './topnService' +import { timeframeRange } from '@/components/Dashboard/timeframe' + +// Metric-chart panel: one entity x one metric over the resolved timeframe. +// Metrics are the same registry the Top-N panel uses (TOPN_KPIS). +export const DEFAULT_CHART_METRIC = 'response-time' +export const DEFAULT_CHART_ENTITY = 'localhost' + +export interface MetricSeries { + timestamps: number[] + values: (number | null)[] // null = gap (NaN from RRD); chart renders a break + unit: string +} + +// Entity labels that carry the given metric, for the options dropdown. +export const listMetricEntities = async (metricId: string): Promise => { + try { + const sources = await listKpiSources(metricId) + return [...new Set(sources.map(s => s.label))].sort((a, b) => a.localeCompare(b)) + } catch { + return [] + } +} + +export const queryMetricSeries = async ( + metricId: string, + entityLabel: string, + timeframe: Timeframe +): Promise => { + const kpi = TOPN_KPIS.find(k => k.id === metricId) ?? TOPN_KPIS[0] + try { + const sources = await listKpiSources(kpi.id) + const source = + sources.find(s => s.label === entityLabel) ?? + sources.find(s => s.label.toLowerCase() === entityLabel.toLowerCase()) + if (!source) { + return null + } + + const { start, end } = timeframeRange(timeframe) + // ~200 points; never below the 5-min collection interval (a single huge + // bucket returns NaN from RRD — same constraint as Top-N). + const step = Math.max(300_000, Math.floor((end - start) / 200)) + const payload = { + start, + end, + step, + maxrows: 2000, + relaxed: true, + source: [ + { + label: 'm', + resourceId: source.resourceId, + attribute: source.attribute, + aggregation: 'AVERAGE', + transient: false + } + ] + } + const resp = await rest.post('/measurements', payload, { headers: { Accept: 'application/json' }}) + const timestamps: number[] = resp.data?.timestamps ?? [] + const raw: number[] = resp.data?.columns?.[0]?.values ?? [] + const values = raw.map(v => (Number.isFinite(v) ? v * kpi.scale : null)) + if (!timestamps.length || values.every(v => v === null)) { + return null + } + return { timestamps, values, unit: kpi.unit } + } catch { + return null + } +} diff --git a/ui/src/services/topnService.ts b/ui/src/services/topnService.ts new file mode 100644 index 000000000000..04fb2708aea9 --- /dev/null +++ b/ui/src/services/topnService.ts @@ -0,0 +1,153 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import { rest } from './axiosInstances' +import { type Timeframe } from '@/types/dashboard' +import { timeframeRange } from '@/components/Dashboard/timeframe' + +// A KPI describes how to find the measurement source for each ranked entity and +// how to present its value. Extensible — add interface traffic, CPU, etc. here. +export interface TopnKpiDef { + id: string + label: string + unit: string + scale: number // multiply the raw RRD value (e.g. icmp is microseconds -> ms) + // returns the attribute name to query if this child resource carries the KPI + match: (childResourceId: string, attributeKeys: string[]) => string | null +} + +export const TOPN_KPIS: TopnKpiDef[] = [ + { + id: 'response-time', + label: 'Node Response Time (ICMP)', + unit: 'ms', + scale: 0.001, + match: (id, attrs) => (id.includes('responseTime') && attrs.includes('icmp') ? 'icmp' : null) + } +] + +export const DEFAULT_TOPN_KPI = 'response-time' +export const DEFAULT_TOPN_N = 5 + +export interface TopnRow { + label: string + value: number + unit: string +} + +const MAX_SOURCES = 250 // cap candidate resources per query + + +interface RawResource { + id?: string + label?: string + rrdGraphAttributes?: Record + children?: { resource?: RawResource[] } +} + +export interface MeasurementSource { + resourceId: string + attribute: string + label: string +} + +const collectSources = (root: { resource?: RawResource[] }, kpi: TopnKpiDef): MeasurementSource[] => { + const out: MeasurementSource[] = [] + for (const node of root?.resource ?? []) { + const nodeLabel = node.label ?? node.id ?? 'node' + for (const child of node.children?.resource ?? []) { + const attrs = Object.keys(child.rrdGraphAttributes ?? {}) + const attribute = kpi.match(child.id ?? '', attrs) + if (attribute && child.id) { + out.push({ resourceId: child.id, attribute, label: nodeLabel }) + } + } + } + return out.slice(0, MAX_SOURCES) +} + +// All entities (resources) carrying the given KPI — shared by Top-N and the metric chart. +export const listKpiSources = async (kpiId: string): Promise => { + const kpi = TOPN_KPIS.find(k => k.id === kpiId) ?? TOPN_KPIS[0] + const tree = await rest.get('/resources?depth=2', { headers: { Accept: 'application/json' }}) + return collectSources(tree.data ?? {}, kpi) +} + +export const queryTopn = async ( + kpiId: string, + timeframe: Timeframe, + n: number, + direction: 'asc' | 'desc' +): Promise => { + const kpi = TOPN_KPIS.find(k => k.id === kpiId) ?? TOPN_KPIS[0] + try { + const sources = await listKpiSources(kpi.id) + if (!sources.length) { + return [] + } + + const { start, end } = timeframeRange(timeframe) + // Use a normal resolution step (a single huge bucket returns NaN from RRD); + // the series is averaged below. Cap to ~1000 points per source. + const step = Math.max(300_000, Math.floor((end - start) / 1000)) + const payload = { + start, + end, + step, + maxrows: 2000, + relaxed: true, + source: sources.map((s, i) => ({ + label: `s${i}`, + resourceId: s.resourceId, + attribute: s.attribute, + aggregation: 'AVERAGE', + transient: false + })) + } + const resp = await rest.post('/measurements', payload, { headers: { Accept: 'application/json' }}) + const labels: string[] = resp.data?.labels ?? [] + const columns: { values?: number[] }[] = resp.data?.columns ?? [] + + const rows: TopnRow[] = [] + labels.forEach((label, i) => { + // the backend returns labels/columns in HashMap (hash) order, NOT request + // order, so column i does not map to sources[i]. Each label is the "s{k}" + // we sent, which encodes the true source index — map through that. + const sourceIndex = /^s(\d+)$/.test(label) ? Number(label.slice(1)) : i + const source = sources[sourceIndex] + if (!source) { + return + } + const values = (columns[i]?.values ?? []).filter(v => Number.isFinite(v)) + if (!values.length) { + return + } + const avg = values.reduce((a, b) => a + b, 0) / values.length + rows.push({ label: source.label, value: avg * kpi.scale, unit: kpi.unit }) + }) + + rows.sort((a, b) => (direction === 'asc' ? a.value - b.value : b.value - a.value)) + return rows.slice(0, Math.max(1, n)) + } catch (_err) { + return [] + } +} diff --git a/ui/tests/services/topnService.test.ts b/ui/tests/services/topnService.test.ts new file mode 100644 index 000000000000..6473ad181b95 --- /dev/null +++ b/ui/tests/services/topnService.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { queryTopn } from '@/services/topnService' +import { rest } from '@/services/axiosInstances' +import { TimeframePreset } from '@/types/dashboard' + +vi.mock('@/services/axiosInstances', () => ({ + rest: { get: vi.fn(), post: vi.fn() } +})) + +const tf = { preset: TimeframePreset.Last24h, from: null, to: null } + +// two nodes each carrying the ICMP response-time attribute +const resourceTree = { + data: { + resource: [ + { id: 'node[1]', label: 'node-A', children: { resource: [{ id: 'node[1].responseTime[10.0.0.1]', rrdGraphAttributes: { icmp: {}}}] }}, + { id: 'node[2]', label: 'node-B', children: { resource: [{ id: 'node[2].responseTime[10.0.0.2]', rrdGraphAttributes: { icmp: {}}}] }} + ] + } +} + +describe('queryTopn column-to-source mapping', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(rest.get).mockResolvedValue(resourceTree) + }) + afterEach(() => vi.restoreAllMocks()) + + it('attributes each measurement to the source its label encodes, not its position', async () => { + // backend returns columns in HASH order: s1 (node-B) first, s0 (node-A) second + vi.mocked(rest.post).mockResolvedValue({ + data: { + labels: ['s1', 's0'], + columns: [ + { values: [200000, 200000] }, // s1 => node-B => 200ms + { values: [50000, 50000] } // s0 => node-A => 50ms + ] + } + }) + + const rows = await queryTopn('response-time', tf, 5, 'desc') + + const byLabel = Object.fromEntries(rows.map(r => [r.label, Math.round(r.value)])) + expect(byLabel['node-A']).toBe(50) + expect(byLabel['node-B']).toBe(200) + }) + + it('ranks descending and honors n', async () => { + vi.mocked(rest.post).mockResolvedValue({ + data: { labels: ['s0', 's1'], columns: [{ values: [50000] }, { values: [200000] }] } + }) + const rows = await queryTopn('response-time', tf, 1, 'desc') + expect(rows).toHaveLength(1) + expect(rows[0].label).toBe('node-B') + }) + + it('returns empty when there are no sources', async () => { + vi.mocked(rest.get).mockResolvedValue({ data: { resource: [] }}) + expect(await queryTopn('response-time', tf, 5, 'desc')).toEqual([]) + }) +})
+ Loading… +
+ No data for {{ kpiLabel }} in this timeframe. +
+ {{ kpiLabel }} · {{ direction === 'desc' ? 'highest' : 'lowest' }} {{ n }} +