1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

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)
This commit is contained in:
Nuno Góis 2025-02-19 12:23:52 +00:00 committed by GitHub
parent c938b0fa6c
commit b4bfadd95e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 703 additions and 9 deletions

View File

@ -65,7 +65,6 @@ export const adminRoutes: INavigationMenuItem[] = [
path: '/admin/network/*', path: '/admin/network/*',
title: 'Network', title: 'Network',
menu: { adminSettings: true, mode: ['pro', 'enterprise'] }, menu: { adminSettings: true, mode: ['pro', 'enterprise'] },
configFlag: 'networkViewEnabled',
group: 'instance', group: 'instance',
}, },
{ {

View File

@ -4,8 +4,12 @@ import { Tab, Tabs } from '@mui/material';
import { Route, Routes, useLocation } from 'react-router-dom'; import { Route, Routes, useLocation } from 'react-router-dom';
import { TabLink } from 'component/common/TabNav/TabLink'; import { TabLink } from 'component/common/TabNav/TabLink';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { useUiFlag } from 'hooks/useUiFlag';
const NetworkOverview = lazy(() => import('./NetworkOverview/NetworkOverview')); const NetworkOverview = lazy(() => import('./NetworkOverview/NetworkOverview'));
const NetworkConnectedEdges = lazy(
() => import('./NetworkConnectedEdges/NetworkConnectedEdges'),
);
const NetworkTraffic = lazy(() => import('./NetworkTraffic/NetworkTraffic')); const NetworkTraffic = lazy(() => import('./NetworkTraffic/NetworkTraffic'));
const NetworkTrafficUsage = lazy( const NetworkTrafficUsage = lazy(
() => import('./NetworkTrafficUsage/NetworkTrafficUsage'), () => import('./NetworkTrafficUsage/NetworkTrafficUsage'),
@ -20,6 +24,10 @@ const tabs = [
label: 'Traffic', label: 'Traffic',
path: '/admin/network/traffic', path: '/admin/network/traffic',
}, },
{
label: 'Connected Edges',
path: '/admin/network/connected-edges',
},
{ {
label: 'Data Usage', label: 'Data Usage',
path: '/admin/network/data-usage', path: '/admin/network/data-usage',
@ -28,6 +36,11 @@ const tabs = [
export const Network = () => { export const Network = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const edgeObservabilityEnabled = useUiFlag('edgeObservability');
const filteredTabs = tabs.filter(
({ label }) => label !== 'Connected Edges' || edgeObservabilityEnabled,
);
return ( return (
<div> <div>
@ -41,7 +54,7 @@ export const Network = () => {
variant='scrollable' variant='scrollable'
allowScrollButtonsMobile allowScrollButtonsMobile
> >
{tabs.map(({ label, path }) => ( {filteredTabs.map(({ label, path }) => (
<Tab <Tab
key={label} key={label}
value={path} value={path}
@ -57,8 +70,14 @@ export const Network = () => {
} }
> >
<Routes> <Routes>
<Route path='traffic' element={<NetworkTraffic />} />
<Route path='*' element={<NetworkOverview />} /> <Route path='*' element={<NetworkOverview />} />
<Route path='traffic' element={<NetworkTraffic />} />
{edgeObservabilityEnabled && (
<Route
path='connected-edges'
element={<NetworkConnectedEdges />}
/>
)}
<Route <Route
path='data-usage' path='data-usage'
element={<NetworkTrafficUsage />} element={<NetworkTrafficUsage />}

View File

@ -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' && (
<p>Resource metrics are only available when running on Linux</p>
);
return (
<StyledInstance>
<StyledAccordion>
<StyledAccordionSummary
expandIcon={<ExpandMore />}
connectionStatus={connectionStatus}
>
<Tooltip
arrow
title={`${connectionStatus}. Last reported: ${lastReport}`}
>
<CircleIcon />
</Tooltip>
{instance.id || instance.instanceId}
</StyledAccordionSummary>
<StyledAccordionDetails>
<StyledDetailRow>
<strong>ID</strong>
<span>{instance.instanceId}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Upstream</strong>
<span>{instance.connectedVia || 'Unleash'}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Status</strong>
<StyledBadge
color={
connectionStatus === 'Disconnected'
? 'error'
: connectionStatus === 'Stale'
? 'warning'
: 'success'
}
>
{connectionStatus}
</StyledBadge>
</StyledDetailRow>
<StyledDetailRow>
<strong>Start</strong>
<span>{start}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Last report</strong>
<span>{lastReport}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>App name</strong>
<span>{instance.appName}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Region</strong>
<span>{instance.region || 'Unknown'}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Version</strong>
<span>{instance.edgeVersion}</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>CPU</strong>
<span>
{cpuPercentage}{' '}
<HelpIcon
tooltip={
<>
<p>
CPU average usage since instance
started
</p>
{archWarning}
</>
}
size='16px'
/>
</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Memory</strong>
<span>
{memory}{' '}
<HelpIcon
tooltip={
<>
<p>Current memory usage</p>
{archWarning}
</>
}
size='16px'
/>
</span>
</StyledDetailRow>
<StyledDetailRow>
<strong>Stream clients</strong>
<span>{instance.connectedStreamingClients}</span>
</StyledDetailRow>
<StyledDetailRow>
<NetworkConnectedEdgeInstanceLatency
instance={instance}
/>
</StyledDetailRow>
</StyledAccordionDetails>
</StyledAccordion>
</StyledInstance>
);
};

View File

@ -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 (
<StyledTable>
<thead>
<tr>
<th>Latency (ms)</th>
<th>Avg</th>
<th>p99</th>
</tr>
</thead>
<tbody>
<tr>
<td>Client Features</td>
<td>{instance.clientFeaturesAverageLatencyMs}</td>
<td>{instance.clientFeaturesP99LatencyMs}</td>
</tr>
<tr>
<td>Frontend</td>
<td>{instance.frontendApiAverageLatencyMs}</td>
<td>{instance.frontendApiP99LatencyMs}</td>
</tr>
<StyledUpstreamSection>
<td colSpan={3}>Upstream</td>
</StyledUpstreamSection>
<tr>
<td>Client Features</td>
<td>{instance.upstreamFeaturesAverageLatencyMs}</td>
<td>{instance.upstreamFeaturesP99LatencyMs}</td>
</tr>
<tr>
<td>Metrics</td>
<td>{instance.upstreamMetricsAverageLatencyMs}</td>
<td>{instance.upstreamMetricsP99LatencyMs}</td>
</tr>
<tr>
<td>Edge</td>
<td>{instance.upstreamEdgeAverageLatencyMs}</td>
<td>{instance.upstreamEdgeP99LatencyMs}</td>
</tr>
</tbody>
</StyledTable>
);
};

View File

@ -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<string>;
level: number;
};
const createGroups = (edges: ConnectedEdge[]): Map<string, AppNameGroup> =>
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<string, AppNameGroup>());
const computeGroupLevels = (
groups: Map<string, AppNameGroup>,
): Map<number, AppNameGroup[]> => {
const memo = new Map<string, number>();
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<number, AppNameGroup[]>();
groups.forEach((group) => {
const levelGroups = levelsMap.get(group.level) || [];
levelGroups.push(group);
levelsMap.set(group.level, levelGroups);
});
return levelsMap;
};
const processEdges = (edges: ConnectedEdge[]): Map<number, AppNameGroup[]> => {
const groups = createGroups(edges);
const instanceIdToId = new Map<string, string>();
const idToAppName = new Map<string, string>();
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<string>();
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 <Alert severity='warning'>No data available.</Alert>;
return (
<ArcherContainer
strokeColor={theme.palette.text.primary}
endShape={{ arrow: { arrowLength: 4, arrowThickness: 4 } }}
>
<StyledUnleashLevel>
<ArcherElement id={UNLEASH}>
<StyledNode>
<ThemeMode
darkmode={<LogoIconWhite />}
lightmode={<LogoIcon />}
/>
<Typography sx={{ mt: 1 }}>{UNLEASH}</Typography>
</StyledNode>
</ArcherElement>
</StyledUnleashLevel>
{levels.map((level) => (
<StyledEdgeLevel key={level}>
{edgeLevels
.get(level)
?.map(({ appName, groupTargets, instances }) => (
<ArcherElement
key={appName}
id={appName}
relations={Array.from(groupTargets).map(
(target) => ({
targetId: target,
targetAnchor: 'bottom',
sourceAnchor: 'top',
style: {
strokeColor:
theme.palette.secondary.border,
},
}),
)}
>
<StyledGroup>
<StyledNodeHeader>
{unknownify(appName)}
</StyledNodeHeader>
<StyledNodeDescription>
{instances.length} instance
{instances.length !== 1 && 's'}
</StyledNodeDescription>
{instances.map((instance) => (
<NetworkConnectedEdgeInstance
key={instance.instanceId}
instance={instance}
/>
))}
</StyledGroup>
</ArcherElement>
))}
</StyledEdgeLevel>
))}
</ArcherContainer>
);
};
export default NetworkConnectedEdges;

View File

@ -11,6 +11,7 @@ import type {
import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg'; import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
import { ReactComponent as LogoIconWhite } from 'assets/icons/logoWhiteBg.svg'; import { ReactComponent as LogoIconWhite } from 'assets/icons/logoWhiteBg.svg';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import { NetworkPrometheusAPIWarning } from '../NetworkPrometheusAPIWarning';
const StyleUnleashContainer = styled('div')(({ theme }) => ({ const StyleUnleashContainer = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(18), marginBottom: theme.spacing(18),
@ -122,7 +123,12 @@ export const NetworkOverview = () => {
}, [metrics]); }, [metrics]);
if (apps.length === 0) { if (apps.length === 0) {
return <Alert severity='warning'>No data available.</Alert>; return (
<Alert severity='warning'>
No data available.
<NetworkPrometheusAPIWarning />
</Alert>
);
} }
return ( return (

View File

@ -0,0 +1,24 @@
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
export const NetworkPrometheusAPIWarning = () => {
const {
uiConfig: { networkViewEnabled },
} = useUiConfig();
if (networkViewEnabled) return null;
return (
<p>
This view requires the <strong>PROMETHEUS_API</strong> environment
variable to be set. Refer to our{' '}
<a
href='https://docs.getunleash.io/reference/network-view#data-source'
target='_blank'
rel='noreferrer'
>
documentation
</a>{' '}
for more information.
</p>
);
};

View File

@ -28,6 +28,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { unknownify } from 'utils/unknownify'; import { unknownify } from 'utils/unknownify';
import type { Theme } from '@mui/material/styles/createTheme'; import type { Theme } from '@mui/material/styles/createTheme';
import { NetworkPrometheusAPIWarning } from '../NetworkPrometheusAPIWarning';
const pointStyles = ['circle', 'rect', 'rectRounded', 'rectRot', 'triangle']; const pointStyles = ['circle', 'rect', 'rectRounded', 'rectRot', 'triangle'];
@ -206,7 +207,12 @@ export const NetworkTraffic: VFC = () => {
return ( return (
<ConditionallyRender <ConditionallyRender
condition={data.datasets.length === 0} condition={data.datasets.length === 0}
show={<Alert severity='warning'>No data available.</Alert>} show={
<Alert severity='warning'>
No data available.
<NetworkPrometheusAPIWarning />
</Alert>
}
elseShow={ elseShow={
<Box sx={{ display: 'grid', gap: 4 }}> <Box sx={{ display: 'grid', gap: 4 }}>
<div style={{ height: 400 }}> <div style={{ height: 400 }}>

View File

@ -78,6 +78,10 @@ exports[`order of items in navigation > menu for enterprise plan 1`] = `
"icon": "AssignmentOutlinedIcon", "icon": "AssignmentOutlinedIcon",
"text": "Single sign-on", "text": "Single sign-on",
}, },
{
"icon": "HubOutlinedIcon",
"text": "Network",
},
{ {
"icon": "BuildOutlinedIcon", "icon": "BuildOutlinedIcon",
"text": "Maintenance", "text": "Maintenance",
@ -256,6 +260,10 @@ exports[`order of items in navigation > menu for pro plan 1`] = `
"icon": "AssignmentOutlinedIcon", "icon": "AssignmentOutlinedIcon",
"text": "Single sign-on", "text": "Single sign-on",
}, },
{
"icon": "HubOutlinedIcon",
"text": "Network",
},
{ {
"icon": "BuildOutlinedIcon", "icon": "BuildOutlinedIcon",
"text": "Maintenance", "text": "Maintenance",

View File

@ -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<ConnectedEdge[]>(
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());
};

View File

@ -1,8 +1,10 @@
import useSWR, { type SWRConfiguration } from 'swr'; import type { SWRConfiguration } from 'swr';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import type { RequestsPerSecondSchema } from 'openapi'; import type { RequestsPerSecondSchema } from 'openapi';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
export interface IInstanceMetricsResponse { export interface IInstanceMetricsResponse {
metrics: RequestsPerSecondSchema; metrics: RequestsPerSecondSchema;
@ -17,7 +19,13 @@ export interface IInstanceMetricsResponse {
export const useInstanceMetrics = ( export const useInstanceMetrics = (
options: SWRConfiguration = {}, options: SWRConfiguration = {},
): IInstanceMetricsResponse => { ): IInstanceMetricsResponse => {
const { data, error, mutate } = useSWR( const {
uiConfig: { networkViewEnabled },
} = useUiConfig();
const { data, error, mutate } = useConditionalSWR(
networkViewEnabled,
{},
formatApiPath(`api/admin/metrics/rps`), formatApiPath(`api/admin/metrics/rps`),
fetcher, fetcher,
options, options,

View File

@ -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;
};

View File

@ -94,6 +94,7 @@ export type UiFlags = {
dataUsageMultiMonthView?: boolean; dataUsageMultiMonthView?: boolean;
uiGlobalFontSize?: boolean; uiGlobalFontSize?: boolean;
connectionCount?: boolean; connectionCount?: boolean;
edgeObservability?: boolean;
}; };
export interface IVersionInfo { export interface IVersionInfo {

View File

@ -101,7 +101,7 @@ export const uiConfigSchema = {
}, },
networkViewEnabled: { networkViewEnabled: {
type: 'boolean', type: 'boolean',
description: 'Whether to enable the Unleash network view or not.', description: 'Whether a Prometheus API is available.',
example: true, example: true,
}, },
frontendApiOrigins: { frontendApiOrigins: {

View File

@ -67,7 +67,8 @@ export type IFlagKey =
| 'dataUsageMultiMonthView' | 'dataUsageMultiMonthView'
| 'uiGlobalFontSize' | 'uiGlobalFontSize'
| 'connectionCount' | 'connectionCount'
| 'teamsIntegrationChangeRequests'; | 'teamsIntegrationChangeRequests'
| 'edgeObservability';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -324,6 +325,10 @@ const flags: IFlags = {
process.env.EXPERIMENTAL_TEAMS_INTEGRATION_CHANGE_REQUESTS, process.env.EXPERIMENTAL_TEAMS_INTEGRATION_CHANGE_REQUESTS,
false, false,
), ),
edgeObservability: parseEnvVarBoolean(
process.env.EXPERIMENTAL_EDGE_OBSERVABILITY,
false,
),
}; };
export const defaultExperimentalOptions: IExperimentalOptions = { export const defaultExperimentalOptions: IExperimentalOptions = {