@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/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
|
/>
|
|
@@ -56,6 +56,7 @@ const HEALTH_TONE: Record<AppHealth, FacetTone> = {
|
|
|
56
56
|
unhealthy: 'error',
|
|
57
57
|
degraded: 'warning',
|
|
58
58
|
healthy: 'success',
|
|
59
|
+
neutral: 'info', // Idle — sky, calm
|
|
59
60
|
unknown: 'neutral',
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -96,9 +97,11 @@ export interface ApplicationsViewProps {
|
|
|
96
97
|
/** Rendered instead of the built-in EmptyState when there are zero entries
|
|
97
98
|
* pre-filter (the fleet host injects a coverage/offline-aware empty). */
|
|
98
99
|
emptySlot?: ReactNode
|
|
100
|
+
/** Leading element in the header actions cluster (e.g. a freshness control). */
|
|
101
|
+
headerActions?: ReactNode
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
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) {
|
|
102
105
|
const [textFilter, setTextFilter] = useState('')
|
|
103
106
|
const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
|
|
104
107
|
const [fEnv, setFEnv] = useState<Set<string>>(new Set())
|
|
@@ -293,10 +296,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
293
296
|
description={description}
|
|
294
297
|
actions={
|
|
295
298
|
<>
|
|
299
|
+
{headerActions}
|
|
296
300
|
<SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
|
|
297
301
|
{healthTile('unhealthy', 'error')}
|
|
298
302
|
{healthTile('degraded', 'warning')}
|
|
299
303
|
{healthTile('healthy', 'success')}
|
|
304
|
+
{healthTile('neutral', 'info')}
|
|
300
305
|
{healthTile('unknown', 'neutral')}
|
|
301
306
|
</>
|
|
302
307
|
}
|
|
@@ -5,6 +5,10 @@ import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../uti
|
|
|
5
5
|
|
|
6
6
|
export interface AuditFinding {
|
|
7
7
|
kind: string
|
|
8
|
+
/** API group, backfilled by the backend from the builtin Kind→group table
|
|
9
|
+
* (built-ins → e.g. "apps"/"batch"; CRDs → ""). Part of the resource key
|
|
10
|
+
* used to join findings onto topology nodes / list rows. */
|
|
11
|
+
group?: string
|
|
8
12
|
namespace: string
|
|
9
13
|
name: string
|
|
10
14
|
checkID: string
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { AuditBadgeTooltip } from './AuditBadgeTooltip'
|
|
4
|
+
|
|
5
|
+
const msgs = [
|
|
6
|
+
{ severity: 'warning', message: 'Service selector matches no pods' },
|
|
7
|
+
{ severity: 'danger', message: 'Ingress references missing Service' },
|
|
8
|
+
{ severity: 'warning', message: 'Uses a deprecated API version' },
|
|
9
|
+
{ severity: 'warning', message: 'A fourth finding' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('AuditBadgeTooltip', () => {
|
|
13
|
+
it('lists each finding message up to the cap', () => {
|
|
14
|
+
const html = renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 2)} />)
|
|
15
|
+
expect(html).toContain('Service selector matches no pods')
|
|
16
|
+
expect(html).toContain('Ingress references missing Service')
|
|
17
|
+
expect(html).not.toContain('more')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('caps the list and collapses the rest into "+N more"', () => {
|
|
21
|
+
const html = renderToString(<AuditBadgeTooltip messages={msgs} max={3} />)
|
|
22
|
+
expect(html).toContain('+1 more')
|
|
23
|
+
expect(html).not.toContain('A fourth finding')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('shows the click hint by default and omits it when disabled', () => {
|
|
27
|
+
expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} />)).toContain('Click to open')
|
|
28
|
+
expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} clickHint={false} />)).not.toContain('Click to open')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ShieldAlert, AlertTriangle } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
4
|
+
|
|
5
|
+
export interface AuditBadgeMessage {
|
|
6
|
+
severity: string
|
|
7
|
+
message: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface AuditBadgeTooltipProps {
|
|
11
|
+
messages: AuditBadgeMessage[]
|
|
12
|
+
/** Max messages to list before collapsing the rest into "+N more". */
|
|
13
|
+
max?: number
|
|
14
|
+
/** Hint that clicking opens the resource — omitted when the badge isn't clickable. */
|
|
15
|
+
clickHint?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Inline tooltip body for an audit badge: lists the actual finding messages
|
|
20
|
+
* (danger-first) so the operator reads WHAT is wrong on hover, instead of a
|
|
21
|
+
* content-free "N findings". Shared by the resource-list and topology-node
|
|
22
|
+
* badges so the two can't drift.
|
|
23
|
+
*/
|
|
24
|
+
export function AuditBadgeTooltip({ messages, max = 3, clickHint = true }: AuditBadgeTooltipProps) {
|
|
25
|
+
const shown = messages.slice(0, max)
|
|
26
|
+
const overflow = messages.length - shown.length
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex flex-col gap-1 text-left">
|
|
29
|
+
{shown.map((m, i) => {
|
|
30
|
+
const isDanger = m.severity === 'danger'
|
|
31
|
+
const Icon = isDanger ? ShieldAlert : AlertTriangle
|
|
32
|
+
return (
|
|
33
|
+
<div key={i} className="flex items-start gap-1.5">
|
|
34
|
+
<Icon className={clsx('w-3 h-3 shrink-0 mt-0.5', isDanger ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
|
|
35
|
+
<span>{m.message}</span>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
})}
|
|
39
|
+
{overflow > 0 && (
|
|
40
|
+
<div className="text-theme-text-tertiary">{`+${overflow} more`}</div>
|
|
41
|
+
)}
|
|
42
|
+
{clickHint && (
|
|
43
|
+
<div className="text-theme-text-tertiary mt-0.5">Click to open →</div>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -27,6 +27,10 @@ export interface CheckMeta {
|
|
|
27
27
|
remediation: string
|
|
28
28
|
frameworks?: string[]
|
|
29
29
|
references?: CheckReference[]
|
|
30
|
+
/** Finding means the specific resource is broken (reference-integrity /
|
|
31
|
+
* lifecycle), worth a per-resource topology/list badge — vs posture /
|
|
32
|
+
* best-practice checks that fire near-universally. Set by the backend registry. */
|
|
33
|
+
badgeWorthy?: boolean
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/** An authoritative link for a check (K8s docs, CIS, NSA/CISA, …). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { AuditCard, type AuditCardData } from './AuditCard'
|
|
2
2
|
export { AuditAlerts, type AuditFinding } from './AuditAlerts'
|
|
3
|
+
export { AuditBadgeTooltip, type AuditBadgeMessage } from './AuditBadgeTooltip'
|
|
3
4
|
export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta, type CheckReference } from './AuditFindingsTable'
|
|
@@ -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 && (
|
|
@@ -184,11 +184,11 @@ export interface GitOpsDetailLayoutProps {
|
|
|
184
184
|
isFleetContext?: boolean
|
|
185
185
|
destinationCluster?: { id: string; name: string }
|
|
186
186
|
|
|
187
|
-
// Tab-title side effect — sets document.title to "<name>
|
|
187
|
+
// Tab-title side effect — sets document.title to "<name> · Radar" while
|
|
188
188
|
// mounted, restores on unmount. Opt-in so hub-web fleet detail can pick
|
|
189
189
|
// its own title format ("<name> — Fleet GitOps").
|
|
190
190
|
manageDocumentTitle?: boolean
|
|
191
|
-
documentTitleSuffix?: string // defaults to "
|
|
191
|
+
documentTitleSuffix?: string // defaults to " · Radar"
|
|
192
192
|
|
|
193
193
|
// Children slot — for dialogs (SyncOptionsDialog, RollbackDialog, …)
|
|
194
194
|
// that should portal to body. Caller owns dialog state. Children render
|
|
@@ -246,7 +246,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
246
246
|
useEffect(() => {
|
|
247
247
|
if (!manageDocumentTitle) return
|
|
248
248
|
const previous = document.title
|
|
249
|
-
const suffix = documentTitleSuffix ?? '
|
|
249
|
+
const suffix = documentTitleSuffix ?? ' · Radar'
|
|
250
250
|
document.title = `${identity.name}${suffix}`
|
|
251
251
|
return () => { document.title = previous }
|
|
252
252
|
}, [identity.name, manageDocumentTitle, documentTitleSuffix])
|
|
@@ -67,7 +67,10 @@ function getStatusIcon(status: GitOpsStatus) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
function getStatusColorClass(status: GitOpsStatus): string {
|
|
70
|
-
|
|
70
|
+
// Suspended sync (manual-sync mode) is an intentional operating mode many teams
|
|
71
|
+
// run by default — sky (info), not amber. Real drift surfaces separately as
|
|
72
|
+
// OutOfSync (still amber); the Pause icon + "Suspended" label signal it's manual.
|
|
73
|
+
if (status.suspended) return SEVERITY_BADGE_BORDERED.info
|
|
71
74
|
if (status.sync === 'Synced' && status.health === 'Healthy') return SEVERITY_BADGE_BORDERED.success
|
|
72
75
|
if (status.health === 'Degraded') return SEVERITY_BADGE_BORDERED.error
|
|
73
76
|
if (status.sync === 'OutOfSync') return SEVERITY_BADGE_BORDERED.warning
|
|
@@ -112,7 +115,9 @@ function getHealthInfo(health: GitOpsHealthStatus) {
|
|
|
112
115
|
case 'Degraded':
|
|
113
116
|
return { icon: XCircle, color: SEVERITY_BADGE.error, label: 'Degraded' }
|
|
114
117
|
case 'Suspended':
|
|
115
|
-
|
|
118
|
+
// Intentional pause, not a degradation — sky (info), matching the resource
|
|
119
|
+
// table + app rollup. The Pause icon already signals it's deliberate.
|
|
120
|
+
return { icon: Pause, color: SEVERITY_BADGE.info, label: 'Suspended' }
|
|
116
121
|
case 'Missing':
|
|
117
122
|
return { icon: AlertCircle, color: SEVERITY_BADGE.warning, label: 'Missing' }
|
|
118
123
|
default:
|
|
@@ -125,8 +130,9 @@ function getHealthInfo(health: GitOpsHealthStatus) {
|
|
|
125
130
|
*/
|
|
126
131
|
export function SyncStatusBadge({ sync, suspended }: { sync: SyncStatus; suspended?: boolean }) {
|
|
127
132
|
if (suspended) {
|
|
133
|
+
// Manual-sync mode is intentional — sky (info), not amber. See getStatusColorClass.
|
|
128
134
|
return (
|
|
129
|
-
<span className={clsx('badge', SEVERITY_BADGE_BORDERED.
|
|
135
|
+
<span className={clsx('badge', SEVERITY_BADGE_BORDERED.info)}>
|
|
130
136
|
<Pause className="w-3 h-3" />
|
|
131
137
|
Suspended
|
|
132
138
|
</span>
|
|
@@ -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>
|
|
@@ -1704,7 +1705,9 @@ function compareRows(a: GitOpsRow, b: GitOpsRow, sortKey: SortKey) {
|
|
|
1704
1705
|
// row sorting above a Synced-Progressing one. A sync-aware triage ordering is a
|
|
1705
1706
|
// reasonable separate default, but not what "sort by Health" should mean.)
|
|
1706
1707
|
const HEALTH_RANK: Record<string, number> = {
|
|
1707
|
-
|
|
1708
|
+
// Suspended is intentional/benign (neutral), so it sorts at the healthy end —
|
|
1709
|
+
// not near Degraded/Missing — matching its sky tone across the other surfaces.
|
|
1710
|
+
Degraded: 0, Missing: 0, Unknown: 2, Progressing: 3, Healthy: 4, Suspended: 4,
|
|
1708
1711
|
}
|
|
1709
1712
|
function healthRank(row: GitOpsRow): number {
|
|
1710
1713
|
return HEALTH_RANK[row.health] ?? 2
|
|
@@ -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>
|