@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
@@ -97,6 +97,8 @@ import {
97
97
  SealedSecretRenderer,
98
98
  WorkflowTemplateRenderer,
99
99
  NetworkPolicyRenderer,
100
+ CiliumNetworkPolicyRenderer,
101
+ ClusterNetworkPolicyRenderer,
100
102
  PodDisruptionBudgetRenderer,
101
103
  ServiceAccountRenderer,
102
104
  RoleRenderer,
@@ -212,7 +214,10 @@ const KNOWN_KINDS = new Set([
212
214
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
213
215
  'orders', 'challenges',
214
216
  'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates',
215
- 'networkpolicies', 'poddisruptionbudgets', 'serviceaccounts',
217
+ 'networkpolicies', 'networkpolicy',
218
+ 'ciliumnetworkpolicies', 'ciliumnetworkpolicy', 'ciliumclusterwidenetworkpolicies', 'ciliumclusterwidenetworkpolicy',
219
+ 'clusternetworkpolicies', 'clusternetworkpolicy',
220
+ 'poddisruptionbudgets', 'serviceaccounts',
216
221
  'roles', 'clusterroles', 'rolebindings', 'clusterrolebindings',
217
222
  'events', 'gitrepositories', 'ocirepositories', 'helmrepositories',
218
223
  'kustomizations', 'helmreleases', 'alerts', 'applications',
@@ -348,9 +353,11 @@ export function ResourceRendererDispatch({
348
353
  {kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
349
354
  {kind === 'tcproutes' && <SimpleRouteRenderer data={data} kind="TCPRoute" onNavigate={onNavigate} />}
350
355
  {kind === 'tlsroutes' && <SimpleRouteRenderer data={data} kind="TLSRoute" onNavigate={onNavigate} />}
351
- {kind === 'sealedsecrets' && <SealedSecretRenderer data={data} />}
356
+ {kind === 'sealedsecrets' && <SealedSecretRenderer data={data} onNavigate={onNavigate} />}
352
357
  {kind === 'workflowtemplates' && <WorkflowTemplateRenderer data={data} />}
353
- {kind === 'networkpolicies' && <NetworkPolicyRenderer data={data} />}
358
+ {(kind === 'networkpolicies' || kind === 'networkpolicy') && <NetworkPolicyRenderer data={data} />}
359
+ {(kind === 'ciliumnetworkpolicies' || kind === 'ciliumnetworkpolicy' || kind === 'ciliumclusterwidenetworkpolicies' || kind === 'ciliumclusterwidenetworkpolicy') && <CiliumNetworkPolicyRenderer data={data} />}
360
+ {(kind === 'clusternetworkpolicies' || kind === 'clusternetworkpolicy') && <ClusterNetworkPolicyRenderer data={data} />}
354
361
  {kind === 'poddisruptionbudgets' && <PodDisruptionBudgetRenderer data={data} />}
355
362
  {kind === 'serviceaccounts' && <ServiceAccountRenderer data={data} />}
356
363
  {(kind === 'roles' || kind === 'clusterroles') && <RoleRenderer data={data} />}
@@ -359,8 +366,8 @@ export function ResourceRendererDispatch({
359
366
  {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
360
367
  {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
361
368
  {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
362
- {kind === 'kustomizations' && <KustomizationRenderer data={data} />}
363
- {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} />}
369
+ {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
370
+ {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
364
371
  {kind === 'alerts' && <AlertRenderer data={data} />}
365
372
  {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
366
373
  {kind === 'nodepools' && <KarpenterNodePoolRenderer data={data} onNavigate={onNavigate} />}
@@ -388,7 +395,7 @@ export function ResourceRendererDispatch({
388
395
  {kind === 'backupstoragelocations' && <VeleroBSLRenderer data={data} />}
389
396
  {kind === 'volumesnapshotlocations' && <VeleroVSLRenderer data={data} />}
390
397
  {kind === 'externalsecrets' && <ExternalSecretRenderer data={data} onNavigate={onNavigate} />}
391
- {kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} />}
398
+ {kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} onNavigate={onNavigate} />}
392
399
  {(kind === 'secretstores' || kind === 'clustersecretstores') && <SecretStoreRenderer data={data} />}
393
400
  {kind === 'clusters' && <CNPGClusterRenderer data={data} onNavigate={onNavigate} />}
394
401
  {kind === 'scheduledbackups' && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
@@ -1,3 +1,4 @@
1
1
  export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
2
2
  export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
3
3
  export { ResourceActionsBar, RevisionHistoryDialog } from './ResourceActionsBar'
4
+ export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
@@ -603,10 +603,17 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick }: ActivityCar
603
603
  {item.owner && (
604
604
  <span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
605
605
  <span className="text-xs text-theme-text-quaternary">←</span>
606
- <ResourceRefBadge
607
- resourceRef={{ kind: item.owner.kind, namespace: item.namespace, name: item.owner.name }}
608
- onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
609
- />
606
+ {item.owner.kind === 'ReplicaSet' ? (
607
+ <ResourceRefBadge
608
+ resourceRef={{ kind: 'Deployment', namespace: item.namespace, name: item.owner.name.replace(/-[a-z0-9]+$/, '') }}
609
+ onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
610
+ />
611
+ ) : (
612
+ <ResourceRefBadge
613
+ resourceRef={{ kind: item.owner.kind, namespace: item.namespace, name: item.owner.name }}
614
+ onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
615
+ />
616
+ )}
610
617
  </span>
611
618
  )}
612
619
  {item.createdAt && (
@@ -302,6 +302,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
302
302
  const canCollapse = isPodGroup && onCollapse && isExpanded
303
303
  const statusIssue = nodeData.statusIssue as string | undefined
304
304
  const issueTooltip = getIssueTooltip(statusIssue)
305
+ const policyStatus = nodeData.policyStatus as string | undefined
305
306
 
306
307
  // CSS class for icon (replaces Lucide SVG - saves ~5 DOM elements per node)
307
308
  const iconClass = `topology-icon topology-icon-${kind.toLowerCase()}`
@@ -417,6 +418,20 @@ export const K8sResourceNode = memo(function K8sResourceNode({
417
418
  )}
418
419
  </div>
419
420
 
421
+ {/* Network Policy coverage indicator */}
422
+ {policyStatus && (
423
+ <Tooltip content={policyStatus === 'protected' ? 'Protected by NetworkPolicy' : 'No NetworkPolicy coverage'} position="right">
424
+ <span className={clsx(
425
+ 'inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold cursor-help',
426
+ policyStatus === 'protected'
427
+ ? 'bg-green-500/20 text-green-500 border border-green-500/30'
428
+ : 'bg-yellow-500/20 text-yellow-500 border border-yellow-500/30',
429
+ )}>
430
+ {policyStatus === 'protected' ? '✓' : '!'}
431
+ </span>
432
+ </Tooltip>
433
+ )}
434
+
420
435
  {/* Name */}
421
436
  <div className="text-sm font-medium text-theme-text-primary truncate pr-1">
422
437
  {name}
@@ -1,4 +1,4 @@
1
- import { FolderTree } from 'lucide-react'
1
+ import { FolderTree, ShieldCheck } from 'lucide-react'
2
2
  import type { TopologyMode, GroupingMode } from '../../types/core'
3
3
 
4
4
  interface TopologyControlsProps {
@@ -7,6 +7,8 @@ interface TopologyControlsProps {
7
7
  groupingMode: GroupingMode
8
8
  onGroupingModeChange: (mode: GroupingMode) => void
9
9
  showNoGrouping?: boolean
10
+ showPolicyEffect?: boolean
11
+ onShowPolicyEffectChange?: (show: boolean) => void
10
12
  }
11
13
 
12
14
  export function TopologyControls({
@@ -15,9 +17,27 @@ export function TopologyControls({
15
17
  groupingMode,
16
18
  onGroupingModeChange,
17
19
  showNoGrouping = true,
20
+ showPolicyEffect = false,
21
+ onShowPolicyEffectChange,
18
22
  }: TopologyControlsProps) {
19
23
  return (
20
24
  <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
25
+ {/* Policy effect toggle */}
26
+ {onShowPolicyEffectChange && (
27
+ <button
28
+ onClick={() => onShowPolicyEffectChange(!showPolicyEffect)}
29
+ className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg border transition-colors ${
30
+ showPolicyEffect
31
+ ? 'bg-indigo-600 text-white border-indigo-600'
32
+ : 'bg-theme-surface/90 backdrop-blur text-theme-text-secondary border-theme-border hover:text-theme-text-primary'
33
+ }`}
34
+ title="Show NetworkPolicy effects on edges"
35
+ >
36
+ <ShieldCheck className="w-3.5 h-3.5" />
37
+ Policies
38
+ </button>
39
+ )}
40
+
21
41
  {/* Grouping selector */}
22
42
  <div className="flex items-center gap-1.5 px-2 py-1.5 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg">
23
43
  <FolderTree className="w-3.5 h-3.5 text-theme-text-secondary" />
@@ -73,7 +73,7 @@ const LARGE_CLUSTER_NS_THRESHOLD = 5
73
73
 
74
74
  // Build edges, handling collapsed groups
75
75
  function buildEdges(
76
- topologyEdges: { id: string; source: string; target: string; type: string }[],
76
+ topologyEdges: TopologyEdge[],
77
77
  collapsedGroups: Set<string>,
78
78
  groupMap: Map<string, string[]>,
79
79
  groupingMode: GroupingMode,
@@ -73,6 +73,10 @@ const KIND: Record<string, string> = {
73
73
  HorizontalPodAutoscaler: 'bg-pink-100 text-pink-800 border-pink-300 dark:bg-pink-950/50 dark:text-pink-300 dark:border-pink-700/40',
74
74
  PersistentVolumeClaim: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
75
75
  PodDisruptionBudget: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
76
+ NetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
77
+ CiliumNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
78
+ CiliumClusterwideNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
79
+ ClusterNetworkPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
76
80
 
77
81
  // Cluster-scoped
78
82
  Node: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
@@ -147,6 +151,10 @@ export function getKindColorClass(kind: string): string {
147
151
  horizontalpodautoscalers: 'HorizontalPodAutoscaler',
148
152
  persistentvolumeclaims: 'PersistentVolumeClaim',
149
153
  poddisruptionbudgets: 'PodDisruptionBudget',
154
+ networkpolicies: 'NetworkPolicy',
155
+ ciliumnetworkpolicies: 'CiliumNetworkPolicy',
156
+ ciliumclusterwidenetworkpolicies: 'CiliumClusterwideNetworkPolicy',
157
+ clusternetworkpolicies: 'ClusterNetworkPolicy',
150
158
  rollouts: 'Rollout', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
151
159
  events: 'Event', helmreleases: 'HelmRelease',
152
160
  }
@@ -28,6 +28,53 @@ export function KeyValueBadgeList({ items }: { items: Record<string, unknown> |
28
28
  )
29
29
  }
30
30
 
31
+ /**
32
+ * Renders a Kubernetes label selector (matchLabels + matchExpressions).
33
+ * Accepts either a full selector object `{ matchLabels?, matchExpressions? }`
34
+ * or a flat `Record<string, string>` (e.g. Service.spec.selector).
35
+ * Pass `inline` for flow-inline rendering (e.g. within a sentence).
36
+ */
37
+ export function LabelSelectorDisplay({
38
+ selector,
39
+ emptyText = 'All',
40
+ inline = false,
41
+ }: {
42
+ selector: any
43
+ emptyText?: string
44
+ inline?: boolean
45
+ }) {
46
+ if (!selector) {
47
+ return <span className="text-xs text-theme-text-tertiary">{emptyText}</span>
48
+ }
49
+
50
+ // Detect flat selector (e.g. Service.spec.selector has no matchLabels wrapper)
51
+ const isFlat = typeof selector === 'object' && !Array.isArray(selector) && !selector.matchLabels && !selector.matchExpressions
52
+ const matchLabels: Record<string, unknown> = isFlat ? selector : (selector.matchLabels || {})
53
+ const matchExpressions: Array<{ key: string; operator: string; values?: string[] }> = selector.matchExpressions || []
54
+
55
+ const hasLabels = Object.keys(matchLabels).length > 0
56
+ const hasExpressions = matchExpressions.length > 0
57
+
58
+ if (!hasLabels && !hasExpressions) {
59
+ return <span className="text-xs text-theme-text-tertiary">{emptyText}</span>
60
+ }
61
+
62
+ const Wrapper = inline ? 'span' : 'div'
63
+
64
+ return (
65
+ <Wrapper className={inline ? 'inline-flex flex-wrap gap-1 align-middle' : 'flex flex-wrap gap-1'}>
66
+ {Object.entries(matchLabels).map(([k, v]) => (
67
+ <KeyValueBadge key={`l-${k}`} k={k} v={String(v)} />
68
+ ))}
69
+ {matchExpressions.map((expr, i) => (
70
+ <span key={`e-${i}`} className="badge bg-theme-elevated text-theme-text-secondary">
71
+ {expr.key} {expr.operator}{expr.values && expr.values.length > 0 ? ` ${expr.values.join(', ')}` : ''}
72
+ </span>
73
+ ))}
74
+ </Wrapper>
75
+ )
76
+ }
77
+
31
78
  interface SectionProps {
32
79
  title: string
33
80
  icon?: React.ComponentType<{ className?: string }>
@@ -117,7 +164,7 @@ export function Property({ label, value, copyable, onCopy, copied }: PropertyPro
117
164
 
118
165
  return (
119
166
  <div className="flex items-start gap-2 text-sm">
120
- <span className="text-theme-text-tertiary w-28 shrink-0">{label}</span>
167
+ <span className="text-theme-text-tertiary w-40 shrink-0">{label}</span>
121
168
  <span className="text-theme-text-primary break-all flex-1">{displayValue}</span>
122
169
  {copyable && onCopy && !isReactElement(value) && (
123
170
  <button
@@ -234,7 +281,7 @@ export interface Problem {
234
281
  message: string
235
282
  }
236
283
 
237
- /** Displays a list of problem alerts (warnings and errors) for GitOps resources */
284
+ /** Displays a list of problem alerts (warnings and errors) */
238
285
  export function ProblemAlerts({ problems }: { problems: Problem[] }) {
239
286
  if (problems.length === 0) return null
240
287
 
@@ -568,9 +615,25 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
568
615
  {relationships.scalers && relationships.scalers.length > 0 && (
569
616
  <RelationshipGroup label="Autoscaler" refs={dedupeRefs(relationships.scalers)} onNavigate={onNavigate} />
570
617
  )}
571
- {relationships.policies && relationships.policies.length > 0 && (
572
- <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(relationships.policies)} onNavigate={onNavigate} />
573
- )}
618
+ {relationships.policies && relationships.policies.length > 0 && (() => {
619
+ const policyKinds = new Set(['NetworkPolicy', 'CiliumNetworkPolicy', 'CiliumClusterwideNetworkPolicy', 'ClusterNetworkPolicy'])
620
+ const pdbs = relationships.policies.filter(r => r.kind === 'PodDisruptionBudget')
621
+ const netpols = relationships.policies.filter(r => policyKinds.has(r.kind))
622
+ const other = relationships.policies.filter(r => r.kind !== 'PodDisruptionBudget' && !policyKinds.has(r.kind))
623
+ return (
624
+ <>
625
+ {pdbs.length > 0 && (
626
+ <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(pdbs)} onNavigate={onNavigate} />
627
+ )}
628
+ {netpols.length > 0 && (
629
+ <RelationshipGroup label="Network Policies" refs={dedupeRefs(netpols)} onNavigate={onNavigate} />
630
+ )}
631
+ {other.length > 0 && (
632
+ <RelationshipGroup label="Policies" refs={dedupeRefs(other)} onNavigate={onNavigate} />
633
+ )}
634
+ </>
635
+ )
636
+ })()}
574
637
  {relationships.scaleTarget && (
575
638
  <RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
576
639
  )}
@@ -121,6 +121,12 @@ interface WorkloadViewProps {
121
121
  renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
122
122
  /** Whether metrics are available for this resource kind */
123
123
  isMetricsAvailable?: (kind: string, resource: any) => boolean
124
+ /** Render extra content at the bottom of the overview tab (e.g. audit findings) */
125
+ renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
126
+
127
+ // ── Duplicate ────────────────────────────────────────────────────────────
128
+ /** Duplicate handler — opens create dialog with this resource's YAML */
129
+ onDuplicate?: (params: { kind: string; namespace: string; name: string; yaml: string }) => void
124
130
 
125
131
  // ── ResourceActionsBar props (passed through) ────────────────────────────
126
132
  /** All props for the actions bar (forwarded as-is) */
@@ -166,6 +172,9 @@ export function WorkloadView({
166
172
  renderLogsTab,
167
173
  renderMetricsTab,
168
174
  isMetricsAvailable,
175
+ // Duplicate
176
+ onDuplicate,
177
+ renderOverviewExtra,
169
178
  // Actions bar
170
179
  actionsBarProps,
171
180
  // Renderer overrides
@@ -430,22 +439,30 @@ export function WorkloadView({
430
439
  onSave={onUpdateResource}
431
440
  isSaving={isUpdatingResource}
432
441
  saveError={updateResourceError}
442
+ onDuplicate={onDuplicate}
433
443
  />
434
444
  ) : (
435
- <ResourceRendererDispatch
436
- resource={selectedResource}
437
- data={resource}
438
- relationships={relationships}
439
- certificateInfo={certificateInfo}
440
- onCopy={copyToClipboard}
441
- copied={copied}
442
- onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
443
- onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
444
- isSavingSecret={isUpdatingResource}
445
- rendererOverrides={rendererOverrides}
446
- resolvedEnvFrom={resolvedEnvFrom}
447
- renderMetrics={renderMetricsTab}
448
- />
445
+ <>
446
+ <ResourceRendererDispatch
447
+ resource={selectedResource}
448
+ data={resource}
449
+ relationships={relationships}
450
+ certificateInfo={certificateInfo}
451
+ onCopy={copyToClipboard}
452
+ copied={copied}
453
+ onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
454
+ onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
455
+ isSavingSecret={isUpdatingResource}
456
+ rendererOverrides={rendererOverrides}
457
+ resolvedEnvFrom={resolvedEnvFrom}
458
+ renderMetrics={renderMetricsTab}
459
+ />
460
+ {renderOverviewExtra && (
461
+ <div className="px-4 pb-4">
462
+ {renderOverviewExtra({ kind, namespace, name })}
463
+ </div>
464
+ )}
465
+ </>
449
466
  )}
450
467
  </div>
451
468
  </div>
@@ -571,21 +588,22 @@ export function WorkloadView({
571
588
  {/* Tab Content */}
572
589
  <div className="flex-1 overflow-hidden relative">
573
590
  {activeTab === 'overview' && (
574
- <InfoTab
575
- resource={resource}
576
- selectedResource={selectedResource}
577
- relationships={relationships}
578
- isLoading={resourceLoading}
579
- onNavigate={onNavigateToResource}
580
- onCopy={copyToClipboard}
581
- copied={copied}
582
- onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
583
- isSavingSecret={isUpdatingResource}
584
- onOpenLogs={handleOpenLogs}
585
- onSwitchToTimeline={() => handleSetTab('timeline')}
586
- rendererOverrides={rendererOverrides}
587
- resolvedEnvFrom={resolvedEnvFrom}
588
- />
591
+ <InfoTab
592
+ resource={resource}
593
+ selectedResource={selectedResource}
594
+ relationships={relationships}
595
+ isLoading={resourceLoading}
596
+ onNavigate={onNavigateToResource}
597
+ onCopy={copyToClipboard}
598
+ copied={copied}
599
+ onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
600
+ isSavingSecret={isUpdatingResource}
601
+ onOpenLogs={handleOpenLogs}
602
+ onSwitchToTimeline={() => handleSetTab('timeline')}
603
+ rendererOverrides={rendererOverrides}
604
+ resolvedEnvFrom={resolvedEnvFrom}
605
+ extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
606
+ />
589
607
  )}
590
608
  {activeTab === 'timeline' && (
591
609
  <EventsTab
@@ -635,6 +653,7 @@ export function WorkloadView({
635
653
  onSave={onUpdateResource}
636
654
  isSaving={isUpdatingResource}
637
655
  saveError={updateResourceError}
656
+ onDuplicate={onDuplicate}
638
657
  />
639
658
  )}
640
659
  </div>
@@ -1068,6 +1087,7 @@ function InfoTab({
1068
1087
  onSwitchToTimeline,
1069
1088
  rendererOverrides,
1070
1089
  resolvedEnvFrom,
1090
+ extraContent,
1071
1091
  }: {
1072
1092
  resource: any
1073
1093
  selectedResource: SelectedResource
@@ -1082,6 +1102,7 @@ function InfoTab({
1082
1102
  onSwitchToTimeline?: () => void
1083
1103
  rendererOverrides?: RendererOverrides
1084
1104
  resolvedEnvFrom?: ResolvedEnvFrom
1105
+ extraContent?: ReactNode
1085
1106
  }) {
1086
1107
  if (isLoading) {
1087
1108
  return (
@@ -1132,6 +1153,11 @@ function InfoTab({
1132
1153
  </div>
1133
1154
  )}
1134
1155
  />
1156
+ {extraContent && (
1157
+ <div className="px-4 pb-4">
1158
+ {extraContent}
1159
+ </div>
1160
+ )}
1135
1161
  </div>
1136
1162
  )
1137
1163
  }
package/src/index.ts CHANGED
@@ -33,3 +33,6 @@ export * from './components/dock'
33
33
 
34
34
  // Topology (TopologyGraph, TopologySearch, TopologyFilterSidebar, layout utilities)
35
35
  export * from './components/topology'
36
+
37
+ // Cluster audit (AuditCard, AuditAlerts, AuditFindingsTable)
38
+ export * from './components/audit'
@@ -43,7 +43,7 @@
43
43
  /* ── Text (cool neutral) ── */
44
44
  --text-primary: light-dark(#1A1C20, #F5F6F8);
45
45
  --text-secondary: light-dark(#555860, #D8DAE0);
46
- --text-tertiary: light-dark(#7E828A, #B0B5C0);
46
+ --text-tertiary: light-dark(#6B7080, #B0B5C0);
47
47
  --text-disabled: light-dark(#A0A4AC, #6A7080);
48
48
 
49
49
  /* ── Borders (lightly blue-tinted in light mode) ── */
package/src/types/core.ts CHANGED
@@ -145,6 +145,7 @@ export interface TopologyEdge {
145
145
  type: EdgeType
146
146
  label?: string
147
147
  skipIfKindVisible?: string // Hide this edge if this kind is visible (for shortcut edges)
148
+ policyEffect?: 'allowed' | 'blocked' | 'unprotected'
148
149
  }
149
150
 
150
151
  export interface Topology {
@@ -846,7 +847,7 @@ export interface TrafficFilters {
846
847
  }
847
848
 
848
849
  // Main view type now includes 'traffic' and 'cost'
849
- export type ExtendedMainView = MainView | 'traffic' | 'cost'
850
+ export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'audit'
850
851
 
851
852
  // ============================================================================
852
853
  // Image Filesystem Types
@@ -230,7 +230,9 @@ export interface ArgoAppStatus {
230
230
  operationState?: {
231
231
  phase?: ArgoOperationPhase | string // Known types + allow unknown
232
232
  message?: string
233
+ startedAt?: string
233
234
  finishedAt?: string
235
+ retryCount?: number
234
236
  syncResult?: {
235
237
  revision?: string
236
238
  source?: ArgoSyncResultSource
@@ -238,6 +240,12 @@ export interface ArgoAppStatus {
238
240
  }
239
241
  }
240
242
  reconciledAt?: string
243
+ history?: Array<{
244
+ revision?: string
245
+ deployedAt?: string
246
+ id?: number
247
+ source?: { repoURL?: string; path?: string }
248
+ }>
241
249
  }
242
250
 
243
251
  /**
@@ -52,6 +52,13 @@ export const HELM_STATUS_COLORS: Record<string, string> = {
52
52
  uninstalled: BADGE_SEVERITY_COLORS.neutral,
53
53
  }
54
54
 
55
+ // Best practices category colors
56
+ export const BP_CATEGORY_BADGE: Record<string, string> = {
57
+ Security: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-400 dark:border-purple-800/40',
58
+ Reliability: 'bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/40 dark:text-sky-400 dark:border-sky-800/40',
59
+ Efficiency: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800/40',
60
+ }
61
+
55
62
  // Default fallback color
56
63
  export const DEFAULT_BADGE_COLOR = 'bg-theme-elevated text-theme-text-secondary'
57
64
 
@@ -8,3 +8,5 @@ export * from './resource-hierarchy'
8
8
  export * from './log-format'
9
9
  export * from './download'
10
10
  export * from './api-resources'
11
+ export * from './skeleton-yaml'
12
+ export * from './k8s-errors'
@@ -0,0 +1,142 @@
1
+ // Human-friendly summaries for common Kubernetes API error patterns.
2
+ // Falls back to null when no known pattern matches — caller shows raw error.
3
+
4
+ interface FriendlyError {
5
+ summary: string
6
+ suggestion: string
7
+ }
8
+
9
+ const patterns: Array<{ test: (msg: string) => boolean; friendly: (msg: string) => FriendlyError }> = [
10
+ // Admission webhook timeout
11
+ {
12
+ test: (msg) => /failed calling webhook.*context deadline exceeded/i.test(msg),
13
+ friendly: (msg) => {
14
+ const webhook = msg.match(/webhook "([^"]+)"/)?.[1] ?? 'unknown'
15
+ const service = webhook.split('.')[0] || webhook
16
+ return {
17
+ summary: `Admission webhook timed out (${service})`,
18
+ suggestion: 'A cluster validation webhook failed to respond. This is usually a temporary cluster issue, not a problem with your YAML. Try again or contact your cluster admin.',
19
+ }
20
+ },
21
+ },
22
+ // Admission webhook rejected
23
+ {
24
+ test: (msg) => /admission webhook.*denied the request/i.test(msg),
25
+ friendly: (msg) => {
26
+ const webhook = msg.match(/webhook "([^"]+)"/)?.[1] ?? 'unknown'
27
+ return {
28
+ summary: `Rejected by admission policy (${webhook.split('.')[0]})`,
29
+ suggestion: 'A cluster admission webhook rejected this resource. Check that your YAML meets the cluster\'s policies, or contact your cluster admin.',
30
+ }
31
+ },
32
+ },
33
+ // Validation error — invalid field value
34
+ {
35
+ test: (msg) => /is invalid:.*Invalid value/i.test(msg),
36
+ friendly: (msg) => {
37
+ const fields = [...msg.matchAll(/(\S+): Invalid value/g)].map(m => m[1])
38
+ const fieldList = fields.length > 0 ? fields.join(', ') : 'unknown field'
39
+ return {
40
+ summary: `Validation failed: invalid value for ${fieldList}`,
41
+ suggestion: 'One or more field values don\'t match what Kubernetes expects. Check the highlighted fields in the error details.',
42
+ }
43
+ },
44
+ },
45
+ // Validation error — selector doesn't match labels
46
+ {
47
+ test: (msg) => /`selector` does not match template `labels`/i.test(msg),
48
+ friendly: () => ({
49
+ summary: 'Selector doesn\'t match template labels',
50
+ suggestion: 'The spec.selector.matchLabels must match spec.template.metadata.labels exactly.',
51
+ }),
52
+ },
53
+ // Validation error — required field
54
+ {
55
+ test: (msg) => /Required value|is required/i.test(msg),
56
+ friendly: (msg) => {
57
+ const field = msg.match(/(\S+): Required value/)?.[1] ?? msg.match(/(\S+) is required/)?.[1] ?? ''
58
+ return {
59
+ summary: `Missing required field${field ? `: ${field}` : ''}`,
60
+ suggestion: 'Add the missing required field to your YAML.',
61
+ }
62
+ },
63
+ },
64
+ // RBAC forbidden — use "is forbidden:" to avoid matching "Forbidden: field is immutable" or "forbidden: exceeded quota"
65
+ {
66
+ test: (msg) => /is forbidden:|cannot .* in the namespace/i.test(msg),
67
+ friendly: (msg) => {
68
+ const verb = msg.match(/cannot (\w+)/)?.[1] ?? 'perform this action on'
69
+ return {
70
+ summary: `Permission denied`,
71
+ suggestion: `Your cluster role doesn't allow you to ${verb} this resource. Contact your cluster admin for access.`,
72
+ }
73
+ },
74
+ },
75
+ // Already exists (create mode)
76
+ {
77
+ test: (msg) => /already exists/i.test(msg),
78
+ friendly: () => ({
79
+ summary: 'Resource already exists',
80
+ suggestion: 'Switch to Apply mode to update the existing resource, or change the name to create a new one.',
81
+ }),
82
+ },
83
+ // Quota exceeded
84
+ {
85
+ test: (msg) => /exceeded quota|quota.*exceeded|forbidden.*quota/i.test(msg),
86
+ friendly: () => ({
87
+ summary: 'Resource quota exceeded',
88
+ suggestion: 'The namespace has reached its resource quota limit. Request a quota increase or reduce resource requests.',
89
+ }),
90
+ },
91
+ // Namespace not found
92
+ {
93
+ test: (msg) => /namespace.*not found|namespaces.*not found/i.test(msg),
94
+ friendly: (msg) => {
95
+ const ns = msg.match(/namespace[s]? "([^"]+)"/)?.[1] ?? ''
96
+ return {
97
+ summary: `Namespace "${ns}" not found`,
98
+ suggestion: 'Create the namespace first or change the namespace in your YAML.',
99
+ }
100
+ },
101
+ },
102
+ // Immutable field
103
+ {
104
+ test: (msg) => /field is immutable/i.test(msg),
105
+ friendly: () => ({
106
+ summary: 'Cannot change immutable field',
107
+ suggestion: 'Some fields can\'t be modified after creation. Delete and recreate the resource, or remove the immutable field change.',
108
+ }),
109
+ },
110
+ // Connection refused / cluster unreachable
111
+ {
112
+ test: (msg) => /connection refused|no such host|cluster.*unreachable/i.test(msg),
113
+ friendly: () => ({
114
+ summary: 'Cannot reach the cluster',
115
+ suggestion: 'The Kubernetes API server is unreachable. Check your cluster connection.',
116
+ }),
117
+ },
118
+ ]
119
+
120
+ // Strip the "failed to apply resource: " / "failed to create resource: " prefix our backend adds
121
+ function stripPrefix(msg: string): string {
122
+ return msg.replace(/^failed to (apply|create) resource:\s*/i, '')
123
+ }
124
+
125
+ export function getFriendlyError(rawError: string): { summary: string; suggestion: string; raw: string } | null {
126
+ const cleaned = stripPrefix(rawError)
127
+ for (const pattern of patterns) {
128
+ if (pattern.test(cleaned)) {
129
+ const { summary, suggestion } = pattern.friendly(cleaned)
130
+ return { summary, suggestion, raw: cleaned }
131
+ }
132
+ }
133
+ return null
134
+ }
135
+
136
+ // Always returns something displayable — friendly if matched, raw if not
137
+ export function formatApplyError(rawError: string): { summary: string; suggestion?: string; raw: string } {
138
+ const friendly = getFriendlyError(rawError)
139
+ if (friendly) return friendly
140
+ const cleaned = stripPrefix(rawError)
141
+ return { summary: cleaned, raw: rawError }
142
+ }