@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { expect, it } from 'vitest'
|
|
4
|
+
import { CRDVersionsCell } from './crd-cells'
|
|
5
|
+
|
|
6
|
+
it('sorts served versions, shows two complete tokens and summarizes the rest', () => {
|
|
7
|
+
const container = document.createElement('div')
|
|
8
|
+
container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[
|
|
9
|
+
{ name: 'v2alpha1', served: true, deprecated: true }, { name: 'v1beta1', served: true },
|
|
10
|
+
{ name: 'v1', served: true, storage: true }, { name: 'v2', served: true }, { name: 'v3', served: false },
|
|
11
|
+
]} />)
|
|
12
|
+
expect(Array.from(container.querySelectorAll('.font-mono')).map(el => el.textContent)).toEqual(['v2', 'v1'])
|
|
13
|
+
expect(container.textContent).toBe('v2v1+2')
|
|
14
|
+
expect(container.querySelector('[class*="overflow-hidden"]')).toBeNull()
|
|
15
|
+
for (const token of container.querySelectorAll('.font-mono')) {
|
|
16
|
+
expect(token.parentElement?.classList.contains('shrink-0')).toBe(true)
|
|
17
|
+
expect(token.parentElement?.classList.contains('whitespace-nowrap')).toBe(true)
|
|
18
|
+
}
|
|
19
|
+
container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[
|
|
20
|
+
{ name: 'v1beta2', served: true }, { name: 'v1beta1', served: true, deprecated: true },
|
|
21
|
+
]} />)
|
|
22
|
+
expect(container.textContent).toBe('v1beta2v1beta1deprecated')
|
|
23
|
+
container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[
|
|
24
|
+
{ name: 'v1beta2', served: true, deprecated: true }, { name: 'v1beta1', served: true, deprecated: true },
|
|
25
|
+
{ name: 'v1alpha1', served: true },
|
|
26
|
+
]} />)
|
|
27
|
+
expect(container.textContent).toBe('v1beta2deprecated+2')
|
|
28
|
+
container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[]} />)
|
|
29
|
+
expect(container.textContent).toBe('None served')
|
|
30
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Badge } from '../../ui/Badge'
|
|
2
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
3
|
+
import { compareKubeAwareVersions, type CRDVersion } from '../../../utils/crd-schema'
|
|
4
|
+
|
|
5
|
+
export function CRDVersionLabel({ version, showStorage = true }: { version: CRDVersion; showStorage?: boolean }) {
|
|
6
|
+
return <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap">
|
|
7
|
+
<span className="font-mono">{version.name}</span>
|
|
8
|
+
{showStorage && version.storage && <Tooltip content="Objects are saved in this version; the API server converts them to the others on read">
|
|
9
|
+
<Badge tone="structural" size="sm">storage</Badge>
|
|
10
|
+
</Tooltip>}
|
|
11
|
+
{version.deprecated && <Tooltip content={version.deprecationWarning || 'Requests for this version return a deprecation warning'}>
|
|
12
|
+
<Badge severity="warning" size="sm">deprecated</Badge>
|
|
13
|
+
</Tooltip>}
|
|
14
|
+
{!version.served && <Tooltip content="The API server doesn't serve this version, so clients can't read or write it">
|
|
15
|
+
<Badge severity="neutral" size="sm">not served</Badge>
|
|
16
|
+
</Tooltip>}
|
|
17
|
+
</span>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Rendered widths (px) measured in the list: a version name character, the
|
|
21
|
+
// deprecated badge with its gap, the gap between tokens, the "+N" count, and the
|
|
22
|
+
// Served Versions column's content width (w-64 less its padding).
|
|
23
|
+
const VERSION_CHAR_PX = 8.5
|
|
24
|
+
const DEPRECATED_BADGE_PX = 80
|
|
25
|
+
const TOKEN_GAP_PX = 8
|
|
26
|
+
const COUNT_PX = 24
|
|
27
|
+
const VERSIONS_CELL_CONTENT_PX = 224
|
|
28
|
+
|
|
29
|
+
function versionTokenWidth(version: CRDVersion): number {
|
|
30
|
+
return version.name.length * VERSION_CHAR_PX + (version.deprecated ? DEPRECATED_BADGE_PX : 0)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One line per row and never a partial version: storage is left to the drawer,
|
|
34
|
+
// and versions that would not fit whole collapse into a count.
|
|
35
|
+
export function CRDVersionsCell({ versions }: { versions: CRDVersion[] }) {
|
|
36
|
+
const served = versions.filter(v => v.served).sort((a, b) => compareKubeAwareVersions(a.name, b.name))
|
|
37
|
+
if (served.length === 0) return <span className="text-sm text-theme-text-tertiary">None served</span>
|
|
38
|
+
let used = 0
|
|
39
|
+
let shown = 0
|
|
40
|
+
for (const version of served.slice(0, 2)) {
|
|
41
|
+
const width = versionTokenWidth(version) + (shown ? TOKEN_GAP_PX : 0)
|
|
42
|
+
const hiddenAfter = served.length - shown - 1
|
|
43
|
+
const budget = VERSIONS_CELL_CONTENT_PX - (hiddenAfter > 0 ? COUNT_PX + TOKEN_GAP_PX : 0)
|
|
44
|
+
if (shown && used + width > budget) break
|
|
45
|
+
used += width
|
|
46
|
+
shown++
|
|
47
|
+
}
|
|
48
|
+
const rest = served.slice(shown)
|
|
49
|
+
return <div className="flex items-center gap-2 whitespace-nowrap text-sm text-theme-text-secondary">
|
|
50
|
+
{served.slice(0, shown).map(v => <CRDVersionLabel key={v.name} version={v} showStorage={false} />)}
|
|
51
|
+
{rest.length > 0 && <Tooltip wrapperClassName="shrink-0 whitespace-nowrap" content={rest.map(v => [v.name, v.storage && 'storage', v.deprecated && (v.deprecationWarning ? `deprecated: ${v.deprecationWarning}` : 'deprecated')].filter(Boolean).join(' · ')).join('; ')}>
|
|
52
|
+
<span className="shrink-0 whitespace-nowrap">+{rest.length}</span>
|
|
53
|
+
</Tooltip>}
|
|
54
|
+
</div>
|
|
55
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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 { ParentRulesList, SchemaFacts, SchemaPathText } from './crd-schema-parts'
|
|
6
|
+
import { indexCRDSchema } from '../../../utils/crd-schema'
|
|
7
|
+
|
|
8
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
9
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
10
|
+
let root: ReturnType<typeof createRoot>
|
|
11
|
+
let host: HTMLDivElement
|
|
12
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove() })
|
|
13
|
+
function mount(content: React.ReactNode) {
|
|
14
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
15
|
+
act(() => root.render(content))
|
|
16
|
+
}
|
|
17
|
+
it('renders messages before their expressions, including parent and root rules, and discloses grouped rules', () => {
|
|
18
|
+
const schema = { 'x-kubernetes-validations': [{ rule: 'has(self.spec)', message: 'Spec must exist' }], properties: { spec: {
|
|
19
|
+
'x-kubernetes-validations': Array.from({ length: 5 }, (_, i) => ({ rule: `self.x > ${i}`, message: `Message ${i}` })), properties: { x: { type: 'integer' } },
|
|
20
|
+
} } }
|
|
21
|
+
const node = indexCRDSchema(schema).find(node => node.path.length === 2)!
|
|
22
|
+
mount(<><SchemaFacts schema={{ 'x-kubernetes-validations': [{ rule: 'self > 0', message: 'Positive value' }, { rule: 'self < 10' }] }} /><ParentRulesList node={node} root={schema} /></>)
|
|
23
|
+
const own = host.querySelector('.card-inner')!
|
|
24
|
+
expect(own.firstElementChild?.textContent).toBe('Positive value')
|
|
25
|
+
expect(own.lastElementChild?.textContent).toBe('self > 0')
|
|
26
|
+
expect(host.textContent).toContain('5 rules on spec')
|
|
27
|
+
expect(host.textContent).toContain('1 rule on Document root')
|
|
28
|
+
const rootGroup = Array.from(host.querySelectorAll('button')).find(b => b.textContent?.includes('1 rule on Document root'))!
|
|
29
|
+
expect(rootGroup.getAttribute('aria-expanded')).toBe('false')
|
|
30
|
+
expect(host.textContent).not.toContain('Spec must exist')
|
|
31
|
+
act(() => rootGroup.click())
|
|
32
|
+
expect(host.textContent).toContain('Spec must exist')
|
|
33
|
+
expect(host.textContent).not.toContain('Message 3')
|
|
34
|
+
const button = Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Show 2 more')!
|
|
35
|
+
expect(button.getAttribute('aria-expanded')).toBe('false')
|
|
36
|
+
act(() => button.click())
|
|
37
|
+
expect(button.getAttribute('aria-expanded')).toBe('true')
|
|
38
|
+
expect(host.textContent).toContain('Message 4')
|
|
39
|
+
for (const card of host.querySelectorAll('.card-inner')) {
|
|
40
|
+
if (card.children.length === 2) expect(card.firstElementChild?.tagName).toBe('P')
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
it('uses labeled code blocks for long constraints and structured defaults while short scalars remain badges', () => {
|
|
44
|
+
mount(<SchemaFacts schema={{ default: { x: 1 }, pattern: 'x'.repeat(41), minimum: 0 }} />)
|
|
45
|
+
expect(Array.from(host.querySelectorAll('pre')).map(p => p.textContent)).toEqual(['{"x":1}', 'x'.repeat(41)])
|
|
46
|
+
expect(Array.from(host.querySelectorAll('pre')).map(p => p.previousElementSibling?.textContent)).toEqual(['default', 'pattern'])
|
|
47
|
+
expect(host.querySelector('.badge-sm')?.textContent).toContain('minimum: 0')
|
|
48
|
+
})
|
|
49
|
+
it('highlights only direct case-insensitive substrings of the displayed path', () => {
|
|
50
|
+
const node = indexCRDSchema({ properties: { ports: { items: { properties: { name: { type: 'string' } } } } } })[1]
|
|
51
|
+
const both = (query: string) => <><SchemaPathText path={node.path} query={query} part="parent" /><SchemaPathText path={node.path} query={query} part="leaf" /></>
|
|
52
|
+
mount(both('PORTS[]'))
|
|
53
|
+
expect(host.querySelector('mark')?.textContent).toBe('ports[]')
|
|
54
|
+
act(() => root.render(both('nam')))
|
|
55
|
+
expect(host.querySelector('mark')?.textContent).toBe('nam')
|
|
56
|
+
act(() => root.render(both('ports[0].name')))
|
|
57
|
+
expect(host.querySelector('mark')).toBeNull()
|
|
58
|
+
expect(host.textContent).toBe('ports[].name')
|
|
59
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { Search, X } from 'lucide-react'
|
|
3
|
+
import { Badge } from '../../ui/Badge'
|
|
4
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
|
|
5
|
+
import { Input } from '../../ui/Input'
|
|
6
|
+
import {
|
|
7
|
+
displaySchemaPath, enclosingRules, schemaCompositions, schemaConstraints, schemaElement, schemaParentPath,
|
|
8
|
+
type CRDSchema, type EnclosingRules, type SchemaNode, type SchemaPath,
|
|
9
|
+
} from '../../../utils/crd-schema'
|
|
10
|
+
|
|
11
|
+
export function RequiredMarker() {
|
|
12
|
+
return <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">required</span>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SchemaFieldFilter({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
|
16
|
+
return <div className="relative min-w-0 flex-1">
|
|
17
|
+
<Search aria-hidden="true" className="pointer-events-none absolute left-2.5 top-2 h-4 w-4 text-theme-text-tertiary" />
|
|
18
|
+
<Input aria-label="Filter fields" placeholder="Find a field or paste an error path" value={value} onChange={e => onChange(e.target.value)} className="h-8 w-full min-w-0 rounded-md border border-theme-border bg-theme-elevated pl-8 pr-8 text-xs text-theme-text-primary placeholder:text-theme-text-tertiary focus:border-accent focus:outline-none" />
|
|
19
|
+
{value && <button type="button" aria-label="Clear field filter" onClick={() => onChange('')} className="absolute right-2 top-2 text-theme-text-tertiary hover:text-theme-text-primary"><X className="h-4 w-4" /></button>}
|
|
20
|
+
</div>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Renders text[from, to) with the matched range marked and a break opportunity
|
|
24
|
+
// after each `.`, so long paths wrap between segments instead of mid-name.
|
|
25
|
+
function PathPiece({ text, from, to, match }: { text: string; from: number; to: number; match: [number, number] | null }) {
|
|
26
|
+
const parts: ReactNode[] = []
|
|
27
|
+
const pushPlain = (start: number, end: number) => {
|
|
28
|
+
text.slice(start, end).split(/(?<=\.)/).forEach((piece, i, all) => {
|
|
29
|
+
parts.push(<span key={`${start}-${i}`}>{piece}</span>)
|
|
30
|
+
if (piece.endsWith('.') && i < all.length - 1) parts.push(<wbr key={`${start}-${i}-w`} />)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
const [ms, me] = match ? [Math.max(match[0], from), Math.min(match[1], to)] : [0, 0]
|
|
34
|
+
if (!match || ms >= me) { pushPlain(from, to); return <>{parts}</> }
|
|
35
|
+
pushPlain(from, ms)
|
|
36
|
+
parts.push(<mark key="m" className="rounded-sm bg-accent-muted text-theme-text-primary">{text.slice(ms, me)}</mark>)
|
|
37
|
+
pushPlain(me, to)
|
|
38
|
+
return <>{parts}</>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One part of a search hit's path, with the query's match marked across the
|
|
42
|
+
* parent/field boundary: the muted parent path, or the field name itself. */
|
|
43
|
+
export function SchemaPathText({ path, query = '', part }: { path: SchemaPath; query?: string; part: 'parent' | 'leaf' }) {
|
|
44
|
+
const text = displaySchemaPath(path)
|
|
45
|
+
const parentLength = schemaParentPath(path).length
|
|
46
|
+
const term = query.trim().toLowerCase()
|
|
47
|
+
const at = term ? text.toLowerCase().indexOf(term) : -1
|
|
48
|
+
const match: [number, number] | null = at < 0 ? null : [at, at + term.length]
|
|
49
|
+
if (part === 'parent') {
|
|
50
|
+
return parentLength > 0 ? <span className="block font-mono text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"><PathPiece text={text} from={0} to={parentLength} match={match} /></span> : null
|
|
51
|
+
}
|
|
52
|
+
return <PathPiece text={text} from={parentLength} to={text.length} match={match} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function SchemaRules({ rules }: { rules: NonNullable<CRDSchema['x-kubernetes-validations']> }) {
|
|
56
|
+
return <>{rules.map((validation, i) => <div key={i} className="card-inner space-y-1">
|
|
57
|
+
{validation.message && <p className="text-sm text-theme-text-secondary">{validation.message}</p>}
|
|
58
|
+
<div className={`whitespace-pre-wrap break-words font-mono text-xs ${validation.message ? 'text-theme-text-tertiary' : 'text-theme-text-secondary'}`}>{validation.rule}</div>
|
|
59
|
+
</div>)}</>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ConstraintDisplay({ label, value, block }: { label: string; value: string; block: boolean }) {
|
|
63
|
+
return block ? <div className="min-w-0 w-full space-y-1">
|
|
64
|
+
<p className="text-xs text-theme-text-secondary">{label}</p>
|
|
65
|
+
<pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words rounded bg-theme-elevated p-3 font-mono text-xs text-theme-text-secondary">{value}</pre>
|
|
66
|
+
</div> : <Badge tone="structural" size="sm">{label}: {value}</Badge>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function SchemaFacts({ schema, elementOf, parentDescription, showDescription = true }: { schema: CRDSchema; elementOf?: string; parentDescription?: string; showDescription?: boolean }) {
|
|
70
|
+
const element = schemaElement(schema)
|
|
71
|
+
const prefix = elementOf ? `${elementOf} ` : ''
|
|
72
|
+
return <>
|
|
73
|
+
{showDescription && schema.description && schema.description !== parentDescription && <p className="whitespace-pre-wrap">{elementOf ? `Each entry (${elementOf}): ${schema.description}` : schema.description}</p>}
|
|
74
|
+
<div className="flex flex-wrap gap-1">{schemaConstraints(schema).map(fact => <ConstraintDisplay key={fact.label} label={`${prefix}${fact.label}`} value={fact.value} block={fact.value.length > 40 || (fact.label === 'default' && schema.default !== null && typeof schema.default === 'object')} />)}</div>
|
|
75
|
+
{schema['x-kubernetes-embedded-resource'] && <p>{elementOf ? 'Each entry is' : 'Holds'} a Kubernetes object: apiVersion, kind and metadata are implied.</p>}
|
|
76
|
+
{schema['x-kubernetes-preserve-unknown-fields'] && <p>{elementOf ? 'Each entry accepts' : 'Accepts'} fields not declared in this schema.</p>}
|
|
77
|
+
{schema['x-kubernetes-int-or-string'] && <p>{elementOf ? 'Each entry is' : 'Accepts'} an integer or a string.</p>}
|
|
78
|
+
<SchemaRules rules={schema['x-kubernetes-validations'] ?? []} />
|
|
79
|
+
{schemaCompositions(schema).map(composition => <div key={composition.label}>
|
|
80
|
+
<span>{prefix}{composition.label}: </span><code className="break-words whitespace-pre-wrap">{composition.value}</code>
|
|
81
|
+
</div>)}
|
|
82
|
+
{element && <SchemaFacts schema={element.schema} elementOf={`${prefix}${element.label}`} parentDescription={schema.description ?? parentDescription} />}
|
|
83
|
+
</>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ParentRuleGroup({ group, defaultOpen }: { group: EnclosingRules; defaultOpen: boolean }) {
|
|
87
|
+
const [groupOpen, setGroupOpen] = useState(defaultOpen)
|
|
88
|
+
const [moreOpen, setMoreOpen] = useState(false)
|
|
89
|
+
const groupDisclosure = useDisclosure(groupOpen)
|
|
90
|
+
const moreDisclosure = useDisclosure(moreOpen)
|
|
91
|
+
const count = group.validations.length + group.compositions.length
|
|
92
|
+
const rules = [
|
|
93
|
+
...group.validations.map((rule, i) => <SchemaRules key={`v${i}`} rules={[rule]} />),
|
|
94
|
+
...group.compositions.map(composition => <div key={composition.label}><span>{composition.label}: </span><code className="whitespace-pre-wrap break-words">{composition.value}</code></div>),
|
|
95
|
+
]
|
|
96
|
+
return <div className="space-y-1">
|
|
97
|
+
<button type="button" {...groupDisclosure.buttonProps} onClick={() => setGroupOpen(v => !v)} className="flex items-start gap-1 text-left text-xs text-theme-text-tertiary hover:text-theme-text-secondary">
|
|
98
|
+
<CollapseChevron open={groupOpen} className="mt-0.5 h-3 w-3 shrink-0" />
|
|
99
|
+
<span className="break-words">{count} {count === 1 ? 'rule' : 'rules'} on <span className="font-mono">{group.path || 'Document root'}</span></span>
|
|
100
|
+
</button>
|
|
101
|
+
<Collapse open={groupOpen} id={groupDisclosure.panelId} mountLazily>
|
|
102
|
+
<div className="space-y-1">
|
|
103
|
+
{rules.slice(0, 3)}
|
|
104
|
+
{count > 3 && <>
|
|
105
|
+
<button type="button" {...moreDisclosure.buttonProps} onClick={() => setMoreOpen(v => !v)} className="text-xs text-accent">{moreOpen ? 'Show fewer' : `Show ${count - 3} more`}</button>
|
|
106
|
+
<Collapse open={moreOpen} id={moreDisclosure.panelId} mountLazily><div className="space-y-1">{rules.slice(3)}</div></Collapse>
|
|
107
|
+
</>}
|
|
108
|
+
</div>
|
|
109
|
+
</Collapse>
|
|
110
|
+
</div>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The nearest parent's rules are the likeliest to concern the field; farther
|
|
114
|
+
// groups start collapsed to their path and count.
|
|
115
|
+
export function ParentRulesList({ node, root }: { node: SchemaNode; root: CRDSchema }) {
|
|
116
|
+
const groups = enclosingRules(node, root)
|
|
117
|
+
return groups.length ? <div className="space-y-2">
|
|
118
|
+
<p className="text-xs font-medium text-theme-text-tertiary">Rules on parent fields</p>
|
|
119
|
+
{groups.map((group, i) => <ParentRuleGroup key={group.path} group={group} defaultOpen={i === 0} />)}
|
|
120
|
+
</div> : null
|
|
121
|
+
}
|
|
@@ -195,3 +195,9 @@ export { RayJobRenderer } from './RayJobRenderer'
|
|
|
195
195
|
|
|
196
196
|
export { OpenTelemetryCollectorRenderer, OpenTelemetryInstrumentationRenderer } from './OpenTelemetryRenderers'
|
|
197
197
|
export { FlaggerCanaryRenderer, FlaggerMetricTemplateRenderer } from './FlaggerRenderers'
|
|
198
|
+
|
|
199
|
+
export { StrimziConnectorRenderer } from './StrimziConnectorRenderer'
|
|
200
|
+
|
|
201
|
+
export { CustomResourceDefinitionRenderer } from './CustomResourceDefinitionRenderer'
|
|
202
|
+
|
|
203
|
+
export { CRDSchemaBrowser, type CRDSchemaBrowserProps } from './CRDSchemaBrowser'
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getClusterQueueCohort,
|
|
7
7
|
getClusterQueuePendingWorkloads,
|
|
8
8
|
getClusterQueueAdmittedWorkloads,
|
|
9
|
+
getKueueQueueReservingWorkloads,
|
|
9
10
|
getClusterQueueFlavors,
|
|
10
11
|
getLocalQueueStatus,
|
|
11
12
|
getLocalQueueClusterQueue,
|
|
@@ -42,6 +43,8 @@ export function ClusterQueueCell({ resource, column }: { resource: any; column:
|
|
|
42
43
|
const pending = getClusterQueuePendingWorkloads(resource)
|
|
43
44
|
return <span className="text-sm text-theme-text-secondary">{pending}</span>
|
|
44
45
|
}
|
|
46
|
+
case 'reservingWorkloads':
|
|
47
|
+
return <span className="text-sm text-theme-text-secondary">{getKueueQueueReservingWorkloads(resource)}</span>
|
|
45
48
|
case 'admittedWorkloads': {
|
|
46
49
|
const admitted = getClusterQueueAdmittedWorkloads(resource)
|
|
47
50
|
return <span className="text-sm text-theme-text-secondary">{admitted}</span>
|
|
@@ -73,6 +76,8 @@ export function LocalQueueCell({ resource, column }: { resource: any; column: st
|
|
|
73
76
|
const pending = getLocalQueuePendingWorkloads(resource)
|
|
74
77
|
return <span className="text-sm text-theme-text-secondary">{pending}</span>
|
|
75
78
|
}
|
|
79
|
+
case 'reservingWorkloads':
|
|
80
|
+
return <span className="text-sm text-theme-text-secondary">{getKueueQueueReservingWorkloads(resource)}</span>
|
|
76
81
|
case 'admittedWorkloads': {
|
|
77
82
|
const admitted = getLocalQueueAdmittedWorkloads(resource)
|
|
78
83
|
return <span className="text-sm text-theme-text-secondary">{admitted}</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { getArgoApplicationStatus, isArgoOperationInProgress } from './resource-utils-argo'
|
|
2
|
+
import { argoComparedRevisionCurrent, argoResumeSettings, getArgoApplicationStatus, isArgoAutoSyncEnabled, isArgoAutoSyncSwitchedOff, isArgoOperationInProgress } from './resource-utils-argo'
|
|
3
3
|
|
|
4
4
|
describe('getArgoApplicationStatus', () => {
|
|
5
5
|
// A Suspended Argo app is intentionally paused — neutral (sky), matching the
|
|
@@ -21,6 +21,16 @@ describe('getArgoApplicationStatus', () => {
|
|
|
21
21
|
expect(badge.level).toBe('unhealthy')
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
// Radar's annotations plus automation switched off in place still read as
|
|
25
|
+
// suspended; the same annotations on an app that auto-syncs again do not.
|
|
26
|
+
it('treats a Radar-suspended app with automated.enabled=false as suspended', () => {
|
|
27
|
+
const annotations = { 'radarhq.io/suspended-prune': 'true' }
|
|
28
|
+
const off = { metadata: { annotations }, spec: { syncPolicy: { automated: { enabled: false } } }, status: {} }
|
|
29
|
+
const on = { metadata: { annotations }, spec: { syncPolicy: { automated: { prune: true } } }, status: {} }
|
|
30
|
+
expect(getArgoApplicationStatus(off).text).toBe('Suspended')
|
|
31
|
+
expect(getArgoApplicationStatus(on).text).not.toBe('Suspended')
|
|
32
|
+
})
|
|
33
|
+
|
|
24
34
|
it('surfaces an operation being terminated', () => {
|
|
25
35
|
const badge = getArgoApplicationStatus({ status: { operationState: { phase: 'Terminating' } } })
|
|
26
36
|
expect(badge.level).toBe('degraded')
|
|
@@ -38,3 +48,86 @@ describe('isArgoOperationInProgress', () => {
|
|
|
38
48
|
expect(isArgoOperationInProgress(app)).toBe(expected)
|
|
39
49
|
})
|
|
40
50
|
})
|
|
51
|
+
|
|
52
|
+
// Mirrors gitops.ArgoAutoSyncEnabled and Argo's SyncPolicy.IsAutomatedSyncEnabled:
|
|
53
|
+
// Argo CD 3.x keeps the automated block (and its prune/selfHeal) when
|
|
54
|
+
// automation is switched off with enabled: false.
|
|
55
|
+
describe('isArgoAutoSyncEnabled', () => {
|
|
56
|
+
const app = (syncPolicy: unknown) => ({ spec: { syncPolicy } })
|
|
57
|
+
it.each([
|
|
58
|
+
['no sync policy', undefined, false],
|
|
59
|
+
['no automated block', {}, false],
|
|
60
|
+
['automated: null', { automated: null }, false],
|
|
61
|
+
['empty automated block', { automated: {} }, true],
|
|
62
|
+
['automated with prune/selfHeal', { automated: { prune: true, selfHeal: true } }, true],
|
|
63
|
+
['enabled: true', { automated: { enabled: true } }, true],
|
|
64
|
+
['enabled: false', { automated: { enabled: false, prune: true, selfHeal: true } }, false],
|
|
65
|
+
])('%s', (_label, syncPolicy, expected) => {
|
|
66
|
+
expect(isArgoAutoSyncEnabled(app(syncPolicy))).toBe(expected)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('tolerates a missing resource', () => {
|
|
70
|
+
expect(isArgoAutoSyncEnabled(undefined)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('isArgoAutoSyncSwitchedOff', () => {
|
|
75
|
+
const app = (syncPolicy: unknown) => ({ spec: { syncPolicy } })
|
|
76
|
+
// Only an automated block kept with enabled: false; a Manual app has none.
|
|
77
|
+
it.each([
|
|
78
|
+
['no automated block', {}, false],
|
|
79
|
+
['automated: null', { automated: null }, false],
|
|
80
|
+
['automated on', { automated: { prune: true } }, false],
|
|
81
|
+
['enabled: true', { automated: { enabled: true } }, false],
|
|
82
|
+
['enabled: false', { automated: { enabled: false, prune: true } }, true],
|
|
83
|
+
])('%s', (_label, syncPolicy, expected) => {
|
|
84
|
+
expect(isArgoAutoSyncSwitchedOff(app(syncPolicy))).toBe(expected)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('tolerates a missing resource', () => {
|
|
88
|
+
expect(isArgoAutoSyncSwitchedOff(undefined)).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('argoResumeSettings', () => {
|
|
93
|
+
const app = (syncPolicy: unknown) => ({ spec: { syncPolicy } })
|
|
94
|
+
it('keeps a block switched off in place, whatever the Radar', () => {
|
|
95
|
+
expect(argoResumeSettings(app({ automated: { enabled: false, prune: true } }), undefined)).toEqual({ prune: true, selfHeal: false })
|
|
96
|
+
})
|
|
97
|
+
it.each([
|
|
98
|
+
['a Radar starting both off', true, true, { prune: false, selfHeal: false }],
|
|
99
|
+
['a Radar starting only prune off', true, false, { prune: false, selfHeal: true }],
|
|
100
|
+
['a host that passes no self-heal answer', true, undefined, { prune: false, selfHeal: true }],
|
|
101
|
+
['an older Radar', false, false, { prune: true, selfHeal: true }],
|
|
102
|
+
['an unknown Radar', undefined, undefined, null],
|
|
103
|
+
])('without a block, follows %s', (_label, serverStartsPruneOff, serverStartsSelfHealOff, expected) => {
|
|
104
|
+
expect(argoResumeSettings(app({}), serverStartsPruneOff, serverStartsSelfHealOff)).toEqual(expected)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Mirrors the backend's pin rule (pkg/gitops argoSyncOperation), so the sync
|
|
109
|
+
// dialog names the compared revision only when a sync will use it.
|
|
110
|
+
describe('argoComparedRevisionCurrent', () => {
|
|
111
|
+
const source = { repoURL: 'https://example.com/repo', path: 'app', targetRevision: 'main' }
|
|
112
|
+
const values = { repoURL: 'https://example.com/values', ref: 'values', targetRevision: 'main' }
|
|
113
|
+
const single = (sync: any, spec: any = { source }) => ({ spec, status: { sync } })
|
|
114
|
+
|
|
115
|
+
it('is current when the comparison was for the same source, whatever its key order', () => {
|
|
116
|
+
expect(argoComparedRevisionCurrent(single({ revision: 'sha', comparedTo: { source: { targetRevision: 'main', path: 'app', repoURL: 'https://example.com/repo' } } }))).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
it('is not current after the source changed, or without a compared revision', () => {
|
|
119
|
+
expect(argoComparedRevisionCurrent(single({ revision: 'sha', comparedTo: { source: { ...source, targetRevision: 'release' } } }))).toBe(false)
|
|
120
|
+
expect(argoComparedRevisionCurrent(single({ comparedTo: { source } }))).toBe(false)
|
|
121
|
+
expect(argoComparedRevisionCurrent(single(undefined))).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
it('needs every source compared, one revision each, for a multi-source Application', () => {
|
|
124
|
+
const spec = { sources: [source, values] }
|
|
125
|
+
expect(argoComparedRevisionCurrent(single({ revisions: ['a', 'b'], comparedTo: { sources: [source, values] } }, spec))).toBe(true)
|
|
126
|
+
expect(argoComparedRevisionCurrent(single({ revisions: ['a'], comparedTo: { sources: [source, values] } }, spec))).toBe(false)
|
|
127
|
+
expect(argoComparedRevisionCurrent(single({ revisions: ['a', 'b'], comparedTo: { sources: [values, source] } }, spec))).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
it('is never current for a hydrated Application', () => {
|
|
130
|
+
const spec = { sourceHydrator: { drySource: source, syncSource: { targetBranch: 'env/prod', path: 'out' } }, source }
|
|
131
|
+
expect(argoComparedRevisionCurrent(single({ revision: 'sha', comparedTo: { source } }, spec))).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -12,16 +12,15 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
|
|
|
12
12
|
const sync = app.status?.sync?.status
|
|
13
13
|
const opPhase = app.status?.operationState?.phase
|
|
14
14
|
|
|
15
|
-
// Check for suspended (
|
|
15
|
+
// Check for suspended (auto-sync off). Honor both the current
|
|
16
16
|
// "radarhq.io/suspended-prune" annotation and the legacy "skyhook.io/..."
|
|
17
17
|
// key still present on Applications suspended by older Radar builds.
|
|
18
18
|
// The annotation value stores the prior prune state as "true"/"false" for
|
|
19
19
|
// restore on resume — both strings are truthy in JS, which is intentional:
|
|
20
20
|
// the *presence* of the annotation is what signals suspended, not its value.
|
|
21
|
-
const hasAutomatedSync = !!app.spec?.syncPolicy?.automated
|
|
22
21
|
const annotations = app.metadata?.annotations
|
|
23
22
|
const suspendedByRadar = annotations?.['radarhq.io/suspended-prune'] || annotations?.['skyhook.io/suspended-prune']
|
|
24
|
-
if (health === 'Suspended' || (!
|
|
23
|
+
if (health === 'Suspended' || (!isArgoAutoSyncEnabled(app) && suspendedByRadar)) {
|
|
25
24
|
// Suspended = an operator deliberately paused this app — intentional, not a
|
|
26
25
|
// degradation. Neutral (sky), matching the backend rollup (mapArgoHealth) so a
|
|
27
26
|
// suspended app reads the same Idle tone in Applications, the resource table,
|
|
@@ -64,14 +63,94 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
|
|
|
64
63
|
return { text: health || sync || 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
65
64
|
}
|
|
66
65
|
|
|
66
|
+
// isArgoAutoSyncEnabled reports whether an Argo CD Application auto-syncs.
|
|
67
|
+
// Argo CD 3.x can keep spec.syncPolicy.automated, with its prune and selfHeal
|
|
68
|
+
// settings, while switching automation off with `enabled: false`, so the block
|
|
69
|
+
// being present is not enough: only an absent or true `enabled` counts. Same
|
|
70
|
+
// rule as Argo's SyncPolicy.IsAutomatedSyncEnabled and the backend's
|
|
71
|
+
// gitops.ArgoAutoSyncEnabled, so the UI and the rollback gate agree.
|
|
72
|
+
export function isArgoAutoSyncEnabled(app: any): boolean {
|
|
73
|
+
const automated = app?.spec?.syncPolicy?.automated
|
|
74
|
+
if (automated == null || typeof automated !== 'object') return false
|
|
75
|
+
return automated.enabled !== false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// isArgoAutoSyncSwitchedOff reports an automated block kept with
|
|
79
|
+
// `enabled: false`. Only a Radar advertising `argoRollbackSync` turns such an
|
|
80
|
+
// app's auto-sync back on; an older one's resume leaves `enabled: false` set.
|
|
81
|
+
export function isArgoAutoSyncSwitchedOff(app: any): boolean {
|
|
82
|
+
const automated = app?.spec?.syncPolicy?.automated
|
|
83
|
+
return automated != null && typeof automated === 'object' && automated.enabled === false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// argoResumeSettings says what Enable auto-sync turns on for an Application
|
|
87
|
+
// Radar didn't suspend. A block switched off in place keeps its own prune and
|
|
88
|
+
// selfHeal. Without a block it depends on the Radar serving the request: one
|
|
89
|
+
// advertising argoSyncInheritance starts prune off, and one advertising
|
|
90
|
+
// argoEnableSelfHealOff starts selfHeal off; an older one turns each on. Null
|
|
91
|
+
// while either isn't known.
|
|
92
|
+
export function argoResumeSettings(app: any, serverStartsPruneOff: boolean | undefined, serverStartsSelfHealOff?: boolean): { prune: boolean; selfHeal: boolean } | null {
|
|
93
|
+
const automated = app?.spec?.syncPolicy?.automated
|
|
94
|
+
if (automated != null && typeof automated === 'object') {
|
|
95
|
+
return { prune: automated.prune === true, selfHeal: automated.selfHeal === true }
|
|
96
|
+
}
|
|
97
|
+
if (serverStartsPruneOff === undefined) return null
|
|
98
|
+
return { prune: !serverStartsPruneOff, selfHeal: !serverStartsSelfHealOff }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// isArgoHydrated: spec.sourceHydrator sets the Application's source, taking
|
|
102
|
+
// precedence over spec.source and spec.sources.
|
|
103
|
+
function isArgoHydrated(app: any): boolean {
|
|
104
|
+
return app?.spec?.sourceHydrator != null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// isArgoMultiSource: the Application syncs several sources, so a single
|
|
108
|
+
// revision override doesn't apply. Leftover spec.sources beside a hydrator don't count.
|
|
109
|
+
export function isArgoMultiSource(app: any): boolean {
|
|
110
|
+
return !isArgoHydrated(app) && Array.isArray(app?.spec?.sources) && app.spec.sources.length > 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// argoComparedRevisionCurrent mirrors the rule a Radar advertising
|
|
114
|
+
// argoSyncInheritance applies to a sync without a revision: it uses the last
|
|
115
|
+
// compared revision(s) only when the comparison was for the Application's
|
|
116
|
+
// current source(s), one revision per source. Hydrated Applications are
|
|
117
|
+
// never pinned.
|
|
118
|
+
export function argoComparedRevisionCurrent(app: any): boolean {
|
|
119
|
+
if (isArgoHydrated(app)) return false
|
|
120
|
+
const sync = app?.status?.sync
|
|
121
|
+
if (isArgoMultiSource(app)) {
|
|
122
|
+
const sources = app.spec.sources
|
|
123
|
+
return Array.isArray(sync?.revisions) && sync.revisions.length === sources.length && jsonEqual(sources, sync?.comparedTo?.sources)
|
|
124
|
+
}
|
|
125
|
+
const source = app?.spec?.source
|
|
126
|
+
return !!source && Object.keys(source).length > 0 && !!sync?.revision && jsonEqual(source, sync?.comparedTo?.source)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function jsonEqual(a: unknown, b: unknown): boolean {
|
|
130
|
+
if (a === b) return true
|
|
131
|
+
if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]))
|
|
132
|
+
if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(b)) {
|
|
133
|
+
const aKeys = Object.keys(a)
|
|
134
|
+
return aKeys.length === Object.keys(b).length && aKeys.every((key) => jsonEqual((a as any)[key], (b as any)[key]))
|
|
135
|
+
}
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// argoApplicationSyncOptions returns spec.syncPolicy.syncOptions: the options
|
|
140
|
+
// Argo CD applies to every sync of the Application unless a sync names its own.
|
|
141
|
+
export function argoApplicationSyncOptions(app: any): string[] {
|
|
142
|
+
const options = app?.spec?.syncPolicy?.syncOptions
|
|
143
|
+
return Array.isArray(options) ? options.filter((option): option is string => typeof option === 'string') : []
|
|
144
|
+
}
|
|
145
|
+
|
|
67
146
|
export function isArgoOperationInProgress(app: any): boolean {
|
|
68
147
|
const phase = app?.status?.operationState?.phase
|
|
69
148
|
return app?.operation != null || phase === 'Running' || phase === 'Terminating'
|
|
70
149
|
}
|
|
71
150
|
|
|
72
151
|
// Radar suspends an Argo Application by clearing spec.syncPolicy.automated and
|
|
73
|
-
// recording the prior
|
|
74
|
-
//
|
|
152
|
+
// recording the prior block in annotations (the whole block, plus the
|
|
153
|
+
// prune/selfHeal flags older builds read) so Resume can restore it. The *presence* of any of these annotations marks the app as suspended —
|
|
75
154
|
// independent of health.status, which stays whatever the app's resources report
|
|
76
155
|
// (often Missing/OutOfSync, never literally "Suspended"). Both the current
|
|
77
156
|
// radarhq.io keys and the legacy skyhook.io keys (still on apps suspended by
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, it } from 'vitest'
|
|
2
|
+
import { getCRDStatus, getCRDColumnValue } from './resource-utils-crd'
|
|
3
|
+
import { getCellFilterValue } from './resource-utils'
|
|
4
|
+
import { getGenericResourceStatus } from './generic-status'
|
|
5
|
+
import { getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
6
|
+
|
|
7
|
+
it.each([
|
|
8
|
+
[{ type: 'Established', status: 'True' }, 'Established', 'healthy'],
|
|
9
|
+
[{ type: 'Established', status: 'False', message: 'waiting' }, 'Not established', 'unhealthy'],
|
|
10
|
+
[{ type: 'NamesAccepted', status: 'False', reason: 'Conflict', message: 'name taken' }, 'Conflict', 'unhealthy'],
|
|
11
|
+
[{ type: 'Terminating', status: 'True' }, 'Terminating', 'degraded'],
|
|
12
|
+
[{ type: 'Established', status: 'Unknown' }, 'Unknown', 'unknown'],
|
|
13
|
+
[undefined, 'Unknown', 'unknown'],
|
|
14
|
+
])('shares CRD condition classification: %j', (condition, text, tone) => {
|
|
15
|
+
const data = { kind: 'CustomResourceDefinition', apiVersion: 'apiextensions.k8s.io/v1', status: { conditions: condition ? [condition] : [] } }
|
|
16
|
+
expect(getCRDStatus(data)).toMatchObject({ text, tone })
|
|
17
|
+
expect(getGenericResourceStatus(data)).toEqual(getCRDStatus(data))
|
|
18
|
+
expect(getResourceStatus('customresourcedefinitions', data)?.text).toBe(text)
|
|
19
|
+
})
|
|
20
|
+
it('prioritizes termination and rejected names over establishment', () => {
|
|
21
|
+
const status = { conditions: [{ type: 'Established', status: 'True' }, { type: 'NamesAccepted', status: 'False' }] }
|
|
22
|
+
expect(getCRDStatus({ status }).tone).toBe('unhealthy')
|
|
23
|
+
expect(getCRDStatus({ metadata: { deletionTimestamp: 'now' }, status }).text).toBe('Terminating')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('filters the CRD list by the declared kind and served versions, not the CRD object itself', () => {
|
|
27
|
+
const crd = { apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', spec: { scope: 'Cluster', names: { kind: 'Widget' }, versions: [{ name: 'v1', served: true }, { name: 'v2', served: false }] } }
|
|
28
|
+
expect(getCellFilterValue(crd, 'kind', 'customresourcedefinitions')).toBe('Widget')
|
|
29
|
+
expect(getCellFilterValue(crd, 'versions', 'customresourcedefinitions')).toBe('v1')
|
|
30
|
+
expect(getCellFilterValue(crd, 'scope', 'customresourcedefinitions')).toBe('Cluster')
|
|
31
|
+
expect(getCRDColumnValue({ ...crd, apiVersion: 'other.example.io/v1' }, 'kind')).toBeUndefined()
|
|
32
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GenericStatus } from './generic-status'
|
|
2
|
+
import { isApiGroup } from './resource-utils-cnpg'
|
|
3
|
+
|
|
4
|
+
export function getCRDStatus(data: any): GenericStatus {
|
|
5
|
+
const conditions: any[] = data.status?.conditions || []
|
|
6
|
+
const terminating = conditions.find(c => c.type === 'Terminating' && c.status === 'True')
|
|
7
|
+
if (data.metadata?.deletionTimestamp || terminating) return { text: 'Terminating', tone: 'degraded', reason: terminating?.message }
|
|
8
|
+
for (const [type, fallback] of [['NamesAccepted', 'Names not accepted'], ['Established', 'Not established']]) {
|
|
9
|
+
const condition = conditions.find(c => c.type === type && c.status === 'False')
|
|
10
|
+
if (condition) return { text: condition.reason || fallback, tone: 'unhealthy', reason: condition.message }
|
|
11
|
+
}
|
|
12
|
+
return conditions.some(c => c.type === 'Established' && c.status === 'True')
|
|
13
|
+
? { text: 'Established', tone: 'healthy' }
|
|
14
|
+
: { text: 'Unknown', tone: 'unknown' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getCRDColumnValue(data: any, column: string): string | undefined {
|
|
18
|
+
if (data?.kind !== 'CustomResourceDefinition' || !isApiGroup(data.apiVersion, 'apiextensions.k8s.io')) return undefined
|
|
19
|
+
switch (column) {
|
|
20
|
+
case 'kind': return data.spec?.names?.kind ?? ''
|
|
21
|
+
case 'scope': return data.spec?.scope ?? ''
|
|
22
|
+
case 'versions': return (data.spec?.versions ?? []).filter((v: any) => v?.served).map((v: any) => v.name).join(', ')
|
|
23
|
+
case 'status': return getCRDStatus(data).text
|
|
24
|
+
default: return undefined
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
1
3
|
import { describe, expect, it } from 'vitest'
|
|
2
4
|
import {
|
|
3
5
|
getClusterQueueCohort,
|
|
@@ -335,3 +337,13 @@ describe('JobSet status evidence boundaries', () => {
|
|
|
335
337
|
expect(getJobSetStatus({ spec: { suspend: true }, status: { conditions } }).text).toBe('Suspended')
|
|
336
338
|
})
|
|
337
339
|
})
|
|
340
|
+
|
|
341
|
+
// The topology node (pkg/topology) and Go's lifecycle read the same vectors.
|
|
342
|
+
describe('JobSet status shared lifecycle vectors', () => {
|
|
343
|
+
const vectors = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../../pkg/jobset/testdata/lifecycle.json', import.meta.url)), 'utf8')) as {
|
|
344
|
+
cases: { name: string; jobSet: unknown; label: string; level: string }[]
|
|
345
|
+
}
|
|
346
|
+
it.each(vectors.cases.map((c) => [c.name, c] as const))('%s', (_name, c) => {
|
|
347
|
+
expect(getJobSetStatus(c.jobSet)).toMatchObject({ text: c.label, level: c.level })
|
|
348
|
+
})
|
|
349
|
+
})
|