diff --git a/web_ui/frontend/app/cache/metrics/page.tsx b/web_ui/frontend/app/cache/metrics/page.tsx index 37d28fd3a..985ad52e7 100644 --- a/web_ui/frontend/app/cache/metrics/page.tsx +++ b/web_ui/frontend/app/cache/metrics/page.tsx @@ -1,153 +1,3 @@ -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} - - ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - +import CacheMetricPage from '@/components/graphs/CacheMetricPage'; +const Page = () => ; export default Page; 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/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 && ( + + + + + + + + + + )} 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..25fbfa9c9 --- /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 RemoteCachePage = () => { + 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 ee2ad7217..66a31bea5 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, @@ -42,7 +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, @@ -69,6 +79,7 @@ export const BytesMetricBoxPlot = ({ title: string; options?: ChartOptions; }) => { + const router = useRouter(); const { rate, time, resolution, range } = useContext(GraphContext); const { data } = useSWR( @@ -118,6 +129,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,7 +180,9 @@ export const MetricBoxPlot = ({ title: string; options?: ChartOptions; }) => { + const router = useRouter(); const { rate, time, resolution, range } = useContext(GraphContext); + const dispatch = useContext(AlertDispatchContext); const { data } = useSWR( [metric, rate, time, resolution, range], @@ -163,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}`], @@ -187,6 +226,30 @@ 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') { + let serverType: ServerType = 'Cache'; + if (servers) { + const server = servers.find((s) => s.name == serverName); + if (server) { + serverType = server.type; + } + } + + router.push( + `/director/metrics/${serverType.toLowerCase()}/?server_name=` + + position.chart.tooltip.body[0].lines[0].split(':')[0] + ); + } + } catch {} + }, plugins: { tooltip: { callbacks: { @@ -241,6 +304,7 @@ 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..262c8324d 100644 --- a/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx +++ b/web_ui/frontend/app/director/metrics/components/ServerUptime.tsx @@ -31,6 +31,9 @@ import { } from '@mui/material'; 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); @@ -53,24 +56,28 @@ const ServerUptime = () => { data = useMemo(() => (data ? data : []), [data]); return ( - + <> {data.length === 0 && !isLoading && !isValidating && ( No data available )} - - + +
- Server - Status - Restarts + Server + Status + Restarts {data.map((d) => ( - {d.serverName} + + {d.serverName} + {
-
+ ); }; interface ServerUptimeData { serverName: string; + serverType: ServerType; ranges: Range[]; points: Point[]; } @@ -133,16 +141,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 0b1413a45..9f9dc6085 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, @@ -8,6 +9,7 @@ import { TableContainer, TableHead, TableRow, + styled, } from '@mui/material'; import { MatrixResponseData, @@ -17,11 +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); @@ -67,87 +72,101 @@ 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)} + + + ))} + + + )} ); }; +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/app/director/metrics/components/TransferBarGraph.tsx b/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx index d6c842470..7c33d88a7 100644 --- a/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx +++ b/web_ui/frontend/app/director/metrics/components/TransferBarGraph.tsx @@ -55,7 +55,7 @@ const TransferBarGraph = () => { const { data } = useSWR>( [ - 'transferRateGraph', + 'transferBarGraph', graphContext.rate, graphContext.range, graphContext.resolution, diff --git a/web_ui/frontend/app/director/metrics/origin/page.tsx b/web_ui/frontend/app/director/metrics/origin/page.tsx new file mode 100644 index 000000000..f4bf0099d --- /dev/null +++ b/web_ui/frontend/app/director/metrics/origin/page.tsx @@ -0,0 +1,23 @@ +'use client'; + +import OriginMetricPage from '@/components/graphs/OriginMetricPage'; +import { Suspense, useState, useEffect } 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/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 = () => { {[ + , + , { - return ( - - - - {[ - , - , - , - , - ].map((component, index) => ( - - {component} - - ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - +import OriginMetricPage from '@/components/graphs/OriginMetricPage'; +const Page = () => ; export default Page; 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/CardList.tsx b/web_ui/frontend/components/CardList.tsx index 31a10339e..d6031394c 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..f9fbd26be 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/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/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/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/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/OriginMetricPage.tsx b/web_ui/frontend/components/graphs/OriginMetricPage.tsx new file mode 100644 index 000000000..246fce00d --- /dev/null +++ b/web_ui/frontend/components/graphs/OriginMetricPage.tsx @@ -0,0 +1,181 @@ +import { Box, Grid, Paper, Typography } from '@mui/material'; +import { green, grey, blue } from '@mui/material/colors'; + +import { + ProjectTable, + TransferRateGraph, + CPUGraph, + MemoryGraph, + BigBytesMetric, + BigMetric, + StorageGraph, +} from '@/components/metrics'; + +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/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 171675dc0..e62dd9b95 100644 --- a/web_ui/frontend/components/graphs/prometheus.tsx +++ b/web_ui/frontend/components/graphs/prometheus.tsx @@ -328,3 +328,44 @@ export const fillArrayNulls = ( return [parseInt(key), value]; }); }; + +/** + * 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< + string, + string | { comparator: '=' | '!='; value: string } | undefined + > +) => { + // 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; +}; 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..6b89d59c6 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, @@ -21,56 +22,66 @@ 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; bytesAccessed: string; } -const ProjectTable = () => { +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 ( <> {projectData !== undefined && ( - - - - - - Project - Bytes Accessed + +
+ + + Project + Bytes Accessed + + + + {projectData.map((project) => ( + + {project.name} + + {project.bytesAccessed.toLocaleString()} + - - - {projectData.map((project) => ( - - {project.name} - - {project.bytesAccessed.toLocaleString()} - - - ))} - -
-
-
+ ))} + + + )} ); }; 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() 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;