@skyhook-io/k8s-ui 1.19.0 → 1.20.0
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 +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
- package/src/components/gitops/insights/insights-helpers.ts +12 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/severity.ts +14 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +40 -0
- package/src/types/gitops-insights.ts +11 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- package/src/utils/workload-history-notes.ts +93 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { CRDVersionsCell } from './renderers/crd-cells'
|
|
2
|
+
import { getCRDColumnValue } from './resource-utils-crd'
|
|
3
|
+
import { getStrimziConnectorColumnValue } from './resource-utils-strimzi'
|
|
1
4
|
import { getOpenTelemetryColumnValue } from './resource-utils-opentelemetry'
|
|
2
5
|
import { getFlaggerColumnValue } from './resource-utils-flagger'
|
|
3
6
|
import type { ResourceCoverage } from '../../types/resource-coverage'
|
|
@@ -369,6 +372,7 @@ function phonePinnedLeft(compareMode: boolean | undefined, showCheckbox: boolean
|
|
|
369
372
|
// Built-in sortable keys. Hoisted so getColumnMinWidth can reserve room for the
|
|
370
373
|
// sort icon without the header render and the width math drifting apart.
|
|
371
374
|
const BUILTIN_SORTABLE_COLUMN_KEYS = new Set([
|
|
375
|
+
'connectCluster', 'connectorClass', 'connectorTasks',
|
|
372
376
|
'name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version',
|
|
373
377
|
'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object',
|
|
374
378
|
'cpu', 'memory', 'containers',
|
|
@@ -579,6 +583,15 @@ const DEFAULT_COLUMNS: Column[] = [
|
|
|
579
583
|
]
|
|
580
584
|
|
|
581
585
|
const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
586
|
+
kafkaconnectors: [
|
|
587
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
588
|
+
{ key: 'namespace', label: 'Namespace', width: 'w-40 shrink-0' },
|
|
589
|
+
{ key: 'status', label: 'Operator Snapshot', width: 'w-44 shrink-0', tooltip: 'Operator-reported connector and task state, not a live runtime check.' },
|
|
590
|
+
{ key: 'connectCluster', label: 'KafkaConnect', width: 'min-w-32' },
|
|
591
|
+
{ key: 'connectorClass', label: 'Class', width: 'min-w-48' },
|
|
592
|
+
{ key: 'connectorTasks', label: 'Tasks / Max', width: 'w-28 shrink-0', tooltip: 'Reported tasks / configured maximum. A smaller count is not itself a failure.' },
|
|
593
|
+
{ key: 'age', label: 'Age', width: 'w-24 shrink-0' },
|
|
594
|
+
],
|
|
582
595
|
opentelemetrycollectors: [
|
|
583
596
|
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
584
597
|
{ key: 'namespace', label: 'Namespace', width: 'w-40 shrink-0' },
|
|
@@ -1009,6 +1022,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1009
1022
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1010
1023
|
{ key: 'cohort', label: 'Cohort', width: 'w-32', tooltip: 'Queues in this cohort can share unused quota, subject to borrowing and lending limits' },
|
|
1011
1024
|
{ key: 'pendingWorkloads', label: 'Pending', width: 'w-24', tooltip: 'Workloads in this ClusterQueue waiting for admission, across namespaces; inspect workload status for the blocker' },
|
|
1025
|
+
{ key: 'reservingWorkloads', label: 'Reserving', width: 'w-24', tooltip: 'Workloads holding a quota reservation in this ClusterQueue, admitted ones included, so it is not added to Admitted' },
|
|
1012
1026
|
{ key: 'admittedWorkloads', label: 'Admitted', width: 'w-24', tooltip: 'Unfinished workloads admitted through this ClusterQueue, across namespaces; admission does not mean their pods are running' },
|
|
1013
1027
|
{ key: 'flavors', label: 'Flavors', width: 'w-40' },
|
|
1014
1028
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
@@ -1019,6 +1033,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1019
1033
|
{ key: 'status', label: 'Status', width: 'w-24' },
|
|
1020
1034
|
{ key: 'clusterQueue', label: 'Cluster Queue', width: 'w-40' },
|
|
1021
1035
|
{ key: 'pendingWorkloads', label: 'Pending', width: 'w-24', tooltip: 'Workloads submitted to this LocalQueue that are waiting for ClusterQueue admission' },
|
|
1036
|
+
{ key: 'reservingWorkloads', label: 'Reserving', width: 'w-24', tooltip: 'Workloads from this LocalQueue holding a quota reservation, admitted ones included, so it is not added to Admitted' },
|
|
1022
1037
|
{ key: 'admittedWorkloads', label: 'Admitted', width: 'w-24', tooltip: 'Unfinished workloads from this LocalQueue admitted to its ClusterQueue; their pods may still be waiting to run' },
|
|
1023
1038
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1024
1039
|
],
|
|
@@ -1717,6 +1732,14 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1717
1732
|
{ key: 'renewTime', label: 'Last Renewed', width: 'w-32', tooltip: 'Time since the recorded renewal; red means it exceeds leaseDurationSeconds. Check holder health and API connectivity' },
|
|
1718
1733
|
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1719
1734
|
],
|
|
1735
|
+
customresourcedefinitions: [
|
|
1736
|
+
{ key: 'name', label: 'Name' },
|
|
1737
|
+
{ key: 'kind', label: 'Kind', width: 'w-56' },
|
|
1738
|
+
{ key: 'scope', label: 'Scope', width: 'w-32' },
|
|
1739
|
+
{ key: 'versions', label: 'Served Versions', width: 'w-64' },
|
|
1740
|
+
{ key: 'status', label: 'Status', width: 'w-36' },
|
|
1741
|
+
{ key: 'age', label: 'Age', width: 'w-24' },
|
|
1742
|
+
],
|
|
1720
1743
|
priorityclasses: [
|
|
1721
1744
|
{ key: 'name', label: 'Name' },
|
|
1722
1745
|
{ key: 'value', label: 'Value', width: 'w-24', tooltip: 'Higher values receive scheduling preference; Pods can still be preempted by Pods with higher priority' },
|
|
@@ -2914,6 +2937,7 @@ function isCuratedUnboundedGroup(key: string, group: string): boolean {
|
|
|
2914
2937
|
* cannot occupy one. See CORE_API_GROUPS.
|
|
2915
2938
|
*/
|
|
2916
2939
|
const CURATED_COLUMN_GROUPS: Record<string, readonly string[]> = {
|
|
2940
|
+
kafkaconnectors: ['kafka.strimzi.io'],
|
|
2917
2941
|
opentelemetrycollectors: ['opentelemetry.io'],
|
|
2918
2942
|
instrumentations: ['opentelemetry.io'],
|
|
2919
2943
|
canaries: ['flagger.app'],
|
|
@@ -4640,12 +4664,12 @@ export function ResourcesView({
|
|
|
4640
4664
|
if (containers.length > 0) {
|
|
4641
4665
|
openLogs?.({ namespace: ns, podName: name, containers })
|
|
4642
4666
|
}
|
|
4643
|
-
} else if (['deployments', 'statefulsets', 'daemonsets', '
|
|
4667
|
+
} else if (['deployments', 'statefulsets', 'daemonsets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kindLower) || isCoreBatchJob(kindLower, selectedKind.group)) {
|
|
4644
4668
|
openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
|
|
4645
4669
|
}
|
|
4646
4670
|
},
|
|
4647
4671
|
enabled: highlightedIndex >= 0 && !compareMode && (
|
|
4648
|
-
['pods', 'deployments', 'statefulsets', 'daemonsets', '
|
|
4672
|
+
['pods', 'deployments', 'statefulsets', 'daemonsets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(selectedKind.name.toLowerCase()) ||
|
|
4649
4673
|
isCoreBatchJob(selectedKind.name, selectedKind.group)
|
|
4650
4674
|
),
|
|
4651
4675
|
},
|
|
@@ -5441,6 +5465,11 @@ export function ResourcesView({
|
|
|
5441
5465
|
const meta = resource.metadata || {}
|
|
5442
5466
|
const status = resource.status || {}
|
|
5443
5467
|
const kindLower = kind?.toLowerCase() || ''
|
|
5468
|
+
const connectorValue = getStrimziConnectorColumnValue(resource, column)
|
|
5469
|
+
if (connectorValue !== undefined) {
|
|
5470
|
+
if (column === 'connectorTasks') return Array.isArray(resource.status?.connectorStatus?.tasks) ? resource.status.connectorStatus.tasks.length : -1
|
|
5471
|
+
return connectorValue
|
|
5472
|
+
}
|
|
5444
5473
|
const otelValue = getOpenTelemetryColumnValue(resource, column)
|
|
5445
5474
|
if (otelValue !== undefined) return otelValue
|
|
5446
5475
|
const flaggerValue = getFlaggerColumnValue(resource, column)
|
|
@@ -7579,6 +7608,11 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
7579
7608
|
return <KyvernoCleanupPolicyCell resource={resource} column={column} />
|
|
7580
7609
|
}
|
|
7581
7610
|
|
|
7611
|
+
const connectorValue = getStrimziConnectorColumnValue(resource, column)
|
|
7612
|
+
if (connectorValue !== undefined) {
|
|
7613
|
+
if (column === 'status') return <GenericCell resource={resource} column={column} />
|
|
7614
|
+
return <span className="text-sm text-theme-text-secondary break-all">{connectorValue || '-'}</span>
|
|
7615
|
+
}
|
|
7582
7616
|
if (isApiGroup(resource.apiVersion, 'opentelemetry.io') && ['opentelemetrycollectors', 'instrumentations'].includes(kindLower)) {
|
|
7583
7617
|
if (column === 'status') return <GenericCell resource={resource} column={column} />
|
|
7584
7618
|
return <span className="text-sm text-theme-text-secondary break-all">{getOpenTelemetryColumnValue(resource, column) || '-'}</span>
|
|
@@ -7703,6 +7737,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
7703
7737
|
return <IngressClassCell resource={resource} column={column} />
|
|
7704
7738
|
case 'leases':
|
|
7705
7739
|
return <LeaseCell resource={resource} column={column} />
|
|
7740
|
+
case 'customresourcedefinitions':
|
|
7741
|
+
return isApiGroup(resource.apiVersion, 'apiextensions.k8s.io') ? <CRDCell resource={resource} column={column} /> : <GenericCell resource={resource} column={column} />
|
|
7706
7742
|
case 'priorityclasses':
|
|
7707
7743
|
return <PriorityClassCell resource={resource} column={column} />
|
|
7708
7744
|
case 'runtimeclasses':
|
|
@@ -9250,7 +9286,10 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
|
|
|
9250
9286
|
case 'pods': {
|
|
9251
9287
|
const m = metrics.nodes.get(resource.metadata?.name)
|
|
9252
9288
|
const allocatable = resource.status?.allocatable?.pods
|
|
9253
|
-
|
|
9289
|
+
// No metrics entry means the count wasn't loaded (e.g. node metrics are
|
|
9290
|
+
// off while a namespace is selected), not that the node runs no Pods.
|
|
9291
|
+
if (!m) return <span className="text-sm text-theme-text-tertiary font-mono">-</span>
|
|
9292
|
+
const podCount = m.podCount ?? 0
|
|
9254
9293
|
if (!allocatable) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
|
|
9255
9294
|
// Allocatable pods is a Quantity, so a 1000-pod node reports "1k" —
|
|
9256
9295
|
// a raw-integer read would see 1 and peg the bar at 21600%.
|
|
@@ -9912,3 +9951,11 @@ function EventCell({ resource, column }: { resource: any; column: string }) {
|
|
|
9912
9951
|
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
9913
9952
|
}
|
|
9914
9953
|
}
|
|
9954
|
+
|
|
9955
|
+
function CRDCell({ resource, column }: { resource: any; column: string }) {
|
|
9956
|
+
if (column === 'status') return <GenericCell resource={resource} column={column} />
|
|
9957
|
+
const spec = resource.spec || {}
|
|
9958
|
+
if (column === 'versions') return <CRDVersionsCell versions={spec.versions || []} />
|
|
9959
|
+
const value = getCRDColumnValue(resource, column)
|
|
9960
|
+
return <span className="text-sm text-theme-text-secondary">{value || '-'}</span>
|
|
9961
|
+
}
|
|
@@ -108,8 +108,8 @@ describe('curated column ownership', () => {
|
|
|
108
108
|
// Brace-counting, not parsing: assert the extraction actually saw the whole
|
|
109
109
|
// table, or a silently-missed key would let the guard above pass vacuously.
|
|
110
110
|
it('extracted every curated key', () => {
|
|
111
|
-
expect(CURATED.length).toBe(
|
|
112
|
-
expect(OWNED.size).toBe(
|
|
111
|
+
expect(CURATED.length).toBe(246)
|
|
112
|
+
expect(OWNED.size).toBe(207)
|
|
113
113
|
expect(CURATED).toContain('pods')
|
|
114
114
|
expect(CURATED).toContain('crossplanemanagedresources')
|
|
115
115
|
expect([...OWNED]).toContain('awsmanagedcontrolplanes')
|
|
@@ -166,7 +166,7 @@ describe('every declaration resolves', () => {
|
|
|
166
166
|
.map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const)
|
|
167
167
|
|
|
168
168
|
it('covers the whole ownership table', () => {
|
|
169
|
-
expect(DECLARED.length).toBe(
|
|
169
|
+
expect(DECLARED.length).toBe(207)
|
|
170
170
|
})
|
|
171
171
|
|
|
172
172
|
it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getCRDStatus } from './resource-utils-crd'
|
|
2
|
+
import { getStrimziConnectorStatus } from './resource-utils-strimzi'
|
|
1
3
|
import { getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from './resource-utils-opentelemetry'
|
|
2
4
|
import { getFlaggerCanaryStatus } from './resource-utils-flagger'
|
|
3
5
|
import { isApiGroup } from './resource-utils-cnpg'
|
|
@@ -91,6 +93,11 @@ function fromCondition(cond: any, polarityKnown: boolean): GenericStatus | null
|
|
|
91
93
|
* callers render their own placeholder rather than inventing one.
|
|
92
94
|
*/
|
|
93
95
|
export function getGenericResourceStatus(resource: any): GenericStatus | null {
|
|
96
|
+
if (resource?.kind === 'CustomResourceDefinition' && isApiGroup(resource.apiVersion, 'apiextensions.k8s.io')) return getCRDStatus(resource)
|
|
97
|
+
if (resource?.kind === 'KafkaConnector' && isApiGroup(resource.apiVersion, 'kafka.strimzi.io')) {
|
|
98
|
+
const native = getStrimziConnectorStatus(resource)
|
|
99
|
+
return { text: native.text, tone: native.level }
|
|
100
|
+
}
|
|
94
101
|
if (isApiGroup(resource?.apiVersion, 'opentelemetry.io')) {
|
|
95
102
|
const native = resource.kind === 'OpenTelemetryCollector' ? getOpenTelemetryCollectorStatus(resource)
|
|
96
103
|
: resource.kind === 'Instrumentation' ? getOpenTelemetryInstrumentationStatus(resource) : null
|
|
@@ -568,3 +568,73 @@ describe('getPodPhaseDisplay', () => {
|
|
|
568
568
|
expect(r.level).toBe('healthy')
|
|
569
569
|
})
|
|
570
570
|
})
|
|
571
|
+
|
|
572
|
+
describe('scheduling-gated pods', () => {
|
|
573
|
+
const oldTs = '2020-01-01T00:00:00Z'
|
|
574
|
+
const gated = {
|
|
575
|
+
metadata: { creationTimestamp: oldTs },
|
|
576
|
+
spec: { schedulingGates: [{ name: 'kueue.x-k8s.io/admission' }] },
|
|
577
|
+
status: { phase: 'Pending' },
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
it('getPodStatus reads a gated pod as neutral "Scheduling gated", not an aged Pending warning', () => {
|
|
581
|
+
const r = getPodStatus(gated)
|
|
582
|
+
expect(r.level).toBe('neutral')
|
|
583
|
+
expect(r.text).toBe('Scheduling gated')
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('getPodStatus still ages an ungated Pending pod into a degraded warning', () => {
|
|
587
|
+
const ungated = { metadata: { creationTimestamp: oldTs }, status: { phase: 'Pending' } }
|
|
588
|
+
expect(getPodStatus(ungated).level).toBe('degraded')
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('getPodPhaseDisplay explains the hold and names the gate in its hint', () => {
|
|
592
|
+
const r = getPodPhaseDisplay(gated)
|
|
593
|
+
expect(r.level).toBe('neutral')
|
|
594
|
+
expect(r.text).toContain('Scheduling gated')
|
|
595
|
+
expect(r.hint).toContain('kueue.x-k8s.io/admission')
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
describe('scheduling-gated detection on summary list payloads', () => {
|
|
600
|
+
// The summary list payload keeps spec.schedulingGates (with an otherwise minimal
|
|
601
|
+
// spec), so the badge classifies a held pod straight from the authoritative spec
|
|
602
|
+
// field — no dependency on the scheduler's PodScheduled condition.
|
|
603
|
+
const summaryGated = {
|
|
604
|
+
metadata: { creationTimestamp: '2020-01-01T00:00:00Z' },
|
|
605
|
+
spec: { schedulingGates: [{ name: 'kueue.x-k8s.io/admission' }], containers: [{ name: 'trainer' }] },
|
|
606
|
+
status: { phase: 'Pending' },
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
it('getPodStatus reads a summary-shaped gated pod as neutral', () => {
|
|
610
|
+
const r = getPodStatus(summaryGated)
|
|
611
|
+
expect(r.level).toBe('neutral')
|
|
612
|
+
expect(r.text).toBe('Scheduling gated')
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('a released pod is aged normally even if a stale SchedulingGated condition lingers', () => {
|
|
616
|
+
// Gates removed (empty spec) but the scheduler has not yet rewritten the
|
|
617
|
+
// condition: the spec is authoritative, so the pod must age, not read as held.
|
|
618
|
+
const released = {
|
|
619
|
+
metadata: { creationTimestamp: '2020-01-01T00:00:00Z' },
|
|
620
|
+
spec: { containers: [{ name: 'trainer' }] },
|
|
621
|
+
status: {
|
|
622
|
+
phase: 'Pending',
|
|
623
|
+
conditions: [{ type: 'PodScheduled', status: 'False', reason: 'SchedulingGated' }],
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
expect(getPodStatus(released).level).toBe('degraded')
|
|
627
|
+
expect(getPodStatus(released).text).toBe('Pending')
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('an Unschedulable condition is NOT treated as scheduling-gated', () => {
|
|
631
|
+
const unsched = {
|
|
632
|
+
metadata: { creationTimestamp: '2020-01-01T00:00:00Z' },
|
|
633
|
+
status: {
|
|
634
|
+
phase: 'Pending',
|
|
635
|
+
conditions: [{ type: 'PodScheduled', status: 'False', reason: 'Unschedulable' }],
|
|
636
|
+
},
|
|
637
|
+
}
|
|
638
|
+
expect(getPodStatus(unsched).text).toBe('Unschedulable')
|
|
639
|
+
})
|
|
640
|
+
})
|
|
@@ -1,7 +1,60 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup, renderToString } from 'react-dom/server'
|
|
3
3
|
import { ArgoApplicationRenderer } from './ArgoApplicationRenderer'
|
|
4
4
|
|
|
5
|
+
// SSR splits interpolated text with <!-- --> markers; strip so assertions read as prose.
|
|
6
|
+
function render(element: Parameters<typeof renderToString>[0]): string {
|
|
7
|
+
return renderToString(element).replace(/<!-- -->/g, '')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function app(automated: Record<string, unknown> | undefined) {
|
|
11
|
+
return {
|
|
12
|
+
metadata: { name: 'guestbook', namespace: 'argocd' },
|
|
13
|
+
spec: {
|
|
14
|
+
project: 'default',
|
|
15
|
+
source: { repoURL: 'https://github.com/argoproj/argocd-example-apps', path: 'guestbook', targetRevision: 'HEAD' },
|
|
16
|
+
destination: { server: 'https://kubernetes.default.svc', namespace: 'guestbook' },
|
|
17
|
+
syncPolicy: automated ? { automated } : {},
|
|
18
|
+
},
|
|
19
|
+
status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } },
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function automatedSyncBadge(html: string): string | undefined {
|
|
24
|
+
return html.match(/Automated Sync<\/span>.*?<span class="badge[^"]*">([^<]+)<\/span>/)?.[1]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ArgoApplicationRenderer sync policy', () => {
|
|
28
|
+
it('reads an automated block as auto-sync on', () => {
|
|
29
|
+
const html = render(<ArgoApplicationRenderer data={app({ prune: true, selfHeal: true })} />)
|
|
30
|
+
expect(automatedSyncBadge(html)).toBe('Enabled')
|
|
31
|
+
expect(html).toContain('Self Heal')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Argo CD 3.x switches automation off in place: the block and its
|
|
35
|
+
// prune/selfHeal stay, but the controller does not auto-sync.
|
|
36
|
+
it('reads automated.enabled=false as auto-sync off', () => {
|
|
37
|
+
const html = render(<ArgoApplicationRenderer data={app({ enabled: false, prune: true, selfHeal: true })} />)
|
|
38
|
+
expect(automatedSyncBadge(html)).toBe('Disabled')
|
|
39
|
+
expect(html).not.toContain('Self Heal')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('reads a missing automated block as auto-sync off', () => {
|
|
43
|
+
const html = render(<ArgoApplicationRenderer data={app(undefined)} />)
|
|
44
|
+
expect(automatedSyncBadge(html)).toBe('Disabled')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ManagedResourcesList appends the count to its title itself.
|
|
49
|
+
describe('ArgoApplicationRenderer managed resources', () => {
|
|
50
|
+
it('shows the count once', () => {
|
|
51
|
+
const data = app(undefined)
|
|
52
|
+
const html = render(<ArgoApplicationRenderer data={{ ...data, status: { ...data.status, resources: [{ kind: 'Service', name: 'guestbook-ui', namespace: 'guestbook', status: 'Synced' }] } }} />)
|
|
53
|
+
expect(html).toContain('Managed Resources (1)')
|
|
54
|
+
expect(html).not.toContain('(1) (1)')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
5
58
|
describe('Argo Helm value files', () => {
|
|
6
59
|
it.each([true, false, undefined])('shows missing-file behavior for single and multiple sources: %s', optional => {
|
|
7
60
|
const source = { chart: 'cluster', repoURL: 'https://cloudnative-pg.github.io/charts', helm: { valueFiles: ['values.yaml', 'overrides.yaml'], ignoreMissingValueFiles: optional } }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
|
|
4
|
+
import { isArgoAutoSyncEnabled } from '../resource-utils-argo'
|
|
4
5
|
import { GitOpsStatusBadge, ManagedResourcesList, SyncCountdown } from '../../gitops'
|
|
5
6
|
import {
|
|
6
7
|
argoStatusToGitOpsStatus,
|
|
@@ -157,6 +158,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
157
158
|
const isMultiSource = Array.isArray(spec.sources) && spec.sources.length > 0
|
|
158
159
|
const destination = spec.destination || {}
|
|
159
160
|
const syncPolicy = spec.syncPolicy || {}
|
|
161
|
+
const autoSyncEnabled = isArgoAutoSyncEnabled(data)
|
|
160
162
|
const operationState = status.operationState
|
|
161
163
|
|
|
162
164
|
// Check if sync is in progress
|
|
@@ -192,7 +194,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
192
194
|
</div>
|
|
193
195
|
|
|
194
196
|
{/* Sync countdown (only if automated sync is enabled) */}
|
|
195
|
-
{
|
|
197
|
+
{autoSyncEnabled && (
|
|
196
198
|
<SyncCountdown
|
|
197
199
|
interval="5m" // ArgoCD default
|
|
198
200
|
lastSyncTime={status.reconciledAt}
|
|
@@ -241,14 +243,14 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
241
243
|
<span
|
|
242
244
|
className={clsx(
|
|
243
245
|
'badge',
|
|
244
|
-
|
|
246
|
+
autoSyncEnabled ? 'status-green' : BADGE_INACTIVE
|
|
245
247
|
)}
|
|
246
248
|
>
|
|
247
|
-
{
|
|
249
|
+
{autoSyncEnabled ? 'Enabled' : 'Disabled'}
|
|
248
250
|
</span>
|
|
249
251
|
}
|
|
250
252
|
/>
|
|
251
|
-
{
|
|
253
|
+
{autoSyncEnabled && (
|
|
252
254
|
<>
|
|
253
255
|
<Property
|
|
254
256
|
label="Self Heal"
|
|
@@ -374,10 +376,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
|
|
|
374
376
|
|
|
375
377
|
{/* Managed Resources */}
|
|
376
378
|
{managedResources.length > 0 && (
|
|
377
|
-
<ManagedResourcesList
|
|
378
|
-
resources={managedResources}
|
|
379
|
-
title={`Managed Resources (${managedResources.length})`}
|
|
380
|
-
/>
|
|
379
|
+
<ManagedResourcesList resources={managedResources} />
|
|
381
380
|
)}
|
|
382
381
|
|
|
383
382
|
{/* Revision History */}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { CRDSchemaBrowser } from './CRDSchemaBrowser'
|
|
6
|
+
import { resourceToYaml } from '../../../utils/yaml'
|
|
7
|
+
|
|
8
|
+
vi.mock('../../ui/CodeViewer', () => ({ CodeViewer: (props: any) => <pre data-theme={props.theme} data-language={props.language} data-copy={props.showCopyButton}>{props.code}</pre> }))
|
|
9
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
10
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
11
|
+
let root: ReturnType<typeof createRoot>
|
|
12
|
+
let host: HTMLDivElement
|
|
13
|
+
const schema = { type: 'object', 'x-kubernetes-validations': [{ rule: 'has(self.spec)', message: 'Spec is required' }], properties: { spec: {
|
|
14
|
+
type: 'object', description: 'Settings', required: ['name'], 'x-kubernetes-validations': [{ rule: 'self.name != ""', message: 'Choose a name' }], properties: {
|
|
15
|
+
name: { type: 'string', description: 'A display name', minLength: 1, 'x-kubernetes-validations': [{ rule: 'self != "bad"', message: 'Use a good name' }] },
|
|
16
|
+
ports: { type: 'array', items: { properties: { port: { type: 'integer', minimum: 1 } } } },
|
|
17
|
+
},
|
|
18
|
+
} } }
|
|
19
|
+
const data = { metadata: { uid: 'a', name: 'widgets.example.io' }, spec: { names: { kind: 'Widget' }, versions: [
|
|
20
|
+
{ name: 'v1', served: true, schema: { openAPIV3Schema: schema } },
|
|
21
|
+
{ name: 'v1beta1', served: true, schema: { openAPIV3Schema: { properties: { spec: { properties: { name: { type: 'integer' } } } } } } },
|
|
22
|
+
{ name: 'v1alpha1', served: true, schema: { openAPIV3Schema: { properties: { other: { type: 'boolean' } } } } },
|
|
23
|
+
{ name: 'v2', served: false },
|
|
24
|
+
] } }
|
|
25
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove() })
|
|
26
|
+
function mount(resource = data, onCopy?: (text: string) => void) {
|
|
27
|
+
if (!host?.isConnected) { host = document.createElement('div'); document.body.append(host); root = createRoot(host) }
|
|
28
|
+
act(() => root.render(<CRDSchemaBrowser data={resource} theme="light" onCopy={onCopy} />))
|
|
29
|
+
}
|
|
30
|
+
function click(text: string, within: Element = host) {
|
|
31
|
+
const button = Array.from(within.querySelectorAll('button')).find(b => b.textContent === text || b.getAttribute('aria-label') === text)
|
|
32
|
+
expect(button, text).toBeDefined(); act(() => button!.click())
|
|
33
|
+
}
|
|
34
|
+
function filter(value: string) {
|
|
35
|
+
const input = host.querySelector('input')!
|
|
36
|
+
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })) })
|
|
37
|
+
}
|
|
38
|
+
function version(name: string) {
|
|
39
|
+
act(() => host.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!.click())
|
|
40
|
+
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]')).find(b => b.textContent?.startsWith(name))!
|
|
41
|
+
act(() => option.click())
|
|
42
|
+
}
|
|
43
|
+
const detail = () => host.querySelector('[aria-label="Schema field detail"]')!
|
|
44
|
+
const outline = () => host.querySelector('[aria-label="Schema outline"]')!
|
|
45
|
+
|
|
46
|
+
it('selects independently of expansion, shows facts and parent rules, and follows child fields', () => {
|
|
47
|
+
const copy = vi.fn(); mount(data, copy)
|
|
48
|
+
expect(detail().textContent).toContain('Select a field')
|
|
49
|
+
expect(outline().textContent).not.toContain('ports')
|
|
50
|
+
click('specobject', outline())
|
|
51
|
+
expect(detail().textContent).toContain('Settings')
|
|
52
|
+
expect(detail().textContent).toContain('Choose a name')
|
|
53
|
+
expect(detail().textContent).toContain('Spec is required')
|
|
54
|
+
expect(outline().textContent).not.toContain('ports')
|
|
55
|
+
click('namestringrequired', detail())
|
|
56
|
+
expect(detail().textContent).toContain('spec.name')
|
|
57
|
+
expect(detail().textContent).toContain('minLength: 1')
|
|
58
|
+
expect(detail().textContent).toContain('Use a good name')
|
|
59
|
+
expect(detail().textContent).toContain('Rules on parent fields')
|
|
60
|
+
expect(detail().textContent).toContain('Choose a name')
|
|
61
|
+
click('Copy field path', detail()); expect(copy).toHaveBeenCalledWith('spec.name')
|
|
62
|
+
click('Expand spec', outline())
|
|
63
|
+
expect(outline().querySelector('[aria-current="true"]')?.textContent).toContain('name')
|
|
64
|
+
})
|
|
65
|
+
it('filters pasted paths, selects results and highlights only direct matches', () => {
|
|
66
|
+
mount(); filter('spec.ports[0].port')
|
|
67
|
+
expect(host.textContent).toContain('1 matching field')
|
|
68
|
+
expect(outline().querySelector('mark')).toBeNull()
|
|
69
|
+
click('spec.ports[].portinteger', outline())
|
|
70
|
+
expect(detail().textContent).toContain('spec.ports[].port')
|
|
71
|
+
expect(detail().textContent).toContain('minimum: 1')
|
|
72
|
+
filter('PORTS[]')
|
|
73
|
+
expect(outline().querySelector('mark')?.textContent).toBe('ports[]')
|
|
74
|
+
click('Clear field filter'); expect(host.querySelector('input')?.value).toBe('')
|
|
75
|
+
})
|
|
76
|
+
it('retains selection across versions only while the path exists, and resets on another CRD', () => {
|
|
77
|
+
mount(); filter('spec.name'); click('spec.namestringrequired', outline())
|
|
78
|
+
version('v1beta1')
|
|
79
|
+
expect(detail().textContent).toContain('spec.name')
|
|
80
|
+
expect(detail().textContent).toContain('integer')
|
|
81
|
+
expect(host.querySelector('input')?.value).toBe('spec.name')
|
|
82
|
+
version('v1alpha1'); expect(detail().textContent).toContain('Select a field')
|
|
83
|
+
version('v1'); expect(detail().textContent).toContain('Select a field')
|
|
84
|
+
click('Raw'); mount({ ...data, metadata: { ...data.metadata, uid: 'b' } })
|
|
85
|
+
expect(host.querySelector('input')?.value).toBe('')
|
|
86
|
+
expect(host.querySelector('button[aria-pressed="true"]')?.textContent).toBe('Fields')
|
|
87
|
+
expect(detail().textContent).toContain('Select a field')
|
|
88
|
+
})
|
|
89
|
+
it('shows the version schema as YAML with the existing raw viewer and honest missing-schema copy', () => {
|
|
90
|
+
mount(); click('Raw')
|
|
91
|
+
expect(host.querySelector('input[aria-label="Filter fields"]')).toBeNull()
|
|
92
|
+
const raw = host.querySelector('pre')!
|
|
93
|
+
expect(raw.textContent).toBe(resourceToYaml(schema))
|
|
94
|
+
expect(raw.dataset).toMatchObject({ theme: 'light', language: 'yaml', copy: 'true' })
|
|
95
|
+
version('v2')
|
|
96
|
+
expect(host.textContent).toContain("Radar didn't receive a schema for this version")
|
|
97
|
+
expect(host.querySelector('pre')).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
it('copies a path through the clipboard and handles clipboard rejection', async () => {
|
|
100
|
+
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
|
|
101
|
+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
102
|
+
mount(); click('specobject', outline())
|
|
103
|
+
await act(async () => click('Copy field path', detail()))
|
|
104
|
+
expect(writeText).toHaveBeenCalledWith('spec')
|
|
105
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Check, Copy } from 'lucide-react'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
|
|
4
|
+
import { CodeViewer } from '../../ui/CodeViewer'
|
|
5
|
+
import { SelectMenu } from '../../ui/SelectMenu'
|
|
6
|
+
import { resourceToYaml } from '../../../utils/yaml'
|
|
7
|
+
import {
|
|
8
|
+
displaySchemaPath, filterCRDSchema, indexCRDSchema, preferredCRDVersion,
|
|
9
|
+
schemaChildren, schemaLeafName, schemaTypeLabel,
|
|
10
|
+
type CRDSchema, type CRDVersion, type SchemaNode,
|
|
11
|
+
} from '../../../utils/crd-schema'
|
|
12
|
+
import { ParentRulesList, RequiredMarker, SchemaFacts, SchemaFieldFilter, SchemaPathText } from './crd-schema-parts'
|
|
13
|
+
|
|
14
|
+
export interface CRDSchemaBrowserProps {
|
|
15
|
+
data: any
|
|
16
|
+
theme?: 'light' | 'dark'
|
|
17
|
+
onCopy?: (text: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function OutlineRows({ nodes, selected, onSelect, query = '' }: { nodes: SchemaNode[]; selected?: string; onSelect: (node: SchemaNode) => void; query?: string }) {
|
|
21
|
+
const [visible, setVisible] = useState(100)
|
|
22
|
+
return <div className="space-y-0.5">
|
|
23
|
+
{nodes.slice(0, query ? 200 : visible).map(node => <OutlineRow key={displaySchemaPath(node.path)} node={node} selected={selected} onSelect={onSelect} query={query} />)}
|
|
24
|
+
{!query && nodes.length > visible && <button type="button" className="py-1 text-xs text-accent" onClick={() => setVisible(n => n + 100)}>Show {Math.min(100, nodes.length - visible)} more</button>}
|
|
25
|
+
</div>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function OutlineRow({ node, selected, onSelect, query }: { node: SchemaNode; selected?: string; onSelect: (node: SchemaNode) => void; query: string }) {
|
|
29
|
+
const [open, setOpen] = useState(false)
|
|
30
|
+
const disclosure = useDisclosure(open)
|
|
31
|
+
const children = useMemo(() => schemaChildren(node.schema, node.path, node), [node])
|
|
32
|
+
const path = displaySchemaPath(node.path)
|
|
33
|
+
return <div>
|
|
34
|
+
<div className={`flex items-start rounded ${selected === path ? 'selection' : 'hover:bg-theme-hover'}`}>
|
|
35
|
+
{!query && children.length > 0 ? <button type="button" {...disclosure.buttonProps} aria-label={`Expand ${path}`} onClick={() => setOpen(v => !v)} className="shrink-0 p-2"><CollapseChevron open={open} className="h-3 w-3" /></button> : <span className="w-7 shrink-0" />}
|
|
36
|
+
<button type="button" aria-current={selected === path ? 'true' : undefined} onClick={() => onSelect(node)} className="min-w-0 flex-1 py-1.5 pr-2 text-left text-sm">
|
|
37
|
+
{query && <SchemaPathText path={node.path} query={query} part="parent" />}
|
|
38
|
+
<span className="flex flex-wrap items-baseline gap-x-2">
|
|
39
|
+
<span className="break-all font-mono text-theme-text-primary">{query ? <SchemaPathText path={node.path} query={query} part="leaf" /> : schemaLeafName(node.path)}</span>
|
|
40
|
+
<span className="text-xs text-theme-text-tertiary">{schemaTypeLabel(node.schema)}</span>
|
|
41
|
+
{node.required && <RequiredMarker />}
|
|
42
|
+
</span>
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
{!query && children.length > 0 && <Collapse open={open} id={disclosure.panelId} mountLazily><div className="border-l border-theme-border pl-3"><OutlineRows nodes={children} selected={selected} onSelect={onSelect} /></div></Collapse>}
|
|
46
|
+
</div>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ChildFields({ nodes, onSelect }: { nodes: SchemaNode[]; onSelect: (node: SchemaNode) => void }) {
|
|
50
|
+
const [visible, setVisible] = useState(100)
|
|
51
|
+
return <div className="space-y-1">
|
|
52
|
+
<h3 className="text-sm font-medium text-theme-text-primary">Fields</h3>
|
|
53
|
+
{nodes.slice(0, visible).map(node => <button key={displaySchemaPath(node.path)} type="button" onClick={() => onSelect(node)} className="flex w-full flex-wrap items-baseline gap-2 rounded px-2 py-1 text-left hover:bg-theme-hover">
|
|
54
|
+
<span className="break-all font-mono text-sm text-accent">{schemaLeafName(node.path)}</span><span className="text-xs text-theme-text-tertiary">{schemaTypeLabel(node.schema)}</span>{node.required && <RequiredMarker />}
|
|
55
|
+
</button>)}
|
|
56
|
+
{nodes.length > visible && <button type="button" onClick={() => setVisible(n => n + 100)} className="text-xs text-accent">Show {Math.min(100, nodes.length - visible)} more</button>}
|
|
57
|
+
</div>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function FieldDetail({ node, schema, onSelect, onCopy }: { node: SchemaNode; schema: CRDSchema; onSelect: (node: SchemaNode) => void; onCopy?: (text: string) => void }) {
|
|
61
|
+
const path = displaySchemaPath(node.path)
|
|
62
|
+
const children = useMemo(() => schemaChildren(node.schema, node.path, node), [node])
|
|
63
|
+
const [copied, setCopied] = useState(false)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!copied) return
|
|
66
|
+
const timer = setTimeout(() => setCopied(false), 1500)
|
|
67
|
+
return () => clearTimeout(timer)
|
|
68
|
+
}, [copied])
|
|
69
|
+
return <div className="space-y-4 text-sm text-theme-text-secondary">
|
|
70
|
+
<div className="flex items-start gap-2">
|
|
71
|
+
<h2 className="min-w-0 flex-1 break-all font-mono text-theme-text-primary">{path}</h2>
|
|
72
|
+
<button type="button" aria-label="Copy field path" className="flex shrink-0 items-center gap-1 text-xs hover:text-theme-text-primary" onClick={async () => {
|
|
73
|
+
try {
|
|
74
|
+
if (onCopy) onCopy(path)
|
|
75
|
+
else await navigator.clipboard.writeText(path)
|
|
76
|
+
setCopied(true)
|
|
77
|
+
} catch { /* Clipboard access can be denied by the browser. */ }
|
|
78
|
+
}}>{copied ? <><Check className="h-3 w-3" />Copied</> : <><Copy className="h-3 w-3" />Copy</>}</button>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex items-baseline gap-2"><span className="font-mono">{schemaTypeLabel(node.schema)}</span>{node.required && <RequiredMarker />}</div>
|
|
81
|
+
<SchemaFacts schema={node.schema} />
|
|
82
|
+
<ParentRulesList node={node} root={schema} />
|
|
83
|
+
{children.length > 0 && <ChildFields key={path} nodes={children} onSelect={onSelect} />}
|
|
84
|
+
</div>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function CRDSchemaBrowser(props: CRDSchemaBrowserProps) {
|
|
88
|
+
return <SchemaBrowser key={JSON.stringify([props.data.metadata?.uid, props.data.metadata?.name])} {...props} />
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function SchemaBrowser({ data, theme, onCopy }: CRDSchemaBrowserProps) {
|
|
92
|
+
const versions: CRDVersion[] = data.spec?.versions ?? []
|
|
93
|
+
const [picked, setPicked] = useState(() => preferredCRDVersion(versions)?.name)
|
|
94
|
+
const version = versions.find(v => v.name === picked) ?? preferredCRDVersion(versions)
|
|
95
|
+
const schema = version?.schema?.openAPIV3Schema
|
|
96
|
+
const [filter, setFilter] = useState('')
|
|
97
|
+
const [mode, setMode] = useState<'fields' | 'raw'>('fields')
|
|
98
|
+
const [selection, setSelection] = useState<SchemaNode>()
|
|
99
|
+
const needsIndex = Boolean(filter.trim() || selection)
|
|
100
|
+
const index = useMemo(() => needsIndex ? indexCRDSchema(schema) : [], [schema, needsIndex])
|
|
101
|
+
const path = selection && displaySchemaPath(selection.path)
|
|
102
|
+
const selected = selection && index.find(node => displaySchemaPath(node.path) === path)
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (selection && !selected) setSelection(undefined)
|
|
105
|
+
}, [selection, selected])
|
|
106
|
+
const nodes = useMemo(() => schema ? schemaChildren(schema) : [], [schema])
|
|
107
|
+
const results = filter.trim() ? filterCRDSchema(index, filter) : undefined
|
|
108
|
+
return <div className="min-w-0">
|
|
109
|
+
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b border-theme-border bg-theme-surface p-3">
|
|
110
|
+
{mode === 'fields' ? <SchemaFieldFilter value={filter} onChange={setFilter} /> : <p className="min-w-0 flex-1 text-xs text-theme-text-tertiary">The {version?.name} schema exactly as the CRD declares it</p>}
|
|
111
|
+
{versions.length > 1 && <div className="flex items-center gap-2 text-xs text-theme-text-secondary"><span>Version</span><SelectMenu ariaLabel="Schema version" value={version?.name ?? ''} onChange={setPicked} options={versions.map(v => ({ value: v.name, label: v.name, description: [v.storage && 'storage', v.deprecated && 'deprecated', !v.served && 'not served', !v.schema?.openAPIV3Schema && 'schema not received'].filter(Boolean).join(' · ') }))} /></div>}
|
|
112
|
+
<div className="flex rounded-md border border-theme-border bg-theme-base p-0.5 text-xs">
|
|
113
|
+
{(['fields', 'raw'] as const).map(value => <button key={value} type="button" aria-pressed={mode === value} onClick={() => setMode(value)} className={`rounded px-2 py-1 ${mode === value ? 'bg-theme-elevated text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary'}`}>{value === 'fields' ? 'Fields' : 'Raw'}</button>)}
|
|
114
|
+
</div>
|
|
115
|
+
{mode === 'fields' && results && <p className="text-xs text-theme-text-tertiary">{results.total} matching {results.total === 1 ? 'field' : 'fields'}{results.total > 200 ? ' — showing 200; narrow the filter' : ''}</p>}
|
|
116
|
+
</div>
|
|
117
|
+
{!schema ? <p className="p-4 text-sm text-theme-text-tertiary">Radar didn't receive a schema for this version. The YAML tab shows the object as returned.</p>
|
|
118
|
+
: mode === 'raw' ? <CodeViewer code={resourceToYaml(schema)} language="yaml" showCopyButton theme={theme} onCopy={onCopy} />
|
|
119
|
+
: <div className="grid min-w-0 md:grid-cols-[2fr_3fr]">
|
|
120
|
+
<div aria-label="Schema outline" className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto border-b border-theme-border p-3 md:border-b-0 md:border-r">
|
|
121
|
+
<OutlineRows key={`${version?.name}:${results ? 'filter' : 'tree'}`} nodes={results?.nodes ?? nodes} selected={selected && displaySchemaPath(selected.path)} onSelect={setSelection} query={filter.trim()} />
|
|
122
|
+
{!results && nodes.length === 0 && <p className="text-sm text-theme-text-tertiary">This schema declares no fields.</p>}
|
|
123
|
+
</div>
|
|
124
|
+
<div aria-label="Schema field detail" className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto p-4">
|
|
125
|
+
{selected ? <FieldDetail key={`${version?.name}:${path}`} node={selected} schema={schema} onSelect={setSelection} onCopy={onCopy} /> : <div className="space-y-4 text-sm text-theme-text-secondary">
|
|
126
|
+
<h2 className="font-medium text-theme-text-primary">{data.spec?.names?.kind} · {version?.name}</h2>
|
|
127
|
+
<p>Select a field to see its rules, or paste a path from an API error into the filter.</p>
|
|
128
|
+
<SchemaFacts schema={schema} />
|
|
129
|
+
<ChildFields key={version?.name} nodes={nodes} onSelect={setSelection} />
|
|
130
|
+
</div>}
|
|
131
|
+
</div>
|
|
132
|
+
</div>}
|
|
133
|
+
</div>
|
|
134
|
+
}
|