@skyhook-io/k8s-ui 1.4.0 → 1.4.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 (52) hide show
  1. package/package.json +2 -2
  2. package/src/components/resources/ResourcesView.tsx +112 -49
  3. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  4. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  5. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  6. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  7. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  8. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  9. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
  10. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  11. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  12. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  13. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
  14. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  15. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  16. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  17. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  18. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  19. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  20. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  21. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  22. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  23. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  24. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  25. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
  26. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  27. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  28. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  29. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  30. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  31. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  32. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  33. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  34. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  35. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  36. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  37. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  38. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  39. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  40. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  41. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  42. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  43. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  44. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  45. package/src/components/resources/resource-utils.ts +131 -33
  46. package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
  47. package/src/components/timeline/TimelineList.tsx +11 -4
  48. package/src/components/ui/drawer-components.tsx +48 -1
  49. package/src/theme/variables.css +1 -1
  50. package/src/types/gitops.ts +8 -0
  51. package/src/utils/navigation.test.ts +29 -2
  52. package/src/utils/navigation.ts +45 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -65,7 +65,7 @@
65
65
  "react": "^19.2.4",
66
66
  "react-dom": "^19.2.4",
67
67
  "typescript": "^6.0.2",
68
- "vitest": "^4.0.18",
68
+ "vitest": "^4.1.3",
69
69
  "yaml": "^2.8.3"
70
70
  }
71
71
  }
@@ -31,6 +31,8 @@ import {
31
31
  getPodStatus,
32
32
  getPodRestarts,
33
33
  getPodProblems,
34
+ getWorkloadProblems,
35
+ workloadMatchesProblemCategory,
34
36
  getContainerSquareStates,
35
37
  getWorkloadImages,
36
38
  getWorkloadConditions,
@@ -116,6 +118,8 @@ import {
116
118
  getCellFilterValue,
117
119
  parseColumnFilters,
118
120
  serializeColumnFilters,
121
+ podMatchesProblemCategory,
122
+ SEVERITY_DOT_COLOR,
119
123
  } from './resource-utils'
120
124
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
121
125
  import { Tooltip } from '../ui/Tooltip'
@@ -140,7 +144,9 @@ import { ResourcesSidebar } from './ResourcesSidebar'
140
144
  import type { SelectedKindInfo } from './ResourcesSidebar'
141
145
 
142
146
  // Pod problem filter options (special multi-select, not a single column value)
143
- const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts'] as const
147
+ const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
148
+ const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'] as const
149
+ const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
144
150
 
145
151
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
146
152
  const SKIP_FILTER_COLUMNS = new Set([
@@ -455,6 +461,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
455
461
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
456
462
  { key: 'status', label: 'Status', width: 'w-24' },
457
463
  { key: 'instanceType', label: 'Instance Type', width: 'w-32' },
464
+ { key: 'capacityType', label: 'Capacity', width: 'w-24', tooltip: 'Spot or On-Demand' },
465
+ { key: 'zone', label: 'Zone', width: 'w-28', hideOnMobile: true },
458
466
  { key: 'nodePool', label: 'Node Pool', width: 'w-32' },
459
467
  { key: 'nodeName', label: 'Node', width: 'w-40', hideOnMobile: true },
460
468
  { key: 'age', label: 'Age', width: 'w-20' },
@@ -2476,27 +2484,8 @@ export function ResourcesView({
2476
2484
  const podMatchesProblemFilter = useCallback((pod: any, filters: string[]): boolean => {
2477
2485
  if (filters.length === 0) return true
2478
2486
  const problems = getPodProblems(pod)
2479
- const problemMessages = problems.map(p => p.message)
2480
2487
  const restarts = getPodRestarts(pod)
2481
-
2482
- return filters.some(filter => {
2483
- switch (filter) {
2484
- case 'CrashLoopBackOff':
2485
- return problemMessages.includes('CrashLoopBackOff')
2486
- case 'ImagePullBackOff':
2487
- return problemMessages.some(m => m.includes('ImagePull'))
2488
- case 'OOMKilled':
2489
- return problemMessages.includes('OOMKilled')
2490
- case 'Unschedulable':
2491
- return problemMessages.includes('Unschedulable')
2492
- case 'Not Ready':
2493
- return problemMessages.includes('Not Ready') || problemMessages.some(m => m.includes('Probe'))
2494
- case 'High Restarts':
2495
- return restarts > 5
2496
- default:
2497
- return false
2498
- }
2499
- })
2488
+ return filters.some(filter => podMatchesProblemCategory(problems, restarts, filter))
2500
2489
  }, [])
2501
2490
 
2502
2491
 
@@ -2526,9 +2515,17 @@ export function ResourcesView({
2526
2515
  )
2527
2516
  }
2528
2517
 
2529
- // Apply problem filters (pods only)
2530
- if (problemFilters.length > 0 && selectedKind.name.toLowerCase() === 'pods') {
2531
- result = result.filter((r: any) => podMatchesProblemFilter(r, problemFilters))
2518
+ // Apply problem filters
2519
+ if (problemFilters.length > 0) {
2520
+ const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
2521
+ if (kindLower === 'pods') {
2522
+ result = result.filter((r: any) => podMatchesProblemFilter(r, problemFilters))
2523
+ } else if (WORKLOAD_KINDS.has(kindLower)) {
2524
+ result = result.filter((r: any) => {
2525
+ const problems = getWorkloadProblems(r, kindLower)
2526
+ return problemFilters.some(f => workloadMatchesProblemCategory(problems, f))
2527
+ })
2528
+ }
2532
2529
  }
2533
2530
 
2534
2531
  // Apply inactive ReplicaSet filter (default: hide inactive)
@@ -2802,15 +2799,12 @@ export function ResourcesView({
2802
2799
 
2803
2800
  for (const pod of resources) {
2804
2801
  const podProblems = getPodProblems(pod)
2805
- const msgs = podProblems.map(p => p.message)
2806
2802
  const restarts = getPodRestarts(pod)
2807
-
2808
- if (msgs.includes('CrashLoopBackOff')) problemCounts['CrashLoopBackOff']++
2809
- if (msgs.some(m => m.includes('ImagePull'))) problemCounts['ImagePullBackOff']++
2810
- if (msgs.includes('OOMKilled')) problemCounts['OOMKilled']++
2811
- if (msgs.includes('Unschedulable')) problemCounts['Unschedulable']++
2812
- if (msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe'))) problemCounts['Not Ready']++
2813
- if (restarts > 5) problemCounts['High Restarts']++
2803
+ for (const category of POD_PROBLEMS) {
2804
+ if (podMatchesProblemCategory(podProblems, restarts, category)) {
2805
+ problemCounts[category]++
2806
+ }
2807
+ }
2814
2808
  }
2815
2809
 
2816
2810
  const activeProblems = POD_PROBLEMS
@@ -2821,6 +2815,28 @@ export function ResourcesView({
2821
2815
  }
2822
2816
  }
2823
2817
 
2818
+ // Workload-specific: compute problem counts
2819
+ if (WORKLOAD_KINDS.has(kindLower)) {
2820
+ const problemCounts: Record<string, number> = {}
2821
+ WORKLOAD_PROBLEMS.forEach(p => problemCounts[p] = 0)
2822
+
2823
+ for (const resource of resources) {
2824
+ const workloadProblems = getWorkloadProblems(resource, kindLower)
2825
+ for (const category of WORKLOAD_PROBLEMS) {
2826
+ if (workloadMatchesProblemCategory(workloadProblems, category)) {
2827
+ problemCounts[category]++
2828
+ }
2829
+ }
2830
+ }
2831
+
2832
+ const activeProblems = WORKLOAD_PROBLEMS
2833
+ .map(p => ({ value: p, count: problemCounts[p] }))
2834
+ .filter(p => p.count > 0)
2835
+ if (activeProblems.length > 0) {
2836
+ problems = activeProblems
2837
+ }
2838
+ }
2839
+
2824
2840
  // Compute available labels for label filtering
2825
2841
  const labelCounts: Record<string, number> = {}
2826
2842
  for (const r of resources) {
@@ -4021,12 +4037,21 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4021
4037
  const status = getPodStatus(resource)
4022
4038
  const problems = getPodProblems(resource)
4023
4039
  return (
4024
- <div className="flex items-center gap-2">
4025
- <span className={clsx('badge', status.color)}>
4040
+ <div className="flex items-center gap-2 min-w-0">
4041
+ <span className={clsx('badge truncate', status.color)} title={status.text}>
4026
4042
  {status.text}
4027
4043
  </span>
4028
- {problems.length > 0 && (
4029
- <Tooltip content={problems.map(p => p.message).join(', ')}>
4044
+ {problems.length > 1 && (
4045
+ <Tooltip content={
4046
+ <div className="space-y-0.5">
4047
+ {problems.map((p, i) => (
4048
+ <div key={i} className="flex items-center gap-1.5">
4049
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
4050
+ <span>{p.message}</span>
4051
+ </div>
4052
+ ))}
4053
+ </div>
4054
+ }>
4030
4055
  <span className="text-red-400">
4031
4056
  <AlertTriangle className="w-3.5 h-3.5" />
4032
4057
  </span>
@@ -4101,7 +4126,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4101
4126
  }
4102
4127
  }
4103
4128
 
4104
- function WorkloadCell({ resource, column }: { resource: any; kind: string; column: string }) {
4129
+ function WorkloadCell({ resource, column, kind }: { resource: any; kind: string; column: string }) {
4105
4130
  const status = resource.status || {}
4106
4131
  const spec = resource.spec || {}
4107
4132
 
@@ -4110,13 +4135,32 @@ function WorkloadCell({ resource, column }: { resource: any; kind: string; colum
4110
4135
  const desired = spec.replicas ?? 0
4111
4136
  const ready = status.readyReplicas || 0
4112
4137
  const allReady = ready === desired && desired > 0
4138
+ const problems = getWorkloadProblems(resource, kind)
4113
4139
  return (
4114
- <span className={clsx(
4115
- 'text-sm font-medium',
4116
- desired === 0 ? 'text-theme-text-secondary' : allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4117
- )}>
4118
- {ready}/{desired}
4119
- </span>
4140
+ <div className="flex items-center gap-1.5">
4141
+ <span className={clsx(
4142
+ 'text-sm font-medium',
4143
+ desired === 0 ? 'text-theme-text-secondary' : allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4144
+ )}>
4145
+ {ready}/{desired}
4146
+ </span>
4147
+ {problems.length > 0 && (
4148
+ <Tooltip content={
4149
+ <div className="space-y-0.5">
4150
+ {problems.map((p, i) => (
4151
+ <div key={i} className="flex items-center gap-1.5">
4152
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
4153
+ <span>{p.message}</span>
4154
+ </div>
4155
+ ))}
4156
+ </div>
4157
+ }>
4158
+ <span className="text-red-400">
4159
+ <AlertTriangle className="w-3.5 h-3.5" />
4160
+ </span>
4161
+ </Tooltip>
4162
+ )}
4163
+ </div>
4120
4164
  )
4121
4165
  }
4122
4166
  case 'upToDate':
@@ -4167,13 +4211,32 @@ function DaemonSetCell({ resource, column }: { resource: any; column: string })
4167
4211
  const desired = status.desiredNumberScheduled || 0
4168
4212
  const ready = status.numberReady || 0
4169
4213
  const allReady = ready === desired && desired > 0
4214
+ const problems = getWorkloadProblems(resource, 'daemonsets')
4170
4215
  return (
4171
- <span className={clsx(
4172
- 'text-sm font-medium',
4173
- allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4174
- )}>
4175
- {ready}
4176
- </span>
4216
+ <div className="flex items-center gap-1.5">
4217
+ <span className={clsx(
4218
+ 'text-sm font-medium',
4219
+ allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4220
+ )}>
4221
+ {ready}
4222
+ </span>
4223
+ {problems.length > 0 && (
4224
+ <Tooltip content={
4225
+ <div className="space-y-0.5">
4226
+ {problems.map((p, i) => (
4227
+ <div key={i} className="flex items-center gap-1.5">
4228
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
4229
+ <span>{p.message}</span>
4230
+ </div>
4231
+ ))}
4232
+ </div>
4233
+ }>
4234
+ <span className="text-red-400">
4235
+ <AlertTriangle className="w-3.5 h-3.5" />
4236
+ </span>
4237
+ </Tooltip>
4238
+ )}
4239
+ </div>
4177
4240
  )
4178
4241
  }
4179
4242
  case 'upToDate':
@@ -1,4 +1,4 @@
1
- import { GitBranch, FolderTree, Settings, Target, XCircle } from 'lucide-react'
1
+ import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
4
  import { formatAge } from '../resource-utils'
@@ -16,6 +16,62 @@ interface ArgoApplicationRendererProps {
16
16
  isTerminating?: boolean
17
17
  }
18
18
 
19
+ function SourceProperties({ source }: { source: any }) {
20
+ if (!source) return null
21
+ return (
22
+ <>
23
+ <Property label="Repository" value={source.repoURL} />
24
+ {source.path && <Property label="Path" value={source.path} />}
25
+ {source.targetRevision && (
26
+ <Property
27
+ label="Target Revision"
28
+ value={
29
+ <span className="flex items-center gap-1">
30
+ <GitBranch className="w-3.5 h-3.5" />
31
+ {source.targetRevision}
32
+ </span>
33
+ }
34
+ />
35
+ )}
36
+ {source.chart && <Property label="Helm Chart" value={source.chart} />}
37
+ {source.helm?.valueFiles && source.helm.valueFiles.length > 0 && (
38
+ <Property label="Value Files" value={source.helm.valueFiles.join(', ')} />
39
+ )}
40
+ {source.helm?.parameters && source.helm.parameters.length > 0 && (
41
+ <Property
42
+ label="Helm Parameters"
43
+ value={
44
+ <div className="flex flex-wrap gap-1">
45
+ {source.helm.parameters.filter(Boolean).map((p: { name: string; value: string }, i: number) => (
46
+ <span key={i} className="badge-sm bg-theme-elevated text-theme-text-secondary font-mono">
47
+ {p.name ?? '?'}={p.value ?? ''}
48
+ </span>
49
+ ))}
50
+ </div>
51
+ }
52
+ />
53
+ )}
54
+ {source.kustomize?.namePrefix && (
55
+ <Property label="Kustomize Prefix" value={source.kustomize.namePrefix} />
56
+ )}
57
+ </>
58
+ )
59
+ }
60
+
61
+ function isSyncResourceFailed(res: any): boolean {
62
+ return res.status === 'SyncFailed' || res.hookPhase === 'Failed' || res.hookPhase === 'Error'
63
+ }
64
+
65
+ function getSyncResourceBadgeClass(status: string, hookPhase?: string): string {
66
+ if (status === 'SyncFailed' || hookPhase === 'Failed' || hookPhase === 'Error') {
67
+ return 'status-unhealthy'
68
+ }
69
+ if (status === 'Synced') return 'status-healthy'
70
+ if (status === 'Pruned') return 'status-neutral'
71
+ if (status === 'PruneSkipped') return 'status-degraded'
72
+ return 'status-unknown'
73
+ }
74
+
19
75
  export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: ArgoApplicationRendererProps) {
20
76
  const status = (data.status || {}) as ArgoAppStatus & {
21
77
  resources?: ArgoResource[]
@@ -47,8 +103,9 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
47
103
  problems.push({ color: 'yellow', message: 'Application is out of sync with git' })
48
104
  }
49
105
 
50
- // Extract source info
51
- const source = spec.source || {}
106
+ // Extract source info — support both spec.source (single) and spec.sources (multi, ArgoCD 2.6+)
107
+ const sources: any[] = (spec.sources && spec.sources.length > 0) ? spec.sources : (spec.source ? [spec.source] : [])
108
+ const isMultiSource = Array.isArray(spec.sources) && spec.sources.length > 0
52
109
  const destination = spec.destination || {}
53
110
  const syncPolicy = spec.syncPolicy || {}
54
111
  const operationState = status.operationState
@@ -56,6 +113,12 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
56
113
  // Check if sync is in progress
57
114
  const isSyncing = operationState?.phase === 'Running'
58
115
 
116
+ // Extract sync result resources for per-resource failure details
117
+ const syncResultResources: any[] = operationState?.syncResult?.resources || []
118
+ const failedSyncResources = syncResultResources.filter(isSyncResourceFailed)
119
+ const otherSyncResources = syncResultResources.filter((r: any) => !isSyncResourceFailed(r))
120
+ const sortedSyncResources = [...failedSyncResources, ...otherSyncResources]
121
+
59
122
  return (
60
123
  <>
61
124
  <ProblemAlerts problems={problems} />
@@ -90,31 +153,27 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
90
153
  </div>
91
154
  </Section>
92
155
 
93
- {/* Source section */}
94
- <Section title="Source" icon={FolderTree}>
95
- <PropertyList>
96
- <Property label="Repository" value={source.repoURL} />
97
- {source.path && <Property label="Path" value={source.path} />}
98
- {source.targetRevision && (
99
- <Property
100
- label="Target Revision"
101
- value={
102
- <span className="flex items-center gap-1">
103
- <GitBranch className="w-3.5 h-3.5" />
104
- {source.targetRevision}
105
- </span>
106
- }
107
- />
108
- )}
109
- {source.chart && <Property label="Helm Chart" value={source.chart} />}
110
- {source.helm?.valueFiles && source.helm.valueFiles.length > 0 && (
111
- <Property label="Value Files" value={source.helm.valueFiles.join(', ')} />
112
- )}
113
- {source.kustomize?.namePrefix && (
114
- <Property label="Kustomize Prefix" value={source.kustomize.namePrefix} />
115
- )}
116
- </PropertyList>
117
- </Section>
156
+ {/* Source section — renders each source for multi-source apps */}
157
+ {isMultiSource ? (
158
+ <Section title={`Sources (${sources.length})`} icon={FolderTree}>
159
+ <div className="space-y-3">
160
+ {sources.map((src: any, idx: number) => (
161
+ <div key={idx} className="card-inner">
162
+ <div className="text-xs font-medium text-theme-text-tertiary mb-1.5">Source {idx + 1}</div>
163
+ <PropertyList>
164
+ <SourceProperties source={src} />
165
+ </PropertyList>
166
+ </div>
167
+ ))}
168
+ </div>
169
+ </Section>
170
+ ) : sources.length > 0 ? (
171
+ <Section title="Source" icon={FolderTree}>
172
+ <PropertyList>
173
+ <SourceProperties source={sources[0]} />
174
+ </PropertyList>
175
+ </Section>
176
+ ) : null}
118
177
 
119
178
  {/* Destination section */}
120
179
  <Section title="Destination" icon={Target}>
@@ -192,9 +251,22 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
192
251
  {operationState.message && (
193
252
  <Property label="Message" value={operationState.message} />
194
253
  )}
254
+ {operationState.startedAt && (
255
+ <Property label="Started" value={formatAge(operationState.startedAt)} />
256
+ )}
195
257
  {operationState.finishedAt && (
196
258
  <Property label="Finished" value={formatAge(operationState.finishedAt)} />
197
259
  )}
260
+ {operationState.retryCount != null && (
261
+ <Property
262
+ label="Retries"
263
+ value={
264
+ syncPolicy.retry?.limit
265
+ ? `${operationState.retryCount}/${syncPolicy.retry.limit}`
266
+ : String(operationState.retryCount)
267
+ }
268
+ />
269
+ )}
198
270
  {operationState.syncResult?.revision && (
199
271
  <Property label="Revision" value={operationState.syncResult.revision} />
200
272
  )}
@@ -202,6 +274,55 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
202
274
  </Section>
203
275
  )}
204
276
 
277
+ {/* Sync Result Resources — per-resource sync status with failure details */}
278
+ {sortedSyncResources.length > 0 && (
279
+ <Section
280
+ title={`Sync Result Resources (${sortedSyncResources.length})`}
281
+ icon={ListChecks}
282
+ defaultExpanded={failedSyncResources.length > 0}
283
+ >
284
+ <div className="space-y-1.5">
285
+ {sortedSyncResources.map((res: any, idx: number) => {
286
+ const isFailed = isSyncResourceFailed(res)
287
+ return (
288
+ <div
289
+ key={`${res.kind}-${res.namespace}-${res.name}-${idx}`}
290
+ className={clsx(
291
+ 'card-inner px-3 py-2',
292
+ isFailed && 'border-l-2 border-red-500'
293
+ )}
294
+ >
295
+ <div className="flex items-center gap-2 text-sm">
296
+ <span className={clsx('badge badge-sm', getSyncResourceBadgeClass(res.status, res.hookPhase))}>
297
+ {res.status || res.hookPhase || 'Unknown'}
298
+ </span>
299
+ <span className="text-theme-text-primary">
300
+ {res.kind}/{res.name}
301
+ </span>
302
+ {res.namespace && (
303
+ <span className="text-theme-text-tertiary text-xs">({res.namespace})</span>
304
+ )}
305
+ </div>
306
+ {res.message && (
307
+ <div className={clsx(
308
+ 'text-xs mt-1 break-all',
309
+ isFailed ? 'text-red-400' : 'text-theme-text-secondary'
310
+ )}>
311
+ {res.message}
312
+ </div>
313
+ )}
314
+ {res.hookPhase && res.hookPhase !== res.status && (
315
+ <div className="text-xs text-theme-text-tertiary mt-0.5">
316
+ Hook: {res.hookPhase}
317
+ </div>
318
+ )}
319
+ </div>
320
+ )
321
+ })}
322
+ </div>
323
+ </Section>
324
+ )}
325
+
205
326
  {/* Managed Resources */}
206
327
  {managedResources.length > 0 && (
207
328
  <ManagedResourcesList
@@ -210,6 +331,47 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
210
331
  />
211
332
  )}
212
333
 
334
+ {/* Revision History */}
335
+ {status.history && status.history.length > 0 && (
336
+ <Section title={`Revision History (${status.history.length})`} icon={History} defaultExpanded={false}>
337
+ <div className="space-y-2 max-h-48 overflow-y-auto">
338
+ {status.history
339
+ .slice(0, 10)
340
+ .map((entry, idx) => (
341
+ <div
342
+ key={entry.id ?? idx}
343
+ className={clsx(
344
+ 'p-2 rounded text-sm',
345
+ idx === 0
346
+ ? 'bg-green-500/10 border border-green-500/30'
347
+ : 'bg-theme-elevated/30'
348
+ )}
349
+ >
350
+ <div className="flex items-center justify-between">
351
+ <span className="font-medium text-theme-text-primary font-mono text-xs">
352
+ {entry.revision
353
+ ? entry.revision.length > 12
354
+ ? entry.revision.slice(0, 12)
355
+ : entry.revision
356
+ : '-'}
357
+ </span>
358
+ {entry.deployedAt && (
359
+ <span className="text-xs text-theme-text-tertiary">
360
+ {formatAge(entry.deployedAt)}
361
+ </span>
362
+ )}
363
+ </div>
364
+ {entry.source?.path && (
365
+ <div className="text-xs text-theme-text-secondary mt-0.5 truncate" title={entry.source.path}>
366
+ {entry.source.path}
367
+ </div>
368
+ )}
369
+ </div>
370
+ ))}
371
+ </div>
372
+ </Section>
373
+ )}
374
+
213
375
  {/* Revision Info */}
214
376
  {(status.sync?.revision || status.reconciledAt) && (
215
377
  <Section title="Revision Info" defaultExpanded={false}>
@@ -25,6 +25,13 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
25
25
  const target = getCNPGBackupTarget(data)
26
26
  const clusterName = getCNPGBackupCluster(data)
27
27
 
28
+ // WAL range fields
29
+ const beginWal = data.status?.beginWal
30
+ const endWal = data.status?.endWal
31
+ const beginLSN = data.status?.beginLSN
32
+ const endLSN = data.status?.endLSN
33
+ const hasWalRange = beginWal || endWal || beginLSN || endLSN
34
+
28
35
  return (
29
36
  <>
30
37
  {/* Problem alerts */}
@@ -43,6 +50,9 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
43
50
  <Property label="Method" value={getCNPGBackupMethod(data)} />
44
51
  <Property label="Duration" value={getCNPGBackupDuration(data)} />
45
52
  <Property label="Backup Name" value={getCNPGBackupName(data)} />
53
+ {data.status?.instanceID?.podName && (
54
+ <Property label="Instance" value={data.status.instanceID.podName} />
55
+ )}
46
56
  </PropertyList>
47
57
  {data.status?.startedAt && (
48
58
  <div className="mt-2 pt-2 border-t border-theme-border">
@@ -76,6 +86,18 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
76
86
  </PropertyList>
77
87
  </Section>
78
88
 
89
+ {/* WAL Range */}
90
+ {hasWalRange && (
91
+ <Section title="WAL Range" defaultExpanded>
92
+ <PropertyList>
93
+ {beginWal && <Property label="Begin WAL" value={beginWal} />}
94
+ {endWal && <Property label="End WAL" value={endWal} />}
95
+ {beginLSN && <Property label="Begin LSN" value={beginLSN} />}
96
+ {endLSN && <Property label="End LSN" value={endLSN} />}
97
+ </PropertyList>
98
+ </Section>
99
+ )}
100
+
79
101
  {/* Target - for PITR backups */}
80
102
  {target !== '-' && (
81
103
  <Section title="Target" defaultExpanded>