@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, u
2
2
  import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
3
3
  import { PaneLoader } from '../ui/PaneLoader'
4
4
  import { RestrictedState } from '../ui/RestrictedState'
5
+ import { Input } from '../ui/Input'
5
6
  import type { TopPodMetrics, TopNodeMetrics } from '../../types'
6
7
  import {
7
8
  Search,
@@ -84,6 +85,10 @@ import {
84
85
  getWorkflowDuration,
85
86
  getWorkflowProgress,
86
87
  getWorkflowTemplate,
88
+ getCronWorkflowStatus,
89
+ getCronWorkflowSchedule,
90
+ getCronWorkflowLastRun,
91
+ getCronWorkflowTemplate,
87
92
  getPVStatus,
88
93
  getPVAccessModes,
89
94
  getPVClaim,
@@ -460,6 +465,15 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
460
465
  { key: 'template', label: 'Template', width: 'w-40', hideOnMobile: true },
461
466
  { key: 'age', label: 'Age', width: 'w-24' },
462
467
  ],
468
+ cronworkflows: [
469
+ { key: 'name', label: 'Name' },
470
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
471
+ { key: 'schedule', label: 'Schedule', width: 'w-40' },
472
+ { key: 'status', label: 'Status', width: 'w-28' },
473
+ { key: 'lastRun', label: 'Last Run', width: 'w-28', hideOnMobile: true },
474
+ { key: 'template', label: 'Template', width: 'w-40', hideOnMobile: true },
475
+ { key: 'age', label: 'Age', width: 'w-24' },
476
+ ],
463
477
  certificates: [
464
478
  { key: 'name', label: 'Name' },
465
479
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -762,6 +776,12 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
762
776
  { key: 'templates', label: 'Templates', width: 'w-24' },
763
777
  { key: 'age', label: 'Age', width: 'w-24' },
764
778
  ],
779
+ clusterworkflowtemplates: [
780
+ { key: 'name', label: 'Name' },
781
+ { key: 'entrypoint', label: 'Entrypoint', width: 'w-36' },
782
+ { key: 'templates', label: 'Templates', width: 'w-24' },
783
+ { key: 'age', label: 'Age', width: 'w-24' },
784
+ ],
765
785
  networkpolicies: [
766
786
  { key: 'name', label: 'Name' },
767
787
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -1709,8 +1729,16 @@ function isLikelyCrossplaneMRGroup(kind: string, group: string): boolean {
1709
1729
  if (k === 'providerconfig' || k === 'providerconfigs') return false
1710
1730
  if (group.endsWith('.upbound.io')) return true
1711
1731
  if (group === 'kubernetes.crossplane.io' || group === 'helm.crossplane.io') return true
1712
- // Reserved Crossplane core groups — never MRs.
1713
- if (group === 'crossplane.io' || group === 'pkg.crossplane.io' || group === 'apiextensions.crossplane.io') {
1732
+ // Reserved Crossplane core groups — never MRs. protection.crossplane.io
1733
+ // (Usage/ClusterUsage) and ops.crossplane.io (Operations) are Crossplane v2's
1734
+ // new core groups: lifecycle/orchestration objects, not provider resources.
1735
+ if (
1736
+ group === 'crossplane.io' ||
1737
+ group === 'pkg.crossplane.io' ||
1738
+ group === 'apiextensions.crossplane.io' ||
1739
+ group === 'protection.crossplane.io' ||
1740
+ group === 'ops.crossplane.io'
1741
+ ) {
1714
1742
  return false
1715
1743
  }
1716
1744
  // Any other *.crossplane.io subgroup is presumed to be a provider's MR group.
@@ -1810,6 +1838,8 @@ interface ResourcesViewData {
1810
1838
  export const ResourcesViewDataContext = React.createContext<ResourcesViewData>({})
1811
1839
 
1812
1840
  export interface ResourceQueryResult {
1841
+ resourceName?: string
1842
+ group?: string
1813
1843
  data?: any[]
1814
1844
  isLoading: boolean
1815
1845
  error?: any
@@ -1842,7 +1872,7 @@ interface ResourcesViewProps {
1842
1872
  /** @deprecated Use resourceCounts + resourceForbidden + selectedKindQuery instead */
1843
1873
  resourceQueries?: ResourceQueryResult[]
1844
1874
  // Lightweight counts for sidebar badges (from /api/resource-counts)
1845
- resourceCounts?: Record<string, number>
1875
+ resourceCounts?: Record<string, number | null>
1846
1876
  resourceForbidden?: string[]
1847
1877
  /** Per-kind reason a forbidden kind is hidden ("rbac_denied" | "unavailable"),
1848
1878
  * keyed by the same count key as resourceForbidden. Drives RestrictedState copy. */
@@ -1950,6 +1980,26 @@ interface ResourcesViewProps {
1950
1980
 
1951
1981
  // Default selected kind
1952
1982
  const DEFAULT_KIND_INFO: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
1983
+ export const LOADED_RESOURCE_COUNT_TTL_MS = 5 * 60 * 1000
1984
+
1985
+ interface LoadedResourceCount {
1986
+ count: number
1987
+ expiresAt: number
1988
+ }
1989
+
1990
+ type LoadedResourceCountCache = Record<string, LoadedResourceCount>
1991
+
1992
+ function resourceCountKey(resource: Pick<APIResource, 'group' | 'kind'>): string {
1993
+ return resource.group ? `${resource.group}/${resource.kind}` : resource.kind
1994
+ }
1995
+
1996
+ export function resourceQueryMatchesSelectedKind(
1997
+ query: Pick<ResourceQueryResult, 'resourceName' | 'group'> | undefined,
1998
+ selectedKind: SelectedKindInfo,
1999
+ ): boolean {
2000
+ if (!query?.resourceName) return true
2001
+ return query.resourceName === selectedKind.name && (query.group ?? '') === selectedKind.group
2002
+ }
1953
2003
 
1954
2004
  // Read initial state from URL — kind is in the path: {basePath}/{kind}
1955
2005
  //
@@ -1999,6 +2049,53 @@ function getInitialKindFromURL(
1999
2049
  return defaultKind
2000
2050
  }
2001
2051
 
2052
+ export function canonicalizeSelectedKind(
2053
+ selectedKind: SelectedKindInfo,
2054
+ resourcesToCount: Array<Pick<APIResource, 'name' | 'kind' | 'group'>>,
2055
+ apiResources?: APIResource[],
2056
+ ): SelectedKindInfo | null {
2057
+ const nameMatch = resourcesToCount.find(r =>
2058
+ r.name === selectedKind.name && r.group === selectedKind.group
2059
+ )
2060
+ if (nameMatch) {
2061
+ return selectedKind.kind === nameMatch.kind
2062
+ ? null
2063
+ : { name: nameMatch.name, kind: nameMatch.kind, group: nameMatch.group }
2064
+ }
2065
+
2066
+ const match = apiResources?.find(r =>
2067
+ r.kind === selectedKind.kind && r.group === selectedKind.group
2068
+ )
2069
+ return match ? { name: match.name, kind: match.kind, group: match.group } : null
2070
+ }
2071
+
2072
+ export function deriveSidebarResourceCounts(
2073
+ resourcesToCount: Array<Pick<APIResource, 'kind' | 'group'>>,
2074
+ resourceCounts: Record<string, number | null> | undefined,
2075
+ resourceUnavailable: string[] | undefined,
2076
+ loadedCountCache: LoadedResourceCountCache,
2077
+ now: number = Date.now(),
2078
+ ): Record<string, number | null> {
2079
+ const unavailableKinds = new Set(resourceUnavailable ?? [])
2080
+ const results: Record<string, number | null> = {}
2081
+
2082
+ for (const resource of resourcesToCount) {
2083
+ const key = resourceCountKey(resource)
2084
+ const cached = loadedCountCache[key]
2085
+ if (resourceCounts && key in resourceCounts) {
2086
+ results[key] = resourceCounts[key] ?? null
2087
+ } else if (cached && cached.expiresAt > now) {
2088
+ results[key] = cached.count
2089
+ } else if (unavailableKinds.has(key)) {
2090
+ results[key] = null
2091
+ } else {
2092
+ results[key] = null
2093
+ }
2094
+ }
2095
+
2096
+ return results
2097
+ }
2098
+
2002
2099
  // Get initial filters from URL
2003
2100
  function getInitialFiltersFromURL() {
2004
2101
  const params = new URLSearchParams(window.location.search)
@@ -2006,6 +2103,7 @@ function getInitialFiltersFromURL() {
2006
2103
  const columnFilters = parseColumnFilters(params.get('filters'))
2007
2104
  const result = {
2008
2105
  search: params.get('search') || '',
2106
+ regex: params.get('regex') === 'true',
2009
2107
  columnFilters,
2010
2108
  problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
2011
2109
  showInactive: params.get('showInactive') === 'true',
@@ -2085,7 +2183,7 @@ export function ResourcesView({
2085
2183
  setBulkForceDelete(false)
2086
2184
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
2087
2185
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
2088
- const [regexMode, setRegexMode] = useState(false)
2186
+ const [regexMode, setRegexMode] = useState(initialFilters.regex)
2089
2187
  const [sortColumn, setSortColumn] = useState<string | null>(null)
2090
2188
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
2091
2189
  // Filter state
@@ -2679,11 +2777,11 @@ export function ResourcesView({
2679
2777
  if (containers.length > 0) {
2680
2778
  openLogs?.({ namespace: ns, podName: name, containers })
2681
2779
  }
2682
- } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(kindLower)) {
2780
+ } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kindLower)) {
2683
2781
  openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
2684
2782
  }
2685
2783
  },
2686
- enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
2784
+ enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(selectedKind.name.toLowerCase()),
2687
2785
  },
2688
2786
  {
2689
2787
  id: 'resources-sort-name',
@@ -2834,6 +2932,11 @@ export function ResourcesView({
2834
2932
  setSearchTerm(newFilters.search)
2835
2933
  }
2836
2934
 
2935
+ // Update regex mode if it changed
2936
+ if (newFilters.regex !== regexMode) {
2937
+ setRegexMode(newFilters.regex)
2938
+ }
2939
+
2837
2940
  // Update column filters if changed
2838
2941
  const newFiltersStr = serializeColumnFilters(newFilters.columnFilters)
2839
2942
  const currentFiltersStr = serializeColumnFilters(columnFilters)
@@ -2864,6 +2967,7 @@ export function ResourcesView({
2864
2967
  const updateURL = useCallback((
2865
2968
  kindInfo: SelectedKindInfo,
2866
2969
  search: string,
2970
+ regex: boolean,
2867
2971
  colFilters: Record<string, string[]>,
2868
2972
  problems: string[],
2869
2973
  showInactive: boolean,
@@ -2886,6 +2990,11 @@ export function ResourcesView({
2886
2990
  } else {
2887
2991
  params.delete('search')
2888
2992
  }
2993
+ if (regex) {
2994
+ params.set('regex', 'true')
2995
+ } else {
2996
+ params.delete('regex')
2997
+ }
2889
2998
  // Write column filters as `filters` param; remove legacy `status` param
2890
2999
  const filtersStr = serializeColumnFilters(colFilters)
2891
3000
  if (filtersStr) {
@@ -2947,6 +3056,7 @@ export function ResourcesView({
2947
3056
 
2948
3057
  const clearAllFilters = useCallback(() => {
2949
3058
  setSearchTerm('')
3059
+ setRegexMode(false)
2950
3060
  setColumnFilters({})
2951
3061
  setProblemFilters([])
2952
3062
  setLabelSelector('')
@@ -2957,7 +3067,7 @@ export function ResourcesView({
2957
3067
  // params are out of scope here; the host's onClearNamespaces (and its
2958
3068
  // own state→URL sync) owns namespace cleanup.
2959
3069
  const params = new URLSearchParams(window.location.search)
2960
- for (const key of ['search', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive']) {
3070
+ for (const key of ['search', 'regex', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive']) {
2961
3071
  params.delete(key)
2962
3072
  }
2963
3073
  navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
@@ -3014,8 +3124,8 @@ export function ResourcesView({
3014
3124
  shouldPushHistory.current = false
3015
3125
  prevSelectedResourceRef.current = current
3016
3126
 
3017
- updateURL(selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3018
- }, [selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
3127
+ updateURL(selectedKind, searchTerm, regexMode, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3128
+ }, [selectedKind, searchTerm, regexMode, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
3019
3129
 
3020
3130
  // Handle resource click from URL on mount
3021
3131
  useEffect(() => {
@@ -3136,24 +3246,15 @@ export function ResourcesView({
3136
3246
  // getInitialKindFromURL can't look up CRDs, so name may be wrong (e.g., 'HTTPRoute' instead of 'httproutes')
3137
3247
  useEffect(() => {
3138
3248
  if (!apiResources) return
3139
- // Check if current selectedKind already matches a discovered resource
3140
- const alreadyResolved = resourcesToCount.some(r =>
3141
- r.name === selectedKind.name && r.group === selectedKind.group
3142
- )
3143
- if (alreadyResolved) return
3144
-
3145
- // Try to match by kind name (URL stores kind=HTTPRoute, API has name=httproutes)
3146
- const match = apiResources.find(r =>
3147
- r.kind === selectedKind.kind && r.group === selectedKind.group
3148
- )
3149
- if (match) {
3150
- setSelectedKind({ name: match.name, kind: match.kind, group: match.group })
3249
+ const canonicalKind = canonicalizeSelectedKind(selectedKind, resourcesToCount, apiResources)
3250
+ if (canonicalKind) {
3251
+ setSelectedKind(canonicalKind)
3151
3252
  }
3152
3253
  }, [apiResources, resourcesToCount, selectedKind.name, selectedKind.kind, selectedKind.group])
3153
3254
 
3154
3255
  // Resource data — prefer new lightweight props over legacy resourceQueries array
3155
3256
  const resourceQueries = resourceQueriesProp ?? []
3156
- const useNewCountsMode = !!resourceCountsProp
3257
+ const useNewCountsMode = resourceCountsProp !== undefined || selectedKindQueryProp !== undefined
3157
3258
 
3158
3259
  // Find the selected kind's query
3159
3260
  const selectedQueryIndex = useMemo(() => {
@@ -3163,7 +3264,8 @@ export function ResourcesView({
3163
3264
  }, [resourcesToCount, selectedKind.name, selectedKind.group])
3164
3265
 
3165
3266
  const selectedQuery = selectedKindQueryProp ?? resourceQueries[selectedQueryIndex]
3166
- const resources = selectedQuery?.data
3267
+ const selectedQueryMatchesKind = resourceQueryMatchesSelectedKind(selectedQuery, selectedKind)
3268
+ const resources = selectedQueryMatchesKind ? selectedQuery?.data : undefined
3167
3269
 
3168
3270
  // Auto-surface the GPU column the first time GPU-bearing rows load for a kind
3169
3271
  // the user has never customized — a static default can't know whether the
@@ -3204,24 +3306,63 @@ export function ResourcesView({
3204
3306
  annotation: Array.from(annotations).sort(),
3205
3307
  }
3206
3308
  }, [resources])
3207
- const isLoading = selectedQuery?.isLoading ?? true
3208
- const selectedQueryError = selectedQuery?.error
3309
+ const isLoading = selectedQueryMatchesKind ? (selectedQuery?.isLoading ?? true) : true
3310
+ const selectedQueryError = selectedQueryMatchesKind ? selectedQuery?.error : undefined
3311
+ const selectedKindCountKey = selectedKind.group
3312
+ ? `${selectedKind.group}/${selectedKind.kind}`
3313
+ : selectedKind.kind
3314
+ const selectedQueryHasLoadedCount = Array.isArray(resources) && !isLoading && !selectedQueryError && !largeListGuard
3315
+ const selectedLoadedResourceCount = Array.isArray(resources) ? resources.length : undefined
3316
+ const [loadedCountCache, setLoadedCountCache] = useState<LoadedResourceCountCache>({})
3317
+ const namespaceScopeKey = useMemo(
3318
+ () => namespaces.length === 0 ? '' : [...namespaces].sort().join('\0'),
3319
+ [namespaces],
3320
+ )
3321
+ const loadedCountScopeRef = useRef(namespaceScopeKey)
3322
+
3323
+ useEffect(() => {
3324
+ if (loadedCountScopeRef.current === namespaceScopeKey) return
3325
+ loadedCountScopeRef.current = namespaceScopeKey
3326
+ setLoadedCountCache({})
3327
+ }, [namespaceScopeKey])
3328
+
3329
+ useEffect(() => {
3330
+ if (!selectedQueryHasLoadedCount || selectedLoadedResourceCount == null) return
3331
+ const now = Date.now()
3332
+ const loadedAt = selectedQuery?.dataUpdatedAt && selectedQuery.dataUpdatedAt > 0
3333
+ ? selectedQuery.dataUpdatedAt
3334
+ : now
3335
+ const expiresAt = loadedAt + LOADED_RESOURCE_COUNT_TTL_MS
3336
+
3337
+ setLoadedCountCache(prev => {
3338
+ const next: LoadedResourceCountCache = {}
3339
+ let changed = false
3340
+ for (const [key, cached] of Object.entries(prev)) {
3341
+ if (cached.expiresAt > now || key === selectedKindCountKey) {
3342
+ next[key] = cached
3343
+ } else {
3344
+ changed = true
3345
+ }
3346
+ }
3347
+
3348
+ const existing = next[selectedKindCountKey]
3349
+ if (existing?.count === selectedLoadedResourceCount && existing.expiresAt === expiresAt) {
3350
+ return changed ? next : prev
3351
+ }
3352
+
3353
+ return {
3354
+ ...next,
3355
+ [selectedKindCountKey]: { count: selectedLoadedResourceCount, expiresAt },
3356
+ }
3357
+ })
3358
+ }, [selectedQueryHasLoadedCount, selectedLoadedResourceCount, selectedKindCountKey, selectedQuery?.dataUpdatedAt])
3209
3359
 
3210
3360
  // Derive counts — prefer lightweight resourceCounts prop over full query data
3211
3361
  const counts = useMemo(() => {
3212
3362
  if (useNewCountsMode) {
3213
- // resourceCountsProp uses "group/Kind" keys for CRDs, "Kind" for core — same format as sidebar
3214
- const unavailableKinds = new Set(resourceUnavailableProp ?? [])
3215
- const results: Record<string, number | null> = {}
3216
- for (const resource of resourcesToCount) {
3217
- const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
3218
- if (unavailableKinds.has(key)) {
3219
- results[key] = null
3220
- } else if (key in resourceCountsProp!) {
3221
- results[key] = resourceCountsProp![key]
3222
- } else {
3223
- results[key] = 0
3224
- }
3363
+ const results = deriveSidebarResourceCounts(resourcesToCount, resourceCountsProp, resourceUnavailableProp, loadedCountCache)
3364
+ if (selectedQueryHasLoadedCount && selectedLoadedResourceCount != null) {
3365
+ results[selectedKindCountKey] = selectedLoadedResourceCount
3225
3366
  }
3226
3367
  return results
3227
3368
  }
@@ -3233,7 +3374,14 @@ export function ResourcesView({
3233
3374
  results[key] = Array.isArray(data) ? data.length : 0
3234
3375
  })
3235
3376
  return results
3236
- }, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceUnavailableProp, resourceQueries])
3377
+ }, [useNewCountsMode, resourcesToCount, resourceCountsProp, resourceUnavailableProp, loadedCountCache, selectedQueryHasLoadedCount, selectedLoadedResourceCount, selectedKindCountKey, resourceQueries])
3378
+
3379
+ const sidebarResourceUnavailable = useMemo(() => {
3380
+ if (!resourceUnavailableProp?.length) {
3381
+ return resourceUnavailableProp
3382
+ }
3383
+ return resourceUnavailableProp.filter(key => counts[key] == null)
3384
+ }, [resourceUnavailableProp, counts])
3237
3385
 
3238
3386
  // Track which resource kinds returned 403 Forbidden
3239
3387
  const forbiddenKinds = useMemo(() => {
@@ -3254,9 +3402,6 @@ export function ResourcesView({
3254
3402
  // denials) OR the selected kind is in the counts `forbidden` set. Denied
3255
3403
  // cluster-scoped kinds return 200 with `[]` from the list endpoint, so the
3256
3404
  // 403 signal alone misses them — they'd fall through to "No <kind> found".
3257
- const selectedKindCountKey = selectedKind.group
3258
- ? `${selectedKind.group}/${selectedKind.kind}`
3259
- : selectedKind.kind
3260
3405
  // Actual rows win over a stale counts `forbidden` entry: a kind can be marked
3261
3406
  // forbidden/unavailable in counts (e.g. an informer not yet synced at counts
3262
3407
  // time) while the list query has since returned data — show the table, not
@@ -3918,6 +4063,7 @@ export function ResourcesView({
3918
4063
  // a no-op for that case.
3919
4064
  const hasAnyFilter =
3920
4065
  !!searchTerm ||
4066
+ regexMode ||
3921
4067
  !!labelSelector ||
3922
4068
  hasOwnerFilter ||
3923
4069
  problemFilters.length > 0 ||
@@ -3986,7 +4132,7 @@ export function ResourcesView({
3986
4132
  apiResources={apiResourcesProp}
3987
4133
  resourceCounts={counts}
3988
4134
  resourceForbidden={Array.from(forbiddenKinds)}
3989
- resourceUnavailable={resourceUnavailableProp}
4135
+ resourceUnavailable={sidebarResourceUnavailable}
3990
4136
  pinned={pinned}
3991
4137
  togglePin={togglePin}
3992
4138
  isPinned={isPinned}
@@ -4005,9 +4151,8 @@ export function ResourcesView({
4005
4151
  <div className="flex-1 min-w-0">
4006
4152
  <div className="relative max-w-md">
4007
4153
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
4008
- <input
4154
+ <Input
4009
4155
  ref={searchInputRef}
4010
- type="text"
4011
4156
  placeholder={regexMode ? 'Search by regex... (press /)' : 'Search... (press /)'}
4012
4157
  value={searchTerm}
4013
4158
  onChange={(e) => setSearchTerm(e.target.value)}
@@ -4034,7 +4179,8 @@ export function ResourcesView({
4034
4179
  }
4035
4180
  }}
4036
4181
  className={clsx(
4037
- 'w-full pl-10 pr-10 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
4182
+ 'w-full pl-10 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
4183
+ searchTerm ? 'pr-16' : 'pr-10',
4038
4184
  searchRegex.error
4039
4185
  ? 'border-red-500/60 focus:ring-red-500'
4040
4186
  : 'border-theme-border-light focus:ring-skyhook-500'
@@ -4047,7 +4193,8 @@ export function ResourcesView({
4047
4193
  aria-label={regexMode ? 'Disable regex search' : 'Enable regex search'}
4048
4194
  title={regexMode ? 'Regex search enabled — click to disable' : 'Enable regex search'}
4049
4195
  className={clsx(
4050
- 'absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-6 h-6 rounded transition-colors',
4196
+ 'absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-6 h-6 rounded transition-colors',
4197
+ searchTerm ? 'right-9' : 'right-2',
4051
4198
  regexMode
4052
4199
  ? 'bg-skyhook-500/20 text-skyhook-400'
4053
4200
  : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover'
@@ -4055,6 +4202,19 @@ export function ResourcesView({
4055
4202
  >
4056
4203
  <Regex className="w-3.5 h-3.5" />
4057
4204
  </button>
4205
+ {searchTerm && (
4206
+ <button
4207
+ type="button"
4208
+ aria-label="Clear search"
4209
+ onClick={() => {
4210
+ setSearchTerm('')
4211
+ searchInputRef.current?.focus()
4212
+ }}
4213
+ className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-6 h-6 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover transition-colors"
4214
+ >
4215
+ <X className="w-3.5 h-3.5" />
4216
+ </button>
4217
+ )}
4058
4218
  {searchRegex.error && (
4059
4219
  <div
4060
4220
  title={searchRegex.error}
@@ -4146,8 +4306,7 @@ export function ResourcesView({
4146
4306
  <div className="flex items-center gap-2 p-2 border-b border-theme-border">
4147
4307
  <div className="relative flex-1">
4148
4308
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
4149
- <input
4150
- type="text"
4309
+ <Input
4151
4310
  placeholder="Search labels..."
4152
4311
  value={labelSearch}
4153
4312
  onChange={(e) => setLabelSearch(e.target.value)}
@@ -4325,8 +4484,7 @@ export function ResourcesView({
4325
4484
  setCustomColumnDraft(d => ({ ...d, path: '' }))
4326
4485
  }}
4327
4486
  >
4328
- <input
4329
- type="text"
4487
+ <Input
4330
4488
  list={customColKeysListId}
4331
4489
  value={customColumnDraft.path}
4332
4490
  onChange={e => setCustomColumnDraft(d => ({ ...d, path: e.target.value }))}
@@ -4511,7 +4669,10 @@ export function ResourcesView({
4511
4669
  <p>No {selectedKind.kind} found</p>
4512
4670
  {searchTerm && (
4513
4671
  <button
4514
- onClick={() => setSearchTerm('')}
4672
+ onClick={() => {
4673
+ setSearchTerm('')
4674
+ setRegexMode(false)
4675
+ }}
4515
4676
  className="flex items-center gap-1.5 text-sm mt-2 px-3 py-1.5 rounded-md bg-theme-elevated hover:bg-theme-border text-theme-text-secondary hover:text-theme-text-primary transition-colors"
4516
4677
  >
4517
4678
  No results for "{searchTerm}"
@@ -4708,8 +4869,7 @@ export function ResourcesView({
4708
4869
  <div className="flex items-center gap-2 p-2 border-b border-theme-border">
4709
4870
  <div className="relative flex-1">
4710
4871
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
4711
- <input
4712
- type="text"
4872
+ <Input
4713
4873
  placeholder="Search..."
4714
4874
  value={columnFilterSearch}
4715
4875
  onChange={(e) => setColumnFilterSearch(e.target.value)}
@@ -5210,6 +5370,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5210
5370
  return <RolloutCell resource={resource} column={column} />
5211
5371
  case 'workflows':
5212
5372
  return <WorkflowCell resource={resource} column={column} />
5373
+ case 'cronworkflows':
5374
+ return <CronWorkflowCell resource={resource} column={column} />
5213
5375
  case 'certificates':
5214
5376
  return <CertificateCell resource={resource} column={column} />
5215
5377
  case 'persistentvolumes':
@@ -5242,6 +5404,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5242
5404
  case 'sealedsecrets':
5243
5405
  return <SealedSecretCell resource={resource} column={column} />
5244
5406
  case 'workflowtemplates':
5407
+ case 'clusterworkflowtemplates':
5245
5408
  return <WorkflowTemplateCell resource={resource} column={column} />
5246
5409
  case 'networkpolicies':
5247
5410
  return <NetworkPolicyCell resource={resource} column={column} />
@@ -6576,6 +6739,33 @@ function WorkflowCell({ resource, column }: { resource: any; column: string }) {
6576
6739
  }
6577
6740
  }
6578
6741
 
6742
+ function CronWorkflowCell({ resource, column }: { resource: any; column: string }) {
6743
+ switch (column) {
6744
+ case 'status': {
6745
+ const status = getCronWorkflowStatus(resource)
6746
+ return (
6747
+ <span className={clsx('badge', status.color)}>
6748
+ {status.text}
6749
+ </span>
6750
+ )
6751
+ }
6752
+ case 'schedule':
6753
+ return <span className="font-mono text-xs text-theme-text-secondary">{getCronWorkflowSchedule(resource)}</span>
6754
+ case 'lastRun':
6755
+ return <span className="text-sm text-theme-text-secondary">{getCronWorkflowLastRun(resource)}</span>
6756
+ case 'template': {
6757
+ const template = getCronWorkflowTemplate(resource)
6758
+ return (
6759
+ <Tooltip content={template}>
6760
+ <span className="block truncate text-sm text-theme-text-secondary">{template}</span>
6761
+ </Tooltip>
6762
+ )
6763
+ }
6764
+ default:
6765
+ return <span className="text-sm text-theme-text-tertiary">-</span>
6766
+ }
6767
+ }
6768
+
6579
6769
 
6580
6770
  function PersistentVolumeCell({ resource, column }: { resource: any; column: string }) {
6581
6771
  switch (column) {
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
2
2
  import { ShieldCheck, ChevronDown, ChevronRight, CheckCircle2, XCircle, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
5
+ import { Input } from '../../ui/Input'
5
6
  import { formatAge } from '../resource-utils'
6
7
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER } from './trivy-shared'
7
8
  import { pluralize } from '../../../utils/pluralize'
@@ -138,8 +139,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
138
139
  <div className="flex items-center gap-2 mb-2">
139
140
  <div className="relative flex-1">
140
141
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
141
- <input
142
- type="text"
142
+ <Input
143
143
  placeholder="Filter by control name or ID..."
144
144
  value={searchTerm}
145
145
  onChange={(e) => setSearchTerm(e.target.value)}
@@ -0,0 +1,99 @@
1
+ import { Clock, Pause } from 'lucide-react'
2
+ import { Section, PropertyList, Property, AlertBanner, ResourceLink, ConditionsSection } from '../../ui/drawer-components'
3
+ import { formatAge, cronToHuman } from '../resource-utils'
4
+
5
+ interface CronWorkflowRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
8
+ }
9
+
10
+ export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererProps) {
11
+ const spec = data.spec || {}
12
+ const status = data.status || {}
13
+ const workflowSpec = spec.workflowSpec || {}
14
+ const schedules = Array.isArray(spec.schedules) ? spec.schedules : spec.schedule ? [spec.schedule] : []
15
+ const isSuspended = spec.suspend === true
16
+ const active = Array.isArray(status.active) ? status.active : []
17
+ const workflowTemplateRef = workflowSpec.workflowTemplateRef || null
18
+ const templateName = workflowTemplateRef?.name || workflowTemplateRef?.template || ''
19
+ const templateClusterScope = workflowTemplateRef?.clusterScope === true
20
+ const hasNeverRun = !status.lastScheduledTime && active.length === 0
21
+
22
+ return (
23
+ <>
24
+ {isSuspended && (
25
+ <AlertBanner
26
+ variant="info"
27
+ icon={Pause}
28
+ title="CronWorkflow Suspended"
29
+ message="No new Workflows will be scheduled until this CronWorkflow is resumed."
30
+ />
31
+ )}
32
+
33
+ {hasNeverRun && !isSuspended && (
34
+ <AlertBanner
35
+ variant="info"
36
+ title="Never Scheduled"
37
+ message="This CronWorkflow has no retained active or historical Workflow runs in status."
38
+ />
39
+ )}
40
+
41
+ <Section title="Schedule" icon={Clock}>
42
+ <PropertyList>
43
+ <Property label={schedules.length === 1 ? 'Schedule' : 'Schedules'} value={schedules.join(', ')} />
44
+ {schedules.length === 1 && <Property label="Human" value={cronToHuman(schedules[0])} />}
45
+ <Property label="Timezone" value={spec.timezone || 'Cluster default'} />
46
+ <Property label="Suspend" value={isSuspended ? 'Yes' : 'No'} />
47
+ <Property label="Concurrency" value={spec.concurrencyPolicy || 'Allow'} />
48
+ <Property label="Last Scheduled" value={status.lastScheduledTime ? formatAge(status.lastScheduledTime) : 'Never'} />
49
+ <Property label="Active Workflows" value={active.length} />
50
+ </PropertyList>
51
+ </Section>
52
+
53
+ <Section title="Workflow Template">
54
+ <PropertyList>
55
+ {templateName && (
56
+ <Property
57
+ label="Template Ref"
58
+ value={
59
+ <ResourceLink
60
+ name={templateName}
61
+ kind={templateClusterScope ? 'clusterworkflowtemplates' : 'workflowtemplates'}
62
+ namespace={templateClusterScope ? '' : data.metadata?.namespace || ''}
63
+ group="argoproj.io"
64
+ label={templateClusterScope ? `${templateName} (cluster)` : templateName}
65
+ onNavigate={onNavigate}
66
+ />
67
+ }
68
+ />
69
+ )}
70
+ <Property label="Entrypoint" value={workflowSpec.entrypoint || '-'} />
71
+ <Property label="Arguments" value={(workflowSpec.arguments?.parameters || []).length} />
72
+ <Property label="Templates" value={(workflowSpec.templates || []).length} />
73
+ </PropertyList>
74
+ </Section>
75
+
76
+ <Section title="History">
77
+ <PropertyList>
78
+ <Property label="Success History" value={spec.successfulJobsHistoryLimit ?? 3} />
79
+ <Property label="Failed History" value={spec.failedJobsHistoryLimit ?? 1} />
80
+ <Property label="Starting Deadline" value={spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'} />
81
+ </PropertyList>
82
+ </Section>
83
+
84
+ {active.length > 0 && (
85
+ <Section title="Active Workflows">
86
+ <div className="space-y-1">
87
+ {active.map((wf: any) => (
88
+ <div key={`${wf.namespace || data.metadata?.namespace || ''}/${wf.name}`} className="text-sm">
89
+ <ResourceLink name={wf.name} kind="workflows" namespace={wf.namespace || data.metadata?.namespace || ''} group="argoproj.io" onNavigate={onNavigate} />
90
+ </div>
91
+ ))}
92
+ </div>
93
+ </Section>
94
+ )}
95
+
96
+ <ConditionsSection conditions={status.conditions} />
97
+ </>
98
+ )
99
+ }