@skyhook-io/k8s-ui 1.13.3 → 1.13.4
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 +1 -1
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +964 -96
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +198 -0
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { provenanceSource, HEALTH_META, appSourceLabel, isDeclaredAppSource, APP_IDENTITY_ANNOTATION, type AppWorkload } from '../../utils/applications'
|
|
1
|
+
import { provenanceSource, HEALTH_META, appSourceLabel, isDeclaredAppSource, APP_IDENTITY_ANNOTATION, type AppRolloutSummary, type AppWorkload } from '../../utils/applications'
|
|
2
2
|
import { midTruncate } from '../../utils/format'
|
|
3
3
|
|
|
4
4
|
// Structured tooltip content for the application chips. Both render a short
|
|
@@ -59,6 +59,18 @@ export function VersionTooltip({ workloads }: { workloads: Pick<AppWorkload, 'na
|
|
|
59
59
|
)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export function RolloutSummaryTooltip({ summary }: { summary: AppRolloutSummary }) {
|
|
63
|
+
const details = summary.details.slice(0, 8)
|
|
64
|
+
return (
|
|
65
|
+
<div className="max-w-xs space-y-1">
|
|
66
|
+
{details.map((detail, index) => <div key={index}>{detail}</div>)}
|
|
67
|
+
{summary.details.length > details.length && (
|
|
68
|
+
<div className="text-theme-text-tertiary">+{summary.details.length - details.length} more</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
export function CategoryTooltip({
|
|
63
75
|
category,
|
|
64
76
|
addonReason,
|
|
@@ -76,6 +76,31 @@ function renderDetail(props: Partial<ApplicationDetailProps> = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
describe("ApplicationDetail shell", () => {
|
|
79
|
+
it("shows rollout activity in the application summary and workload matrix", () => {
|
|
80
|
+
const rolling: AppRow = {
|
|
81
|
+
...app,
|
|
82
|
+
workloads: app.workloads.map((workload, index) => index === 0 ? {
|
|
83
|
+
...workload,
|
|
84
|
+
rollout: {
|
|
85
|
+
phase: 'progressing',
|
|
86
|
+
active: true,
|
|
87
|
+
manual: false,
|
|
88
|
+
label: 'Rolling out',
|
|
89
|
+
detail: '2/3 updated · 3 available',
|
|
90
|
+
desired: 3,
|
|
91
|
+
updated: 2,
|
|
92
|
+
ready: 3,
|
|
93
|
+
available: 3,
|
|
94
|
+
},
|
|
95
|
+
} : workload),
|
|
96
|
+
}
|
|
97
|
+
const html = renderDetail({ app: rolling })
|
|
98
|
+
|
|
99
|
+
expect(html).toContain('Rolling out')
|
|
100
|
+
expect(html).toContain('1 workload affected · 4/4 ready')
|
|
101
|
+
expect(html).toContain('2/3 updated · 3 available')
|
|
102
|
+
})
|
|
103
|
+
|
|
79
104
|
it("preserves the built-in issue surface when the host slot is omitted", () => {
|
|
80
105
|
const html = renderDetail({ renderOverviewIssues: undefined });
|
|
81
106
|
|
|
@@ -31,7 +31,7 @@ import { EmptyState } from "../ui/EmptyState";
|
|
|
31
31
|
import { ResourceRefBadge } from "../ui/drawer-components";
|
|
32
32
|
import { TopologyGraph } from "../topology/TopologyGraph";
|
|
33
33
|
import { pluralize } from "../../utils/pluralize";
|
|
34
|
-
import {
|
|
34
|
+
import { kindToPluralWithGroup, refToSelectedResource } from "../../utils/navigation";
|
|
35
35
|
import {
|
|
36
36
|
batchRunParentNodes,
|
|
37
37
|
tagWorkloadOwnership,
|
|
@@ -67,6 +67,8 @@ import {
|
|
|
67
67
|
batchSignalForApp,
|
|
68
68
|
batchActivityForApp,
|
|
69
69
|
batchRuntimeForApp,
|
|
70
|
+
rolloutSummaryForApps,
|
|
71
|
+
rolloutDisplayHealth,
|
|
70
72
|
applicationDisplayHealth,
|
|
71
73
|
sourceReportedHealth,
|
|
72
74
|
sourceSyncHealth,
|
|
@@ -80,7 +82,7 @@ import {
|
|
|
80
82
|
} from "../../utils/applications";
|
|
81
83
|
import { PaneLoader } from "../ui/PaneLoader";
|
|
82
84
|
import { midTruncate } from "../../utils/format";
|
|
83
|
-
import { VersionTooltip, AppIdentityTooltip } from "./AppTooltips";
|
|
85
|
+
import { VersionTooltip, AppIdentityTooltip, RolloutSummaryTooltip } from "./AppTooltips";
|
|
84
86
|
import {
|
|
85
87
|
ProvenanceBadge,
|
|
86
88
|
ClassBadge,
|
|
@@ -359,6 +361,10 @@ export function ApplicationDetail({
|
|
|
359
361
|
);
|
|
360
362
|
const workloadClass = workloadClassOf(app.workload_class);
|
|
361
363
|
const batchRuntime = batchRuntimeForApp(app);
|
|
364
|
+
const rolloutSummary = useMemo(
|
|
365
|
+
() => rolloutSummaryForApps([{ ...app, workloads }]),
|
|
366
|
+
[app, workloads],
|
|
367
|
+
);
|
|
362
368
|
const overall = applicationDisplayHealth(app);
|
|
363
369
|
const verdictTone = HEALTH_META[overall].pill;
|
|
364
370
|
const verdictLabel =
|
|
@@ -545,11 +551,12 @@ export function ApplicationDetail({
|
|
|
545
551
|
onSelectWorkloadRun(parentWorkload, node);
|
|
546
552
|
return;
|
|
547
553
|
}
|
|
554
|
+
const group = topologyGroup(node);
|
|
548
555
|
onNavigateToResource?.({
|
|
549
|
-
kind:
|
|
556
|
+
kind: kindToPluralWithGroup(node.kind, group ?? ""),
|
|
550
557
|
namespace: ns,
|
|
551
558
|
name: node.name,
|
|
552
|
-
group
|
|
559
|
+
group,
|
|
553
560
|
});
|
|
554
561
|
},
|
|
555
562
|
[
|
|
@@ -702,8 +709,16 @@ export function ApplicationDetail({
|
|
|
702
709
|
{workloadClass === "job" ? (
|
|
703
710
|
<ContextFact label="Runtime">{batchRuntime.label}</ContextFact>
|
|
704
711
|
) : desired > 0 ? (
|
|
705
|
-
<ContextFact label="Ready">
|
|
706
|
-
|
|
712
|
+
<ContextFact label="Ready"><ReadyBar ready={ready} desired={desired} width="w-16" /></ContextFact>
|
|
713
|
+
) : null}
|
|
714
|
+
{workloadClass !== "job" && rolloutSummary ? (
|
|
715
|
+
<ContextFact label="Runtime">
|
|
716
|
+
<Tooltip content={<RolloutSummaryTooltip summary={rolloutSummary} />} delay={150}>
|
|
717
|
+
<span className="inline-flex items-center gap-1.5">
|
|
718
|
+
<StatusDot tone={mapHealthToTone(rolloutSummary.health)} />
|
|
719
|
+
{rolloutSummary.label}
|
|
720
|
+
</span>
|
|
721
|
+
</Tooltip>
|
|
707
722
|
</ContextFact>
|
|
708
723
|
) : null}
|
|
709
724
|
{(app.appVersion || versions.length > 0) && (
|
|
@@ -1074,6 +1089,10 @@ function ApplicationOverview({
|
|
|
1074
1089
|
app.runtimeHealth ??
|
|
1075
1090
|
worstHealth(workloads.map((workload) => workload.health)),
|
|
1076
1091
|
);
|
|
1092
|
+
const rolloutSummary = useMemo(
|
|
1093
|
+
() => rolloutSummaryForApps([{ ...app, workloads }]),
|
|
1094
|
+
[app, workloads],
|
|
1095
|
+
);
|
|
1077
1096
|
const hasDeliveryStatus = Boolean(
|
|
1078
1097
|
app.sourceStatus?.sync || app.sourceStatus?.health,
|
|
1079
1098
|
);
|
|
@@ -1116,11 +1135,17 @@ function ApplicationOverview({
|
|
|
1116
1135
|
value={
|
|
1117
1136
|
pureBatch
|
|
1118
1137
|
? batchRuntimeForApp(app).label
|
|
1119
|
-
:
|
|
1138
|
+
: rolloutSummary
|
|
1139
|
+
? runtimeHealth === 'healthy' || runtimeHealth === 'neutral'
|
|
1140
|
+
? rolloutSummary.label
|
|
1141
|
+
: `${HEALTH_META[runtimeHealth].label} · ${rolloutSummary.label}`
|
|
1142
|
+
: HEALTH_META[runtimeHealth].label
|
|
1120
1143
|
}
|
|
1121
1144
|
detail={
|
|
1122
1145
|
pureBatch
|
|
1123
1146
|
? batchStats.activeDetail
|
|
1147
|
+
: rolloutSummary
|
|
1148
|
+
? `${rolloutSummary.count} workload${rolloutSummary.count === 1 ? "" : "s"} affected · ${ready}/${desired} ready`
|
|
1124
1149
|
: desired > 0
|
|
1125
1150
|
? `${ready}/${desired} ready`
|
|
1126
1151
|
: "No desired replicas"
|
|
@@ -2023,6 +2048,12 @@ function workloadRuntimeStatus(workload: AppWorkload): {
|
|
|
2023
2048
|
label: string;
|
|
2024
2049
|
health: AppHealth;
|
|
2025
2050
|
} {
|
|
2051
|
+
if (workload.rollout && (workload.rollout.active || workload.rollout.phase === 'stalled')) {
|
|
2052
|
+
return {
|
|
2053
|
+
label: workload.rollout.label,
|
|
2054
|
+
health: rolloutDisplayHealth(workload.rollout, workload.health),
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2026
2057
|
const batch = workload.batch;
|
|
2027
2058
|
if (!batch) {
|
|
2028
2059
|
const health = healthOf(workload.health);
|
|
@@ -2039,6 +2070,7 @@ function workloadRuntimeStatus(workload: AppWorkload): {
|
|
|
2039
2070
|
}
|
|
2040
2071
|
|
|
2041
2072
|
function workloadRuntimeDetail(workload: AppWorkload): string {
|
|
2073
|
+
if (workload.rollout && (workload.rollout.active || workload.rollout.phase === 'stalled')) return workload.rollout.detail || `${workload.ready}/${workload.desired} ready`
|
|
2042
2074
|
const batch = workload.batch;
|
|
2043
2075
|
if (!batch) return `${workload.ready}/${workload.desired} ready`;
|
|
2044
2076
|
if ((batch.activeRuns ?? 0) > 0)
|
|
@@ -39,14 +39,29 @@ import {
|
|
|
39
39
|
foldAppGroups,
|
|
40
40
|
batchSignalForApp,
|
|
41
41
|
batchRuntimeForApp,
|
|
42
|
+
rolloutSummaryForApps,
|
|
42
43
|
type FoldedRow,
|
|
43
44
|
} from '../../utils/applications'
|
|
44
45
|
import { ReadyBar } from './ReadyBar'
|
|
45
46
|
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
|
|
46
|
-
import { AppIdentityTooltip, EnvHint } from './AppTooltips'
|
|
47
|
+
import { AppIdentityTooltip, EnvHint, RolloutSummaryTooltip } from './AppTooltips'
|
|
47
48
|
|
|
48
49
|
function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
|
|
49
|
-
if (workloadClass !== 'job')
|
|
50
|
+
if (workloadClass !== 'job') {
|
|
51
|
+
const rollout = rolloutSummaryForApps(apps)
|
|
52
|
+
if (!rollout) return <ReadyBar ready={ready} desired={desired} />
|
|
53
|
+
return (
|
|
54
|
+
<span className="inline-flex max-w-full items-center gap-3">
|
|
55
|
+
<ReadyBar ready={ready} desired={desired} />
|
|
56
|
+
<Tooltip content={<RolloutSummaryTooltip summary={rollout} />} delay={150}>
|
|
57
|
+
<span className="inline-flex min-w-0 items-center gap-1.5 text-xs text-theme-text-secondary">
|
|
58
|
+
<StatusDot tone={mapHealthToTone(rollout.health)} />
|
|
59
|
+
<span className="truncate">{rollout.label}</span>
|
|
60
|
+
</span>
|
|
61
|
+
</Tooltip>
|
|
62
|
+
</span>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
50
65
|
const runtimes = apps.map(batchRuntimeForApp)
|
|
51
66
|
const worst = runtimes.reduce((current, runtime) => (HEALTH_RANK[runtime.health] ?? 0) > (HEALTH_RANK[current.health] ?? 0) ? runtime : current, runtimes[0])
|
|
52
67
|
if (!worst) return <span className="text-xs text-theme-text-tertiary">Idle</span>
|
|
@@ -60,7 +75,7 @@ function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps:
|
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
function applicationRuntimeLabel(apps: AppRow[], workloadClass: AppWorkloadClass, health: AppHealth): string {
|
|
63
|
-
if (workloadClass !== 'job') return HEALTH_META[health].label
|
|
78
|
+
if (workloadClass !== 'job') return rolloutSummaryForApps(apps)?.detail || HEALTH_META[health].label
|
|
64
79
|
const labels = Array.from(new Set(apps.map((app) => batchRuntimeForApp(app).label)))
|
|
65
80
|
return labels.length === 1 ? labels[0] : 'Multiple batch states'
|
|
66
81
|
}
|
|
@@ -6,7 +6,8 @@ export function ReadyBar({ ready, desired, width = 'w-12' }: { ready: number; de
|
|
|
6
6
|
if (desired <= 0) {
|
|
7
7
|
return <span className="font-mono text-xs tabular-nums text-theme-text-tertiary">—</span>
|
|
8
8
|
}
|
|
9
|
-
const
|
|
9
|
+
const targetReady = Math.min(ready, desired)
|
|
10
|
+
const pct = Math.min(100, Math.round((targetReady / desired) * 100))
|
|
10
11
|
const ok = ready >= desired
|
|
11
12
|
// Text matches the bar's tier: amber for partial readiness, red only when
|
|
12
13
|
// nothing is ready — partial must not read as fully down.
|
|
@@ -16,7 +17,7 @@ export function ReadyBar({ ready, desired, width = 'w-12' }: { ready: number; de
|
|
|
16
17
|
<span className={`inline-block h-1.5 ${width} rounded-full bg-theme-hover`}>
|
|
17
18
|
<span className={`block h-1.5 rounded-full ${tier.bar}`} style={{ width: `${pct}%` }} />
|
|
18
19
|
</span>
|
|
19
|
-
<span className={`font-mono text-xs tabular-nums ${ok ? 'text-theme-text-secondary' : tier.text}`}>{
|
|
20
|
+
<span className={`font-mono text-xs tabular-nums ${ok ? 'text-theme-text-secondary' : tier.text}`}>{targetReady}/{desired || '—'}</span>
|
|
20
21
|
</span>
|
|
21
22
|
)
|
|
22
23
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
3
|
import {
|
|
3
4
|
buildSingleAppEntry,
|
|
4
5
|
searchTextForEntry,
|
|
5
6
|
foldAppGroups,
|
|
6
7
|
type AppRow,
|
|
7
8
|
type SingleAppEntry,
|
|
9
|
+
rolloutSummaryForApps,
|
|
8
10
|
} from '../../utils/applications'
|
|
11
|
+
import { ReadyBar } from './ReadyBar'
|
|
9
12
|
|
|
10
13
|
// Parity tests for the pure core the shared ApplicationsView is built on:
|
|
11
14
|
// buildSingleAppEntry (the extracted former buildEntry), the search-text helper,
|
|
@@ -116,6 +119,94 @@ describe('buildSingleAppEntry', () => {
|
|
|
116
119
|
})
|
|
117
120
|
})
|
|
118
121
|
|
|
122
|
+
describe('ReadyBar', () => {
|
|
123
|
+
it('caps target readiness during a surge', () => {
|
|
124
|
+
const html = renderToStaticMarkup(<ReadyBar ready={2} desired={1} />)
|
|
125
|
+
expect(html).toContain('1/1')
|
|
126
|
+
expect(html).not.toContain('2/1')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('rolloutSummaryForApps', () => {
|
|
131
|
+
it('surfaces a healthy surge rollout as activity rather than degradation', () => {
|
|
132
|
+
const summary = rolloutSummaryForApps([app({
|
|
133
|
+
workloads: [wl({
|
|
134
|
+
ready: 3,
|
|
135
|
+
desired: 3,
|
|
136
|
+
rollout: { phase: 'progressing', active: true, manual: false, label: 'Rolling out', detail: '2/3 updated · 3 available', desired: 3, updated: 2, ready: 3, available: 3 },
|
|
137
|
+
})],
|
|
138
|
+
})])
|
|
139
|
+
|
|
140
|
+
expect(summary).toMatchObject({ label: 'Rolling out', health: 'neutral', count: 1 })
|
|
141
|
+
expect(summary?.detail).toContain('2/3 updated')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('keeps rollout copy while preserving a worse workload health tone', () => {
|
|
145
|
+
const summary = rolloutSummaryForApps([app({
|
|
146
|
+
workloads: [wl({
|
|
147
|
+
health: 'unhealthy',
|
|
148
|
+
rollout: { phase: 'progressing', active: true, manual: false, label: 'Rolling out', desired: 3, updated: 1, ready: 0, available: 0 },
|
|
149
|
+
})],
|
|
150
|
+
})])
|
|
151
|
+
|
|
152
|
+
expect(summary).toMatchObject({ label: 'Rolling out', health: 'unhealthy' })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('escalates a stalled revision without changing stable app health data', () => {
|
|
156
|
+
const summary = rolloutSummaryForApps([app({
|
|
157
|
+
workloads: [wl({
|
|
158
|
+
rollout: { phase: 'stalled', active: false, manual: false, label: 'New revision cannot start', detail: 'ImagePullBackOff', desired: 1, updated: 0, ready: 1, available: 1 },
|
|
159
|
+
})],
|
|
160
|
+
})])
|
|
161
|
+
|
|
162
|
+
expect(summary).toMatchObject({ label: 'New revision cannot start', health: 'unhealthy' })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('names stalled work before the other pending changes in a grouped app', () => {
|
|
166
|
+
const summary = rolloutSummaryForApps([app({
|
|
167
|
+
workloads: [
|
|
168
|
+
wl({ name: 'broken', rollout: { phase: 'stalled', active: false, manual: false, label: 'Rollout stalled', desired: 1, updated: 0, ready: 1, available: 1 } }),
|
|
169
|
+
wl({ name: 'manual', rollout: { phase: 'paused', active: true, manual: false, label: 'Rollout paused', desired: 1, updated: 0, ready: 1, available: 1 } }),
|
|
170
|
+
],
|
|
171
|
+
})])
|
|
172
|
+
|
|
173
|
+
expect(summary).toMatchObject({ label: '1 stalled · 1 waiting', health: 'unhealthy', count: 2 })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not report zero waiting workloads when every change is stalled', () => {
|
|
177
|
+
const stalled = { phase: 'stalled' as const, active: false, manual: false, label: 'Rollout stalled', desired: 1, updated: 0, ready: 1, available: 1 }
|
|
178
|
+
const summary = rolloutSummaryForApps([app({
|
|
179
|
+
workloads: [wl({ name: 'one', rollout: stalled }), wl({ name: 'two', rollout: stalled })],
|
|
180
|
+
})])
|
|
181
|
+
|
|
182
|
+
expect(summary?.label).toBe('2 stalled')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('does not promote inactive rollout policies to app-level activity', () => {
|
|
186
|
+
const summary = rolloutSummaryForApps([app({
|
|
187
|
+
workloads: [
|
|
188
|
+
wl({ rollout: { phase: 'paused', active: false, manual: false, label: 'Rollout paused', desired: 1, updated: 1, ready: 0, available: 0 } }),
|
|
189
|
+
wl({ rollout: { phase: 'partition-reached', active: false, manual: false, label: 'Partition reached', desired: 3, updated: 2, ready: 2, available: 2 } }),
|
|
190
|
+
],
|
|
191
|
+
})])
|
|
192
|
+
|
|
193
|
+
expect(summary).toBeNull()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('caps grouped rollout details while preserving the full structured list', () => {
|
|
197
|
+
const summary = rolloutSummaryForApps([app({
|
|
198
|
+
workloads: Array.from({ length: 10 }, (_, index) => wl({
|
|
199
|
+
name: `worker-${index}`,
|
|
200
|
+
rollout: { phase: 'progressing', active: true, manual: false, label: 'Rolling out', desired: 1, updated: 0, ready: 1, available: 1 },
|
|
201
|
+
})),
|
|
202
|
+
})])
|
|
203
|
+
|
|
204
|
+
expect(summary).toMatchObject({ label: '10 updating', count: 10 })
|
|
205
|
+
expect(summary?.details).toHaveLength(10)
|
|
206
|
+
expect(summary?.detail).toContain('+2 more')
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
119
210
|
describe('searchTextForEntry (single)', () => {
|
|
120
211
|
it('matches on name, namespace, version, env, and workload kind', () => {
|
|
121
212
|
const e = buildSingleAppEntry(
|
|
@@ -243,7 +243,10 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
243
243
|
// context inline (per-row secondary line), and an extra hover
|
|
244
244
|
// tooltip would just overlap the search input.
|
|
245
245
|
<>
|
|
246
|
-
|
|
246
|
+
{/* flex (not a plain span): ClusterName is inline-flex and would
|
|
247
|
+
otherwise baseline-align inside this wrapper's line box,
|
|
248
|
+
sitting a couple px above the segment's vertical center. */}
|
|
249
|
+
<span className={variant === 'segment' ? 'flex items-center min-w-0 flex-1' : 'flex items-center min-w-0'}>
|
|
247
250
|
<ClusterName
|
|
248
251
|
name={currentName}
|
|
249
252
|
fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
|
|
@@ -4,7 +4,7 @@ import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, Termin
|
|
|
4
4
|
import { Tooltip } from '../ui/Tooltip';
|
|
5
5
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
6
6
|
import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
|
|
7
|
-
import {
|
|
7
|
+
import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTimingForDisplay } from './issue-timing';
|
|
8
8
|
import {
|
|
9
9
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
10
10
|
ISSUE_SEVERITY_HEADER_BAND_CLASS,
|
|
@@ -212,7 +212,9 @@ export function IssueRow({
|
|
|
212
212
|
const severity = normalizeIssueSeverity(issue.severity);
|
|
213
213
|
const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
|
|
214
214
|
const slotCtx = { issue, open };
|
|
215
|
-
const
|
|
215
|
+
const partialUnknown = issue.onset_coverage?.unknown ?? 0;
|
|
216
|
+
const partialOnset = Boolean(issue.first_seen && partialUnknown > 0);
|
|
217
|
+
const timing = issueTimingForDisplay(issue);
|
|
216
218
|
|
|
217
219
|
// Severity pill + age + timing chip. Rendered in two positions the container
|
|
218
220
|
// query toggles: inline at the row's right edge on a wide container, and on a
|
|
@@ -224,28 +226,19 @@ export function IssueRow({
|
|
|
224
226
|
{ISSUE_SEVERITY_LABEL[severity]}
|
|
225
227
|
</span>
|
|
226
228
|
{issue.first_seen ? (
|
|
227
|
-
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
>
|
|
233
|
-
<Clock className="h-3 w-3" aria-hidden />
|
|
234
|
-
{formatCompactAge(issue.first_seen)}
|
|
235
|
-
</time>
|
|
236
|
-
</Tooltip>
|
|
237
|
-
{timing ? (
|
|
238
|
-
<Tooltip content={timing.tooltip} delay={200}>
|
|
239
|
-
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
240
|
-
</Tooltip>
|
|
241
|
-
) : null}
|
|
242
|
-
</>
|
|
243
|
-
) : issue.onset_unknown ? (
|
|
244
|
-
<Tooltip content="Radar can confirm this issue is active, but current Kubernetes state does not reveal when it began." delay={200} wrapperClassName="shrink-0">
|
|
245
|
-
<span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
229
|
+
<Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
|
|
230
|
+
<time
|
|
231
|
+
dateTime={issue.first_seen}
|
|
232
|
+
className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
233
|
+
>
|
|
246
234
|
<Clock className="h-3 w-3" aria-hidden />
|
|
247
|
-
|
|
248
|
-
</
|
|
235
|
+
{partialOnset ? '≥' : ''}{formatCompactAge(issue.first_seen)}
|
|
236
|
+
</time>
|
|
237
|
+
</Tooltip>
|
|
238
|
+
) : null}
|
|
239
|
+
{timing ? (
|
|
240
|
+
<Tooltip content={timing.tooltip} delay={200}>
|
|
241
|
+
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
249
242
|
</Tooltip>
|
|
250
243
|
) : null}
|
|
251
244
|
</div>
|
|
@@ -437,7 +430,9 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
437
430
|
// present, else headline+detail) rather than a string that may never appear.
|
|
438
431
|
const visibleMessage = [headline, detail].filter(Boolean).join(' ');
|
|
439
432
|
const shownText = issue.cause ?? visibleMessage;
|
|
440
|
-
const
|
|
433
|
+
const partialUnknown = issue.onset_coverage?.unknown ?? 0;
|
|
434
|
+
const partialOnset = Boolean(issue.first_seen && partialUnknown > 0);
|
|
435
|
+
const timing = issueTimingForDisplay(issue);
|
|
441
436
|
const rawError = [
|
|
442
437
|
...new Set(
|
|
443
438
|
[issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
|
|
@@ -455,14 +450,24 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
455
450
|
if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
|
|
456
451
|
if (crash) meta.push(crash);
|
|
457
452
|
if (timing) {
|
|
453
|
+
if (partialOnset && issue.first_seen) {
|
|
454
|
+
meta.push(`some signals active at least ${formatRelativeAgeTime(issue.first_seen)}`);
|
|
455
|
+
}
|
|
458
456
|
meta.push(timing.meta);
|
|
457
|
+
if (!partialOnset && timing.kind === 'regression' && issue.first_seen) {
|
|
458
|
+
meta.push(`active at least ${formatRelativeAgeTime(issue.first_seen)}`);
|
|
459
|
+
}
|
|
460
|
+
} else if (partialOnset && issue.first_seen) {
|
|
461
|
+
const total = (issue.onset_coverage?.known ?? 0) + partialUnknown;
|
|
462
|
+
meta.push(`some signals active at least ${formatRelativeAgeTime(issue.first_seen)}; timing unknown for ${partialUnknown} of ${total} signals`);
|
|
459
463
|
} else if (issue.first_seen) {
|
|
460
|
-
meta.push(`
|
|
461
|
-
}
|
|
462
|
-
|
|
464
|
+
meta.push(`active at least ${formatRelativeAgeTime(issue.first_seen)}`);
|
|
465
|
+
}
|
|
466
|
+
if (issue.onset_unknown && !timing && !partialOnset) {
|
|
467
|
+
meta.push('exact onset unknown');
|
|
463
468
|
}
|
|
464
469
|
if (issue.first_seen) {
|
|
465
|
-
if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
470
|
+
if (issue.last_seen && (timing?.kind !== 'creation' || partialOnset)) meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
466
471
|
}
|
|
467
472
|
if (issue.change_context) meta.push(changeContextText(issue.change_context));
|
|
468
473
|
|
|
@@ -631,9 +636,13 @@ function DiagnosticContext({
|
|
|
631
636
|
// freshness, the two facts the compact "2h" hides.
|
|
632
637
|
function ageTitle(issue: Issue): string {
|
|
633
638
|
const parts: string[] = [];
|
|
634
|
-
const timing =
|
|
635
|
-
|
|
636
|
-
if (
|
|
639
|
+
const timing = issueTimingForDisplay(issue);
|
|
640
|
+
const partialUnknown = issue.onset_coverage?.unknown ?? 0;
|
|
641
|
+
if (timing && partialUnknown === 0) parts.push(timing.tooltip);
|
|
642
|
+
const firstSeenTitle = issueFirstSeenTitle(issue);
|
|
643
|
+
if (firstSeenTitle) parts.push(firstSeenTitle);
|
|
644
|
+
const resourceContext = issueResourceCreatedTitle(issue);
|
|
645
|
+
if (resourceContext) parts.push(resourceContext);
|
|
637
646
|
if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
638
647
|
return parts.join('\n');
|
|
639
648
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import { AlertTriangle } from 'lucide-react'
|
|
3
|
-
import {
|
|
3
|
+
import { kindToPluralWithGroup } from '../../utils/navigation'
|
|
4
4
|
import { IssueRow } from './IssuesView'
|
|
5
5
|
import { compareIssues, subjectRef, type Issue, type IssueResourceRef } from './types'
|
|
6
6
|
|
|
@@ -56,7 +56,8 @@ export function sameResource(a: IssueResourceRef, b: IssueResourceRef): boolean
|
|
|
56
56
|
return (
|
|
57
57
|
a.name === b.name &&
|
|
58
58
|
(a.namespace ?? '') === (b.namespace ?? '') &&
|
|
59
|
-
|
|
59
|
+
kindToPluralWithGroup(a.kind, a.group ?? '').toLowerCase() ===
|
|
60
|
+
kindToPluralWithGroup(b.kind, b.group ?? '').toLowerCase()
|
|
60
61
|
)
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export { IssueRow, IssuesView } from './IssuesView';
|
|
6
6
|
export type { IssueDiagnosisSource, IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
|
|
7
7
|
export { ResourceIssuesSection } from './ResourceIssuesSection';
|
|
8
|
-
export { issueTiming } from './issue-timing';
|
|
8
|
+
export { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming, issueTimingForDisplay } from './issue-timing';
|
|
9
9
|
export type { IssueTimingDisplay, IssueTimingDisplayKind } from './issue-timing';
|
|
10
10
|
export {
|
|
11
11
|
ISSUE_SEVERITIES,
|
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
isIssueSeverity,
|
|
14
14
|
subjectRef,
|
|
15
15
|
memberRef,
|
|
16
|
+
issueSortAnchor,
|
|
17
|
+
compareIssueSortAnchors,
|
|
16
18
|
} from './types';
|
|
17
19
|
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
18
20
|
export {
|