@skyhook-io/k8s-ui 1.6.0 → 1.6.2
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 +7 -2
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +403 -69
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +31 -6
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
Copy,
|
|
23
23
|
Check,
|
|
24
24
|
Plus,
|
|
25
|
+
GitCompare,
|
|
25
26
|
} from 'lucide-react'
|
|
26
27
|
import { clsx } from 'clsx'
|
|
27
28
|
import { ResourceBar } from '../ui/ResourceBar'
|
|
@@ -137,6 +138,8 @@ import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPo
|
|
|
137
138
|
import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
|
|
138
139
|
import { BackupCell, RestoreCell, ScheduleCell, BackupStorageLocationCell } from './renderers/velero-cells'
|
|
139
140
|
import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCell } from './renderers/cnpg-cells'
|
|
141
|
+
import { ManagedResourceCell, CompositeResourceCell, CrossplaneProviderCell, CrossplaneProviderConfigCell, CompositionCell, XRDCell } from './renderers/crossplane-cells'
|
|
142
|
+
import { isManagedResource, isComposite } from './resource-utils-crossplane'
|
|
140
143
|
import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntryCell, PeerAuthenticationCell, AuthorizationPolicyCell } from './renderers/istio-cells'
|
|
141
144
|
import { KnativeServiceCell, ConfigurationCell as KnativeConfigurationCell, RevisionCell as KnativeRevisionCell, RouteCell as KnativeRouteCell, BrokerCell, TriggerCell, EventTypeCell, PingSourceCell, ApiServerSourceCell, ContainerSourceCell, SinkBindingCell, ChannelCell, InMemoryChannelCell, SubscriptionCell, SequenceCell, ParallelCell, DomainMappingCell, ServerlessServiceCell, KnativeIngressCell, KnativeCertificateCell } from './renderers/knative-cells'
|
|
142
145
|
import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
|
|
@@ -148,6 +151,7 @@ import { AzureManagedControlPlaneCell, AzureManagedMachinePoolCell, AzureMachine
|
|
|
148
151
|
import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
149
152
|
import { ResourcesSidebar } from './ResourcesSidebar'
|
|
150
153
|
import type { SelectedKindInfo } from './ResourcesSidebar'
|
|
154
|
+
import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
|
|
151
155
|
|
|
152
156
|
// Pod problem filter options (special multi-select, not a single column value)
|
|
153
157
|
const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
|
|
@@ -155,7 +159,7 @@ const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'
|
|
|
155
159
|
const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
|
|
156
160
|
|
|
157
161
|
// Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
|
|
158
|
-
const SKIP_FILTER_COLUMNS = new Set([
|
|
162
|
+
export const SKIP_FILTER_COLUMNS = new Set([
|
|
159
163
|
'name', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
|
|
160
164
|
'ports', 'message', 'url', 'ref', 'revision', 'path', 'selector', 'ready', 'restarts',
|
|
161
165
|
'completions', 'duration', 'schedule', 'lastRun', 'target', 'replicas', 'metrics',
|
|
@@ -163,12 +167,22 @@ const SKIP_FILTER_COLUMNS = new Set([
|
|
|
163
167
|
'issuer', 'domain', 'presented', 'listeners', 'routes', 'addresses', 'hostnames',
|
|
164
168
|
'parents', 'backends', 'controller', 'description', 'externalIP', 'address',
|
|
165
169
|
'conditions', 'taints', 'desired', 'upToDate', 'available', 'owner',
|
|
166
|
-
'tls', 'endpoints', 'object', 'count', 'lastSeen', '
|
|
170
|
+
'tls', 'endpoints', 'object', 'count', 'lastSeen', 'source', 'inventory',
|
|
167
171
|
'lastUpdated', 'chart', 'events', 'repo',
|
|
168
172
|
'generators', 'applications', 'destinations', 'sources', 'budget', 'healthy', 'allowed',
|
|
169
173
|
'secrets', 'subjects', 'role', 'entrypoint', 'templates',
|
|
170
174
|
])
|
|
171
175
|
|
|
176
|
+
// Namespace/node cardinality scales with cluster size, not kind enum size;
|
|
177
|
+
// node keeps a generous cap as a sanity bound, namespace is uncapped.
|
|
178
|
+
export function isColumnFilterableByDistinctCount(colKey: string, distinctCount: number): boolean {
|
|
179
|
+
if (SKIP_FILTER_COLUMNS.has(colKey)) return false
|
|
180
|
+
const maxDistinct = colKey === 'namespace'
|
|
181
|
+
? Number.POSITIVE_INFINITY
|
|
182
|
+
: colKey === 'node' ? 200 : 30
|
|
183
|
+
return distinctCount >= 2 && distinctCount <= maxDistinct
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
// Column definitions per resource kind
|
|
173
187
|
interface Column {
|
|
174
188
|
key: string
|
|
@@ -233,11 +247,11 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
233
247
|
pods: [
|
|
234
248
|
{ key: 'name', label: 'Name' },
|
|
235
249
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
236
|
-
{ key: 'containers', label: 'Containers', width: 'w-
|
|
250
|
+
{ key: 'containers', label: 'Containers', width: 'w-32' },
|
|
237
251
|
{ key: 'status', label: 'Status', width: 'w-40' },
|
|
238
252
|
{ key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
|
|
239
253
|
{ key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
|
|
240
|
-
{ key: 'restarts', label: 'Restarts', width: 'w-
|
|
254
|
+
{ key: 'restarts', label: 'Restarts', width: 'w-28' },
|
|
241
255
|
{ key: 'podIP', label: 'Pod IP', width: 'w-32', defaultVisible: false },
|
|
242
256
|
{ key: 'node', label: 'Node', width: 'w-44', hideOnMobile: true },
|
|
243
257
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -246,8 +260,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
246
260
|
{ key: 'name', label: 'Name' },
|
|
247
261
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
248
262
|
{ key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
|
|
249
|
-
{ key: 'upToDate', label: 'Up-to-date', width: 'w-
|
|
250
|
-
{ key: 'available', label: 'Available', width: 'w-
|
|
263
|
+
{ key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
|
|
264
|
+
{ key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds)' },
|
|
251
265
|
{ key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
|
|
252
266
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
253
267
|
],
|
|
@@ -256,8 +270,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
256
270
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
257
271
|
{ key: 'desired', label: 'Desired', width: 'w-20', tooltip: 'Number of nodes that should run the daemon pod (based on node selector)' },
|
|
258
272
|
{ key: 'ready', label: 'Ready', width: 'w-20', tooltip: 'Number of pods that are ready (passing readiness probes)' },
|
|
259
|
-
{ key: 'upToDate', label: 'Up-to-date', width: 'w-
|
|
260
|
-
{ key: 'available', label: 'Available', width: 'w-
|
|
273
|
+
{ key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template spec' },
|
|
274
|
+
{ key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds duration)' },
|
|
261
275
|
{ key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
|
|
262
276
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
263
277
|
],
|
|
@@ -265,7 +279,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
265
279
|
{ key: 'name', label: 'Name' },
|
|
266
280
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
267
281
|
{ key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
|
|
268
|
-
{ key: 'upToDate', label: 'Up-to-date', width: 'w-
|
|
282
|
+
{ key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
|
|
269
283
|
{ key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
|
|
270
284
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
271
285
|
],
|
|
@@ -328,7 +342,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
328
342
|
{ key: 'name', label: 'Name' },
|
|
329
343
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
330
344
|
{ key: 'status', label: 'Status', width: 'w-28' },
|
|
331
|
-
{ key: 'completions', label: 'Completions', width: 'w-
|
|
345
|
+
{ key: 'completions', label: 'Completions', width: 'w-32' },
|
|
332
346
|
{ key: 'duration', label: 'Duration', width: 'w-24', hideOnMobile: true },
|
|
333
347
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
334
348
|
],
|
|
@@ -363,7 +377,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
363
377
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
364
378
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
365
379
|
{ key: 'capacity', label: 'Capacity', width: 'w-24' },
|
|
366
|
-
{ key: 'storageClass', label: 'Storage Class', width: 'w-
|
|
380
|
+
{ key: 'storageClass', label: 'Storage Class', width: 'w-40', hideOnMobile: true },
|
|
367
381
|
{ key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'Access modes: RWO=ReadWriteOnce, RWX=ReadWriteMany, ROX=ReadOnlyMany' },
|
|
368
382
|
{ key: 'volume', label: 'Volume', width: 'w-48', hideOnMobile: true },
|
|
369
383
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -402,7 +416,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
402
416
|
{ key: 'capacity', label: 'Capacity', width: 'w-24' },
|
|
403
417
|
{ key: 'accessModes', label: 'Access', width: 'w-20', tooltip: 'RWO=ReadWriteOnce, ROX=ReadOnlyMany, RWX=ReadWriteMany' },
|
|
404
418
|
{ key: 'reclaimPolicy', label: 'Reclaim', width: 'w-20' },
|
|
405
|
-
{ key: 'storageClass', label: 'Storage Class', width: 'w-
|
|
419
|
+
{ key: 'storageClass', label: 'Storage Class', width: 'w-40', hideOnMobile: true },
|
|
406
420
|
{ key: 'claim', label: 'Claim', width: 'w-48', hideOnMobile: true },
|
|
407
421
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
408
422
|
],
|
|
@@ -667,8 +681,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
667
681
|
serviceaccounts: [
|
|
668
682
|
{ key: 'name', label: 'Name' },
|
|
669
683
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
670
|
-
{ key: 'automount', label: 'Automount', width: 'w-
|
|
671
|
-
{ key: 'secrets', label: 'Secrets', width: 'w-
|
|
684
|
+
{ key: 'automount', label: 'Automount', width: 'w-32', tooltip: 'Whether token is automatically mounted in pods' },
|
|
685
|
+
{ key: 'secrets', label: 'Secrets', width: 'w-24' },
|
|
672
686
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
673
687
|
],
|
|
674
688
|
roles: [
|
|
@@ -705,13 +719,13 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
705
719
|
{ key: 'name', label: 'Name' },
|
|
706
720
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
707
721
|
{ key: 'holder', label: 'Holder', width: 'w-48' },
|
|
708
|
-
{ key: 'renewTime', label: 'Last Renewed', width: 'w-
|
|
722
|
+
{ key: 'renewTime', label: 'Last Renewed', width: 'w-32' },
|
|
709
723
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
710
724
|
],
|
|
711
725
|
priorityclasses: [
|
|
712
726
|
{ key: 'name', label: 'Name' },
|
|
713
727
|
{ key: 'value', label: 'Value', width: 'w-24' },
|
|
714
|
-
{ key: 'globalDefault', label: 'Global Default', width: 'w-
|
|
728
|
+
{ key: 'globalDefault', label: 'Global Default', width: 'w-36' },
|
|
715
729
|
{ key: 'preemptionPolicy', label: 'Preemption', width: 'w-32' },
|
|
716
730
|
{ key: 'description', label: 'Description', width: 'w-64', hideOnMobile: true },
|
|
717
731
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -723,27 +737,27 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
723
737
|
],
|
|
724
738
|
mutatingwebhookconfigurations: [
|
|
725
739
|
{ key: 'name', label: 'Name' },
|
|
726
|
-
{ key: 'webhooks', label: 'Webhooks', width: 'w-
|
|
727
|
-
{ key: 'failurePolicy', label: 'Failure Policy', width: 'w-
|
|
740
|
+
{ key: 'webhooks', label: 'Webhooks', width: 'w-28' },
|
|
741
|
+
{ key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
|
|
728
742
|
{ key: 'target', label: 'Target', width: 'w-48', hideOnMobile: true },
|
|
729
743
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
730
744
|
],
|
|
731
745
|
validatingwebhookconfigurations: [
|
|
732
746
|
{ key: 'name', label: 'Name' },
|
|
733
|
-
{ key: 'webhooks', label: 'Webhooks', width: 'w-
|
|
734
|
-
{ key: 'failurePolicy', label: 'Failure Policy', width: 'w-
|
|
747
|
+
{ key: 'webhooks', label: 'Webhooks', width: 'w-28' },
|
|
748
|
+
{ key: 'failurePolicy', label: 'Failure Policy', width: 'w-36' },
|
|
735
749
|
{ key: 'target', label: 'Target', width: 'w-48', hideOnMobile: true },
|
|
736
750
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
737
751
|
],
|
|
738
752
|
events: [
|
|
739
753
|
{ key: 'name', label: 'Name' },
|
|
740
754
|
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
741
|
-
{ key: 'type', label: 'Type', width: 'w-
|
|
742
|
-
{ key: 'reason', label: 'Reason', width: 'w-
|
|
755
|
+
{ key: 'type', label: 'Type', width: 'w-24' },
|
|
756
|
+
{ key: 'reason', label: 'Reason', width: 'w-32' },
|
|
743
757
|
{ key: 'message', label: 'Message', width: 'w-64' },
|
|
744
758
|
{ key: 'object', label: 'Object', width: 'w-48', hideOnMobile: true },
|
|
745
|
-
{ key: 'count', label: 'Count', width: 'w-
|
|
746
|
-
{ key: 'lastSeen', label: 'Last Seen', width: 'w-
|
|
759
|
+
{ key: 'count', label: 'Count', width: 'w-20' },
|
|
760
|
+
{ key: 'lastSeen', label: 'Last Seen', width: 'w-28' },
|
|
747
761
|
],
|
|
748
762
|
// ============================================================================
|
|
749
763
|
// FLUXCD GITOPS RESOURCES
|
|
@@ -791,6 +805,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
791
805
|
{ key: 'chart', label: 'Chart', width: 'w-40' },
|
|
792
806
|
{ key: 'version', label: 'Version', width: 'w-24' },
|
|
793
807
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
808
|
+
{ key: 'message', label: 'Message', width: 'w-64', hideOnMobile: true, tooltip: 'Last diagnostic message — distinguishes dependency-wait, install/upgrade failure, and test failure' },
|
|
794
809
|
{ key: 'revision', label: 'Rev', width: 'w-16', hideOnMobile: true, tooltip: 'Helm release revision number' },
|
|
795
810
|
{ key: 'lastUpdated', label: 'Last Updated', width: 'w-28', tooltip: 'Time since last successful reconciliation' },
|
|
796
811
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -932,7 +947,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
932
947
|
{ key: 'store', label: 'Store', width: 'w-36' },
|
|
933
948
|
{ key: 'provider', label: 'Provider', width: 'w-28' },
|
|
934
949
|
{ key: 'refreshInterval', label: 'Refresh', width: 'w-24' },
|
|
935
|
-
{ key: 'lastSync', label: 'Last Sync', width: 'w-
|
|
950
|
+
{ key: 'lastSync', label: 'Last Sync', width: 'w-28' },
|
|
936
951
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
937
952
|
],
|
|
938
953
|
clusterexternalsecrets: [
|
|
@@ -966,7 +981,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
966
981
|
{ key: 'namespaces', label: 'Scope', width: 'w-24', tooltip: 'Included namespaces (* = all)' },
|
|
967
982
|
{ key: 'duration', label: 'Duration', width: 'w-24' },
|
|
968
983
|
{ key: 'expiry', label: 'Expires', width: 'w-24' },
|
|
969
|
-
{ key: 'errors', label: 'Errors', width: 'w-
|
|
984
|
+
{ key: 'errors', label: 'Errors', width: 'w-20' },
|
|
970
985
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
971
986
|
],
|
|
972
987
|
restores: [
|
|
@@ -975,7 +990,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
975
990
|
{ key: 'status', label: 'Status', width: 'w-28' },
|
|
976
991
|
{ key: 'backupName', label: 'Backup', width: 'w-40' },
|
|
977
992
|
{ key: 'duration', label: 'Duration', width: 'w-24' },
|
|
978
|
-
{ key: 'errors', label: 'Errors', width: 'w-
|
|
993
|
+
{ key: 'errors', label: 'Errors', width: 'w-20' },
|
|
979
994
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
980
995
|
],
|
|
981
996
|
schedules: [
|
|
@@ -1004,10 +1019,10 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1004
1019
|
{ key: 'name', label: 'Name' },
|
|
1005
1020
|
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
1006
1021
|
{ key: 'status', label: 'Status', width: 'w-28' },
|
|
1007
|
-
{ key: 'instances', label: 'Instances', width: 'w-
|
|
1022
|
+
{ key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
|
|
1008
1023
|
{ key: 'primary', label: 'Primary', width: 'w-36' },
|
|
1009
1024
|
{ key: 'image', label: 'Image', width: 'w-28' },
|
|
1010
|
-
{ key: 'storage', label: 'Storage', width: 'w-
|
|
1025
|
+
{ key: 'storage', label: 'Storage', width: 'w-28' },
|
|
1011
1026
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1012
1027
|
],
|
|
1013
1028
|
scheduledbackups: [
|
|
@@ -1016,8 +1031,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1016
1031
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1017
1032
|
{ key: 'cluster', label: 'Cluster', width: 'w-36' },
|
|
1018
1033
|
{ key: 'schedule', label: 'Schedule', width: 'w-36' },
|
|
1019
|
-
{ key: 'lastSchedule', label: 'Last Run', width: 'w-
|
|
1020
|
-
{ key: 'suspended', label: 'Suspended', width: 'w-
|
|
1034
|
+
{ key: 'lastSchedule', label: 'Last Run', width: 'w-28' },
|
|
1035
|
+
{ key: 'suspended', label: 'Suspended', width: 'w-28' },
|
|
1021
1036
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1022
1037
|
],
|
|
1023
1038
|
poolers: [
|
|
@@ -1026,8 +1041,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1026
1041
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1027
1042
|
{ key: 'cluster', label: 'Cluster', width: 'w-36' },
|
|
1028
1043
|
{ key: 'type', label: 'Type', width: 'w-16' },
|
|
1029
|
-
{ key: 'poolMode', label: 'Pool Mode', width: 'w-
|
|
1030
|
-
{ key: 'instances', label: 'Instances', width: 'w-
|
|
1044
|
+
{ key: 'poolMode', label: 'Pool Mode', width: 'w-32' },
|
|
1045
|
+
{ key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
|
|
1031
1046
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1032
1047
|
],
|
|
1033
1048
|
// ============================================================================
|
|
@@ -1101,7 +1116,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1101
1116
|
{ key: 'status', label: 'Status', width: 'w-28' },
|
|
1102
1117
|
{ key: 'routing', label: 'Traffic', width: 'w-20', tooltip: 'Whether this revision is receiving traffic' },
|
|
1103
1118
|
{ key: 'image', label: 'Image', width: 'w-48' },
|
|
1104
|
-
{ key: 'concurrency', label: 'Concurrency', width: 'w-
|
|
1119
|
+
{ key: 'concurrency', label: 'Concurrency', width: 'w-32' },
|
|
1105
1120
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1106
1121
|
],
|
|
1107
1122
|
knativeroutes: [
|
|
@@ -1363,7 +1378,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1363
1378
|
{ key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
|
|
1364
1379
|
{ key: 'cluster', label: 'Cluster', width: 'w-32 shrink-0' },
|
|
1365
1380
|
{ key: 'ready', label: 'Ready', width: 'w-20 shrink-0' },
|
|
1366
|
-
{ key: 'initialized', label: 'Initialized', width: 'w-
|
|
1381
|
+
{ key: 'initialized', label: 'Initialized', width: 'w-28 shrink-0' },
|
|
1367
1382
|
{ key: 'version', label: 'Version', width: 'w-24 shrink-0' },
|
|
1368
1383
|
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1369
1384
|
],
|
|
@@ -1513,6 +1528,43 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1513
1528
|
{ key: 'status', label: 'Status', width: 'w-28 shrink-0' },
|
|
1514
1529
|
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1515
1530
|
],
|
|
1531
|
+
// Crossplane — Managed Resources are unbounded (one kind per provider CRD);
|
|
1532
|
+
// routed via isLikelyCrossplaneMRGroup, not by exact kind plural.
|
|
1533
|
+
crossplanemanagedresources: [
|
|
1534
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
1535
|
+
{ key: 'namespace', label: 'Namespace', width: 'w-32 shrink-0' },
|
|
1536
|
+
{ key: 'kind', label: 'Kind', width: 'w-32 shrink-0' },
|
|
1537
|
+
{ key: 'external', label: 'External Name', width: 'min-w-48', hideOnMobile: true },
|
|
1538
|
+
{ key: 'provider', label: 'Provider Config', width: 'w-40 shrink-0', hideOnMobile: true },
|
|
1539
|
+
{ key: 'status', label: 'Status', width: 'w-32 shrink-0' },
|
|
1540
|
+
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1541
|
+
],
|
|
1542
|
+
providers: [
|
|
1543
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
1544
|
+
{ key: 'package', label: 'Package', width: 'min-w-48' },
|
|
1545
|
+
{ key: 'revision', label: 'Revision', width: 'w-36 shrink-0', hideOnMobile: true },
|
|
1546
|
+
{ key: 'status', label: 'Status', width: 'w-32 shrink-0' },
|
|
1547
|
+
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1548
|
+
],
|
|
1549
|
+
providerconfigs: [
|
|
1550
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
1551
|
+
{ key: 'credentials', label: 'Credentials', width: 'w-36 shrink-0' },
|
|
1552
|
+
{ key: 'status', label: 'Status', width: 'w-32 shrink-0' },
|
|
1553
|
+
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1554
|
+
],
|
|
1555
|
+
compositions: [
|
|
1556
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
1557
|
+
{ key: 'composite', label: 'Composite Kind', width: 'w-44 shrink-0' },
|
|
1558
|
+
{ key: 'mode', label: 'Mode', width: 'w-24 shrink-0' },
|
|
1559
|
+
{ key: 'functions', label: 'Functions', width: 'w-28 shrink-0', hideOnMobile: true },
|
|
1560
|
+
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1561
|
+
],
|
|
1562
|
+
compositeresourcedefinitions: [
|
|
1563
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
1564
|
+
{ key: 'kind', label: 'Kind', width: 'w-40 shrink-0' },
|
|
1565
|
+
{ key: 'claim', label: 'Claim Kind', width: 'w-40 shrink-0', hideOnMobile: true },
|
|
1566
|
+
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
1567
|
+
],
|
|
1516
1568
|
}
|
|
1517
1569
|
|
|
1518
1570
|
// Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
|
|
@@ -1545,8 +1597,32 @@ function normalizeKindToPlural(kind: string, group?: string): string {
|
|
|
1545
1597
|
return lower
|
|
1546
1598
|
}
|
|
1547
1599
|
|
|
1600
|
+
// Crossplane Managed Resources are unbounded (each provider ships its own
|
|
1601
|
+
// CRDs under per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
|
|
1602
|
+
// Route any non-core-Crossplane resource in these groups to the generic MR
|
|
1603
|
+
// column set. ProviderConfig is excluded — those have their own renderer.
|
|
1604
|
+
function isLikelyCrossplaneMRGroup(kind: string, group: string): boolean {
|
|
1605
|
+
if (!group) return false
|
|
1606
|
+
const k = kind.toLowerCase()
|
|
1607
|
+
if (k === 'providerconfig' || k === 'providerconfigs') return false
|
|
1608
|
+
if (group.endsWith('.upbound.io')) return true
|
|
1609
|
+
if (group === 'kubernetes.crossplane.io' || group === 'helm.crossplane.io') return true
|
|
1610
|
+
// Reserved Crossplane core groups — never MRs.
|
|
1611
|
+
if (group === 'crossplane.io' || group === 'pkg.crossplane.io' || group === 'apiextensions.crossplane.io') {
|
|
1612
|
+
return false
|
|
1613
|
+
}
|
|
1614
|
+
// Any other *.crossplane.io subgroup is presumed to be a provider's MR group.
|
|
1615
|
+
if (group.endsWith('.crossplane.io')) return true
|
|
1616
|
+
return false
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1548
1619
|
function getColumnsForKind(kind: string, group?: string): Column[] {
|
|
1549
|
-
|
|
1620
|
+
const key = normalizeKindToPlural(kind, group)
|
|
1621
|
+
if (KNOWN_COLUMNS[key]) return KNOWN_COLUMNS[key]
|
|
1622
|
+
if (group && isLikelyCrossplaneMRGroup(kind, group)) {
|
|
1623
|
+
return KNOWN_COLUMNS.crossplanemanagedresources
|
|
1624
|
+
}
|
|
1625
|
+
return DEFAULT_COLUMNS
|
|
1550
1626
|
}
|
|
1551
1627
|
|
|
1552
1628
|
// Get the default visible columns for a kind
|
|
@@ -1679,6 +1755,26 @@ interface ResourcesViewProps {
|
|
|
1679
1755
|
* should also wire onResourceClick to handle the deep-link-on-load
|
|
1680
1756
|
* case. */
|
|
1681
1757
|
onRowSelect?: (resource: any) => void
|
|
1758
|
+
/**
|
|
1759
|
+
* Overrides the default compare-mode submit (which navigates to
|
|
1760
|
+
* `/compare?kind=...&a=...&b=...`). Hosts use this to route to a
|
|
1761
|
+
* different URL — e.g. Radar Hub's `/fleet/compare` with cluster IDs.
|
|
1762
|
+
* Picks arrive in click order, each carrying `clusterId`/`clusterName`
|
|
1763
|
+
* when `resolveRowCluster` is set (which is what keeps cross-cluster
|
|
1764
|
+
* picks distinct in the equality check).
|
|
1765
|
+
*/
|
|
1766
|
+
onCompareSubmit?: (
|
|
1767
|
+
picks: NamespacedRef[],
|
|
1768
|
+
kind: { name: string; group?: string },
|
|
1769
|
+
) => void
|
|
1770
|
+
/**
|
|
1771
|
+
* Resolves the cluster scope for a row when compare-mode is enabled.
|
|
1772
|
+
* Stamps `clusterId`/`clusterName` onto each pick so the same `ns/name`
|
|
1773
|
+
* in two different clusters is treated as two distinct picks. Hub-web
|
|
1774
|
+
* returns `row._cluster` here; OSS leaves it unset and picks key on
|
|
1775
|
+
* namespace+name only.
|
|
1776
|
+
*/
|
|
1777
|
+
resolveRowCluster?: (resource: any) => { id: string; name: string } | undefined
|
|
1682
1778
|
}
|
|
1683
1779
|
|
|
1684
1780
|
// Default selected kind
|
|
@@ -1752,6 +1848,51 @@ function getInitialFiltersFromURL() {
|
|
|
1752
1848
|
// Sort state type
|
|
1753
1849
|
type SortDirection = 'asc' | 'desc' | null
|
|
1754
1850
|
|
|
1851
|
+
// Coarse "just now / Xm / Xh / Xd" buckets — finer-grained updates
|
|
1852
|
+
// add motion in the periphery without aiding any user decision.
|
|
1853
|
+
function formatLastUpdatedBucket(elapsedMs: number): string {
|
|
1854
|
+
const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
|
|
1855
|
+
if (elapsedSec < 60) return 'just now'
|
|
1856
|
+
const minutes = Math.floor(elapsedSec / 60)
|
|
1857
|
+
if (minutes < 60) return `${minutes}m`
|
|
1858
|
+
const hours = Math.floor(minutes / 60)
|
|
1859
|
+
if (hours < 24) return `${hours}h`
|
|
1860
|
+
return `${Math.floor(hours / 24)}d`
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// ms until the displayed bucket would change.
|
|
1864
|
+
function msToNextBucket(elapsedMs: number): number {
|
|
1865
|
+
const elapsed = Math.max(0, elapsedMs)
|
|
1866
|
+
if (elapsed < 60_000) return 60_000 - elapsed
|
|
1867
|
+
if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
|
|
1868
|
+
if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
|
|
1869
|
+
return 86_400_000 - (elapsed % 86_400_000)
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// Isolated subtree so re-renders don't cascade into the parent's
|
|
1873
|
+
// virtualized table.
|
|
1874
|
+
function LastUpdatedLabel({ lastUpdated }: { lastUpdated: Date }) {
|
|
1875
|
+
const [, force] = useState(0)
|
|
1876
|
+
useEffect(() => {
|
|
1877
|
+
let id: ReturnType<typeof setTimeout>
|
|
1878
|
+
function schedule() {
|
|
1879
|
+
const delay = Math.max(1000, msToNextBucket(Date.now() - lastUpdated.getTime()))
|
|
1880
|
+
id = setTimeout(() => {
|
|
1881
|
+
force(t => t + 1)
|
|
1882
|
+
schedule()
|
|
1883
|
+
}, delay)
|
|
1884
|
+
}
|
|
1885
|
+
schedule()
|
|
1886
|
+
return () => clearTimeout(id)
|
|
1887
|
+
}, [lastUpdated])
|
|
1888
|
+
return (
|
|
1889
|
+
<div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
1890
|
+
<Clock className="w-3.5 h-3.5" />
|
|
1891
|
+
<span>Updated {formatLastUpdatedBucket(Date.now() - lastUpdated.getTime())}</span>
|
|
1892
|
+
</div>
|
|
1893
|
+
)
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1755
1896
|
export function ResourcesView({
|
|
1756
1897
|
namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange,
|
|
1757
1898
|
apiResources: apiResourcesProp,
|
|
@@ -1778,6 +1919,8 @@ export function ResourcesView({
|
|
|
1778
1919
|
defaultKind = DEFAULT_KIND_INFO,
|
|
1779
1920
|
extraLeadingColumns,
|
|
1780
1921
|
onRowSelect,
|
|
1922
|
+
onCompareSubmit,
|
|
1923
|
+
resolveRowCluster,
|
|
1781
1924
|
}: ResourcesViewProps) {
|
|
1782
1925
|
const initialFilters = getInitialFiltersFromURL()
|
|
1783
1926
|
const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
|
|
@@ -1921,6 +2064,13 @@ export function ResourcesView({
|
|
|
1921
2064
|
|
|
1922
2065
|
useEffect(() => {
|
|
1923
2066
|
const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
|
|
2067
|
+
// Host-injected extra columns (e.g. fleet Cluster) default to visible
|
|
2068
|
+
// even when the saved column-visibility blob predates them — a naive
|
|
2069
|
+
// Set(saved.visible) would silently hide host columns the user has
|
|
2070
|
+
// never been shown. Trade-off: a user can't permanently hide a host
|
|
2071
|
+
// extra column via the column picker — next mount re-adds it. Track
|
|
2072
|
+
// a sibling "hidden-extras" set if this becomes a real complaint.
|
|
2073
|
+
const extraKeys = extraLeadingColumns?.map(c => c.key) ?? []
|
|
1924
2074
|
if (saved) {
|
|
1925
2075
|
// If saved columns are just the defaults but this kind has specialized columns,
|
|
1926
2076
|
// discard the stale save and use the specialized columns instead
|
|
@@ -1933,14 +2083,16 @@ export function ResourcesView({
|
|
|
1933
2083
|
setVisibleColumns(getDefaultVisibleColumns(allColumns))
|
|
1934
2084
|
setColumnWidths({})
|
|
1935
2085
|
} else {
|
|
1936
|
-
|
|
2086
|
+
const merged = new Set(saved.visible)
|
|
2087
|
+
for (const k of extraKeys) merged.add(k)
|
|
2088
|
+
setVisibleColumns(merged)
|
|
1937
2089
|
setColumnWidths(saved.widths || {})
|
|
1938
2090
|
}
|
|
1939
2091
|
} else {
|
|
1940
2092
|
setVisibleColumns(getDefaultVisibleColumns(allColumns))
|
|
1941
2093
|
setColumnWidths({})
|
|
1942
2094
|
}
|
|
1943
|
-
}, [selectedKind.name, selectedKind.group, allColumns])
|
|
2095
|
+
}, [selectedKind.name, selectedKind.group, allColumns, extraLeadingColumns])
|
|
1944
2096
|
|
|
1945
2097
|
// Save column settings when they change (skip initial load)
|
|
1946
2098
|
const isColumnSettingsLoaded = useRef(false)
|
|
@@ -2076,6 +2228,56 @@ export function ResourcesView({
|
|
|
2076
2228
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
2077
2229
|
const virtuosoRef = useRef<TableVirtuosoHandle>(null)
|
|
2078
2230
|
|
|
2231
|
+
// Compare mode is only meaningful when the host can route picks. Either
|
|
2232
|
+
// path qualifies: onNavigate for the default same-cluster `/compare?...`
|
|
2233
|
+
// URL, or onCompareSubmit for hosts that build their own URL (e.g.
|
|
2234
|
+
// Radar Hub's `/fleet/compare` with cluster IDs).
|
|
2235
|
+
const compareEnabled = !!onNavigate || !!onCompareSubmit
|
|
2236
|
+
const [compareMode, setCompareMode] = useState(false)
|
|
2237
|
+
const [comparePicks, setComparePicks] = useState<CompareTrayPick[]>([])
|
|
2238
|
+
|
|
2239
|
+
// Kind change invalidates picks AND mode — leaving the tray on with empty
|
|
2240
|
+
// pills after the user moves to a different kind is more confusing than helpful.
|
|
2241
|
+
useEffect(() => {
|
|
2242
|
+
setComparePicks([])
|
|
2243
|
+
setCompareMode(false)
|
|
2244
|
+
}, [selectedKind.name, selectedKind.group])
|
|
2245
|
+
|
|
2246
|
+
const exitCompareMode = useCallback(() => {
|
|
2247
|
+
setCompareMode(false)
|
|
2248
|
+
setComparePicks([])
|
|
2249
|
+
}, [])
|
|
2250
|
+
|
|
2251
|
+
const toggleComparePick = useCallback((resource: any) => {
|
|
2252
|
+
if (!resource?.metadata?.name) return
|
|
2253
|
+
const ns = resource.metadata.namespace || ''
|
|
2254
|
+
// Stamp clusterId on the pick when the host provides a resolver
|
|
2255
|
+
// (cross-cluster compare). Without it, two rows with the same ns+name
|
|
2256
|
+
// from different clusters would collapse into one pick.
|
|
2257
|
+
const cluster = resolveRowCluster?.(resource)
|
|
2258
|
+
setComparePicks(prev => togglePick(prev, {
|
|
2259
|
+
namespace: ns,
|
|
2260
|
+
name: resource.metadata.name,
|
|
2261
|
+
clusterId: cluster?.id,
|
|
2262
|
+
clusterName: cluster?.name,
|
|
2263
|
+
}))
|
|
2264
|
+
}, [resolveRowCluster])
|
|
2265
|
+
|
|
2266
|
+
const handleCompareNavigate = useCallback(() => {
|
|
2267
|
+
if (comparePicks.length !== 2) return
|
|
2268
|
+
if (onCompareSubmit) {
|
|
2269
|
+
onCompareSubmit(comparePicks, { name: selectedKind.name, group: selectedKind.group })
|
|
2270
|
+
return
|
|
2271
|
+
}
|
|
2272
|
+
if (!onNavigate) return
|
|
2273
|
+
const params = new URLSearchParams()
|
|
2274
|
+
params.set('kind', selectedKind.name)
|
|
2275
|
+
if (selectedKind.group) params.set('apiGroup', selectedKind.group)
|
|
2276
|
+
params.set('a', refToParam(comparePicks[0]))
|
|
2277
|
+
params.set('b', refToParam(comparePicks[1]))
|
|
2278
|
+
onNavigate(`/compare?${params.toString()}`)
|
|
2279
|
+
}, [comparePicks, selectedKind.name, selectedKind.group, onNavigate, onCompareSubmit])
|
|
2280
|
+
|
|
2079
2281
|
// Reset highlight when kind, search, sort, or namespace changes
|
|
2080
2282
|
const namespacesKey = namespaces.join(',')
|
|
2081
2283
|
useEffect(() => { setHighlightedIndex(-1) }, [selectedKind.name, searchTerm, sortColumn, sortDirection, namespacesKey])
|
|
@@ -2181,7 +2383,11 @@ export function ResourcesView({
|
|
|
2181
2383
|
description: 'Open resource detail',
|
|
2182
2384
|
category: 'Resource Actions',
|
|
2183
2385
|
scope: 'resources',
|
|
2184
|
-
handler: () =>
|
|
2386
|
+
handler: () => {
|
|
2387
|
+
const res = getHighlightedResource()
|
|
2388
|
+
if (compareMode) toggleComparePick(res)
|
|
2389
|
+
else selectResource(res)
|
|
2390
|
+
},
|
|
2185
2391
|
enabled: highlightedIndex >= 0,
|
|
2186
2392
|
},
|
|
2187
2393
|
{
|
|
@@ -2190,7 +2396,11 @@ export function ResourcesView({
|
|
|
2190
2396
|
description: 'Open resource detail',
|
|
2191
2397
|
category: 'Resource Actions',
|
|
2192
2398
|
scope: 'resources',
|
|
2193
|
-
handler: () =>
|
|
2399
|
+
handler: () => {
|
|
2400
|
+
const res = getHighlightedResource()
|
|
2401
|
+
if (compareMode) toggleComparePick(res)
|
|
2402
|
+
else selectResource(res)
|
|
2403
|
+
},
|
|
2194
2404
|
enabled: highlightedIndex >= 0,
|
|
2195
2405
|
},
|
|
2196
2406
|
{
|
|
@@ -2205,7 +2415,7 @@ export function ResourcesView({
|
|
|
2205
2415
|
const cb = onResourceClickYaml || onResourceClick
|
|
2206
2416
|
cb?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
|
|
2207
2417
|
},
|
|
2208
|
-
enabled: highlightedIndex >= 0,
|
|
2418
|
+
enabled: highlightedIndex >= 0 && !compareMode,
|
|
2209
2419
|
},
|
|
2210
2420
|
{
|
|
2211
2421
|
id: 'resources-logs',
|
|
@@ -2229,7 +2439,7 @@ export function ResourcesView({
|
|
|
2229
2439
|
openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
|
|
2230
2440
|
}
|
|
2231
2441
|
},
|
|
2232
|
-
enabled: highlightedIndex >= 0 && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
|
|
2442
|
+
enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs'].includes(selectedKind.name.toLowerCase()),
|
|
2233
2443
|
},
|
|
2234
2444
|
{
|
|
2235
2445
|
id: 'resources-sort-name',
|
|
@@ -2262,11 +2472,11 @@ export function ResourcesView({
|
|
|
2262
2472
|
category: 'Table',
|
|
2263
2473
|
scope: 'resources',
|
|
2264
2474
|
handler: () => {
|
|
2265
|
-
// Close dropdowns first, then clear highlight, then blur search
|
|
2266
2475
|
if (showColumnPicker) { setShowColumnPicker(false); return }
|
|
2267
2476
|
if (openColumnFilter) { setOpenColumnFilter(null); return }
|
|
2268
2477
|
if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
|
|
2269
2478
|
if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
|
|
2479
|
+
if (compareMode) { exitCompareMode(); return }
|
|
2270
2480
|
if (highlightedIndex >= 0) setHighlightedIndex(-1)
|
|
2271
2481
|
else searchInputRef.current?.blur()
|
|
2272
2482
|
},
|
|
@@ -2680,12 +2890,18 @@ export function ResourcesView({
|
|
|
2680
2890
|
|
|
2681
2891
|
const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(() => refetchFn?.())
|
|
2682
2892
|
|
|
2683
|
-
//
|
|
2893
|
+
// React Query bumps dataUpdatedAt on no-op refetches (window focus,
|
|
2894
|
+
// mount, sibling subscribers); structural sharing returns the same
|
|
2895
|
+
// resources reference when data is byte-identical. Skip the timer
|
|
2896
|
+
// reset in that case — otherwise opening a filter drawer looks like
|
|
2897
|
+
// it triggered a real fetch.
|
|
2898
|
+
const lastDataRef = useRef<unknown>(undefined)
|
|
2684
2899
|
useEffect(() => {
|
|
2685
|
-
if (dataUpdatedAt)
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2900
|
+
if (!dataUpdatedAt) return
|
|
2901
|
+
if (resources === lastDataRef.current) return
|
|
2902
|
+
lastDataRef.current = resources
|
|
2903
|
+
setLastUpdated(new Date(dataUpdatedAt))
|
|
2904
|
+
}, [dataUpdatedAt, resources])
|
|
2689
2905
|
|
|
2690
2906
|
// Derive counts — prefer lightweight resourceCounts prop over full query data
|
|
2691
2907
|
const counts = useMemo(() => {
|
|
@@ -3105,6 +3321,14 @@ export function ResourcesView({
|
|
|
3105
3321
|
style={{ ...props.style, tableLayout: 'fixed' }}
|
|
3106
3322
|
>
|
|
3107
3323
|
<colgroup>
|
|
3324
|
+
{/*
|
|
3325
|
+
Compare-mode adds a leading <th>/<td> per row but isn't part
|
|
3326
|
+
of `columns`. Under table-layout:fixed the <colgroup> must
|
|
3327
|
+
match the actual column count, otherwise the browser pads
|
|
3328
|
+
the missing entry by stealing width from a sized neighbour
|
|
3329
|
+
— typically blowing this narrow column out to ~200px.
|
|
3330
|
+
*/}
|
|
3331
|
+
{compareMode && <col style={{ width: 36 }} />}
|
|
3108
3332
|
{columns.map(col => (
|
|
3109
3333
|
<col
|
|
3110
3334
|
key={col.key}
|
|
@@ -3122,7 +3346,7 @@ export function ResourcesView({
|
|
|
3122
3346
|
)
|
|
3123
3347
|
}),
|
|
3124
3348
|
TableRow: VirtuosoTableRow,
|
|
3125
|
-
}), [columns, columnWidths, hasResizedColumns])
|
|
3349
|
+
}), [columns, columnWidths, hasResizedColumns, compareMode])
|
|
3126
3350
|
|
|
3127
3351
|
// Calculate filter options with counts based on current resources (before filtering)
|
|
3128
3352
|
const filterOptions = useMemo(() => {
|
|
@@ -3162,10 +3386,7 @@ export function ResourcesView({
|
|
|
3162
3386
|
}
|
|
3163
3387
|
|
|
3164
3388
|
const distinctCount = Object.keys(valueCounts).length
|
|
3165
|
-
|
|
3166
|
-
// Node column gets a higher cap (50) since clusters commonly have 20-50 nodes
|
|
3167
|
-
const maxDistinct = col.key === 'node' ? 50 : 20
|
|
3168
|
-
if (distinctCount >= 2 && distinctCount <= maxDistinct) {
|
|
3389
|
+
if (isColumnFilterableByDistinctCount(col.key, distinctCount)) {
|
|
3169
3390
|
filterableColumns.push({
|
|
3170
3391
|
key: col.key,
|
|
3171
3392
|
label: col.label,
|
|
@@ -3524,12 +3745,7 @@ export function ResourcesView({
|
|
|
3524
3745
|
</span>
|
|
3525
3746
|
)}
|
|
3526
3747
|
|
|
3527
|
-
{lastUpdated &&
|
|
3528
|
-
<div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
3529
|
-
<Clock className="w-3.5 h-3.5" />
|
|
3530
|
-
<span>Updated <span className="inline-block min-w-[4ch] tabular-nums">{formatAge(lastUpdated.toISOString())}</span></span>
|
|
3531
|
-
</div>
|
|
3532
|
-
)}
|
|
3748
|
+
{lastUpdated && <LastUpdatedLabel lastUpdated={lastUpdated} />}
|
|
3533
3749
|
{/* Column picker */}
|
|
3534
3750
|
<div className="relative" ref={columnPickerRef}>
|
|
3535
3751
|
<button
|
|
@@ -3602,6 +3818,22 @@ export function ResourcesView({
|
|
|
3602
3818
|
</button>
|
|
3603
3819
|
</Tooltip>
|
|
3604
3820
|
)}
|
|
3821
|
+
{compareEnabled && (
|
|
3822
|
+
<Tooltip content={compareMode ? 'Exit compare mode' : 'Compare two resources side-by-side'}>
|
|
3823
|
+
<button
|
|
3824
|
+
onClick={() => (compareMode ? exitCompareMode() : setCompareMode(true))}
|
|
3825
|
+
aria-pressed={compareMode}
|
|
3826
|
+
className={clsx(
|
|
3827
|
+
'p-2 rounded-lg transition-colors',
|
|
3828
|
+
compareMode
|
|
3829
|
+
? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
|
|
3830
|
+
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
|
|
3831
|
+
)}
|
|
3832
|
+
>
|
|
3833
|
+
<GitCompare className="w-4 h-4" />
|
|
3834
|
+
</button>
|
|
3835
|
+
</Tooltip>
|
|
3836
|
+
)}
|
|
3605
3837
|
</div>
|
|
3606
3838
|
|
|
3607
3839
|
{/* Table */}
|
|
@@ -3704,8 +3936,20 @@ export function ResourcesView({
|
|
|
3704
3936
|
components={virtuosoComponents}
|
|
3705
3937
|
fixedHeaderContent={() => (
|
|
3706
3938
|
<tr>
|
|
3939
|
+
{compareMode && (
|
|
3940
|
+
<th
|
|
3941
|
+
// Inline px width — under `table-layout:fixed`,
|
|
3942
|
+
// `w-9` is a hint the browser absorbs into leftover
|
|
3943
|
+
// row width on an icon-only column.
|
|
3944
|
+
style={{ width: 36, minWidth: 36, maxWidth: 36 }}
|
|
3945
|
+
className="px-2 py-3 text-xs font-medium uppercase tracking-wide bg-theme-base border-b border-r-subtle border-theme-border text-center text-skyhook-400"
|
|
3946
|
+
title="Compare mode"
|
|
3947
|
+
>
|
|
3948
|
+
<GitCompare className="w-3.5 h-3.5 inline-block opacity-70" />
|
|
3949
|
+
</th>
|
|
3950
|
+
)}
|
|
3707
3951
|
{columns.map((col, colIdx) => {
|
|
3708
|
-
const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory'].includes(col.key)
|
|
3952
|
+
const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory', 'containers'].includes(col.key)
|
|
3709
3953
|
const isSorted = sortColumn === col.key
|
|
3710
3954
|
const isLastCol = colIdx === columns.length - 1
|
|
3711
3955
|
const filterCol = filterableColumnMap.get(col.key)
|
|
@@ -3763,7 +4007,7 @@ export function ResourcesView({
|
|
|
3763
4007
|
? 'px-1.5 py-0.5 -my-0.5 selection-strong selection-text hover:bg-skyhook-500/30'
|
|
3764
4008
|
: isFilterOpen
|
|
3765
4009
|
? 'p-0.5 text-theme-text-primary'
|
|
3766
|
-
: 'p-0.5 text-theme-text-disabled opacity-
|
|
4010
|
+
: 'p-0.5 text-theme-text-disabled opacity-40 group-hover/th:opacity-100 hover:text-theme-text-primary'
|
|
3767
4011
|
)}
|
|
3768
4012
|
>
|
|
3769
4013
|
<ListFilter className="w-3 h-3" />
|
|
@@ -3875,6 +4119,13 @@ export function ResourcesView({
|
|
|
3875
4119
|
selectedResource?.namespace === resource.metadata?.namespace &&
|
|
3876
4120
|
selectedResource?.name === resource.metadata?.name
|
|
3877
4121
|
const isHighlighted = index === highlightedIndex
|
|
4122
|
+
const pickIdx = compareMode
|
|
4123
|
+
? pickIndex(comparePicks, {
|
|
4124
|
+
namespace: resource.metadata?.namespace || '',
|
|
4125
|
+
name: resource.metadata?.name || '',
|
|
4126
|
+
clusterId: resolveRowCluster?.(resource)?.id,
|
|
4127
|
+
})
|
|
4128
|
+
: -1
|
|
3878
4129
|
return (
|
|
3879
4130
|
<ResourceRowCells
|
|
3880
4131
|
resource={resource}
|
|
@@ -3886,8 +4137,10 @@ export function ResourcesView({
|
|
|
3886
4137
|
isSelected={isSelected}
|
|
3887
4138
|
isHighlighted={isHighlighted}
|
|
3888
4139
|
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
3889
|
-
onClick={() => selectResource(resource, isSelected)}
|
|
4140
|
+
onClick={() => compareMode ? toggleComparePick(resource) : selectResource(resource, isSelected)}
|
|
3890
4141
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
4142
|
+
compareMode={compareMode}
|
|
4143
|
+
comparePickIndex={pickIdx}
|
|
3891
4144
|
/>
|
|
3892
4145
|
)
|
|
3893
4146
|
}}
|
|
@@ -3903,6 +4156,15 @@ export function ResourcesView({
|
|
|
3903
4156
|
</MetricsContext.Provider>
|
|
3904
4157
|
)}
|
|
3905
4158
|
</div>
|
|
4159
|
+
{compareMode && compareEnabled && (
|
|
4160
|
+
<CompareTray
|
|
4161
|
+
kind={selectedKind.name}
|
|
4162
|
+
picks={comparePicks}
|
|
4163
|
+
onRemove={(idx) => setComparePicks(prev => prev.filter((_, i) => i !== idx))}
|
|
4164
|
+
onCompare={handleCompareNavigate}
|
|
4165
|
+
onExit={exitCompareMode}
|
|
4166
|
+
/>
|
|
4167
|
+
)}
|
|
3906
4168
|
</div>
|
|
3907
4169
|
</div>
|
|
3908
4170
|
</ResourcesViewDataContext.Provider>
|
|
@@ -3921,11 +4183,58 @@ interface ResourceRowCellsProps {
|
|
|
3921
4183
|
majorityNodeMinorVersion?: string
|
|
3922
4184
|
onClick?: () => void
|
|
3923
4185
|
onMouseEnter?: () => void
|
|
4186
|
+
compareMode?: boolean
|
|
4187
|
+
/** -1 when not picked; 0 = pick A; 1 = pick B. */
|
|
4188
|
+
comparePickIndex?: number
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
function rowHighlightClass(
|
|
4192
|
+
compareMode: boolean | undefined,
|
|
4193
|
+
comparePickIndex: number,
|
|
4194
|
+
isSelected: boolean | undefined,
|
|
4195
|
+
isHighlighted: boolean | undefined,
|
|
4196
|
+
): string {
|
|
4197
|
+
if (compareMode) {
|
|
4198
|
+
if (comparePickIndex === 0) return `${SIDE_TONES.a.rowBg} ${SIDE_TONES.a.rowBgHover}`
|
|
4199
|
+
if (comparePickIndex === 1) return `${SIDE_TONES.b.rowBg} ${SIDE_TONES.b.rowBgHover}`
|
|
4200
|
+
if (isHighlighted) return 'selection selection-ring'
|
|
4201
|
+
return 'group-hover/row:bg-theme-surface/50'
|
|
4202
|
+
}
|
|
4203
|
+
if (isSelected) return 'selection-strong group-hover/row:bg-skyhook-500/30'
|
|
4204
|
+
if (isHighlighted) return 'selection selection-ring'
|
|
4205
|
+
return 'group-hover/row:bg-theme-surface/50'
|
|
3924
4206
|
}
|
|
3925
4207
|
|
|
3926
|
-
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
|
|
4208
|
+
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1 }: ResourceRowCellsProps) {
|
|
4209
|
+
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
|
|
4210
|
+
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
3927
4211
|
return (
|
|
3928
4212
|
<>
|
|
4213
|
+
{compareMode && (
|
|
4214
|
+
<td
|
|
4215
|
+
onClick={onClick}
|
|
4216
|
+
onMouseEnter={onMouseEnter}
|
|
4217
|
+
style={{ width: 36, minWidth: 36, maxWidth: 36 }}
|
|
4218
|
+
className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
|
|
4219
|
+
>
|
|
4220
|
+
{pickedSide ? (
|
|
4221
|
+
<span
|
|
4222
|
+
className={clsx(
|
|
4223
|
+
'inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold leading-none',
|
|
4224
|
+
SIDE_TONES[pickedSide].chipBg,
|
|
4225
|
+
)}
|
|
4226
|
+
aria-label={pickedSide === 'a' ? 'Pick A' : 'Pick B'}
|
|
4227
|
+
>
|
|
4228
|
+
{pickedSide === 'a' ? 'A' : 'B'}
|
|
4229
|
+
</span>
|
|
4230
|
+
) : (
|
|
4231
|
+
<span
|
|
4232
|
+
className="inline-block w-4 h-4 rounded border border-theme-border-light group-hover/row:border-skyhook-400/60 transition-colors"
|
|
4233
|
+
aria-hidden
|
|
4234
|
+
/>
|
|
4235
|
+
)}
|
|
4236
|
+
</td>
|
|
4237
|
+
)}
|
|
3929
4238
|
{columns.map((col) => (
|
|
3930
4239
|
<td
|
|
3931
4240
|
key={col.key}
|
|
@@ -3934,11 +4243,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
3934
4243
|
className={clsx(
|
|
3935
4244
|
'px-4 py-3 border-b-subtle cursor-pointer transition-colors',
|
|
3936
4245
|
col.key !== 'status' && 'overflow-hidden truncate',
|
|
3937
|
-
|
|
3938
|
-
? 'selection-strong group-hover/row:bg-skyhook-500/30'
|
|
3939
|
-
: isHighlighted
|
|
3940
|
-
? 'selection selection-ring'
|
|
3941
|
-
: 'group-hover/row:bg-theme-surface/50'
|
|
4246
|
+
rowHighlight,
|
|
3942
4247
|
)}
|
|
3943
4248
|
>
|
|
3944
4249
|
<CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
|
|
@@ -4026,7 +4331,14 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
4026
4331
|
)
|
|
4027
4332
|
}
|
|
4028
4333
|
if (column === 'age') {
|
|
4029
|
-
|
|
4334
|
+
if (!meta.creationTimestamp) {
|
|
4335
|
+
return <span className="text-sm text-theme-text-secondary">-</span>
|
|
4336
|
+
}
|
|
4337
|
+
return (
|
|
4338
|
+
<Tooltip content={new Date(meta.creationTimestamp).toLocaleString()}>
|
|
4339
|
+
<span className="text-sm text-theme-text-secondary">{formatAge(meta.creationTimestamp)}</span>
|
|
4340
|
+
</Tooltip>
|
|
4341
|
+
)
|
|
4030
4342
|
}
|
|
4031
4343
|
|
|
4032
4344
|
// Kind-specific columns (normalize CRD singular names like 'ScaledObject' → 'scaledobjects')
|
|
@@ -4352,7 +4664,29 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
4352
4664
|
return <AzureMachineTemplateCell resource={resource} column={column} />
|
|
4353
4665
|
case 'azuremanagedclusters':
|
|
4354
4666
|
return <AzureManagedClusterCell resource={resource} column={column} />
|
|
4667
|
+
// Crossplane
|
|
4668
|
+
case 'providers':
|
|
4669
|
+
// Disambiguate from any future kind named "providers" by checking group
|
|
4670
|
+
if (resource.apiVersion?.startsWith('pkg.crossplane.io/')) {
|
|
4671
|
+
return <CrossplaneProviderCell resource={resource} column={column} />
|
|
4672
|
+
}
|
|
4673
|
+
return <GenericCell resource={resource} column={column} />
|
|
4674
|
+
case 'providerconfigs':
|
|
4675
|
+
return <CrossplaneProviderConfigCell resource={resource} column={column} />
|
|
4676
|
+
case 'compositions':
|
|
4677
|
+
return <CompositionCell resource={resource} column={column} />
|
|
4678
|
+
case 'compositeresourcedefinitions':
|
|
4679
|
+
return <XRDCell resource={resource} column={column} />
|
|
4355
4680
|
default:
|
|
4681
|
+
// Crossplane Managed Resources: unbounded plurals (one CRD per provider
|
|
4682
|
+
// service), detected via group prefix + spec.providerConfigRef heuristic.
|
|
4683
|
+
if (isLikelyCrossplaneMRGroup(kind, group ?? '') && isManagedResource(resource)) {
|
|
4684
|
+
return <ManagedResourceCell resource={resource} column={column} />
|
|
4685
|
+
}
|
|
4686
|
+
// Composite Resources (XRs) — user-defined kinds with resourceRefs.
|
|
4687
|
+
if (isComposite(resource)) {
|
|
4688
|
+
return <CompositeResourceCell resource={resource} column={column} />
|
|
4689
|
+
}
|
|
4356
4690
|
// Generic cell for CRDs and unknown resources
|
|
4357
4691
|
return <GenericCell resource={resource} column={column} />
|
|
4358
4692
|
}
|