From 09b02e34c32156d5d92eb888830ce9d061f2d340 Mon Sep 17 00:00:00 2001 From: Cannon Lock Date: Wed, 5 Feb 2025 14:26:50 -0600 Subject: [PATCH 1/7] Better Origin metric integration on the Director - Add Origin metric page to the director - Add click through interactions on data points to allow investigation to outlier servers --- .../metrics/components/MetricBoxPlot.tsx | 37 ++++ .../metrics/components/StorageTable.tsx | 9 +- .../metrics/components/TransferBarGraph.tsx | 4 +- .../app/director/metrics/origin/page.tsx | 23 +++ web_ui/frontend/app/origin/metrics/page.tsx | 153 +-------------- .../components/graphs/GraphContext.tsx | 11 +- .../components/graphs/OriginMetricPage.tsx | 182 ++++++++++++++++++ .../frontend/components/graphs/prometheus.tsx | 21 ++ .../frontend/components/metrics/CPUGraph.tsx | 9 +- .../components/metrics/MemoryGraph.tsx | 7 +- .../components/metrics/ProjectsTable.tsx | 18 +- .../components/metrics/StorageGraph.tsx | 18 +- .../components/metrics/TransferRateGraph.tsx | 14 +- 13 files changed, 339 insertions(+), 167 deletions(-) create mode 100644 web_ui/frontend/app/director/metrics/origin/page.tsx create mode 100644 web_ui/frontend/components/graphs/OriginMetricPage.tsx diff --git a/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx b/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx index ee2ad7217..f274d67ee 100644 --- a/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx +++ b/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx @@ -1,5 +1,6 @@ 'use client'; +import Link from 'next/link'; import { Chart } from 'react-chartjs-2'; import { CategoryScale, @@ -19,6 +20,7 @@ import { TooltipLabelStyle, TooltipItem, } from 'chart.js'; +import { getRelativePosition } from 'chart.js/helpers'; import { useContext, useEffect, useMemo, useState } from 'react'; import { BoxAndWiskers, @@ -43,6 +45,7 @@ import { toBytesString, } from '@/helpers/bytes'; import { evaluateOrReturn, TypeOrTypeFunction } from '@/helpers/util'; +import { useRouter } from 'next/navigation'; ChartJS.register( BoxPlotController, @@ -69,6 +72,7 @@ export const BytesMetricBoxPlot = ({ title: string; options?: ChartOptions; }) => { + const router = useRouter(); const { rate, time, resolution, range } = useContext(GraphContext); const { data } = useSWR( @@ -118,6 +122,22 @@ export const BytesMetricBoxPlot = ({ type: 'logarithmic', }, }, + onClick: function (evt: any, item: any) { + if (item.length === 0) { + return; + } + const position = getRelativePosition(evt, evt.Chart) as any; // Typing is wrong + try { + const serverName = + position.chart.tooltip.body[0].lines[0].split(':')[0]; + if (serverName != 'Missing Server Name') { + router.push( + '/director/metrics/origin/?server_name=' + + position.chart.tooltip.body[0].lines[0].split(':')[0] + ); + } + } catch {} + }, plugins: { tooltip: { callbacks: { @@ -153,6 +173,7 @@ export const MetricBoxPlot = ({ title: string; options?: ChartOptions; }) => { + const router = useRouter(); const { rate, time, resolution, range } = useContext(GraphContext); const { data } = useSWR( @@ -187,6 +208,22 @@ export const MetricBoxPlot = ({ type: 'logarithmic', }, }, + onClick: function (evt: any, item: any) { + if (item.length === 0) { + return; + } + const position = getRelativePosition(evt, evt.Chart) as any; // Typing is broken here for the Chartjs library + try { + const serverName = + position.chart.tooltip.body[0].lines[0].split(':')[0]; + if (serverName != 'Missing Server Name') { + router.push( + '/director/metrics/origin/?server_name=' + + position.chart.tooltip.body[0].lines[0].split(':')[0] + ); + } + } catch {} + }, plugins: { tooltip: { callbacks: { diff --git a/web_ui/frontend/app/director/metrics/components/StorageTable.tsx b/web_ui/frontend/app/director/metrics/components/StorageTable.tsx index 0b1413a45..ac5791d08 100644 --- a/web_ui/frontend/app/director/metrics/components/StorageTable.tsx +++ b/web_ui/frontend/app/director/metrics/components/StorageTable.tsx @@ -1,5 +1,6 @@ 'use client'; +import Link from 'next/link'; import { Box, Table, @@ -86,7 +87,13 @@ export const StorageTable = () => { }) .map((d) => ( - {d.serverName} + + + {d.serverName} + + { const { data } = useSWR>( [ - 'transferRateGraph', + 'transferBarGraph', graphContext.rate, graphContext.range, graphContext.resolution, @@ -73,6 +73,8 @@ const TransferBarGraph = () => { } ); + console.log(data); + return ( { + const params = useSearchParams(); + const serverName = params.get('server_name') || undefined; + + return ; +}; + +const Page = () => { + return ( + }> + + + ); +}; + +export default Page; diff --git a/web_ui/frontend/app/origin/metrics/page.tsx b/web_ui/frontend/app/origin/metrics/page.tsx index 54542eb8c..ffbe45353 100644 --- a/web_ui/frontend/app/origin/metrics/page.tsx +++ b/web_ui/frontend/app/origin/metrics/page.tsx @@ -1,154 +1,5 @@ -import { Box, Grid, Paper, Typography } from '@mui/material'; -import { green, grey, blue } from '@mui/material/colors'; +import OriginMetricPage from '@/components/graphs/OriginMetricPage'; -import { - BigMetric, - ProjectTable, - BigBytesMetric, - StorageGraph, - TransferRateGraph, - CPUGraph, - MemoryGraph, -} from '@/components/metrics'; - -const Page = () => { - return ( - - - - {[ - , - , - , - , - ].map((component, index) => ( - - {component} - - ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; +const Page = () => ; export default Page; diff --git a/web_ui/frontend/components/graphs/GraphContext.tsx b/web_ui/frontend/components/graphs/GraphContext.tsx index d222cc515..bcb065a63 100644 --- a/web_ui/frontend/components/graphs/GraphContext.tsx +++ b/web_ui/frontend/components/graphs/GraphContext.tsx @@ -154,10 +154,19 @@ function graphReducer(state: GraphContextType, action: GraphReducerAction) { // Update the current windows url to reflect the new state if (window !== undefined) { + // Read in the current url params + const urlParams = new URLSearchParams(window.location.search); + // Update the time and range params + let time = newState.time.toISO(); + if (time) { + urlParams.set('time', time); + } + urlParams.set('range', newState.range.toString()); + window.history.pushState( {}, '', - `?time=${newState.time.toISO()}&range=${newState.range.toString()}` + urlParams.toString() ? `?${urlParams.toString()}` : '' ); } diff --git a/web_ui/frontend/components/graphs/OriginMetricPage.tsx b/web_ui/frontend/components/graphs/OriginMetricPage.tsx new file mode 100644 index 000000000..c8bf4afec --- /dev/null +++ b/web_ui/frontend/components/graphs/OriginMetricPage.tsx @@ -0,0 +1,182 @@ +import { Box, Grid, Paper, Typography } from '@mui/material'; +import { green, grey, blue } from '@mui/material/colors'; + +import { + ProjectTable, + TransferRateGraph, +} from '@/app/origin/metrics/components'; +import { CPUGraph } from '@/app/origin/metrics/components/CPUGraph'; +import { MemoryGraph } from '@/app/origin/metrics/components/MemoryGraph'; +import { + BigBytesMetric, + BigMetric, +} from '@/app/origin/metrics/components/BigNumber'; +import { StorageGraph } from '@/app/origin/metrics/components/StorageGraph'; +import { buildMetric as bm } from '@/components/graphs/prometheus'; + +export const OriginMetricPage = ({ + server_name = undefined, +}: { + server_name?: string; +}) => { + return ( + + + + {[ + , + , + , + , + ].map((component, index) => ( + + {component} + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default OriginMetricPage; diff --git a/web_ui/frontend/components/graphs/prometheus.tsx b/web_ui/frontend/components/graphs/prometheus.tsx index 171675dc0..f3e9a7221 100644 --- a/web_ui/frontend/components/graphs/prometheus.tsx +++ b/web_ui/frontend/components/graphs/prometheus.tsx @@ -328,3 +328,24 @@ export const fillArrayNulls = ( return [parseInt(key), value]; }); }; + +export const buildMetric = ( + metric: string, + args: Record< + string, + string | { comparator: '=' | '!='; value: string } | undefined + > +) => { + return `${metric}{${Object.entries(args) + .reduce((acc: string[], [key, value]) => { + if (typeof value === 'string') { + acc.push(`${key}="${value}"`); + } else if (value === undefined) { + } else { + acc.push(`${key}${value.comparator}"${value.value}"`); + } + + return acc; + }, []) + .join(',')}}`; +}; diff --git a/web_ui/frontend/components/metrics/CPUGraph.tsx b/web_ui/frontend/components/metrics/CPUGraph.tsx index 522e3ddfa..48a37f150 100644 --- a/web_ui/frontend/components/metrics/CPUGraph.tsx +++ b/web_ui/frontend/components/metrics/CPUGraph.tsx @@ -23,6 +23,7 @@ import { GraphDispatchContext, } from '@/components/graphs/GraphContext'; import { + buildMetric, MatrixResponseData, query_raw, TimeDuration, @@ -45,12 +46,13 @@ ChartJS.register( Filler ); -const CPUGraph = () => { +const CPUGraph = ({ server_name = undefined }: { server_name?: string }) => { const graphContext = useContext(GraphContext); const { data: datasets } = useSWR>( [ 'cpuGraph', + server_name, graphContext.rate, graphContext.range, graphContext.resolution, @@ -58,6 +60,7 @@ const CPUGraph = () => { ], () => getData( + server_name, graphContext.rate, graphContext.range, graphContext.resolution, @@ -114,12 +117,14 @@ const CPUGraph = () => { }; const getData = async ( + server_name: string | undefined, rate: TimeDuration, range: TimeDuration, resolution: TimeDuration, time: DateTime ): Promise> => { - const query = `avg by (instance) (irate(process_cpu_seconds_total[${rate}]))[${range}:${resolution}]`; + const metric = buildMetric('process_cpu_seconds_total', { server_name }); + const query = `avg by (instance) (irate(${metric}[${rate}]))[${range}:${resolution}]`; const dataResponse = await query_raw( query, time.toSeconds() diff --git a/web_ui/frontend/components/metrics/MemoryGraph.tsx b/web_ui/frontend/components/metrics/MemoryGraph.tsx index 2705e3dc1..fcde713df 100644 --- a/web_ui/frontend/components/metrics/MemoryGraph.tsx +++ b/web_ui/frontend/components/metrics/MemoryGraph.tsx @@ -22,6 +22,7 @@ import { GraphDispatchContext, } from '@/components/graphs/GraphContext'; import { + buildMetric, MatrixResponseData, query_raw, TimeDuration, @@ -43,7 +44,7 @@ ChartJS.register( Colors ); -const MemoryGraph = () => { +const MemoryGraph = ({ server_name }: { server_name?: string }) => { const graphContext = useContext(GraphContext); const { data: datasets } = useSWR>( @@ -56,6 +57,7 @@ const MemoryGraph = () => { ], () => getData( + buildMetric('go_memstats_alloc_bytes', { server_name }), graphContext.rate, graphContext.range, graphContext.resolution, @@ -112,12 +114,13 @@ const MemoryGraph = () => { }; const getData = async ( + metric: string, rate: TimeDuration, range: TimeDuration, resolution: TimeDuration, time: DateTime ): Promise> => { - const query = `(go_memstats_alloc_bytes / 1024 / 1024)[${range}:${resolution}]`; + const query = `(${metric} / 1024 / 1024)[${range}:${resolution}]`; const dataResponse = await query_raw( query, time.toSeconds() diff --git a/web_ui/frontend/components/metrics/ProjectsTable.tsx b/web_ui/frontend/components/metrics/ProjectsTable.tsx index f94511d7d..3851b25e5 100644 --- a/web_ui/frontend/components/metrics/ProjectsTable.tsx +++ b/web_ui/frontend/components/metrics/ProjectsTable.tsx @@ -10,6 +10,7 @@ import { TableRow, } from '@mui/material'; import { + buildMetric, MatrixResponseData, query_raw, TimeDuration, @@ -27,12 +28,16 @@ interface ProjectData { bytesAccessed: string; } -const ProjectTable = () => { +export const ProjectTable = ({ + server_name = undefined, +}: { + server_name?: string; +}) => { const { rate, time, range, resolution } = useContext(GraphContext); const { data: projectData, error: projectError } = useSWR( ['projectData', time, range], - () => getProjectData(range, time) + () => getProjectData(server_name, range, time) ); return ( @@ -66,11 +71,18 @@ const ProjectTable = () => { }; const getProjectData = async ( + server_name: string | undefined, range: TimeDuration, time: DateTime ): Promise => { + const metric = buildMetric('xrootd_transfer_bytes', { + type: { comparator: '!=', value: 'value' }, + proj: { comparator: '!=', value: '' }, + server_name, + }); + const queryResponse = await query_raw( - `sum by (proj) (increase(xrootd_transfer_bytes{type!="write", proj!=""}[${range}]))`, + `sum by (proj) (increase(${metric}[${range}]))`, time.toSeconds() ); const result = queryResponse.data.result; diff --git a/web_ui/frontend/components/metrics/StorageGraph.tsx b/web_ui/frontend/components/metrics/StorageGraph.tsx index 0128c6eeb..3c9708936 100644 --- a/web_ui/frontend/components/metrics/StorageGraph.tsx +++ b/web_ui/frontend/components/metrics/StorageGraph.tsx @@ -15,6 +15,7 @@ import { grey, blue } from '@mui/material/colors'; import { GraphContext } from '@/components/graphs/GraphContext'; import { + buildMetric, MatrixResponseData, query_raw, TimeDuration, @@ -31,12 +32,16 @@ interface storageData { free: number; } -const StorageGraph = () => { +const StorageGraph = ({ + server_name = undefined, +}: { + server_name?: string; +}) => { const graphContext = useContext(GraphContext); const { data } = useSWR( - ['getStorageData', graphContext.time], - () => getStorageData(graphContext.time.toSeconds()) + ['getStorageData', graphContext.time, server_name], + () => getStorageData(server_name, graphContext.time.toSeconds()) ); const [usedBytes, freeBytes] = convertListBytes([ @@ -74,9 +79,14 @@ const StorageGraph = () => { }; const getStorageData = async ( + server_name: string | undefined, time: number = DateTime.now().toSeconds() ): Promise<{ free: number; used: number }> => { - const url = `sum by (type) (xrootd_storage_volume_bytes{server_type="origin"})`; + const metric = buildMetric('xrootd_storage_volume_bytes', { + server_type: 'origin', + server_name, + }); + const url = `sum by (type) (${metric})`; const response = await query_raw(url, time); const result = response.data.result; diff --git a/web_ui/frontend/components/metrics/TransferRateGraph.tsx b/web_ui/frontend/components/metrics/TransferRateGraph.tsx index 5aba26a78..4cbfc6f51 100644 --- a/web_ui/frontend/components/metrics/TransferRateGraph.tsx +++ b/web_ui/frontend/components/metrics/TransferRateGraph.tsx @@ -24,6 +24,7 @@ import { GraphDispatchContext, } from '@/components/graphs/GraphContext'; import { + buildMetric, MatrixResponseData, query_raw, TimeDuration, @@ -46,7 +47,11 @@ ChartJS.register( Colors ); -const TransferRateGraph = () => { +const TransferRateGraph = ({ + server_name = undefined, +}: { + server_name?: string; +}) => { const graphContext = useContext(GraphContext); const dispatch = useContext(GraphDispatchContext); @@ -62,6 +67,7 @@ const TransferRateGraph = () => { ], () => getData( + server_name, graphContext.rate, graphContext.range, graphContext.resolution, @@ -174,12 +180,16 @@ const toBytesDataset = ( }; const getData = async ( + server_name: string | undefined, rate: TimeDuration, range: TimeDuration, resolution: TimeDuration, time: DateTime ): Promise[]> => { - const query = `sum by (path, type) (rate(xrootd_transfer_bytes[${rate}]))[${range}:${resolution}]`; + const metric = buildMetric('xrootd_transfer_bytes', { + server_name: server_name, + }); + const query = `sum by (path, type) (rate(${metric}[${rate}]))[${range}:${resolution}]`; const dataResponse = await query_raw( query, time.toSeconds() From f4eb1131f30a055f0f22fc780dee5e37c693e28e Mon Sep 17 00:00:00 2001 From: Cannon Lock Date: Tue, 1 Apr 2025 12:43:28 -0500 Subject: [PATCH 2/7] Fixup the Cache page and add enhance the onClick ability to provide ServerType specific views --- web_ui/frontend/app/cache/metrics/page.tsx | 155 +-------------- .../app/director/metrics/cache/page.tsx | 23 +++ .../metrics/components/MetricBoxPlot.tsx | 32 +++- .../metrics/components/ServerUptime.tsx | 13 +- .../metrics/components/StorageTable.tsx | 45 +++-- .../components/graphs/CacheMetricPage.tsx | 179 ++++++++++++++++++ .../components/graphs/OriginMetricPage.tsx | 11 +- .../components/metrics/ProjectsTable.tsx | 2 +- web_ui/frontend/types.ts | 4 +- 9 files changed, 282 insertions(+), 182 deletions(-) create mode 100644 web_ui/frontend/app/director/metrics/cache/page.tsx create mode 100644 web_ui/frontend/components/graphs/CacheMetricPage.tsx diff --git a/web_ui/frontend/app/cache/metrics/page.tsx b/web_ui/frontend/app/cache/metrics/page.tsx index 37d28fd3a..d159d43df 100644 --- a/web_ui/frontend/app/cache/metrics/page.tsx +++ b/web_ui/frontend/app/cache/metrics/page.tsx @@ -1,153 +1,2 @@ -import { Box, Grid, Paper, Typography } from '@mui/material'; -import { green, grey, blue } from '@mui/material/colors'; - -import { - BigMetric, - ProjectTable, - BigBytesMetric, - StorageGraph, - TransferRateGraph, - CPUGraph, - MemoryGraph, -} from '@/components/metrics'; - -const Page = () => { - return ( - - - - {[ - , - , - , - ].map((component, index) => ( - - {component} - - ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default Page; +import CacheMetricPage from '@/components/graphs/CacheMetricPage'; +export default CacheMetricPage; diff --git a/web_ui/frontend/app/director/metrics/cache/page.tsx b/web_ui/frontend/app/director/metrics/cache/page.tsx new file mode 100644 index 000000000..b67c79c29 --- /dev/null +++ b/web_ui/frontend/app/director/metrics/cache/page.tsx @@ -0,0 +1,23 @@ +'use client'; + +import CacheMetricPage from '@/components/graphs/CacheMetricPage'; +import { Suspense } from 'react'; +import { Skeleton } from '@mui/material'; +import { useSearchParams } from 'next/navigation'; + +const RemoteOriginPage = () => { + const params = useSearchParams(); + const serverName = params.get('server_name') || undefined; + + return ; +}; + +const Page = () => { + return ( + }> + + + ); +}; + +export default Page; diff --git a/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx b/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx index f274d67ee..e284a7f2a 100644 --- a/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx +++ b/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx @@ -44,8 +44,15 @@ import { toBytes, toBytesString, } from '@/helpers/bytes'; -import { evaluateOrReturn, TypeOrTypeFunction } from '@/helpers/util'; +import { + alertOnError, + evaluateOrReturn, + TypeOrTypeFunction, +} from '@/helpers/util'; import { useRouter } from 'next/navigation'; +import { ServerGeneral, ServerType } from '@/types'; +import { getDirectorServers } from '@/helpers/get'; +import { AlertDispatchContext } from '@/components/AlertProvider'; ChartJS.register( BoxPlotController, @@ -175,6 +182,7 @@ export const MetricBoxPlot = ({ }) => { const router = useRouter(); const { rate, time, resolution, range } = useContext(GraphContext); + const dispatch = useContext(AlertDispatchContext); const { data } = useSWR( [metric, rate, time, resolution, range], @@ -184,6 +192,16 @@ export const MetricBoxPlot = ({ } ); + const { data: servers } = useSWR( + 'getDirectorServers', + async () => + await alertOnError( + getDirectorServers, + 'Failed to fetch servers', + dispatch + ) + ); + const chartData = useMemo(() => { return { labels: [`${title}`], @@ -217,8 +235,16 @@ export const MetricBoxPlot = ({ const serverName = position.chart.tooltip.body[0].lines[0].split(':')[0]; if (serverName != 'Missing Server Name') { + let serverType: ServerType = 'Cache'; + if (servers) { + const server = servers.find((s) => s.name == serverName); + if (server) { + serverType = server.type; + } + } + router.push( - '/director/metrics/origin/?server_name=' + + `/director/metrics/${serverType.toLowerCase()}/?server_name=` + position.chart.tooltip.body[0].lines[0].split(':')[0] ); } @@ -278,6 +304,8 @@ export const getMetricData = async ( return result?.metric?.server_name || 'Missing Server Name'; }); + console.log(data); + return { data, labels, diff --git a/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx b/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx index f25d30163..986caed52 100644 --- a/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx +++ b/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx @@ -31,6 +31,8 @@ import { } from '@mui/material'; import { AlertDispatchContext } from '@/components/AlertProvider'; import { alertOnError } from '@/helpers/util'; +import Link from 'next/link'; +import { ServerType } from '@/types'; const ServerUptime = () => { const dispatch = useContext(AlertDispatchContext); @@ -70,7 +72,11 @@ const ServerUptime = () => { {data.map((d) => ( - {d.serverName} + + {d.serverName} + { interface ServerUptimeData { serverName: string; + serverType: ServerType; ranges: Range[]; points: Point[]; } @@ -133,16 +140,18 @@ export const getMetricData = async ( let uptimes: ServerUptimeData[] = countResponseFilled.result.map((result) => { const serverName = result.metric.server_name; + const serverType = (result.metric.server_type || 'Cache') as ServerType; // Default to Cache which subsets the Origin metrics const ranges = countResponseToRanges(result); const restartServer = restartResponse.data.result.filter( (r) => r.metric.server_name === serverName ); if (restartServer.length === 0) { - return { serverName, ranges, points: [] }; + return { serverName, serverType, ranges, points: [] }; } return { serverName, + serverType, ranges, points: restartResponseToPoints(restartServer[0]), }; diff --git a/web_ui/frontend/app/director/metrics/components/StorageTable.tsx b/web_ui/frontend/app/director/metrics/components/StorageTable.tsx index ac5791d08..ff2792864 100644 --- a/web_ui/frontend/app/director/metrics/components/StorageTable.tsx +++ b/web_ui/frontend/app/director/metrics/components/StorageTable.tsx @@ -23,6 +23,7 @@ import { GraphContext } from '@/components/graphs/GraphContext'; import { DateTime } from 'luxon'; import chroma from 'chroma-js'; import { convertToBiggestBytes, toBytes, toBytesString } from '@/helpers/bytes'; +import { ServerType } from '@/types'; export const StorageTable = () => { const { rate, time, range, resolution } = useContext(GraphContext); @@ -89,7 +90,7 @@ export const StorageTable = () => { {d.serverName} @@ -131,30 +132,40 @@ export const StorageTable = () => { ); }; +interface StorageMetric { + serverName: string; + serverType: ServerType; + free: number; + total: number; +} + const getStorageData = async ( time: DateTime -): Promise< - Record -> => { +): Promise> => { const query = `xrootd_storage_volume_bytes`; const response = await query_raw(query, time.toSeconds()); const result = response.data.result; - return result.reduce((acc: Record, r) => { - const serverName = r.metric.server_name; - const type = r.metric.type; + return result.reduce( + (acc: Record, r): Record => { + const serverName = r.metric.server_name; + const serverType = (r.metric?.server_type || 'Cache') as ServerType; // Default to Cache which subsets the Origin metrics + const type = r.metric.type; - if (serverName === undefined) { - return acc; - } + if (serverName === undefined) { + return acc; + } - acc[serverName] = { - ...acc?.[serverName], - serverName: serverName, - [type]: Number(r.value[1]), - }; + acc[serverName] = { + ...acc?.[serverName], + [type]: Number(r.value[1]), + serverName, + serverType, + }; - return acc; - }, {}); + return acc; + }, + {} + ); }; diff --git a/web_ui/frontend/components/graphs/CacheMetricPage.tsx b/web_ui/frontend/components/graphs/CacheMetricPage.tsx new file mode 100644 index 000000000..219523297 --- /dev/null +++ b/web_ui/frontend/components/graphs/CacheMetricPage.tsx @@ -0,0 +1,179 @@ +import { Box, Grid, Paper, Typography } from '@mui/material'; +import { green, grey, blue } from '@mui/material/colors'; + +import { + BigMetric, + ProjectTable, + BigBytesMetric, + TransferRateGraph, + CPUGraph, + MemoryGraph, + StorageGraph, +} from '@/components/metrics'; +import { buildMetric as bm } from '@/components'; + +export const CacheMetricPage = ({ + server_name = undefined, +}: { + server_name?: string; +}) => { + return ( + + + + {[ + , + , + , + ].map((component, index) => ( + + {component} + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CacheMetricPage; diff --git a/web_ui/frontend/components/graphs/OriginMetricPage.tsx b/web_ui/frontend/components/graphs/OriginMetricPage.tsx index c8bf4afec..246fce00d 100644 --- a/web_ui/frontend/components/graphs/OriginMetricPage.tsx +++ b/web_ui/frontend/components/graphs/OriginMetricPage.tsx @@ -4,14 +4,13 @@ import { green, grey, blue } from '@mui/material/colors'; import { ProjectTable, TransferRateGraph, -} from '@/app/origin/metrics/components'; -import { CPUGraph } from '@/app/origin/metrics/components/CPUGraph'; -import { MemoryGraph } from '@/app/origin/metrics/components/MemoryGraph'; -import { + CPUGraph, + MemoryGraph, BigBytesMetric, BigMetric, -} from '@/app/origin/metrics/components/BigNumber'; -import { StorageGraph } from '@/app/origin/metrics/components/StorageGraph'; + StorageGraph, +} from '@/components/metrics'; + import { buildMetric as bm } from '@/components/graphs/prometheus'; export const OriginMetricPage = ({ diff --git a/web_ui/frontend/components/metrics/ProjectsTable.tsx b/web_ui/frontend/components/metrics/ProjectsTable.tsx index 3851b25e5..df9e73047 100644 --- a/web_ui/frontend/components/metrics/ProjectsTable.tsx +++ b/web_ui/frontend/components/metrics/ProjectsTable.tsx @@ -28,7 +28,7 @@ interface ProjectData { bytesAccessed: string; } -export const ProjectTable = ({ +const ProjectTable = ({ server_name = undefined, }: { server_name?: string; diff --git a/web_ui/frontend/types.ts b/web_ui/frontend/types.ts index 01f49554c..4d97f3614 100644 --- a/web_ui/frontend/types.ts +++ b/web_ui/frontend/types.ts @@ -29,6 +29,8 @@ export interface DirectorNamespace { origins: string[]; } +export type ServerType = 'Origin' | 'Cache'; + interface ServerBase { name: string; version: string; @@ -38,7 +40,7 @@ interface ServerBase { brokerUrl: string; url: string; webUrl: string; - type: 'Origin' | 'Cache'; + type: ServerType; latitude: number; longitude: number; capabilities: Capabilities; From 88bc244f0f90b763e29f48945017ea4f25c0ae20 Mon Sep 17 00:00:00 2001 From: Cannon Lock Date: Tue, 1 Apr 2025 12:52:35 -0500 Subject: [PATCH 3/7] Enhance Director Server Cards - Add the version to the card title - Add a button to go to the servers metrics --- .../app/director/components/DirectorCard.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/web_ui/frontend/app/director/components/DirectorCard.tsx b/web_ui/frontend/app/director/components/DirectorCard.tsx index f99652142..3a42b9b1d 100644 --- a/web_ui/frontend/app/director/components/DirectorCard.tsx +++ b/web_ui/frontend/app/director/components/DirectorCard.tsx @@ -16,7 +16,7 @@ import { } from '@mui/material'; import { red, grey } from '@mui/material/colors'; import { Server } from '@/index'; -import { Language } from '@mui/icons-material'; +import { Equalizer, Language } from '@mui/icons-material'; import { NamespaceIcon } from '@/components/Namespace/index'; import useSWR from 'swr'; import Link from 'next/link'; @@ -80,7 +80,10 @@ export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { - {server.name} + + {server.name} + {server?.version && <> • {server.version}} + @@ -136,6 +139,22 @@ export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { )} + {authenticated && + authenticated.role == 'admin' && + server?.webUrl && ( + + + + + + + + + + )} From 39615a55400848cb05231653fb0f8ba7e7b33760 Mon Sep 17 00:00:00 2001 From: Cannon Lock Date: Tue, 1 Apr 2025 13:41:58 -0500 Subject: [PATCH 4/7] Director Metrics Page Clean Up - Fix the headers to they are sticky on the tables - Add the by project metrics table - Add boxplots for the transfer bytes and operations to see top caches --- web_ui/frontend/app/cache/metrics/page.tsx | 5 +- .../app/director/metrics/cache/page.tsx | 4 +- .../metrics/components/ServerUptime.tsx | 15 +- .../metrics/components/StorageTable.tsx | 117 +++++++-------- web_ui/frontend/app/director/metrics/page.tsx | 11 ++ web_ui/frontend/app/origin/metrics/page.tsx | 2 - web_ui/frontend/components/CardList.tsx | 2 + web_ui/frontend/components/DataTable.tsx | 69 --------- web_ui/frontend/components/Namespace/Card.tsx | 2 + .../Namespace/NamespaceCardList.tsx | 2 + .../components/Namespace/PendingCard.tsx | 2 + web_ui/frontend/components/ServerTable.tsx | 136 ------------------ .../components/StyledHeadTableCell.tsx | 20 +++ .../components/metrics/ProjectsTable.tsx | 41 +++--- 14 files changed, 131 insertions(+), 297 deletions(-) delete mode 100644 web_ui/frontend/components/DataTable.tsx delete mode 100644 web_ui/frontend/components/ServerTable.tsx create mode 100644 web_ui/frontend/components/StyledHeadTableCell.tsx diff --git a/web_ui/frontend/app/cache/metrics/page.tsx b/web_ui/frontend/app/cache/metrics/page.tsx index d159d43df..1ebd785e8 100644 --- a/web_ui/frontend/app/cache/metrics/page.tsx +++ b/web_ui/frontend/app/cache/metrics/page.tsx @@ -1,2 +1,3 @@ -import CacheMetricPage from '@/components/graphs/CacheMetricPage'; -export default CacheMetricPage; +import CacheMetricPage from '@/components/graphs/OriginMetricPage'; +const Page = () => ; +export default Page; diff --git a/web_ui/frontend/app/director/metrics/cache/page.tsx b/web_ui/frontend/app/director/metrics/cache/page.tsx index b67c79c29..25fbfa9c9 100644 --- a/web_ui/frontend/app/director/metrics/cache/page.tsx +++ b/web_ui/frontend/app/director/metrics/cache/page.tsx @@ -5,7 +5,7 @@ import { Suspense } from 'react'; import { Skeleton } from '@mui/material'; import { useSearchParams } from 'next/navigation'; -const RemoteOriginPage = () => { +const RemoteCachePage = () => { const params = useSearchParams(); const serverName = params.get('server_name') || undefined; @@ -15,7 +15,7 @@ const RemoteOriginPage = () => { const Page = () => { return ( }> - + ); }; diff --git a/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx b/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx index 986caed52..262c8324d 100644 --- a/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx +++ b/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx @@ -33,6 +33,7 @@ import { AlertDispatchContext } from '@/components/AlertProvider'; import { alertOnError } from '@/helpers/util'; import Link from 'next/link'; import { ServerType } from '@/types'; +import StyledTableCell from '@/components/StyledHeadTableCell'; const ServerUptime = () => { const dispatch = useContext(AlertDispatchContext); @@ -55,17 +56,17 @@ const ServerUptime = () => { data = useMemo(() => (data ? data : []), [data]); return ( - + <> {data.length === 0 && !isLoading && !isValidating && ( No data available )} - - + +
- Server - Status - Restarts + Server + Status + Restarts @@ -94,7 +95,7 @@ const ServerUptime = () => {
-
+ ); }; diff --git a/web_ui/frontend/app/director/metrics/components/StorageTable.tsx b/web_ui/frontend/app/director/metrics/components/StorageTable.tsx index ff2792864..9f9dc6085 100644 --- a/web_ui/frontend/app/director/metrics/components/StorageTable.tsx +++ b/web_ui/frontend/app/director/metrics/components/StorageTable.tsx @@ -9,6 +9,7 @@ import { TableContainer, TableHead, TableRow, + styled, } from '@mui/material'; import { MatrixResponseData, @@ -18,12 +19,14 @@ import { } from '@/components/graphs/prometheus'; import useSWR from 'swr'; -import { useContext, useMemo } from 'react'; +import { ReactNode, useContext, useMemo } from 'react'; import { GraphContext } from '@/components/graphs/GraphContext'; import { DateTime } from 'luxon'; import chroma from 'chroma-js'; import { convertToBiggestBytes, toBytes, toBytesString } from '@/helpers/bytes'; import { ServerType } from '@/types'; +import { tableCellClasses } from '@mui/material/TableCell'; +import StyledTableCell from '@/components/StyledHeadTableCell'; export const StorageTable = () => { const { rate, time, range, resolution } = useContext(GraphContext); @@ -69,64 +72,62 @@ export const StorageTable = () => { return ( <> {storageData !== undefined && ( - - - - - - Server - Used ({totalUsedByteString}) - Free ({totalFreeByteString}) - - - - {Object.values(storageData) - .sort((a, b) => { - const nameA = a.serverName.toUpperCase(); - const nameB = b.serverName.toUpperCase(); - return nameA > nameB ? 1 : nameA < nameB ? -1 : 0; - }) - .map((d) => ( - - - - {d.serverName} - - - +
+ + + Server + Used ({totalUsedByteString}) + Free ({totalFreeByteString}) + + + + {Object.values(storageData) + .sort((a, b) => { + const nameA = a.serverName.toUpperCase(); + const nameB = b.serverName.toUpperCase(); + return nameA > nameB ? 1 : nameA < nameB ? -1 : 0; + }) + .map((d) => ( + + + - {toBytesString(d.total - d.free)} - - - {toBytesString(d.free)} - - - ))} - -
-
-
+ {d.serverName} + +
+ + {toBytesString(d.total - d.free)} + + + {toBytesString(d.free)} + +
+ ))} + + + )} ); diff --git a/web_ui/frontend/app/director/metrics/page.tsx b/web_ui/frontend/app/director/metrics/page.tsx index 806f181dd..a8cc108ec 100644 --- a/web_ui/frontend/app/director/metrics/page.tsx +++ b/web_ui/frontend/app/director/metrics/page.tsx @@ -25,6 +25,7 @@ const Page = () => { {[ , , + , ].map((component, index) => ( {component} @@ -37,6 +38,16 @@ const Page = () => { {[ + , + , ; - export default Page; diff --git a/web_ui/frontend/components/CardList.tsx b/web_ui/frontend/components/CardList.tsx index 31a10339e..0836ded36 100644 --- a/web_ui/frontend/components/CardList.tsx +++ b/web_ui/frontend/components/CardList.tsx @@ -1,3 +1,5 @@ +'use client' + import React, { ComponentType, FunctionComponent, diff --git a/web_ui/frontend/components/DataTable.tsx b/web_ui/frontend/components/DataTable.tsx deleted file mode 100644 index 2388add8e..000000000 --- a/web_ui/frontend/components/DataTable.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { ReactElement, useMemo } from 'react'; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, -} from '@mui/material'; - -export interface ColumnMap { - [key: string]: Column; -} - -export interface Column { - name: string; - cellNode: React.JSX.ElementType; -} - -export interface Record { - [key: string]: string | number | boolean | null; -} - -const DataTable = ({ - columnMap, - data, -}: { - columnMap: ColumnMap; - data: Record[]; -}): ReactElement => { - // If there is data then show, if not then indicate no data - const rows = useMemo(() => { - if (data.length !== 0) { - return data.map((record, index) => ( - - {Object.entries(columnMap).map(([key, column], index) => { - const CellNode = column.cellNode; - return {record[key]}; - })} - - )); - } else { - return ( - - {Object.entries(columnMap).map(([key, column], index) => { - return No Data; - })} - - ); - } - }, [data]); - - return ( - - - - - {Object.values(columnMap).map((column, index) => ( - {column.name} - ))} - - - {rows} -
-
- ); -}; - -export default DataTable; diff --git a/web_ui/frontend/components/Namespace/Card.tsx b/web_ui/frontend/components/Namespace/Card.tsx index d8a13935d..131b1faff 100644 --- a/web_ui/frontend/components/Namespace/Card.tsx +++ b/web_ui/frontend/components/Namespace/Card.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Alert, Alert as AlertType, RegistryNamespace } from '@/index'; import React, { useContext, useRef, useState } from 'react'; import { diff --git a/web_ui/frontend/components/Namespace/NamespaceCardList.tsx b/web_ui/frontend/components/Namespace/NamespaceCardList.tsx index b8770b529..f2cea5748 100644 --- a/web_ui/frontend/components/Namespace/NamespaceCardList.tsx +++ b/web_ui/frontend/components/Namespace/NamespaceCardList.tsx @@ -1,3 +1,5 @@ +'use client'; + import React, { ComponentType, FunctionComponent, diff --git a/web_ui/frontend/components/Namespace/PendingCard.tsx b/web_ui/frontend/components/Namespace/PendingCard.tsx index e99451d5c..f9ffd615f 100644 --- a/web_ui/frontend/components/Namespace/PendingCard.tsx +++ b/web_ui/frontend/components/Namespace/PendingCard.tsx @@ -1,3 +1,5 @@ +'use client' + import React, { useContext, useMemo, useRef, useState } from 'react'; import { Authenticated, secureFetch } from '@/helpers/login'; import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material'; diff --git a/web_ui/frontend/components/ServerTable.tsx b/web_ui/frontend/components/ServerTable.tsx deleted file mode 100644 index bcdb65b54..000000000 --- a/web_ui/frontend/components/ServerTable.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { - Table, - TableCell, - TableBody, - TableContainer, - TableHead, - TableRow, - Paper, - Typography, - Box, -} from '@mui/material'; -import React, { - FunctionComponent, - ReactElement, - ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { Skeleton } from '@mui/material'; -import Link from 'next/link'; - -import DataTable, { Record } from '@/components/DataTable'; -import { TableCellOverflow } from '@/components/Cell'; -import { getErrorMessage } from '@/helpers/util'; - -interface ExportData extends Record { - Type: string; - 'Local Path': string; - 'Namespace Prefix': string; -} - -const TableCellOverflowLink: React.JSX.ElementType = ({ - children, - ...props -}) => { - if (children === null) { - children = ''; - } - - return ( - - {children} - - ); -}; - -interface Server extends Record { - name: string; - authUrl: string; - url: string; - webUrl: string; - type: string; - latitude: number; - longitude: number; -} - -interface ServerTableProps { - type?: 'cache' | 'origin'; -} - -export const ServerTable = ({ type }: ServerTableProps) => { - const [data, setData] = useState(undefined); - const [error, setError] = useState(undefined); - - const keyToName = { - name: { - name: 'Name', - cellNode: TableCellOverflow, - }, - authUrl: { - name: 'Auth URL', - cellNode: TableCellOverflowLink, - }, - url: { - name: 'URL', - cellNode: TableCellOverflowLink, - }, - webUrl: { - name: 'Web URL', - cellNode: TableCellOverflowLink, - }, - }; - - const getData = useCallback(async () => { - const url = new URL( - '/api/v1.0/director_ui/servers', - window.location.origin - ); - if (type) { - url.searchParams.append('server_type', type); - } - - let response = await fetch(url); - if (response.ok) { - const responseData: Server[] = await response.json(); - responseData.sort((a, b) => a.name.localeCompare(b.name)); - setData(responseData); - } else { - setError(await getErrorMessage(response)); - } - }, [type]); - - useEffect(() => { - getData(); - }, []); - - if (error) { - return ( - - - {error} - - - ); - } - - return ( - - {data ? ( - - ) : ( - - )} - - ); -}; diff --git a/web_ui/frontend/components/StyledHeadTableCell.tsx b/web_ui/frontend/components/StyledHeadTableCell.tsx new file mode 100644 index 000000000..2bc88bef3 --- /dev/null +++ b/web_ui/frontend/components/StyledHeadTableCell.tsx @@ -0,0 +1,20 @@ +import { styled, TableCell } from '@mui/material'; +import { tableCellClasses } from '@mui/material/TableCell'; + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + [`&.${tableCellClasses.head}:last-of-type`]: { + borderRadius: '0 5px 0 0', + }, + [`&.${tableCellClasses.head}:first-of-type`]: { + borderRadius: '5px 0 0 0', + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + }, +})); + +export default StyledTableCell; diff --git a/web_ui/frontend/components/metrics/ProjectsTable.tsx b/web_ui/frontend/components/metrics/ProjectsTable.tsx index df9e73047..6b89d59c6 100644 --- a/web_ui/frontend/components/metrics/ProjectsTable.tsx +++ b/web_ui/frontend/components/metrics/ProjectsTable.tsx @@ -22,6 +22,7 @@ import { useContext } from 'react'; import { GraphContext } from '@/components/graphs/GraphContext'; import { DateTime } from 'luxon'; import { convertToBiggestBytes } from '@/helpers/bytes'; +import StyledTableCell from '@/components/StyledHeadTableCell'; interface ProjectData { name: string; @@ -43,28 +44,26 @@ const ProjectTable = ({ return ( <> {projectData !== undefined && ( - - - - - - Project - Bytes Accessed + +
+ + + Project + Bytes Accessed + + + + {projectData.map((project) => ( + + {project.name} + + {project.bytesAccessed.toLocaleString()} + - - - {projectData.map((project) => ( - - {project.name} - - {project.bytesAccessed.toLocaleString()} - - - ))} - -
-
-
+ ))} + + + )} ); From 97b69d4fe9d03cef62cce7dcd00c38071badc6b3 Mon Sep 17 00:00:00 2001 From: Cannon Lock <49032265+CannonLock@users.noreply.github.com> Date: Tue, 1 Apr 2025 15:22:08 -0500 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web_ui/frontend/app/cache/metrics/page.tsx | 2 +- .../frontend/app/director/metrics/components/MetricBoxPlot.tsx | 3 +-- .../app/director/metrics/components/TransferBarGraph.tsx | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/web_ui/frontend/app/cache/metrics/page.tsx b/web_ui/frontend/app/cache/metrics/page.tsx index 1ebd785e8..985ad52e7 100644 --- a/web_ui/frontend/app/cache/metrics/page.tsx +++ b/web_ui/frontend/app/cache/metrics/page.tsx @@ -1,3 +1,3 @@ -import CacheMetricPage from '@/components/graphs/OriginMetricPage'; +import CacheMetricPage from '@/components/graphs/CacheMetricPage'; const Page = () => ; export default Page; diff --git a/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx b/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx index e284a7f2a..66a31bea5 100644 --- a/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx +++ b/web_ui/frontend/app/director/metrics/components/MetricBoxPlot.tsx @@ -304,8 +304,7 @@ export const getMetricData = async ( return result?.metric?.server_name || 'Missing Server Name'; }); - console.log(data); - + // console.log(data); return { data, labels, diff --git a/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx b/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx index c645d4342..7c33d88a7 100644 --- a/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx +++ b/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx @@ -73,8 +73,6 @@ const TransferBarGraph = () => { } ); - console.log(data); - return ( Date: Tue, 1 Apr 2025 15:55:59 -0500 Subject: [PATCH 6/7] Reformat via Prettier --- web_ui/frontend/components/CardList.tsx | 2 +- web_ui/frontend/components/Namespace/PendingCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web_ui/frontend/components/CardList.tsx b/web_ui/frontend/components/CardList.tsx index 0836ded36..d6031394c 100644 --- a/web_ui/frontend/components/CardList.tsx +++ b/web_ui/frontend/components/CardList.tsx @@ -1,4 +1,4 @@ -'use client' +'use client'; import React, { ComponentType, diff --git a/web_ui/frontend/components/Namespace/PendingCard.tsx b/web_ui/frontend/components/Namespace/PendingCard.tsx index f9ffd615f..f9fbd26be 100644 --- a/web_ui/frontend/components/Namespace/PendingCard.tsx +++ b/web_ui/frontend/components/Namespace/PendingCard.tsx @@ -1,4 +1,4 @@ -'use client' +'use client'; import React, { useContext, useMemo, useRef, useState } from 'react'; import { Authenticated, secureFetch } from '@/helpers/login'; From e2773574979e92cc8f43601705997997abcdf465 Mon Sep 17 00:00:00 2001 From: Cannon Lock Date: Wed, 2 Apr 2025 15:36:18 -0500 Subject: [PATCH 7/7] Address Review Comments - Make buildMetric louder when issues arise - Optimize buildMetric to leave out cruft when building empty arg string - Delete unused components --- web_ui/frontend/app/cache/page.tsx | 2 - web_ui/frontend/app/origin/page.tsx | 1 - web_ui/frontend/components/graphs/Graph.tsx | 132 ----------- .../frontend/components/graphs/LineGraph.tsx | 192 --------------- .../frontend/components/graphs/RateGraph.tsx | 223 ------------------ .../frontend/components/graphs/prometheus.tsx | 44 +++- 6 files changed, 32 insertions(+), 562 deletions(-) delete mode 100644 web_ui/frontend/components/graphs/Graph.tsx delete mode 100644 web_ui/frontend/components/graphs/LineGraph.tsx delete mode 100644 web_ui/frontend/components/graphs/RateGraph.tsx diff --git a/web_ui/frontend/app/cache/page.tsx b/web_ui/frontend/app/cache/page.tsx index 6f9b4618d..6192abfd1 100644 --- a/web_ui/frontend/app/cache/page.tsx +++ b/web_ui/frontend/app/cache/page.tsx @@ -20,11 +20,9 @@ import { Box, Grid, Typography } from '@mui/material'; -import RateGraph from '@/components/graphs/RateGraph'; import StatusBox from '@/components/StatusBox'; import { DataPoint, TimeDuration } from '@/components/graphs/prometheus'; import FederationOverview from '@/components/FederationOverview'; -import LineGraph from '@/components/graphs/LineGraph'; export default function Home() { return ( diff --git a/web_ui/frontend/app/origin/page.tsx b/web_ui/frontend/app/origin/page.tsx index 739b991a3..2911647e9 100644 --- a/web_ui/frontend/app/origin/page.tsx +++ b/web_ui/frontend/app/origin/page.tsx @@ -29,7 +29,6 @@ import { } from '@mui/material'; import { Key, CheckCircle } from '@mui/icons-material'; -import RateGraph from '@/components/graphs/RateGraph'; import StatusBox from '@/components/StatusBox'; import { DataExportTable } from '@/components/DataExportTable'; import { TimeDuration } from '@/components/graphs/prometheus'; diff --git a/web_ui/frontend/components/graphs/Graph.tsx b/web_ui/frontend/components/graphs/Graph.tsx deleted file mode 100644 index a263dea05..000000000 --- a/web_ui/frontend/components/graphs/Graph.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research - * - * Licensed under the Apache License, Version 2.0 (the "License"); you - * may not use this file except in compliance with the License. You may - * obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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. - * - ***************************************************************/ - -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - TimeScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - ChartOptions, - Colors, -} from 'chart.js'; - -import zoomPlugin from 'chartjs-plugin-zoom'; -import 'chartjs-adapter-luxon'; - -import { BoxProps } from '@mui/material'; - -import { Line } from 'react-chartjs-2'; -import { Box, Skeleton, Typography } from '@mui/material'; - -import { getDataWrapperFunction } from '@/components/graphs/prometheus'; -import { ChartData } from 'chart.js'; -import useSWR from 'swr'; - -const defaultOptions: Partial> = { - scales: { - x: { - type: 'time', - time: { - round: 'second', - }, - }, - }, -}; - -interface GraphProps { - getData: getDataWrapperFunction; - drawer?: any; - options?: ChartOptions<'line'>; - boxProps?: BoxProps; -} - -export default function Graph({ - getData, - options, - boxProps, - drawer, -}: GraphProps) { - const randomString = useRef(Math.random().toString(36).substring(7)); - const { data, isLoading, error, mutate } = useSWR( - 'projectData' + randomString.current, - getData - ); - - // Anytime the getter changes lets update the data accordingly - useEffect(() => { - mutate(); - }, [getData]); - - useEffect(() => { - ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - TimeScale, - zoomPlugin, - Colors - ); - }, []); - - return ( - - {isLoading || !data ? ( - - - - ) : ( - <> - - - - {drawer ? drawer : undefined} - - )} - {error && ( - - - {JSON.stringify(error)} - - - )} - - ); -} diff --git a/web_ui/frontend/components/graphs/LineGraph.tsx b/web_ui/frontend/components/graphs/LineGraph.tsx deleted file mode 100644 index e0a596ab9..000000000 --- a/web_ui/frontend/components/graphs/LineGraph.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research - * - * Licensed under the Apache License, Version 2.0 (the "License"); you - * may not use this file except in compliance with the License. You may - * obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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. - * - ***************************************************************/ - -'use client'; - -import dynamic from 'next/dynamic'; -import React, { useCallback, useState } from 'react'; -import { ChartOptions, ChartDataset } from 'chart.js'; -import { ChartData } from 'chart.js'; -import { DateTime } from 'luxon'; -import { BoxProps, Grid } from '@mui/material'; - -import { - query_basic, - query_rate, - TimeDuration, -} from '@/components/graphs/prometheus'; -import { - GraphDrawer, - RateInput, - ResolutionInput, -} from '@/components/graphs/Drawer'; -const Graph = dynamic(() => import('@/components/graphs/Graph'), { - ssr: false, -}); - -interface RateGraphDrawerProps { - reset: Function; - resolution: TimeDuration; - duration: TimeDuration; - time: DateTime; - setResolution: Function; - setDuration: Function; - setTime: Function; -} - -function LineGraphDrawer({ - reset, - resolution, - duration, - time, - setResolution, - setDuration, - setTime, -}: RateGraphDrawerProps) { - return ( - - - - - - - - ); -} - -interface LineGraphProps { - boxProps?: BoxProps; - metrics: string[]; - duration?: TimeDuration; - resolution?: TimeDuration; - options?: ChartOptions<'line'>; - datasetOptions?: - | Partial> - | Partial>[]; - datasetTransform?: (x: ChartDataset<'line'>) => ChartDataset<'line'>; -} - -async function getData( - metrics: string[], - duration: TimeDuration, - resolution: TimeDuration, - time: DateTime, - datasetOptions: - | Partial> - | Partial>[], - datasetTransform?: (x: ChartDataset<'line'>) => ChartDataset<'line'> -) { - let chartData: ChartData<'line', any, any> = { - datasets: await Promise.all( - metrics.map(async (metric, index) => { - let datasetOption: Partial> = {}; - if (datasetOptions instanceof Array) { - try { - datasetOption = datasetOptions[index]; - } catch (e) { - console.error( - 'datasetOptions is an array, but the number of elements < the number of metrics' - ); - } - } else { - datasetOption = datasetOptions; - } - - let updatedTime = time; - if (updatedTime.hasSame(DateTime.now(), 'day')) { - updatedTime = DateTime.now(); - } - - let dataset = { - data: await query_basic({ - metric, - range: duration, - resolution: resolution, - time: updatedTime, - }), - ...datasetOption, - }; - - if (datasetTransform) { - return datasetTransform(dataset); - } - - return dataset; - }) - ), - }; - - return chartData; -} - -export default function LineGraph({ - boxProps, - metrics, - duration = new TimeDuration(31, 'd'), - resolution = new TimeDuration(1, 'h'), - options, - datasetOptions = {}, - datasetTransform, -}: LineGraphProps) { - let reset = useCallback(() => { - setDuration(duration.copy()); - setResolution(resolution.copy()); - setTime(DateTime.now()); - }, [duration, resolution]); - - let [_duration, setDuration] = useState(duration); - let [_resolution, setResolution] = useState(resolution); - let [_time, setTime] = useState(DateTime.now()); - - return ( - - getData( - metrics, - _duration, - _resolution, - _time, - datasetOptions, - datasetTransform - ) - } - options={options} - drawer={ - - } - boxProps={boxProps} - /> - ); -} diff --git a/web_ui/frontend/components/graphs/RateGraph.tsx b/web_ui/frontend/components/graphs/RateGraph.tsx deleted file mode 100644 index 6d24994f0..000000000 --- a/web_ui/frontend/components/graphs/RateGraph.tsx +++ /dev/null @@ -1,223 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research - * - * Licensed under the Apache License, Version 2.0 (the "License"); you - * may not use this file except in compliance with the License. You may - * obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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. - * - ***************************************************************/ - -'use client'; - -import dynamic from 'next/dynamic'; - -import React, { useEffect, useState } from 'react'; -import { ChartOptions, ChartDataset, ChartData } from 'chart.js'; - -import { DateTime } from 'luxon'; - -import 'chartjs-adapter-luxon'; - -import { BoxProps, Grid } from '@mui/material'; - -import { - query_rate, - TimeDuration, - DurationType, - PrometheusQuery, - prometheusResultToDataPoints, -} from '@/components/graphs/prometheus'; - -import { GraphDrawer, ResolutionInput, RateInput } from './Drawer'; - -const Graph = dynamic(() => import('@/components/graphs/Graph'), { - ssr: false, -}); - -interface RateGraphDrawerProps { - reset: Function; - rate: TimeDuration; - resolution: TimeDuration; - duration: TimeDuration; - time: DateTime; - setRate: Function; - setResolution: Function; - setDuration: Function; - setTime: Function; -} - -function RateGraphDrawer({ - reset, - rate, - resolution, - duration, - time, - setRate, - setResolution, - setDuration, - setTime, -}: RateGraphDrawerProps) { - return ( - - - - - - - - - - - ); -} - -interface RateGraphProps { - boxProps?: BoxProps; - metrics: string[]; - rate?: TimeDuration; - duration?: TimeDuration; - resolution?: TimeDuration; - options?: ChartOptions<'line'>; - datasetOptions?: - | Partial> - | Partial>[]; -} - -export default function RateGraph({ - boxProps, - metrics, - rate = new TimeDuration(30, 'm'), - duration = new TimeDuration(1, 'd'), - resolution = new TimeDuration(1, 'm'), - options = {}, - datasetOptions = {}, -}: RateGraphProps) { - let default_rate = rate; - let default_duration = duration; - let default_resolution = resolution; - - let reset = () => { - setRate(default_rate.copy()); - setDuration(default_duration.copy()); - setResolution(default_resolution.copy()); - setTime(DateTime.now()); - }; - - let [_rate, setRate] = useState(rate); - let [_duration, _setDuration] = useState(duration); - let [_resolution, setResolution] = useState(resolution); - let [_time, _setTime] = useState(DateTime.now()); - - // Create some reasonable defaults for the graph - let setDuration = (duration: TimeDuration) => { - if (duration.value == 1) { - setRate(new TimeDuration(30, 'm')); - setResolution(new TimeDuration(10, 'm')); - } else if (duration.value == 7) { - setRate(new TimeDuration(3, 'h')); - setResolution(new TimeDuration(30, 'm')); - } else if (duration.value == 31) { - setRate(new TimeDuration(12, 'h')); - setResolution(new TimeDuration(12, 'h')); - } - - _setDuration(duration); - }; - - let setTime = (time: DateTime) => { - // If it's not today, then set time to the end of that day - // If it's today, then set to date.now - // - // This helps us to get the latest data while not going over the wanted time range - // If we set the time to the future, PromQL will give you random data in the future to - // interpolate the missing ones - if (time.hasSame(DateTime.now(), 'day')) { - time = DateTime.now(); - } else { - time.set({ hour: 23, minute: 59, second: 59, millisecond: 999 }); - } - _setTime(time); - }; - - async function getData() { - let chartData: ChartData<'line', any, any> = { - datasets: await Promise.all( - metrics.map(async (metric, index) => { - let datasetOption: Partial> = {}; - if (datasetOptions instanceof Array) { - try { - datasetOption = datasetOptions[index]; - } catch (e) { - console.error( - 'datasetOptions is an array, but the number of elements < the number of metrics' - ); - } - } else { - datasetOption = datasetOptions; - } - - let updatedTime = _time; - if (updatedTime.hasSame(DateTime.now(), 'day')) { - updatedTime = DateTime.now(); - } - - const queryResponse = await query_rate({ - metric, - rate: _rate, - range: _duration, - resolution: _resolution, - time: updatedTime, - }); - - const dataPoints = prometheusResultToDataPoints(queryResponse); - - return { - data: dataPoints, - ...datasetOption, - }; - }) - ), - }; - - return chartData; - } - - return ( - - } - options={options} - boxProps={boxProps} - /> - ); -} diff --git a/web_ui/frontend/components/graphs/prometheus.tsx b/web_ui/frontend/components/graphs/prometheus.tsx index f3e9a7221..e62dd9b95 100644 --- a/web_ui/frontend/components/graphs/prometheus.tsx +++ b/web_ui/frontend/components/graphs/prometheus.tsx @@ -329,6 +329,10 @@ export const fillArrayNulls = ( }); }; +/** + * Build a prometheus metric string from a metric name and a set of key value pairs + * Results in something like: metric{key1="value1",key2="value2"} + */ export const buildMetric = ( metric: string, args: Record< @@ -336,16 +340,32 @@ export const buildMetric = ( string | { comparator: '=' | '!='; value: string } | undefined > ) => { - return `${metric}{${Object.entries(args) - .reduce((acc: string[], [key, value]) => { - if (typeof value === 'string') { - acc.push(`${key}="${value}"`); - } else if (value === undefined) { - } else { - acc.push(`${key}${value.comparator}"${value.value}"`); - } - - return acc; - }, []) - .join(',')}}`; + // Verify the metric name is valid + if (!metric.match(/^[a-zA-Z_:][a-zA-Z0-9_:]*$/)) { + throw new Error(`Invalid metric name: ${metric}`); + } + + // Verify the key names are valid + if ( + !Object.keys(args).every((key) => key.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/)) + ) { + throw new Error(`Invalid key name in metric: ${metric}`); + } + + const labels = Object.entries(args).reduce((acc: string[], [key, value]) => { + if (typeof value === 'string') { + acc.push(`${key}="${value}"`); + } else if (value === undefined) { + } else { + acc.push(`${key}${value.comparator}"${value.value}"`); + } + return acc; + }, []); + + let labelString = ''; + if (labels.length > 0) { + labelString = `{${labels.join(',')}}`; + } + + return metric + labelString; };