@skyhook-io/k8s-ui 1.8.11 → 1.8.13

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.
Files changed (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -0,0 +1,257 @@
1
+ import type { ResourceRef, TimelineEvent } from '../types/core'
2
+ import { getKindLabel } from './api-resources'
3
+ import type { AppHistory, AppHistoryAnchor, AppSourceRef } from './applications'
4
+ import { isProblematicEvent } from './resource-hierarchy'
5
+
6
+ export type ApplicationHistoryCategory = 'deployment' | 'change' | 'problem'
7
+ export type ApplicationHistoryRange = '24h' | '7d' | '30d' | 'all'
8
+
9
+ export interface ApplicationHistoryItem {
10
+ id: string
11
+ category: ApplicationHistoryCategory
12
+ title: string
13
+ timestamp: string
14
+ detail?: string
15
+ status?: string
16
+ revision?: string
17
+ initiatedBy?: string
18
+ count?: number
19
+ resource?: ResourceRef
20
+ sourceRef?: AppSourceRef
21
+ }
22
+
23
+ const ROOT_RUNTIME_KINDS = new Set([
24
+ 'Deployment',
25
+ 'DaemonSet',
26
+ 'StatefulSet',
27
+ 'Rollout',
28
+ 'Job',
29
+ 'CronJob',
30
+ 'Workflow',
31
+ 'CronWorkflow',
32
+ ])
33
+
34
+ const RELATED_RUNTIME_KINDS = new Set([
35
+ 'Service',
36
+ 'Ingress',
37
+ 'Gateway',
38
+ 'HTTPRoute',
39
+ 'GRPCRoute',
40
+ 'TCPRoute',
41
+ 'TLSRoute',
42
+ 'ConfigMap',
43
+ 'Secret',
44
+ 'SealedSecret',
45
+ 'HorizontalPodAutoscaler',
46
+ 'ScaledObject',
47
+ 'ScaledJob',
48
+ 'PersistentVolumeClaim',
49
+ 'PodDisruptionBudget',
50
+ 'NetworkPolicy',
51
+ 'ServiceAccount',
52
+ 'ServiceMonitor',
53
+ 'PodMonitor',
54
+ ])
55
+
56
+ const NORMAL_BATCH_LIFECYCLE_REASONS = new Set([
57
+ 'started',
58
+ 'created',
59
+ 'completed',
60
+ 'complete',
61
+ 'successcriteriamet',
62
+ ])
63
+
64
+ const CONFIG_DATA_KINDS = new Set(['ConfigMap', 'Secret', 'SealedSecret'])
65
+ const CONFIG_DATA_PATH = /^(?:spec\.)?(?:data|stringData|binaryData|encryptedData)(\W|$)/
66
+ const EPHEMERAL_RUN_KINDS = new Set(['Job', 'Workflow'])
67
+ const SOURCE_BURST_WINDOW_MS = 2 * 60 * 1000
68
+
69
+ const PROBLEM_TITLES: Record<string, string> = {
70
+ failedscheduling: "Can't be scheduled",
71
+ failedmount: "Volume can't be mounted",
72
+ failedattachvolume: "Volume can't be attached",
73
+ failedpull: 'Image pull failed',
74
+ errimagepull: 'Image pull failed',
75
+ imagepullbackoff: 'Image pull back-off',
76
+ unhealthy: 'Health check failed',
77
+ }
78
+
79
+ function validTimestamp(value: string | undefined): value is string {
80
+ return Boolean(value && !value.startsWith('0001-01-01T00:00:00') && Number.isFinite(new Date(value).getTime()))
81
+ }
82
+
83
+ function sourceMatchesEvent(source: AppSourceRef | undefined, event: TimelineEvent): boolean {
84
+ if (!source) return false
85
+ return source.kind.toLowerCase() === event.kind.toLowerCase()
86
+ && source.namespace === event.namespace
87
+ && source.name === event.name
88
+ }
89
+
90
+ function anchorItem(anchor: AppHistoryAnchor, sourceRef: AppSourceRef | undefined, index: number): ApplicationHistoryItem | null {
91
+ if (!validTimestamp(anchor.timestamp)) return null
92
+ return {
93
+ id: `source:${anchor.type}:${anchor.timestamp}:${anchor.revision ?? index}`,
94
+ category: 'deployment',
95
+ title: anchor.title,
96
+ timestamp: anchor.timestamp,
97
+ detail: anchor.source || anchor.message,
98
+ status: anchor.status,
99
+ revision: anchor.revision,
100
+ initiatedBy: anchor.initiatedBy,
101
+ sourceRef,
102
+ }
103
+ }
104
+
105
+ function eventTitle(event: TimelineEvent, problem: boolean): string {
106
+ const kind = getKindLabel(event.kind)
107
+ if (problem) {
108
+ const reason = event.reason?.toLowerCase()
109
+ if (reason === 'podscheduled' && event.message?.includes('nodes are available')) return "Can't be scheduled"
110
+ if (reason === 'failedcreate') {
111
+ if (event.kind === 'CronJob' || event.kind === 'CronWorkflow') return 'Scheduled run could not be created'
112
+ if (ROOT_RUNTIME_KINDS.has(event.kind)) return 'Pod could not be created'
113
+ return `${kind} could not create a resource`
114
+ }
115
+ if (EPHEMERAL_RUN_KINDS.has(event.kind)) {
116
+ if (reason === 'backofflimitexceeded') return `${kind} failed after repeated retries`
117
+ if (reason === 'deadlineexceeded') return `${kind} exceeded its deadline`
118
+ if (reason === 'failed') return `${kind} failed`
119
+ }
120
+ return PROBLEM_TITLES[reason ?? ''] ?? event.reason ?? `${kind} needs attention`
121
+ }
122
+ if (event.eventType === 'add') return `${kind} created`
123
+ if (event.eventType === 'delete') return `${kind} deleted`
124
+ if (event.reason && event.reason !== 'Update' && event.reason !== 'Updated') return event.reason
125
+ return `${kind} updated`
126
+ }
127
+
128
+ function eventDetail(event: TimelineEvent): string | undefined {
129
+ const message = event.message?.trim()
130
+ const summary = event.diff?.summary?.trim()
131
+ if (message && summary && message !== summary) return `${message} · ${summary}`
132
+ return message || summary || undefined
133
+ }
134
+
135
+ function hasDesiredStateChange(event: TimelineEvent): boolean {
136
+ return Boolean(event.diff?.fields.some(({ path }) => {
137
+ if (path === 'spec' || path.startsWith('spec.')) return true
138
+ if (!CONFIG_DATA_KINDS.has(event.kind)) return false
139
+ return path === 'immutable' || (event.kind === 'Secret' && path === 'type') || CONFIG_DATA_PATH.test(path)
140
+ }))
141
+ }
142
+
143
+ function hasConfigurationDataChange(event: TimelineEvent): boolean {
144
+ return CONFIG_DATA_KINDS.has(event.kind) && Boolean(event.diff?.fields.some(({ path }) =>
145
+ path === 'immutable' || CONFIG_DATA_PATH.test(path),
146
+ ))
147
+ }
148
+
149
+ function isEphemeralRun(event: TimelineEvent): boolean {
150
+ return EPHEMERAL_RUN_KINDS.has(event.kind) && Boolean(event.owner)
151
+ }
152
+
153
+ function historyEventItem(event: TimelineEvent): ApplicationHistoryItem | null {
154
+ if (!validTimestamp(event.timestamp)) return null
155
+ const unhealthy = event.healthState === 'degraded' || event.healthState === 'unhealthy'
156
+ const leafRuntimeResource = event.kind === 'Pod' || event.kind === 'ReplicaSet'
157
+ const reason = event.reason?.toLowerCase()
158
+ const normalBatchLifecycle = EPHEMERAL_RUN_KINDS.has(event.kind)
159
+ && event.eventType !== 'Warning'
160
+ && Boolean(reason && NORMAL_BATCH_LIFECYCLE_REASONS.has(reason))
161
+ const problem = !normalBatchLifecycle
162
+ && (isProblematicEvent(event) || (unhealthy && (!leafRuntimeResource || Boolean(event.message?.trim()))))
163
+ if (!problem && event.source === 'k8s_event') return null
164
+ if (event.kind === 'Pod' || event.kind === 'ReplicaSet') {
165
+ if (!problem) return null
166
+ } else if (!problem && !ROOT_RUNTIME_KINDS.has(event.kind) && !RELATED_RUNTIME_KINDS.has(event.kind)) {
167
+ return null
168
+ }
169
+ if (!problem && event.eventType === 'update' && !hasDesiredStateChange(event)) return null
170
+ if (!problem && (event.eventType === 'add' || event.eventType === 'delete') && isEphemeralRun(event)) return null
171
+ const resource = problem && event.owner
172
+ ? {
173
+ kind: event.owner.kind,
174
+ namespace: event.namespace,
175
+ name: event.owner.name,
176
+ }
177
+ : {
178
+ kind: event.kind,
179
+ namespace: event.namespace,
180
+ name: event.name,
181
+ group: event.apiVersion?.includes('/') ? event.apiVersion.split('/')[0] : undefined,
182
+ }
183
+ return {
184
+ id: `event:${event.id}`,
185
+ category: problem ? 'problem' : 'change',
186
+ title: eventTitle(event, problem),
187
+ timestamp: event.timestamp,
188
+ detail: eventDetail(event),
189
+ count: event.count,
190
+ resource,
191
+ }
192
+ }
193
+
194
+ function warningKey(item: ApplicationHistoryItem): string {
195
+ const resource = item.resource
196
+ return `${item.title}\u0000${resource?.kind ?? ''}\u0000${resource?.namespace ?? ''}\u0000${resource?.name ?? ''}\u0000${item.detail ?? ''}`
197
+ }
198
+
199
+ export function buildApplicationHistoryItems(
200
+ history: AppHistory | undefined,
201
+ events: TimelineEvent[],
202
+ ): ApplicationHistoryItem[] {
203
+ const anchorsByChange = new Map<string, AppHistoryAnchor>()
204
+ for (const anchor of history?.anchors ?? []) {
205
+ if (!validTimestamp(anchor.timestamp)) continue
206
+ const key = `${anchor.type}\u0000${anchor.title}\u0000${anchor.timestamp}\u0000${anchor.revision ?? ''}`
207
+ const existing = anchorsByChange.get(key)
208
+ if (!existing) {
209
+ anchorsByChange.set(key, anchor)
210
+ continue
211
+ }
212
+ anchorsByChange.set(key, {
213
+ ...existing,
214
+ status: anchor.status ?? existing.status,
215
+ source: existing.source ?? anchor.source,
216
+ message: anchor.message ?? existing.message,
217
+ initiatedBy: anchor.initiatedBy ?? existing.initiatedBy,
218
+ })
219
+ }
220
+ const anchors = [...anchorsByChange.values()]
221
+ .map((anchor, index) => anchorItem(anchor, history?.sourceRef, index))
222
+ .filter((item): item is ApplicationHistoryItem => item !== null)
223
+ const hasAnchors = anchors.length > 0
224
+ const anchorTimes = anchors.map(({ timestamp }) => new Date(timestamp).getTime())
225
+ const warningItems = new Map<string, ApplicationHistoryItem>()
226
+ const runtimeItems: ApplicationHistoryItem[] = []
227
+
228
+ for (const event of events) {
229
+ const item = historyEventItem(event)
230
+ if (!item) continue
231
+ if (item.category !== 'problem' && sourceMatchesEvent(history?.sourceRef, event)) continue
232
+ if (item.category !== 'problem') {
233
+ const eventTime = new Date(item.timestamp).getTime()
234
+ if (!hasConfigurationDataChange(event)
235
+ && hasAnchors
236
+ && anchorTimes.some((anchorTime) => Math.abs(eventTime - anchorTime) <= SOURCE_BURST_WINDOW_MS)) continue
237
+ runtimeItems.push(item)
238
+ continue
239
+ }
240
+ const key = warningKey(item)
241
+ const existing = warningItems.get(key)
242
+ if (!existing) {
243
+ warningItems.set(key, item)
244
+ continue
245
+ }
246
+ existing.count = Math.max(existing.count ?? 1, item.count ?? 1)
247
+ if (new Date(item.timestamp).getTime() > new Date(existing.timestamp).getTime()) {
248
+ existing.timestamp = item.timestamp
249
+ existing.id = item.id
250
+ }
251
+ }
252
+
253
+ return [...anchors, ...runtimeItems, ...warningItems.values()].sort((a, b) => {
254
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
255
+ return byTime || a.id.localeCompare(b.id)
256
+ })
257
+ }
@@ -412,6 +412,40 @@ describe('buildAppMembershipIndex', () => {
412
412
  expect(idx.byResource.has('Route/team-a/HTTPRoute/web-http')).toBe(false)
413
413
  })
414
414
 
415
+ it('indexes explicit relationship refs across namespaces', () => {
416
+ const idx = buildAppMembershipIndex([
417
+ row({
418
+ key: 'team-a/app/billing', name: 'billing', namespaces: ['team-a', 'shared'],
419
+ relationships: {
420
+ serviceRefs: [{ kind: 'Service', namespace: 'team-a', name: 'billing-api' }],
421
+ configRefs: [{ kind: 'ConfigMap', namespace: 'shared', name: 'billing-config' }],
422
+ storageRefs: [{ kind: 'PersistentVolumeClaim', namespace: 'team-a', name: 'billing-data' }],
423
+ },
424
+ }),
425
+ ])
426
+
427
+ expect(idx.byResource.get('Service/team-a/billing-api')?.appName).toBe('billing')
428
+ expect(idx.byResource.get('ConfigMap/shared/billing-config')?.appName).toBe('billing')
429
+ expect(idx.byResource.get('PersistentVolumeClaim/team-a/billing-data')?.appName).toBe('billing')
430
+ })
431
+
432
+ it('indexes the exact deployment source object', () => {
433
+ const idx = buildAppMembershipIndex([
434
+ row({
435
+ key: 'team-a/app/billing', name: 'billing',
436
+ sourceRef: {
437
+ type: 'gitops',
438
+ tool: 'argocd',
439
+ kind: 'Application',
440
+ namespace: 'argocd',
441
+ name: 'billing',
442
+ },
443
+ }),
444
+ ])
445
+
446
+ expect(idx.byResource.get('Application/argocd/billing')?.appName).toBe('billing')
447
+ })
448
+
415
449
  it('skips satellites for a multi-namespace app (can not place them unambiguously)', () => {
416
450
  const idx = buildAppMembershipIndex([
417
451
  row({
@@ -641,6 +641,31 @@ export function buildAppMembershipIndex(rows: AppRow[]): AppMembershipIndex {
641
641
  setFirst(byResource, `${routeKind}/${ns}/${routeName}`, membership);
642
642
  }
643
643
  }
644
+ if (row.relationships) {
645
+ const explicitRefs = [
646
+ row.relationships.serviceRefs,
647
+ row.relationships.ingressRefs,
648
+ row.relationships.routeRefs,
649
+ row.relationships.configRefs,
650
+ row.relationships.scalerRefs,
651
+ row.relationships.storageRefs,
652
+ row.relationships.pdbRefs,
653
+ row.relationships.networkPolicyRefs,
654
+ ];
655
+ for (const refs of explicitRefs) {
656
+ for (const ref of refs ?? []) {
657
+ if (ref.kind && ref.name)
658
+ setFirst(byResource, `${ref.kind}/${ref.namespace}/${ref.name}`, membership);
659
+ }
660
+ }
661
+ }
662
+ if (row.sourceRef?.kind && row.sourceRef.name) {
663
+ setFirst(
664
+ byResource,
665
+ `${row.sourceRef.kind}/${row.sourceRef.namespace}/${row.sourceRef.name}`,
666
+ membership,
667
+ );
668
+ }
644
669
  for (const mk of row.matchKeys || []) {
645
670
  if (mk.startsWith("name-stem:")) continue;
646
671
  setFirst(byEvidence, mk, membership);
@@ -23,5 +23,6 @@ export * from './git-provider-urls'
23
23
  export * from './applications'
24
24
  export * from './topology-neighborhood'
25
25
  export * from './application-topology'
26
+ export * from './application-history'
26
27
  export * from './bulk-workload-actions'
27
28
  export * from './workflow-execution'
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
 
3
3
  import type { TimelineEvent, Topology } from '../types/core'
4
4
 
5
- import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isPinnedLaneRef, laneTrackEvents, laneHasEventInWindow, isChildVisibleInWindow, subtreeEvents, getAllEventsFromHierarchy, collidingLaneKeys, laneCollisionKey, type ResourceLane } from './resource-hierarchy'
5
+ import { buildResourceHierarchy, eventsForApplication, extractPinnedLanes, removePinnedLanes, isPinnedLaneRef, laneTrackEvents, laneHasEventInWindow, isChildVisibleInWindow, subtreeEvents, getAllEventsFromHierarchy, collidingLaneKeys, laneCollisionKey, type ResourceLane } from './resource-hierarchy'
6
6
  import type { AppMembershipIndex, AppMembership } from './applications'
7
7
 
8
8
  function svcEvent(namespace: string, name: string): TimelineEvent {
@@ -274,6 +274,40 @@ describe('buildResourceHierarchy app-membership cascade (grouping=app)', () => {
274
274
  })
275
275
  })
276
276
 
277
+ describe('eventsForApplication', () => {
278
+ const billing: AppMembership = { appKey: 'team-a/app/billing', appName: 'billing' }
279
+
280
+ it('returns a selected singleton app subtree without the global two-member threshold', () => {
281
+ const deployment = changeEvent('Deployment', 'team-a', 'billing-api', { id: 'deployment' })
282
+ const pod = changeEvent('Pod', 'team-a', 'billing-api-abc12', {
283
+ id: 'pod',
284
+ owner: { kind: 'Deployment', name: 'billing-api' },
285
+ })
286
+ const unrelated = changeEvent('Deployment', 'team-a', 'other', { id: 'other' })
287
+ const appIndex = index({ 'Deployment/team-a/billing-api': billing })
288
+
289
+ const events = eventsForApplication([deployment, pod, unrelated], undefined, appIndex)
290
+
291
+ expect(events.map((event) => event.id).sort()).toEqual(['deployment', 'pod'])
292
+ })
293
+
294
+ it('includes a relationship root and its owned descendants', () => {
295
+ const service = changeEvent('Service', 'team-a', 'billing-api', { id: 'service' })
296
+ const deployment = changeEvent('Deployment', 'team-a', 'billing-api', { id: 'deployment' })
297
+ const topology = {
298
+ nodes: [
299
+ { id: 'service/team-a/billing-api', data: {} },
300
+ { id: 'deployment/team-a/billing-api', data: {} },
301
+ ],
302
+ edges: [{ id: 'edge', source: 'service/team-a/billing-api', target: 'deployment/team-a/billing-api', type: 'exposes' }],
303
+ } as unknown as Topology
304
+ const appIndex = index({ 'Service/team-a/billing-api': billing })
305
+
306
+ expect(eventsForApplication([service, deployment], topology, appIndex).map((event) => event.id).sort())
307
+ .toEqual(['deployment', 'service'])
308
+ })
309
+ })
310
+
277
311
  // --- kind-contract nesting (parent-driven naming) ---------------------------
278
312
  //
279
313
  // A resource whose only in-window events are ownerless (deletion-time cache miss)
@@ -1049,6 +1049,26 @@ export function subtreeEvents(lane: ResourceLane): TimelineEvent[] {
1049
1049
  return sortEventsForRendering(unique)
1050
1050
  }
1051
1051
 
1052
+ /** Return every event attributable to one selected application. The caller
1053
+ * supplies an index containing only that app, so singleton apps are retained
1054
+ * even though the global Timeline intentionally avoids one-member app groups. */
1055
+ export function eventsForApplication(
1056
+ events: TimelineEvent[],
1057
+ topology: Topology | undefined,
1058
+ appIndex: AppMembershipIndex,
1059
+ ): TimelineEvent[] {
1060
+ const roots = buildResourceHierarchy({ events, topology, grouping: 'owner' })
1061
+ const matched = roots.filter((lane) => cascadeRootMembership(lane, appIndex) !== null)
1062
+ const unique = new Map<string, TimelineEvent>()
1063
+ for (const lane of matched) {
1064
+ for (const event of subtreeEvents(lane)) unique.set(event.id, event)
1065
+ }
1066
+ return [...unique.values()].sort((a, b) => {
1067
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1068
+ return byTime || (b.seq ?? 0) - (a.seq ?? 0) || a.id.localeCompare(b.id)
1069
+ })
1070
+ }
1071
+
1052
1072
  /** The events a lane paints on its OWN track row, given whether it's expanded.
1053
1073
  * - collapsed parent → whole-subtree aggregate (the roll-up)
1054
1074
  * - expanded parent, or a leaf → only the lane's own events