@skyhook-io/k8s-ui 1.8.10 → 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 (116) 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/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
@@ -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' },
@@ -1818,6 +1838,8 @@ interface ResourcesViewData {
1818
1838
  export const ResourcesViewDataContext = React.createContext<ResourcesViewData>({})
1819
1839
 
1820
1840
  export interface ResourceQueryResult {
1841
+ resourceName?: string
1842
+ group?: string
1821
1843
  data?: any[]
1822
1844
  isLoading: boolean
1823
1845
  error?: any
@@ -1850,7 +1872,7 @@ interface ResourcesViewProps {
1850
1872
  /** @deprecated Use resourceCounts + resourceForbidden + selectedKindQuery instead */
1851
1873
  resourceQueries?: ResourceQueryResult[]
1852
1874
  // Lightweight counts for sidebar badges (from /api/resource-counts)
1853
- resourceCounts?: Record<string, number>
1875
+ resourceCounts?: Record<string, number | null>
1854
1876
  resourceForbidden?: string[]
1855
1877
  /** Per-kind reason a forbidden kind is hidden ("rbac_denied" | "unavailable"),
1856
1878
  * keyed by the same count key as resourceForbidden. Drives RestrictedState copy. */
@@ -1958,6 +1980,26 @@ interface ResourcesViewProps {
1958
1980
 
1959
1981
  // Default selected kind
1960
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
+ }
1961
2003
 
1962
2004
  // Read initial state from URL — kind is in the path: {basePath}/{kind}
1963
2005
  //
@@ -2007,6 +2049,53 @@ function getInitialKindFromURL(
2007
2049
  return defaultKind
2008
2050
  }
2009
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
+
2010
2099
  // Get initial filters from URL
2011
2100
  function getInitialFiltersFromURL() {
2012
2101
  const params = new URLSearchParams(window.location.search)
@@ -2014,6 +2103,7 @@ function getInitialFiltersFromURL() {
2014
2103
  const columnFilters = parseColumnFilters(params.get('filters'))
2015
2104
  const result = {
2016
2105
  search: params.get('search') || '',
2106
+ regex: params.get('regex') === 'true',
2017
2107
  columnFilters,
2018
2108
  problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
2019
2109
  showInactive: params.get('showInactive') === 'true',
@@ -2093,7 +2183,7 @@ export function ResourcesView({
2093
2183
  setBulkForceDelete(false)
2094
2184
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
2095
2185
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
2096
- const [regexMode, setRegexMode] = useState(false)
2186
+ const [regexMode, setRegexMode] = useState(initialFilters.regex)
2097
2187
  const [sortColumn, setSortColumn] = useState<string | null>(null)
2098
2188
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
2099
2189
  // Filter state
@@ -2687,11 +2777,11 @@ export function ResourcesView({
2687
2777
  if (containers.length > 0) {
2688
2778
  openLogs?.({ namespace: ns, podName: name, containers })
2689
2779
  }
2690
- } 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)) {
2691
2781
  openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
2692
2782
  }
2693
2783
  },
2694
- 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()),
2695
2785
  },
2696
2786
  {
2697
2787
  id: 'resources-sort-name',
@@ -2842,6 +2932,11 @@ export function ResourcesView({
2842
2932
  setSearchTerm(newFilters.search)
2843
2933
  }
2844
2934
 
2935
+ // Update regex mode if it changed
2936
+ if (newFilters.regex !== regexMode) {
2937
+ setRegexMode(newFilters.regex)
2938
+ }
2939
+
2845
2940
  // Update column filters if changed
2846
2941
  const newFiltersStr = serializeColumnFilters(newFilters.columnFilters)
2847
2942
  const currentFiltersStr = serializeColumnFilters(columnFilters)
@@ -2872,6 +2967,7 @@ export function ResourcesView({
2872
2967
  const updateURL = useCallback((
2873
2968
  kindInfo: SelectedKindInfo,
2874
2969
  search: string,
2970
+ regex: boolean,
2875
2971
  colFilters: Record<string, string[]>,
2876
2972
  problems: string[],
2877
2973
  showInactive: boolean,
@@ -2894,6 +2990,11 @@ export function ResourcesView({
2894
2990
  } else {
2895
2991
  params.delete('search')
2896
2992
  }
2993
+ if (regex) {
2994
+ params.set('regex', 'true')
2995
+ } else {
2996
+ params.delete('regex')
2997
+ }
2897
2998
  // Write column filters as `filters` param; remove legacy `status` param
2898
2999
  const filtersStr = serializeColumnFilters(colFilters)
2899
3000
  if (filtersStr) {
@@ -2955,6 +3056,7 @@ export function ResourcesView({
2955
3056
 
2956
3057
  const clearAllFilters = useCallback(() => {
2957
3058
  setSearchTerm('')
3059
+ setRegexMode(false)
2958
3060
  setColumnFilters({})
2959
3061
  setProblemFilters([])
2960
3062
  setLabelSelector('')
@@ -2965,7 +3067,7 @@ export function ResourcesView({
2965
3067
  // params are out of scope here; the host's onClearNamespaces (and its
2966
3068
  // own state→URL sync) owns namespace cleanup.
2967
3069
  const params = new URLSearchParams(window.location.search)
2968
- for (const key of ['search', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive']) {
3070
+ for (const key of ['search', 'regex', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive']) {
2969
3071
  params.delete(key)
2970
3072
  }
2971
3073
  navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
@@ -3022,8 +3124,8 @@ export function ResourcesView({
3022
3124
  shouldPushHistory.current = false
3023
3125
  prevSelectedResourceRef.current = current
3024
3126
 
3025
- updateURL(selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3026
- }, [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])
3027
3129
 
3028
3130
  // Handle resource click from URL on mount
3029
3131
  useEffect(() => {
@@ -3144,24 +3246,15 @@ export function ResourcesView({
3144
3246
  // getInitialKindFromURL can't look up CRDs, so name may be wrong (e.g., 'HTTPRoute' instead of 'httproutes')
3145
3247
  useEffect(() => {
3146
3248
  if (!apiResources) return
3147
- // Check if current selectedKind already matches a discovered resource
3148
- const alreadyResolved = resourcesToCount.some(r =>
3149
- r.name === selectedKind.name && r.group === selectedKind.group
3150
- )
3151
- if (alreadyResolved) return
3152
-
3153
- // Try to match by kind name (URL stores kind=HTTPRoute, API has name=httproutes)
3154
- const match = apiResources.find(r =>
3155
- r.kind === selectedKind.kind && r.group === selectedKind.group
3156
- )
3157
- if (match) {
3158
- setSelectedKind({ name: match.name, kind: match.kind, group: match.group })
3249
+ const canonicalKind = canonicalizeSelectedKind(selectedKind, resourcesToCount, apiResources)
3250
+ if (canonicalKind) {
3251
+ setSelectedKind(canonicalKind)
3159
3252
  }
3160
3253
  }, [apiResources, resourcesToCount, selectedKind.name, selectedKind.kind, selectedKind.group])
3161
3254
 
3162
3255
  // Resource data — prefer new lightweight props over legacy resourceQueries array
3163
3256
  const resourceQueries = resourceQueriesProp ?? []
3164
- const useNewCountsMode = !!resourceCountsProp
3257
+ const useNewCountsMode = resourceCountsProp !== undefined || selectedKindQueryProp !== undefined
3165
3258
 
3166
3259
  // Find the selected kind's query
3167
3260
  const selectedQueryIndex = useMemo(() => {
@@ -3171,7 +3264,8 @@ export function ResourcesView({
3171
3264
  }, [resourcesToCount, selectedKind.name, selectedKind.group])
3172
3265
 
3173
3266
  const selectedQuery = selectedKindQueryProp ?? resourceQueries[selectedQueryIndex]
3174
- const resources = selectedQuery?.data
3267
+ const selectedQueryMatchesKind = resourceQueryMatchesSelectedKind(selectedQuery, selectedKind)
3268
+ const resources = selectedQueryMatchesKind ? selectedQuery?.data : undefined
3175
3269
 
3176
3270
  // Auto-surface the GPU column the first time GPU-bearing rows load for a kind
3177
3271
  // the user has never customized — a static default can't know whether the
@@ -3212,24 +3306,63 @@ export function ResourcesView({
3212
3306
  annotation: Array.from(annotations).sort(),
3213
3307
  }
3214
3308
  }, [resources])
3215
- const isLoading = selectedQuery?.isLoading ?? true
3216
- 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])
3217
3359
 
3218
3360
  // Derive counts — prefer lightweight resourceCounts prop over full query data
3219
3361
  const counts = useMemo(() => {
3220
3362
  if (useNewCountsMode) {
3221
- // resourceCountsProp uses "group/Kind" keys for CRDs, "Kind" for core — same format as sidebar
3222
- const unavailableKinds = new Set(resourceUnavailableProp ?? [])
3223
- const results: Record<string, number | null> = {}
3224
- for (const resource of resourcesToCount) {
3225
- const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
3226
- if (unavailableKinds.has(key)) {
3227
- results[key] = null
3228
- } else if (key in resourceCountsProp!) {
3229
- results[key] = resourceCountsProp![key]
3230
- } else {
3231
- results[key] = 0
3232
- }
3363
+ const results = deriveSidebarResourceCounts(resourcesToCount, resourceCountsProp, resourceUnavailableProp, loadedCountCache)
3364
+ if (selectedQueryHasLoadedCount && selectedLoadedResourceCount != null) {
3365
+ results[selectedKindCountKey] = selectedLoadedResourceCount
3233
3366
  }
3234
3367
  return results
3235
3368
  }
@@ -3241,7 +3374,14 @@ export function ResourcesView({
3241
3374
  results[key] = Array.isArray(data) ? data.length : 0
3242
3375
  })
3243
3376
  return results
3244
- }, [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])
3245
3385
 
3246
3386
  // Track which resource kinds returned 403 Forbidden
3247
3387
  const forbiddenKinds = useMemo(() => {
@@ -3262,9 +3402,6 @@ export function ResourcesView({
3262
3402
  // denials) OR the selected kind is in the counts `forbidden` set. Denied
3263
3403
  // cluster-scoped kinds return 200 with `[]` from the list endpoint, so the
3264
3404
  // 403 signal alone misses them — they'd fall through to "No <kind> found".
3265
- const selectedKindCountKey = selectedKind.group
3266
- ? `${selectedKind.group}/${selectedKind.kind}`
3267
- : selectedKind.kind
3268
3405
  // Actual rows win over a stale counts `forbidden` entry: a kind can be marked
3269
3406
  // forbidden/unavailable in counts (e.g. an informer not yet synced at counts
3270
3407
  // time) while the list query has since returned data — show the table, not
@@ -3926,6 +4063,7 @@ export function ResourcesView({
3926
4063
  // a no-op for that case.
3927
4064
  const hasAnyFilter =
3928
4065
  !!searchTerm ||
4066
+ regexMode ||
3929
4067
  !!labelSelector ||
3930
4068
  hasOwnerFilter ||
3931
4069
  problemFilters.length > 0 ||
@@ -3994,7 +4132,7 @@ export function ResourcesView({
3994
4132
  apiResources={apiResourcesProp}
3995
4133
  resourceCounts={counts}
3996
4134
  resourceForbidden={Array.from(forbiddenKinds)}
3997
- resourceUnavailable={resourceUnavailableProp}
4135
+ resourceUnavailable={sidebarResourceUnavailable}
3998
4136
  pinned={pinned}
3999
4137
  togglePin={togglePin}
4000
4138
  isPinned={isPinned}
@@ -4013,9 +4151,8 @@ export function ResourcesView({
4013
4151
  <div className="flex-1 min-w-0">
4014
4152
  <div className="relative max-w-md">
4015
4153
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
4016
- <input
4154
+ <Input
4017
4155
  ref={searchInputRef}
4018
- type="text"
4019
4156
  placeholder={regexMode ? 'Search by regex... (press /)' : 'Search... (press /)'}
4020
4157
  value={searchTerm}
4021
4158
  onChange={(e) => setSearchTerm(e.target.value)}
@@ -4042,7 +4179,8 @@ export function ResourcesView({
4042
4179
  }
4043
4180
  }}
4044
4181
  className={clsx(
4045
- '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',
4046
4184
  searchRegex.error
4047
4185
  ? 'border-red-500/60 focus:ring-red-500'
4048
4186
  : 'border-theme-border-light focus:ring-skyhook-500'
@@ -4055,7 +4193,8 @@ export function ResourcesView({
4055
4193
  aria-label={regexMode ? 'Disable regex search' : 'Enable regex search'}
4056
4194
  title={regexMode ? 'Regex search enabled — click to disable' : 'Enable regex search'}
4057
4195
  className={clsx(
4058
- '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',
4059
4198
  regexMode
4060
4199
  ? 'bg-skyhook-500/20 text-skyhook-400'
4061
4200
  : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover'
@@ -4063,6 +4202,19 @@ export function ResourcesView({
4063
4202
  >
4064
4203
  <Regex className="w-3.5 h-3.5" />
4065
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
+ )}
4066
4218
  {searchRegex.error && (
4067
4219
  <div
4068
4220
  title={searchRegex.error}
@@ -4154,8 +4306,7 @@ export function ResourcesView({
4154
4306
  <div className="flex items-center gap-2 p-2 border-b border-theme-border">
4155
4307
  <div className="relative flex-1">
4156
4308
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
4157
- <input
4158
- type="text"
4309
+ <Input
4159
4310
  placeholder="Search labels..."
4160
4311
  value={labelSearch}
4161
4312
  onChange={(e) => setLabelSearch(e.target.value)}
@@ -4333,8 +4484,7 @@ export function ResourcesView({
4333
4484
  setCustomColumnDraft(d => ({ ...d, path: '' }))
4334
4485
  }}
4335
4486
  >
4336
- <input
4337
- type="text"
4487
+ <Input
4338
4488
  list={customColKeysListId}
4339
4489
  value={customColumnDraft.path}
4340
4490
  onChange={e => setCustomColumnDraft(d => ({ ...d, path: e.target.value }))}
@@ -4519,7 +4669,10 @@ export function ResourcesView({
4519
4669
  <p>No {selectedKind.kind} found</p>
4520
4670
  {searchTerm && (
4521
4671
  <button
4522
- onClick={() => setSearchTerm('')}
4672
+ onClick={() => {
4673
+ setSearchTerm('')
4674
+ setRegexMode(false)
4675
+ }}
4523
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"
4524
4677
  >
4525
4678
  No results for "{searchTerm}"
@@ -4716,8 +4869,7 @@ export function ResourcesView({
4716
4869
  <div className="flex items-center gap-2 p-2 border-b border-theme-border">
4717
4870
  <div className="relative flex-1">
4718
4871
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
4719
- <input
4720
- type="text"
4872
+ <Input
4721
4873
  placeholder="Search..."
4722
4874
  value={columnFilterSearch}
4723
4875
  onChange={(e) => setColumnFilterSearch(e.target.value)}
@@ -5218,6 +5370,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5218
5370
  return <RolloutCell resource={resource} column={column} />
5219
5371
  case 'workflows':
5220
5372
  return <WorkflowCell resource={resource} column={column} />
5373
+ case 'cronworkflows':
5374
+ return <CronWorkflowCell resource={resource} column={column} />
5221
5375
  case 'certificates':
5222
5376
  return <CertificateCell resource={resource} column={column} />
5223
5377
  case 'persistentvolumes':
@@ -5250,6 +5404,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5250
5404
  case 'sealedsecrets':
5251
5405
  return <SealedSecretCell resource={resource} column={column} />
5252
5406
  case 'workflowtemplates':
5407
+ case 'clusterworkflowtemplates':
5253
5408
  return <WorkflowTemplateCell resource={resource} column={column} />
5254
5409
  case 'networkpolicies':
5255
5410
  return <NetworkPolicyCell resource={resource} column={column} />
@@ -6584,6 +6739,33 @@ function WorkflowCell({ resource, column }: { resource: any; column: string }) {
6584
6739
  }
6585
6740
  }
6586
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
+
6587
6769
 
6588
6770
  function PersistentVolumeCell({ resource, column }: { resource: any; column: string }) {
6589
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
+ }
@@ -48,7 +48,9 @@ export function JobRenderer({ data }: JobRendererProps) {
48
48
  const conditions = status.conditions || []
49
49
 
50
50
  const startTime = status.startTime ? new Date(status.startTime) : null
51
- const completionTime = status.completionTime ? new Date(status.completionTime) : null
51
+ const terminalCondition = conditions.find((c: any) => (c.type === 'Complete' || c.type === 'Failed') && c.status === 'True')
52
+ const finishedAt = status.completionTime || terminalCondition?.lastTransitionTime
53
+ const completionTime = finishedAt ? new Date(finishedAt) : null
52
54
  const duration = startTime && completionTime
53
55
  ? formatDuration(completionTime.getTime() - startTime.getTime(), true)
54
56
  : startTime