From 10347cbcf4f9be04c124e2d2872d9ef7442dd3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Tue, 5 Nov 2024 14:31:22 +0100 Subject: [PATCH] feat(front): resources/jobs charts in dashboard Display charts of resources (nodes/cores) status and jobs queue in dashboard page based on metrics from Prometheus, when metrics feature is enabled. fix #275 --- CHANGELOG.md | 2 + frontend/public/chart_placeholder.svg | 1 + .../dashboard/ChartJobsHistogram.vue | 52 +++++ .../dashboard/ChartResourcesHistogram.vue | 97 ++++++++ .../components/dashboard/DashboardCharts.vue | 82 +++++++ frontend/src/composables/DataGetter.ts | 25 +- frontend/src/composables/DataPoller.ts | 32 ++- frontend/src/composables/GatewayAPI.ts | 54 ++++- .../src/composables/dashboard/LiveChart.ts | 213 ++++++++++++++++++ frontend/src/stores/runtime.ts | 33 ++- frontend/src/views/DashboardView.vue | 5 + 11 files changed, 585 insertions(+), 11 deletions(-) create mode 100644 frontend/public/chart_placeholder.svg create mode 100644 frontend/src/components/dashboard/ChartJobsHistogram.vue create mode 100644 frontend/src/components/dashboard/ChartResourcesHistogram.vue create mode 100644 frontend/src/components/dashboard/DashboardCharts.vue create mode 100644 frontend/src/composables/dashboard/LiveChart.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e31591d5..92beece1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Display service message below login form if defined (#253). - Add dependency on _charts.js_ and _luxon_ adapter to draw charts with timeseries metrics. + - Display charts of resources (nodes/cores) status and jobs queue in dashboard + page based on metrics from Prometheus (#275). - conf: - Add `racksdb` > `infrastructure` parameter for the agent. - Add `metrics` > `enabled` parameter for the agent. diff --git a/frontend/public/chart_placeholder.svg b/frontend/public/chart_placeholder.svg new file mode 100644 index 00000000..a69665b2 --- /dev/null +++ b/frontend/public/chart_placeholder.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/dashboard/ChartJobsHistogram.vue b/frontend/src/components/dashboard/ChartJobsHistogram.vue new file mode 100644 index 00000000..ed2b68e9 --- /dev/null +++ b/frontend/src/components/dashboard/ChartJobsHistogram.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/components/dashboard/ChartResourcesHistogram.vue b/frontend/src/components/dashboard/ChartResourcesHistogram.vue new file mode 100644 index 00000000..2ec65c2c --- /dev/null +++ b/frontend/src/components/dashboard/ChartResourcesHistogram.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/dashboard/DashboardCharts.vue b/frontend/src/components/dashboard/DashboardCharts.vue new file mode 100644 index 00000000..9a0dfed3 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardCharts.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/composables/DataGetter.ts b/frontend/src/composables/DataGetter.ts index ea052225..0109bf3d 100644 --- a/frontend/src/composables/DataGetter.ts +++ b/frontend/src/composables/DataGetter.ts @@ -70,9 +70,11 @@ export function useGatewayDataGetter( } export function useClusterDataGetter( - callback: GatewayAnyClusterApiKey, - otherParam?: string | number + initialCallback: GatewayAnyClusterApiKey, + initialOtherParam?: string | number ) { + let callback = initialCallback + let otherParam = initialOtherParam const data: Ref = ref() const unable: Ref = ref(false) const loaded: Ref = ref(false) @@ -98,7 +100,6 @@ export function useClusterDataGetter( async function get(cluster: string) { try { unable.value = false - if (gateway.isValidGatewayClusterWithStringAPIKey(callback)) { data.value = (await gateway[callback](cluster, otherParam as string)) as Type } else if (gateway.isValidGatewayClusterWithNumberAPIKey(callback)) { @@ -124,6 +125,22 @@ export function useClusterDataGetter( } } + function setCallback(newCallback: GatewayAnyClusterApiKey) { + callback = newCallback + loaded.value = false + if (runtime.currentCluster) { + get(runtime.currentCluster.name) + } + } + + function setParam(newOtherParam: string | number) { + otherParam = newOtherParam + loaded.value = false + if (runtime.currentCluster) { + get(runtime.currentCluster.name) + } + } + watch( () => runtime.currentCluster, (newCluster, oldCluster) => { @@ -141,5 +158,5 @@ export function useClusterDataGetter( get(runtime.currentCluster.name) } }) - return { data, unable, loaded } + return { data, unable, loaded, setCallback, setParam } } diff --git a/frontend/src/composables/DataPoller.ts b/frontend/src/composables/DataPoller.ts index 57657948..7413e5b5 100644 --- a/frontend/src/composables/DataPoller.ts +++ b/frontend/src/composables/DataPoller.ts @@ -14,17 +14,21 @@ import { useGatewayAPI } from '@/composables/GatewayAPI' import type { GatewayAnyClusterApiKey } from '@/composables/GatewayAPI' import { useRuntimeStore } from '@/stores/runtime' -type ClusterDataPoller = { - data: Ref +export interface ClusterDataPoller { + data: Ref unable: Ref loaded: Ref + setCallback: (newCallback: GatewayAnyClusterApiKey) => void + setParam: (newOtherParam: string | number) => void } export function useClusterDataPoller( - callback: GatewayAnyClusterApiKey, + initialCallback: GatewayAnyClusterApiKey, timeout: number, - otherParam?: number | string + initialOtherParam?: number | string ): ClusterDataPoller { + let callback = initialCallback + let otherParam = initialOtherParam const data: Ref = ref() const unable: Ref = ref(false) const loaded: Ref = ref(false) @@ -95,6 +99,24 @@ export function useClusterDataPoller( gateway.abort() } + function setCallback(newCallback: GatewayAnyClusterApiKey) { + if (runtime.currentCluster) stop(runtime.currentCluster.name) + callback = newCallback + loaded.value = false + if (runtime.currentCluster) { + start(runtime.currentCluster.name) + } + } + + function setParam(newOtherParam: string | number) { + if (runtime.currentCluster) stop(runtime.currentCluster.name) + otherParam = newOtherParam + loaded.value = false + if (runtime.currentCluster) { + start(runtime.currentCluster.name) + } + } + watch( () => runtime.currentCluster, (newCluster, oldCluster) => { @@ -123,5 +145,5 @@ export function useClusterDataPoller( } }) - return { data, unable, loaded } + return { data, unable, loaded, setCallback, setParam } } diff --git a/frontend/src/composables/GatewayAPI.ts b/frontend/src/composables/GatewayAPI.ts index c78a08af..51203bea 100644 --- a/frontend/src/composables/GatewayAPI.ts +++ b/frontend/src/composables/GatewayAPI.ts @@ -24,6 +24,7 @@ interface loginIdents { export interface ClusterDescription { name: string infrastructure: string + metrics: boolean permissions: ClusterPermissions stats?: ClusterStats } @@ -255,6 +256,22 @@ export interface ClusterReservation { flags: string[] } +export type MetricValue = [number, number] +const MetricRanges = ['week', 'day', 'hour'] as const +export type MetricRange = (typeof MetricRanges)[number] +export type MetricResourceState = 'idle' | 'down' | 'mixed' | 'allocated' | 'drain' | 'unknown' +export type MetricJobState = + | 'unknown' + | 'cancelled' + | 'completed' + | 'completing' + | 'running' + | 'pending' + +export function isMetricRange(range: unknown): range is MetricRange { + return typeof range === 'string' && MetricRanges.includes(range as MetricRange) +} + export function renderClusterOptionalNumber(optionalNumber: ClusterOptionalNumber): string { if (!optionalNumber.set) { return '-' @@ -356,7 +373,12 @@ const GatewayClusterAPIKeys = [ export type GatewayClusterAPIKey = (typeof GatewayClusterAPIKeys)[number] const GatewayClusterWithNumberAPIKeys = ['job'] as const export type GatewayClusterWithNumberAPIKey = (typeof GatewayClusterWithNumberAPIKeys)[number] -const GatewayClusterWithStringAPIKeys = ['node'] as const +const GatewayClusterWithStringAPIKeys = [ + 'node', + 'metrics_nodes', + 'metrics_cores', + 'metrics_jobs' +] as const export type GatewayClusterWithStringAPIKey = (typeof GatewayClusterWithStringAPIKeys)[number] export type GatewayAnyClusterApiKey = | GatewayClusterAPIKey @@ -525,6 +547,33 @@ export function useGatewayAPI() { return await get(`/agents/${cluster}/accounts`) } + async function metrics_nodes( + cluster: string, + last: string + ): Promise> { + return await get>( + `/agents/${cluster}/metrics/nodes?range=${last}` + ) + } + + async function metrics_cores( + cluster: string, + last: string + ): Promise> { + return await get>( + `/agents/${cluster}/metrics/cores?range=${last}` + ) + } + + async function metrics_jobs( + cluster: string, + last: string + ): Promise> { + return await get>( + `/agents/${cluster}/metrics/jobs?range=${last}` + ) + } + async function infrastructureImagePng( cluster: string, infrastructure: string, @@ -599,6 +648,9 @@ export function useGatewayAPI() { qos, reservations, accounts, + metrics_nodes, + metrics_cores, + metrics_jobs, infrastructureImagePng, abort, isValidGatewayGenericAPIKey, diff --git a/frontend/src/composables/dashboard/LiveChart.ts b/frontend/src/composables/dashboard/LiveChart.ts new file mode 100644 index 00000000..afcd3c2f --- /dev/null +++ b/frontend/src/composables/dashboard/LiveChart.ts @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2023-2024 Rackslab + * + * This file is part of Slurm-web. + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { watch, onMounted } from 'vue' +import type { Ref } from 'vue' +import type { GatewayAnyClusterApiKey } from '@/composables/GatewayAPI' +import { useRuntimeStore } from '@/stores/runtime' +import { useClusterDataPoller } from '@/composables/DataPoller' +import type { ClusterDataPoller } from '@/composables/DataPoller' +import type { MetricValue } from '@/composables/GatewayAPI' +import { Chart } from 'chart.js/auto' +import type { ChartOptions, TimeScaleOptions, TimeUnit } from 'chart.js' +import 'chartjs-adapter-luxon' +import { DateTime } from 'luxon' +import type { Point } from 'node_modules/chart.js/dist/core/core.controller' + +export interface DashboardLiveChart { + metrics: ClusterDataPoller> + setCallback: (callback: GatewayAnyClusterApiKey) => void +} + +export function useDashboardLiveChart( + callback: GatewayAnyClusterApiKey, + chartCanvas: Ref, + stateColors: Record, + possibleStates: MetricKeyType[] +): DashboardLiveChart { + const runtimeStore = useRuntimeStore() + const metrics = useClusterDataPoller>( + callback, + 30000, + runtimeStore.dashboard.range + ) + let chart: Chart | null + + /* Update charts datasets when metrics values change. */ + watch( + () => metrics.data.value, + () => { + /* If chart is null, stop here. */ + if (!chart) return + + /* If poller data is undefined, just set an empty dataset and leave. */ + if (!metrics.data.value) { + chart.data.datasets = [] + return + } + + for (const state of possibleStates) { + /* If current state is not present in poller data keys, skip it. */ + if (!(state in metrics.data.value)) continue + /* Compute new data array */ + const new_data = metrics.data.value[state as MetricKeyType].map((value) => ({ + x: value[0], + y: value[1] + })) + /* Search for existing dataset which has the current state as label */ + const matching_datasets = chart.data.datasets.filter((dataset) => dataset.label == state) + if (!matching_datasets.length) { + /* If matching dataset has not been found, push a new dataset with all + * its parameters. */ + chart.data.datasets.push({ + label: state, + data: new_data, + barPercentage: 1, + fill: 'stack', + backgroundColor: stateColors[state as MetricKeyType] + }) + continue + } else { + /* If matching dataset has been found, get the timestamp of the last + * datapoint. */ + const last_timestamp = (matching_datasets[0].data.slice(-1)[0] as Point).x + /* Iterate over new data to insert in the dataset only the datapoints + * with a timestamp after the timestamp of the last datapoint in + * current dataset, and count inserted values. */ + let nb_new_values = 0 + new_data.forEach((item) => { + if (item.x > last_timestamp) { + matching_datasets[0].data.push(item) + nb_new_values += 1 + } + }) + /* Remove n datapoints from the beginning of the dataset, where n is + * the number of the inserted points, in order to keep a consistent + * number of datapoints. */ + matching_datasets[0].data.splice(0, nb_new_values) + } + } + /* Update suggested min and unit of x-axis. */ + if (chart.options.scales && chart.options.scales.x) { + chart.options.scales.x.suggestedMin = suggestedMin() + ;(chart.options.scales.x as TimeScaleOptions).time.unit = timeframeUnit() + } + /* Finally update the chart. */ + chart.update() + } + ) + + /* Clear chart datasets and set new poller param when dashboard range is + * modified. */ + watch( + () => runtimeStore.dashboard.range, + () => { + if (chart) chart.data.datasets = [] + metrics.setParam(runtimeStore.dashboard.range) + } + ) + + /* Compute the suggested min of the x-axis depending on the current dashboard + * range. */ + function suggestedMin() { + const now = Date.now() + let result = 0 + if (runtimeStore.dashboard.range == 'hour') { + result = now - 60 * 60 * 1000 + } + if (runtimeStore.dashboard.range == 'day') { + result = now - 24 * 60 * 60 * 1000 + } + if (runtimeStore.dashboard.range == 'week') { + result = now - 7 * 24 * 60 * 60 * 1000 + } + return result + } + + /* Determine the timeframe unit of the x-axis depending on the current + * dashboard range. */ + function timeframeUnit(): TimeUnit { + if (runtimeStore.dashboard.range == 'hour') { + return 'minute' + } + return 'hour' + } + + /* Determine ticks labels on y-axis */ + function yTicksCallback(value: number | string) { + /* y-axis represent nodes, cores or jobs, select only integers values */ + if (typeof value !== 'number') return value + if (value % 1 === 0) { + return value + } + } + + /* Determine ticks labels on x-axis. */ + function xTicksCallback(value: number | string) { + if (typeof value === 'number') { + const dt = DateTime.fromMillis(value) + // localized time simple every five minutes with hour range. + if (runtimeStore.dashboard.range == 'hour' && value % (1000 * 60 * 5) === 0) + return dt.toLocaleString(DateTime.TIME_SIMPLE) + // localized time simple every hours with day range. + if (runtimeStore.dashboard.range == 'day' && value % (1000 * 60 * 60) === 0) + return dt.toLocaleString(DateTime.TIME_SIMPLE) + // localized numeric day time at midnight and empty tick at noon. + if (runtimeStore.dashboard.range == 'week') { + if (value % (1000 * 60 * 60 * 24) === 0) { + return dt.toLocaleString({ month: 'numeric', day: 'numeric' }) + } + if (value % (1000 * 60 * 60 * 12) === 0) { + return '' + } + } + } + } + + const genericOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + stacked: true, + beginAtZero: true, + ticks: { + callback: yTicksCallback + } + }, + x: { + type: 'time', + stacked: true, + grid: { + offset: false + }, + ticks: { + callback: xTicksCallback + } + } + } + } + + /* Clear chart datasets and set new metrics callback */ + function setCallback(callback: GatewayAnyClusterApiKey) { + if (chart) chart.data.datasets = [] + metrics.setCallback(callback) + } + + onMounted(() => { + if (chartCanvas.value) { + chart = new Chart(chartCanvas.value, { + type: 'bar', + data: { datasets: [] }, + options: genericOptions + }) + } + }) + + return { metrics, setCallback } +} diff --git a/frontend/src/stores/runtime.ts b/frontend/src/stores/runtime.ts index d4d4b8cd..1372a423 100644 --- a/frontend/src/stores/runtime.ts +++ b/frontend/src/stores/runtime.ts @@ -11,7 +11,36 @@ import { ref } from 'vue' import type { Ref } from 'vue' import type { RouteLocation } from 'vue-router' import { getNodeMainState } from '@/composables/GatewayAPI' -import type { ClusterDescription, ClusterJob, ClusterNode } from '@/composables/GatewayAPI' +import type { + ClusterDescription, + ClusterJob, + ClusterNode, + MetricRange +} from '@/composables/GatewayAPI' + +/* + * Dashboard view settings + */ + +interface DashboardQueryParameters { + range?: string + cores?: boolean +} + +class DashboardViewSettings { + range: MetricRange = 'hour' + coresToggle = false + query() { + const result: DashboardQueryParameters = {} + if (this.range != 'hour') { + result.range = this.range + } + if (this.coresToggle) { + result.cores = this.coresToggle + } + return result + } +} /* * Jobs view settings @@ -277,6 +306,7 @@ export const useRuntimeStore = defineStore('runtime', () => { const routePath: Ref = ref('/') const beforeSettingsRoute: Ref = ref(undefined) + const dashboard = ref(new DashboardViewSettings()) const jobs: Ref = ref(new JobsViewSettings()) const resources: Ref = ref(new ResourcesViewSettings()) @@ -336,6 +366,7 @@ export const useRuntimeStore = defineStore('runtime', () => { navigation, routePath, beforeSettingsRoute, + dashboard, jobs, resources, errors, diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index c1d4d14b..50082d3a 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -8,10 +8,14 @@