@skyhook-io/k8s-ui 1.14.5 → 1.14.6
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 +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +12 -0
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.6",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
91
|
"@types/node": "^26.2.0",
|
|
92
|
-
"@types/react": "^19.
|
|
93
|
-
"@types/react-dom": "^19.
|
|
92
|
+
"@types/react": "^19.3.0",
|
|
93
|
+
"@types/react-dom": "^19.3.0",
|
|
94
94
|
"@xterm/addon-fit": "^0.11.0",
|
|
95
95
|
"@xterm/addon-web-links": "^0.12.0",
|
|
96
96
|
"@xterm/xterm": "^6.0.0",
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
"elkjs": "^0.11.1",
|
|
101
101
|
"lucide-react": "^1.37.0",
|
|
102
102
|
"monaco-editor": "0.55.1",
|
|
103
|
-
"react": "^19.
|
|
104
|
-
"react-dom": "^19.
|
|
103
|
+
"react": "^19.3.0",
|
|
104
|
+
"react-dom": "^19.3.0",
|
|
105
105
|
"typescript": "^6.0.2",
|
|
106
106
|
"vitest": "^4.1.11",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { ClipboardCheck, ArrowRight, Check } from 'lucide-react'
|
|
1
|
+
import { ClipboardCheck, ArrowRight, Check, AlertTriangle } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { SEVERITY_TEXT, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
4
4
|
import { SEVERITY_FILL_CLASS, SEVERITY_TEXT_CLASS } from '../checks/severity'
|
|
5
5
|
|
|
6
6
|
export interface AuditCardData {
|
|
7
|
+
missingInputs?: string[]
|
|
7
8
|
passing: number
|
|
8
9
|
/** Raw compatibility counts; warning renders as Medium and danger as High. */
|
|
9
10
|
warning: number
|
|
@@ -17,16 +18,17 @@ interface AuditCardProps {
|
|
|
17
18
|
onNavigate: () => void
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
type SeverityLevel = 'success' | 'high' | 'medium'
|
|
21
|
+
type SeverityLevel = 'success' | 'high' | 'medium' | 'neutral'
|
|
21
22
|
|
|
22
23
|
function getSeverityLevel(data: AuditCardData): SeverityLevel {
|
|
23
|
-
if (data.warning + data.danger === 0) return 'success'
|
|
24
|
+
if (data.warning + data.danger === 0) return data.missingInputs?.length || data.passing === 0 ? 'neutral' : 'success'
|
|
24
25
|
const highRatio = data.danger / (data.warning + data.danger)
|
|
25
26
|
if (highRatio > 0.2) return 'high'
|
|
26
27
|
return 'medium'
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const ACCENT_BG: Record<SeverityLevel, string> = {
|
|
31
|
+
neutral: 'bg-theme-elevated',
|
|
30
32
|
success: 'bg-green-500/10',
|
|
31
33
|
high: 'bg-orange-500/10',
|
|
32
34
|
medium: 'bg-yellow-500/10',
|
|
@@ -35,9 +37,10 @@ const ACCENT_BG: Record<SeverityLevel, string> = {
|
|
|
35
37
|
export function AuditCard({ data, onNavigate }: AuditCardProps) {
|
|
36
38
|
const total = data.passing + data.warning + data.danger
|
|
37
39
|
const issueCount = data.warning + data.danger
|
|
38
|
-
const
|
|
40
|
+
const incomplete = (data.missingInputs?.length ?? 0) > 0
|
|
41
|
+
const allPassing = issueCount === 0 && total > 0 && !incomplete
|
|
39
42
|
const level = getSeverityLevel(data)
|
|
40
|
-
const accentColor = level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
|
|
43
|
+
const accentColor = level === 'neutral' ? 'text-theme-text-secondary' : level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
|
|
41
44
|
const accentBg = ACCENT_BG[level]
|
|
42
45
|
|
|
43
46
|
return (
|
|
@@ -56,7 +59,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
|
|
|
56
59
|
</span>
|
|
57
60
|
) : (
|
|
58
61
|
<span className={clsx('badge-sm', accentBg, accentColor)}>
|
|
59
|
-
<Check className="w-3 h-3" />
|
|
62
|
+
{allPassing ? <Check className="w-3 h-3" /> : <ClipboardCheck className="w-3 h-3" />}
|
|
60
63
|
</span>
|
|
61
64
|
)}
|
|
62
65
|
</div>
|
|
@@ -71,6 +74,12 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
|
|
|
71
74
|
<span className="text-xs text-theme-text-tertiary">{total} checks across {Object.keys(data.categories).length} categories</span>
|
|
72
75
|
)}
|
|
73
76
|
</div>
|
|
77
|
+
) : issueCount === 0 ? (
|
|
78
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
79
|
+
{incomplete ? <AlertTriangle className="w-8 h-8 text-warning-text" /> : <ClipboardCheck className="w-8 h-8 text-theme-text-tertiary" />}
|
|
80
|
+
<span className="text-sm font-medium text-theme-text-primary">{incomplete ? 'Some checks could not run' : 'No resources evaluated'}</span>
|
|
81
|
+
<span className="text-xs text-theme-text-secondary">{incomplete ? 'No findings in the available data.' : 'Check the selected scope and check settings.'}</span>
|
|
82
|
+
</div>
|
|
74
83
|
) : (
|
|
75
84
|
<>
|
|
76
85
|
{/* Distribution bar — only show when we have passing data for context */}
|
|
@@ -107,7 +116,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
|
|
|
107
116
|
)}
|
|
108
117
|
</div>
|
|
109
118
|
) : (
|
|
110
|
-
<span className={clsx('text-xs font-semibold', SEVERITY_TEXT.success)}>All passing</span>
|
|
119
|
+
<span className={clsx('text-xs font-semibold', incomplete ? 'text-theme-text-secondary' : SEVERITY_TEXT.success)}>{incomplete ? 'No findings' : 'All passing'}</span>
|
|
111
120
|
)}
|
|
112
121
|
</div>
|
|
113
122
|
)
|
|
@@ -117,6 +126,8 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
|
|
|
117
126
|
)}
|
|
118
127
|
</div>
|
|
119
128
|
|
|
129
|
+
{incomplete && issueCount > 0 && <p className="px-4 pb-2 text-xs text-warning-text">Some checks could not run.</p>}
|
|
130
|
+
|
|
120
131
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end">
|
|
121
132
|
<span className={clsx(
|
|
122
133
|
'flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider transition-colors',
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { AuditCard } from './AuditCard'
|
|
4
|
+
import { ChecksView } from '../checks/ChecksView'
|
|
5
|
+
import { FilterLocationProvider } from '../../filter-state'
|
|
6
|
+
|
|
7
|
+
describe('audit coverage presentation', () => {
|
|
8
|
+
for (const [passing, missingInputs, expected] of [
|
|
9
|
+
[0, [], 'No resources evaluated'],
|
|
10
|
+
[0, ['secrets'], 'Some checks could not run'],
|
|
11
|
+
[4, ['secrets'], 'Some checks could not run'],
|
|
12
|
+
[4, [], 'All checks passing'],
|
|
13
|
+
] as const) {
|
|
14
|
+
it(`shows ${expected} with ${passing} passing and ${missingInputs.length} unavailable inputs`, () => {
|
|
15
|
+
const html = renderToString(<AuditCard data={{ passing, missingInputs: [...missingInputs], warning: 0, danger: 0, categories: {} }} onNavigate={() => {}} />)
|
|
16
|
+
expect(html).toContain(expected)
|
|
17
|
+
if (missingInputs.length || passing === 0) expect(html).not.toContain('All checks passing')
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
it('keeps a coverage warning alongside real findings', () => {
|
|
21
|
+
const html = renderToString(<AuditCard data={{ passing: 1, missingInputs: ['secrets'], warning: 1, danger: 0, categories: { Security: { passing: 1, warning: 0, danger: 0 } } }} onNavigate={() => {}} />)
|
|
22
|
+
expect(html).toContain('Some checks could not run')
|
|
23
|
+
expect(html).not.toContain('All passing')
|
|
24
|
+
})
|
|
25
|
+
for (const [evaluated, missingInputs, expected] of [
|
|
26
|
+
[0, [], 'No resources evaluated'],
|
|
27
|
+
[0, ['secrets'], 'No findings in the available data'],
|
|
28
|
+
[4, ['secrets'], 'No findings in the available data'],
|
|
29
|
+
[4, [], 'Every audited resource passed'],
|
|
30
|
+
] as const) {
|
|
31
|
+
it(`Checks: ${expected}`, () => {
|
|
32
|
+
const html = renderToString(
|
|
33
|
+
<FilterLocationProvider value={{ searchParams: new URLSearchParams(), update: () => {} }}>
|
|
34
|
+
<ChecksView checks={[]} catalog={{}} anyData evaluated={evaluated} missingInputs={[...missingInputs]} />
|
|
35
|
+
</FilterLocationProvider>,
|
|
36
|
+
)
|
|
37
|
+
expect(html).toContain(expected)
|
|
38
|
+
if (missingInputs.length) {
|
|
39
|
+
expect(html).toContain('Unavailable inputs:')
|
|
40
|
+
expect(html).toContain('secrets')
|
|
41
|
+
expect(html).not.toContain('Every audited resource passed')
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
})
|
|
@@ -102,6 +102,19 @@ describe('AreaChart domain and axes', () => {
|
|
|
102
102
|
expect(attrs(render({ series: [series([[0, 1], [3600, 5]])] }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(1)
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
it('breaks omitted evaluations only when the caller supplies the interval', () => {
|
|
106
|
+
const sparse = [series([[0, 1], [15, 2], [105, 3], [120, 4]])]
|
|
107
|
+
const paths = (stepSeconds?: number) => attrs(render({ series: sparse, stepSeconds }), 'path', 'fill').filter(fill => fill === 'none')
|
|
108
|
+
expect(paths(15)).toHaveLength(2)
|
|
109
|
+
expect(paths()).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('preserves explicit gaps, zeros and isolated-sample behavior with a known interval', () => {
|
|
113
|
+
const gaps = [series([[0, 0], [15, 0], [30, null], [45, 1], [60, 2]])]
|
|
114
|
+
expect(attrs(render({ series: gaps, stepSeconds: 15 }), 'path', 'fill').filter(fill => fill === 'none')).toHaveLength(2)
|
|
115
|
+
expect(attrs(render({ series: [series([[0, 1]])], stepSeconds: 15 }), 'path', 'fill')).toHaveLength(0)
|
|
116
|
+
})
|
|
117
|
+
|
|
105
118
|
it('renders nothing for an empty series list', () => {
|
|
106
119
|
expect(render({ series: [] })).toBe('')
|
|
107
120
|
})
|
|
@@ -5,6 +5,7 @@ import { formatMetricValue, formatTimestamp } from './format'
|
|
|
5
5
|
import { layoutAnnotations } from './annotations'
|
|
6
6
|
import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
|
|
7
7
|
import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
|
|
8
|
+
import { nearestSample } from './nearestSample'
|
|
8
9
|
|
|
9
10
|
// Below this rendered width the annotation label pills would cover most of the
|
|
10
11
|
// plot once the 1000-unit viewBox is scaled down; the lines stay and the label
|
|
@@ -12,7 +13,7 @@ import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
|
|
|
12
13
|
export const ANNOTATION_LABEL_MIN_WIDTH_PX = 420
|
|
13
14
|
const ANNOTATION_HOVER_TOLERANCE = 8
|
|
14
15
|
|
|
15
|
-
export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, layout = 'full' }: {
|
|
16
|
+
export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full' }: {
|
|
16
17
|
series: TimeSeries[]
|
|
17
18
|
color: string
|
|
18
19
|
fillColor: string
|
|
@@ -22,6 +23,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
22
23
|
annotations?: ChartAnnotation[]
|
|
23
24
|
/** X axis window in unix seconds. Defaults to the sample extent. */
|
|
24
25
|
domain?: { start: number; end: number }
|
|
26
|
+
/** Expected positive sample interval; omitted evaluations break paths and cannot supply hover values. */
|
|
27
|
+
stepSeconds?: number
|
|
25
28
|
/**
|
|
26
29
|
* Display name per series, parallel to `series`. Pass it when the chart
|
|
27
30
|
* shows a subset of a larger result so names stay distinguishable across
|
|
@@ -32,16 +35,18 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
32
35
|
* 'full' is the fixed wide layout every existing caller gets. 'auto' switches
|
|
33
36
|
* to the compact layout (two ticks per axis, larger text, taller plot) when
|
|
34
37
|
* the rendered chart is narrower than ANNOTATION_LABEL_MIN_WIDTH_PX;
|
|
35
|
-
* 'compact' forces it.
|
|
38
|
+
* 'compact' forces it. 'dashboard' uses readable, medium-width axes and
|
|
39
|
+
* keeps text and plot height stable as its container resizes.
|
|
36
40
|
*/
|
|
37
|
-
layout?: 'full' | 'auto' | 'compact'
|
|
41
|
+
layout?: 'full' | 'auto' | 'compact' | 'dashboard'
|
|
38
42
|
}) {
|
|
39
43
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
40
44
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
41
45
|
const [hoverX, setHoverX] = useState<number | null>(null)
|
|
42
46
|
const [labelsFit, setLabelsFit] = useState(true)
|
|
47
|
+
const [renderedWidth, setRenderedWidth] = useState(0)
|
|
43
48
|
const multiSeries = series.length > 1
|
|
44
|
-
const compact = layout === 'compact' || (layout === 'auto' && !labelsFit)
|
|
49
|
+
const compact = layout === 'compact' || (layout === 'auto' && !labelsFit) || (layout === 'dashboard' && renderedWidth > 0 && renderedWidth < 300)
|
|
45
50
|
const countAxis = isCountUnit(unit)
|
|
46
51
|
|
|
47
52
|
const chartData = useMemo(() => {
|
|
@@ -91,14 +96,14 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
91
96
|
// Layout constants. marginLeft sized for the widest expected Y-tick label
|
|
92
97
|
// ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
|
|
93
98
|
// need extra viewBox-space to survive the down-scale.
|
|
94
|
-
const base = chartLayout(compact)
|
|
99
|
+
const base = chartLayout(compact, layout === 'dashboard', renderedWidth)
|
|
95
100
|
const { width, height, marginRight, marginTop, marginBottom, fontSize, yIntervals, xIntervals } = base
|
|
96
101
|
const yValues = chartData ? yAxisValues(chartData.yMin, chartData.yMax, yIntervals, countAxis) : []
|
|
97
102
|
const yLabels = yValues.map(val => formatMetricValue(val, unit))
|
|
98
103
|
// The compact layout's larger text makes a label like "161.5 MiB" wider
|
|
99
104
|
// than the fixed margin, and SVG clips it; grow the margin to the widest
|
|
100
105
|
// label there. The full layout keeps its fixed margin for existing callers.
|
|
101
|
-
const marginLeft = compact
|
|
106
|
+
const marginLeft = compact || layout === 'dashboard'
|
|
102
107
|
? Math.max(base.marginLeft, Math.ceil(Math.max(0, ...yLabels.map(label => label.length)) * fontSize * 0.62 + 12))
|
|
103
108
|
: base.marginLeft
|
|
104
109
|
const plotWidth = width - marginLeft - marginRight
|
|
@@ -128,10 +133,10 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
128
133
|
const ts = minTs + ((maxTs - minTs) / xIntervals) * i
|
|
129
134
|
// In the compact layout the two labels sit at the plot edges and would
|
|
130
135
|
// otherwise spill past the viewBox.
|
|
131
|
-
const anchor: 'start' | 'middle' | 'end' = !compact ? 'middle' : i === 0 ? 'start' : 'end'
|
|
136
|
+
const anchor: 'start' | 'middle' | 'end' = !(compact || layout === 'dashboard') ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
|
|
132
137
|
return { ts, x: toX(ts), label: formatTimestamp(ts), anchor }
|
|
133
138
|
})
|
|
134
|
-
}, [chartData, xIntervals, compact])
|
|
139
|
+
}, [chartData, xIntervals, compact, layout, marginLeft, plotWidth])
|
|
135
140
|
|
|
136
141
|
const paths = useMemo(() => {
|
|
137
142
|
if (!chartData) return []
|
|
@@ -155,6 +160,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
155
160
|
// rather than bridging across missing data or dropping to y=0. Each run
|
|
156
161
|
// becomes its own path segment; a run needs >=2 points to render a line.
|
|
157
162
|
let run: { x: number; y: number }[] = []
|
|
163
|
+
let previousTimestamp: number | undefined
|
|
158
164
|
let runIdx = 0
|
|
159
165
|
const flush = () => {
|
|
160
166
|
if (run.length >= 2) {
|
|
@@ -177,15 +183,18 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
177
183
|
for (const dp of s.dataPoints) {
|
|
178
184
|
if (dp.value == null) {
|
|
179
185
|
flush()
|
|
186
|
+
previousTimestamp = undefined
|
|
180
187
|
continue
|
|
181
188
|
}
|
|
189
|
+
if (stepSeconds !== undefined && previousTimestamp !== undefined && dp.timestamp - previousTimestamp > stepSeconds * 1.5) flush()
|
|
182
190
|
run.push({ x: toX(dp.timestamp), y: toY(dp.value) })
|
|
191
|
+
previousTimestamp = dp.timestamp
|
|
183
192
|
}
|
|
184
193
|
flush()
|
|
185
194
|
})
|
|
186
195
|
|
|
187
196
|
return segments
|
|
188
|
-
}, [chartData])
|
|
197
|
+
}, [chartData, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, fillColor, stepSeconds])
|
|
189
198
|
|
|
190
199
|
const placedAnnotations = useMemo(() => {
|
|
191
200
|
if (!chartData || !annotations?.length) return []
|
|
@@ -195,12 +204,16 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
195
204
|
{ minTs: chartData.minTs, maxTs: chartData.maxTs },
|
|
196
205
|
{ left: marginLeft, right: width - marginRight },
|
|
197
206
|
)
|
|
198
|
-
}, [chartData, annotations])
|
|
207
|
+
}, [chartData, annotations, width, marginLeft, marginRight])
|
|
199
208
|
|
|
200
209
|
useEffect(() => {
|
|
201
210
|
const el = wrapperRef.current
|
|
202
|
-
if (!el || (placedAnnotations.length === 0 && layout !== 'auto') || typeof ResizeObserver === 'undefined') return
|
|
203
|
-
const update = () =>
|
|
211
|
+
if (!el || (placedAnnotations.length === 0 && layout !== 'auto' && layout !== 'dashboard') || typeof ResizeObserver === 'undefined') return
|
|
212
|
+
const update = () => {
|
|
213
|
+
const measuredWidth = el.getBoundingClientRect().width
|
|
214
|
+
setLabelsFit(measuredWidth >= ANNOTATION_LABEL_MIN_WIDTH_PX)
|
|
215
|
+
if (layout === 'dashboard') setRenderedWidth(measuredWidth)
|
|
216
|
+
}
|
|
204
217
|
update()
|
|
205
218
|
const observer = new ResizeObserver(update)
|
|
206
219
|
observer.observe(el)
|
|
@@ -236,18 +249,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
236
249
|
if (ts < seriesMin - tolerance || ts > seriesMax + tolerance) {
|
|
237
250
|
return null
|
|
238
251
|
}
|
|
239
|
-
|
|
240
|
-
// show and would render NaN in the tooltip / a dot at y=NaN.
|
|
241
|
-
let closest: { timestamp: number; value: number } | null = null
|
|
242
|
-
let closestDist = Infinity
|
|
243
|
-
for (const dp of dps) {
|
|
244
|
-
if (dp.value == null) continue
|
|
245
|
-
const dist = Math.abs(dp.timestamp - ts)
|
|
246
|
-
if (dist < closestDist) {
|
|
247
|
-
closestDist = dist
|
|
248
|
-
closest = { timestamp: dp.timestamp, value: dp.value }
|
|
249
|
-
}
|
|
250
|
-
}
|
|
252
|
+
const closest = nearestSample(dps, ts, stepSeconds)
|
|
251
253
|
if (!closest) return null
|
|
252
254
|
return {
|
|
253
255
|
label: shortLabels[vi],
|
|
@@ -263,7 +265,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
263
265
|
)
|
|
264
266
|
|
|
265
267
|
return { ts, x: clampedX, points, nearbyAnnotations }
|
|
266
|
-
}, [hoverX, chartData, placedAnnotations, seriesLabels])
|
|
268
|
+
}, [hoverX, chartData, placedAnnotations, seriesLabels, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, stepSeconds])
|
|
267
269
|
|
|
268
270
|
const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
|
|
269
271
|
const svg = svgRef.current
|
|
@@ -286,6 +288,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
286
288
|
ref={svgRef}
|
|
287
289
|
viewBox={`0 0 ${width} ${height}`}
|
|
288
290
|
className="w-full h-full"
|
|
291
|
+
style={layout === 'dashboard' ? { height: 240 } : undefined}
|
|
289
292
|
preserveAspectRatio="xMidYMid meet"
|
|
290
293
|
data-chart-layout={compact ? 'compact' : 'full'}
|
|
291
294
|
>
|
|
@@ -157,6 +157,8 @@ export interface PrometheusChartsViewProps {
|
|
|
157
157
|
statusError?: string;
|
|
158
158
|
onConnect: () => void;
|
|
159
159
|
connecting: boolean;
|
|
160
|
+
/** Discovery is running server-side (not triggered by this view); show progress, not the CTA. */
|
|
161
|
+
discovering?: boolean;
|
|
160
162
|
// Selection (controlled by host)
|
|
161
163
|
category: PrometheusMetricCategory;
|
|
162
164
|
onCategoryChange: (c: PrometheusMetricCategory) => void;
|
|
@@ -177,6 +179,7 @@ export function PrometheusChartsView({
|
|
|
177
179
|
statusError,
|
|
178
180
|
onConnect,
|
|
179
181
|
connecting,
|
|
182
|
+
discovering = false,
|
|
180
183
|
category,
|
|
181
184
|
onCategoryChange,
|
|
182
185
|
range,
|
|
@@ -208,6 +211,15 @@ export function PrometheusChartsView({
|
|
|
208
211
|
if (!metricsLoading && !metricsError && !series.length) return null;
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
if (!isConnected && discovering) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
217
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
218
|
+
Discovering Prometheus…
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
if (!isConnected) {
|
|
212
224
|
return (
|
|
213
225
|
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
@@ -29,4 +29,20 @@ describe('integer axes', () => {
|
|
|
29
29
|
expect(compact.fontSize).toBeGreaterThan(full.fontSize)
|
|
30
30
|
expect(compact.height / compact.width).toBeGreaterThan(full.height / full.width)
|
|
31
31
|
})
|
|
32
|
+
|
|
33
|
+
it('keeps dashboard axes readable without changing existing layouts', () => {
|
|
34
|
+
const dashboard = chartLayout(false, true)
|
|
35
|
+
expect([dashboard.width, dashboard.height, dashboard.fontSize]).toEqual([600, 240, 14])
|
|
36
|
+
expect(chartLayout(true, true)).toEqual(chartLayout(true))
|
|
37
|
+
expect(chartLayout(false).width).toBe(1000)
|
|
38
|
+
})
|
|
39
|
+
it('fits dashboard plots to their container without scaling text or height', () => {
|
|
40
|
+
for (const width of [280, 380, 550, 1050]) {
|
|
41
|
+
const layout = chartLayout(width < 420, true, width)
|
|
42
|
+
expect(layout.width).toBe(width)
|
|
43
|
+
expect(layout.height).toBe(240)
|
|
44
|
+
expect(layout.fontSize).toBe(12)
|
|
45
|
+
}
|
|
46
|
+
expect(chartLayout(false, false, 500)).toEqual(chartLayout(false))
|
|
47
|
+
})
|
|
32
48
|
})
|
|
@@ -18,7 +18,9 @@ export interface ChartLayout {
|
|
|
18
18
|
// The compact layout trades tick density for legible text: a 400-unit
|
|
19
19
|
// viewBox scaled into a ~280px pane keeps 14-unit text near 10px, where the
|
|
20
20
|
// full layout's 11-unit text in a 1000-unit viewBox would be 3px.
|
|
21
|
-
export function chartLayout(compact: boolean): ChartLayout {
|
|
21
|
+
export function chartLayout(compact: boolean, dashboard = false, renderedWidth = 0): ChartLayout {
|
|
22
|
+
if (dashboard && renderedWidth > 0) return { width: renderedWidth, height: 240, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 12, yIntervals: compact ? 1 : 2, xIntervals: compact ? 1 : renderedWidth >= 760 ? 4 : 2 }
|
|
23
|
+
if (dashboard && !compact) return { width: 600, height: 240, marginLeft: 90, marginRight: 24, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 2, xIntervals: 2 }
|
|
22
24
|
return compact
|
|
23
25
|
? { width: 400, height: 260, marginLeft: 60, marginRight: 14, marginTop: 12, marginBottom: 28, fontSize: 14, yIntervals: 1, xIntervals: 1 }
|
|
24
26
|
: { width: 1000, height: 300, marginLeft: 84, marginRight: 40, marginTop: 10, marginBottom: 30, fontSize: 11, yIntervals: 4, xIntervals: 6 }
|
|
@@ -7,6 +7,10 @@ export function formatMetricValue(value: number, unit: string): string {
|
|
|
7
7
|
if (value < 0) return `-${formatMetricValue(-value, unit)}`
|
|
8
8
|
|
|
9
9
|
switch (unit) {
|
|
10
|
+
case 'percent':
|
|
11
|
+
return `${value.toFixed(value < 1 ? 2 : 1)}%`
|
|
12
|
+
case 'requests/s':
|
|
13
|
+
return `${value.toFixed(value < 1 ? 3 : 1)} req/s`
|
|
10
14
|
case 'cores': {
|
|
11
15
|
if (value < 0.0001) return '< 0.1m'
|
|
12
16
|
if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { nearestSample } from './nearestSample'
|
|
3
|
+
|
|
4
|
+
const points = [
|
|
5
|
+
{ timestamp: 0, value: 0 },
|
|
6
|
+
{ timestamp: 15, value: 2 },
|
|
7
|
+
{ timestamp: 105, value: 3 },
|
|
8
|
+
{ timestamp: 120, value: 4 },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('nearestSample', () => {
|
|
12
|
+
it('withholds hover values inside missing evaluations, including a single missing slot', () => {
|
|
13
|
+
expect(nearestSample(points, 60, 15)).toBeNull()
|
|
14
|
+
expect(nearestSample([{ timestamp: 0, value: 1 }, { timestamp: 30, value: 2 }], 15, 15)).toBeNull()
|
|
15
|
+
expect(nearestSample([...points, { timestamp: 135, value: null }], 135, 15)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps measured zero and only snaps within half an evaluation interval', () => {
|
|
19
|
+
expect(nearestSample(points, 0, 15)).toEqual(points[0])
|
|
20
|
+
expect(nearestSample(points, 110, 15)).toEqual(points[2])
|
|
21
|
+
expect(nearestSample(points, -8, 15)).toBeNull()
|
|
22
|
+
expect(nearestSample(points, 128, 15)).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('preserves unrestricted nearest-finite behavior for generic charts', () => {
|
|
26
|
+
expect(nearestSample(points, 60)).toEqual(points[1])
|
|
27
|
+
expect(nearestSample([...points, { timestamp: 135, value: null }], 135)).toEqual(points[3])
|
|
28
|
+
expect(nearestSample([], 0)).toBeNull()
|
|
29
|
+
expect(nearestSample([{ timestamp: 0, value: null }], 0)).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TimeSeriesPoint } from './types'
|
|
2
|
+
|
|
3
|
+
export function nearestSample(points: TimeSeriesPoint[], timestamp: number, stepSeconds?: number): { timestamp: number; value: number } | null {
|
|
4
|
+
let closest: { timestamp: number; value: number } | null = null
|
|
5
|
+
let distance = Infinity
|
|
6
|
+
for (const point of points) {
|
|
7
|
+
if (point.value == null) continue
|
|
8
|
+
const candidateDistance = Math.abs(point.timestamp - timestamp)
|
|
9
|
+
if (candidateDistance < distance) {
|
|
10
|
+
distance = candidateDistance
|
|
11
|
+
closest = { timestamp: point.timestamp, value: point.value }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return stepSeconds !== undefined && distance > stepSeconds / 2 ? null : closest
|
|
15
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ChevronRight, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
|
|
4
|
-
import { CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
4
|
+
import { AlertBanner, CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
5
5
|
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
6
6
|
import type { CheckMeta, CheckReference } from '../audit'
|
|
7
7
|
import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
|
|
@@ -56,6 +56,9 @@ export interface ChecksViewProps {
|
|
|
56
56
|
catalog: Record<string, CheckMeta>
|
|
57
57
|
/** True when at least one source returned audit data. */
|
|
58
58
|
anyData: boolean
|
|
59
|
+
/** Evaluations and unavailable inputs for the scope supplied by the host. */
|
|
60
|
+
evaluated?: number
|
|
61
|
+
missingInputs?: string[]
|
|
59
62
|
/** Deep-link href for a resource (host routing). Omit for non-link text. */
|
|
60
63
|
resourceHref?: (ref: CheckResourceRef) => string
|
|
61
64
|
/** In-app resource navigation (client-side). Takes precedence over href. */
|
|
@@ -106,7 +109,7 @@ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
|
|
|
106
109
|
q: { param: 'q', type: 'text' },
|
|
107
110
|
})
|
|
108
111
|
|
|
109
|
-
export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
|
|
112
|
+
export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs = [], resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
|
|
110
113
|
// Severity / category / framework / search live in the URL (shareable,
|
|
111
114
|
// bookmarkable audit links) via the shared filter-state contract. The cluster
|
|
112
115
|
// facet is deliberately NOT here — it's a host-controlled seam (see
|
|
@@ -345,6 +348,14 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
345
348
|
</div>
|
|
346
349
|
</div>
|
|
347
350
|
|
|
351
|
+
{missingInputs.length > 0 && (
|
|
352
|
+
<AlertBanner
|
|
353
|
+
variant="warning"
|
|
354
|
+
title="Some checks could not run"
|
|
355
|
+
message={<>Findings cover only available inputs. Unavailable inputs: {missingInputs.join(', ')}.</>}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
|
|
348
359
|
{fleetChecks.length === 0 ? (
|
|
349
360
|
hasFilters ? (
|
|
350
361
|
<EmptyState
|
|
@@ -362,6 +373,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
362
373
|
</button>
|
|
363
374
|
}
|
|
364
375
|
/>
|
|
376
|
+
) : missingInputs.length > 0 ? (
|
|
377
|
+
<EmptyState
|
|
378
|
+
headline="No findings in the available data"
|
|
379
|
+
body="The audit is incomplete. This does not mean all checks passed."
|
|
380
|
+
/>
|
|
381
|
+
) : evaluated === 0 ? (
|
|
382
|
+
<EmptyState headline="No resources evaluated" body="No resources were evaluated with the current scope and check settings." />
|
|
365
383
|
) : anyData ? (
|
|
366
384
|
<EmptyState
|
|
367
385
|
tone="healthy"
|
|
@@ -4,6 +4,7 @@ import { PaneLoader } from '../ui/PaneLoader'
|
|
|
4
4
|
|
|
5
5
|
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
6
6
|
import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
|
|
7
|
+
import { GitOpsHealthSourceNotice } from './GitOpsHealthSourceNotice'
|
|
7
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
9
|
import type { GitOpsHealthStatus, GitOpsInsight, GitOpsIssue, GitOpsRemediation, SyncStatus } from '../../types'
|
|
9
10
|
|
|
@@ -138,6 +139,14 @@ export interface GitOpsDetailLayoutProps {
|
|
|
138
139
|
// on the OSS-specific CodeViewer component.
|
|
139
140
|
helmValuesContent?: ReactNode
|
|
140
141
|
|
|
142
|
+
// GitOpsHealthSourceNotice inputs: where its "Why?" link goes, and what
|
|
143
|
+
// (if anything) to say after the remote-destination sentence. Both are
|
|
144
|
+
// host decisions; the notice itself keys off `insight.summary`.
|
|
145
|
+
healthDocsUrl?: string
|
|
146
|
+
remoteDestinationHint?: ReactNode
|
|
147
|
+
// Opens the host's Argo CD settings, when it has such a place.
|
|
148
|
+
onOpenSettings?: () => void
|
|
149
|
+
|
|
141
150
|
// Action buttons — Argo + Flux. Exactly one applies for any given CR.
|
|
142
151
|
isArgoApp: boolean
|
|
143
152
|
isFlux: boolean
|
|
@@ -222,6 +231,9 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
222
231
|
helmValuesOpen,
|
|
223
232
|
onToggleHelmValues,
|
|
224
233
|
helmValuesContent,
|
|
234
|
+
healthDocsUrl,
|
|
235
|
+
remoteDestinationHint,
|
|
236
|
+
onOpenSettings,
|
|
225
237
|
isArgoApp,
|
|
226
238
|
isFlux,
|
|
227
239
|
isFluxWorkload,
|
|
@@ -458,6 +470,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
458
470
|
{!fullscreen && (
|
|
459
471
|
<>
|
|
460
472
|
<GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} renderRevisionMeta={renderRevisionMeta} />
|
|
473
|
+
<GitOpsHealthSourceNotice summary={insight?.summary} changes={insight?.changes} docsUrl={healthDocsUrl} remoteDestinationHint={remoteDestinationHint} onOpenSettings={onOpenSettings} />
|
|
461
474
|
<GitOpsIssuesBand
|
|
462
475
|
issues={insight?.issues}
|
|
463
476
|
terminating={terminating}
|