@skyhook-io/k8s-ui 1.17.1 → 1.18.1

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.
Files changed (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -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
- <Property label="Value Files" value={source.helm.valueFiles.join(', ')} />
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: any
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 recentFailures = activeJobs === 0 && lastSchedule > 0 && lastSchedule > lastSuccess
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="This CronJob has never run. Check the schedule and starting deadline settings."
57
+ message="Kubernetes has not recorded a scheduled run. Check the schedule and controller Events."
50
58
  />
51
59
  )}
52
60
 
53
- {/* Recent failures warning */}
54
- {recentFailures && !isSuspended && (
61
+ {completionUnverified && !isSuspended && (
55
62
  <AlertBanner
56
- variant="error"
57
- title="Recent Jobs Failing"
58
- message={<>Jobs have been scheduled but haven't succeeded recently. Last success: {formatRelativeAgeTime(status.lastSuccessfulTime, 'never')}. Check job history and pod logs.</>}
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?.length > 0 && (
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: any) => (
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: any, idx: number) => (
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
- <ResourceLink
156
- name={ref.name}
157
- kind={ref.kind === 'Secret' ? 'secrets' : 'configmaps'}
158
- namespace={data.metadata?.namespace || ''}
159
- onNavigate={onNavigate}
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
+ })