@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.
- package/package.json +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +6 -1
- package/src/components/audit/AuditAlerts.tsx +4 -0
- package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
- package/src/components/audit/AuditFindingsTable.tsx +4 -0
- package/src/components/audit/index.ts +1 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +49 -46
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +49 -40
- package/src/components/issues/ResourceIssuesSection.tsx +145 -0
- package/src/components/issues/diagnostic.ts +86 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +23 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +58 -83
- package/src/components/resources/cron-to-human.test.ts +41 -0
- package/src/components/resources/get-pod-problems.test.ts +18 -0
- package/src/components/resources/health-golden.test.ts +66 -0
- package/src/components/resources/renderers/JobRenderer.tsx +6 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
- package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
- package/src/components/resources/renderers/PodRenderer.tsx +28 -9
- package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
- package/src/components/resources/resource-utils-argo.test.ts +23 -0
- package/src/components/resources/resource-utils-argo.ts +5 -1
- package/src/components/resources/resource-utils-keda.ts +12 -8
- package/src/components/resources/resource-utils.ts +34 -14
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
- package/src/components/timeline/shared.tsx +15 -4
- package/src/components/topology/K8sResourceNode.tsx +28 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/PaneLoader.tsx +24 -6
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +61 -13
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +8 -0
- package/src/types/core.ts +142 -3
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/badge-colors.ts +7 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- 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
|
}
|
package/src/utils/animation.ts
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
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
|
-
|
|
652
|
-
|
|
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 = '
|
|
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
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
-
|
|
476
|
-
|
|
477
|
+
const groupKey = `${lane.namespace}/${appLabel}`
|
|
478
|
+
if (!appGroups.has(groupKey)) {
|
|
479
|
+
appGroups.set(groupKey, [])
|
|
477
480
|
}
|
|
478
|
-
appGroups.get(
|
|
481
|
+
appGroups.get(groupKey)!.push(laneId)
|
|
479
482
|
}
|
|
480
483
|
|
|
481
484
|
// For each app group with multiple members, pick the best parent
|