@skyhook-io/radar-app 1.16.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AggregatedFlow, TrafficFlow } from '../../types'
|
|
2
|
+
import type { TrafficEndpointPair, TrafficEndpointRef, TrafficMatch } from '../../api/traffic'
|
|
3
|
+
import type { TrafficGraphSelection } from './TrafficGraph'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Does a flow fall into one of the selected HTTP status ranges?
|
|
@@ -136,6 +138,19 @@ export function mergeFlowVolume(into: AggregatedFlow, flow: AggregatedFlow): voi
|
|
|
136
138
|
if (flow.errorCount) into.errorCount = (into.errorCount || 0) + flow.errorCount
|
|
137
139
|
if (flow.requestRate) into.requestRate = (into.requestRate || 0) + flow.requestRate
|
|
138
140
|
if (flow.errorRate) into.errorRate = (into.errorRate || 0) + flow.errorRate
|
|
141
|
+
// New objects rather than in-place sums: a merged edge starts as a shallow
|
|
142
|
+
// copy of the server's, so its maps are still the server's.
|
|
143
|
+
into.httpStatusCounts = sumCounts(into.httpStatusCounts, flow.httpStatusCounts)
|
|
144
|
+
into.verdictCounts = sumCounts(into.verdictCounts, flow.verdictCounts)
|
|
145
|
+
into.dropReasons = sumCounts(into.dropReasons, flow.dropReasons)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sumCounts(a: Record<string, number> | undefined, b: Record<string, number> | undefined): Record<string, number> | undefined {
|
|
149
|
+
if (!b) return a
|
|
150
|
+
if (!a) return { ...b }
|
|
151
|
+
const sum = { ...a }
|
|
152
|
+
for (const [k, v] of Object.entries(b)) sum[k] = (sum[k] ?? 0) + v
|
|
153
|
+
return sum
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
/**
|
|
@@ -287,3 +302,396 @@ export function dedupeHTTPPairs(flows: TrafficFlow[], callerOriented: boolean):
|
|
|
287
302
|
}
|
|
288
303
|
return flows.filter(f => !(isHTTP(f, 'REQUEST') && answered.has(call(f.source.name, f.destination.name, f))))
|
|
289
304
|
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A graph edge, carrying the server edges merged into it. The graph renames and
|
|
308
|
+
* merges endpoints (external services, the Internet node, the addon group), so
|
|
309
|
+
* these are what a selection is traced back through to ask the server for its
|
|
310
|
+
* records.
|
|
311
|
+
*/
|
|
312
|
+
export type GraphFlow = AggregatedFlow & { rawPairs?: TrafficEndpointPair[] }
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The port an edge merged from several shows: its lowest real one, whatever
|
|
316
|
+
* order the parts arrived in (0 means none was reported, so it shows only
|
|
317
|
+
* when no part has a port). The Internet node groups by it and selections
|
|
318
|
+
* find the edge by it, so it must not change from one refresh to the next.
|
|
319
|
+
*/
|
|
320
|
+
export function mergedEdgePort(a: number, b: number): number {
|
|
321
|
+
if (a === 0) return b
|
|
322
|
+
if (b === 0) return a
|
|
323
|
+
return Math.min(a, b)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether an edge carries a port: its own, or one of the edges merged into it.
|
|
328
|
+
* A merged edge shows one of its ports, so a selection made on another must
|
|
329
|
+
* still find it.
|
|
330
|
+
*/
|
|
331
|
+
export function edgeCarriesPort(flow: GraphFlow, port: number | undefined): boolean {
|
|
332
|
+
return port === undefined || flow.port === port || !!flow.rawPairs?.some(p => p.port === port)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Of the edges between a selection's endpoints, the ones it means: those on
|
|
337
|
+
* its exact port when any are, else those with the port merged in. Two edges
|
|
338
|
+
* can join the same endpoints (the Internet node keeps ports apart), and the
|
|
339
|
+
* one shown on a port must win over one that merely carries it.
|
|
340
|
+
*/
|
|
341
|
+
export function edgesOnPort<T>(candidates: T[], flowOf: (c: T) => GraphFlow | undefined, port: number | undefined): T[] {
|
|
342
|
+
if (port === undefined) return candidates
|
|
343
|
+
const exact = candidates.filter(c => flowOf(c)?.port === port)
|
|
344
|
+
if (exact.length > 0) return exact
|
|
345
|
+
return candidates.filter(c => {
|
|
346
|
+
const flow = flowOf(c)
|
|
347
|
+
return !!flow && edgeCarriesPort(flow, port)
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function endpointPair(flow: AggregatedFlow): TrafficEndpointPair {
|
|
352
|
+
const ref = (e: AggregatedFlow['source']) => ({
|
|
353
|
+
namespace: e.namespace || undefined,
|
|
354
|
+
name: e.name,
|
|
355
|
+
kind: e.kind,
|
|
356
|
+
...(e.workloadKind && { workloadKind: e.workloadKind }),
|
|
357
|
+
})
|
|
358
|
+
return {
|
|
359
|
+
source: ref(flow.source),
|
|
360
|
+
destination: ref(flow.destination),
|
|
361
|
+
port: flow.port,
|
|
362
|
+
...(flow.directionUnknown && { directionUnknown: true }),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Adds a merged edge's pairs to the edge it was merged into. The target owns
|
|
367
|
+
* its array, so merging appends in place: copying it on every merge is
|
|
368
|
+
* quadratic, and an ingress with tens of thousands of distinct clients
|
|
369
|
+
* collapses into a single Internet edge. */
|
|
370
|
+
export function mergeRawPairs(into: GraphFlow, from: GraphFlow): void {
|
|
371
|
+
const target = into.rawPairs ?? (into.rawPairs = [])
|
|
372
|
+
for (const pair of from.rawPairs ?? []) target.push(pair)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The renderer draws the addon group's virtual endpoints as one group node.
|
|
376
|
+
function selectableId(e: { namespace?: string; name: string; kind?: string }): string {
|
|
377
|
+
if (e.kind === 'AddonGroupTarget' || e.kind === 'AddonGroupSource') return 'addon-group'
|
|
378
|
+
return graphEndpointId(e)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A record's endpoint as the graph names it: a pod with a known workload is
|
|
383
|
+
* drawn as that workload. Mirrors the server's graphEndpoint (pkg/traffic),
|
|
384
|
+
* which builds the aggregation the graph is drawn from — the two have to agree
|
|
385
|
+
* for a record to be found under its node.
|
|
386
|
+
*/
|
|
387
|
+
export function graphEndpoint(e: TrafficFlow['source']): { namespace?: string; name: string } {
|
|
388
|
+
if (e.kind === 'Pod' && e.workload && e.namespace && e.workload !== e.name) return { namespace: e.namespace, name: e.workload }
|
|
389
|
+
return e
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function graphEndpointId(e: { namespace?: string; name: string }): string {
|
|
393
|
+
return e.namespace ? `${e.namespace}/${e.name}` : e.name
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function pairKey(source: { namespace?: string; name: string }, destination: { namespace?: string; name: string }, port: number, directionUnknown?: boolean): string {
|
|
397
|
+
return `${graphEndpointId(source)}->${graphEndpointId(destination)}:${port}${directionUnknown ? ':u' : ''}`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether a record belongs to the selected edges, in the grouping the graph
|
|
402
|
+
* draws (`drawn`). Without `responsesCallerOriented` the server sends an HTTP
|
|
403
|
+
* response as it was observed, server to client on the client's port, so a
|
|
404
|
+
* response is then also matched to an edge running the other way. Its port says nothing about which of the
|
|
405
|
+
* caller's edges it answers, so it is matched to each of them; other traffic
|
|
406
|
+
* between the same endpoints is not, being on edges of its own.
|
|
407
|
+
*/
|
|
408
|
+
export function selectedEdgeMatcher(
|
|
409
|
+
pairs: TrafficEndpointPair[] | null,
|
|
410
|
+
responsesCallerOriented: boolean,
|
|
411
|
+
drawn: (e: TrafficFlow['source']) => { namespace?: string; name: string },
|
|
412
|
+
): (f: TrafficFlow) => boolean {
|
|
413
|
+
if (!pairs) return () => true
|
|
414
|
+
const keys = new Set(pairs.map(p => pairKey(p.source, p.destination, p.port, p.directionUnknown)))
|
|
415
|
+
const callers = responsesCallerOriented ? null : new Set(pairs.map(p => `${graphEndpointId(p.source)}->${graphEndpointId(p.destination)}`))
|
|
416
|
+
return f => {
|
|
417
|
+
const src = drawn(f.source)
|
|
418
|
+
const dst = drawn(f.destination)
|
|
419
|
+
if (keys.has(pairKey(src, dst, f.port, f.directionUnknown))) return true
|
|
420
|
+
// The response travels callee to caller: reversed, it is the edge's.
|
|
421
|
+
return !!callers && f.l7Protocol === 'HTTP' && f.l7Type === 'RESPONSE' &&
|
|
422
|
+
callers.has(`${graphEndpointId(dst)}->${graphEndpointId(src)}`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The server edges behind the selected node or edge, deduplicated and in a
|
|
428
|
+
* stable order — the server returns its aggregation in no particular order,
|
|
429
|
+
* and the result keys the records query. Null when the selection traces back
|
|
430
|
+
* to none. `inAddonGroup` says which endpoints the addon group node holds
|
|
431
|
+
* while addons are grouped, so selecting the group selects their traffic too.
|
|
432
|
+
*/
|
|
433
|
+
export function selectionRawPairs(
|
|
434
|
+
flows: GraphFlow[],
|
|
435
|
+
selection: TrafficGraphSelection | null,
|
|
436
|
+
inAddonGroup?: (e: { namespace?: string; name: string }) => boolean,
|
|
437
|
+
): TrafficEndpointPair[] | null {
|
|
438
|
+
if (!selection) return null
|
|
439
|
+
const pairs = new Map<string, TrafficEndpointPair>()
|
|
440
|
+
const groupId = (e: GraphFlow['source']) =>
|
|
441
|
+
inAddonGroup && selection.nodeId === 'addon-group' && inAddonGroup(e) ? 'addon-group' : selectableId(e)
|
|
442
|
+
const touching = flows.filter(flow => {
|
|
443
|
+
const sourceId = groupId(flow.source)
|
|
444
|
+
const destId = groupId(flow.destination)
|
|
445
|
+
return selection.type === 'node'
|
|
446
|
+
? sourceId === selection.nodeId || destId === selection.nodeId
|
|
447
|
+
: sourceId === selection.sourceId && destId === selection.destId &&
|
|
448
|
+
!!flow.directionUnknown === !!selection.directionUnknown
|
|
449
|
+
})
|
|
450
|
+
// An edge stands for its port; an edge merged from several ports shows one
|
|
451
|
+
// of them and carries the rest (edgesOnPort).
|
|
452
|
+
const selected = selection.type === 'node' ? touching : edgesOnPort(touching, f => f, selection.port)
|
|
453
|
+
for (const flow of selected) {
|
|
454
|
+
for (const pair of flow.rawPairs ?? []) {
|
|
455
|
+
pairs.set(pairKey(pair.source, pair.destination, pair.port, pair.directionUnknown), pair)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (pairs.size === 0) return null
|
|
459
|
+
return Array.from(pairs.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, pair]) => pair)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* What to ask the server for, for the records behind a selection traced to
|
|
464
|
+
* `pairs` (selectionRawPairs). A node that is one server endpoint is sent as
|
|
465
|
+
* that endpoint: a busy node has too many edges to name each within a URL.
|
|
466
|
+
* The answer then holds its hidden edges too, so the caller keeps the pairs
|
|
467
|
+
* to narrow it. Anything else — an edge, a node merged from several
|
|
468
|
+
* endpoints — is sent as its edges.
|
|
469
|
+
*/
|
|
470
|
+
export function selectionMatch(
|
|
471
|
+
pairs: TrafficEndpointPair[] | null,
|
|
472
|
+
selection: TrafficGraphSelection | null,
|
|
473
|
+
): TrafficMatch | null {
|
|
474
|
+
if (!pairs) return null
|
|
475
|
+
if (selection?.type !== 'node') return { pairs }
|
|
476
|
+
const refKey = (r: TrafficEndpointRef) => `${graphEndpointId(r)}|${r.kind ?? ''}`
|
|
477
|
+
// Only pods can be named to the source; any other endpoint sent alone would
|
|
478
|
+
// fetch everything, while its edges still name the pods at their far ends.
|
|
479
|
+
const nameable = (r: TrafficEndpointRef) => r.kind === 'Pod' || r.kind === 'Workload'
|
|
480
|
+
let only: TrafficEndpointRef | undefined
|
|
481
|
+
for (const p of pairs) {
|
|
482
|
+
const side = graphEndpointId(p.source) === selection.nodeId ? p.source
|
|
483
|
+
: graphEndpointId(p.destination) === selection.nodeId ? p.destination
|
|
484
|
+
: undefined
|
|
485
|
+
if (!side || !nameable(side) || (only && refKey(side) !== refKey(only))) return { pairs }
|
|
486
|
+
only = side
|
|
487
|
+
}
|
|
488
|
+
return only ? { endpoints: [only] } : { pairs }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* How costly the graph is to draw. The layout runs on the main thread and its
|
|
493
|
+
* cost grows faster than linearly, edges more than nodes, so edges count
|
|
494
|
+
* double, as Weave Scope weighs them. Laying out whole namespaces of a dense
|
|
495
|
+
* service graph took 0.55s at a score of 493, 2s at 728, 4.5s at 1,433, 13s
|
|
496
|
+
* at 1,883 and 32s at 2,329; larger graphs overflowed ELK's stack.
|
|
497
|
+
*/
|
|
498
|
+
export function graphSize(flows: AggregatedFlow[]): { nodes: number; edges: number; score: number } {
|
|
499
|
+
const nodes = new Set<string>()
|
|
500
|
+
for (const flow of flows) {
|
|
501
|
+
nodes.add(graphEndpointId(flow.source))
|
|
502
|
+
nodes.add(graphEndpointId(flow.destination))
|
|
503
|
+
}
|
|
504
|
+
return { nodes: nodes.size, edges: flows.length, score: nodes.size + 2 * flows.length }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Above this the graph is not drawn until the view is narrowed (about 1.5s of layout). */
|
|
508
|
+
export const GRAPH_DRAW_BUDGET = 600
|
|
509
|
+
/** Above this it is not drawn at all (about 5s of layout, freezing the tab). */
|
|
510
|
+
export const GRAPH_DRAW_CEILING = 1500
|
|
511
|
+
|
|
512
|
+
/** 5xx responses on an edge: counted where the source observes responses,
|
|
513
|
+
* a per-second rate where it measures one. */
|
|
514
|
+
export function flowErrors(flow: AggregatedFlow): number {
|
|
515
|
+
return flow.httpStatusCounts?.['5xx'] || errorRateOf(flow)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function flowDrops(flow: AggregatedFlow): number {
|
|
519
|
+
return flow.verdictCounts?.dropped ?? 0
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Whether this edge carries HTTP responses, so its 5xx count is a measured
|
|
523
|
+
* zero rather than unknown; plain TCP has none. Zero rates and counts are left
|
|
524
|
+
* out of the response, so a healthy HTTP edge shows only by its requests or
|
|
525
|
+
* protocol. */
|
|
526
|
+
export function hasStatusData(flow: AggregatedFlow): boolean {
|
|
527
|
+
return (!!flow.httpStatusCounts && Object.keys(flow.httpStatusCounts).length > 0) ||
|
|
528
|
+
!!flow.requestRate || !!flow.requestCount || !!flow.errorRate || !!flow.errorCount ||
|
|
529
|
+
flow.l7Protocol === 'HTTP' || flow.l7Protocol === 'gRPC'
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* A workload (or any endpoint) the view is narrowed to: it and everything it
|
|
534
|
+
* talks to. Identified by namespace and name, the way the graph names nodes,
|
|
535
|
+
* so it means the same thing whether pods are grouped or not — ungrouped, it
|
|
536
|
+
* is the pods the workload owns.
|
|
537
|
+
*/
|
|
538
|
+
export interface TrafficFocus {
|
|
539
|
+
namespace?: string
|
|
540
|
+
name: string
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The focus as the URL carries it: "ns/name", or "/name" for an endpoint
|
|
544
|
+
* with no namespace, whose name can itself hold a slash (a CIDR). */
|
|
545
|
+
export function focusParam(focus: TrafficFocus): string {
|
|
546
|
+
return `${focus.namespace ?? ''}/${focus.name}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function parseFocus(value: string | null): TrafficFocus | null {
|
|
550
|
+
if (!value) return null
|
|
551
|
+
const slash = value.indexOf('/')
|
|
552
|
+
if (slash < 0) return null
|
|
553
|
+
const namespace = value.slice(0, slash)
|
|
554
|
+
const name = value.slice(slash + 1)
|
|
555
|
+
if (!name) return null
|
|
556
|
+
return namespace ? { namespace, name } : { name }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The graph's id for the focused node. */
|
|
560
|
+
export function focusId(focus: TrafficFocus): string {
|
|
561
|
+
return graphEndpointId(focus)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function touchesFocus(e: AggregatedFlow['source'], focus: TrafficFocus): boolean {
|
|
565
|
+
if ((e.namespace || undefined) !== focus.namespace) return false
|
|
566
|
+
return e.name === focus.name || (e.kind === 'Pod' && e.workload === focus.name)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The edges with the focus at either end. */
|
|
570
|
+
export function focusNeighborhood<T extends AggregatedFlow>(flows: T[], focus: TrafficFocus): T[] {
|
|
571
|
+
return flows.filter(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export interface EndpointSummary {
|
|
575
|
+
id: string
|
|
576
|
+
name: string
|
|
577
|
+
namespace?: string
|
|
578
|
+
kind: string
|
|
579
|
+
workloadKind?: string
|
|
580
|
+
volume: number
|
|
581
|
+
errors: number
|
|
582
|
+
drops: number
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Every endpoint drawn from these flows, with the traffic on its edges. The
|
|
586
|
+
* options a focus can be chosen from, so each one has something to show.
|
|
587
|
+
* Pods drawn ungrouped also offer their workload, which a focus covers whole. */
|
|
588
|
+
export function endpointSummaries(flows: AggregatedFlow[]): EndpointSummary[] {
|
|
589
|
+
const byId = new Map<string, EndpointSummary>()
|
|
590
|
+
const add = (e: { namespace?: string; name: string; kind: string; workloadKind?: string }, flow: AggregatedFlow, seen: Set<string>) => {
|
|
591
|
+
const id = graphEndpointId(e)
|
|
592
|
+
if (seen.has(id)) return
|
|
593
|
+
seen.add(id)
|
|
594
|
+
let s = byId.get(id)
|
|
595
|
+
if (!s) {
|
|
596
|
+
s = { id, name: e.name, namespace: e.namespace || undefined, kind: e.kind, workloadKind: e.workloadKind, volume: 0, errors: 0, drops: 0 }
|
|
597
|
+
byId.set(id, s)
|
|
598
|
+
}
|
|
599
|
+
s.volume += flow.connections
|
|
600
|
+
s.errors += flowErrors(flow)
|
|
601
|
+
s.drops += flowDrops(flow)
|
|
602
|
+
}
|
|
603
|
+
for (const flow of flows) {
|
|
604
|
+
const seen = new Set<string>()
|
|
605
|
+
for (const e of [flow.source, flow.destination]) {
|
|
606
|
+
add(e, flow, seen)
|
|
607
|
+
if (e.kind === 'Pod' && e.namespace && e.workload && e.workload !== e.name) {
|
|
608
|
+
add({ namespace: e.namespace, name: e.workload, kind: 'Workload', workloadKind: e.workloadKind }, flow, seen)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return Array.from(byId.values())
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Where the problems are first, then the busiest. */
|
|
616
|
+
export function byProblemsThenVolume(a: { errors: number; drops: number; volume: number }, b: { errors: number; drops: number; volume: number }): number {
|
|
617
|
+
const pa = a.errors > 0 || a.drops > 0 ? 1 : 0
|
|
618
|
+
const pb = b.errors > 0 || b.drops > 0 ? 1 : 0
|
|
619
|
+
if (pa !== pb) return pb - pa
|
|
620
|
+
if (a.drops + a.errors !== b.drops + b.errors) return b.drops + b.errors - (a.drops + a.errors)
|
|
621
|
+
return b.volume - a.volume
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Search results for a query: a name that starts with the query before one
|
|
626
|
+
* that only contains it, and within each, problems first, then volume.
|
|
627
|
+
*/
|
|
628
|
+
export function searchEndpoints(summaries: EndpointSummary[], query: string, limit = 50): EndpointSummary[] {
|
|
629
|
+
const q = query.trim().toLowerCase()
|
|
630
|
+
const rank = (s: EndpointSummary) => {
|
|
631
|
+
if (!q) return 1
|
|
632
|
+
const name = s.name.toLowerCase()
|
|
633
|
+
if (name === q) return 0
|
|
634
|
+
if (name.startsWith(q)) return 1
|
|
635
|
+
if (name.includes(q)) return 2
|
|
636
|
+
if (s.id.toLowerCase().includes(q)) return 3
|
|
637
|
+
return -1
|
|
638
|
+
}
|
|
639
|
+
return summaries
|
|
640
|
+
.map(s => ({ s, r: rank(s) }))
|
|
641
|
+
.filter(x => x.r >= 0)
|
|
642
|
+
.sort((a, b) => a.r - b.r || byProblemsThenVolume(a.s, b.s))
|
|
643
|
+
.slice(0, limit)
|
|
644
|
+
.map(x => x.s)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export interface NamespaceSummary {
|
|
648
|
+
name: string
|
|
649
|
+
endpoints: number
|
|
650
|
+
connections: number
|
|
651
|
+
volume: number
|
|
652
|
+
errors: number
|
|
653
|
+
drops: number
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Per namespace, what narrowing to it would show. Narrowing keeps an edge
|
|
658
|
+
* when either end is in the namespace, as the server does, so a cross-namespace
|
|
659
|
+
* edge counts toward both of its namespaces, and its far end toward the
|
|
660
|
+
* namespace's endpoints.
|
|
661
|
+
*/
|
|
662
|
+
/** Whether the namespace's own view — its edges, with either end in it — would
|
|
663
|
+
* be over the draw budget, judged from the traffic already fetched. Picking it
|
|
664
|
+
* fetches that namespace on its own, which can bring in more. */
|
|
665
|
+
export function namespaceOverBudget(ns: NamespaceSummary): boolean {
|
|
666
|
+
return ns.endpoints + 2 * ns.connections > GRAPH_DRAW_BUDGET
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function namespaceSummaries(flows: AggregatedFlow[]): NamespaceSummary[] {
|
|
670
|
+
const byNs = new Map<string, { endpoints: Set<string>; connections: number; volume: number; errors: number; drops: number }>()
|
|
671
|
+
for (const flow of flows) {
|
|
672
|
+
const namespaces = new Set([flow.source.namespace, flow.destination.namespace].filter(Boolean))
|
|
673
|
+
for (const ns of namespaces) {
|
|
674
|
+
let s = byNs.get(ns)
|
|
675
|
+
if (!s) {
|
|
676
|
+
s = { endpoints: new Set(), connections: 0, volume: 0, errors: 0, drops: 0 }
|
|
677
|
+
byNs.set(ns, s)
|
|
678
|
+
}
|
|
679
|
+
s.endpoints.add(graphEndpointId(flow.source))
|
|
680
|
+
s.endpoints.add(graphEndpointId(flow.destination))
|
|
681
|
+
s.connections += 1
|
|
682
|
+
s.volume += flow.connections
|
|
683
|
+
s.errors += flowErrors(flow)
|
|
684
|
+
s.drops += flowDrops(flow)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return Array.from(byNs.entries())
|
|
688
|
+
.map(([name, s]) => ({ name, endpoints: s.endpoints.size, connections: s.connections, volume: s.volume, errors: s.errors, drops: s.drops }))
|
|
689
|
+
.sort((a, b) => byProblemsThenVolume(a, b) || a.name.localeCompare(b.name))
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The edges as table rows, problems first, then volume. */
|
|
693
|
+
export function connectionRows<T extends AggregatedFlow>(flows: T[]): T[] {
|
|
694
|
+
const keyed = flows.map(flow => ({ flow, errors: flowErrors(flow), drops: flowDrops(flow), volume: flow.connections }))
|
|
695
|
+
keyed.sort(byProblemsThenVolume)
|
|
696
|
+
return keyed.map(k => k.flow)
|
|
697
|
+
}
|
|
@@ -1,50 +1,125 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { Megaphone } from 'lucide-react'
|
|
3
|
-
import { nextSeenVersion,
|
|
4
|
-
import {
|
|
3
|
+
import { nextSeenVersion, whatsNewToOpen } from './WhatsNew'
|
|
4
|
+
import {
|
|
5
|
+
AUTO_OPEN_SCORE,
|
|
6
|
+
composeReleaseNotes,
|
|
7
|
+
latestReleaseNotesFor,
|
|
8
|
+
MAX_CARDS,
|
|
9
|
+
MAX_LINES,
|
|
10
|
+
releaseLine,
|
|
11
|
+
RELEASE_NOTES,
|
|
12
|
+
releaseNotesFor,
|
|
13
|
+
unreadReleases,
|
|
14
|
+
type ReleaseNotes,
|
|
15
|
+
} from './releaseNotes'
|
|
5
16
|
import { compareVersions } from '../../utils/version'
|
|
6
17
|
|
|
7
|
-
const
|
|
18
|
+
const highlight = (id: string, importance: number) => ({ id, icon: Megaphone, title: id, description: 'd', importance })
|
|
19
|
+
|
|
20
|
+
const entry = (version: string, importances: number[] = [9, 9, 9], improvements: string[] = []): ReleaseNotes => ({
|
|
8
21
|
version,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
improvements: [],
|
|
22
|
+
highlights: importances.map((importance, i) => highlight(`${version}-${i}`, importance)),
|
|
23
|
+
improvements,
|
|
12
24
|
})
|
|
13
25
|
|
|
14
26
|
const catalog = [entry('v1.15.0'), entry('v2.0.0')]
|
|
15
27
|
|
|
16
|
-
const
|
|
17
|
-
|
|
28
|
+
const unread = (current: string, lastSeen: string | null, prior = true, list = catalog) =>
|
|
29
|
+
unreadReleases(current, lastSeen, prior, list).map(n => n.version)
|
|
18
30
|
|
|
19
|
-
describe('
|
|
20
|
-
it('
|
|
21
|
-
expect(
|
|
22
|
-
expect(
|
|
23
|
-
expect(
|
|
31
|
+
describe('unreadReleases', () => {
|
|
32
|
+
it('lists the releases after the last one seen, newest first', () => {
|
|
33
|
+
expect(unread('v2.0.0', 'v1.14.0')).toEqual(['v2.0.0', 'v1.15.0'])
|
|
34
|
+
expect(unread('v2.0.0', 'v1.15.0')).toEqual(['v2.0.0'])
|
|
35
|
+
expect(unread('v2.0.0', 'v2.0.0')).toEqual([])
|
|
36
|
+
expect(unread('2.0.0', 'v2.0.0')).toEqual([])
|
|
24
37
|
})
|
|
25
38
|
|
|
26
|
-
it('
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
39
|
+
it('covers a patch of a release with notes, and nothing above the running version', () => {
|
|
40
|
+
expect(unread('v1.15.1', 'v1.14.1')).toEqual(['v1.15.0'])
|
|
41
|
+
expect(unread('v2.3.4', 'v1.15.2')).toEqual(['v2.0.0'])
|
|
29
42
|
})
|
|
30
43
|
|
|
31
|
-
it('
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
44
|
+
it('is empty once a later release was seen, including after a downgrade', () => {
|
|
45
|
+
expect(unread('v1.15.2', 'v1.15.1')).toEqual([])
|
|
46
|
+
expect(unread('v1.15.0', 'v2.0.0')).toEqual([])
|
|
34
47
|
})
|
|
35
48
|
|
|
36
|
-
it('
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
49
|
+
it('is empty on a fresh install, and covers everything for installs that predate the seen record', () => {
|
|
50
|
+
expect(unread('v2.0.0', null, false)).toEqual([])
|
|
51
|
+
expect(unread('v2.0.0', null, true)).toEqual(['v2.0.0', 'v1.15.0'])
|
|
39
52
|
})
|
|
40
53
|
|
|
41
54
|
it('treats a record that is not a version as an install with nothing seen', () => {
|
|
42
|
-
expect(
|
|
55
|
+
expect(unread('v2.0.0', 'vdev', false)).toEqual(['v2.0.0', 'v1.15.0'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('is empty when no notes exist at or below the running version', () => {
|
|
59
|
+
expect(unread('v1.14.9', 'v1.14.0')).toEqual([])
|
|
60
|
+
expect(unread('dev', 'v1.14.1')).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('orders by version whatever the catalog order', () => {
|
|
64
|
+
expect(unread('v2.0.0', null, true, [entry('v1.15.0'), entry('v2.0.0'), entry('v1.16.0')])).toEqual(['v2.0.0', 'v1.16.0', 'v1.15.0'])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('composeReleaseNotes', () => {
|
|
69
|
+
it('ranks highlights by importance, weighting older releases down', () => {
|
|
70
|
+
const notes = composeReleaseNotes([entry('v1.17.0', [6]), entry('v1.16.0', [9]), entry('v1.15.0', [10])])
|
|
71
|
+
expect(notes.highlights.map(h => [h.id, h.score])).toEqual([
|
|
72
|
+
['v1.16.0-0', 6.75],
|
|
73
|
+
['v1.17.0-0', 6],
|
|
74
|
+
['v1.15.0-0', 5],
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('breaks ties toward the newer release, then the authored order', () => {
|
|
79
|
+
const notes = composeReleaseNotes([entry('v1.16.0', [3, 6]), entry('v1.15.0', [8])])
|
|
80
|
+
expect(notes.highlights.map(h => h.id)).toEqual(['v1.16.0-1', 'v1.15.0-0', 'v1.16.0-0'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps the authored order of a single release with equal importance', () => {
|
|
84
|
+
expect(composeReleaseNotes([entry('v1.16.0', [5, 5, 5])]).highlights.map(h => h.id)).toEqual(['v1.16.0-0', 'v1.16.0-1', 'v1.16.0-2'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('shows at most three releases and says when older ones were left out', () => {
|
|
88
|
+
const four = [entry('v1.18.0'), entry('v1.17.0'), entry('v1.16.0'), entry('v1.15.0')]
|
|
89
|
+
const notes = composeReleaseNotes(four)
|
|
90
|
+
expect(notes.versions).toEqual(['v1.18.0', 'v1.17.0', 'v1.16.0'])
|
|
91
|
+
expect(notes.complete).toBe(false)
|
|
92
|
+
expect(notes.highlights.some(h => h.version === 'v1.15.0')).toBe(false)
|
|
93
|
+
expect(composeReleaseNotes(four.slice(0, 3)).complete).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('turns highlights past the cards into lines ahead of the improvements, within the line cap', () => {
|
|
97
|
+
const notes = composeReleaseNotes([
|
|
98
|
+
entry('v1.16.0', [7, 7, 7, 7, 7, 2], ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']),
|
|
99
|
+
entry('v1.15.0', [1], ['old']),
|
|
100
|
+
])
|
|
101
|
+
expect(notes.highlights).toHaveLength(MAX_CARDS)
|
|
102
|
+
expect(notes.lines).toHaveLength(MAX_LINES)
|
|
103
|
+
expect(notes.lines.slice(0, 2).map(l => l.text)).toEqual(['v1.16.0-5', 'v1.15.0-0'])
|
|
104
|
+
expect(notes.lines.map(l => l.text)).not.toContain('old')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('scores the dialog by the cards it shows', () => {
|
|
108
|
+
expect(composeReleaseNotes([entry('v1.16.0', [4, 4, 4, 4, 4, 10])]).score).toBe(10 + 4 * 4)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('whatsNewToOpen', () => {
|
|
113
|
+
it('opens when the unseen releases show enough, and not for a quiet release', () => {
|
|
114
|
+
const list = [entry('v1.15.0', [9, 8, 7, 7, 7]), entry('v1.16.0', [6, 5, 4])]
|
|
115
|
+
expect(whatsNewToOpen('v1.15.0', 'v1.14.1', true, list)?.score).toBe(38)
|
|
116
|
+
expect(whatsNewToOpen('v1.16.0', 'v1.15.0', true, list)).toBeNull()
|
|
117
|
+
expect(whatsNewToOpen('v1.16.0', 'v1.14.1', true, list)?.score).toBeGreaterThanOrEqual(AUTO_OPEN_SCORE)
|
|
43
118
|
})
|
|
44
119
|
|
|
45
|
-
it('stays closed when
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
120
|
+
it('stays closed when nothing is unseen', () => {
|
|
121
|
+
expect(whatsNewToOpen('v2.0.0', 'v2.0.0', true, catalog)).toBeNull()
|
|
122
|
+
expect(whatsNewToOpen('v2.0.0', null, false, catalog)).toBeNull()
|
|
48
123
|
})
|
|
49
124
|
})
|
|
50
125
|
|
|
@@ -91,13 +166,17 @@ describe('the shipped catalog', () => {
|
|
|
91
166
|
expect(new Set(versions).size).toBe(versions.length)
|
|
92
167
|
for (const notes of RELEASE_NOTES) {
|
|
93
168
|
expect(notes.version, notes.version).toMatch(/^v\d+\.\d+\.\d+$/)
|
|
94
|
-
expect(notes.highlights.length, `${notes.version} needs a
|
|
169
|
+
expect(notes.highlights.length, `${notes.version} needs a highlight`).toBeGreaterThan(0)
|
|
95
170
|
expect(new Set(notes.highlights.map(h => h.id)).size, `${notes.version} highlight ids`).toBe(notes.highlights.length)
|
|
171
|
+
expect(new Set(notes.improvements).size, `${notes.version} improvements`).toBe(notes.improvements.length)
|
|
172
|
+
const top = notes.highlights.reduce((best, h) => (h.importance > best.importance ? h : best))
|
|
96
173
|
for (const h of notes.highlights) {
|
|
174
|
+
expect(h.description.length, `${notes.version}/${h.id}: description fits a card`).toBeLessThanOrEqual(150)
|
|
175
|
+
if (h.leadDescription) expect(h.id, `${notes.version}/${h.id}: only the release's top highlight can lead`).toBe(top.id)
|
|
176
|
+
expect(Number.isInteger(h.importance) && h.importance >= 1 && h.importance <= 10, `${notes.version}/${h.id}: importance is 1-10`).toBe(true)
|
|
97
177
|
expect(!!h.path === !!h.cta, `${notes.version}/${h.id}: path and cta go together`).toBe(true)
|
|
98
178
|
if (h.path) expect(h.path, `${notes.version}/${h.id}`).toMatch(/^\//)
|
|
99
179
|
}
|
|
100
|
-
expect(notes.releaseUrl, notes.version).toMatch(/^https:\/\//)
|
|
101
180
|
}
|
|
102
181
|
})
|
|
103
182
|
})
|