@skyhook-io/k8s-ui 1.17.1 → 1.18.0
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 +4 -4
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +4 -1
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +80 -2
- package/src/components/logs/LogsViewer.tsx +21 -4
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.tsx +93 -75
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.tsx +40 -11
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +3 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +14 -8
- package/src/types/core.ts +25 -1
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { applyColumnOrder, moveColumn, pinnedColumnCount, sanitizeColumnOrder } from './column-order'
|
|
3
|
+
|
|
4
|
+
const cols = (...keys: string[]) => keys.map(key => ({ key }))
|
|
5
|
+
const keysOf = (list: { key: string }[]) => list.map(c => c.key)
|
|
6
|
+
|
|
7
|
+
const PODS = cols('name', 'namespace', 'ready', 'status', 'restarts', 'age', 'node')
|
|
8
|
+
|
|
9
|
+
describe('applyColumnOrder', () => {
|
|
10
|
+
it('keeps definition order when no saved order is provided', () => {
|
|
11
|
+
expect(keysOf(applyColumnOrder(PODS, undefined))).toEqual(keysOf(PODS))
|
|
12
|
+
expect(keysOf(applyColumnOrder(PODS, null))).toEqual(keysOf(PODS))
|
|
13
|
+
expect(keysOf(applyColumnOrder(PODS, []))).toEqual(keysOf(PODS))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies the saved order and keeps Name first', () => {
|
|
17
|
+
const order = ['age', 'restarts', 'namespace', 'ready', 'status', 'node']
|
|
18
|
+
expect(keysOf(applyColumnOrder(PODS, order)))
|
|
19
|
+
.toEqual(['name', 'age', 'restarts', 'namespace', 'ready', 'status', 'node'])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('ignores a saved Name position', () => {
|
|
23
|
+
const order = ['age', 'name', 'namespace', 'ready', 'status', 'restarts', 'node']
|
|
24
|
+
expect(keysOf(applyColumnOrder(PODS, order))[0]).toBe('name')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps host columns that lead Name in front of it', () => {
|
|
28
|
+
const fleet = cols('cluster', 'name', 'namespace', 'age')
|
|
29
|
+
expect(keysOf(applyColumnOrder(fleet, ['age', 'cluster', 'namespace'])))
|
|
30
|
+
.toEqual(['cluster', 'name', 'age', 'namespace'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('puts a column added after the save in its default position', () => {
|
|
34
|
+
// 'ip' arrives between status and restarts; the user had moved age up.
|
|
35
|
+
const next = cols('name', 'namespace', 'ready', 'status', 'ip', 'restarts', 'age', 'node')
|
|
36
|
+
const order = ['age', 'namespace', 'ready', 'status', 'restarts', 'node']
|
|
37
|
+
expect(keysOf(applyColumnOrder(next, order)))
|
|
38
|
+
.toEqual(['name', 'age', 'namespace', 'ready', 'status', 'ip', 'restarts', 'node'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('appends a new trailing column, such as a label column, at the end', () => {
|
|
42
|
+
const next = [...PODS, { key: 'label:app' }]
|
|
43
|
+
const order = ['node', 'age', 'namespace', 'ready', 'status', 'restarts']
|
|
44
|
+
expect(keysOf(applyColumnOrder(next, order)).at(-1)).toBe('label:app')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('keeps several new adjacent columns in their definition order', () => {
|
|
48
|
+
const next = cols('name', 'a', 'b', 'namespace', 'age')
|
|
49
|
+
expect(keysOf(applyColumnOrder(next, ['age', 'namespace'])))
|
|
50
|
+
.toEqual(['name', 'age', 'a', 'b', 'namespace'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('keeps a hidden column in place: order is independent of visibility', () => {
|
|
54
|
+
const order = ['age', 'restarts', 'namespace', 'ready', 'status', 'node']
|
|
55
|
+
const ordered = applyColumnOrder(PODS, order)
|
|
56
|
+
const visible = new Set(['name', 'age', 'namespace', 'ready', 'status', 'node'])
|
|
57
|
+
expect(keysOf(ordered.filter(c => visible.has(c.key))))
|
|
58
|
+
.toEqual(['name', 'age', 'namespace', 'ready', 'status', 'node'])
|
|
59
|
+
visible.add('restarts')
|
|
60
|
+
expect(keysOf(ordered.filter(c => visible.has(c.key))))
|
|
61
|
+
.toEqual(['name', 'age', 'restarts', 'namespace', 'ready', 'status', 'node'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('drops saved keys for columns that no longer exist', () => {
|
|
65
|
+
const order = ['gone', 'age', 'namespace', 'ready', 'status', 'restarts', 'node']
|
|
66
|
+
expect(keysOf(applyColumnOrder(PODS, order)))
|
|
67
|
+
.toEqual(['name', 'age', 'namespace', 'ready', 'status', 'restarts', 'node'])
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('sanitizeColumnOrder', () => {
|
|
72
|
+
it('reads anything that is not a key list as no order', () => {
|
|
73
|
+
expect(sanitizeColumnOrder(undefined)).toBeNull()
|
|
74
|
+
expect(sanitizeColumnOrder('age')).toBeNull()
|
|
75
|
+
expect(sanitizeColumnOrder({ 0: 'age' })).toBeNull()
|
|
76
|
+
expect(sanitizeColumnOrder([1, null])).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps string keys, first occurrence wins', () => {
|
|
80
|
+
expect(sanitizeColumnOrder(['age', 3, 'node', 'age'])).toEqual(['age', 'node'])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('moveColumn', () => {
|
|
85
|
+
it('returns the movable keys with the column moved', () => {
|
|
86
|
+
expect(moveColumn(PODS, 'age', 0)).toEqual(['age', 'namespace', 'ready', 'status', 'restarts', 'node'])
|
|
87
|
+
expect(moveColumn(PODS, 'namespace', 5)).toEqual(['ready', 'status', 'restarts', 'age', 'node', 'namespace'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns null for a no-op, an out-of-range edge move, or a pinned column', () => {
|
|
91
|
+
expect(moveColumn(PODS, 'namespace', -1)).toBeNull()
|
|
92
|
+
expect(moveColumn(PODS, 'node', 99)).toBeNull()
|
|
93
|
+
expect(moveColumn(PODS, 'name', 2)).toBeNull()
|
|
94
|
+
expect(moveColumn(PODS, 'missing', 0)).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('pinnedColumnCount', () => {
|
|
99
|
+
it('pins through Name, and nothing when there is no Name column', () => {
|
|
100
|
+
expect(pinnedColumnCount(PODS)).toBe(1)
|
|
101
|
+
expect(pinnedColumnCount(cols('cluster', 'name', 'age'))).toBe(2)
|
|
102
|
+
expect(pinnedColumnCount(cols('age'))).toBe(0)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The user's column order for a resource table, kept apart from visibility: it
|
|
2
|
+
// lists every movable column, hidden ones included, so a column hidden and later
|
|
3
|
+
// re-shown comes back where it was.
|
|
4
|
+
|
|
5
|
+
interface Keyed {
|
|
6
|
+
key: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Columns up to and including Name stay where the kind defines them. Name is
|
|
10
|
+
* the flexible column and the row link; host-injected leading columns sit in
|
|
11
|
+
* front of it by contract. */
|
|
12
|
+
export function pinnedColumnCount(columns: readonly Keyed[]): number {
|
|
13
|
+
return columns.findIndex(c => c.key === 'name') + 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A saved order read from localStorage: only string keys, first occurrence
|
|
17
|
+
* wins. Anything else reads as "no saved order". */
|
|
18
|
+
export function sanitizeColumnOrder(raw: unknown): string[] | null {
|
|
19
|
+
if (!Array.isArray(raw)) return null
|
|
20
|
+
const seen = new Set<string>()
|
|
21
|
+
for (const k of raw) {
|
|
22
|
+
if (typeof k === 'string' && !seen.has(k)) seen.add(k)
|
|
23
|
+
}
|
|
24
|
+
return seen.size ? Array.from(seen) : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Columns in the user's saved order.
|
|
29
|
+
*
|
|
30
|
+
* Saved keys that no longer exist are ignored. A column the saved order does not
|
|
31
|
+
* name (a built-in, printer, or custom column) goes
|
|
32
|
+
* where it sits by default: right before the column that follows it in the
|
|
33
|
+
* definition, or at the end when nothing that follows it is in the saved order.
|
|
34
|
+
*/
|
|
35
|
+
export function applyColumnOrder<T extends Keyed>(columns: readonly T[], order: readonly string[] | null | undefined): T[] {
|
|
36
|
+
if (!order?.length) return [...columns]
|
|
37
|
+
const pinned = pinnedColumnCount(columns)
|
|
38
|
+
const movable = columns.slice(pinned)
|
|
39
|
+
const rank = new Map(order.map((k, i) => [k, i]))
|
|
40
|
+
const result = movable
|
|
41
|
+
.filter(c => rank.has(c.key))
|
|
42
|
+
.sort((a, b) => rank.get(a.key)! - rank.get(b.key)!)
|
|
43
|
+
movable.forEach((c, i) => {
|
|
44
|
+
if (rank.has(c.key)) return
|
|
45
|
+
const anchor = movable.slice(i + 1).find(n => rank.has(n.key))
|
|
46
|
+
const at = anchor ? result.indexOf(anchor) : -1
|
|
47
|
+
if (at === -1) result.push(c)
|
|
48
|
+
else result.splice(at, 0, c)
|
|
49
|
+
})
|
|
50
|
+
return [...columns.slice(0, pinned), ...result]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The movable keys of `columns` (already in display order) after moving `key`
|
|
54
|
+
* to `toIndex` within them. Returns null when nothing would change. */
|
|
55
|
+
export function moveColumn(columns: readonly Keyed[], key: string, toIndex: number): string[] | null {
|
|
56
|
+
const keys = columns.slice(pinnedColumnCount(columns)).map(c => c.key)
|
|
57
|
+
const from = keys.indexOf(key)
|
|
58
|
+
if (from === -1) return null
|
|
59
|
+
const to = Math.max(0, Math.min(keys.length - 1, toIndex))
|
|
60
|
+
if (to === from) return null
|
|
61
|
+
keys.splice(from, 1)
|
|
62
|
+
keys.splice(to, 0, key)
|
|
63
|
+
return keys
|
|
64
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ArgoApplicationRenderer } from './ArgoApplicationRenderer'
|
|
4
|
+
|
|
5
|
+
describe('Argo Helm value files', () => {
|
|
6
|
+
it.each([true, false, undefined])('shows missing-file behavior for single and multiple sources: %s', optional => {
|
|
7
|
+
const source = { chart: 'cluster', repoURL: 'https://cloudnative-pg.github.io/charts', helm: { valueFiles: ['values.yaml', 'overrides.yaml'], ignoreMissingValueFiles: optional } }
|
|
8
|
+
for (const spec of [{ source }, { sources: [source] }]) {
|
|
9
|
+
const html = renderToStaticMarkup(<ArgoApplicationRenderer data={{ metadata: { name: 'app' }, spec }} />)
|
|
10
|
+
expect(html).toContain('values.yaml, overrides.yaml')
|
|
11
|
+
expect(html).toContain('If a Value File Is Missing')
|
|
12
|
+
expect(html).toContain(optional ? 'Skipped' : 'Render fails')
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -81,7 +81,10 @@ function SourceProperties({ source }: { source: any }) {
|
|
|
81
81
|
)}
|
|
82
82
|
{source.chart && <Property label="Helm Chart" value={source.chart} />}
|
|
83
83
|
{source.helm?.valueFiles && source.helm.valueFiles.length > 0 && (
|
|
84
|
-
|
|
84
|
+
<>
|
|
85
|
+
<Property label="Value Files" value={source.helm.valueFiles.join(', ')} />
|
|
86
|
+
<Property label="If a Value File Is Missing" value={source.helm.ignoreMissingValueFiles === true ? 'Skipped' : 'Render fails'} />
|
|
87
|
+
</>
|
|
85
88
|
)}
|
|
86
89
|
{source.helm?.parameters && source.helm.parameters.length > 0 && (
|
|
87
90
|
<Property
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { CronJobRenderer } from './CronJobRenderer'
|
|
4
|
+
import { getCronJobStatus } from '../resource-utils'
|
|
5
|
+
|
|
6
|
+
describe('CronJob scheduling and execution evidence', () => {
|
|
7
|
+
it('does not turn a missing recorded completion into an execution failure', () => {
|
|
8
|
+
const html = renderToStaticMarkup(<CronJobRenderer data={{
|
|
9
|
+
spec: { schedule: '0 * * * *', timeZone: 'UTC', startingDeadlineSeconds: 0 },
|
|
10
|
+
status: { lastScheduleTime: '2026-01-02T12:00:00Z', lastSuccessfulTime: '2026-01-02T11:00:00Z' },
|
|
11
|
+
}} />)
|
|
12
|
+
expect(html).toContain('Latest completion not verified')
|
|
13
|
+
expect(html).not.toContain('Recent Jobs Failing')
|
|
14
|
+
expect(html).toContain('>UTC<')
|
|
15
|
+
expect(html).toContain('>0s<')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('shows suspension without claiming an old Job recovered', () => {
|
|
19
|
+
const html = renderToStaticMarkup(<CronJobRenderer data={{
|
|
20
|
+
spec: { schedule: '* * * * *', suspend: true },
|
|
21
|
+
status: { lastScheduleTime: '2026-01-02T12:00:00Z' },
|
|
22
|
+
}} />)
|
|
23
|
+
expect(html).toContain('CronJob Suspended')
|
|
24
|
+
expect(html).not.toContain('Latest completion not verified')
|
|
25
|
+
expect(html).toContain('Controller local time (zone not reported)')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('does not grade enabled configuration as successful scheduling', () => {
|
|
29
|
+
expect(getCronJobStatus({ spec: { schedule: '* * * * *' }, status: {} })).toMatchObject({ text: 'Enabled', level: 'neutral' })
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -5,7 +5,23 @@ import { TimeValue } from '../../ui/ScheduleValue'
|
|
|
5
5
|
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
6
6
|
|
|
7
7
|
interface CronJobRendererProps {
|
|
8
|
-
data:
|
|
8
|
+
data: {
|
|
9
|
+
metadata?: { namespace?: string }
|
|
10
|
+
spec?: {
|
|
11
|
+
schedule?: string
|
|
12
|
+
timeZone?: string
|
|
13
|
+
suspend?: boolean
|
|
14
|
+
concurrencyPolicy?: string
|
|
15
|
+
startingDeadlineSeconds?: number
|
|
16
|
+
successfulJobsHistoryLimit?: number
|
|
17
|
+
failedJobsHistoryLimit?: number
|
|
18
|
+
}
|
|
19
|
+
status?: {
|
|
20
|
+
lastScheduleTime?: string
|
|
21
|
+
lastSuccessfulTime?: string
|
|
22
|
+
active?: { name: string; namespace?: string }[]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
9
25
|
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
10
26
|
}
|
|
11
27
|
|
|
@@ -13,21 +29,13 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
|
|
|
13
29
|
const status = data.status || {}
|
|
14
30
|
const spec = data.spec || {}
|
|
15
31
|
|
|
16
|
-
// Check for issues or notable states
|
|
17
32
|
const isSuspended = spec.suspend === true
|
|
18
33
|
const hasNeverRun = !status.lastScheduleTime
|
|
19
34
|
|
|
20
|
-
// "Recent failures" means the latest scheduled run didn't reach success.
|
|
21
|
-
// Only meaningful once nothing is running — while a job is active the most
|
|
22
|
-
// recent run is simply still in flight, not failing. With concurrencyPolicy
|
|
23
|
-
// Allow an older failed run can overlap a new active one; CronJob status only
|
|
24
|
-
// carries aggregate timestamps, so we accept missing that rare case here (the
|
|
25
|
-
// failed Job still surfaces in the job list / topology) rather than reviving
|
|
26
|
-
// the false positive that fired on every normal in-flight run.
|
|
27
35
|
const activeJobs = status.active?.length || 0
|
|
28
36
|
const lastSchedule = status.lastScheduleTime ? new Date(status.lastScheduleTime).getTime() : 0
|
|
29
37
|
const lastSuccess = status.lastSuccessfulTime ? new Date(status.lastSuccessfulTime).getTime() : 0
|
|
30
|
-
const
|
|
38
|
+
const completionUnverified = activeJobs === 0 && lastSchedule > 0 && lastSchedule > lastSuccess
|
|
31
39
|
|
|
32
40
|
return (
|
|
33
41
|
<>
|
|
@@ -46,23 +54,23 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
|
|
|
46
54
|
<AlertBanner
|
|
47
55
|
variant="info"
|
|
48
56
|
title="Never Scheduled"
|
|
49
|
-
message="
|
|
57
|
+
message="Kubernetes has not recorded a scheduled run. Check the schedule and controller Events."
|
|
50
58
|
/>
|
|
51
59
|
)}
|
|
52
60
|
|
|
53
|
-
{
|
|
54
|
-
{recentFailures && !isSuspended && (
|
|
61
|
+
{completionUnverified && !isSuspended && (
|
|
55
62
|
<AlertBanner
|
|
56
|
-
variant="
|
|
57
|
-
title="
|
|
58
|
-
message={<>
|
|
63
|
+
variant="info"
|
|
64
|
+
title="Latest completion not verified"
|
|
65
|
+
message={<>No successful completion is recorded after the latest scheduled run. Last success: {formatRelativeAgeTime(status.lastSuccessfulTime, 'never')}. Inspect the Job history to check the execution outcome.</>}
|
|
59
66
|
/>
|
|
60
67
|
)}
|
|
61
68
|
|
|
62
69
|
<Section title="Schedule" icon={Clock}>
|
|
63
70
|
<PropertyList>
|
|
64
71
|
<Property label="Schedule" value={spec.schedule} />
|
|
65
|
-
<Property label="Human" value={cronToHuman(spec.schedule)} />
|
|
72
|
+
<Property label="Human" value={cronToHuman(spec.schedule ?? '')} />
|
|
73
|
+
<Property label="Time Zone" value={spec.timeZone ?? 'Controller local time (zone not reported)'} />
|
|
66
74
|
<Property label="Suspend" value={spec.suspend ? 'Yes' : 'No'} />
|
|
67
75
|
<Property label="Last Schedule" value={<TimeValue timestamp={status.lastScheduleTime} fallback="Never" />} />
|
|
68
76
|
<Property label="Last Success" value={<TimeValue timestamp={status.lastSuccessfulTime} fallback="Never" />} />
|
|
@@ -73,16 +81,16 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
|
|
|
73
81
|
<Section title="Configuration">
|
|
74
82
|
<PropertyList>
|
|
75
83
|
<Property label="Concurrency" value={spec.concurrencyPolicy || 'Allow'} />
|
|
76
|
-
<Property label="Starting Deadline" value={spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'} />
|
|
84
|
+
<Property label="Starting Deadline" value={spec.startingDeadlineSeconds != null ? `${spec.startingDeadlineSeconds}s` : 'None'} />
|
|
77
85
|
<Property label="Success History" value={spec.successfulJobsHistoryLimit ?? 3} />
|
|
78
86
|
<Property label="Failed History" value={spec.failedJobsHistoryLimit ?? 1} />
|
|
79
87
|
</PropertyList>
|
|
80
88
|
</Section>
|
|
81
89
|
|
|
82
|
-
{status.active
|
|
90
|
+
{status.active && status.active.length > 0 && (
|
|
83
91
|
<Section title="Active Jobs">
|
|
84
92
|
<div className="space-y-1">
|
|
85
|
-
{status.active.map((job
|
|
93
|
+
{status.active.map((job) => (
|
|
86
94
|
<div key={job.name} className="text-sm">
|
|
87
95
|
<ResourceLink name={job.name} kind="jobs" namespace={job.namespace || data.metadata?.namespace || ''} onNavigate={onNavigate} />
|
|
88
96
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
1
2
|
import { Package, Settings, CheckCircle2, History, FileCode2, GitBranch } from 'lucide-react'
|
|
2
3
|
import { clsx } from 'clsx'
|
|
3
4
|
import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts, ResourceLink } from '../../ui/drawer-components'
|
|
@@ -5,12 +6,21 @@ import { GitOpsStatusBadge, SyncCountdown } from '../../gitops'
|
|
|
5
6
|
import { fluxConditionsToGitOpsStatus, type FluxCondition } from '../../../types/gitops'
|
|
6
7
|
import { TimeValue } from '../../ui/ScheduleValue'
|
|
7
8
|
|
|
9
|
+
export interface FluxValuesReference {
|
|
10
|
+
kind?: 'ConfigMap' | 'Secret'
|
|
11
|
+
name: string
|
|
12
|
+
optional?: boolean
|
|
13
|
+
valuesKey?: string
|
|
14
|
+
targetPath?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
interface FluxHelmReleaseRendererProps {
|
|
9
18
|
data: any
|
|
10
19
|
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
20
|
+
renderValuesReference?: (reference: FluxValuesReference) => ReactNode
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
export function FluxHelmReleaseRenderer({ data, onNavigate }: FluxHelmReleaseRendererProps) {
|
|
23
|
+
export function FluxHelmReleaseRenderer({ data, onNavigate, renderValuesReference }: FluxHelmReleaseRendererProps) {
|
|
14
24
|
const status = data.status || {}
|
|
15
25
|
const spec = data.spec || {}
|
|
16
26
|
const conditions = (status.conditions || []) as FluxCondition[]
|
|
@@ -59,7 +69,7 @@ export function FluxHelmReleaseRenderer({ data, onNavigate }: FluxHelmReleaseRen
|
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
// Values info
|
|
62
|
-
const valuesFrom = spec.valuesFrom || []
|
|
72
|
+
const valuesFrom: FluxValuesReference[] = spec.valuesFrom || []
|
|
63
73
|
const values = spec.values || {}
|
|
64
74
|
const valueKeys = Object.keys(values)
|
|
65
75
|
|
|
@@ -149,15 +159,18 @@ export function FluxHelmReleaseRenderer({ data, onNavigate }: FluxHelmReleaseRen
|
|
|
149
159
|
Values From
|
|
150
160
|
</div>
|
|
151
161
|
<div className="space-y-1">
|
|
152
|
-
{valuesFrom.map((ref
|
|
162
|
+
{valuesFrom.map((ref, idx) => (
|
|
153
163
|
<div key={idx} className="card-inner text-sm flex items-center gap-2 flex-wrap">
|
|
154
164
|
<span className="badge-sm status-neutral">{ref.kind || 'ConfigMap'}</span>
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
{ref.optional && <span className="badge-sm status-neutral">Optional</span>}
|
|
166
|
+
{renderValuesReference ? renderValuesReference(ref) : (
|
|
167
|
+
<ResourceLink
|
|
168
|
+
name={ref.name}
|
|
169
|
+
kind={ref.kind === 'Secret' ? 'secrets' : 'configmaps'}
|
|
170
|
+
namespace={data.metadata?.namespace || ''}
|
|
171
|
+
onNavigate={onNavigate}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
161
174
|
{ref.valuesKey && (
|
|
162
175
|
<span className="text-theme-text-tertiary text-xs">
|
|
163
176
|
key: {ref.valuesKey}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { PodRenderer } from './PodRenderer'
|
|
6
|
+
import { getExecContainerNames } from '../resource-utils'
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
9
|
+
|
|
10
|
+
const running = { running: { startedAt: '2026-10-16T10:00:00Z' } }
|
|
11
|
+
|
|
12
|
+
const pod = {
|
|
13
|
+
metadata: { name: 'app-with-sidecar', namespace: 'shop' },
|
|
14
|
+
spec: {
|
|
15
|
+
initContainers: [
|
|
16
|
+
{ name: 'migrate', image: 'busybox' },
|
|
17
|
+
{ name: 'log-shipper', image: 'fluent-bit:3', restartPolicy: 'Always' },
|
|
18
|
+
{ name: 'stopped-sidecar', image: 'busybox', restartPolicy: 'Always' },
|
|
19
|
+
],
|
|
20
|
+
containers: [{ name: 'app', image: 'shop/app:1' }],
|
|
21
|
+
},
|
|
22
|
+
status: {
|
|
23
|
+
phase: 'Running',
|
|
24
|
+
initContainerStatuses: [
|
|
25
|
+
{ name: 'migrate', state: running, restartCount: 0 },
|
|
26
|
+
{ name: 'log-shipper', state: running, restartCount: 0, ready: true },
|
|
27
|
+
{ name: 'stopped-sidecar', state: { waiting: { reason: 'CrashLoopBackOff' } }, restartCount: 3 },
|
|
28
|
+
],
|
|
29
|
+
containerStatuses: [{ name: 'app', state: running, restartCount: 0, ready: true }],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let container: HTMLDivElement
|
|
34
|
+
let root: Root
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
container = document.createElement('div')
|
|
38
|
+
document.body.appendChild(container)
|
|
39
|
+
root = createRoot(container)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
act(() => root.unmount())
|
|
44
|
+
container.remove()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function initCard(name: string): HTMLElement {
|
|
48
|
+
const label = Array.from(container.querySelectorAll('span')).find((s) => s.textContent === name)
|
|
49
|
+
if (!label) throw new Error(`no init container card for ${name}`)
|
|
50
|
+
return label.closest('.rounded-lg') as HTMLElement
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const terminalButton = (card: HTMLElement) => card.querySelector('svg.lucide-terminal')?.closest('button') ?? null
|
|
54
|
+
|
|
55
|
+
describe('PodRenderer native sidecar terminal', () => {
|
|
56
|
+
it('offers Terminal on a running native sidecar only, and lists sidecars in the terminal', () => {
|
|
57
|
+
const onOpenTerminal = vi.fn()
|
|
58
|
+
act(() => {
|
|
59
|
+
root.render(
|
|
60
|
+
<PodRenderer data={pod} onCopy={() => undefined} copied={null} canExec canViewLogs onOpenTerminal={onOpenTerminal} />,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(terminalButton(initCard('migrate'))).toBeNull()
|
|
65
|
+
expect(terminalButton(initCard('stopped-sidecar'))).toBeNull()
|
|
66
|
+
|
|
67
|
+
const sidecarTerminal = terminalButton(initCard('log-shipper'))
|
|
68
|
+
expect(sidecarTerminal).not.toBeNull()
|
|
69
|
+
act(() => sidecarTerminal!.click())
|
|
70
|
+
|
|
71
|
+
expect(onOpenTerminal).toHaveBeenCalledWith({
|
|
72
|
+
namespace: 'shop',
|
|
73
|
+
podName: 'app-with-sidecar',
|
|
74
|
+
containerName: 'log-shipper',
|
|
75
|
+
containers: ['app', 'log-shipper', 'stopped-sidecar'],
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('hides the sidecar Terminal without exec permission', () => {
|
|
80
|
+
act(() => {
|
|
81
|
+
root.render(
|
|
82
|
+
<PodRenderer data={pod} onCopy={() => undefined} copied={null} canViewLogs onOpenTerminal={vi.fn()} />,
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
expect(terminalButton(initCard('log-shipper'))).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('PodRenderer sidecar files and image', () => {
|
|
90
|
+
it('switches between a sidecar\'s files and its image, not the first container\'s', () => {
|
|
91
|
+
let browser: { containers: string[]; initialContainer: string; onSwitchToImageFiles: (c?: string) => void } | undefined
|
|
92
|
+
let imageBrowser: { image: string; onSwitchToPodFiles?: () => void } | undefined
|
|
93
|
+
act(() => {
|
|
94
|
+
root.render(
|
|
95
|
+
<PodRenderer
|
|
96
|
+
data={pod}
|
|
97
|
+
onCopy={() => undefined}
|
|
98
|
+
copied={null}
|
|
99
|
+
canExec
|
|
100
|
+
renderPodBrowser={(props) => { browser = props; return null }}
|
|
101
|
+
renderImageBrowser={(props) => { imageBrowser = props; return null }}
|
|
102
|
+
/>,
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const card = initCard('log-shipper')
|
|
107
|
+
act(() => (card.querySelector('svg.lucide-folder-open')!.closest('button') as HTMLButtonElement).click())
|
|
108
|
+
expect(browser?.initialContainer).toBe('log-shipper')
|
|
109
|
+
expect(browser?.containers).toEqual(['app', 'log-shipper', 'stopped-sidecar'])
|
|
110
|
+
|
|
111
|
+
// The user switched the dialog to app before asking for image files.
|
|
112
|
+
act(() => browser!.onSwitchToImageFiles('app'))
|
|
113
|
+
expect(imageBrowser?.image).toBe('shop/app:1')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('opens a sidecar\'s files from its image', () => {
|
|
117
|
+
let browser: { initialContainer: string; onSwitchToImageFiles: (c?: string) => void } | undefined
|
|
118
|
+
let imageBrowser: { image: string; onSwitchToPodFiles?: () => void } | undefined
|
|
119
|
+
act(() => {
|
|
120
|
+
root.render(
|
|
121
|
+
<PodRenderer
|
|
122
|
+
data={pod}
|
|
123
|
+
onCopy={() => undefined}
|
|
124
|
+
copied={null}
|
|
125
|
+
canExec
|
|
126
|
+
renderPodBrowser={(props) => { browser = props; return null }}
|
|
127
|
+
renderImageBrowser={(props) => { imageBrowser = props; return null }}
|
|
128
|
+
/>,
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
const card = initCard('log-shipper')
|
|
132
|
+
act(() => (card.querySelector('svg.lucide-folder-open')!.closest('button') as HTMLButtonElement).click())
|
|
133
|
+
act(() => browser!.onSwitchToImageFiles())
|
|
134
|
+
expect(imageBrowser?.image).toBe('fluent-bit:3')
|
|
135
|
+
act(() => imageBrowser!.onSwitchToPodFiles!())
|
|
136
|
+
expect(browser?.initialContainer).toBe('log-shipper')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('PodRenderer image to live files', () => {
|
|
141
|
+
function renderWith(data: typeof pod) {
|
|
142
|
+
let browser: { initialContainer: string } | undefined
|
|
143
|
+
let imageBrowser: { image: string; onSwitchToPodFiles?: () => void } | undefined
|
|
144
|
+
act(() => {
|
|
145
|
+
root.render(
|
|
146
|
+
<PodRenderer
|
|
147
|
+
data={data}
|
|
148
|
+
onCopy={() => undefined}
|
|
149
|
+
copied={null}
|
|
150
|
+
canExec
|
|
151
|
+
renderPodBrowser={(props) => { browser = props; return null }}
|
|
152
|
+
renderImageBrowser={(props) => { imageBrowser = props; return null }}
|
|
153
|
+
/>,
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
return { getBrowser: () => browser, getImageBrowser: () => imageBrowser }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const openImage = (name: string) => {
|
|
160
|
+
const card = initCard(name)
|
|
161
|
+
const link = Array.from(card.querySelectorAll('button')).find((el) => el.textContent?.startsWith('Image:'))
|
|
162
|
+
if (!link) throw new Error(`no image link on ${name}`)
|
|
163
|
+
act(() => (link as HTMLElement).click())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
it('does not send a stopped sidecar\'s image to its live files', () => {
|
|
167
|
+
const { getBrowser, getImageBrowser } = renderWith(pod)
|
|
168
|
+
// stopped-sidecar and migrate share the busybox image; neither is a running exec target.
|
|
169
|
+
openImage('stopped-sidecar')
|
|
170
|
+
expect(getImageBrowser()?.image).toBe('busybox')
|
|
171
|
+
act(() => getImageBrowser()!.onSwitchToPodFiles!())
|
|
172
|
+
expect(getBrowser()?.initialContainer).toBe('app')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('keeps the live-files link for a running sidecar while the pod is still Pending', () => {
|
|
176
|
+
const pending = { ...pod, status: { ...pod.status, phase: 'Pending' } }
|
|
177
|
+
const { getBrowser, getImageBrowser } = renderWith(pending)
|
|
178
|
+
openImage('log-shipper')
|
|
179
|
+
expect(getImageBrowser()?.onSwitchToPodFiles).toBeDefined()
|
|
180
|
+
act(() => getImageBrowser()!.onSwitchToPodFiles!())
|
|
181
|
+
expect(getBrowser()?.initialContainer).toBe('log-shipper')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('offers no live-files link in a Pending pod when nothing using the image runs', () => {
|
|
185
|
+
const pending = { ...pod, status: { ...pod.status, phase: 'Pending' } }
|
|
186
|
+
const { getImageBrowser } = renderWith(pending)
|
|
187
|
+
openImage('stopped-sidecar')
|
|
188
|
+
expect(getImageBrowser()?.onSwitchToPodFiles).toBeUndefined()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('getExecContainerNames', () => {
|
|
193
|
+
it('lists regular containers first, then native sidecars, never one-shot init containers', () => {
|
|
194
|
+
expect(getExecContainerNames(pod)).toEqual(['app', 'log-shipper', 'stopped-sidecar'])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('handles a pod without init containers', () => {
|
|
198
|
+
expect(getExecContainerNames({ spec: { containers: [{ name: 'app' }] } })).toEqual(['app'])
|
|
199
|
+
})
|
|
200
|
+
})
|