From b4bfadd95eae76d9e528b3e5386f4c86662ec7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 19 Feb 2025 12:23:52 +0000 Subject: [PATCH] chore: visualize connected edges (#9325) https://linear.app/unleash/issue/2-3233/visualize-connected-edge-instances Adds a new tab in the Network page to visualize connected Edges. This is behind a `edgeObservability` flag. Also opens up the Network page even if you don't have a Prometheus API configured. When accessing the tabs that require it to set, and it isn't, we show some extra information about this and redirect you to the respective section in our docs. ![image](https://github.com/user-attachments/assets/1689f785-7544-450b-8c33-159609fc0f7d) ![image](https://github.com/user-attachments/assets/a7a14805-0488-41d2-885f-5e11a8495127) ![image](https://github.com/user-attachments/assets/918cba87-5538-4600-a71f-1143b2e33e2a) --- frontend/src/component/admin/adminRoutes.ts | 1 - .../src/component/admin/network/Network.tsx | 23 +- .../NetworkConnectedEdgeInstance.tsx | 255 ++++++++++++++++++ .../NetworkConnectedEdgeInstanceLatency.tsx | 75 ++++++ .../NetworkConnectedEdges.tsx | 226 ++++++++++++++++ .../NetworkOverview/NetworkOverview.tsx | 8 +- .../network/NetworkPrometheusAPIWarning.tsx | 24 ++ .../network/NetworkTraffic/NetworkTraffic.tsx | 8 +- .../NavigationSidebar.test.tsx.snap | 8 + .../useConnectedEdges/useConnectedEdges.ts | 39 +++ .../useInstanceMetrics/useInstanceMetrics.ts | 12 +- frontend/src/interfaces/connectedEdge.ts | 23 ++ frontend/src/interfaces/uiConfig.ts | 1 + src/lib/openapi/spec/ui-config-schema.ts | 2 +- src/lib/types/experimental.ts | 7 +- 15 files changed, 703 insertions(+), 9 deletions(-) create mode 100644 frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstance.tsx create mode 100644 frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstanceLatency.tsx create mode 100644 frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx create mode 100644 frontend/src/component/admin/network/NetworkPrometheusAPIWarning.tsx create mode 100644 frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts create mode 100644 frontend/src/interfaces/connectedEdge.ts diff --git a/frontend/src/component/admin/adminRoutes.ts b/frontend/src/component/admin/adminRoutes.ts index 5f4fdd7dbf..6ab6f00b24 100644 --- a/frontend/src/component/admin/adminRoutes.ts +++ b/frontend/src/component/admin/adminRoutes.ts @@ -65,7 +65,6 @@ export const adminRoutes: INavigationMenuItem[] = [ path: '/admin/network/*', title: 'Network', menu: { adminSettings: true, mode: ['pro', 'enterprise'] }, - configFlag: 'networkViewEnabled', group: 'instance', }, { diff --git a/frontend/src/component/admin/network/Network.tsx b/frontend/src/component/admin/network/Network.tsx index 6a7ae1ccb0..cd82e8f547 100644 --- a/frontend/src/component/admin/network/Network.tsx +++ b/frontend/src/component/admin/network/Network.tsx @@ -4,8 +4,12 @@ import { Tab, Tabs } from '@mui/material'; import { Route, Routes, useLocation } from 'react-router-dom'; import { TabLink } from 'component/common/TabNav/TabLink'; import { PageContent } from 'component/common/PageContent/PageContent'; +import { useUiFlag } from 'hooks/useUiFlag'; const NetworkOverview = lazy(() => import('./NetworkOverview/NetworkOverview')); +const NetworkConnectedEdges = lazy( + () => import('./NetworkConnectedEdges/NetworkConnectedEdges'), +); const NetworkTraffic = lazy(() => import('./NetworkTraffic/NetworkTraffic')); const NetworkTrafficUsage = lazy( () => import('./NetworkTrafficUsage/NetworkTrafficUsage'), @@ -20,6 +24,10 @@ const tabs = [ label: 'Traffic', path: '/admin/network/traffic', }, + { + label: 'Connected Edges', + path: '/admin/network/connected-edges', + }, { label: 'Data Usage', path: '/admin/network/data-usage', @@ -28,6 +36,11 @@ const tabs = [ export const Network = () => { const { pathname } = useLocation(); + const edgeObservabilityEnabled = useUiFlag('edgeObservability'); + + const filteredTabs = tabs.filter( + ({ label }) => label !== 'Connected Edges' || edgeObservabilityEnabled, + ); return (
@@ -41,7 +54,7 @@ export const Network = () => { variant='scrollable' allowScrollButtonsMobile > - {tabs.map(({ label, path }) => ( + {filteredTabs.map(({ label, path }) => ( { } > - } /> } /> + } /> + {edgeObservabilityEnabled && ( + } + /> + )} } diff --git a/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstance.tsx b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstance.tsx new file mode 100644 index 0000000000..4758424298 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstance.tsx @@ -0,0 +1,255 @@ +import { useLocationSettings } from 'hooks/useLocationSettings'; +import type { ConnectedEdge } from 'interfaces/connectedEdge'; +import CircleIcon from '@mui/icons-material/Circle'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import { formatDateYMDHMS } from 'utils/formatDate'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + styled, + Tooltip, +} from '@mui/material'; +import { Badge } from 'component/common/Badge/Badge'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { NetworkConnectedEdgeInstanceLatency } from './NetworkConnectedEdgeInstanceLatency'; + +const StyledInstance = styled('div')(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusMedium, + border: '1px solid', + borderColor: theme.palette.secondary.border, + backgroundColor: theme.palette.secondary.light, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: 0, + zIndex: 1, + marginTop: theme.spacing(1), +})); + +const StyledAccordion = styled(Accordion)({ + background: 'transparent', + boxShadow: 'none', +}); + +const StyledAccordionSummary = styled(AccordionSummary, { + shouldForwardProp: (prop) => prop !== 'connectionStatus', +})<{ connectionStatus: InstanceConnectionStatus }>( + ({ theme, connectionStatus }) => ({ + fontSize: theme.fontSizes.smallBody, + padding: theme.spacing(1), + minHeight: theme.spacing(3), + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: theme.spacing(1), + margin: 0, + '&.Mui-expanded': { + margin: 0, + }, + '& svg': { + fontSize: theme.fontSizes.mainHeader, + color: + connectionStatus === 'Stale' + ? theme.palette.warning.main + : connectionStatus === 'Disconnected' + ? theme.palette.error.main + : theme.palette.success.main, + }, + }, + }), +); + +const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + fontSize: theme.fontSizes.smallerBody, + gap: theme.spacing(2), +})); + +const StyledDetailRow = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + gap: theme.spacing(2), + '& > span': { + display: 'flex', + alignItems: 'center', + }, +})); + +const StyledBadge = styled(Badge)(({ theme }) => ({ + padding: theme.spacing(0, 1), +})); + +const getConnectionStatus = ({ + reportedAt, +}: ConnectedEdge): InstanceConnectionStatus => { + const reportedTime = new Date(reportedAt).getTime(); + const reportedSecondsAgo = (Date.now() - reportedTime) / 1000; + + if (reportedSecondsAgo > 360) return 'Disconnected'; + if (reportedSecondsAgo > 180) return 'Stale'; + + return 'Connected'; +}; + +const getCPUPercentage = ({ + started, + reportedAt, + cpuUsage, +}: ConnectedEdge): string => { + const cpuUsageSeconds = Number(cpuUsage); + if (!cpuUsageSeconds) return 'No usage'; + + const startedTimestamp = new Date(started).getTime(); + const reportedTimestamp = new Date(reportedAt).getTime(); + + const totalRuntimeSeconds = (reportedTimestamp - startedTimestamp) / 1000; + if (totalRuntimeSeconds === 0) return 'No usage'; + + return `${((cpuUsageSeconds / totalRuntimeSeconds) * 100).toFixed(2)} %`; +}; + +const getMemory = ({ memoryUsage }: ConnectedEdge): string => { + if (!memoryUsage) return 'No usage'; + + const units = ['B', 'KB', 'MB', 'GB']; + let size = memoryUsage; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +}; + +type InstanceConnectionStatus = 'Connected' | 'Stale' | 'Disconnected'; + +interface INetworkConnectedEdgeInstanceProps { + instance: ConnectedEdge; +} + +export const NetworkConnectedEdgeInstance = ({ + instance, +}: INetworkConnectedEdgeInstanceProps) => { + const { locationSettings } = useLocationSettings(); + + const connectionStatus = getConnectionStatus(instance); + const start = formatDateYMDHMS(instance.started, locationSettings?.locale); + const lastReport = formatDateYMDHMS( + instance.reportedAt, + locationSettings?.locale, + ); + const cpuPercentage = getCPUPercentage(instance); + const memory = getMemory(instance); + const archWarning = cpuPercentage === 'No usage' && + memory === 'No usage' && ( +

Resource metrics are only available when running on Linux

+ ); + + return ( + + + } + connectionStatus={connectionStatus} + > + + + + {instance.id || instance.instanceId} + + + + ID + {instance.instanceId} + + + Upstream + {instance.connectedVia || 'Unleash'} + + + Status + + {connectionStatus} + + + + Start + {start} + + + Last report + {lastReport} + + + App name + {instance.appName} + + + Region + {instance.region || 'Unknown'} + + + Version + {instance.edgeVersion} + + + CPU + + {cpuPercentage}{' '} + +

+ CPU average usage since instance + started +

+ {archWarning} + + } + size='16px' + /> +
+
+ + Memory + + {memory}{' '} + +

Current memory usage

+ {archWarning} + + } + size='16px' + /> +
+
+ + Stream clients + {instance.connectedStreamingClients} + + + + +
+
+
+ ); +}; diff --git a/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstanceLatency.tsx b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstanceLatency.tsx new file mode 100644 index 0000000000..45a6ee213d --- /dev/null +++ b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdgeInstanceLatency.tsx @@ -0,0 +1,75 @@ +import { styled } from '@mui/material'; +import type { ConnectedEdge } from 'interfaces/connectedEdge'; + +const StyledTable = styled('table')(({ theme }) => ({ + width: '100%', + borderCollapse: 'collapse', + fontSize: theme.fontSizes.smallerBody, + '& > thead': { + borderBottom: `1px solid ${theme.palette.text.primary}`, + }, + '& tr': { + textAlign: 'right', + '& > th:first-of-type,td:first-of-type': { + textAlign: 'left', + }, + }, +})); + +const StyledUpstreamSection = styled('tr')(({ theme }) => ({ + fontWeight: theme.fontWeight.bold, + borderBottom: `1px solid ${theme.palette.text.primary}`, + '& > td:first-of-type': { + paddingTop: theme.spacing(1), + }, +})); + +interface INetworkConnectedEdgeInstanceLatencyProps { + instance: ConnectedEdge; +} + +export const NetworkConnectedEdgeInstanceLatency = ({ + instance, +}: INetworkConnectedEdgeInstanceLatencyProps) => { + return ( + + + + Latency (ms) + Avg + p99 + + + + + Client Features + {instance.clientFeaturesAverageLatencyMs} + {instance.clientFeaturesP99LatencyMs} + + + Frontend + {instance.frontendApiAverageLatencyMs} + {instance.frontendApiP99LatencyMs} + + + Upstream + + + Client Features + {instance.upstreamFeaturesAverageLatencyMs} + {instance.upstreamFeaturesP99LatencyMs} + + + Metrics + {instance.upstreamMetricsAverageLatencyMs} + {instance.upstreamMetricsP99LatencyMs} + + + Edge + {instance.upstreamEdgeAverageLatencyMs} + {instance.upstreamEdgeP99LatencyMs} + + + + ); +}; diff --git a/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx new file mode 100644 index 0000000000..0be86202b1 --- /dev/null +++ b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx @@ -0,0 +1,226 @@ +import { usePageTitle } from 'hooks/usePageTitle'; +import { ArcherContainer, ArcherElement } from 'react-archer'; +import { Alert, Typography, styled, useTheme } from '@mui/material'; +import { unknownify } from 'utils/unknownify'; +import { useMemo } from 'react'; +import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg'; +import { ReactComponent as LogoIconWhite } from 'assets/icons/logoWhiteBg.svg'; +import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; +import { useConnectedEdges } from 'hooks/api/getters/useConnectedEdges/useConnectedEdges'; +import type { ConnectedEdge } from 'interfaces/connectedEdge'; +import { NetworkConnectedEdgeInstance } from './NetworkConnectedEdgeInstance'; + +const UNLEASH = 'Unleash'; + +const StyledUnleashLevel = styled('div')(({ theme }) => ({ + marginBottom: theme.spacing(18), + display: 'flex', + justifyContent: 'center', +})); + +const StyledEdgeLevel = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', + gap: theme.spacing(4), + flexWrap: 'wrap', + marginTop: theme.spacing(2), +})); + +const StyledNode = styled('div')(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusMedium, + border: `1px solid ${theme.palette.secondary.border}`, + backgroundColor: theme.palette.secondary.light, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: theme.spacing(1.5), + zIndex: 1, + marginTop: theme.spacing(1), + '& > svg': { + width: theme.spacing(9), + height: theme.spacing(9), + }, +})); + +const StyledGroup = styled(StyledNode)({ + backgroundColor: 'transparent', +}); + +const StyledNodeHeader = styled(Typography)(({ theme }) => ({ + fontWeight: theme.fontWeight.bold, +})); + +const StyledNodeDescription = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, +})); + +type AppNameGroup = { + appName: string; + instances: ConnectedEdge[]; + groupTargets: Set; + level: number; +}; + +const createGroups = (edges: ConnectedEdge[]): Map => + edges.reduce((groups, edge) => { + if (!groups.has(edge.appName)) { + groups.set(edge.appName, { + appName: edge.appName, + instances: [], + groupTargets: new Set(), + level: 0, + }); + } + groups.get(edge.appName)!.instances.push(edge); + return groups; + }, new Map()); + +const computeGroupLevels = ( + groups: Map, +): Map => { + const memo = new Map(); + + const getLevel = (group: AppNameGroup): number => { + if (memo.has(group.appName)) return memo.get(group.appName)!; + let level = 0; + group.groupTargets.forEach((target) => { + if (target !== UNLEASH) { + const targetGroup = groups.get(target); + if (targetGroup) { + level = Math.max(level, getLevel(targetGroup) + 1); + } + } + }); + memo.set(group.appName, level); + return level; + }; + + groups.forEach((group) => { + group.level = getLevel(group); + }); + + const levelsMap = new Map(); + groups.forEach((group) => { + const levelGroups = levelsMap.get(group.level) || []; + levelGroups.push(group); + levelsMap.set(group.level, levelGroups); + }); + return levelsMap; +}; + +const processEdges = (edges: ConnectedEdge[]): Map => { + const groups = createGroups(edges); + const instanceIdToId = new Map(); + const idToAppName = new Map(); + + groups.forEach((group) => { + group.instances = group.instances + .sort((a, b) => a.instanceId.localeCompare(b.instanceId)) + .map((instance, index) => { + const id = `${group.appName}-${index + 1}`; + instanceIdToId.set(instance.instanceId, id); + idToAppName.set(id, group.appName); + return { ...instance, id }; + }); + }); + + groups.forEach((group) => { + group.instances = group.instances.map((instance) => ({ + ...instance, + connectedVia: instance.connectedVia + ? instanceIdToId.get(instance.connectedVia) || + instance.connectedVia + : UNLEASH, + })); + const targets = new Set(); + group.instances.forEach((instance) => { + if (!instance.connectedVia || instance.connectedVia === UNLEASH) { + targets.add(UNLEASH); + } else { + const targetApp = idToAppName.get(instance.connectedVia); + if (targetApp && targetApp !== group.appName) { + targets.add(targetApp); + } + } + }); + group.groupTargets = targets; + }); + + return computeGroupLevels(groups); +}; + +export const NetworkConnectedEdges = () => { + usePageTitle('Network - Connected Edges'); + const theme = useTheme(); + const { connectedEdges } = useConnectedEdges({ refreshInterval: 30_000 }); + + const edgeLevels = useMemo( + () => processEdges(connectedEdges), + [connectedEdges], + ); + const levels = Array.from(edgeLevels.keys()).sort((a, b) => a - b); + + if (edgeLevels.size === 0) + return No data available.; + + return ( + + + + + } + lightmode={} + /> + {UNLEASH} + + + + {levels.map((level) => ( + + {edgeLevels + .get(level) + ?.map(({ appName, groupTargets, instances }) => ( + ({ + targetId: target, + targetAnchor: 'bottom', + sourceAnchor: 'top', + style: { + strokeColor: + theme.palette.secondary.border, + }, + }), + )} + > + + + {unknownify(appName)} + + + {instances.length} instance + {instances.length !== 1 && 's'} + + {instances.map((instance) => ( + + ))} + + + ))} + + ))} + + ); +}; + +export default NetworkConnectedEdges; diff --git a/frontend/src/component/admin/network/NetworkOverview/NetworkOverview.tsx b/frontend/src/component/admin/network/NetworkOverview/NetworkOverview.tsx index a2c395a5c8..754bc38b05 100644 --- a/frontend/src/component/admin/network/NetworkOverview/NetworkOverview.tsx +++ b/frontend/src/component/admin/network/NetworkOverview/NetworkOverview.tsx @@ -11,6 +11,7 @@ import type { import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg'; import { ReactComponent as LogoIconWhite } from 'assets/icons/logoWhiteBg.svg'; import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; +import { NetworkPrometheusAPIWarning } from '../NetworkPrometheusAPIWarning'; const StyleUnleashContainer = styled('div')(({ theme }) => ({ marginBottom: theme.spacing(18), @@ -122,7 +123,12 @@ export const NetworkOverview = () => { }, [metrics]); if (apps.length === 0) { - return No data available.; + return ( + + No data available. + + + ); } return ( diff --git a/frontend/src/component/admin/network/NetworkPrometheusAPIWarning.tsx b/frontend/src/component/admin/network/NetworkPrometheusAPIWarning.tsx new file mode 100644 index 0000000000..8353a1fa7c --- /dev/null +++ b/frontend/src/component/admin/network/NetworkPrometheusAPIWarning.tsx @@ -0,0 +1,24 @@ +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; + +export const NetworkPrometheusAPIWarning = () => { + const { + uiConfig: { networkViewEnabled }, + } = useUiConfig(); + + if (networkViewEnabled) return null; + + return ( +

+ This view requires the PROMETHEUS_API environment + variable to be set. Refer to our{' '} + + documentation + {' '} + for more information. +

+ ); +}; diff --git a/frontend/src/component/admin/network/NetworkTraffic/NetworkTraffic.tsx b/frontend/src/component/admin/network/NetworkTraffic/NetworkTraffic.tsx index bff36affe7..3feca79007 100644 --- a/frontend/src/component/admin/network/NetworkTraffic/NetworkTraffic.tsx +++ b/frontend/src/component/admin/network/NetworkTraffic/NetworkTraffic.tsx @@ -28,6 +28,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit import { usePageTitle } from 'hooks/usePageTitle'; import { unknownify } from 'utils/unknownify'; import type { Theme } from '@mui/material/styles/createTheme'; +import { NetworkPrometheusAPIWarning } from '../NetworkPrometheusAPIWarning'; const pointStyles = ['circle', 'rect', 'rectRounded', 'rectRot', 'triangle']; @@ -206,7 +207,12 @@ export const NetworkTraffic: VFC = () => { return ( No data available.} + show={ + + No data available. + + + } elseShow={
diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/__snapshots__/NavigationSidebar.test.tsx.snap b/frontend/src/component/layout/MainLayout/NavigationSidebar/__snapshots__/NavigationSidebar.test.tsx.snap index 07aaa5b624..83eb5ebe97 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/__snapshots__/NavigationSidebar.test.tsx.snap +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/__snapshots__/NavigationSidebar.test.tsx.snap @@ -78,6 +78,10 @@ exports[`order of items in navigation > menu for enterprise plan 1`] = ` "icon": "AssignmentOutlinedIcon", "text": "Single sign-on", }, + { + "icon": "HubOutlinedIcon", + "text": "Network", + }, { "icon": "BuildOutlinedIcon", "text": "Maintenance", @@ -256,6 +260,10 @@ exports[`order of items in navigation > menu for pro plan 1`] = ` "icon": "AssignmentOutlinedIcon", "text": "Single sign-on", }, + { + "icon": "HubOutlinedIcon", + "text": "Network", + }, { "icon": "BuildOutlinedIcon", "text": "Maintenance", diff --git a/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts b/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts new file mode 100644 index 0000000000..4e7afd89c1 --- /dev/null +++ b/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import type { ConnectedEdge } from 'interfaces/connectedEdge'; +import type { SWRConfiguration } from 'swr'; +import { useUiFlag } from 'hooks/useUiFlag'; + +const DEFAULT_DATA: ConnectedEdge[] = []; + +export const useConnectedEdges = (options?: SWRConfiguration) => { + const { isEnterprise } = useUiConfig(); + const edgeObservabilityEnabled = useUiFlag('edgeObservability'); + + const { data, error, mutate } = useConditionalSWR( + isEnterprise() && edgeObservabilityEnabled, + DEFAULT_DATA, + formatApiPath('api/admin/metrics/edges'), + fetcher, + options, + ); + + return useMemo( + () => ({ + connectedEdges: data ?? [], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Connected Edges')) + .then((res) => res.json()); +}; diff --git a/frontend/src/hooks/api/getters/useInstanceMetrics/useInstanceMetrics.ts b/frontend/src/hooks/api/getters/useInstanceMetrics/useInstanceMetrics.ts index 31890661f8..f47dc34bd1 100644 --- a/frontend/src/hooks/api/getters/useInstanceMetrics/useInstanceMetrics.ts +++ b/frontend/src/hooks/api/getters/useInstanceMetrics/useInstanceMetrics.ts @@ -1,8 +1,10 @@ -import useSWR, { type SWRConfiguration } from 'swr'; +import type { SWRConfiguration } from 'swr'; import { useMemo } from 'react'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import type { RequestsPerSecondSchema } from 'openapi'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import useUiConfig from '../useUiConfig/useUiConfig'; export interface IInstanceMetricsResponse { metrics: RequestsPerSecondSchema; @@ -17,7 +19,13 @@ export interface IInstanceMetricsResponse { export const useInstanceMetrics = ( options: SWRConfiguration = {}, ): IInstanceMetricsResponse => { - const { data, error, mutate } = useSWR( + const { + uiConfig: { networkViewEnabled }, + } = useUiConfig(); + + const { data, error, mutate } = useConditionalSWR( + networkViewEnabled, + {}, formatApiPath(`api/admin/metrics/rps`), fetcher, options, diff --git a/frontend/src/interfaces/connectedEdge.ts b/frontend/src/interfaces/connectedEdge.ts new file mode 100644 index 0000000000..dc4d9bba6f --- /dev/null +++ b/frontend/src/interfaces/connectedEdge.ts @@ -0,0 +1,23 @@ +export type ConnectedEdge = { + id?: string; + appName: string; + connectedStreamingClients: number; + edgeVersion: string; + instanceId: string; + region: string | null; + reportedAt: string; + started: string; + connectedVia?: string; + cpuUsage: string; + memoryUsage: number; + clientFeaturesAverageLatencyMs: string; + clientFeaturesP99LatencyMs: string; + frontendApiAverageLatencyMs: string; + frontendApiP99LatencyMs: string; + upstreamFeaturesAverageLatencyMs: string; + upstreamFeaturesP99LatencyMs: string; + upstreamMetricsAverageLatencyMs: string; + upstreamMetricsP99LatencyMs: string; + upstreamEdgeAverageLatencyMs: string; + upstreamEdgeP99LatencyMs: string; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 9a881e4569..a3931c9ad1 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -94,6 +94,7 @@ export type UiFlags = { dataUsageMultiMonthView?: boolean; uiGlobalFontSize?: boolean; connectionCount?: boolean; + edgeObservability?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/openapi/spec/ui-config-schema.ts b/src/lib/openapi/spec/ui-config-schema.ts index 705b7acabb..f121a1d52d 100644 --- a/src/lib/openapi/spec/ui-config-schema.ts +++ b/src/lib/openapi/spec/ui-config-schema.ts @@ -101,7 +101,7 @@ export const uiConfigSchema = { }, networkViewEnabled: { type: 'boolean', - description: 'Whether to enable the Unleash network view or not.', + description: 'Whether a Prometheus API is available.', example: true, }, frontendApiOrigins: { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index e1504613a6..5c9a96f01c 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -67,7 +67,8 @@ export type IFlagKey = | 'dataUsageMultiMonthView' | 'uiGlobalFontSize' | 'connectionCount' - | 'teamsIntegrationChangeRequests'; + | 'teamsIntegrationChangeRequests' + | 'edgeObservability'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -324,6 +325,10 @@ const flags: IFlags = { process.env.EXPERIMENTAL_TEAMS_INTEGRATION_CHANGE_REQUESTS, false, ), + edgeObservability: parseEnvVarBoolean( + process.env.EXPERIMENTAL_EDGE_OBSERVABILITY, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = {