@skyhook-io/k8s-ui 1.3.2 → 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 (78) hide show
  1. package/package.json +2 -2
  2. package/src/components/audit/AuditAlerts.tsx +88 -0
  3. package/src/components/audit/AuditCard.tsx +130 -0
  4. package/src/components/audit/AuditFindingsTable.tsx +526 -0
  5. package/src/components/audit/index.ts +3 -0
  6. package/src/components/dock/DockContext.tsx +5 -2
  7. package/src/components/dock/LocalTerminalTab.tsx +11 -0
  8. package/src/components/resources/ResourcesView.tsx +126 -49
  9. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  10. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  12. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  13. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  14. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  15. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +445 -0
  16. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  17. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  18. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  19. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +160 -0
  20. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  21. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  22. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  24. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  25. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  26. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  27. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  28. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  29. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  30. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  31. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +17 -57
  34. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  35. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  36. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  38. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  39. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  41. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  42. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  43. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  44. package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
  45. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  47. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  49. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  50. package/src/components/resources/renderers/index.ts +3 -0
  51. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  52. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  53. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  54. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  55. package/src/components/resources/resource-utils.ts +131 -33
  56. package/src/components/shared/CreateResourceDialog.tsx +236 -0
  57. package/src/components/shared/EditableYamlView.tsx +16 -1
  58. package/src/components/shared/ResourceActionsBar.tsx +9 -7
  59. package/src/components/shared/ResourceRendererDispatch.tsx +13 -6
  60. package/src/components/shared/index.ts +1 -0
  61. package/src/components/timeline/TimelineList.tsx +11 -4
  62. package/src/components/topology/K8sResourceNode.tsx +15 -0
  63. package/src/components/topology/TopologyControls.tsx +21 -1
  64. package/src/components/topology/TopologyGraph.tsx +1 -1
  65. package/src/components/ui/Badge.tsx +8 -0
  66. package/src/components/ui/drawer-components.tsx +68 -5
  67. package/src/components/workload/WorkloadView.tsx +55 -29
  68. package/src/index.ts +3 -0
  69. package/src/theme/variables.css +1 -1
  70. package/src/types/core.ts +2 -1
  71. package/src/types/gitops.ts +8 -0
  72. package/src/utils/badge-colors.ts +7 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/k8s-errors.ts +142 -0
  75. package/src/utils/navigation.test.ts +29 -2
  76. package/src/utils/navigation.ts +45 -24
  77. package/src/utils/resource-icons.ts +3 -0
  78. package/src/utils/skeleton-yaml.ts +302 -0
@@ -20,6 +20,7 @@ import {
20
20
  Tag,
21
21
  Copy,
22
22
  Check,
23
+ Plus,
23
24
  } from 'lucide-react'
24
25
  import { clsx } from 'clsx'
25
26
  import { ResourceBar } from '../ui/ResourceBar'
@@ -30,6 +31,8 @@ import {
30
31
  getPodStatus,
31
32
  getPodRestarts,
32
33
  getPodProblems,
34
+ getWorkloadProblems,
35
+ workloadMatchesProblemCategory,
33
36
  getContainerSquareStates,
34
37
  getWorkloadImages,
35
38
  getWorkloadConditions,
@@ -115,6 +118,8 @@ import {
115
118
  getCellFilterValue,
116
119
  parseColumnFilters,
117
120
  serializeColumnFilters,
121
+ podMatchesProblemCategory,
122
+ SEVERITY_DOT_COLOR,
118
123
  } from './resource-utils'
119
124
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
120
125
  import { Tooltip } from '../ui/Tooltip'
@@ -139,7 +144,9 @@ import { ResourcesSidebar } from './ResourcesSidebar'
139
144
  import type { SelectedKindInfo } from './ResourcesSidebar'
140
145
 
141
146
  // Pod problem filter options (special multi-select, not a single column value)
142
- 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'])
143
150
 
144
151
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
145
152
  const SKIP_FILTER_COLUMNS = new Set([
@@ -454,6 +461,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
454
461
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
455
462
  { key: 'status', label: 'Status', width: 'w-24' },
456
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 },
457
466
  { key: 'nodePool', label: 'Node Pool', width: 'w-32' },
458
467
  { key: 'nodeName', label: 'Node', width: 'w-40', hideOnMobile: true },
459
468
  { key: 'age', label: 'Age', width: 'w-20' },
@@ -1421,6 +1430,8 @@ interface ResourcesViewProps {
1421
1430
  onSelectedKindChange?: (kind: { name: string; kind: string; group: string }) => void
1422
1431
  /** When true, the sidebar is not rendered. Useful when a standalone ResourcesSidebar is used externally. */
1423
1432
  hideSidebar?: boolean
1433
+ /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
1434
+ onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
1424
1435
  }
1425
1436
 
1426
1437
  // Default selected kind
@@ -1492,6 +1503,7 @@ export function ResourcesView({
1492
1503
  onOpenWorkloadLogs,
1493
1504
  onSelectedKindChange,
1494
1505
  hideSidebar = false,
1506
+ onCreateResource,
1495
1507
  }: ResourcesViewProps) {
1496
1508
  const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
1497
1509
  const initialFilters = getInitialFiltersFromURL()
@@ -2472,27 +2484,8 @@ export function ResourcesView({
2472
2484
  const podMatchesProblemFilter = useCallback((pod: any, filters: string[]): boolean => {
2473
2485
  if (filters.length === 0) return true
2474
2486
  const problems = getPodProblems(pod)
2475
- const problemMessages = problems.map(p => p.message)
2476
2487
  const restarts = getPodRestarts(pod)
2477
-
2478
- return filters.some(filter => {
2479
- switch (filter) {
2480
- case 'CrashLoopBackOff':
2481
- return problemMessages.includes('CrashLoopBackOff')
2482
- case 'ImagePullBackOff':
2483
- return problemMessages.some(m => m.includes('ImagePull'))
2484
- case 'OOMKilled':
2485
- return problemMessages.includes('OOMKilled')
2486
- case 'Unschedulable':
2487
- return problemMessages.includes('Unschedulable')
2488
- case 'Not Ready':
2489
- return problemMessages.includes('Not Ready') || problemMessages.some(m => m.includes('Probe'))
2490
- case 'High Restarts':
2491
- return restarts > 5
2492
- default:
2493
- return false
2494
- }
2495
- })
2488
+ return filters.some(filter => podMatchesProblemCategory(problems, restarts, filter))
2496
2489
  }, [])
2497
2490
 
2498
2491
 
@@ -2522,9 +2515,17 @@ export function ResourcesView({
2522
2515
  )
2523
2516
  }
2524
2517
 
2525
- // Apply problem filters (pods only)
2526
- if (problemFilters.length > 0 && selectedKind.name.toLowerCase() === 'pods') {
2527
- 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
+ }
2528
2529
  }
2529
2530
 
2530
2531
  // Apply inactive ReplicaSet filter (default: hide inactive)
@@ -2798,15 +2799,12 @@ export function ResourcesView({
2798
2799
 
2799
2800
  for (const pod of resources) {
2800
2801
  const podProblems = getPodProblems(pod)
2801
- const msgs = podProblems.map(p => p.message)
2802
2802
  const restarts = getPodRestarts(pod)
2803
-
2804
- if (msgs.includes('CrashLoopBackOff')) problemCounts['CrashLoopBackOff']++
2805
- if (msgs.some(m => m.includes('ImagePull'))) problemCounts['ImagePullBackOff']++
2806
- if (msgs.includes('OOMKilled')) problemCounts['OOMKilled']++
2807
- if (msgs.includes('Unschedulable')) problemCounts['Unschedulable']++
2808
- if (msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe'))) problemCounts['Not Ready']++
2809
- if (restarts > 5) problemCounts['High Restarts']++
2803
+ for (const category of POD_PROBLEMS) {
2804
+ if (podMatchesProblemCategory(podProblems, restarts, category)) {
2805
+ problemCounts[category]++
2806
+ }
2807
+ }
2810
2808
  }
2811
2809
 
2812
2810
  const activeProblems = POD_PROBLEMS
@@ -2817,6 +2815,28 @@ export function ResourcesView({
2817
2815
  }
2818
2816
  }
2819
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
+
2820
2840
  // Compute available labels for label filtering
2821
2841
  const labelCounts: Record<string, number> = {}
2822
2842
  for (const r of resources) {
@@ -3157,6 +3177,16 @@ export function ResourcesView({
3157
3177
  : <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
3158
3178
  }
3159
3179
  </button>
3180
+ {onCreateResource && (
3181
+ <Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
3182
+ <button
3183
+ onClick={() => onCreateResource(selectedKind)}
3184
+ className="p-2 hover:bg-theme-elevated rounded-lg text-theme-text-secondary hover:text-theme-text-primary transition-colors"
3185
+ >
3186
+ <Plus className="w-4 h-4" />
3187
+ </button>
3188
+ </Tooltip>
3189
+ )}
3160
3190
  </div>
3161
3191
 
3162
3192
  {/* Table */}
@@ -4007,12 +4037,21 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4007
4037
  const status = getPodStatus(resource)
4008
4038
  const problems = getPodProblems(resource)
4009
4039
  return (
4010
- <div className="flex items-center gap-2">
4011
- <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}>
4012
4042
  {status.text}
4013
4043
  </span>
4014
- {problems.length > 0 && (
4015
- <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
+ }>
4016
4055
  <span className="text-red-400">
4017
4056
  <AlertTriangle className="w-3.5 h-3.5" />
4018
4057
  </span>
@@ -4087,7 +4126,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4087
4126
  }
4088
4127
  }
4089
4128
 
4090
- function WorkloadCell({ resource, column }: { resource: any; kind: string; column: string }) {
4129
+ function WorkloadCell({ resource, column, kind }: { resource: any; kind: string; column: string }) {
4091
4130
  const status = resource.status || {}
4092
4131
  const spec = resource.spec || {}
4093
4132
 
@@ -4096,13 +4135,32 @@ function WorkloadCell({ resource, column }: { resource: any; kind: string; colum
4096
4135
  const desired = spec.replicas ?? 0
4097
4136
  const ready = status.readyReplicas || 0
4098
4137
  const allReady = ready === desired && desired > 0
4138
+ const problems = getWorkloadProblems(resource, kind)
4099
4139
  return (
4100
- <span className={clsx(
4101
- 'text-sm font-medium',
4102
- desired === 0 ? 'text-theme-text-secondary' : allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4103
- )}>
4104
- {ready}/{desired}
4105
- </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>
4106
4164
  )
4107
4165
  }
4108
4166
  case 'upToDate':
@@ -4153,13 +4211,32 @@ function DaemonSetCell({ resource, column }: { resource: any; column: string })
4153
4211
  const desired = status.desiredNumberScheduled || 0
4154
4212
  const ready = status.numberReady || 0
4155
4213
  const allReady = ready === desired && desired > 0
4214
+ const problems = getWorkloadProblems(resource, 'daemonsets')
4156
4215
  return (
4157
- <span className={clsx(
4158
- 'text-sm font-medium',
4159
- allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4160
- )}>
4161
- {ready}
4162
- </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>
4163
4240
  )
4164
4241
  }
4165
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>