@skyhook-io/k8s-ui 1.5.2 → 1.5.4
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.
- package/package.json +1 -1
- package/src/components/audit/AuditAlerts.tsx +6 -0
- package/src/components/audit/AuditFindingsTable.tsx +83 -33
- package/src/components/gitops/ManagedResourcesList.tsx +2 -1
- package/src/components/logs/JsonLogLine.tsx +10 -7
- package/src/components/logs/LogCore.tsx +160 -63
- package/src/components/logs/LogToolbarSelects.tsx +17 -6
- package/src/components/logs/LogsViewer.tsx +8 -7
- package/src/components/logs/StructuredLogLine.tsx +46 -47
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -34
- package/src/components/logs/log-palette.ts +222 -0
- package/src/components/logs/useLogBuffer.ts +6 -1
- package/src/components/resources/ResourcesView.tsx +183 -52
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +4 -3
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +4 -3
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +2 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -3
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -4
- package/src/components/resources/renderers/trivy-shared.tsx +3 -2
- package/src/components/resources/resource-utils-istio.ts +4 -3
- package/src/components/resources/resource-utils.ts +10 -2
- package/src/components/timeline/TimelineList.tsx +2 -1
- package/src/components/topology/GroupNode.tsx +2 -1
- package/src/components/topology/TopologyGraph.tsx +2 -1
- package/src/components/ui/Badge.tsx +2 -1
- package/src/components/ui/ClusterName.tsx +95 -0
- package/src/components/ui/EmptyState.tsx +118 -0
- package/src/components/ui/FilterPill.tsx +98 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +2 -1
- package/src/components/ui/ResourceBar.tsx +1 -1
- package/src/components/ui/Tooltip.tsx +43 -0
- package/src/components/ui/drawer-components.tsx +11 -0
- package/src/components/ui/index.ts +7 -0
- package/src/components/ui/provider-logos/aws-dark.png +0 -0
- package/src/components/ui/provider-logos/aws.png +0 -0
- package/src/components/ui/provider-logos/azure.svg +23 -0
- package/src/components/ui/provider-logos/gcp.png +0 -0
- package/src/components/ui/status-tone.test.ts +53 -0
- package/src/components/ui/status-tone.tsx +104 -0
- package/src/components/workload/WorkloadView.tsx +3 -3
- package/src/theme/components.css +7 -0
- package/src/utils/badge-colors.ts +1 -0
- package/src/utils/context-name.test.ts +106 -0
- package/src/utils/context-name.ts +25 -6
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.ts +4 -8
- package/src/utils/pluralize.test.ts +98 -0
- package/src/utils/pluralize.ts +60 -0
|
@@ -122,6 +122,7 @@ import {
|
|
|
122
122
|
SEVERITY_DOT_COLOR,
|
|
123
123
|
} from './resource-utils'
|
|
124
124
|
import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
|
|
125
|
+
import { pluralize } from '../../utils/pluralize'
|
|
125
126
|
import { Tooltip } from '../ui/Tooltip'
|
|
126
127
|
// CRD-specific cell components (extracted)
|
|
127
128
|
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
|
|
@@ -179,6 +180,31 @@ interface Column {
|
|
|
179
180
|
minWidth?: number // minimum width in px
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Extra column injected by the parent — for example, a leading "Cluster"
|
|
185
|
+
* column when the table is rendered inside a multi-cluster host.
|
|
186
|
+
* Self-contained: carries its own render/sort/filter functions so the
|
|
187
|
+
* host code doesn't need to extend KNOWN_COLUMNS or the per-kind cell
|
|
188
|
+
* renderers.
|
|
189
|
+
*
|
|
190
|
+
* When extraLeadingColumns is undefined or empty (the standard
|
|
191
|
+
* single-cluster path), the rest of ResourcesView's behavior is
|
|
192
|
+
* byte-identical to today.
|
|
193
|
+
*
|
|
194
|
+
* Keys must NOT collide with existing KNOWN_COLUMNS keys (name,
|
|
195
|
+
* namespace, age, status, ready, etc.) — collisions silently bypass
|
|
196
|
+
* the extra and fall through to the built-in cell.
|
|
197
|
+
*/
|
|
198
|
+
export interface ExtraColumn extends Column {
|
|
199
|
+
/** Cell content for this column. Receives the resource row. */
|
|
200
|
+
render: (resource: any) => React.ReactNode
|
|
201
|
+
/** Sort key extractor. Falls back to localeCompare on render output. */
|
|
202
|
+
getSortValue?: (resource: any) => string | number
|
|
203
|
+
/** Filter value extractor used by the column-filter dropdown's
|
|
204
|
+
* unique-values pull. Falls back to row not being filterable. */
|
|
205
|
+
getFilterValue?: (resource: any) => string
|
|
206
|
+
}
|
|
207
|
+
|
|
182
208
|
// Tailwind width class → pixel minimum mapping for CSS Grid column sizing
|
|
183
209
|
const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
|
|
184
210
|
'w-12': 48, 'w-14': 56, 'w-16': 64, 'w-20': 80, 'w-24': 96,
|
|
@@ -364,7 +390,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
364
390
|
{ key: 'name', label: 'Name' },
|
|
365
391
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
366
392
|
{ key: 'status', label: 'Ready', width: 'w-24' },
|
|
367
|
-
{ key: 'domains', label: 'Domains', width: 'w-
|
|
393
|
+
{ key: 'domains', label: 'Domains', width: 'w-56' },
|
|
368
394
|
{ key: 'issuer', label: 'Issuer', width: 'w-36', hideOnMobile: true },
|
|
369
395
|
{ key: 'expires', label: 'Expires', width: 'w-24', tooltip: 'Days until certificate expires' },
|
|
370
396
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -1628,21 +1654,54 @@ interface ResourcesViewProps {
|
|
|
1628
1654
|
hideSidebar?: boolean
|
|
1629
1655
|
/** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
|
|
1630
1656
|
onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
|
|
1657
|
+
/** Columns prepended to KNOWN_COLUMNS for every kind. For example, a
|
|
1658
|
+
* multi-cluster host can inject a leading Cluster column. Each extra
|
|
1659
|
+
* column is self-contained (own render/sort/filter), so the host
|
|
1660
|
+
* doesn't need to extend KNOWN_COLUMNS or per-kind cell renderers.
|
|
1661
|
+
* When undefined, behavior is byte-identical to single-cluster mode. */
|
|
1662
|
+
extraLeadingColumns?: ExtraColumn[]
|
|
1663
|
+
/** Escape hatch for full-page-nav row selection: receive the FULL
|
|
1664
|
+
* resource object on row click / Enter / `d` / search-Enter, instead
|
|
1665
|
+
* of the stripped {kind, namespace, name, group} shape onResourceClick
|
|
1666
|
+
* gets. Lets the parent read injected fields (e.g. multi-cluster
|
|
1667
|
+
* row-level metadata for cross-tree drill-in nav) without a parallel
|
|
1668
|
+
* (kind, ns, name) → owner lookup that wouldn't dedup for resources
|
|
1669
|
+
* sharing a namespaced name across cluster boundaries. When set,
|
|
1670
|
+
* fires INSTEAD OF onResourceClick on those selection paths.
|
|
1671
|
+
*
|
|
1672
|
+
* Not honored on: `y` (open YAML — routes through onResourceClickYaml)
|
|
1673
|
+
* and `l` (open logs — different intent). URL deep-link hydration on
|
|
1674
|
+
* mount (`?resource=ns/name`) only has stripped params and always
|
|
1675
|
+
* calls onResourceClick — hosts using onRowSelect for full-page nav
|
|
1676
|
+
* should also wire onResourceClick to handle the deep-link-on-load
|
|
1677
|
+
* case. */
|
|
1678
|
+
onRowSelect?: (resource: any) => void
|
|
1631
1679
|
}
|
|
1632
1680
|
|
|
1633
1681
|
// Default selected kind
|
|
1634
1682
|
const DEFAULT_KIND_INFO: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
|
|
1635
1683
|
|
|
1636
1684
|
// Read initial state from URL — kind is in the path: {basePath}/{kind}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1685
|
+
//
|
|
1686
|
+
// Hosts that own their own URL shape (an embedding host with custom
|
|
1687
|
+
// routes outside the `{basePath}/{kind}` convention) pass a synthetic
|
|
1688
|
+
// pathname/search via the locationPathname/locationSearch props. When
|
|
1689
|
+
// those are provided, prefer them over window.location — otherwise the
|
|
1690
|
+
// host's URL wouldn't resolve against the synthetic basePath and we'd
|
|
1691
|
+
// fall through to DEFAULT_KIND.
|
|
1692
|
+
function getInitialKindFromURL(
|
|
1693
|
+
basePath: string = '/resources',
|
|
1694
|
+
locationPathname?: string,
|
|
1695
|
+
locationSearch?: string,
|
|
1696
|
+
): SelectedKindInfo {
|
|
1697
|
+
const pathname = locationPathname || window.location.pathname
|
|
1698
|
+
const search = locationSearch || window.location.search
|
|
1640
1699
|
const base = basePath.replace(/\/$/, '') // strip trailing slash
|
|
1641
1700
|
let kind: string | null = null
|
|
1642
1701
|
if (pathname.startsWith(base + '/')) {
|
|
1643
1702
|
kind = pathname.slice(base.length + 1).split('/')[0] || null
|
|
1644
1703
|
}
|
|
1645
|
-
const group = new URLSearchParams(
|
|
1704
|
+
const group = new URLSearchParams(search).get('apiGroup') || ''
|
|
1646
1705
|
if (kind) {
|
|
1647
1706
|
// Find matching resource from CORE_RESOURCES or use as-is
|
|
1648
1707
|
// Only match core resources when no apiGroup is specified (avoids collisions like KNative Service)
|
|
@@ -1700,13 +1759,15 @@ export function ResourcesView({
|
|
|
1700
1759
|
onSelectedKindChange,
|
|
1701
1760
|
hideSidebar = false,
|
|
1702
1761
|
onCreateResource,
|
|
1762
|
+
extraLeadingColumns,
|
|
1763
|
+
onRowSelect,
|
|
1703
1764
|
}: ResourcesViewProps) {
|
|
1704
1765
|
const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
|
|
1705
1766
|
const initialFilters = getInitialFiltersFromURL()
|
|
1706
|
-
const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath))
|
|
1767
|
+
const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, locationPathname, locationSearch))
|
|
1707
1768
|
// Sync selectedKind from URL when locationPathname changes (e.g., browser back, external sidebar navigation)
|
|
1708
1769
|
useEffect(() => {
|
|
1709
|
-
const kindFromURL = getInitialKindFromURL(basePath)
|
|
1770
|
+
const kindFromURL = getInitialKindFromURL(basePath, locationPathname, locationSearch)
|
|
1710
1771
|
if (kindFromURL.name !== selectedKind.name || kindFromURL.group !== selectedKind.group) {
|
|
1711
1772
|
setSelectedKind(kindFromURL)
|
|
1712
1773
|
}
|
|
@@ -1804,8 +1865,36 @@ export function ResourcesView({
|
|
|
1804
1865
|
return { pods, nodes }
|
|
1805
1866
|
}, [topPodMetrics, topNodeMetrics])
|
|
1806
1867
|
|
|
1807
|
-
//
|
|
1808
|
-
|
|
1868
|
+
// Prepend extraLeadingColumns (host-injected leading columns) before
|
|
1869
|
+
// the kind-specific KNOWN_COLUMNS entries. When extras are undefined,
|
|
1870
|
+
// this collapses to single-cluster behavior. Built-in keys win on
|
|
1871
|
+
// collision: a colliding extra is filtered out (with a dev-mode warn)
|
|
1872
|
+
// so we don't render two columns sharing one key — that would yield
|
|
1873
|
+
// duplicate React keys and corrupt visibleColumns / columnWidths state.
|
|
1874
|
+
const allColumns = useMemo(() => {
|
|
1875
|
+
const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
|
|
1876
|
+
if (!extraLeadingColumns?.length) return kindColumns
|
|
1877
|
+
const builtinKeys = new Set(kindColumns.map(c => c.key))
|
|
1878
|
+
const filteredExtras = extraLeadingColumns.filter(c => {
|
|
1879
|
+
if (builtinKeys.has(c.key)) {
|
|
1880
|
+
if (import.meta.env?.DEV) {
|
|
1881
|
+
// eslint-disable-next-line no-console
|
|
1882
|
+
console.warn(`[ResourcesView] extraLeadingColumns key "${c.key}" collides with a built-in column for kind "${selectedKind.name}" — extra ignored`)
|
|
1883
|
+
}
|
|
1884
|
+
return false
|
|
1885
|
+
}
|
|
1886
|
+
return true
|
|
1887
|
+
})
|
|
1888
|
+
return [...filteredExtras, ...kindColumns]
|
|
1889
|
+
}, [selectedKind.name, selectedKind.group, extraLeadingColumns])
|
|
1890
|
+
|
|
1891
|
+
// Map of extra column keys for fast O(1) lookup on each render path
|
|
1892
|
+
// (cell render, sort, column-filter unique-values).
|
|
1893
|
+
const extraColumnsByKey = useMemo(() => {
|
|
1894
|
+
const m = new Map<string, ExtraColumn>()
|
|
1895
|
+
extraLeadingColumns?.forEach(c => m.set(c.key, c))
|
|
1896
|
+
return m
|
|
1897
|
+
}, [extraLeadingColumns])
|
|
1809
1898
|
|
|
1810
1899
|
useEffect(() => {
|
|
1811
1900
|
const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
|
|
@@ -1985,6 +2074,28 @@ export function ResourcesView({
|
|
|
1985
2074
|
return highlightedResourceRef.current
|
|
1986
2075
|
}, [])
|
|
1987
2076
|
|
|
2077
|
+
// Selection handler shared by row click, Enter / `d` shortcuts, and the
|
|
2078
|
+
// search-Enter path. Honors the onRowSelect escape hatch when set so
|
|
2079
|
+
// full-page-nav consumers stay consistent across activation paths;
|
|
2080
|
+
// otherwise falls through to the drawer-pattern onResourceClick with
|
|
2081
|
+
// the stripped {kind, namespace, name, group} shape.
|
|
2082
|
+
// (Distinct from `y` / `l` shortcuts, which open YAML / logs rather
|
|
2083
|
+
// than "select the row" — those route to their own callbacks.)
|
|
2084
|
+
const selectResource = useCallback((resource: any, isSelected = false) => {
|
|
2085
|
+
if (!resource?.metadata?.name) return
|
|
2086
|
+
if (onRowSelect) {
|
|
2087
|
+
onRowSelect(resource)
|
|
2088
|
+
return
|
|
2089
|
+
}
|
|
2090
|
+
const stripped = {
|
|
2091
|
+
kind: selectedKind.name,
|
|
2092
|
+
namespace: resource.metadata.namespace || '',
|
|
2093
|
+
name: resource.metadata.name,
|
|
2094
|
+
group: selectedKind.group,
|
|
2095
|
+
}
|
|
2096
|
+
onResourceClick?.(isSelected ? null : stripped)
|
|
2097
|
+
}, [onRowSelect, onResourceClick, selectedKind.name, selectedKind.group])
|
|
2098
|
+
|
|
1988
2099
|
// Register navigation shortcuts
|
|
1989
2100
|
useRegisterShortcuts([
|
|
1990
2101
|
{
|
|
@@ -2047,11 +2158,7 @@ export function ResourcesView({
|
|
|
2047
2158
|
description: 'Open resource detail',
|
|
2048
2159
|
category: 'Resource Actions',
|
|
2049
2160
|
scope: 'resources',
|
|
2050
|
-
handler: () =>
|
|
2051
|
-
const res = getHighlightedResource()
|
|
2052
|
-
if (!res?.metadata?.name) return
|
|
2053
|
-
onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
|
|
2054
|
-
},
|
|
2161
|
+
handler: () => selectResource(getHighlightedResource()),
|
|
2055
2162
|
enabled: highlightedIndex >= 0,
|
|
2056
2163
|
},
|
|
2057
2164
|
{
|
|
@@ -2060,11 +2167,7 @@ export function ResourcesView({
|
|
|
2060
2167
|
description: 'Open resource detail',
|
|
2061
2168
|
category: 'Resource Actions',
|
|
2062
2169
|
scope: 'resources',
|
|
2063
|
-
handler: () =>
|
|
2064
|
-
const res = getHighlightedResource()
|
|
2065
|
-
if (!res?.metadata?.name) return
|
|
2066
|
-
onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
|
|
2067
|
-
},
|
|
2170
|
+
handler: () => selectResource(getHighlightedResource()),
|
|
2068
2171
|
enabled: highlightedIndex >= 0,
|
|
2069
2172
|
},
|
|
2070
2173
|
{
|
|
@@ -2221,7 +2324,7 @@ export function ResourcesView({
|
|
|
2221
2324
|
isSyncingFromURL.current = true
|
|
2222
2325
|
|
|
2223
2326
|
// Re-read URL params and update state
|
|
2224
|
-
const newKind = getInitialKindFromURL(basePath)
|
|
2327
|
+
const newKind = getInitialKindFromURL(basePath, locationPathname, locationSearch)
|
|
2225
2328
|
const newFilters = getInitialFiltersFromURL()
|
|
2226
2329
|
|
|
2227
2330
|
// Update kind if it changed
|
|
@@ -2318,13 +2421,13 @@ export function ResourcesView({
|
|
|
2318
2421
|
|
|
2319
2422
|
const newPath = `${basePath}/${kindInfo.name}`
|
|
2320
2423
|
const queryStr = params.toString()
|
|
2321
|
-
const newURL = queryStr ? `${newPath}?${queryStr}` : newPath
|
|
2322
2424
|
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2425
|
+
// Route both push and replace through `navigate` (which honors the
|
|
2426
|
+
// onNavigate prop). The previous direct `window.history.replaceState`
|
|
2427
|
+
// bypass meant a host that wants to suppress URL writes (passing
|
|
2428
|
+
// `onNavigate={() => {}}`) couldn't suppress filter-change
|
|
2429
|
+
// updates — they'd still rewrite the address bar through history.
|
|
2430
|
+
navigate({ pathname: newPath, search: queryStr }, { replace: !pushHistory })
|
|
2328
2431
|
}, [navigate, basePath])
|
|
2329
2432
|
|
|
2330
2433
|
// Update URL when any filter changes
|
|
@@ -2373,12 +2476,13 @@ export function ResourcesView({
|
|
|
2373
2476
|
// Signal that initial resource param has been processed — URL update effect can now run
|
|
2374
2477
|
hasProcessedInitialResource.current = true
|
|
2375
2478
|
|
|
2376
|
-
// If the URL has no kind segment (e.g., /resources), update to include the default kind
|
|
2479
|
+
// If the URL has no kind segment (e.g., /resources), update to include the default kind.
|
|
2480
|
+
// Route through `navigate` so an onNavigate-suppressing host can opt out.
|
|
2377
2481
|
const base = basePath.replace(/\/$/, '')
|
|
2378
2482
|
const path = window.location.pathname
|
|
2379
2483
|
if (path === base || path === base + '/') {
|
|
2380
2484
|
const search = window.location.search
|
|
2381
|
-
|
|
2485
|
+
navigate({ pathname: `${base}/${selectedKind.name}`, search: search.replace(/^\?/, '') }, { replace: true })
|
|
2382
2486
|
}
|
|
2383
2487
|
}, []) // Only on mount
|
|
2384
2488
|
|
|
@@ -2701,13 +2805,17 @@ export function ResourcesView({
|
|
|
2701
2805
|
}
|
|
2702
2806
|
|
|
2703
2807
|
// Apply column filters (generic, multi-select per column — OR within column, AND across columns)
|
|
2808
|
+
// Extra columns override the built-in getCellFilterValue
|
|
2809
|
+
// when the parent supplied a custom getFilterValue.
|
|
2704
2810
|
const activeColFilters = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
|
|
2705
2811
|
if (activeColFilters.length > 0) {
|
|
2706
2812
|
const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
|
|
2707
2813
|
result = result.filter((r: any) =>
|
|
2708
|
-
activeColFilters.every(([col, vals]) =>
|
|
2709
|
-
|
|
2710
|
-
|
|
2814
|
+
activeColFilters.every(([col, vals]) => {
|
|
2815
|
+
const extra = extraColumnsByKey.get(col)
|
|
2816
|
+
const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col, kindLower)
|
|
2817
|
+
return vals.includes(cellVal)
|
|
2818
|
+
})
|
|
2711
2819
|
)
|
|
2712
2820
|
}
|
|
2713
2821
|
|
|
@@ -2762,11 +2870,13 @@ export function ResourcesView({
|
|
|
2762
2870
|
})
|
|
2763
2871
|
}
|
|
2764
2872
|
|
|
2765
|
-
// Apply custom sorting if set
|
|
2873
|
+
// Apply custom sorting if set. Extra columns override
|
|
2874
|
+
// the built-in getSortValue for their key.
|
|
2766
2875
|
if (sortColumn && sortDirection) {
|
|
2876
|
+
const extra = extraColumnsByKey.get(sortColumn)
|
|
2767
2877
|
result = [...result].sort((a: any, b: any) => {
|
|
2768
|
-
const aVal = getSortValue(a, sortColumn, selectedKind.name)
|
|
2769
|
-
const bVal = getSortValue(b, sortColumn, selectedKind.name)
|
|
2878
|
+
const aVal = extra?.getSortValue ? extra.getSortValue(a) : getSortValue(a, sortColumn, selectedKind.name)
|
|
2879
|
+
const bVal = extra?.getSortValue ? extra.getSortValue(b) : getSortValue(b, sortColumn, selectedKind.name)
|
|
2770
2880
|
let comparison = 0
|
|
2771
2881
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
2772
2882
|
comparison = aVal - bVal
|
|
@@ -2951,7 +3061,12 @@ export function ResourcesView({
|
|
|
2951
3061
|
if (!resources || resources.length === 0) return null
|
|
2952
3062
|
|
|
2953
3063
|
const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
|
|
2954
|
-
|
|
3064
|
+
// Iterate over allColumns (built-ins + injected extras) so an
|
|
3065
|
+
// ExtraColumn with getFilterValue gets a column-filter dropdown like
|
|
3066
|
+
// any built-in does. The previous formulation iterated KNOWN_COLUMNS
|
|
3067
|
+
// directly, which silently skipped extras whose keys weren't already
|
|
3068
|
+
// in the built-in set.
|
|
3069
|
+
const columns = allColumns
|
|
2955
3070
|
|
|
2956
3071
|
// Auto-detect filterable columns
|
|
2957
3072
|
const filterableColumns: Array<{
|
|
@@ -2963,10 +3078,16 @@ export function ResourcesView({
|
|
|
2963
3078
|
for (const col of columns) {
|
|
2964
3079
|
if (SKIP_FILTER_COLUMNS.has(col.key)) continue
|
|
2965
3080
|
|
|
3081
|
+
// Extra columns supply their own filter value extractor.
|
|
3082
|
+
// Skip the dropdown for extras that didn't supply one (no way to
|
|
3083
|
+
// build the unique-values list without it).
|
|
3084
|
+
const extra = extraColumnsByKey.get(col.key)
|
|
3085
|
+
if (extra && !extra.getFilterValue) continue
|
|
3086
|
+
|
|
2966
3087
|
// Count distinct values for this column
|
|
2967
3088
|
const valueCounts: Record<string, number> = {}
|
|
2968
3089
|
for (const r of resources) {
|
|
2969
|
-
const val = getCellFilterValue(r, col.key, kindLower)
|
|
3090
|
+
const val = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col.key, kindLower)
|
|
2970
3091
|
if (val) {
|
|
2971
3092
|
valueCounts[val] = (valueCounts[val] || 0) + 1
|
|
2972
3093
|
}
|
|
@@ -3052,7 +3173,7 @@ export function ResourcesView({
|
|
|
3052
3173
|
|
|
3053
3174
|
if (filterableColumns.length === 0 && !problems && labelValues.length === 0) return null
|
|
3054
3175
|
return { columns: filterableColumns, problems, labels: labelValues }
|
|
3055
|
-
}, [resources, selectedKind.name])
|
|
3176
|
+
}, [resources, selectedKind.name, selectedKind.group, allColumns])
|
|
3056
3177
|
|
|
3057
3178
|
// Map filterable columns by key for O(1) lookup in header rendering
|
|
3058
3179
|
const filterableColumnMap = useMemo(() => {
|
|
@@ -3090,17 +3211,18 @@ export function ResourcesView({
|
|
|
3090
3211
|
? existing.filter(p => p !== pair)
|
|
3091
3212
|
: [...existing, pair]
|
|
3092
3213
|
const newSelector = newLabels.join(',')
|
|
3093
|
-
// Sync to URL
|
|
3214
|
+
// Sync to URL — route through `navigate` so the onNavigate prop
|
|
3215
|
+
// can suppress the write.
|
|
3094
3216
|
const params = new URLSearchParams(window.location.search)
|
|
3095
3217
|
if (newSelector) {
|
|
3096
3218
|
params.set('labels', newSelector)
|
|
3097
3219
|
} else {
|
|
3098
3220
|
params.delete('labels')
|
|
3099
3221
|
}
|
|
3100
|
-
|
|
3222
|
+
navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
|
|
3101
3223
|
return newSelector
|
|
3102
3224
|
})
|
|
3103
|
-
}, [])
|
|
3225
|
+
}, [navigate])
|
|
3104
3226
|
|
|
3105
3227
|
// Parse active label pairs for display
|
|
3106
3228
|
const activeLabelPairs = useMemo(() => {
|
|
@@ -3170,9 +3292,7 @@ export function ResourcesView({
|
|
|
3170
3292
|
// Defer to next frame so the highlight renders before we open
|
|
3171
3293
|
requestAnimationFrame(() => {
|
|
3172
3294
|
const res = highlightedResourceRef.current ?? filteredResources[0]
|
|
3173
|
-
|
|
3174
|
-
onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
|
|
3175
|
-
}
|
|
3295
|
+
selectResource(res)
|
|
3176
3296
|
})
|
|
3177
3297
|
} else if (e.key === 'Escape') {
|
|
3178
3298
|
searchInputRef.current?.blur()
|
|
@@ -3322,10 +3442,12 @@ export function ResourcesView({
|
|
|
3322
3442
|
onClick={() => {
|
|
3323
3443
|
setOwnerKind('')
|
|
3324
3444
|
setOwnerName('')
|
|
3445
|
+
// Route through `navigate` so onNavigate-suppressing hosts
|
|
3446
|
+
// can opt out of the URL write.
|
|
3325
3447
|
const params = new URLSearchParams(window.location.search)
|
|
3326
3448
|
params.delete('ownerKind')
|
|
3327
3449
|
params.delete('ownerName')
|
|
3328
|
-
|
|
3450
|
+
navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
|
|
3329
3451
|
}}
|
|
3330
3452
|
className="hover:text-theme-text-primary"
|
|
3331
3453
|
>
|
|
@@ -3676,14 +3798,12 @@ export function ResourcesView({
|
|
|
3676
3798
|
kind={selectedKind.name}
|
|
3677
3799
|
group={selectedKind.group}
|
|
3678
3800
|
columns={columns}
|
|
3801
|
+
extraColumnsByKey={extraColumnsByKey}
|
|
3679
3802
|
hasSpacerColumn={hasResizedColumns}
|
|
3680
3803
|
isSelected={isSelected}
|
|
3681
3804
|
isHighlighted={isHighlighted}
|
|
3682
3805
|
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
3683
|
-
onClick={() =>
|
|
3684
|
-
const res = { kind: selectedKind.name, namespace: resource.metadata?.namespace || '', name: resource.metadata?.name, group: selectedKind.group }
|
|
3685
|
-
onResourceClick?.(isSelected ? null : res)
|
|
3686
|
-
}}
|
|
3806
|
+
onClick={() => selectResource(resource, isSelected)}
|
|
3687
3807
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
3688
3808
|
/>
|
|
3689
3809
|
)
|
|
@@ -3711,6 +3831,7 @@ interface ResourceRowCellsProps {
|
|
|
3711
3831
|
kind: string
|
|
3712
3832
|
group?: string
|
|
3713
3833
|
columns: Column[]
|
|
3834
|
+
extraColumnsByKey?: Map<string, ExtraColumn>
|
|
3714
3835
|
hasSpacerColumn: boolean
|
|
3715
3836
|
isSelected?: boolean
|
|
3716
3837
|
isHighlighted?: boolean
|
|
@@ -3719,7 +3840,7 @@ interface ResourceRowCellsProps {
|
|
|
3719
3840
|
onMouseEnter?: () => void
|
|
3720
3841
|
}
|
|
3721
3842
|
|
|
3722
|
-
function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
|
|
3843
|
+
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
|
|
3723
3844
|
return (
|
|
3724
3845
|
<>
|
|
3725
3846
|
{columns.map((col) => (
|
|
@@ -3737,7 +3858,7 @@ function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isS
|
|
|
3737
3858
|
: 'group-hover/row:bg-theme-surface/50'
|
|
3738
3859
|
)}
|
|
3739
3860
|
>
|
|
3740
|
-
<CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} />
|
|
3861
|
+
<CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
|
|
3741
3862
|
</td>
|
|
3742
3863
|
))}
|
|
3743
3864
|
{hasSpacerColumn && <td className="border-b-subtle p-0" />}
|
|
@@ -3777,9 +3898,19 @@ interface CellContentProps {
|
|
|
3777
3898
|
column: string
|
|
3778
3899
|
group?: string
|
|
3779
3900
|
majorityNodeMinorVersion?: string
|
|
3901
|
+
/** When provided, the parent has injected an ExtraColumn for this
|
|
3902
|
+
* column key. Render via the extra's render() and short-circuit
|
|
3903
|
+
* the built-in cell logic. */
|
|
3904
|
+
extraColumn?: ExtraColumn
|
|
3780
3905
|
}
|
|
3781
3906
|
|
|
3782
|
-
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion }: CellContentProps) {
|
|
3907
|
+
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn }: CellContentProps) {
|
|
3908
|
+
// Parent-injected extra columns short-circuit the built-in switch.
|
|
3909
|
+
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
3910
|
+
if (extraColumn) {
|
|
3911
|
+
return <>{extraColumn.render(resource)}</>
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3783
3914
|
const meta = resource.metadata || {}
|
|
3784
3915
|
|
|
3785
3916
|
// Common columns
|
|
@@ -4291,7 +4422,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
|
|
|
4291
4422
|
{sq.restarts > 0 && (
|
|
4292
4423
|
<div className={clsx('border-t border-theme-border/50 pt-1 space-y-0.5', restartColor)}>
|
|
4293
4424
|
<div className="flex items-center gap-1.5">
|
|
4294
|
-
<span>{sq.restarts
|
|
4425
|
+
<span>{pluralize(sq.restarts, 'restart')}</span>
|
|
4295
4426
|
{lt?.finishedAt && <span className="text-theme-text-tertiary">· last {timeAgo(lt.finishedAt)}</span>}
|
|
4296
4427
|
</div>
|
|
4297
4428
|
{lt?.reason && (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database, HardDrive, Activity, Clock, Shield, KeyRound } from 'lucide-react'
|
|
2
2
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
3
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
3
4
|
import {
|
|
4
5
|
getCNPGClusterInstances,
|
|
5
6
|
getCNPGClusterPhase,
|
|
@@ -118,14 +119,14 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
118
119
|
<AlertBanner
|
|
119
120
|
variant="error"
|
|
120
121
|
title="Certificate Expired"
|
|
121
|
-
items={expiredCerts.map(c => `${c.secretName} expired ${
|
|
122
|
+
items={expiredCerts.map(c => `${c.secretName} expired ${pluralize(Math.abs(c.daysUntilExpiry), 'day')} ago (${c.expiryDate})`)}
|
|
122
123
|
/>
|
|
123
124
|
)}
|
|
124
125
|
{criticalCerts.length > 0 && (
|
|
125
126
|
<AlertBanner
|
|
126
127
|
variant="error"
|
|
127
128
|
title="Certificate Expiring Soon"
|
|
128
|
-
items={criticalCerts.map(c => `${c.secretName} expires in ${c.daysUntilExpiry
|
|
129
|
+
items={criticalCerts.map(c => `${c.secretName} expires in ${pluralize(c.daysUntilExpiry, 'day')} (${c.expiryDate})`)}
|
|
129
130
|
/>
|
|
130
131
|
)}
|
|
131
132
|
{warningCerts.length > 0 && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Shield, Clock, Globe, Key } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
|
|
4
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
4
5
|
|
|
5
6
|
interface CertificateRendererProps {
|
|
6
7
|
data: any
|
|
@@ -87,7 +88,7 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
|
|
|
87
88
|
{expiresWithin7Days && (
|
|
88
89
|
<AlertBanner
|
|
89
90
|
variant="error"
|
|
90
|
-
title={`Certificate expires in ${daysUntilExpiry
|
|
91
|
+
title={`Certificate expires in ${pluralize(daysUntilExpiry, 'day')}`}
|
|
91
92
|
message="Check that cert-manager is renewing this certificate."
|
|
92
93
|
/>
|
|
93
94
|
)}
|
|
@@ -95,7 +96,7 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
|
|
|
95
96
|
{expiresWithin30Days && (
|
|
96
97
|
<AlertBanner
|
|
97
98
|
variant="warning"
|
|
98
|
-
title={`Certificate expires in ${daysUntilExpiry
|
|
99
|
+
title={`Certificate expires in ${pluralize(daysUntilExpiry, 'day')}`}
|
|
99
100
|
message="Renewal should happen automatically before expiry."
|
|
100
101
|
/>
|
|
101
102
|
)}
|
|
@@ -103,7 +104,7 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
|
|
|
103
104
|
{failedIssuanceAttempts > 0 && (
|
|
104
105
|
<AlertBanner
|
|
105
106
|
variant="warning"
|
|
106
|
-
title={
|
|
107
|
+
title={pluralize(failedIssuanceAttempts, 'failed issuance attempt')}
|
|
107
108
|
message={lastFailureTime ? `Last failure: ${formatDate(lastFailureTime)}` : undefined}
|
|
108
109
|
/>
|
|
109
110
|
)}
|
|
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
|
|
5
5
|
import { formatAge } from '../resource-utils'
|
|
6
6
|
import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER } from './trivy-shared'
|
|
7
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
7
8
|
|
|
8
9
|
interface ClusterComplianceReportRendererProps {
|
|
9
10
|
data: any
|
|
@@ -77,7 +78,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
|
|
|
77
78
|
<AlertBanner
|
|
78
79
|
variant="error"
|
|
79
80
|
icon={XCircle}
|
|
80
|
-
title={`${failCount
|
|
81
|
+
title={`${pluralize(failCount, 'control')} failed`}
|
|
81
82
|
message="Review failed controls to improve cluster compliance."
|
|
82
83
|
/>
|
|
83
84
|
)}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getClusterExternalSecretNamespaces,
|
|
8
8
|
getClusterExternalSecretNamespaceSelector,
|
|
9
9
|
} from '../resource-utils-eso'
|
|
10
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
10
11
|
|
|
11
12
|
interface ClusterExternalSecretRendererProps {
|
|
12
13
|
data: any
|
|
@@ -38,7 +39,7 @@ export function ClusterExternalSecretRenderer({ data, onNavigate }: ClusterExter
|
|
|
38
39
|
{failedNamespaces.length > 0 && (
|
|
39
40
|
<AlertBanner
|
|
40
41
|
variant="error"
|
|
41
|
-
title={`Failed in ${failedNamespaces.length
|
|
42
|
+
title={`Failed in ${pluralize(failedNamespaces.length, 'namespace')}`}
|
|
42
43
|
message="ExternalSecret provisioning failed in one or more namespaces."
|
|
43
44
|
items={failedNamespaces.map(f => `${f.namespace}${f.reason ? `: ${f.reason}` : ''}`)}
|
|
44
45
|
/>
|
|
@@ -48,9 +49,9 @@ export function ClusterExternalSecretRenderer({ data, onNavigate }: ClusterExter
|
|
|
48
49
|
<Section title="Overview" icon={Globe} defaultExpanded>
|
|
49
50
|
<PropertyList>
|
|
50
51
|
<Property label="Status" value={cesStatus.text} />
|
|
51
|
-
<Property label="Provisioned" value={
|
|
52
|
+
<Property label="Provisioned" value={pluralize(provisionedNamespaces.length, 'namespace')} />
|
|
52
53
|
{failedNamespaces.length > 0 && (
|
|
53
|
-
<Property label="Failed" value={
|
|
54
|
+
<Property label="Failed" value={pluralize(failedNamespaces.length, 'namespace')} />
|
|
54
55
|
)}
|
|
55
56
|
</PropertyList>
|
|
56
57
|
</Section>
|
|
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
5
5
|
import { formatAge } from '../resource-utils'
|
|
6
6
|
import { SEVERITY_BADGE_COLORS, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
|
|
7
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
7
8
|
|
|
8
9
|
interface ExposedSecretReportRendererProps {
|
|
9
10
|
data: any
|
|
@@ -72,7 +73,7 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
|
|
|
72
73
|
className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
|
|
73
74
|
>
|
|
74
75
|
{expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
|
75
|
-
{secrets.length
|
|
76
|
+
{pluralize(secrets.length, 'secret')}
|
|
76
77
|
</button>
|
|
77
78
|
{expanded && (
|
|
78
79
|
<div className="overflow-x-auto -mx-1">
|
|
@@ -2,6 +2,7 @@ import { Container, Settings } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, KnativeNotReadyBanner } from '../../ui/drawer-components'
|
|
4
4
|
import { getRevisionStatus } from '../resource-utils-knative'
|
|
5
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
5
6
|
|
|
6
7
|
interface KnativeRevisionRendererProps {
|
|
7
8
|
data: any
|
|
@@ -82,7 +83,7 @@ export function KnativeRevisionRenderer({ data }: KnativeRevisionRendererProps)
|
|
|
82
83
|
)}
|
|
83
84
|
{c.env && c.env.length > 0 && (
|
|
84
85
|
<div className="text-xs text-theme-text-tertiary mt-1">
|
|
85
|
-
Env: {c.env.length
|
|
86
|
+
Env: {pluralize(c.env.length, 'variable')}
|
|
86
87
|
</div>
|
|
87
88
|
)}
|
|
88
89
|
</div>
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getKyvernoPolicyRuleCountByType,
|
|
15
15
|
getKyvernoPolicyAutogenRules,
|
|
16
16
|
} from '../resource-utils-kyverno'
|
|
17
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
17
18
|
|
|
18
19
|
// ============================================================================
|
|
19
20
|
// PolicyReport / ClusterPolicyReport Renderer
|
|
@@ -112,7 +113,7 @@ export function PolicyReportRenderer({ data }: PolicyReportRendererProps) {
|
|
|
112
113
|
{summary.fail > 0 && (
|
|
113
114
|
<AlertBanner
|
|
114
115
|
variant="error"
|
|
115
|
-
title={`${summary.fail
|
|
116
|
+
title={`${pluralize(summary.fail, 'policy check')} failed`}
|
|
116
117
|
message={failResults.length <= 3
|
|
117
118
|
? failResults.map((r: any) => `${r.policy}${r.rule ? '/' + r.rule : ''}: ${r.message || 'failed'}`).join('; ')
|
|
118
119
|
: `${failResults.slice(0, 2).map((r: any) => r.policy).join(', ')} and ${failResults.length - 2} more`
|
|
@@ -122,7 +123,7 @@ export function PolicyReportRenderer({ data }: PolicyReportRendererProps) {
|
|
|
122
123
|
{summary.error > 0 && summary.fail === 0 && (
|
|
123
124
|
<AlertBanner
|
|
124
125
|
variant="error"
|
|
125
|
-
title={`${summary.error
|
|
126
|
+
title={`${pluralize(summary.error, 'policy check')} errored`}
|
|
126
127
|
message={errorResults.length <= 3
|
|
127
128
|
? errorResults.map((r: any) => `${r.policy}: ${r.message || 'error'}`).join('; ')
|
|
128
129
|
: undefined
|
|
@@ -132,7 +133,7 @@ export function PolicyReportRenderer({ data }: PolicyReportRendererProps) {
|
|
|
132
133
|
{summary.warn > 0 && summary.fail === 0 && summary.error === 0 && (
|
|
133
134
|
<AlertBanner
|
|
134
135
|
variant="warning"
|
|
135
|
-
title={
|
|
136
|
+
title={pluralize(summary.warn, 'policy warning')}
|
|
136
137
|
/>
|
|
137
138
|
)}
|
|
138
139
|
|