@skyhook-io/k8s-ui 1.7.11 → 1.7.13
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/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +64 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +43 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -1
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- package/src/utils/workload-colors.ts +36 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useMemo, useEffect, useCallback, useRef, useContext } from 'react'
|
|
1
|
+
import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, useId } from 'react'
|
|
2
2
|
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
4
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
Plus,
|
|
25
25
|
GitCompare,
|
|
26
26
|
Regex,
|
|
27
|
+
ListChecks,
|
|
27
28
|
} from 'lucide-react'
|
|
28
29
|
import { clsx } from 'clsx'
|
|
29
30
|
import { ResourceBar } from '../ui/ResourceBar'
|
|
@@ -127,6 +128,8 @@ import {
|
|
|
127
128
|
} from './resource-utils'
|
|
128
129
|
import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
|
|
129
130
|
import { pluralize } from '../../utils/pluralize'
|
|
131
|
+
import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
|
|
132
|
+
import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
|
|
130
133
|
import { Tooltip } from '../ui/Tooltip'
|
|
131
134
|
// CRD-specific cell components (extracted)
|
|
132
135
|
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
|
|
@@ -135,6 +138,8 @@ import { VulnerabilityReportCell, ConfigAuditReportCell, ExposedSecretReportCell
|
|
|
135
138
|
import { CertificateCell, CertificateRequestCell, ClusterIssuerCell, IssuerCell, OrderCell, ChallengeCell } from './renderers/certmanager-cells'
|
|
136
139
|
import { NodePoolCell, NodeClaimCell, EC2NodeClassCell } from './renderers/karpenter-cells'
|
|
137
140
|
import { ScaledObjectCell, ScaledJobCell, TriggerAuthenticationCell, ClusterTriggerAuthenticationCell } from './renderers/keda-cells'
|
|
141
|
+
import { ResourceClaimCell, ResourceClaimTemplateCell, DeviceClassCell, ResourceSliceCell } from './renderers/dra-cells'
|
|
142
|
+
import { NvidiaClusterPolicyCell, NvidiaDriverCell } from './renderers/nvidia-cells'
|
|
138
143
|
import { ServiceMonitorCell, PrometheusRuleCell, PodMonitorCell } from './renderers/prometheus-cells'
|
|
139
144
|
import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPolicyCell } from './renderers/kyverno-cells'
|
|
140
145
|
import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
|
|
@@ -154,6 +159,7 @@ import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboa
|
|
|
154
159
|
import { ResourcesSidebar } from './ResourcesSidebar'
|
|
155
160
|
import type { SelectedKindInfo } from './ResourcesSidebar'
|
|
156
161
|
import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
|
|
162
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
157
163
|
|
|
158
164
|
// Pod problem filter options (special multi-select, not a single column value)
|
|
159
165
|
const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
|
|
@@ -222,6 +228,31 @@ export interface ExtraColumn extends Column {
|
|
|
222
228
|
getFilterValue?: (resource: any) => string
|
|
223
229
|
}
|
|
224
230
|
|
|
231
|
+
// Materializes a custom-column def into a self-contained ExtraColumn so it
|
|
232
|
+
// rides the existing render/sort/filter override rails (extraColumnsByKey).
|
|
233
|
+
// The pure parts (key encoding, value read, load-boundary sanitizer) live in
|
|
234
|
+
// utils/custom-columns so they're unit-tested; only the JSX render stays here.
|
|
235
|
+
function buildCustomColumn(d: CustomColumnDef): ExtraColumn {
|
|
236
|
+
return {
|
|
237
|
+
key: customColumnKey(d),
|
|
238
|
+
label: d.path,
|
|
239
|
+
width: 'w-40',
|
|
240
|
+
defaultVisible: true,
|
|
241
|
+
tooltip: d.source === 'label' ? `Label: ${d.path}` : `Annotation: ${d.path}`,
|
|
242
|
+
render: (resource: any) => {
|
|
243
|
+
const v = readCustomColumnValue(resource, d)
|
|
244
|
+
if (!v) return <span className="text-sm text-theme-text-secondary">-</span>
|
|
245
|
+
return (
|
|
246
|
+
<Tooltip content={v}>
|
|
247
|
+
<span className="text-sm text-theme-text-secondary truncate block">{v}</span>
|
|
248
|
+
</Tooltip>
|
|
249
|
+
)
|
|
250
|
+
},
|
|
251
|
+
getSortValue: (resource: any) => readCustomColumnValue(resource, d),
|
|
252
|
+
getFilterValue: (resource: any) => readCustomColumnValue(resource, d),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
225
256
|
// Tailwind width class → pixel minimum mapping for CSS Grid column sizing
|
|
226
257
|
const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
|
|
227
258
|
'w-12': 48, 'w-14': 56, 'w-16': 64, 'w-20': 80, 'w-24': 96,
|
|
@@ -261,6 +292,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
261
292
|
{ key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
|
|
262
293
|
{ key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
|
|
263
294
|
{ key: 'restarts', label: 'Restarts', width: 'w-28' },
|
|
295
|
+
{ key: 'gpu', label: 'GPU', width: 'w-20', tooltip: 'Effective GPU request (requests, falling back to limits) summed across containers', defaultVisible: false },
|
|
264
296
|
{ key: 'podIP', label: 'Pod IP', width: 'w-32', defaultVisible: false },
|
|
265
297
|
{ key: 'node', label: 'Node', width: 'w-44', hideOnMobile: true },
|
|
266
298
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -337,6 +369,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
337
369
|
{ key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'Current CPU usage / allocatable' },
|
|
338
370
|
{ key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Current memory usage / allocatable' },
|
|
339
371
|
{ key: 'pods', label: 'Pods', width: 'w-28', tooltip: 'Pods running / allocatable' },
|
|
372
|
+
{ key: 'gpu', label: 'GPUs', width: 'w-20', tooltip: 'Allocatable GPUs', defaultVisible: false },
|
|
340
373
|
{ key: 'conditions', label: 'Conditions', width: 'w-40', hideOnMobile: true },
|
|
341
374
|
{ key: 'taints', label: 'Taints', width: 'w-24', hideOnMobile: true },
|
|
342
375
|
{ key: 'version', label: 'Version', width: 'w-28' },
|
|
@@ -548,6 +581,48 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
548
581
|
{ key: 'triggerTypes', label: 'Trigger Types', width: 'w-40' },
|
|
549
582
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
550
583
|
],
|
|
584
|
+
resourceclaims: [
|
|
585
|
+
{ key: 'name', label: 'Name' },
|
|
586
|
+
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
587
|
+
{ key: 'status', label: 'Status', width: 'w-28', tooltip: 'Allocated + reserved, allocated-but-unreserved, or pending' },
|
|
588
|
+
{ key: 'deviceClass', label: 'Device Class', width: 'w-44' },
|
|
589
|
+
{ key: 'allocated', label: 'Allocated Driver', width: 'w-44' },
|
|
590
|
+
{ key: 'reservedFor', label: 'Reserved For', width: 'w-44' },
|
|
591
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
592
|
+
],
|
|
593
|
+
resourceclaimtemplates: [
|
|
594
|
+
{ key: 'name', label: 'Name' },
|
|
595
|
+
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
596
|
+
{ key: 'deviceClass', label: 'Device Class', width: 'w-44' },
|
|
597
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
598
|
+
],
|
|
599
|
+
deviceclasses: [
|
|
600
|
+
{ key: 'name', label: 'Name' },
|
|
601
|
+
{ key: 'selectors', label: 'Selectors', width: 'w-28', tooltip: 'CEL device selector count' },
|
|
602
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
603
|
+
],
|
|
604
|
+
resourceslices: [
|
|
605
|
+
{ key: 'name', label: 'Name' },
|
|
606
|
+
{ key: 'driver', label: 'Driver', width: 'w-44' },
|
|
607
|
+
{ key: 'pool', label: 'Pool', width: 'w-36' },
|
|
608
|
+
{ key: 'node', label: 'Node', width: 'w-44' },
|
|
609
|
+
{ key: 'devices', label: 'Devices', width: 'w-24' },
|
|
610
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
611
|
+
],
|
|
612
|
+
nvidiaclusterpolicies: [
|
|
613
|
+
{ key: 'name', label: 'Name' },
|
|
614
|
+
{ key: 'status', label: 'Status', width: 'w-28' },
|
|
615
|
+
{ key: 'components', label: 'Components', width: 'w-64', tooltip: 'Enabled GPU Operator components' },
|
|
616
|
+
{ key: 'mig', label: 'MIG', width: 'w-24' },
|
|
617
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
618
|
+
],
|
|
619
|
+
nvidiadrivers: [
|
|
620
|
+
{ key: 'name', label: 'Name' },
|
|
621
|
+
{ key: 'status', label: 'Status', width: 'w-28' },
|
|
622
|
+
{ key: 'driverType', label: 'Type', width: 'w-28' },
|
|
623
|
+
{ key: 'version', label: 'Version', width: 'w-32' },
|
|
624
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
625
|
+
],
|
|
551
626
|
scaledjobs: [
|
|
552
627
|
{ key: 'name', label: 'Name' },
|
|
553
628
|
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
@@ -1589,6 +1664,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1589
1664
|
// Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
|
|
1590
1665
|
const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
|
|
1591
1666
|
clusters: { 'postgresql.cnpg.io': 'cnpgclusters', 'cluster.x-k8s.io': 'capiclusters' },
|
|
1667
|
+
clusterpolicies: { 'nvidia.com': 'nvidiaclusterpolicies' },
|
|
1592
1668
|
services: { 'serving.knative.dev': 'knativeservices' },
|
|
1593
1669
|
configurations: { 'serving.knative.dev': 'knativeconfigurations' },
|
|
1594
1670
|
revisions: { 'serving.knative.dev': 'knativerevisions' },
|
|
@@ -1649,12 +1725,36 @@ function getDefaultVisibleColumns(columns: Column[]): Set<string> {
|
|
|
1649
1725
|
return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.key))
|
|
1650
1726
|
}
|
|
1651
1727
|
|
|
1728
|
+
// Host-injected leading columns minus any whose key collides with a built-in.
|
|
1729
|
+
// Rendering two columns under one key duplicates React keys and corrupts the
|
|
1730
|
+
// visibleColumns / columnWidths maps, so a colliding extra is dropped (dev-warn).
|
|
1731
|
+
// Shared by the allColumns memo and the settings-load effect so the collision
|
|
1732
|
+
// rule can't drift between them.
|
|
1733
|
+
function filterHostExtras(
|
|
1734
|
+
extraLeadingColumns: ExtraColumn[] | undefined,
|
|
1735
|
+
builtinKeys: Set<string>,
|
|
1736
|
+
kindName: string,
|
|
1737
|
+
): ExtraColumn[] {
|
|
1738
|
+
if (!extraLeadingColumns?.length) return []
|
|
1739
|
+
return extraLeadingColumns.filter(c => {
|
|
1740
|
+
if (builtinKeys.has(c.key)) {
|
|
1741
|
+
if (import.meta.env?.DEV) {
|
|
1742
|
+
// eslint-disable-next-line no-console
|
|
1743
|
+
console.warn(`[ResourcesView] extraLeadingColumns key "${c.key}" collides with a built-in column for kind "${kindName}" — extra ignored`)
|
|
1744
|
+
}
|
|
1745
|
+
return false
|
|
1746
|
+
}
|
|
1747
|
+
return true
|
|
1748
|
+
})
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1652
1751
|
// localStorage helpers for column settings
|
|
1653
1752
|
const COLUMN_SETTINGS_PREFIX = 'radar-columns-'
|
|
1654
1753
|
|
|
1655
1754
|
interface ColumnSettings {
|
|
1656
1755
|
visible: string[]
|
|
1657
1756
|
widths: Record<string, number>
|
|
1757
|
+
custom?: CustomColumnDef[]
|
|
1658
1758
|
}
|
|
1659
1759
|
|
|
1660
1760
|
function loadColumnSettings(kind: string, group?: string): ColumnSettings | null {
|
|
@@ -1805,6 +1905,9 @@ interface ResourcesViewProps {
|
|
|
1805
1905
|
* the switcher lives outside this component and may persist server-side.
|
|
1806
1906
|
*/
|
|
1807
1907
|
onClearNamespaces?: () => void
|
|
1908
|
+
// Bulk operations
|
|
1909
|
+
onBulkDelete?: (items: Array<{ kind: string; group?: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
|
|
1910
|
+
isBulkDeleting?: boolean
|
|
1808
1911
|
}
|
|
1809
1912
|
|
|
1810
1913
|
// Default selected kind
|
|
@@ -1953,6 +2056,8 @@ export function ResourcesView({
|
|
|
1953
2056
|
onCompareSubmit,
|
|
1954
2057
|
resolveRowCluster,
|
|
1955
2058
|
onClearNamespaces,
|
|
2059
|
+
onBulkDelete,
|
|
2060
|
+
isBulkDeleting = false,
|
|
1956
2061
|
}: ResourcesViewProps) {
|
|
1957
2062
|
const initialFilters = getInitialFiltersFromURL()
|
|
1958
2063
|
const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
|
|
@@ -1970,6 +2075,8 @@ export function ResourcesView({
|
|
|
1970
2075
|
// Notify parent of selected kind changes (including initial mount)
|
|
1971
2076
|
useEffect(() => {
|
|
1972
2077
|
onSelectedKindChange?.(selectedKind)
|
|
2078
|
+
setBulkMode(false)
|
|
2079
|
+
setCheckedResources(new Set())
|
|
1973
2080
|
}, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1974
2081
|
const [searchTerm, setSearchTerm] = useState(initialFilters.search)
|
|
1975
2082
|
const [regexMode, setRegexMode] = useState(false)
|
|
@@ -1993,12 +2100,30 @@ export function ResourcesView({
|
|
|
1993
2100
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set())
|
|
1994
2101
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
|
|
1995
2102
|
const [showColumnPicker, setShowColumnPicker] = useState(false)
|
|
2103
|
+
const [customColumns, setCustomColumns] = useState<CustomColumnDef[]>([])
|
|
2104
|
+
const [customColumnDraft, setCustomColumnDraft] = useState<CustomColumnDef>({ source: 'label', path: '' })
|
|
2105
|
+
// Per-instance so two ResourcesView tables on one page (e.g. fleet compare)
|
|
2106
|
+
// don't share a datalist id and cross-suggest each other's keys.
|
|
2107
|
+
const customColKeysListId = useId()
|
|
1996
2108
|
const columnPickerRef = useRef<HTMLDivElement>(null)
|
|
1997
2109
|
// Label/owner filtering for deep-linking from workload details
|
|
1998
2110
|
const [labelSelector, setLabelSelector] = useState<string>(initialFilters.labelSelector)
|
|
1999
2111
|
const [ownerKind, setOwnerKind] = useState<string>(initialFilters.ownerKind)
|
|
2000
2112
|
const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)
|
|
2001
2113
|
|
|
2114
|
+
// Multi-select state for bulk operations. Checkboxes only render while
|
|
2115
|
+
// bulk mode is active — entered via the toolbar toggle — so the risky
|
|
2116
|
+
// bulk-delete surface stays out of the way during normal browsing.
|
|
2117
|
+
const [bulkMode, setBulkMode] = useState(false)
|
|
2118
|
+
const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
|
|
2119
|
+
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
|
2120
|
+
const [bulkForceDelete, setBulkForceDelete] = useState(false)
|
|
2121
|
+
|
|
2122
|
+
const exitBulkMode = useCallback(() => {
|
|
2123
|
+
setBulkMode(false)
|
|
2124
|
+
setCheckedResources(new Set())
|
|
2125
|
+
}, [])
|
|
2126
|
+
|
|
2002
2127
|
// Column filter helpers
|
|
2003
2128
|
const clearColumnFilter = useCallback((key: string) => {
|
|
2004
2129
|
setColumnFilters(prev => {
|
|
@@ -2064,6 +2189,12 @@ export function ResourcesView({
|
|
|
2064
2189
|
return { pods, nodes }
|
|
2065
2190
|
}, [topPodMetrics, topNodeMetrics])
|
|
2066
2191
|
|
|
2192
|
+
// User-defined label/annotation columns materialized as self-contained
|
|
2193
|
+
// ExtraColumns — appended after the built-ins and wired through the same
|
|
2194
|
+
// render/sort/filter override rails (extraColumnsByKey).
|
|
2195
|
+
const builtCustomColumns = useMemo(() => customColumns.map(buildCustomColumn), [customColumns])
|
|
2196
|
+
const customColumnKeySet = useMemo(() => new Set(builtCustomColumns.map(c => c.key)), [builtCustomColumns])
|
|
2197
|
+
|
|
2067
2198
|
// Prepend extraLeadingColumns (host-injected leading columns) before
|
|
2068
2199
|
// the kind-specific KNOWN_COLUMNS entries. When extras are undefined,
|
|
2069
2200
|
// this collapses to single-cluster behavior. Built-in keys win on
|
|
@@ -2072,48 +2203,74 @@ export function ResourcesView({
|
|
|
2072
2203
|
// duplicate React keys and corrupt visibleColumns / columnWidths state.
|
|
2073
2204
|
const allColumns = useMemo(() => {
|
|
2074
2205
|
const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
|
|
2075
|
-
if (!extraLeadingColumns?.length) return kindColumns
|
|
2206
|
+
if (!extraLeadingColumns?.length && !builtCustomColumns.length) return kindColumns
|
|
2076
2207
|
const builtinKeys = new Set(kindColumns.map(c => c.key))
|
|
2077
|
-
const filteredExtras = extraLeadingColumns
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
// eslint-disable-next-line no-console
|
|
2081
|
-
console.warn(`[ResourcesView] extraLeadingColumns key "${c.key}" collides with a built-in column for kind "${selectedKind.name}" — extra ignored`)
|
|
2082
|
-
}
|
|
2083
|
-
return false
|
|
2084
|
-
}
|
|
2085
|
-
return true
|
|
2086
|
-
})
|
|
2087
|
-
return [...filteredExtras, ...kindColumns]
|
|
2088
|
-
}, [selectedKind.name, selectedKind.group, extraLeadingColumns])
|
|
2208
|
+
const filteredExtras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
|
|
2209
|
+
return [...filteredExtras, ...kindColumns, ...builtCustomColumns]
|
|
2210
|
+
}, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns])
|
|
2089
2211
|
|
|
2090
2212
|
// Map of extra column keys for fast O(1) lookup on each render path
|
|
2091
2213
|
// (cell render, sort, column-filter unique-values).
|
|
2092
2214
|
const extraColumnsByKey = useMemo(() => {
|
|
2093
2215
|
const m = new Map<string, ExtraColumn>()
|
|
2094
2216
|
extraLeadingColumns?.forEach(c => m.set(c.key, c))
|
|
2217
|
+
builtCustomColumns.forEach(c => m.set(c.key, c))
|
|
2095
2218
|
return m
|
|
2096
|
-
}, [extraLeadingColumns])
|
|
2219
|
+
}, [extraLeadingColumns, builtCustomColumns])
|
|
2097
2220
|
|
|
2221
|
+
// Guards the save effect from persisting on the initial load of each kind
|
|
2222
|
+
// (set false by the load effect, flipped true on its first skipped save).
|
|
2223
|
+
const isColumnSettingsLoaded = useRef(false)
|
|
2224
|
+
// Whether the user has a persisted column blob for this kind — data-driven
|
|
2225
|
+
// column defaults (GPU auto-show) must never override explicit user choices.
|
|
2226
|
+
const hadSavedColumnSettings = useRef(false)
|
|
2227
|
+
// Kinds where GPU auto-show already fired this mount. Firing is one-shot:
|
|
2228
|
+
// without this, hiding the auto-shown column re-triggers the effect (it
|
|
2229
|
+
// depends on visibleColumns) and instantly reverts the user's hide.
|
|
2230
|
+
const gpuAutoShownKinds = useRef<Set<string>>(new Set())
|
|
2098
2231
|
useEffect(() => {
|
|
2232
|
+
// Re-arm the skip-initial-save guard per kind. This effect repopulates
|
|
2233
|
+
// visible/widths/custom for the new kind via async setState, so the save
|
|
2234
|
+
// effect (also keyed to selectedKind) that runs in this same commit still
|
|
2235
|
+
// sees the PREVIOUS kind's columns in state and would persist them under
|
|
2236
|
+
// the new kind's storage key. Skipping the first save after each load
|
|
2237
|
+
// prevents that cross-kind write.
|
|
2238
|
+
isColumnSettingsLoaded.current = false
|
|
2099
2239
|
const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
|
|
2240
|
+
hadSavedColumnSettings.current = !!saved
|
|
2241
|
+
// Reconstruct the effective column list locally rather than reading
|
|
2242
|
+
// `allColumns` — keying this effect to the kind (not allColumns) keeps
|
|
2243
|
+
// runtime custom-column add/remove from re-running it and clobbering the
|
|
2244
|
+
// in-session edit (and avoids a setCustomColumns → allColumns → re-run loop).
|
|
2245
|
+
// sanitize guards the localStorage boundary: a corrupted/hand-edited blob
|
|
2246
|
+
// (non-array, blank path, bad source) must not crash the later .map or add dead columns.
|
|
2247
|
+
const savedCustom = sanitizeCustomColumnDefs(saved?.custom)
|
|
2248
|
+
setCustomColumns(savedCustom)
|
|
2249
|
+
const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
|
|
2250
|
+
const builtinKeys = new Set(kindColumns.map(c => c.key))
|
|
2251
|
+
const extras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
|
|
2252
|
+
const effective = [...extras, ...kindColumns, ...savedCustom.map(buildCustomColumn)]
|
|
2100
2253
|
// Host-injected extra columns (e.g. fleet Cluster) default to visible
|
|
2101
2254
|
// even when the saved column-visibility blob predates them — a naive
|
|
2102
2255
|
// Set(saved.visible) would silently hide host columns the user has
|
|
2103
2256
|
// never been shown. Trade-off: a user can't permanently hide a host
|
|
2104
2257
|
// extra column via the column picker — next mount re-adds it. Track
|
|
2105
2258
|
// a sibling "hidden-extras" set if this becomes a real complaint.
|
|
2106
|
-
const extraKeys =
|
|
2259
|
+
const extraKeys = extras.map(c => c.key)
|
|
2107
2260
|
if (saved) {
|
|
2108
2261
|
// If saved columns are just the defaults but this kind has specialized columns,
|
|
2109
|
-
// discard the stale save and use the specialized columns instead
|
|
2262
|
+
// discard the stale save and use the specialized columns instead. User-added
|
|
2263
|
+
// custom columns mean the blob isn't a stale pure-defaults save — keep it, or
|
|
2264
|
+
// the migration would clear them from storage (and un-hide hidden ones).
|
|
2110
2265
|
const defaultKeys = DEFAULT_COLUMNS.map(c => c.key)
|
|
2111
|
-
const isStaleDefaults =
|
|
2266
|
+
const isStaleDefaults = kindColumns !== DEFAULT_COLUMNS &&
|
|
2267
|
+
!savedCustom.length &&
|
|
2112
2268
|
saved.visible.length === defaultKeys.length &&
|
|
2113
2269
|
saved.visible.every(v => defaultKeys.includes(v))
|
|
2114
2270
|
if (isStaleDefaults) {
|
|
2115
2271
|
clearColumnSettings(selectedKind.name, selectedKind.group)
|
|
2116
|
-
|
|
2272
|
+
hadSavedColumnSettings.current = false
|
|
2273
|
+
setVisibleColumns(getDefaultVisibleColumns(effective))
|
|
2117
2274
|
setColumnWidths({})
|
|
2118
2275
|
} else {
|
|
2119
2276
|
const merged = new Set(saved.visible)
|
|
@@ -2122,13 +2279,12 @@ export function ResourcesView({
|
|
|
2122
2279
|
setColumnWidths(saved.widths || {})
|
|
2123
2280
|
}
|
|
2124
2281
|
} else {
|
|
2125
|
-
setVisibleColumns(getDefaultVisibleColumns(
|
|
2282
|
+
setVisibleColumns(getDefaultVisibleColumns(effective))
|
|
2126
2283
|
setColumnWidths({})
|
|
2127
2284
|
}
|
|
2128
|
-
}, [selectedKind.name, selectedKind.group,
|
|
2285
|
+
}, [selectedKind.name, selectedKind.group, extraLeadingColumns])
|
|
2129
2286
|
|
|
2130
|
-
// Save column settings when they change (skip initial load)
|
|
2131
|
-
const isColumnSettingsLoaded = useRef(false)
|
|
2287
|
+
// Save column settings when they change (skip the initial load of each kind)
|
|
2132
2288
|
useEffect(() => {
|
|
2133
2289
|
if (visibleColumns.size === 0) return // not loaded yet
|
|
2134
2290
|
if (!isColumnSettingsLoaded.current) {
|
|
@@ -2138,8 +2294,12 @@ export function ResourcesView({
|
|
|
2138
2294
|
saveColumnSettings(selectedKind.name, selectedKind.group, {
|
|
2139
2295
|
visible: Array.from(visibleColumns),
|
|
2140
2296
|
widths: columnWidths,
|
|
2297
|
+
custom: customColumns,
|
|
2141
2298
|
})
|
|
2142
|
-
|
|
2299
|
+
// A persisted blob blocks GPU auto-show from here on — same rule in-session
|
|
2300
|
+
// as across sessions, so a later data refresh can't override user choices.
|
|
2301
|
+
hadSavedColumnSettings.current = true
|
|
2302
|
+
}, [visibleColumns, columnWidths, customColumns, selectedKind.name, selectedKind.group])
|
|
2143
2303
|
|
|
2144
2304
|
// Close column picker on outside click or Escape
|
|
2145
2305
|
useEffect(() => {
|
|
@@ -2239,13 +2399,56 @@ export function ResourcesView({
|
|
|
2239
2399
|
})
|
|
2240
2400
|
}, [])
|
|
2241
2401
|
|
|
2242
|
-
// Reset column settings to defaults
|
|
2402
|
+
// Reset column settings to defaults (also drops user-added custom columns)
|
|
2243
2403
|
const resetColumnSettings = useCallback(() => {
|
|
2244
2404
|
clearColumnSettings(selectedKind.name, selectedKind.group)
|
|
2245
|
-
|
|
2405
|
+
setCustomColumns([])
|
|
2406
|
+
const customKeys = new Set(builtCustomColumns.map(c => c.key))
|
|
2407
|
+
setVisibleColumns(getDefaultVisibleColumns(allColumns.filter(c => !customKeys.has(c.key))))
|
|
2246
2408
|
setColumnWidths({})
|
|
2247
2409
|
isColumnSettingsLoaded.current = false
|
|
2248
|
-
|
|
2410
|
+
// Back to data-driven defaults: re-arm GPU auto-show for this kind.
|
|
2411
|
+
hadSavedColumnSettings.current = false
|
|
2412
|
+
gpuAutoShownKinds.current.delete(selectedKind.name.toLowerCase())
|
|
2413
|
+
}, [selectedKind.name, selectedKind.group, allColumns, builtCustomColumns])
|
|
2414
|
+
|
|
2415
|
+
// Add a user-defined label/annotation column (dedupe by key) and show it.
|
|
2416
|
+
const addCustomColumn = useCallback((def: CustomColumnDef) => {
|
|
2417
|
+
const path = def.path.trim()
|
|
2418
|
+
if (!path) return
|
|
2419
|
+
const key = customColumnKey({ ...def, path })
|
|
2420
|
+
setCustomColumns(prev => prev.some(c => customColumnKey(c) === key) ? prev : [...prev, { ...def, path }])
|
|
2421
|
+
setVisibleColumns(prev => {
|
|
2422
|
+
if (prev.has(key)) return prev
|
|
2423
|
+
const next = new Set(prev)
|
|
2424
|
+
next.add(key)
|
|
2425
|
+
return next
|
|
2426
|
+
})
|
|
2427
|
+
}, [])
|
|
2428
|
+
|
|
2429
|
+
// Remove a user-defined column entirely (not just hide it).
|
|
2430
|
+
const removeCustomColumn = useCallback((key: string) => {
|
|
2431
|
+
setCustomColumns(prev => prev.filter(c => customColumnKey(c) !== key))
|
|
2432
|
+
setVisibleColumns(prev => {
|
|
2433
|
+
if (!prev.has(key)) return prev
|
|
2434
|
+
const next = new Set(prev)
|
|
2435
|
+
next.delete(key)
|
|
2436
|
+
return next
|
|
2437
|
+
})
|
|
2438
|
+
// Drop any filter/sort that targeted the removed column — otherwise the
|
|
2439
|
+
// table keeps filtering (and syncing to the URL) on a key with no column
|
|
2440
|
+
// and no UI to clear it, and sort silently references a gone column.
|
|
2441
|
+
setColumnFilters(prev => {
|
|
2442
|
+
if (!(key in prev)) return prev
|
|
2443
|
+
const next = { ...prev }
|
|
2444
|
+
delete next[key]
|
|
2445
|
+
return next
|
|
2446
|
+
})
|
|
2447
|
+
if (sortColumn === key) {
|
|
2448
|
+
setSortColumn(null)
|
|
2449
|
+
setSortDirection(null)
|
|
2450
|
+
}
|
|
2451
|
+
}, [sortColumn])
|
|
2249
2452
|
|
|
2250
2453
|
// Keyboard shortcut: / to focus search
|
|
2251
2454
|
useRegisterShortcut({
|
|
@@ -2510,6 +2713,7 @@ export function ResourcesView({
|
|
|
2510
2713
|
if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
|
|
2511
2714
|
if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
|
|
2512
2715
|
if (compareMode) { exitCompareMode(); return }
|
|
2716
|
+
if (bulkMode) { exitBulkMode(); return }
|
|
2513
2717
|
if (highlightedIndex >= 0) setHighlightedIndex(-1)
|
|
2514
2718
|
else searchInputRef.current?.blur()
|
|
2515
2719
|
},
|
|
@@ -2934,6 +3138,46 @@ export function ResourcesView({
|
|
|
2934
3138
|
|
|
2935
3139
|
const selectedQuery = selectedKindQueryProp ?? resourceQueries[selectedQueryIndex]
|
|
2936
3140
|
const resources = selectedQuery?.data
|
|
3141
|
+
|
|
3142
|
+
// Auto-surface the GPU column the first time GPU-bearing rows load for a kind
|
|
3143
|
+
// the user has never customized — a static default can't know whether the
|
|
3144
|
+
// cluster has GPUs, and hiding the signal on GPU clusters buries the point.
|
|
3145
|
+
// The resulting save persists it; explicit user hides are never overridden
|
|
3146
|
+
// (hadSavedColumnSettings guard).
|
|
3147
|
+
useEffect(() => {
|
|
3148
|
+
if (hadSavedColumnSettings.current) return
|
|
3149
|
+
const kindLower = selectedKind.name.toLowerCase()
|
|
3150
|
+
if (kindLower !== 'pods' && kindLower !== 'nodes') return
|
|
3151
|
+
if (gpuAutoShownKinds.current.has(kindLower)) return
|
|
3152
|
+
if (visibleColumns.size === 0 || visibleColumns.has('gpu')) return
|
|
3153
|
+
const rows = (resources as any[] | undefined) ?? []
|
|
3154
|
+
if (rows.length === 0) return
|
|
3155
|
+
const hasGpu = kindLower === 'pods'
|
|
3156
|
+
? rows.some(r => getPodGpuCount(r) > 0)
|
|
3157
|
+
: rows.some(r => getNodeGpuCount(r) > 0)
|
|
3158
|
+
if (hasGpu) {
|
|
3159
|
+
gpuAutoShownKinds.current.add(kindLower)
|
|
3160
|
+
setVisibleColumns(prev => new Set([...prev, 'gpu']))
|
|
3161
|
+
}
|
|
3162
|
+
}, [resources, selectedKind.name, visibleColumns])
|
|
3163
|
+
|
|
3164
|
+
// Label/annotation keys present on the loaded rows — powers the
|
|
3165
|
+
// add-custom-column autocomplete so users don't type keys blind. Sampled
|
|
3166
|
+
// to bound cost on large lists; keys converge fast across rows of one kind.
|
|
3167
|
+
const customColumnKeySuggestions = useMemo(() => {
|
|
3168
|
+
const labels = new Set<string>()
|
|
3169
|
+
const annotations = new Set<string>()
|
|
3170
|
+
const rows = (resources as any[] | undefined) ?? []
|
|
3171
|
+
for (let i = 0; i < rows.length && i < 500; i++) {
|
|
3172
|
+
const meta = rows[i]?.metadata
|
|
3173
|
+
if (meta?.labels) for (const k of Object.keys(meta.labels)) labels.add(k)
|
|
3174
|
+
if (meta?.annotations) for (const k of Object.keys(meta.annotations)) annotations.add(k)
|
|
3175
|
+
}
|
|
3176
|
+
return {
|
|
3177
|
+
label: Array.from(labels).sort(),
|
|
3178
|
+
annotation: Array.from(annotations).sort(),
|
|
3179
|
+
}
|
|
3180
|
+
}, [resources])
|
|
2937
3181
|
const isLoading = selectedQuery?.isLoading ?? true
|
|
2938
3182
|
const selectedQueryError = selectedQuery?.error
|
|
2939
3183
|
const isSelectedForbidden = isForbiddenError(selectedQueryError)
|
|
@@ -3111,6 +3355,11 @@ export function ResourcesView({
|
|
|
3111
3355
|
}
|
|
3112
3356
|
return 0
|
|
3113
3357
|
}
|
|
3358
|
+
case 'gpu': {
|
|
3359
|
+
if (kindLower === 'pods') return getPodGpuCount(resource)
|
|
3360
|
+
if (kindLower === 'nodes') return getNodeGpuCount(resource)
|
|
3361
|
+
return 0
|
|
3362
|
+
}
|
|
3114
3363
|
default:
|
|
3115
3364
|
return ''
|
|
3116
3365
|
}
|
|
@@ -3315,7 +3564,7 @@ export function ResourcesView({
|
|
|
3315
3564
|
}
|
|
3316
3565
|
|
|
3317
3566
|
return result
|
|
3318
|
-
}, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, podMatchesProblemFilter])
|
|
3567
|
+
}, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
|
|
3319
3568
|
|
|
3320
3569
|
// For nodes table: compute the majority minor version so outliers can be highlighted
|
|
3321
3570
|
const majorityNodeMinorVersion = useMemo(() => {
|
|
@@ -3378,6 +3627,39 @@ export function ResourcesView({
|
|
|
3378
3627
|
flatKindListRef.current = list
|
|
3379
3628
|
}, [pinned, categories])
|
|
3380
3629
|
|
|
3630
|
+
// Multi-select helpers
|
|
3631
|
+
const getResourceKey = useCallback((resource: any) => {
|
|
3632
|
+
return resource.metadata?.uid || `${resource.metadata?.namespace || ''}/${resource.metadata?.name || ''}`
|
|
3633
|
+
}, [])
|
|
3634
|
+
|
|
3635
|
+
const toggleChecked = useCallback((resource: any) => {
|
|
3636
|
+
const key = getResourceKey(resource)
|
|
3637
|
+
setCheckedResources(prev => {
|
|
3638
|
+
const next = new Set(prev)
|
|
3639
|
+
if (next.has(key)) next.delete(key)
|
|
3640
|
+
else next.add(key)
|
|
3641
|
+
return next
|
|
3642
|
+
})
|
|
3643
|
+
}, [getResourceKey])
|
|
3644
|
+
|
|
3645
|
+
// Selection state is keyed by resource, but everything user-facing
|
|
3646
|
+
// (count, select-all, the delete payload) derives from checkedItems —
|
|
3647
|
+
// the checked ∩ currently-visible intersection. Rows checked and then
|
|
3648
|
+
// hidden by a filter are excluded, so the bar never promises more
|
|
3649
|
+
// deletions than the confirm dialog will perform.
|
|
3650
|
+
const checkedItems = useMemo(() => {
|
|
3651
|
+
if (checkedResources.size === 0) return []
|
|
3652
|
+
return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
|
|
3653
|
+
}, [filteredResources, checkedResources, getResourceKey])
|
|
3654
|
+
|
|
3655
|
+
const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length
|
|
3656
|
+
|
|
3657
|
+
const toggleCheckAll = useCallback(() => {
|
|
3658
|
+
setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
|
|
3659
|
+
}, [allVisibleChecked, filteredResources, getResourceKey])
|
|
3660
|
+
|
|
3661
|
+
const isCheckboxMode = onBulkDelete != null && bulkMode
|
|
3662
|
+
|
|
3381
3663
|
// Filter columns by visibility
|
|
3382
3664
|
const columns = useMemo(() => {
|
|
3383
3665
|
if (visibleColumns.size === 0) return allColumns.filter(c => c.defaultVisible !== false)
|
|
@@ -3415,6 +3697,7 @@ export function ResourcesView({
|
|
|
3415
3697
|
— typically blowing this narrow column out to ~200px.
|
|
3416
3698
|
*/}
|
|
3417
3699
|
{compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
|
|
3700
|
+
{isCheckboxMode && <col style={{ width: '40px' }} />}
|
|
3418
3701
|
{columns.map(col => (
|
|
3419
3702
|
<col
|
|
3420
3703
|
key={col.key}
|
|
@@ -3432,7 +3715,7 @@ export function ResourcesView({
|
|
|
3432
3715
|
)
|
|
3433
3716
|
}),
|
|
3434
3717
|
TableRow: VirtuosoTableRow,
|
|
3435
|
-
}), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
|
|
3718
|
+
}), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth, isCheckboxMode])
|
|
3436
3719
|
|
|
3437
3720
|
// Calculate filter options with counts based on current resources (before filtering)
|
|
3438
3721
|
const filterOptions = useMemo(() => {
|
|
@@ -3899,8 +4182,8 @@ export function ResourcesView({
|
|
|
3899
4182
|
<Columns3 className="w-4 h-4" />
|
|
3900
4183
|
</button>
|
|
3901
4184
|
{showColumnPicker && (
|
|
3902
|
-
<div className="absolute right-0 top-full mt-1 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-lg py-1 min-w-[200px] max-h-[400px]
|
|
3903
|
-
<div className="px-3 py-2 border-b border-theme-border flex items-center justify-between">
|
|
4185
|
+
<div className="absolute right-0 top-full mt-1 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-lg py-1 min-w-[200px] max-h-[400px] flex flex-col">
|
|
4186
|
+
<div className="shrink-0 px-3 py-2 border-b border-theme-border flex items-center justify-between">
|
|
3904
4187
|
<span className="text-xs font-medium text-theme-text-secondary uppercase">Columns</span>
|
|
3905
4188
|
<button
|
|
3906
4189
|
onClick={resetColumnSettings}
|
|
@@ -3911,26 +4194,95 @@ export function ResourcesView({
|
|
|
3911
4194
|
Reset
|
|
3912
4195
|
</button>
|
|
3913
4196
|
</div>
|
|
3914
|
-
{
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
4197
|
+
{/* Column list scrolls; the add-column footer stays pinned so
|
|
4198
|
+
its input isn't pushed below the fold on kinds with many columns. */}
|
|
4199
|
+
<div className="overflow-y-auto flex-1 min-h-0">
|
|
4200
|
+
{allColumns.map(col => {
|
|
4201
|
+
const isCustom = customColumnKeySet.has(col.key)
|
|
4202
|
+
return (
|
|
4203
|
+
<div
|
|
4204
|
+
key={col.key}
|
|
4205
|
+
className="group flex items-center gap-2 px-3 py-1.5 hover:bg-theme-elevated"
|
|
4206
|
+
>
|
|
4207
|
+
<label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
|
|
4208
|
+
<input
|
|
4209
|
+
type="checkbox"
|
|
4210
|
+
checked={visibleColumns.has(col.key)}
|
|
4211
|
+
onChange={() => toggleColumnVisibility(col.key)}
|
|
4212
|
+
disabled={col.key === 'name'}
|
|
4213
|
+
className="rounded border-theme-border"
|
|
4214
|
+
/>
|
|
4215
|
+
<span className={clsx(
|
|
4216
|
+
'text-sm truncate',
|
|
4217
|
+
col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
|
|
4218
|
+
)} title={col.tooltip || col.label}>
|
|
4219
|
+
{col.label}
|
|
4220
|
+
</span>
|
|
4221
|
+
</label>
|
|
4222
|
+
{isCustom && (
|
|
4223
|
+
<button
|
|
4224
|
+
type="button"
|
|
4225
|
+
onClick={() => removeCustomColumn(col.key)}
|
|
4226
|
+
className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
4227
|
+
title="Remove column"
|
|
4228
|
+
>
|
|
4229
|
+
<X className="w-3.5 h-3.5" />
|
|
4230
|
+
</button>
|
|
4231
|
+
)}
|
|
4232
|
+
</div>
|
|
4233
|
+
)
|
|
4234
|
+
})}
|
|
4235
|
+
</div>
|
|
4236
|
+
{/* Add a label/annotation column */}
|
|
4237
|
+
<div className="shrink-0 border-t border-theme-border pt-2 px-3 pb-1">
|
|
4238
|
+
<div className="flex items-center gap-1 mb-1.5">
|
|
4239
|
+
{(['label', 'annotation'] as CustomColumnSource[]).map(src => (
|
|
4240
|
+
<button
|
|
4241
|
+
key={src}
|
|
4242
|
+
type="button"
|
|
4243
|
+
onClick={() => setCustomColumnDraft(d => ({ ...d, source: src }))}
|
|
4244
|
+
className={clsx(
|
|
4245
|
+
'px-2 py-0.5 text-xs rounded capitalize',
|
|
4246
|
+
customColumnDraft.source === src
|
|
4247
|
+
? 'bg-skyhook-500/20 text-skyhook-400'
|
|
4248
|
+
: 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated'
|
|
4249
|
+
)}
|
|
4250
|
+
>
|
|
4251
|
+
{src}
|
|
4252
|
+
</button>
|
|
4253
|
+
))}
|
|
4254
|
+
</div>
|
|
4255
|
+
<form
|
|
4256
|
+
className="flex items-center gap-1"
|
|
4257
|
+
onSubmit={e => {
|
|
4258
|
+
e.preventDefault()
|
|
4259
|
+
addCustomColumn(customColumnDraft)
|
|
4260
|
+
setCustomColumnDraft(d => ({ ...d, path: '' }))
|
|
4261
|
+
}}
|
|
3918
4262
|
>
|
|
3919
4263
|
<input
|
|
3920
|
-
type="
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
4264
|
+
type="text"
|
|
4265
|
+
list={customColKeysListId}
|
|
4266
|
+
value={customColumnDraft.path}
|
|
4267
|
+
onChange={e => setCustomColumnDraft(d => ({ ...d, path: e.target.value }))}
|
|
4268
|
+
placeholder={`Add ${customColumnDraft.source} key…`}
|
|
4269
|
+
className="flex-1 min-w-0 px-2 py-1 text-xs rounded bg-theme-base border border-theme-border text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
|
|
3925
4270
|
/>
|
|
3926
|
-
<
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
4271
|
+
<datalist id={customColKeysListId}>
|
|
4272
|
+
{customColumnKeySuggestions[customColumnDraft.source].map(k => (
|
|
4273
|
+
<option key={k} value={k} />
|
|
4274
|
+
))}
|
|
4275
|
+
</datalist>
|
|
4276
|
+
<button
|
|
4277
|
+
type="submit"
|
|
4278
|
+
disabled={!customColumnDraft.path.trim()}
|
|
4279
|
+
className="shrink-0 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-40 disabled:hover:bg-transparent"
|
|
4280
|
+
title="Add column"
|
|
4281
|
+
>
|
|
4282
|
+
<Plus className="w-4 h-4" />
|
|
4283
|
+
</button>
|
|
4284
|
+
</form>
|
|
4285
|
+
</div>
|
|
3934
4286
|
</div>
|
|
3935
4287
|
)}
|
|
3936
4288
|
</div>
|
|
@@ -3961,7 +4313,11 @@ export function ResourcesView({
|
|
|
3961
4313
|
{compareEnabled && (
|
|
3962
4314
|
<Tooltip content={compareMode ? 'Exit compare mode' : 'Compare two resources side-by-side'}>
|
|
3963
4315
|
<button
|
|
3964
|
-
onClick={() =>
|
|
4316
|
+
onClick={() => {
|
|
4317
|
+
if (compareMode) { exitCompareMode(); return }
|
|
4318
|
+
exitBulkMode()
|
|
4319
|
+
setCompareMode(true)
|
|
4320
|
+
}}
|
|
3965
4321
|
aria-pressed={compareMode}
|
|
3966
4322
|
className={clsx(
|
|
3967
4323
|
'p-2 rounded-lg transition-colors',
|
|
@@ -3974,8 +4330,51 @@ export function ResourcesView({
|
|
|
3974
4330
|
</button>
|
|
3975
4331
|
</Tooltip>
|
|
3976
4332
|
)}
|
|
4333
|
+
{onBulkDelete && (
|
|
4334
|
+
<Tooltip content={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
|
|
4335
|
+
<button
|
|
4336
|
+
onClick={() => {
|
|
4337
|
+
if (bulkMode) { exitBulkMode(); return }
|
|
4338
|
+
exitCompareMode()
|
|
4339
|
+
setBulkMode(true)
|
|
4340
|
+
}}
|
|
4341
|
+
aria-pressed={bulkMode}
|
|
4342
|
+
className={clsx(
|
|
4343
|
+
'p-2 rounded-lg transition-colors',
|
|
4344
|
+
bulkMode
|
|
4345
|
+
? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
|
|
4346
|
+
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
|
|
4347
|
+
)}
|
|
4348
|
+
>
|
|
4349
|
+
<ListChecks className="w-4 h-4" />
|
|
4350
|
+
</button>
|
|
4351
|
+
</Tooltip>
|
|
4352
|
+
)}
|
|
3977
4353
|
</div>
|
|
3978
4354
|
|
|
4355
|
+
{/* Bulk actions bar */}
|
|
4356
|
+
{isCheckboxMode && (
|
|
4357
|
+
<div className="flex items-center gap-3 px-4 py-2 bg-skyhook-500/10 border-b border-skyhook-400/20 shrink-0">
|
|
4358
|
+
<span className="text-sm font-medium text-theme-text-primary">
|
|
4359
|
+
{checkedItems.length} selected
|
|
4360
|
+
</span>
|
|
4361
|
+
<button
|
|
4362
|
+
onClick={() => setShowBulkDeleteConfirm(true)}
|
|
4363
|
+
disabled={checkedItems.length === 0}
|
|
4364
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
|
|
4365
|
+
>
|
|
4366
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
4367
|
+
Delete
|
|
4368
|
+
</button>
|
|
4369
|
+
<button
|
|
4370
|
+
onClick={exitBulkMode}
|
|
4371
|
+
className="px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
4372
|
+
>
|
|
4373
|
+
Cancel
|
|
4374
|
+
</button>
|
|
4375
|
+
</div>
|
|
4376
|
+
)}
|
|
4377
|
+
|
|
3979
4378
|
{/* Table */}
|
|
3980
4379
|
<div
|
|
3981
4380
|
className="flex-1 overflow-auto relative"
|
|
@@ -4081,8 +4480,22 @@ export function ResourcesView({
|
|
|
4081
4480
|
<GitCompare className="w-3.5 h-3.5 inline-block opacity-70" />
|
|
4082
4481
|
</th>
|
|
4083
4482
|
)}
|
|
4483
|
+
{isCheckboxMode && (
|
|
4484
|
+
<th className="bg-theme-surface border-b border-theme-border w-10 text-center px-0 py-3">
|
|
4485
|
+
<input
|
|
4486
|
+
type="checkbox"
|
|
4487
|
+
checked={allVisibleChecked}
|
|
4488
|
+
ref={(el) => { if (el) el.indeterminate = checkedItems.length > 0 && checkedItems.length < filteredResources.length }}
|
|
4489
|
+
onChange={toggleCheckAll}
|
|
4490
|
+
className="w-3.5 h-3.5 rounded border-theme-border accent-skyhook-500 cursor-pointer"
|
|
4491
|
+
title={checkedItems.length > 0 ? 'Deselect all' : 'Select all'}
|
|
4492
|
+
/>
|
|
4493
|
+
</th>
|
|
4494
|
+
)}
|
|
4084
4495
|
{columns.map((col, colIdx) => {
|
|
4496
|
+
// Built-in sortable keys, plus any extra/custom column that carries its own getSortValue.
|
|
4085
4497
|
const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory', 'containers'].includes(col.key)
|
|
4498
|
+
|| !!extraColumnsByKey.get(col.key)?.getSortValue
|
|
4086
4499
|
const isSorted = sortColumn === col.key
|
|
4087
4500
|
const isLastCol = colIdx === columns.length - 1
|
|
4088
4501
|
const filterCol = filterableColumnMap.get(col.key)
|
|
@@ -4259,6 +4672,7 @@ export function ResourcesView({
|
|
|
4259
4672
|
clusterId: resolveRowCluster?.(resource)?.id,
|
|
4260
4673
|
})
|
|
4261
4674
|
: -1
|
|
4675
|
+
const resourceKey = getResourceKey(resource)
|
|
4262
4676
|
return (
|
|
4263
4677
|
<ResourceRowCells
|
|
4264
4678
|
resource={resource}
|
|
@@ -4269,12 +4683,15 @@ export function ResourcesView({
|
|
|
4269
4683
|
hasSpacerColumn={hasResizedColumns}
|
|
4270
4684
|
isSelected={isSelected}
|
|
4271
4685
|
isHighlighted={isHighlighted}
|
|
4686
|
+
isChecked={checkedResources.has(resourceKey)}
|
|
4687
|
+
showCheckbox={isCheckboxMode}
|
|
4272
4688
|
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
4273
|
-
onClick={() => compareMode ? toggleComparePick(resource) : selectResource(resource, isSelected)}
|
|
4689
|
+
onClick={() => compareMode ? toggleComparePick(resource) : isCheckboxMode ? toggleChecked(resource) : selectResource(resource, isSelected)}
|
|
4274
4690
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
4275
4691
|
compareMode={compareMode}
|
|
4276
4692
|
comparePickIndex={pickIdx}
|
|
4277
4693
|
rowHref={rowHrefFor?.(resource)}
|
|
4694
|
+
onCheckToggle={() => toggleChecked(resource)}
|
|
4278
4695
|
/>
|
|
4279
4696
|
)
|
|
4280
4697
|
}}
|
|
@@ -4301,6 +4718,45 @@ export function ResourcesView({
|
|
|
4301
4718
|
)}
|
|
4302
4719
|
</div>
|
|
4303
4720
|
</div>
|
|
4721
|
+
|
|
4722
|
+
{/* Bulk delete confirmation */}
|
|
4723
|
+
<ConfirmDialog
|
|
4724
|
+
open={showBulkDeleteConfirm}
|
|
4725
|
+
onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
|
|
4726
|
+
onConfirm={() => {
|
|
4727
|
+
const items = checkedItems.map(r => ({
|
|
4728
|
+
kind: selectedKind.name,
|
|
4729
|
+
group: selectedKind.group,
|
|
4730
|
+
namespace: r.metadata?.namespace || '',
|
|
4731
|
+
name: r.metadata?.name || '',
|
|
4732
|
+
}))
|
|
4733
|
+
onBulkDelete?.(items, {
|
|
4734
|
+
force: bulkForceDelete,
|
|
4735
|
+
onSuccess: () => {
|
|
4736
|
+
exitBulkMode()
|
|
4737
|
+
setShowBulkDeleteConfirm(false)
|
|
4738
|
+
setBulkForceDelete(false)
|
|
4739
|
+
},
|
|
4740
|
+
})
|
|
4741
|
+
}}
|
|
4742
|
+
title={`Delete ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
|
|
4743
|
+
message={`You are about to delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}. This action cannot be undone.`}
|
|
4744
|
+
details={checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')}
|
|
4745
|
+
confirmLabel={bulkForceDelete ? `Force Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}` : `Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}`}
|
|
4746
|
+
variant="danger"
|
|
4747
|
+
isLoading={isBulkDeleting}
|
|
4748
|
+
isClosable
|
|
4749
|
+
>
|
|
4750
|
+
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
4751
|
+
<input
|
|
4752
|
+
type="checkbox"
|
|
4753
|
+
checked={bulkForceDelete}
|
|
4754
|
+
onChange={(e) => setBulkForceDelete(e.target.checked)}
|
|
4755
|
+
className="w-4 h-4 rounded border-theme-border bg-theme-base text-red-600 focus:ring-red-500 focus:ring-offset-0"
|
|
4756
|
+
/>
|
|
4757
|
+
<span>Force delete (strips finalizers and bypasses grace period)</span>
|
|
4758
|
+
</label>
|
|
4759
|
+
</ConfirmDialog>
|
|
4304
4760
|
</ResourcesViewDataContext.Provider>
|
|
4305
4761
|
)
|
|
4306
4762
|
}
|
|
@@ -4314,6 +4770,8 @@ interface ResourceRowCellsProps {
|
|
|
4314
4770
|
hasSpacerColumn: boolean
|
|
4315
4771
|
isSelected?: boolean
|
|
4316
4772
|
isHighlighted?: boolean
|
|
4773
|
+
isChecked?: boolean
|
|
4774
|
+
showCheckbox?: boolean
|
|
4317
4775
|
majorityNodeMinorVersion?: string
|
|
4318
4776
|
onClick?: () => void
|
|
4319
4777
|
onMouseEnter?: () => void
|
|
@@ -4324,6 +4782,7 @@ interface ResourceRowCellsProps {
|
|
|
4324
4782
|
* data cells drop their click handlers. The compare-mode chip column
|
|
4325
4783
|
* is unaffected (still toggles picks). */
|
|
4326
4784
|
rowHref?: string
|
|
4785
|
+
onCheckToggle?: () => void
|
|
4327
4786
|
}
|
|
4328
4787
|
|
|
4329
4788
|
function rowHighlightClass(
|
|
@@ -4331,6 +4790,7 @@ function rowHighlightClass(
|
|
|
4331
4790
|
comparePickIndex: number,
|
|
4332
4791
|
isSelected: boolean | undefined,
|
|
4333
4792
|
isHighlighted: boolean | undefined,
|
|
4793
|
+
isChecked?: boolean,
|
|
4334
4794
|
): string {
|
|
4335
4795
|
if (compareMode) {
|
|
4336
4796
|
if (comparePickIndex === 0) return `${SIDE_TONES.a.rowBg} ${SIDE_TONES.a.rowBgHover}`
|
|
@@ -4339,12 +4799,13 @@ function rowHighlightClass(
|
|
|
4339
4799
|
return 'group-hover/row:bg-theme-surface/50'
|
|
4340
4800
|
}
|
|
4341
4801
|
if (isSelected) return 'selection-strong group-hover/row:bg-skyhook-500/30'
|
|
4802
|
+
if (isChecked) return 'selection group-hover/row:bg-skyhook-500/20'
|
|
4342
4803
|
if (isHighlighted) return 'selection selection-ring'
|
|
4343
4804
|
return 'group-hover/row:bg-theme-surface/50'
|
|
4344
4805
|
}
|
|
4345
4806
|
|
|
4346
|
-
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref }: ResourceRowCellsProps) {
|
|
4347
|
-
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
|
|
4807
|
+
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref, onCheckToggle }: ResourceRowCellsProps) {
|
|
4808
|
+
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted, isChecked)
|
|
4348
4809
|
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
4349
4810
|
// When the host supplies an anchor, drop per-cell onClick for the data
|
|
4350
4811
|
// columns: the anchor is the only navigation surface. The compare-mode
|
|
@@ -4377,6 +4838,19 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
4377
4838
|
)}
|
|
4378
4839
|
</td>
|
|
4379
4840
|
)}
|
|
4841
|
+
{showCheckbox && (
|
|
4842
|
+
<td
|
|
4843
|
+
className={clsx('border-b-subtle text-center px-0 py-3 w-10 cursor-pointer transition-colors', rowHighlight)}
|
|
4844
|
+
onClick={(e) => { e.stopPropagation(); onCheckToggle?.() }}
|
|
4845
|
+
>
|
|
4846
|
+
<input
|
|
4847
|
+
type="checkbox"
|
|
4848
|
+
checked={!!isChecked}
|
|
4849
|
+
onChange={() => {}}
|
|
4850
|
+
className="w-3.5 h-3.5 rounded border-theme-border accent-skyhook-500 cursor-pointer"
|
|
4851
|
+
/>
|
|
4852
|
+
</td>
|
|
4853
|
+
)}
|
|
4380
4854
|
{columns.map((col) => (
|
|
4381
4855
|
<td
|
|
4382
4856
|
key={col.key}
|
|
@@ -4636,6 +5110,20 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
4636
5110
|
return <TriggerAuthenticationCell resource={resource} column={column} />
|
|
4637
5111
|
case 'clustertriggerauthentications':
|
|
4638
5112
|
return <ClusterTriggerAuthenticationCell resource={resource} column={column} />
|
|
5113
|
+
// DRA (resource.k8s.io)
|
|
5114
|
+
case 'resourceclaims':
|
|
5115
|
+
return <ResourceClaimCell resource={resource} column={column} />
|
|
5116
|
+
case 'resourceclaimtemplates':
|
|
5117
|
+
return <ResourceClaimTemplateCell resource={resource} column={column} />
|
|
5118
|
+
case 'deviceclasses':
|
|
5119
|
+
return <DeviceClassCell resource={resource} column={column} />
|
|
5120
|
+
case 'resourceslices':
|
|
5121
|
+
return <ResourceSliceCell resource={resource} column={column} />
|
|
5122
|
+
// NVIDIA GPU Operator (nvidiaclusterpolicies is the group-qualified key for nvidia.com clusterpolicies)
|
|
5123
|
+
case 'nvidiaclusterpolicies':
|
|
5124
|
+
return <NvidiaClusterPolicyCell resource={resource} column={column} />
|
|
5125
|
+
case 'nvidiadrivers':
|
|
5126
|
+
return <NvidiaDriverCell resource={resource} column={column} />
|
|
4639
5127
|
// ArgoCD GitOps resources
|
|
4640
5128
|
case 'applications':
|
|
4641
5129
|
return <ArgoApplicationCell resource={resource} column={column} />
|
|
@@ -5117,6 +5605,11 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
|
|
|
5117
5605
|
const tip = buildResourceTooltip('Memory', m.memory, m.memoryRequest, m.memoryLimit, formatMemoryShort)
|
|
5118
5606
|
return <ResourceBar used={formatMemoryShort(m.memory)} total={formatMemoryShort(denom)} percent={pct} colorScheme={getBulletBarScheme(pct, marker)} markerPercent={marker} tooltip={tip} />
|
|
5119
5607
|
}
|
|
5608
|
+
case 'gpu': {
|
|
5609
|
+
const count = getPodGpuCount(resource)
|
|
5610
|
+
if (!count) return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
5611
|
+
return <span className="text-sm text-theme-text-secondary font-mono">{count}</span>
|
|
5612
|
+
}
|
|
5120
5613
|
default:
|
|
5121
5614
|
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
5122
5615
|
}
|
|
@@ -5759,6 +6252,11 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
|
|
|
5759
6252
|
const pct = (podCount / max) * 100
|
|
5760
6253
|
return <ResourceBar used={String(podCount)} total={String(max)} percent={pct} colorScheme="count" />
|
|
5761
6254
|
}
|
|
6255
|
+
case 'gpu': {
|
|
6256
|
+
const count = getNodeGpuCount(resource)
|
|
6257
|
+
if (!count) return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
6258
|
+
return <span className="text-sm text-theme-text-secondary font-mono">{count}</span>
|
|
6259
|
+
}
|
|
5762
6260
|
default:
|
|
5763
6261
|
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
5764
6262
|
}
|