@skyhook-io/k8s-ui 1.8.7 → 1.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +4 -1
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsTableView.tsx +46 -45
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +41 -5
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/diagnostic.ts +22 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/types.ts +18 -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 +20 -81
- 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/topology/TopologyControls.tsx +90 -14
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +26 -8
- 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 +42 -0
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.8",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"yaml": ">=2.0.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@types/node": "^26.0.
|
|
88
|
+
"@types/node": "^26.0.1",
|
|
89
89
|
"@types/react": "^19.2.17",
|
|
90
90
|
"@types/react-dom": "^19.2.3",
|
|
91
91
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"clsx": "^2.1.1",
|
|
96
96
|
"diff": "^9.0.0",
|
|
97
97
|
"elkjs": "^0.11.1",
|
|
98
|
-
"lucide-react": "^1.
|
|
98
|
+
"lucide-react": "^1.22.0",
|
|
99
99
|
"react": "^19.2.7",
|
|
100
100
|
"react-dom": "^19.2.7",
|
|
101
101
|
"typescript": "^6.0.2",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from 'react'
|
|
1
|
+
import { useMemo, type ReactNode } from 'react'
|
|
2
2
|
import { type AppRow, buildSingleAppEntry } from '../../utils/applications'
|
|
3
3
|
import { ApplicationsView } from './ApplicationsView'
|
|
4
4
|
|
|
@@ -11,9 +11,11 @@ import { ApplicationsView } from './ApplicationsView'
|
|
|
11
11
|
export interface ApplicationsListProps {
|
|
12
12
|
apps: AppRow[]
|
|
13
13
|
onSelect: (key: string) => void
|
|
14
|
+
/** Leading element in the header actions (e.g. a freshness control). */
|
|
15
|
+
headerActions?: ReactNode
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
|
|
18
|
+
export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
|
|
17
19
|
// Env tokens this CLUSTER proved (identity classifications on the wire) feed
|
|
18
20
|
// the namespace heuristic, so sibling-less rows in discovered env namespaces
|
|
19
21
|
// still label without any hardcoded vocabulary.
|
|
@@ -25,6 +27,7 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
|
|
|
25
27
|
variant="single"
|
|
26
28
|
entries={entries}
|
|
27
29
|
onSelect={onSelect}
|
|
30
|
+
headerActions={headerActions}
|
|
28
31
|
title="Applications"
|
|
29
32
|
description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
|
|
30
33
|
/>
|
|
@@ -97,9 +97,11 @@ export interface ApplicationsViewProps {
|
|
|
97
97
|
/** Rendered instead of the built-in EmptyState when there are zero entries
|
|
98
98
|
* pre-filter (the fleet host injects a coverage/offline-aware empty). */
|
|
99
99
|
emptySlot?: ReactNode
|
|
100
|
+
/** Leading element in the header actions cluster (e.g. a freshness control). */
|
|
101
|
+
headerActions?: ReactNode
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot }: ApplicationsViewProps) {
|
|
104
|
+
export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
|
|
103
105
|
const [textFilter, setTextFilter] = useState('')
|
|
104
106
|
const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
|
|
105
107
|
const [fEnv, setFEnv] = useState<Set<string>>(new Set())
|
|
@@ -294,6 +296,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
294
296
|
description={description}
|
|
295
297
|
actions={
|
|
296
298
|
<>
|
|
299
|
+
{headerActions}
|
|
297
300
|
<SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
|
|
298
301
|
{healthTile('unhealthy', 'error')}
|
|
299
302
|
{healthTile('degraded', 'warning')}
|
|
@@ -58,6 +58,15 @@ export interface ClusterSwitcherProps {
|
|
|
58
58
|
errorSlot?: ReactNode
|
|
59
59
|
className?: string
|
|
60
60
|
align?: 'left' | 'right'
|
|
61
|
+
/**
|
|
62
|
+
* 'chip' (default) renders a self-contained bordered pill. 'segment' renders
|
|
63
|
+
* a borderless label+value cell for embedding in a shared bordered container
|
|
64
|
+
* (the unified cluster+namespace scope control) — no border/background/min-w
|
|
65
|
+
* of its own, with an optional muted {@link label} before the value.
|
|
66
|
+
*/
|
|
67
|
+
variant?: 'chip' | 'segment'
|
|
68
|
+
/** Muted label shown before the value in the 'segment' variant (e.g. "Cluster"). */
|
|
69
|
+
label?: string
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
// Trigger width cap. With middle-truncation kicking in, this is a
|
|
@@ -83,6 +92,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
83
92
|
errorSlot,
|
|
84
93
|
className = '',
|
|
85
94
|
align = 'left',
|
|
95
|
+
variant = 'chip',
|
|
96
|
+
label,
|
|
86
97
|
}, ref) => {
|
|
87
98
|
const [isOpen, setIsOpen] = useState(false)
|
|
88
99
|
const [search, setSearch] = useState('')
|
|
@@ -195,14 +206,21 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
195
206
|
type="button"
|
|
196
207
|
onClick={() => setIsOpen(v => !v)}
|
|
197
208
|
disabled={disabled || loading}
|
|
198
|
-
className={
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
className={
|
|
210
|
+
variant === 'segment'
|
|
211
|
+
? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] text-[13px] font-medium
|
|
212
|
+
text-theme-text-primary hover:bg-theme-hover transition-colors cursor-pointer
|
|
213
|
+
disabled:opacity-50 disabled:cursor-not-allowed`
|
|
214
|
+
: `flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
|
|
215
|
+
bg-theme-elevated border border-theme-border rounded text-sm font-medium
|
|
216
|
+
text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
|
|
217
|
+
transition-colors cursor-pointer
|
|
218
|
+
disabled:opacity-50 disabled:cursor-not-allowed`
|
|
219
|
+
}
|
|
205
220
|
>
|
|
221
|
+
{label && (
|
|
222
|
+
<span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
|
|
223
|
+
)}
|
|
206
224
|
{loading ? (
|
|
207
225
|
<>
|
|
208
226
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
@@ -220,7 +238,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
220
238
|
<ClusterName
|
|
221
239
|
name={currentName}
|
|
222
240
|
fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
|
|
223
|
-
className={TRIGGER_NAME_MAX_WIDTH}
|
|
241
|
+
className={variant === 'segment' ? 'min-w-0 max-w-[214px]' : TRIGGER_NAME_MAX_WIDTH}
|
|
224
242
|
noTooltip={isOpen}
|
|
225
243
|
/>
|
|
226
244
|
{currentSourceLabel && (
|
|
@@ -240,7 +258,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
240
258
|
)}
|
|
241
259
|
</>
|
|
242
260
|
)}
|
|
243
|
-
<ChevronDown className={`w-3 h-3
|
|
261
|
+
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${variant === 'segment' ? '' : 'ml-auto'} ${isOpen ? 'rotate-180' : ''}`} />
|
|
244
262
|
</button>
|
|
245
263
|
|
|
246
264
|
{isOpen && (
|
|
@@ -33,7 +33,6 @@ import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
|
|
|
33
33
|
import { DistributionBar } from '../ui/DistributionBar'
|
|
34
34
|
import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
|
|
35
35
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
36
|
-
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
37
36
|
import { getGitOpsResourceStatus } from './detail-helpers'
|
|
38
37
|
import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
|
|
39
38
|
import { toggleSet } from './GitOpsGraphFilterRail'
|
|
@@ -192,8 +191,13 @@ export interface GitOpsTableViewProps {
|
|
|
192
191
|
// the Scope-section mode tabs and the empty-state check.
|
|
193
192
|
counts: Record<string, number>
|
|
194
193
|
countsUnavailable?: string[]
|
|
195
|
-
//
|
|
194
|
+
// @deprecated Superseded by `freshnessSlot` — kept for source compatibility
|
|
195
|
+
// with existing consumers; no longer drives any affordance here.
|
|
196
196
|
onRefresh?: () => void
|
|
197
|
+
// Host-injected freshness/liveness control (e.g. a <FreshnessControl>),
|
|
198
|
+
// rendered leading the header actions. The host owns the mode + data, so this
|
|
199
|
+
// shared table makes no assumption about whether the view auto-updates.
|
|
200
|
+
freshnessSlot?: ReactNode
|
|
197
201
|
// Row click — caller routes to its own detail page. When the host also
|
|
198
202
|
// passes `rowHrefFor`, the callback receives the MouseEvent so it can
|
|
199
203
|
// `preventDefault()` for same-tree nav (e.g. react-router) or skip the
|
|
@@ -275,7 +279,7 @@ export function GitOpsTableView({
|
|
|
275
279
|
error,
|
|
276
280
|
counts,
|
|
277
281
|
countsUnavailable,
|
|
278
|
-
|
|
282
|
+
freshnessSlot,
|
|
279
283
|
onRowClick,
|
|
280
284
|
rowHrefFor,
|
|
281
285
|
onDestinationClick,
|
|
@@ -317,15 +321,19 @@ export function GitOpsTableView({
|
|
|
317
321
|
})
|
|
318
322
|
}, [])
|
|
319
323
|
const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
|
|
320
|
-
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'urgency', dir: 'asc' })
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
// Clicking a column sorts by it (starting at the column's natural direction —
|
|
326
|
-
// e.g. last-sync newest-first); clicking the active column reverses.
|
|
324
|
+
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>({ key: 'urgency', dir: 'asc' })
|
|
325
|
+
// 3-state cycle: natural direction → reversed → off. The first click uses each
|
|
326
|
+
// column's natural direction (SORT_DEFAULT_DIR — e.g. Last Sync is newest-first)
|
|
327
|
+
// so the header cycle agrees with the tile-mode sort menu, which seeds the same
|
|
328
|
+
// default. "Off" (null) falls back to the urgency/health-worst-first ordering.
|
|
327
329
|
const onSort = useCallback(
|
|
328
|
-
(key: SortKey) =>
|
|
330
|
+
(key: SortKey) =>
|
|
331
|
+
setSort((prev) => {
|
|
332
|
+
const natural = SORT_DEFAULT_DIR[key]
|
|
333
|
+
if (!prev || prev.key !== key) return { key, dir: natural }
|
|
334
|
+
if (prev.dir === natural) return { key, dir: natural === 'asc' ? 'desc' : 'asc' }
|
|
335
|
+
return null
|
|
336
|
+
}),
|
|
329
337
|
[],
|
|
330
338
|
)
|
|
331
339
|
|
|
@@ -453,7 +461,8 @@ export function GitOpsTableView({
|
|
|
453
461
|
}
|
|
454
462
|
return true
|
|
455
463
|
})
|
|
456
|
-
|
|
464
|
+
const eff = sort ?? { key: 'urgency' as SortKey, dir: 'asc' as SortDir }
|
|
465
|
+
return [...rows].sort((a, b) => compareRows(a, b, eff.key) * (eff.dir === 'asc' ? 1 : -1))
|
|
457
466
|
}, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
|
|
458
467
|
|
|
459
468
|
const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
|
|
@@ -596,19 +605,24 @@ export function GitOpsTableView({
|
|
|
596
605
|
icon={GitBranch}
|
|
597
606
|
title="GitOps"
|
|
598
607
|
description="Applications and reconciliations with source, destination, sync, and health state."
|
|
599
|
-
actions={
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
608
|
+
actions={
|
|
609
|
+
<>
|
|
610
|
+
{freshnessSlot}
|
|
611
|
+
{summaryTiles.map((tile) => (
|
|
612
|
+
<SummaryTile
|
|
613
|
+
key={tile.key}
|
|
614
|
+
label={tile.label}
|
|
615
|
+
value={tile.value}
|
|
616
|
+
tone={tile.tone}
|
|
617
|
+
active={tile.active}
|
|
618
|
+
onClick={() => {
|
|
619
|
+
if (tile.active) tile.clear?.()
|
|
620
|
+
else tile.apply?.()
|
|
621
|
+
}}
|
|
622
|
+
/>
|
|
623
|
+
))}
|
|
624
|
+
</>
|
|
625
|
+
}
|
|
612
626
|
/>
|
|
613
627
|
</div>
|
|
614
628
|
<div
|
|
@@ -678,7 +692,7 @@ export function GitOpsTableView({
|
|
|
678
692
|
pattern); tile mode has no headers, so it keeps a compact sort
|
|
679
693
|
control wired to the same sort state. */}
|
|
680
694
|
{viewMode === 'tiles' && (
|
|
681
|
-
<GitOpsSortMenu sortKey={sort
|
|
695
|
+
<GitOpsSortMenu sortKey={sort?.key ?? 'urgency'} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
|
|
682
696
|
)}
|
|
683
697
|
{labels.length > 0 && (
|
|
684
698
|
<LabelsDropdown
|
|
@@ -727,19 +741,6 @@ export function GitOpsTableView({
|
|
|
727
741
|
<GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
|
|
728
742
|
<GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
|
|
729
743
|
</div>
|
|
730
|
-
{onRefresh && (
|
|
731
|
-
<Tooltip content="Refresh GitOps resources">
|
|
732
|
-
<button
|
|
733
|
-
type="button"
|
|
734
|
-
onClick={triggerRefresh}
|
|
735
|
-
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
736
|
-
>
|
|
737
|
-
{refreshPhase === 'success'
|
|
738
|
-
? <Check className="h-3.5 w-3.5 text-emerald-500" />
|
|
739
|
-
: <RefreshCw className={clsx('h-3.5 w-3.5', (refreshPhase === 'spinning' || loading) && 'animate-spin')} />}
|
|
740
|
-
</button>
|
|
741
|
-
</Tooltip>
|
|
742
|
-
)}
|
|
743
744
|
</div>
|
|
744
745
|
</div>
|
|
745
746
|
|
|
@@ -1187,7 +1188,7 @@ function GitOpsTable({
|
|
|
1187
1188
|
pendingRowActions,
|
|
1188
1189
|
}: {
|
|
1189
1190
|
rows: GitOpsRow[]
|
|
1190
|
-
sort: { key: SortKey; dir: SortDir }
|
|
1191
|
+
sort: { key: SortKey; dir: SortDir } | null
|
|
1191
1192
|
onSort: (key: SortKey) => void
|
|
1192
1193
|
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1193
1194
|
hrefFor?: (row: GitOpsRow) => string
|
|
@@ -1202,13 +1203,13 @@ function GitOpsTable({
|
|
|
1202
1203
|
<table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
|
|
1203
1204
|
<thead className="sticky top-0 z-10 bg-theme-base">
|
|
1204
1205
|
<tr>
|
|
1205
|
-
<SortableTh label="Application" sortKey="name" activeKey={sort
|
|
1206
|
-
<SortableTh label="Project" sortKey="project" activeKey={sort
|
|
1207
|
-
<SortableTh label="Sync" sortKey="sync" activeKey={sort
|
|
1208
|
-
<SortableTh label="Health" sortKey="health" activeKey={sort
|
|
1206
|
+
<SortableTh label="Application" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
|
|
1207
|
+
<SortableTh label="Project" sortKey="project" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
|
|
1208
|
+
<SortableTh label="Sync" sortKey="sync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
|
|
1209
|
+
<SortableTh label="Health" sortKey="health" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
|
|
1209
1210
|
<th className={clsx(TH_CLASS, showDestination ? 'w-[20%]' : 'w-[28%]')}>Source</th>
|
|
1210
1211
|
{showDestination && <th className={clsx(TH_CLASS, 'w-[14%]')}>Destination</th>}
|
|
1211
|
-
<SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort
|
|
1212
|
+
<SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[10%]" />
|
|
1212
1213
|
{showActions && (
|
|
1213
1214
|
<th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
|
|
1214
1215
|
<span className="sr-only">Actions</span>
|
|
@@ -56,6 +56,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
|
|
|
56
56
|
const operationFailure = (insight.issues ?? []).find(
|
|
57
57
|
(i) => i.severity === 'critical' && i.scope === 'operation' && i.stuck,
|
|
58
58
|
)
|
|
59
|
+
const operationTooltipMessage = summary.rawOperationMessage || summary.operationMessage
|
|
59
60
|
return (
|
|
60
61
|
<div className="border-b border-theme-border bg-theme-base px-4 py-2">
|
|
61
62
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
@@ -87,7 +88,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
|
|
|
87
88
|
(parsed cause, retry count, raw message) so the strip stays a
|
|
88
89
|
calm orientation row instead of duplicating the error three times. */}
|
|
89
90
|
{operation && summary.operationMessage && isInFlightPhase(operation) && (
|
|
90
|
-
<Tooltip content={
|
|
91
|
+
<Tooltip content={operationTooltipMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
|
|
91
92
|
<span className="block truncate text-[11px] text-theme-text-secondary">
|
|
92
93
|
{summary.operationMessage}
|
|
93
94
|
</span>
|
|
@@ -424,6 +425,8 @@ function GitOpsFailureCard({
|
|
|
424
425
|
const [showRaw, setShowRaw] = useState(false)
|
|
425
426
|
const stuck = !!issue.stuck
|
|
426
427
|
const ref = issue.refs?.[0]
|
|
428
|
+
const rawControllerMessage = issue.rawMessage || issue.message
|
|
429
|
+
const rawControllerLabel = issue.rawMessage ? 'raw controller error' : 'controller message'
|
|
427
430
|
// Title prioritizes the parsed cause's first sentence. Without parsing we
|
|
428
431
|
// get the bare phase ("Failed") which alone tells the user nothing — fall
|
|
429
432
|
// back to the first sentence of the raw message in that case so something
|
|
@@ -486,12 +489,12 @@ function GitOpsFailureCard({
|
|
|
486
489
|
className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
|
|
487
490
|
>
|
|
488
491
|
{showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
489
|
-
{showRaw ?
|
|
492
|
+
{showRaw ? `Hide ${rawControllerLabel}` : `Show ${rawControllerLabel}`}
|
|
490
493
|
</button>
|
|
491
494
|
</div>
|
|
492
495
|
{showRaw && (
|
|
493
496
|
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border border-theme-border bg-theme-base px-3 py-2 font-mono text-[11px] text-theme-text-secondary">
|
|
494
|
-
{
|
|
497
|
+
{rawControllerMessage}
|
|
495
498
|
</pre>
|
|
496
499
|
)}
|
|
497
500
|
</div>
|
|
@@ -609,6 +612,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
609
612
|
const t = severityTone(issue.severity)
|
|
610
613
|
const ref = issue.refs?.[0]
|
|
611
614
|
const actionable = !!(onSelectIssue && ref)
|
|
615
|
+
const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
|
|
612
616
|
return (
|
|
613
617
|
<button
|
|
614
618
|
key={`${issue.reason}-${index}`}
|
|
@@ -628,6 +632,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
628
632
|
</div>
|
|
629
633
|
<p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
|
|
630
634
|
{issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
|
|
635
|
+
{rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
|
|
631
636
|
{issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
|
|
632
637
|
</div>
|
|
633
638
|
{actionable && ref && (
|
|
@@ -1112,7 +1117,7 @@ function ChangeRow({
|
|
|
1112
1117
|
live health message — operators chasing a broken sync want
|
|
1113
1118
|
the failure reason on the same row, not in a drawer. */}
|
|
1114
1119
|
{change.syncError && (
|
|
1115
|
-
<Tooltip content={change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
|
|
1120
|
+
<Tooltip content={change.rawSyncError || change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
|
|
1116
1121
|
<span className="line-clamp-3 text-xs text-red-600 dark:text-red-400">{change.syncError}</span>
|
|
1117
1122
|
</Tooltip>
|
|
1118
1123
|
)}
|
|
@@ -1386,7 +1391,9 @@ function HistoryRows({
|
|
|
1386
1391
|
</Tooltip>
|
|
1387
1392
|
)}
|
|
1388
1393
|
{item.message && (
|
|
1389
|
-
<
|
|
1394
|
+
<Tooltip content={item.rawMessage || item.message} delay={400} wrapperClassName="mt-0.5 block max-w-full">
|
|
1395
|
+
<div className={clsx('line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
|
|
1396
|
+
</Tooltip>
|
|
1390
1397
|
)}
|
|
1391
1398
|
</div>
|
|
1392
1399
|
</li>
|
|
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from
|
|
|
2
2
|
import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
|
|
3
3
|
import { ClusterName, EmptyState } from '../ui';
|
|
4
4
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
5
|
-
import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle } from './diagnostic';
|
|
5
|
+
import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
|
|
6
6
|
import {
|
|
7
7
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
8
8
|
ISSUE_SEVERITY_LABEL,
|
|
@@ -209,6 +209,17 @@ export function IssueRow({
|
|
|
209
209
|
<span className="shrink-0 tabular-nums">{affected}</span>
|
|
210
210
|
</>
|
|
211
211
|
) : null}
|
|
212
|
+
{issue.incident_parent ? (
|
|
213
|
+
<>
|
|
214
|
+
<span aria-hidden>·</span>
|
|
215
|
+
{/* Non-interactive signal (the header is the toggle — a nested
|
|
216
|
+
button would be invalid); the clickable link lives in the body. */}
|
|
217
|
+
<span className="min-w-0 truncate text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence ?? '')}>
|
|
218
|
+
↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
|
|
219
|
+
<span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
|
|
220
|
+
</span>
|
|
221
|
+
</>
|
|
222
|
+
) : null}
|
|
212
223
|
{renderMeta?.(slotCtx)}
|
|
213
224
|
</div>
|
|
214
225
|
</div>
|
|
@@ -249,6 +260,21 @@ export function IssueRow({
|
|
|
249
260
|
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
|
|
250
261
|
<div className="flex flex-col gap-4">
|
|
251
262
|
<Diagnosis issue={issue} />
|
|
263
|
+
{issue.incident_parent ? (
|
|
264
|
+
<section className="flex flex-col gap-1">
|
|
265
|
+
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
266
|
+
{incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
|
|
267
|
+
{issue.incident_parent.confidence ? (
|
|
268
|
+
<span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence)}>
|
|
269
|
+
{issue.incident_parent.confidence} confidence
|
|
270
|
+
</span>
|
|
271
|
+
) : null}
|
|
272
|
+
</h4>
|
|
273
|
+
<ul className="flex flex-col gap-px">
|
|
274
|
+
<ResourceLine refForLink={memberRef(issue, issue.incident_parent.ref)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
275
|
+
</ul>
|
|
276
|
+
</section>
|
|
277
|
+
) : null}
|
|
252
278
|
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
253
279
|
<div className="border-t border-theme-border/70 pt-3">
|
|
254
280
|
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
@@ -274,7 +300,11 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
274
300
|
const { headline, detail } = issueMessageParts(issue);
|
|
275
301
|
// When the issue carries a parsed plain-English cause, lead with it. The raw
|
|
276
302
|
// detector message is kept below as de-emphasized detail.
|
|
277
|
-
const
|
|
303
|
+
const visibleMessage = [headline, detail].filter(Boolean).join(' ');
|
|
304
|
+
const rawMessage = issue.raw_message ?? (issue.cause ? issue.message ?? '' : '');
|
|
305
|
+
const shouldShowRawMessage = issue.cause
|
|
306
|
+
? Boolean(rawMessage)
|
|
307
|
+
: Boolean(issue.raw_message && issue.raw_message !== visibleMessage);
|
|
278
308
|
return (
|
|
279
309
|
<section className="flex flex-col gap-1">
|
|
280
310
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
|
|
@@ -309,9 +339,9 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
309
339
|
{issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
|
|
310
340
|
</p>
|
|
311
341
|
) : null}
|
|
312
|
-
{/* Raw detector message, de-emphasized
|
|
313
|
-
|
|
314
|
-
{
|
|
342
|
+
{/* Raw detector message, de-emphasized so precise controller/kubelet text
|
|
343
|
+
remains available without leading the diagnosis. */}
|
|
344
|
+
{shouldShowRawMessage ? (
|
|
315
345
|
<p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
|
|
316
346
|
) : null}
|
|
317
347
|
{crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
|
|
@@ -384,6 +414,7 @@ function DiagnosticContext({
|
|
|
384
414
|
key={`${related.ref.group ?? ''}/${related.ref.kind}/${related.ref.namespace ?? ''}/${related.ref.name}#${relIdx}`}
|
|
385
415
|
label="Related"
|
|
386
416
|
refForLink={memberRef(issue, related.ref)}
|
|
417
|
+
count={related.count}
|
|
387
418
|
resourceHref={resourceHref}
|
|
388
419
|
onResourceClick={onResourceClick}
|
|
389
420
|
ResourceLinkIcon={ResourceLinkIcon}
|
|
@@ -478,12 +509,14 @@ function AffectedResources({
|
|
|
478
509
|
function ResourceLine({
|
|
479
510
|
label,
|
|
480
511
|
refForLink,
|
|
512
|
+
count,
|
|
481
513
|
resourceHref,
|
|
482
514
|
onResourceClick,
|
|
483
515
|
ResourceLinkIcon,
|
|
484
516
|
}: {
|
|
485
517
|
label?: string;
|
|
486
518
|
refForLink: IssueResourceRef;
|
|
519
|
+
count?: number;
|
|
487
520
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
488
521
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
489
522
|
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
@@ -498,6 +531,9 @@ function ResourceLine({
|
|
|
498
531
|
{r.namespace ? `${r.namespace} / ` : ''}
|
|
499
532
|
{r.name}
|
|
500
533
|
</span>
|
|
534
|
+
{count && count > 1 ? (
|
|
535
|
+
<span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums" title={`${count} affected resources grouped under this issue`}>{count} affected</span>
|
|
536
|
+
) : null}
|
|
501
537
|
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
502
538
|
</>
|
|
503
539
|
);
|
|
@@ -113,6 +113,9 @@ function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceCli
|
|
|
113
113
|
{rel.ref.namespace ? `${rel.ref.namespace} / ` : ''}
|
|
114
114
|
{rel.ref.name}
|
|
115
115
|
</span>
|
|
116
|
+
{rel.count && rel.count > 1 ? (
|
|
117
|
+
<span className="ml-1 tabular-nums" title={`${rel.count} affected resources grouped under this issue`}>· {rel.count} affected</span>
|
|
118
|
+
) : null}
|
|
116
119
|
</>
|
|
117
120
|
)
|
|
118
121
|
return (
|
|
@@ -42,11 +42,33 @@ export function diagnosticFactLabel(type: string): string {
|
|
|
42
42
|
return 'Affected workloads';
|
|
43
43
|
case 'pvc_blast_radius':
|
|
44
44
|
return 'Blocked pods';
|
|
45
|
+
case 'apiservice_hpa':
|
|
46
|
+
return 'Stalled autoscalers';
|
|
47
|
+
case 'secret_not_ready':
|
|
48
|
+
return 'Dependent pods';
|
|
45
49
|
default:
|
|
46
50
|
return type.replace(/_/g, ' ');
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
// Operator-facing lead-in for the symptom→root pointer chip. Honest per fact
|
|
55
|
+
// type: a declared PVC/Secret edge is a cause; a co-located node is only
|
|
56
|
+
// "related" (node pressure can be a shared victim, not the root), so it must not
|
|
57
|
+
// claim "caused by".
|
|
58
|
+
export function incidentParentLabel(factType?: string, confidence?: string): string {
|
|
59
|
+
switch (factType) {
|
|
60
|
+
case 'pvc_blast_radius':
|
|
61
|
+
case 'secret_not_ready':
|
|
62
|
+
return 'Caused by';
|
|
63
|
+
case 'apiservice_hpa':
|
|
64
|
+
return 'Likely cause';
|
|
65
|
+
case 'node_blast_radius':
|
|
66
|
+
return 'Related';
|
|
67
|
+
default:
|
|
68
|
+
return confidence === 'high' ? 'Caused by' : 'Possible cause';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
// Plain-language gloss for the confidence chip's tooltip — the operator should
|
|
51
73
|
// know a medium link is "these are co-located, the node may be the cause", not a
|
|
52
74
|
// proven fact.
|
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
subjectRef,
|
|
13
13
|
memberRef,
|
|
14
14
|
} from './types';
|
|
15
|
-
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
15
|
+
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
16
16
|
export {
|
|
17
17
|
ISSUE_SEVERITY_LABEL,
|
|
18
18
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { renderToString } from 'react-dom/server'
|
|
2
4
|
import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
|
|
3
5
|
import { categoryLabel, groupLabel, groupBadgeClass } from './severity'
|
|
6
|
+
import { IssueRow } from './IssuesView'
|
|
4
7
|
|
|
5
8
|
const base: Issue = {
|
|
6
9
|
id: 'id-0',
|
|
@@ -105,3 +108,21 @@ describe('image-pull message normalization', () => {
|
|
|
105
108
|
expect(parts.detail).toBe('')
|
|
106
109
|
})
|
|
107
110
|
})
|
|
111
|
+
|
|
112
|
+
describe('IssueRow diagnosis raw messages', () => {
|
|
113
|
+
it('shows raw_message when cleaned issue copy has no parsed cause', () => {
|
|
114
|
+
const issue = mk({
|
|
115
|
+
category: 'gitops_operation_failed',
|
|
116
|
+
category_group: 'configuration',
|
|
117
|
+
severity: 'critical',
|
|
118
|
+
reason: 'OperationFailed',
|
|
119
|
+
message: 'app path does not exist',
|
|
120
|
+
raw_message: 'rpc error: code = Unknown desc = app path does not exist',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const html = renderToString(createElement(IssueRow, { issue, open: true, onToggle: () => undefined, as: 'div' }))
|
|
124
|
+
|
|
125
|
+
expect(html).toContain('app path does not exist')
|
|
126
|
+
expect(html).toContain('rpc error: code = Unknown desc = app path does not exist')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -71,10 +71,26 @@ export interface IssueDiagnosticIssueRef {
|
|
|
71
71
|
reason?: string;
|
|
72
72
|
category?: string;
|
|
73
73
|
severity?: IssueSeverity;
|
|
74
|
+
/** How many affected resources fold into this linked issue from the root's
|
|
75
|
+
* perspective (e.g. 5 of a PVC's mounting pods under one Deployment issue).
|
|
76
|
+
* Absent when the link covers a single resource. */
|
|
77
|
+
count?: number;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
export type IssueDiagnosticConfidence = 'high' | 'medium' | 'low';
|
|
77
81
|
|
|
82
|
+
/** Reverse pointer from a symptom issue to the root issue that explains it
|
|
83
|
+
* (the inverse of diagnostic_context's root→symptom facts). Set only when a
|
|
84
|
+
* single root is unambiguous. `ref` is the parent subject for display + deep
|
|
85
|
+
* navigation (thread the issue's cluster_id onto it via memberRef). */
|
|
86
|
+
export interface IssueIncidentParent {
|
|
87
|
+
id: string;
|
|
88
|
+
ref: IssueResourceRef;
|
|
89
|
+
category?: string;
|
|
90
|
+
confidence?: IssueDiagnosticConfidence;
|
|
91
|
+
fact_type?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
export interface IssueDiagnosticFact {
|
|
79
95
|
type: string;
|
|
80
96
|
message?: string;
|
|
@@ -158,6 +174,7 @@ export interface Issue {
|
|
|
158
174
|
|
|
159
175
|
reason: string;
|
|
160
176
|
message?: string;
|
|
177
|
+
raw_message?: string;
|
|
161
178
|
/** Parsed domain diagnosis: plain-English cause, suggested next step, and
|
|
162
179
|
* an optional structured one-click fix.
|
|
163
180
|
* Server-emitted (omitempty); empty for issues without a parser. */
|
|
@@ -182,6 +199,7 @@ export interface Issue {
|
|
|
182
199
|
members?: IssueResourceRef[];
|
|
183
200
|
members_truncated?: boolean;
|
|
184
201
|
diagnostic_context?: IssueDiagnosticContext;
|
|
202
|
+
incident_parent?: IssueIncidentParent;
|
|
185
203
|
change_context?: IssueChangeContext;
|
|
186
204
|
|
|
187
205
|
// Pod crash context carried from the representative member.
|