@skyhook-io/k8s-ui 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +8 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +186 -67
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/HPARenderer.tsx +2 -1
  16. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
  18. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
  20. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  24. package/src/components/resources/renderers/VPARenderer.tsx +2 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
  26. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  27. package/src/components/resources/renderers/index.ts +2 -0
  28. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  29. package/src/components/resources/resource-utils-contour.ts +34 -0
  30. package/src/components/resources/resource-utils.ts +91 -0
  31. package/src/components/shared/ResourceActionsBar.tsx +276 -144
  32. package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
  33. package/src/components/shared/index.ts +1 -1
  34. package/src/components/timeline/shared.tsx +8 -10
  35. package/src/components/topology/K8sResourceNode.tsx +1 -0
  36. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  37. package/src/components/topology/TopologyGraph.tsx +2 -17
  38. package/src/components/topology/layout.ts +1 -0
  39. package/src/components/topology/topology.css +1 -0
  40. package/src/components/ui/CodeViewer.tsx +1 -2
  41. package/src/components/ui/ConfirmDialog.tsx +85 -130
  42. package/src/components/ui/DialogPortal.tsx +82 -0
  43. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  44. package/src/components/ui/Toast.tsx +5 -5
  45. package/src/components/ui/drawer-components.tsx +28 -22
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/workload/WorkloadView.tsx +30 -2
  48. package/src/theme/index.ts +3 -0
  49. package/src/theme/tailwind-theme.css +61 -0
  50. package/src/theme/variables.css +121 -0
  51. package/src/types/core.ts +13 -2
  52. package/src/utils/animation.ts +1 -1
  53. package/src/utils/badge-colors.ts +158 -123
  54. package/src/utils/log-format.ts +127 -35
  55. package/src/utils/navigation.test.ts +142 -0
  56. package/src/utils/navigation.ts +52 -47
  57. package/src/utils/resource-hierarchy.ts +5 -0
  58. package/src/utils/resource-icons.ts +3 -0
@@ -32,9 +32,9 @@ import type { NavigateToResource } from '../../utils/navigation'
32
32
  import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
33
33
  import {
34
34
  getPodStatus,
35
- getPodReadiness,
36
35
  getPodRestarts,
37
36
  getPodProblems,
37
+ getContainerSquareStates,
38
38
  getWorkloadImages,
39
39
  getWorkloadConditions,
40
40
  getReplicaSetOwner,
@@ -114,6 +114,7 @@ import {
114
114
  getServiceAccountSecretCount,
115
115
  getRoleRuleCount,
116
116
  formatAge,
117
+ formatDuration,
117
118
  truncate,
118
119
  getCellFilterValue,
119
120
  parseColumnFilters,
@@ -136,6 +137,7 @@ import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCel
136
137
  import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntryCell, PeerAuthenticationCell, AuthorizationPolicyCell } from './renderers/istio-cells'
137
138
  import { KnativeServiceCell, ConfigurationCell as KnativeConfigurationCell, RevisionCell as KnativeRevisionCell, RouteCell as KnativeRouteCell, BrokerCell, TriggerCell, EventTypeCell, PingSourceCell, ApiServerSourceCell, ContainerSourceCell, SinkBindingCell, ChannelCell, InMemoryChannelCell, SubscriptionCell, SequenceCell, ParallelCell, DomainMappingCell, ServerlessServiceCell, KnativeIngressCell, KnativeCertificateCell } from './renderers/knative-cells'
138
139
  import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
140
+ import { HTTPProxyCell } from './renderers/contour-cells'
139
141
  import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
140
142
 
141
143
  // Pod problem filter options (special multi-select, not a single column value)
@@ -242,7 +244,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
242
244
  pods: [
243
245
  { key: 'name', label: 'Name' },
244
246
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
245
- { key: 'ready', label: 'Ready', width: 'w-16' },
247
+ { key: 'containers', label: 'Containers', width: 'w-28' },
246
248
  { key: 'status', label: 'Status', width: 'w-40' },
247
249
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
248
250
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
@@ -1318,6 +1320,17 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1318
1320
  { key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
1319
1321
  { key: 'age', label: 'Age', width: 'w-16 shrink-0' },
1320
1322
  ],
1323
+ // Contour
1324
+ httpproxies: [
1325
+ { key: 'name', label: 'Name', width: 'min-w-40' },
1326
+ { key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
1327
+ { key: 'fqdn', label: 'FQDN', width: 'min-w-44' },
1328
+ { key: 'routes', label: 'Routes', width: 'w-20 shrink-0' },
1329
+ { key: 'includes', label: 'Includes', width: 'w-24 shrink-0' },
1330
+ { key: 'tls', label: 'TLS', width: 'w-14 shrink-0' },
1331
+ { key: 'status', label: 'Status', width: 'w-24 shrink-0' },
1332
+ { key: 'age', label: 'Age', width: 'w-16 shrink-0' },
1333
+ ],
1321
1334
  }
1322
1335
 
1323
1336
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
@@ -1344,6 +1357,8 @@ function normalizeKindToPlural(kind: string, group?: string): string {
1344
1357
  if (!lower.endsWith('s') && KNOWN_COLUMNS[lower + 's']) return lower + 's'
1345
1358
  // Try 'es' for kinds ending in s/sh/ch/x/z (e.g., "ingress" → "ingresses")
1346
1359
  if (KNOWN_COLUMNS[lower + 'es']) return lower + 'es'
1360
+ // Try 'ies' for kinds ending in y (e.g., "httpproxy" → "httpproxies")
1361
+ if (lower.endsWith('y') && KNOWN_COLUMNS[lower.slice(0, -1) + 'ies']) return lower.slice(0, -1) + 'ies'
1347
1362
  return lower
1348
1363
  }
1349
1364
 
@@ -1598,8 +1613,6 @@ export function ResourcesView({
1598
1613
  // Pinned kinds (favorites) — provided via props
1599
1614
  const [favoritesExpanded, setFavoritesExpanded] = useState(() => pinned.length > 0)
1600
1615
 
1601
- console.debug('[filters] ResourcesView render:', { kind: selectedKind.name, columnFilters, searchTerm, url: location.search })
1602
-
1603
1616
  useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
1604
1617
  // Track if this is the initial mount to avoid re-syncing on first render
1605
1618
  const isInitialMount = useRef(true)
@@ -2198,6 +2211,14 @@ export function ResourcesView({
2198
2211
  }
2199
2212
  // Signal that initial resource param has been processed — URL update effect can now run
2200
2213
  hasProcessedInitialResource.current = true
2214
+
2215
+ // If the URL has no kind segment (e.g., /resources), update to include the default kind
2216
+ const base = basePath.replace(/\/$/, '')
2217
+ const path = window.location.pathname
2218
+ if (path === base || path === base + '/') {
2219
+ const search = window.location.search
2220
+ window.history.replaceState({}, '', `${base}/${selectedKind.name}${search}`)
2221
+ }
2201
2222
  }, []) // Only on mount
2202
2223
 
2203
2224
  // API resources for dynamic sidebar — provided via props
@@ -2435,13 +2456,15 @@ export function ResourcesView({
2435
2456
  return meta.creationTimestamp ? new Date(meta.creationTimestamp).getTime() : 0
2436
2457
  case 'status':
2437
2458
  return status.phase || ''
2438
- case 'ready':
2439
- // For pods, use ready/total ratio
2459
+ case 'containers':
2460
+ // Pod containers column sort by readiness ratio
2440
2461
  if (status.containerStatuses) {
2441
2462
  const ready = status.containerStatuses.filter((c: any) => c.ready).length
2442
2463
  const total = status.containerStatuses.length
2443
2464
  return total > 0 ? ready / total : 0
2444
2465
  }
2466
+ return 0
2467
+ case 'ready':
2445
2468
  // For DaemonSets, use numberReady/desiredNumberScheduled
2446
2469
  if (kindLower === 'daemonsets') {
2447
2470
  const desired = status.desiredNumberScheduled ?? 0
@@ -2623,13 +2646,13 @@ export function ResourcesView({
2623
2646
  const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
2624
2647
 
2625
2648
  if (kindLower === 'pods') {
2626
- // Completed pods at bottom
2649
+ // Completed pods at bottom, then sort by name for stability across refreshes
2627
2650
  result = [...result].sort((a: any, b: any) => {
2628
2651
  const aCompleted = a.status?.phase === 'Succeeded'
2629
2652
  const bCompleted = b.status?.phase === 'Succeeded'
2630
2653
  if (aCompleted && !bCompleted) return 1
2631
2654
  if (!aCompleted && bCompleted) return -1
2632
- return 0
2655
+ return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
2633
2656
  })
2634
2657
  } else if (kindLower === 'daemonsets') {
2635
2658
  // DaemonSets with 0 desired (empty/inactive) at bottom, then sort by ready desc
@@ -2653,11 +2676,12 @@ export function ResourcesView({
2653
2676
  return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
2654
2677
  })
2655
2678
  } else if (kindLower === 'events') {
2656
- // Events: most recently seen first
2679
+ // Events: most recently seen first, name tiebreaker for same-timestamp stability
2657
2680
  result = [...result].sort((a: any, b: any) => {
2658
2681
  const aTime = new Date(a.lastTimestamp || a.metadata?.creationTimestamp || 0).getTime()
2659
2682
  const bTime = new Date(b.lastTimestamp || b.metadata?.creationTimestamp || 0).getTime()
2660
- return bTime - aTime
2683
+ if (bTime !== aTime) return bTime - aTime
2684
+ return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
2661
2685
  })
2662
2686
  } else if (['deployments', 'statefulsets', 'replicasets'].includes(kindLower)) {
2663
2687
  // Workloads: unhealthy first, scaled-to-zero at bottom
@@ -2680,6 +2704,11 @@ export function ResourcesView({
2680
2704
  // Finally sort by name
2681
2705
  return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
2682
2706
  })
2707
+ } else {
2708
+ // All other kinds: sort by name for stability across refreshes
2709
+ result = [...result].sort((a: any, b: any) =>
2710
+ (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
2711
+ )
2683
2712
  }
2684
2713
  }
2685
2714
 
@@ -3041,18 +3070,16 @@ export function ResourcesView({
3041
3070
  <ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
3042
3071
  <div className="flex h-full w-full">
3043
3072
  {/* Sidebar - Resource Types */}
3044
- <div className="w-72 bg-theme-surface border-r border-theme-border overflow-y-auto overflow-x-hidden shrink-0">
3045
- <div className="flex items-center gap-2 px-3 py-3 border-b border-theme-border">
3046
- <h2 className="text-sm font-medium text-theme-text-secondary uppercase tracking-wide shrink-0">
3047
- Resources
3048
- </h2>
3049
- <div className="flex-1 relative">
3073
+ <div className="w-56 2xl:w-72 bg-theme-surface border-r border-theme-border overflow-y-auto overflow-x-hidden shrink-0">
3074
+ <div className="px-2 py-2 border-b border-theme-border">
3075
+ <div className="relative">
3050
3076
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
3051
3077
  <input
3052
3078
  type="text"
3053
- placeholder="Filter kinds..."
3079
+ placeholder="Filter resources..."
3054
3080
  value={kindFilter}
3055
3081
  onChange={(e) => setKindFilter(e.target.value)}
3082
+ onKeyDown={(e) => { if (e.key === 'Escape') { setKindFilter(''); (e.target as HTMLInputElement).blur() } }}
3056
3083
  className="w-full pl-7 pr-7 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
3057
3084
  />
3058
3085
  {kindFilter && (
@@ -3079,7 +3106,7 @@ export function ResourcesView({
3079
3106
  )}
3080
3107
  <span className="flex-1 text-left">Favorites</span>
3081
3108
  {!favoritesExpanded && pinned.length > 0 && (
3082
- <span className="text-xs px-1.5 py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case">
3109
+ <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center', pinned.length < 1000 ? 'w-8' : 'w-9')}>
3083
3110
  {pinned.length}
3084
3111
  </span>
3085
3112
  )}
@@ -3125,7 +3152,7 @@ export function ResourcesView({
3125
3152
  <div key={category.name} className="mb-2">
3126
3153
  <button
3127
3154
  onClick={() => toggleCategory(category.name)}
3128
- className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
3155
+ className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
3129
3156
  >
3130
3157
  {isExpanded ? (
3131
3158
  <ChevronDown className="w-3 h-3" />
@@ -3134,7 +3161,7 @@ export function ResourcesView({
3134
3161
  )}
3135
3162
  <span className="flex-1 text-left">{category.name}</span>
3136
3163
  {!isExpanded && (
3137
- <span className="text-xs px-1.5 py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case">
3164
+ <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center', category.total < 1000 ? 'w-8' : 'w-9')}>
3138
3165
  {category.total}
3139
3166
  </span>
3140
3167
  )}
@@ -3781,7 +3808,7 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
3781
3808
  ref={ref}
3782
3809
  onClick={onClick}
3783
3810
  className={clsx(
3784
- 'w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
3811
+ 'w-full flex items-center gap-2 px-2 xl:px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
3785
3812
  isSelected
3786
3813
  ? 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
3787
3814
  : forbidden
@@ -3795,36 +3822,39 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
3795
3822
  {resource.kind}
3796
3823
  </span>
3797
3824
  </Tooltip>
3798
- {forbidden ? (
3799
- <Tooltip content="Insufficient permissions" position="left">
3800
- <Shield className="w-3.5 h-3.5 text-amber-400/60" />
3801
- </Tooltip>
3802
- ) : (
3803
- <span className={clsx(
3804
- 'text-xs px-1.5 py-0.5 rounded min-w-[1.5rem] text-center',
3805
- isSelected ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated'
3806
- )}>
3807
- {count}
3808
- </span>
3809
- )}
3810
- {onTogglePin && (
3811
- <span
3812
- role="button"
3813
- onClick={(e) => {
3814
- e.stopPropagation()
3815
- onTogglePin()
3816
- }}
3817
- className={clsx(
3818
- 'ml-auto p-0.5 rounded transition-all shrink-0 hover:bg-theme-hover',
3819
- isPinned
3820
- ? 'text-theme-text-secondary'
3821
- : 'opacity-0 group-hover/kind:opacity-100 text-theme-text-disabled'
3822
- )}
3823
- title={isPinned ? 'Unpin from favorites' : 'Pin to favorites'}
3824
- >
3825
- <Pin className={clsx('w-3.5 h-3.5', isPinned && 'fill-current')} />
3826
- </span>
3827
- )}
3825
+ <div className="ml-auto flex items-center gap-1 shrink-0">
3826
+ {onTogglePin && (
3827
+ <span
3828
+ role="button"
3829
+ onClick={(e) => {
3830
+ e.stopPropagation()
3831
+ onTogglePin()
3832
+ }}
3833
+ className={clsx(
3834
+ 'p-0.5 rounded transition-all hover:bg-theme-hover',
3835
+ isPinned
3836
+ ? 'text-theme-text-secondary'
3837
+ : 'opacity-0 group-hover/kind:opacity-100 text-theme-text-disabled'
3838
+ )}
3839
+ title={isPinned ? 'Unpin from favorites' : 'Pin to favorites'}
3840
+ >
3841
+ <Pin className={clsx('w-3 h-3', isPinned && 'fill-current')} />
3842
+ </span>
3843
+ )}
3844
+ {forbidden ? (
3845
+ <Tooltip content="Insufficient permissions" position="left">
3846
+ <Shield className="w-3.5 h-3.5 text-amber-400/60" />
3847
+ </Tooltip>
3848
+ ) : (
3849
+ <span className={clsx(
3850
+ 'text-xs py-0.5 rounded text-center',
3851
+ isSelected ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated',
3852
+ count < 1000 ? 'w-8' : 'w-9'
3853
+ )}>
3854
+ {count}
3855
+ </span>
3856
+ )}
3857
+ </div>
3828
3858
  </button>
3829
3859
  )
3830
3860
  }
@@ -4202,6 +4232,9 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion }
4202
4232
  return <ServersTransportCell resource={resource} column={column} />
4203
4233
  case 'tlsoptions':
4204
4234
  return <TLSOptionCell resource={resource} column={column} />
4235
+ // Contour
4236
+ case 'httpproxies':
4237
+ return <HTTPProxyCell resource={resource} column={column} />
4205
4238
  default:
4206
4239
  // Generic cell for CRDs and unknown resources
4207
4240
  return <GenericCell resource={resource} column={column} />
@@ -4270,27 +4303,113 @@ function GenericCell({ resource, column }: { resource: any; column: string }) {
4270
4303
  // ============================================================================
4271
4304
 
4272
4305
  function PodCell({ resource, column }: { resource: any; column: string }) {
4273
- const phase = resource.status?.phase
4274
- const isCompleted = phase === 'Succeeded'
4275
4306
  const metrics = useContext(MetricsContext)
4276
4307
  const { onNavigate: navigate } = useContext(ResourcesViewDataContext)
4277
4308
 
4278
4309
  switch (column) {
4279
- case 'ready': {
4280
- const { ready, total } = getPodReadiness(resource)
4281
- const allReady = ready === total && total > 0
4282
- // Completed pods (Succeeded) show neutral color, not red
4283
- const color = isCompleted
4284
- ? 'text-theme-text-secondary'
4285
- : allReady
4286
- ? 'text-green-400'
4287
- : ready > 0
4288
- ? 'text-yellow-400'
4289
- : 'text-red-400'
4310
+ case 'containers': {
4311
+ const squares = getContainerSquareStates(resource)
4312
+ const hasInit = squares.some(s => s.isInit)
4290
4313
  return (
4291
- <span className={clsx('text-sm font-medium', color)}>
4292
- {ready}/{total}
4293
- </span>
4314
+ <div className="flex items-center gap-1">
4315
+ {squares.map((sq, i) => {
4316
+ const showSeparator = hasInit && i > 0 && sq.isInit !== squares[i - 1].isInit
4317
+ const bgClass =
4318
+ sq.status === 'ready' ? 'bg-green-500' :
4319
+ sq.status === 'completed' ? 'bg-theme-text-tertiary/30 border border-theme-text-tertiary' :
4320
+ sq.status === 'running' ? 'bg-yellow-500' :
4321
+ sq.status === 'waiting' ? 'bg-red-500' :
4322
+ sq.status === 'terminated' ? 'bg-red-500' :
4323
+ 'bg-theme-text-tertiary/30 border border-dashed border-theme-text-tertiary'
4324
+ const ringClass = sq.restarts > 0 ? 'ring-2 ring-orange-400' : ''
4325
+ const dotColor =
4326
+ sq.status === 'ready' ? 'bg-green-500' :
4327
+ sq.status === 'completed' ? 'bg-theme-text-tertiary' :
4328
+ sq.status === 'running' ? 'bg-yellow-500' :
4329
+ sq.status === 'waiting' || sq.status === 'terminated' ? 'bg-red-500' :
4330
+ 'bg-theme-text-tertiary'
4331
+ // Relative time helper
4332
+ const timeAgo = (dateStr?: string) => {
4333
+ if (!dateStr) return null
4334
+ const ms = Date.now() - new Date(dateStr).getTime()
4335
+ if (isNaN(ms) || ms < 0) return null
4336
+ if (ms < 60000) return 'just now'
4337
+ return formatDuration(ms) + ' ago'
4338
+ }
4339
+ const runDuration = (start?: string, end?: string) => {
4340
+ if (!start || !end) return null
4341
+ const ms = new Date(end).getTime() - new Date(start).getTime()
4342
+ return ms > 0 ? formatDuration(ms, true) : null
4343
+ }
4344
+ const stateLabel =
4345
+ sq.status === 'ready' ? 'Running' :
4346
+ sq.status === 'completed' ? 'Completed' :
4347
+ sq.status === 'running' ? 'Running (not ready)' :
4348
+ sq.status === 'waiting' ? 'Waiting' :
4349
+ sq.status === 'terminated' ? 'Terminated' : 'Unknown'
4350
+ const uptime = sq.status === 'ready' || sq.status === 'running' ? timeAgo(sq.startedAt) : null
4351
+ const duration = (sq.status === 'completed' || sq.status === 'terminated') ? runDuration(sq.startedAt, sq.finishedAt) : null
4352
+ const lt = sq.lastTermination
4353
+ const restartRecencyMs = lt?.finishedAt ? Date.now() - new Date(lt.finishedAt).getTime() : null
4354
+ const restartColor = restartRecencyMs !== null && restartRecencyMs < 600000 ? 'text-red-400' : restartRecencyMs !== null && restartRecencyMs < 3600000 ? 'text-yellow-400' : 'text-orange-400'
4355
+ const tooltipContent = (
4356
+ <div className="whitespace-normal space-y-1">
4357
+ {/* Header: dot + name + state */}
4358
+ <div className="flex items-center gap-1.5">
4359
+ <div className={clsx('w-2 h-2 rounded-full shrink-0', dotColor)} />
4360
+ <span className="font-medium">{sq.isInit ? <span className="text-theme-text-tertiary font-normal">init · </span> : ''}{sq.name}</span>
4361
+ <span className="text-theme-text-tertiary">·</span>
4362
+ <span className="text-theme-text-secondary">{stateLabel}</span>
4363
+ </div>
4364
+ {/* Reason (when different from state label) */}
4365
+ {sq.reason && sq.reason !== stateLabel && (
4366
+ <div className={clsx(
4367
+ 'font-medium',
4368
+ (sq.status === 'waiting' || sq.status === 'terminated') ? 'text-red-400' : 'text-theme-text-secondary'
4369
+ )}>{sq.reason}</div>
4370
+ )}
4371
+ {/* Message — truncated for tooltip */}
4372
+ {sq.message && (
4373
+ <div className="text-theme-text-secondary text-[11px] leading-tight">
4374
+ {sq.message.length > 120 ? sq.message.slice(0, 120) + '...' : sq.message}
4375
+ </div>
4376
+ )}
4377
+ {/* Exit code + uptime/duration on same line */}
4378
+ {(sq.exitCode !== undefined && sq.status !== 'ready' && sq.status !== 'running') || uptime || duration ? (
4379
+ <div className="text-theme-text-tertiary flex items-center gap-1.5">
4380
+ {sq.exitCode !== undefined && sq.status !== 'ready' && sq.status !== 'running' && (
4381
+ <span className={sq.exitCode !== 0 ? 'text-red-400' : ''}>exit {sq.exitCode}</span>
4382
+ )}
4383
+ {uptime && <span>up {uptime.replace(' ago', '')}</span>}
4384
+ {duration && <span>ran {duration}</span>}
4385
+ </div>
4386
+ ) : null}
4387
+ {/* Restarts + last crash info */}
4388
+ {sq.restarts > 0 && (
4389
+ <div className={clsx('border-t border-theme-border/50 pt-1 space-y-0.5', restartColor)}>
4390
+ <div className="flex items-center gap-1.5">
4391
+ <span>{sq.restarts} restart{sq.restarts !== 1 ? 's' : ''}</span>
4392
+ {lt?.finishedAt && <span className="text-theme-text-tertiary">· last {timeAgo(lt.finishedAt)}</span>}
4393
+ </div>
4394
+ {lt?.reason && (
4395
+ <div className="text-theme-text-tertiary">
4396
+ {lt.reason}{lt.exitCode !== undefined && lt.exitCode !== 0 ? ` (exit ${lt.exitCode})` : ''}
4397
+ </div>
4398
+ )}
4399
+ </div>
4400
+ )}
4401
+ </div>
4402
+ )
4403
+ return (
4404
+ <React.Fragment key={i}>
4405
+ {showSeparator && <div className="w-px h-3 bg-theme-text-tertiary/40 mx-0.5" />}
4406
+ <Tooltip content={tooltipContent} className="whitespace-normal max-w-xs">
4407
+ <div className={clsx('w-2.5 h-2.5 rounded-sm', bgClass, ringClass)} />
4408
+ </Tooltip>
4409
+ </React.Fragment>
4410
+ )
4411
+ })}
4412
+ </div>
4294
4413
  )
4295
4414
  }
4296
4415
  case 'status': {
@@ -0,0 +1,207 @@
1
+ import { Globe, Route, Layers, Lock } from 'lucide-react'
2
+ import { Section, PropertyList, Property, AlertBanner, ConditionsSection, ResourceLink } from '../../ui/drawer-components'
3
+ import {
4
+ getHTTPProxyFQDN,
5
+ getHTTPProxyRouteCount,
6
+ getHTTPProxyServiceCount,
7
+ getHTTPProxyStatus,
8
+ hasHTTPProxyTLS,
9
+ } from '../resource-utils-contour'
10
+
11
+ interface ContourHTTPProxyRendererProps {
12
+ data: any
13
+ onNavigate?: (ref: { kind: string; name: string; namespace: string }) => void
14
+ }
15
+
16
+ export function ContourHTTPProxyRenderer({ data, onNavigate }: ContourHTTPProxyRendererProps) {
17
+ const spec = data.spec || {}
18
+ const status = data.status || {}
19
+ const routes = spec.routes || []
20
+ const includes = spec.includes || []
21
+ const tls = spec.virtualhost?.tls
22
+ const tcpproxy = spec.tcpproxy
23
+ const ns = data.metadata?.namespace || ''
24
+ const conditions = status.conditions
25
+
26
+ const { label: statusLabel } = getHTTPProxyStatus(data)
27
+ const currentStatus = status.currentStatus?.toLowerCase()
28
+
29
+ return (
30
+ <>
31
+ {currentStatus === 'invalid' && (
32
+ <AlertBanner
33
+ variant="error"
34
+ title="Invalid HTTPProxy"
35
+ message={status.description || 'This HTTPProxy has an invalid configuration.'}
36
+ />
37
+ )}
38
+
39
+ {currentStatus === 'orphaned' && (
40
+ <AlertBanner
41
+ variant="warning"
42
+ title="Orphaned HTTPProxy"
43
+ message={status.description || 'This HTTPProxy is orphaned — it is not part of any valid delegation chain.'}
44
+ />
45
+ )}
46
+
47
+ <Section title="HTTPProxy" icon={Globe} defaultExpanded>
48
+ <PropertyList>
49
+ <Property label="FQDN" value={getHTTPProxyFQDN(data)} />
50
+ <Property label="TLS" value={hasHTTPProxyTLS(data) ? 'Enabled' : 'None'} />
51
+ <Property label="Status" value={statusLabel} />
52
+ <Property label="Routes" value={`${getHTTPProxyRouteCount(data)}`} />
53
+ <Property label="Services" value={`${getHTTPProxyServiceCount(data)}`} />
54
+ </PropertyList>
55
+ </Section>
56
+
57
+ <Section title={`Routes (${routes.length})`} icon={Route} defaultExpanded>
58
+ <div className="space-y-3">
59
+ {routes.map((route: any, i: number) => {
60
+ const services = route.services || []
61
+ const routeConditions = route.conditions || []
62
+
63
+ return (
64
+ <div key={i} className="bg-theme-elevated/30 rounded p-3">
65
+ {/* Conditions (prefix match, header match, etc.) */}
66
+ {routeConditions.length > 0 && (
67
+ <div className="flex items-start gap-2 mb-2">
68
+ <span className="text-sm font-medium text-theme-text-primary break-all">
69
+ {routeConditions.map((c: any) => {
70
+ if (c.prefix) return `prefix: ${c.prefix}`
71
+ if (c.header) return `header: ${c.header.name} ${c.header.contains || c.header.exact || c.header.present ? 'match' : ''}`
72
+ return JSON.stringify(c)
73
+ }).join(', ')}
74
+ </span>
75
+ </div>
76
+ )}
77
+
78
+ {routeConditions.length === 0 && (
79
+ <div className="flex items-start gap-2 mb-2">
80
+ <span className="text-sm font-medium text-theme-text-primary">
81
+ (no conditions)
82
+ </span>
83
+ </div>
84
+ )}
85
+
86
+ {/* Services */}
87
+ {services.length > 0 && (
88
+ <div>
89
+ <div className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider mb-1">Services</div>
90
+ <div className="space-y-1">
91
+ {services.map((svc: any, si: number) => {
92
+ const svcNs = svc.namespace || ns
93
+ const port = svc.port ? `:${svc.port}` : ''
94
+ const weight = svc.weight !== undefined ? ` (${svc.weight}%)` : ''
95
+
96
+ return (
97
+ <div key={si} className="flex items-center gap-2 text-xs">
98
+ <ResourceLink
99
+ name={svc.name}
100
+ kind="services"
101
+ namespace={svcNs}
102
+ label={<span className="text-blue-400">{svc.name}{port}{weight}</span>}
103
+ onNavigate={onNavigate}
104
+ />
105
+ </div>
106
+ )
107
+ })}
108
+ </div>
109
+ </div>
110
+ )}
111
+ </div>
112
+ )
113
+ })}
114
+
115
+ {routes.length === 0 && (
116
+ <div className="text-sm text-theme-text-tertiary">No routes configured</div>
117
+ )}
118
+ </div>
119
+ </Section>
120
+
121
+ {includes.length > 0 && (
122
+ <Section title={`Includes (${includes.length})`} icon={Layers} defaultExpanded>
123
+ <div className="space-y-2">
124
+ {includes.map((inc: any, i: number) => {
125
+ const incNs = inc.namespace || ns
126
+ const incConditions = inc.conditions || []
127
+
128
+ return (
129
+ <div key={i} className="flex items-center gap-2 text-xs">
130
+ <ResourceLink
131
+ name={inc.name}
132
+ kind="httpproxies"
133
+ namespace={incNs}
134
+ label={
135
+ <span className="text-blue-400">
136
+ {incNs !== ns ? `${incNs}/` : ''}{inc.name}
137
+ </span>
138
+ }
139
+ onNavigate={onNavigate}
140
+ />
141
+ {incConditions.length > 0 && (
142
+ <span className="text-theme-text-tertiary">
143
+ ({incConditions.map((c: any) => c.prefix ? `prefix: ${c.prefix}` : JSON.stringify(c)).join(', ')})
144
+ </span>
145
+ )}
146
+ </div>
147
+ )
148
+ })}
149
+ </div>
150
+ </Section>
151
+ )}
152
+
153
+ {tcpproxy && (
154
+ <Section title="TCP Proxy" icon={Route} defaultExpanded>
155
+ <div className="space-y-1">
156
+ {(tcpproxy.services || []).map((svc: any, i: number) => {
157
+ const port = svc.port ? `:${svc.port}` : ''
158
+ return (
159
+ <div key={i} className="flex items-center gap-2 text-xs">
160
+ <ResourceLink
161
+ name={svc.name}
162
+ kind="services"
163
+ namespace={svc.namespace || ns}
164
+ label={<span className="text-blue-400">{svc.name}{port}</span>}
165
+ onNavigate={onNavigate}
166
+ />
167
+ </div>
168
+ )
169
+ })}
170
+ </div>
171
+ </Section>
172
+ )}
173
+
174
+ {tls && (
175
+ <Section title="TLS" icon={Lock} defaultExpanded>
176
+ <PropertyList>
177
+ {tls.secretName && (
178
+ <Property label="Secret" value={
179
+ <ResourceLink
180
+ name={tls.secretName}
181
+ kind="secrets"
182
+ namespace={ns}
183
+ onNavigate={onNavigate}
184
+ />
185
+ } />
186
+ )}
187
+ {tls.minimumProtocolVersion && (
188
+ <Property label="Min Protocol" value={tls.minimumProtocolVersion} />
189
+ )}
190
+ {tls.passthrough !== undefined && (
191
+ <Property label="Passthrough" value={tls.passthrough ? 'Yes' : 'No'} />
192
+ )}
193
+ {!tls.secretName && !tls.passthrough && (
194
+ <Property label="Mode" value="TLS termination (no explicit secret)" />
195
+ )}
196
+ </PropertyList>
197
+ </Section>
198
+ )}
199
+
200
+ {conditions && conditions.length > 0 && (
201
+ <Section title="Status" defaultExpanded>
202
+ <ConditionsSection conditions={conditions} />
203
+ </Section>
204
+ )}
205
+ </>
206
+ )
207
+ }
@@ -1,6 +1,7 @@
1
1
  import { Cpu, AlertTriangle } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ResourceLink } from '../../ui/drawer-components'
4
+ import { kindToPlural } from '../../../utils/navigation'
4
5
  import { formatAge } from '../resource-utils'
5
6
 
6
7
  interface HPARendererProps {
@@ -113,7 +114,7 @@ export function HPARenderer({ data, onNavigate }: HPARendererProps) {
113
114
  spec.scaleTargetRef?.name ? (
114
115
  <ResourceLink
115
116
  name={spec.scaleTargetRef.name}
116
- kind={(spec.scaleTargetRef.kind || 'Deployment').toLowerCase() + 's'}
117
+ kind={kindToPlural(spec.scaleTargetRef.kind || 'Deployment')}
117
118
  namespace={data.metadata?.namespace || ''}
118
119
  label={`${spec.scaleTargetRef.kind}/${spec.scaleTargetRef.name}`}
119
120
  onNavigate={onNavigate}
@@ -1,6 +1,7 @@
1
1
  import { Server, Cpu, Settings } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
+ import { kindToPlural } from '../../../utils/navigation'
4
5
  import {
5
6
  getNodeClaimStatus,
6
7
  getNodeClaimInstanceType,
@@ -73,7 +74,7 @@ export function KarpenterNodeClaimRenderer({ data, onNavigate }: KarpenterNodeCl
73
74
  value={nodeClassRef.name ? (
74
75
  <ResourceLink
75
76
  name={nodeClassRef.name}
76
- kind={(nodeClassRef.kind || 'EC2NodeClass').toLowerCase() + 's'}
77
+ kind={kindToPlural(nodeClassRef.kind || 'EC2NodeClass')}
77
78
  namespace=""
78
79
  group={nodeClassRef.group}
79
80
  label={`${nodeClassRef.kind || 'EC2NodeClass'}/${nodeClassRef.name}`}