@skyhook-io/k8s-ui 1.8.6 → 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 (68) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +6 -1
  4. package/src/components/audit/AuditAlerts.tsx +4 -0
  5. package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
  6. package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +4 -0
  8. package/src/components/audit/index.ts +1 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  10. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
  11. package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
  12. package/src/components/gitops/GitOpsTableView.tsx +49 -46
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  14. package/src/components/issues/IssuesView.tsx +49 -40
  15. package/src/components/issues/ResourceIssuesSection.tsx +145 -0
  16. package/src/components/issues/diagnostic.ts +86 -0
  17. package/src/components/issues/index.ts +2 -1
  18. package/src/components/issues/issues.test.ts +21 -0
  19. package/src/components/issues/severity.ts +10 -9
  20. package/src/components/issues/types.ts +23 -0
  21. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  22. package/src/components/namespace-switcher/index.ts +6 -0
  23. package/src/components/resources/ResourcesView.tsx +58 -83
  24. package/src/components/resources/cron-to-human.test.ts +41 -0
  25. package/src/components/resources/get-pod-problems.test.ts +18 -0
  26. package/src/components/resources/health-golden.test.ts +66 -0
  27. package/src/components/resources/renderers/JobRenderer.tsx +6 -2
  28. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  30. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  31. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  32. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  33. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  34. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  35. package/src/components/resources/resource-utils-argo.ts +5 -1
  36. package/src/components/resources/resource-utils-keda.ts +12 -8
  37. package/src/components/resources/resource-utils.ts +34 -14
  38. package/src/components/scope-pill/ScopePill.tsx +35 -0
  39. package/src/components/scope-pill/index.ts +2 -0
  40. package/src/components/timeline/TimelineList.tsx +27 -1
  41. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  42. package/src/components/timeline/shared.tsx +15 -4
  43. package/src/components/topology/K8sResourceNode.tsx +28 -1
  44. package/src/components/topology/TopologyControls.tsx +90 -14
  45. package/src/components/topology/layout.ts +11 -5
  46. package/src/components/ui/FreshnessControl.tsx +153 -0
  47. package/src/components/ui/PaneLoader.tsx +24 -6
  48. package/src/components/ui/SortableTh.tsx +16 -10
  49. package/src/components/ui/Toast.tsx +1 -1
  50. package/src/components/ui/drawer-components.test.tsx +35 -0
  51. package/src/components/ui/drawer-components.tsx +13 -1
  52. package/src/components/ui/index.ts +2 -0
  53. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  54. package/src/components/workload/WorkloadView.tsx +61 -13
  55. package/src/hooks/index.ts +1 -0
  56. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  57. package/src/hooks/useRefreshAnimation.ts +15 -2
  58. package/src/index.ts +8 -0
  59. package/src/types/core.ts +142 -3
  60. package/src/types/gitops-insights.ts +4 -0
  61. package/src/utils/animation.ts +10 -0
  62. package/src/utils/applications.test.ts +55 -1
  63. package/src/utils/applications.ts +28 -7
  64. package/src/utils/badge-colors.ts +7 -0
  65. package/src/utils/format-freshness.test.ts +34 -0
  66. package/src/utils/format.ts +32 -0
  67. package/src/utils/resource-hierarchy.test.ts +51 -0
  68. package/src/utils/resource-hierarchy.ts +7 -4
package/src/index.ts CHANGED
@@ -53,6 +53,14 @@ export * from './components/issues'
53
53
  // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
54
54
  export * from './components/cluster-switcher'
55
55
 
56
+ // Namespace picker (shared scope-filter trigger+dropdown for OSS Radar and
57
+ // Radar Hub — pure presentation, data injected via props)
58
+ export * from './components/namespace-switcher'
59
+
60
+ // Scope pill — the shared bordered shell that groups the cluster + namespace
61
+ // segments into one unit (OSS header + Radar Hub cluster top bar)
62
+ export * from './components/scope-pill'
63
+
56
64
  // Applications (shared host-agnostic list + detail shell for the deployable-
57
65
  // software surface; OSS renders single-cluster, Cloud adds the fleet layer)
58
66
  export * from './components/applications'
package/src/types/core.ts CHANGED
@@ -193,7 +193,7 @@ export function displayKind(kind: string): string {
193
193
  return shortNames[kind] || kind
194
194
  }
195
195
 
196
- export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
196
+ export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
197
197
 
198
198
  export type EdgeType = 'routes-to' | 'exposes' | 'manages' | 'uses' | 'configures' | 'protects'
199
199
 
@@ -557,7 +557,7 @@ export interface HelmRelease {
557
557
  lastOperation?: HelmOperation
558
558
  operations?: HelmOperation[]
559
559
  // Health summary from owned resources
560
- resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
560
+ resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
561
561
  healthIssue?: string // Primary issue if unhealthy (e.g., "OOMKilled")
562
562
  healthSummary?: string // Brief summary like "2/3 pods ready"
563
563
  // When set, this release was installed by Flux's helm-controller — the
@@ -587,6 +587,7 @@ export interface HelmOperation {
587
587
  source: HelmOperationSource
588
588
  confidence: HelmOperationConfidence
589
589
  message: string
590
+ rawMessage?: string
590
591
  evidence?: string
591
592
  failureDescription?: string
592
593
  revision?: number
@@ -597,6 +598,22 @@ export interface HelmOperation {
597
598
  updated?: string
598
599
  }
599
600
 
601
+ export type HelmOperationInsightState = 'active' | 'recovered'
602
+
603
+ export interface HelmSuggestedCompare {
604
+ revision1: number
605
+ revision2: number
606
+ reason?: string
607
+ }
608
+
609
+ export interface HelmOperationInsight {
610
+ state: HelmOperationInsightState
611
+ primaryResource?: HelmOwnedResource
612
+ relatedResources?: HelmOwnedResource[]
613
+ signalCount?: number
614
+ suggestedCompare?: HelmSuggestedCompare
615
+ }
616
+
600
617
  export interface HelmReleaseDetail {
601
618
  name: string
602
619
  namespace: string
@@ -612,14 +629,16 @@ export interface HelmReleaseDetail {
612
629
  notes: string
613
630
  history: HelmRevision[]
614
631
  resources: HelmOwnedResource[]
615
- resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
632
+ resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
616
633
  healthIssue?: string
617
634
  healthSummary?: string
618
635
  hooks?: HelmHook[]
636
+ hookDiagnostics?: HookDiagnostic[]
619
637
  readme?: string
620
638
  dependencies?: ChartDependency[]
621
639
  lastOperation?: HelmOperation
622
640
  operations?: HelmOperation[]
641
+ operationInsight?: HelmOperationInsight
623
642
  // When set, this release was installed by Flux's helm-controller — see
624
643
  // HelmRelease.managedByFluxHelmRelease for context. Format: "namespace/name".
625
644
  managedByFluxHelmRelease?: string
@@ -627,10 +646,79 @@ export interface HelmReleaseDetail {
627
646
 
628
647
  export interface HelmHook {
629
648
  name: string
649
+ namespace?: string
630
650
  kind: string
651
+ path?: string
652
+ manifestChanged?: boolean
631
653
  events: string[]
632
654
  weight: number
633
655
  status?: string
656
+ startedAt?: string
657
+ completedAt?: string
658
+ deletePolicies?: string[]
659
+ outputLogPolicies?: string[]
660
+ }
661
+
662
+ export interface HookDiagnostic {
663
+ name: string
664
+ namespace?: string
665
+ kind: string
666
+ events?: string[]
667
+ phase: string
668
+ message: string
669
+ evidence?: HookEvidence
670
+ evidenceUnavailable?: boolean
671
+ evidenceUnavailableReason?: string
672
+ }
673
+
674
+ export interface HookEvidence {
675
+ summary?: string
676
+ jobs?: HookJobEvidence[]
677
+ pods?: HookPodEvidence[]
678
+ events?: HookEventEvidence[]
679
+ logs?: HookLogEvidence[]
680
+ errors?: string[]
681
+ }
682
+
683
+ export interface HookJobEvidence {
684
+ name: string
685
+ namespace?: string
686
+ status?: string
687
+ active?: number
688
+ succeeded?: number
689
+ failed?: number
690
+ conditions?: string[]
691
+ }
692
+
693
+ export interface HookPodEvidence {
694
+ name: string
695
+ namespace?: string
696
+ phase?: string
697
+ ready?: string
698
+ restartCount?: number
699
+ reason?: string
700
+ message?: string
701
+ }
702
+
703
+ export interface HookEventEvidence {
704
+ involvedKind: string
705
+ involvedName: string
706
+ type?: string
707
+ reason?: string
708
+ message?: string
709
+ count?: number
710
+ lastSeen?: string
711
+ }
712
+
713
+ export interface HookLogEvidence {
714
+ pod: string
715
+ container: string
716
+ previous?: boolean
717
+ lines?: string[]
718
+ totalLines?: number
719
+ matchedLines?: number
720
+ fallback?: boolean
721
+ error?: string
634
722
  }
635
723
 
636
724
  export interface ChartDependency {
@@ -658,12 +746,63 @@ export interface HelmValues {
658
746
  computed?: Record<string, unknown>
659
747
  }
660
748
 
749
+ export interface ValuesDiff {
750
+ revision1: number
751
+ revision2: number
752
+ allValues: boolean
753
+ diff: string
754
+ }
755
+
661
756
  export interface ManifestDiff {
662
757
  revision1: number
663
758
  revision2: number
664
759
  diff: string
665
760
  }
666
761
 
762
+ export interface NotesDiff {
763
+ revision1: number
764
+ revision2: number
765
+ diff: string
766
+ }
767
+
768
+ export interface HooksDiff {
769
+ revision1: number
770
+ revision2: number
771
+ added: HelmHook[]
772
+ removed: HelmHook[]
773
+ modified: HelmHook[]
774
+ unchanged: HelmHook[]
775
+ }
776
+
777
+ export interface HelmResourceRef {
778
+ kind: string
779
+ apiVersion?: string
780
+ name: string
781
+ namespace: string
782
+ }
783
+
784
+ export interface HelmResourceFieldChange {
785
+ path: string
786
+ oldValue: unknown
787
+ newValue: unknown
788
+ }
789
+
790
+ export interface HelmResourceChange extends HelmResourceRef {
791
+ summary?: string
792
+ fieldCount: number
793
+ fields: HelmResourceFieldChange[]
794
+ }
795
+
796
+ export interface ResourceDiff {
797
+ revision1: number
798
+ revision2: number
799
+ added: HelmResourceRef[]
800
+ removed: HelmResourceRef[]
801
+ modified: HelmResourceChange[]
802
+ unchanged: HelmResourceRef[]
803
+ parseErrorCount?: number
804
+ }
805
+
667
806
  // Selected Helm release (for drawer state)
668
807
  export interface SelectedHelmRelease {
669
808
  namespace: string
@@ -45,6 +45,7 @@ export interface GitOpsInsightSummary {
45
45
  // Latest operation status message — surfaced inline in the status strip
46
46
  // when an operation is in flight or just failed.
47
47
  operationMessage?: string
48
+ rawOperationMessage?: string
48
49
  source?: string
49
50
  targetRevision?: string
50
51
  lastRevision?: string
@@ -79,6 +80,7 @@ export interface GitOpsIssue {
79
80
  scope: GitOpsScope
80
81
  reason: string
81
82
  message: string
83
+ rawMessage?: string
82
84
  refs?: GitOpsInsightRef[]
83
85
  action?: string
84
86
  // Plain-English root cause when the message matched a recognized error
@@ -112,6 +114,7 @@ export interface GitOpsChange {
112
114
  // Per-resource sync failure message (Argo's status.resources[].syncResult).
113
115
  // Distinct from `message` (live health). Empty when sync succeeded.
114
116
  syncError?: string
117
+ rawSyncError?: string
115
118
  // Sync hook phase: PreSync / PostSync / SyncFail / PostDelete. Empty
116
119
  // for non-hook resources.
117
120
  hookPhase?: string
@@ -175,6 +178,7 @@ export interface GitOpsHistoryItem {
175
178
  deployedAt?: string
176
179
  phase?: string
177
180
  message?: string
181
+ rawMessage?: string
178
182
  source?: string
179
183
  initiatedBy?: string
180
184
  }
@@ -22,6 +22,16 @@ export const DURATION_DOCK = 150
22
22
  /** Toast exit animation */
23
23
  export const DURATION_TOAST_EXIT = 200
24
24
 
25
+ /** Drawer ↔ fullscreen expand/collapse morph. Longer than DURATION_NORMAL so a
26
+ * large width change advances in small per-frame steps (reads as fluid, not
27
+ * abrupt). Drives the frame width, the content crossfade, and the JS window
28
+ * together — keep them in lockstep. */
29
+ export const DURATION_DRAWER_MORPH = 260
30
+ /** Decelerate curve — fast start, gentle settle. Reads as snappy/responsive (the
31
+ * earlier ease-in-out's slow start felt sluggish). Safe now that the pinned-layer
32
+ * crossfade fixed the reflow that originally needed the gentle start. */
33
+ export const EASE_DRAWER_MORPH = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
34
+
25
35
  // -- Tailwind class presets ---------------------------------------------------
26
36
  // Reusable class fragments — import and spread into clsx() calls.
27
37
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, type AppGroupFoldEntry } from './applications'
2
+ import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, type AppGroupFoldEntry } from './applications'
3
3
 
4
4
  describe('compareVersions', () => {
5
5
  it('orders semver', () => {
@@ -206,6 +206,60 @@ describe('foldAppGroups', () => {
206
206
  })
207
207
  })
208
208
 
209
+ describe('worstHealth', () => {
210
+ // Mirrors pkg/health.WorseOf: unhealthy > degraded > unknown > healthy > neutral,
211
+ // with neutral as the most-benign identity. Regression for the rank-inversion
212
+ // fix — seeding the fold with `unknown` (now rank 2) made all-healthy/all-idle
213
+ // sets wrongly return `unknown`.
214
+ it('all-healthy stays healthy (not unknown)', () => {
215
+ expect(worstHealth(['healthy', 'healthy'])).toBe('healthy')
216
+ })
217
+ it('all-neutral stays neutral', () => {
218
+ expect(worstHealth(['neutral', 'neutral'])).toBe('neutral')
219
+ })
220
+ it('healthy + neutral resolves to healthy (healthy out-ranks idle)', () => {
221
+ expect(worstHealth(['healthy', 'neutral'])).toBe('healthy')
222
+ expect(worstHealth(['neutral', 'healthy'])).toBe('healthy')
223
+ })
224
+ it('unknown out-ranks healthy (a node-lost workload is worse than a running one)', () => {
225
+ expect(worstHealth(['unknown', 'healthy'])).toBe('unknown')
226
+ })
227
+ it('unhealthy dominates everything', () => {
228
+ expect(worstHealth(['unhealthy', 'degraded', 'unknown', 'healthy', 'neutral'])).toBe('unhealthy')
229
+ })
230
+ it('empty set is the most-benign identity', () => {
231
+ expect(worstHealth([])).toBe('neutral')
232
+ })
233
+ })
234
+
235
+ describe('foldAppGroups health rollup', () => {
236
+ const grp = (env: string, health: string): AppGroupFoldEntry => ({
237
+ row: { key: `k-${env}`, name: `billing-${env}`, identity: { key: 'billing', env, confidence: 'medium', evidence: 'e' } },
238
+ health: health as AppGroupFoldEntry['health'],
239
+ versions: [],
240
+ ready: 1,
241
+ desired: 1,
242
+ kinds: { Deployment: 1 },
243
+ classComposition: [{ cls: 'service', count: 1 }],
244
+ })
245
+ const rollup = (...hs: string[]) => {
246
+ const rows = foldAppGroups(hs.map((h, i) => grp(`env${i}`, h)), new Set(), false)
247
+ return (rows[0] as Extract<(typeof rows)[0], { kind: 'group' }>).health
248
+ }
249
+ it('all-healthy group rolls up healthy (regression: was unknown)', () => {
250
+ expect(rollup('healthy', 'healthy')).toBe('healthy')
251
+ })
252
+ it('all-idle group rolls up neutral (Idle), not green', () => {
253
+ expect(rollup('neutral', 'neutral')).toBe('neutral')
254
+ })
255
+ it('mixed healthy + idle reads healthy', () => {
256
+ expect(rollup('healthy', 'neutral')).toBe('healthy')
257
+ })
258
+ it('healthy + unknown reads unknown (node-lost dominates)', () => {
259
+ expect(rollup('healthy', 'unknown')).toBe('unknown')
260
+ })
261
+ })
262
+
209
263
  describe('appGroupingExplainer', () => {
210
264
  it('declared origins fold across clusters with no fix needed', () => {
211
265
  for (const source of ['explicit', 'argo-path', 'argo-appset', 'flux-source']) {
@@ -6,7 +6,7 @@
6
6
  // (internal/server/applications.go). Field names match the Go json tags.
7
7
 
8
8
  export type AppWorkloadClass = 'service' | 'worker' | 'job' | 'mixed' | 'unknown'
9
- export type AppHealth = 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
9
+ export type AppHealth = 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
10
10
 
11
11
  export interface AppWorkload {
12
12
  kind: string
@@ -582,7 +582,9 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
582
582
  const compMap = new Map<AppWorkloadClass, number>()
583
583
  let ready = 0
584
584
  let desired = 0
585
- let health: AppHealth = 'unknown'
585
+ // Seed with the most-benign tier (rank 0) so the max-fold below has a valid
586
+ // identity now that `unknown` ranks above healthy — see worstHealth.
587
+ let health: AppHealth = 'neutral'
586
588
  for (const m of members) {
587
589
  const v = newest(m)
588
590
  // A fleet member spans several per-cluster envs; the host supplies them so
@@ -640,7 +642,9 @@ export function foldAppGroups<T extends AppGroupFoldEntry>(
640
642
  /** Normalize a wire health string to the AppHealth union (the health twin of
641
643
  * workloadClassOf — keeps `as AppHealth` casts out of components). */
642
644
  export function healthOf(value: string | undefined): AppHealth {
643
- return value === 'unhealthy' || value === 'degraded' || value === 'healthy' ? value : 'unknown'
645
+ return value === 'unhealthy' || value === 'degraded' || value === 'healthy' || value === 'neutral'
646
+ ? value
647
+ : 'unknown'
644
648
  }
645
649
 
646
650
  // -----------------------------------------------------------------------------
@@ -648,8 +652,16 @@ export function healthOf(value: string | undefined): AppHealth {
648
652
  // pale-pastel pills (which have no theme token) for the colored tiers.
649
653
  // -----------------------------------------------------------------------------
650
654
 
651
- export const HEALTH_ORDER: AppHealth[] = ['unhealthy', 'degraded', 'healthy', 'unknown']
652
- export const HEALTH_RANK: Record<string, number> = { unhealthy: 3, degraded: 2, healthy: 1, unknown: 0 }
655
+ // Worst-first display + aggregation order. Mirrors the backend's
656
+ // `pkg/health` Rank ordering (unhealthy > degraded > unknown > healthy ≈ neutral)
657
+ // so the app rollup agrees with every other surface. `unknown` ranks ABOVE
658
+ // healthy (a node-lost workload is worse than a running one); `neutral`
659
+ // (intentional/idle) is the most-benign tier, so an all-idle app rolls up to
660
+ // "Idle" while a mixed healthy+idle app still reads Healthy (healthy out-ranks
661
+ // neutral in the max). NOTE: the previous map inverted this — it ranked
662
+ // `unknown: 0` below `healthy`, disagreeing with the backend rollup.
663
+ export const HEALTH_ORDER: AppHealth[] = ['unhealthy', 'degraded', 'unknown', 'healthy', 'neutral']
664
+ export const HEALTH_RANK: Record<string, number> = { unhealthy: 4, degraded: 3, unknown: 2, healthy: 1, neutral: 0 }
653
665
 
654
666
  export interface HealthMeta {
655
667
  label: string
@@ -670,6 +682,7 @@ export const CHIP_TONE = {
670
682
  amber: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900',
671
683
  emerald: 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900',
672
684
  blue: 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-900',
685
+ sky: 'bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
673
686
  violet: 'bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
674
687
  neutral: 'bg-theme-hover text-theme-text-secondary ring-theme-border',
675
688
  muted: 'bg-theme-hover text-theme-text-tertiary ring-theme-border',
@@ -679,6 +692,9 @@ export const HEALTH_META: Record<AppHealth, HealthMeta> = {
679
692
  unhealthy: { label: 'Down', bar: 'bg-rose-500', text: 'text-rose-600 dark:text-rose-400', pill: CHIP_TONE.rose },
680
693
  degraded: { label: 'Degraded', bar: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-400', pill: CHIP_TONE.amber },
681
694
  healthy: { label: 'Healthy', bar: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400', pill: CHIP_TONE.emerald },
695
+ // neutral = intentionally idle/off (every workload suspended or scaled to 0).
696
+ // Sky, calm — not "Healthy" (it isn't serving) and not a problem to act on.
697
+ neutral: { label: 'Idle', bar: 'bg-sky-500', text: 'text-sky-600 dark:text-sky-400', pill: CHIP_TONE.sky },
682
698
  unknown: { label: 'Unknown', bar: 'bg-slate-400', text: 'text-theme-text-tertiary', pill: CHIP_TONE.muted },
683
699
  }
684
700
 
@@ -726,9 +742,14 @@ export function workloadClassOf(value?: AppWorkloadClass): AppWorkloadClass {
726
742
  }
727
743
  }
728
744
 
729
- /** Worst health across a set of raw health strings. */
745
+ /** Worst health across a set of raw health strings. Seeds with `neutral` — the
746
+ * most-benign tier (rank 0) — so it acts as the max-fold identity: any real
747
+ * value out-ranks it, an all-neutral set stays neutral, and healthy+neutral
748
+ * resolves to healthy. (Seeding with `unknown` would be wrong now that `unknown`
749
+ * ranks ABOVE healthy — an all-healthy set would never beat it and return
750
+ * `unknown`. Mirrors pkg/health.WorseOf.) */
730
751
  export function worstHealth(hs: string[]): AppHealth {
731
- let w: AppHealth = 'unknown'
752
+ let w: AppHealth = 'neutral'
732
753
  for (const h of hs) if ((HEALTH_RANK[h] ?? 0) > (HEALTH_RANK[w] ?? 0)) w = h as AppHealth
733
754
  return w
734
755
  }
@@ -38,6 +38,10 @@ export const HEALTH_BADGE_COLORS: Record<string, string> = {
38
38
  degraded: BADGE_SEVERITY_COLORS.warning,
39
39
  alert: BADGE_SEVERITY_COLORS.alert,
40
40
  unhealthy: BADGE_SEVERITY_COLORS.error,
41
+ // neutral = intentional/idle (suspended, scaled-to-0, completed) — sky, calm
42
+ // and distinct from `unknown` (gray, "can't determine"). Reuses the `info`
43
+ // sky palette; see `.status-neutral` in theme/components.css.
44
+ neutral: BADGE_SEVERITY_COLORS.info,
41
45
  unknown: BADGE_SEVERITY_COLORS.neutral,
42
46
  }
43
47
 
@@ -225,6 +229,9 @@ export function healthToSeverity(health: string): Severity {
225
229
  case 'error':
226
230
  case 'failed':
227
231
  return 'error'
232
+ // health 'neutral' (intentional/idle) renders sky via the `info` palette —
233
+ // calm, and visually distinct from 'unknown' which falls through to gray.
234
+ case 'neutral':
228
235
  case 'info':
229
236
  return 'info'
230
237
  default:
@@ -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