@skyhook-io/k8s-ui 1.8.7 → 1.8.8

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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +4 -1
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  5. package/src/components/gitops/GitOpsTableView.tsx +46 -45
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  7. package/src/components/issues/IssuesView.tsx +41 -5
  8. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  9. package/src/components/issues/diagnostic.ts +22 -0
  10. package/src/components/issues/index.ts +1 -1
  11. package/src/components/issues/issues.test.ts +21 -0
  12. package/src/components/issues/types.ts +18 -0
  13. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  14. package/src/components/namespace-switcher/index.ts +6 -0
  15. package/src/components/resources/ResourcesView.tsx +20 -81
  16. package/src/components/scope-pill/ScopePill.tsx +35 -0
  17. package/src/components/scope-pill/index.ts +2 -0
  18. package/src/components/timeline/TimelineList.tsx +27 -1
  19. package/src/components/topology/TopologyControls.tsx +90 -14
  20. package/src/components/ui/FreshnessControl.tsx +153 -0
  21. package/src/components/ui/SortableTh.tsx +16 -10
  22. package/src/components/ui/Toast.tsx +1 -1
  23. package/src/components/ui/index.ts +2 -0
  24. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  25. package/src/components/workload/WorkloadView.tsx +26 -8
  26. package/src/hooks/index.ts +1 -0
  27. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  28. package/src/hooks/useRefreshAnimation.ts +15 -2
  29. package/src/index.ts +8 -0
  30. package/src/types/core.ts +42 -0
  31. package/src/types/gitops-insights.ts +4 -0
  32. package/src/utils/animation.ts +10 -0
  33. package/src/utils/format-freshness.test.ts +34 -0
  34. package/src/utils/format.ts +32 -0
  35. package/src/utils/resource-hierarchy.test.ts +51 -0
  36. package/src/utils/resource-hierarchy.ts +7 -4
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { formatUpdatedAgo, formatLastUpdatedBucket, msToNextBucket } from './format'
3
+
4
+ describe('formatUpdatedAgo', () => {
5
+ it('collapses the first minute to "just now"', () => {
6
+ expect(formatUpdatedAgo(0)).toBe('just now')
7
+ expect(formatUpdatedAgo(59_000)).toBe('just now')
8
+ })
9
+ it('renders minutes and hours with an "ago" suffix', () => {
10
+ expect(formatUpdatedAgo(60_000)).toBe('1m ago')
11
+ expect(formatUpdatedAgo(3_600_000)).toBe('1h ago')
12
+ })
13
+ it('collapses anything past a day to "over a day ago"', () => {
14
+ expect(formatUpdatedAgo(25 * 3_600_000)).toBe('over a day ago')
15
+ })
16
+ })
17
+
18
+ describe('formatLastUpdatedBucket', () => {
19
+ it('buckets by the coarsest unit', () => {
20
+ expect(formatLastUpdatedBucket(0)).toBe('just now')
21
+ expect(formatLastUpdatedBucket(60_000)).toBe('1m')
22
+ expect(formatLastUpdatedBucket(3_600_000)).toBe('1h')
23
+ expect(formatLastUpdatedBucket(86_400_000)).toBe('1d')
24
+ })
25
+ })
26
+
27
+ describe('msToNextBucket', () => {
28
+ it('schedules the next re-render exactly on the bucket boundary', () => {
29
+ expect(msToNextBucket(0)).toBe(60_000)
30
+ expect(msToNextBucket(30_000)).toBe(30_000)
31
+ // 90s elapsed → 30s until the "2m" bucket flips.
32
+ expect(msToNextBucket(90_000)).toBe(30_000)
33
+ })
34
+ })
@@ -192,6 +192,38 @@ export function formatCompactAge(value?: string): string {
192
192
  return `${Math.floor(hours / 24)}d`
193
193
  }
194
194
 
195
+ // Coarse "just now / Xm / Xh / Xd" buckets for freshness labels — finer-grained
196
+ // updates add motion in the periphery without aiding any user decision.
197
+ export function formatLastUpdatedBucket(elapsedMs: number): string {
198
+ const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
199
+ if (elapsedSec < 60) return 'just now'
200
+ const minutes = Math.floor(elapsedSec / 60)
201
+ if (minutes < 60) return `${minutes}m`
202
+ const hours = Math.floor(minutes / 60)
203
+ if (hours < 24) return `${hours}h`
204
+ return `${Math.floor(hours / 24)}d`
205
+ }
206
+
207
+ // ms until the bucket produced by formatLastUpdatedBucket would change — lets a
208
+ // ticker re-render exactly on the boundary instead of polling every second.
209
+ export function msToNextBucket(elapsedMs: number): number {
210
+ const elapsed = Math.max(0, elapsedMs)
211
+ if (elapsed < 60_000) return 60_000 - elapsed
212
+ if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
213
+ if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
214
+ return 86_400_000 - (elapsed % 86_400_000)
215
+ }
216
+
217
+ // Freshness phrasing for "Updated X" indicators: just now / Xm ago / Xh ago /
218
+ // over a day ago. An exact day count is noise for an auto-refresh signal, so
219
+ // anything past 24h collapses to "over a day ago".
220
+ export function formatUpdatedAgo(elapsedMs: number): string {
221
+ const bucket = formatLastUpdatedBucket(elapsedMs)
222
+ if (bucket === 'just now') return 'just now'
223
+ if (bucket.endsWith('d')) return 'over a day ago'
224
+ return `${bucket} ago`
225
+ }
226
+
195
227
  export function formatRelativeAgeTime(value?: string, fallback = '-'): string {
196
228
  if (!value) return fallback
197
229
  const time = Date.parse(value)
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ import type { TimelineEvent, Topology } from '../types/core'
4
+
5
+ import { buildResourceHierarchy } from './resource-hierarchy'
6
+
7
+ function svcEvent(namespace: string, name: string): TimelineEvent {
8
+ return {
9
+ id: `${namespace}/${name}`,
10
+ timestamp: '2024-01-01T00:00:00.000Z',
11
+ source: 'informer',
12
+ kind: 'Service',
13
+ namespace,
14
+ name,
15
+ eventType: 'update',
16
+ }
17
+ }
18
+
19
+ function svcNode(namespace: string, name: string, app: string) {
20
+ return {
21
+ id: `service/${namespace}/${name}`,
22
+ data: { apiVersion: 'v1', labels: { 'app.kubernetes.io/name': app } },
23
+ }
24
+ }
25
+
26
+ function topo(nodes: ReturnType<typeof svcNode>[]): Topology {
27
+ return { nodes, edges: [] } as unknown as Topology
28
+ }
29
+
30
+ describe('buildResourceHierarchy app-label grouping', () => {
31
+ it('does not merge the same app label across namespaces', () => {
32
+ const events = [svcEvent('team-a', 'web'), svcEvent('team-b', 'web')]
33
+ const topology = topo([svcNode('team-a', 'web', 'web'), svcNode('team-b', 'web', 'web')])
34
+
35
+ const lanes = buildResourceHierarchy({ events, topology, groupByApp: true })
36
+
37
+ expect(lanes).toHaveLength(2)
38
+ expect(lanes.every((l) => (l.children ?? []).length === 0)).toBe(true)
39
+ expect(new Set(lanes.map((l) => l.namespace))).toEqual(new Set(['team-a', 'team-b']))
40
+ })
41
+
42
+ it('groups distinct resources sharing an app label within one namespace', () => {
43
+ const events = [svcEvent('team-a', 'web'), svcEvent('team-a', 'web-edge')]
44
+ const topology = topo([svcNode('team-a', 'web', 'web'), svcNode('team-a', 'web-edge', 'web')])
45
+
46
+ const lanes = buildResourceHierarchy({ events, topology, groupByApp: true })
47
+
48
+ expect(lanes).toHaveLength(1)
49
+ expect(lanes[0].children).toHaveLength(1)
50
+ })
51
+ })
@@ -465,17 +465,20 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
465
465
  'HTTPProxy', // Contour
466
466
  ])
467
467
 
468
- // Group lanes by app label
468
+ // Group lanes by app label, scoped to namespace: the same app label in two
469
+ // namespaces (e.g. the same workload deployed to dev and staging) is two
470
+ // distinct apps and must not collapse into one lane.
469
471
  const appGroups = new Map<string, string[]>()
470
472
  for (const [laneId, lane] of laneMap) {
471
473
  if (laneParent.has(laneId)) continue
472
474
  if (!appLabelEligibleKinds.has(lane.kind)) continue
473
475
  const appLabel = laneAppLabels.get(laneId)
474
476
  if (!appLabel) continue
475
- if (!appGroups.has(appLabel)) {
476
- appGroups.set(appLabel, [])
477
+ const groupKey = `${lane.namespace}/${appLabel}`
478
+ if (!appGroups.has(groupKey)) {
479
+ appGroups.set(groupKey, [])
477
480
  }
478
- appGroups.get(appLabel)!.push(laneId)
481
+ appGroups.get(groupKey)!.push(laneId)
479
482
  }
480
483
 
481
484
  // For each app group with multiple members, pick the best parent