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.   
This commit is contained in:
parent
c938b0fa6c
commit
b4bfadd95e
@ -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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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 />}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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 }}>
|
||||||
|
@ -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",
|
||||||
|
@ -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());
|
||||||
|
};
|
@ -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,
|
||||||
|
23
frontend/src/interfaces/connectedEdge.ts
Normal file
23
frontend/src/interfaces/connectedEdge.ts
Normal 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;
|
||||||
|
};
|
@ -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 {
|
||||||
|
@ -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: {
|
||||||
|
@ -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 = {
|
||||||
|
Loading…
Reference in New Issue
Block a user