From 7ab16e51492e60d8fc410633f22caae7b3c8286d Mon Sep 17 00:00:00 2001 From: Javier Ortiz Date: Mon, 9 Nov 2026 11:53:35 +0100 Subject: [PATCH 1/6] feat(app): split the service map by extra group-by expressions Nodes were one per ServiceName and edges one per client service -> server service, so a single slow or failing pod was averaged into its service. The service map now takes up to 3 extra SQL expressions through a groupBy URL param and a Group by editor; a node is the (ServiceName, dim0, dim1, dim2) tuple and an edge joins two such tuples. Both CTEs select each expression as toString(expr). The outer query selects every key column for both sides of the join as server_ / client_, groups by the key tuples and excludes self-calls by comparing the full tuples, so two pods of one service calling each other are an edge while a node calling itself is not. With no expressions the query reduces to the previous one. Node labels gain a second line with the dimension values and the tooltip search links pin each value. The e2e seed gives every service two pods and the new spec covers grouping from the URL, from the editor, and the 3-expression cap. --- .changeset/service-map-group-by.md | 7 + packages/app/src/DBServiceMapPage.tsx | 45 +++- .../src/components/ServiceMap/ServiceMap.tsx | 30 ++- .../components/ServiceMap/ServiceMapEdge.tsx | 7 + .../ServiceMap/ServiceMapGroupByInput.tsx | 70 ++++++ .../components/ServiceMap/ServiceMapNode.tsx | 17 ++ .../ServiceMap/ServiceMapTooltip.tsx | 53 +++- .../ServiceMap/__tests__/dimensions.test.ts | 62 +++++ .../ServiceMap/__tests__/utils.test.ts | 2 + .../src/components/ServiceMap/dimensions.ts | 47 ++++ .../src/hooks/__tests__/useServiceMap.test.ts | 228 ++++++++++++++++++ packages/app/src/hooks/useServiceMap.tsx | 131 ++++++++-- .../e2e/features/service-map-group-by.spec.ts | 75 ++++++ packages/app/tests/e2e/seed-clickhouse.ts | 5 +- 14 files changed, 722 insertions(+), 57 deletions(-) create mode 100644 .changeset/service-map-group-by.md create mode 100644 packages/app/src/components/ServiceMap/ServiceMapGroupByInput.tsx create mode 100644 packages/app/src/components/ServiceMap/__tests__/dimensions.test.ts create mode 100644 packages/app/src/components/ServiceMap/dimensions.ts create mode 100644 packages/app/tests/e2e/features/service-map-group-by.spec.ts diff --git a/.changeset/service-map-group-by.md b/.changeset/service-map-group-by.md new file mode 100644 index 0000000000..31b522011e --- /dev/null +++ b/.changeset/service-map-group-by.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': minor +--- + +feat: split the service map by up to 3 extra group-by SQL expressions (for +example `ResourceAttributes['k8s.pod.name']`), so a node is a service plus +those values and a slow or failing pod is no longer averaged into its service. diff --git a/packages/app/src/DBServiceMapPage.tsx b/packages/app/src/DBServiceMapPage.tsx index 56d954d62a..2dd372de41 100644 --- a/packages/app/src/DBServiceMapPage.tsx +++ b/packages/app/src/DBServiceMapPage.tsx @@ -34,6 +34,10 @@ import { PageLayout } from '@/components/PageLayout'; import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; +import { parseServiceMapGroupBy } from '@/components/ServiceMap/dimensions'; +import ServiceMapGroupByInput, { + ServiceMapSearchFormValues, +} from '@/components/ServiceMap/ServiceMapGroupByInput'; import { IS_LOCAL_MODE } from '@/config'; import { useGetKeyValues } from '@/hooks/useMetadata'; import { useResolvedSourceParam } from '@/hooks/useResolvedSourceParam'; @@ -86,6 +90,8 @@ const searchQueryStateMap = { where: parseAsStringEncoded, whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), services: parseAsArrayOf(parseAsString), + // Comma-separated SQL expressions the nodes are split by, after ServiceName. + groupBy: parseAsStringEncoded, }; function DBServiceMapPage() { @@ -121,14 +127,20 @@ function DBServiceMapPage() { // useResolvedSourceParam will show a notification to the user. const source = paramSource ?? defaultSource; - const { control, handleSubmit, setValue } = useForm({ - values: { - source: source?.id, - where: searchedConfig.where ?? '', - whereLanguage: - searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', - }, - }); + const { control, handleSubmit, setValue } = + useForm({ + values: { + source: source?.id, + where: searchedConfig.where ?? '', + whereLanguage: + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + groupBy: searchedConfig.groupBy ?? '', + }, + }); + const groupBy = useMemo( + () => parseServiceMapGroupBy(searchedConfig.groupBy), + [searchedConfig.groupBy], + ); const watchedSource = useWatch({ control, name: 'source' }); const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = @@ -209,8 +221,13 @@ function DBServiceMapPage() { const onSubmit = useCallback(() => { onSearch(displayedTimeInputValue); - handleSubmit(({ where, whereLanguage }) => { - setSearchedConfig(prev => ({ ...prev, where, whereLanguage })); + handleSubmit(({ where, whereLanguage, groupBy }) => { + setSearchedConfig(prev => ({ + ...prev, + where, + whereLanguage, + groupBy: groupBy || null, + })); })(); }, [handleSubmit, setSearchedConfig, displayedTimeInputValue, onSearch]); @@ -410,6 +427,13 @@ function DBServiceMapPage() { sqlPlaceholder="SQL WHERE to filter spans (ex. Duration > 1000000)" minWidth="min(500px, 100%)" /> + 0 ? selectedServices : undefined } + groupBy={groupBy} onFocusService={onFocusService} /> diff --git a/packages/app/src/components/ServiceMap/ServiceMap.tsx b/packages/app/src/components/ServiceMap/ServiceMap.tsx index e8a5f3a6ce..a2bcafc1db 100644 --- a/packages/app/src/components/ServiceMap/ServiceMap.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMap.tsx @@ -96,6 +96,8 @@ interface ServiceMapPresentationProps { dateRange: [Date, Date]; source: TTraceSource; isSingleTrace?: boolean; + // Extra group-by SQL expressions the nodes are split by (beyond ServiceName). + groupBy: string[]; // The single service the map is currently scoped to (via a node's focus // action), or undefined when showing all/multiple services. focusedService?: string; @@ -109,6 +111,7 @@ function ServiceMapPresentation({ dateRange, source, isSingleTrace, + groupBy, focusedService, onFocusService, }: ServiceMapPresentationProps) { @@ -168,13 +171,14 @@ function ServiceMapPresentation({ useEffect(() => { const nodes: Node[] = Array.from(services?.values() ?? []).map((service, index) => ({ - id: service.serviceName, + id: service.key, data: { ...service, dateRange, source, maxThroughput: metricMax.throughput, isSingleTrace, + groupBy, focusedService, onFocusService, }, @@ -186,16 +190,16 @@ function ServiceMapPresentation({ services?.values() ?? [], ) .filter(service => service.incomingRequestsByClient.size > 0) - .flatMap(({ serviceName, incomingRequestsByClient: requestsByClient }) => - Array.from(requestsByClient.entries()).map( + .flatMap(({ key, serviceName, dimensions, incomingRequestsByClient }) => + Array.from(incomingRequestsByClient.entries()).map( ([ - clientServiceName, + clientKey, { totalRequests, errorPercentage, p50, p95, p99, hasLatency }, ]) => { return { - id: `${serviceName}-${clientServiceName}`, - source: clientServiceName, - target: serviceName, + id: `${clientKey}->${key}`, + source: clientKey, + target: key, animated: true, type: 'request', data: { @@ -208,6 +212,8 @@ function ServiceMapPresentation({ source, dateRange, serviceName, + dimensions, + groupBy, isSingleTrace, }, }; @@ -225,6 +231,7 @@ function ServiceMapPresentation({ source, metricMax.throughput, isSingleTrace, + groupBy, focusedService, onFocusService, ]); @@ -336,11 +343,17 @@ interface ServiceMapProps { where?: string; whereLanguage?: 'sql' | 'lucene'; serviceNames?: string[]; + // Extra group-by SQL expressions; ServiceName is always the first key. + groupBy?: string[]; // Called when a node is clicked, e.g. to drive the service filter to focus // on that service and its immediate dependencies. onFocusService?: (serviceName: string) => void; } +// A stable default so the layout effect does not re-run on every render when +// the caller passes no group-by. +const NO_GROUP_BY: string[] = []; + export default function ServiceMap({ traceId, traceTableSource, @@ -350,6 +363,7 @@ export default function ServiceMap({ where, whereLanguage, serviceNames, + groupBy = NO_GROUP_BY, onFocusService, }: ServiceMapProps) { const { @@ -364,6 +378,7 @@ export default function ServiceMap({ where, whereLanguage, serviceNames, + groupBy, }); useEffect(() => { @@ -390,6 +405,7 @@ export default function ServiceMap({ dateRange={dateRange} source={traceTableSource} isSingleTrace={isSingleTrace} + groupBy={groupBy} focusedService={focusedService} onFocusService={onFocusService} /> diff --git a/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx b/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx index 9e08473531..b55e34b7a5 100644 --- a/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx @@ -20,7 +20,10 @@ export type ServiceMapEdgeData = { hasLatency: boolean; dateRange: [Date, Date]; source: TTraceSource; + // The server (target) node's label parts. serviceName: string; + dimensions: string[]; + groupBy: string[]; isSingleTrace?: boolean; }; @@ -42,6 +45,8 @@ export default function ServiceMapEdge( hasLatency, dateRange, serviceName, + dimensions, + groupBy, source, isSingleTrace, } = props.data; @@ -70,6 +75,8 @@ export default function ServiceMapEdge( source={source} dateRange={dateRange} serviceName={serviceName} + dimensions={dimensions} + groupBy={groupBy} isSingleTrace={isSingleTrace} /> diff --git a/packages/app/src/components/ServiceMap/ServiceMapGroupByInput.tsx b/packages/app/src/components/ServiceMap/ServiceMapGroupByInput.tsx new file mode 100644 index 0000000000..ae8e3ec3ef --- /dev/null +++ b/packages/app/src/components/ServiceMap/ServiceMapGroupByInput.tsx @@ -0,0 +1,70 @@ +import { Control, useFormState } from 'react-hook-form'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils'; +import { Box, Text } from '@mantine/core'; + +import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; +import { MAX_SERVICE_MAP_GROUP_BY } from '@/hooks/useServiceMap'; + +export type ServiceMapSearchFormValues = { + source?: string; + where: string; + whereLanguage: 'sql' | 'lucene'; + // Comma-separated extra group-by SQL expressions. + groupBy: string; +}; + +function validateGroupBy(value: string): true | string { + return ( + splitAndTrimWithBracket(value).length <= MAX_SERVICE_MAP_GROUP_BY || + `Up to ${MAX_SERVICE_MAP_GROUP_BY} group by expressions` + ); +} + +/** The "Group by" SQL editor, with the expression-count validation inline. */ +export default function ServiceMapGroupByInput({ + control, + tableConnection, + sourceId, + dateRange, + onSubmit, +}: { + control: Control; + tableConnection: TableConnection; + sourceId: string; + dateRange: [Date, Date]; + onSubmit: () => void; +}) { + const { errors } = useFormState({ control, name: 'groupBy' }); + + return ( + + + {errors.groupBy?.message ? ( + + {errors.groupBy.message} + + ) : null} + + ); +} diff --git a/packages/app/src/components/ServiceMap/ServiceMapNode.tsx b/packages/app/src/components/ServiceMap/ServiceMapNode.tsx index c0a2219340..7936959756 100644 --- a/packages/app/src/components/ServiceMap/ServiceMapNode.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMapNode.tsx @@ -5,6 +5,7 @@ import { Handle, Node, NodeProps, NodeToolbar, Position } from '@xyflow/react'; import { ServiceAggregation } from '@/hooks/useServiceMap'; +import { formatServiceMapDimensions } from './dimensions'; import { ServiceMapMetricContext } from './ServiceMapMetricContext'; import ServiceMapTooltip from './ServiceMapTooltip'; import { @@ -23,6 +24,8 @@ export type ServiceMapNodeData = ServiceAggregation & { // scale node size. maxThroughput: number; isSingleTrace?: boolean; + // The extra group-by SQL expressions behind `dimensions`. + groupBy: string[]; // Name of the service the map is currently focused on (if any), so a node can // tell whether it is the focused one and show the matching CTA. focusedService?: string; @@ -36,6 +39,8 @@ export default function ServiceMapNode( const { data } = props; const { serviceName, + dimensions, + groupBy, incomingRequests: { totalRequests: totalIncomingRequestCount, errorPercentage, @@ -83,6 +88,7 @@ export default function ServiceMapNode( totalIncomingRequestCount + outgoingRequests, maxThroughput, ); + const dimensionsLabel = formatServiceMapDimensions(dimensions); return ( <> @@ -95,6 +101,8 @@ export default function ServiceMapNode( source={source} dateRange={dateRange} serviceName={serviceName} + dimensions={dimensions} + groupBy={groupBy} isSingleTrace={isSingleTrace} isFocused={focusedService === serviceName} onFocus={ @@ -133,6 +141,15 @@ export default function ServiceMapNode( {serviceName} + {dimensionsLabel ? ( + + {dimensionsLabel} + + ) : null} ); diff --git a/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx b/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx index 9996df3d71..f2bc0fa039 100644 --- a/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx @@ -13,6 +13,10 @@ import { import { formatDurationMs } from '@/utils'; +import { + buildServiceMapDimensionsWhere, + formatServiceMapDimensions, +} from './dimensions'; import { buildServiceMapErrorWhere, ERROR_RATE_HIGH, @@ -31,6 +35,8 @@ export default function ServiceMapTooltip({ source, dateRange, serviceName, + dimensions, + groupBy, isSingleTrace, isFocused, onFocus, @@ -44,6 +50,10 @@ export default function ServiceMapTooltip({ source: TTraceSource; dateRange: [Date, Date]; serviceName: string; + // Values of the extra group-by expressions for this node, in groupBy order. + dimensions: string[]; + // The extra group-by SQL expressions, so search links can pin each value. + groupBy: string[]; isSingleTrace?: boolean; // Whether the map is currently scoped to this service (the focus toggle is on) // — flips the focus CTA to a "clear" action. @@ -63,30 +73,35 @@ export default function ServiceMapTooltip({ ? 'var(--color-text-danger)' : 'var(--color-chart-warning)'; + const dimensionsLabel = formatServiceMapDimensions(dimensions); + const dimensionsWhere = buildServiceMapDimensionsWhere(groupBy, dimensions); + const handleRequestsClick = useCallback(() => { navigateToTraceSearch({ dateRange, source, - where: SqlString.format("? = ? AND ? IN ('Server', 'Consumer')", [ - SqlString.raw(source.serviceNameExpression ?? 'ServiceName'), - serviceName, - SqlString.raw(source.spanKindExpression ?? 'SpanKind'), - ]), + where: + SqlString.format("? = ? AND ? IN ('Server', 'Consumer')", [ + SqlString.raw(source.serviceNameExpression ?? 'ServiceName'), + serviceName, + SqlString.raw(source.spanKindExpression ?? 'SpanKind'), + ]) + dimensionsWhere, }); - }, [dateRange, source, serviceName]); + }, [dateRange, source, serviceName, dimensionsWhere]); const handleErrorsClick = useCallback(() => { navigateToTraceSearch({ dateRange, source, - where: buildServiceMapErrorWhere({ - serviceNameExpression: source.serviceNameExpression ?? 'ServiceName', - serviceName, - spanKindExpression: source.spanKindExpression ?? 'SpanKind', - statusCodeExpression: source.statusCodeExpression ?? 'StatusCode', - }), + where: + buildServiceMapErrorWhere({ + serviceNameExpression: source.serviceNameExpression ?? 'ServiceName', + serviceName, + spanKindExpression: source.spanKindExpression ?? 'SpanKind', + statusCodeExpression: source.statusCodeExpression ?? 'StatusCode', + }) + dimensionsWhere, }); - }, [dateRange, source, serviceName]); + }, [dateRange, source, serviceName, dimensionsWhere]); const showMetrics = totalRequests > 0 && (requestsPerSecond != null || latencyMs != null); @@ -104,6 +119,18 @@ export default function ServiceMapTooltip({ > {serviceName} + {dimensionsLabel ? ( + + {dimensionsLabel} + + ) : null} + + + ); + } + if (error) { return ( diff --git a/packages/app/src/components/ServiceMap/__tests__/utils.test.ts b/packages/app/src/components/ServiceMap/__tests__/utils.test.ts index 03343a48d8..2523fb7ed8 100644 --- a/packages/app/src/components/ServiceMap/__tests__/utils.test.ts +++ b/packages/app/src/components/ServiceMap/__tests__/utils.test.ts @@ -12,6 +12,8 @@ import { getNodeSize, getRequestsPerSecond, getServiceMetricValue, + isServiceMapTooLarge, + MAX_SERVICE_MAP_NODES, navigateToTraceSearch, rawDurationToMs, SERVICE_MAP_METRIC_HUE, @@ -695,3 +697,14 @@ describe('deriveDisplayMetrics', () => { expect(m.latencyMs).toBeDefined(); }); }); + +describe('isServiceMapTooLarge', () => { + it('gates only maps above the node limit', () => { + expect(isServiceMapTooLarge(MAX_SERVICE_MAP_NODES, false)).toBe(false); + expect(isServiceMapTooLarge(MAX_SERVICE_MAP_NODES + 1, false)).toBe(true); + }); + + it('is lifted by "Show anyway"', () => { + expect(isServiceMapTooLarge(MAX_SERVICE_MAP_NODES + 1, true)).toBe(false); + }); +}); diff --git a/packages/app/src/components/ServiceMap/utils.ts b/packages/app/src/components/ServiceMap/utils.ts index dca2201441..9f7c70fd20 100644 --- a/packages/app/src/components/ServiceMap/utils.ts +++ b/packages/app/src/components/ServiceMap/utils.ts @@ -307,6 +307,21 @@ export function formatRate(perSecond: number): string { return `${perSecond.toFixed(2)} req/s`; } +/** + * Largest map rendered without asking. Past this the dagre layout and React + * Flow render are what lock the tab, and a high-cardinality group-by (a URL, + * a trace id) can produce thousands of nodes from one keystroke. + */ +export const MAX_SERVICE_MAP_NODES = 100; + +/** Whether a map of `nodeCount` nodes is held back behind "Show anyway". */ +export function isServiceMapTooLarge( + nodeCount: number, + showAnyway: boolean, +): boolean { + return !showAnyway && nodeCount > MAX_SERVICE_MAP_NODES; +} + const MIN_NODE_SIZE = 32; const MAX_NODE_SIZE = 60; From 72963d2c37db32b5624cc6ac79294c958b15e8dd Mon Sep 17 00:00:00 2001 From: Javier Ortiz Date: Thu, 12 Nov 2026 22:25:33 +0100 Subject: [PATCH 6/6] test(app): cover the service map size gate's reset on new results The gate moves into useServiceMapSizeGate so its behaviour is testable with renderHook: an oversized map is held back, Show anyway releases that result set, and a new result set of the same size is held back again. --- .../src/components/ServiceMap/ServiceMap.tsx | 14 +---- .../__tests__/useServiceMapSizeGate.test.ts | 57 +++++++++++++++++++ .../ServiceMap/useServiceMapSizeGate.ts | 22 +++++++ 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/components/ServiceMap/__tests__/useServiceMapSizeGate.test.ts create mode 100644 packages/app/src/components/ServiceMap/useServiceMapSizeGate.ts diff --git a/packages/app/src/components/ServiceMap/ServiceMap.tsx b/packages/app/src/components/ServiceMap/ServiceMap.tsx index 9b3255aec6..c00ba1c416 100644 --- a/packages/app/src/components/ServiceMap/ServiceMap.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMap.tsx @@ -40,9 +40,9 @@ import { ServiceMapMetricMax, } from './ServiceMapMetricContext'; import ServiceMapNode, { ServiceMapNodeData } from './ServiceMapNode'; +import { useServiceMapSizeGate } from './useServiceMapSizeGate'; import { getServiceMetricValue, - isServiceMapTooLarge, MAX_SERVICE_MAP_NODES, SERVICE_MAP_METRIC_LABEL, SERVICE_MAP_METRICS, @@ -124,15 +124,7 @@ function ServiceMapPresentation({ const { fitView } = useReactFlow(); const colorScheme = useResolvedColorScheme(); const [metric, setMetric] = useState('errorRate'); - // The dataset the user chose to render despite its size. Keyed by identity - // so a new query result is gated again. - const [showAnywayFor, setShowAnywayFor] = useState< - Map | undefined - >(); - const tooLarge = isServiceMapTooLarge( - services?.size ?? 0, - showAnywayFor === services, - ); + const { tooLarge, showAnyway } = useServiceMapSizeGate(services); // Fit the data to the viewport whenever input service information changes useEffect(() => { @@ -290,7 +282,7 @@ function ServiceMapPresentation({ variant="secondary" size="sm" mt="sm" - onClick={() => setShowAnywayFor(services)} + onClick={showAnyway} data-testid="service-map-show-anyway" > Show anyway diff --git a/packages/app/src/components/ServiceMap/__tests__/useServiceMapSizeGate.test.ts b/packages/app/src/components/ServiceMap/__tests__/useServiceMapSizeGate.test.ts new file mode 100644 index 0000000000..45a35ca57e --- /dev/null +++ b/packages/app/src/components/ServiceMap/__tests__/useServiceMapSizeGate.test.ts @@ -0,0 +1,57 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useServiceMapSizeGate } from '@/components/ServiceMap/useServiceMapSizeGate'; +import { MAX_SERVICE_MAP_NODES } from '@/components/ServiceMap/utils'; +import type { ServiceAggregation } from '@/hooks/useServiceMap'; + +function servicesOfSize(size: number): Map { + const services = new Map(); + for (let i = 0; i < size; i++) { + services.set(`svc-${i}`, { + key: `svc-${i}`, + serviceName: `svc-${i}`, + dimensions: [], + incomingRequests: { + totalRequests: 1, + errorCount: 0, + errorPercentage: 0, + p50: 0, + p95: 0, + p99: 0, + hasLatency: false, + }, + incomingRequestsByClient: new Map(), + outgoingRequests: 0, + }); + } + return services; +} + +describe('useServiceMapSizeGate', () => { + it('does not gate a map at the limit', () => { + const { result } = renderHook(() => + useServiceMapSizeGate(servicesOfSize(MAX_SERVICE_MAP_NODES)), + ); + expect(result.current.tooLarge).toBe(false); + }); + + it('gates an oversized map until Show anyway, then gates the next result again', () => { + const first = servicesOfSize(MAX_SERVICE_MAP_NODES + 1); + const { result, rerender } = renderHook( + ({ services }) => useServiceMapSizeGate(services), + { initialProps: { services: first } }, + ); + expect(result.current.tooLarge).toBe(true); + + act(() => result.current.showAnyway()); + expect(result.current.tooLarge).toBe(false); + + // Same result set re-rendered: still shown. + rerender({ services: first }); + expect(result.current.tooLarge).toBe(false); + + // A new query result of the same size is held back again. + rerender({ services: servicesOfSize(MAX_SERVICE_MAP_NODES + 1) }); + expect(result.current.tooLarge).toBe(true); + }); +}); diff --git a/packages/app/src/components/ServiceMap/useServiceMapSizeGate.ts b/packages/app/src/components/ServiceMap/useServiceMapSizeGate.ts new file mode 100644 index 0000000000..b515e98768 --- /dev/null +++ b/packages/app/src/components/ServiceMap/useServiceMapSizeGate.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +import type { ServiceAggregation } from '@/hooks/useServiceMap'; + +import { isServiceMapTooLarge } from './utils'; + +type Services = Map | undefined; + +/** + * Holds an oversized map behind "Show anyway". The override is keyed by the + * result set's identity, so the next query result is gated again. + */ +export function useServiceMapSizeGate(services: Services) { + const [shownAnyway, setShownAnyway] = useState(); + return { + tooLarge: isServiceMapTooLarge( + services?.size ?? 0, + services !== undefined && shownAnyway === services, + ), + showAnyway: () => setShownAnyway(services), + }; +}