@skyhook-io/k8s-ui 1.18.0 → 1.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { SlidersHorizontal } from 'lucide-react'
|
|
3
|
+
import { usePhoneLayout } from '../../hooks/usePhoneLayout'
|
|
4
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
5
|
+
|
|
6
|
+
// The header and scrolling body of a facet rail (Applications, GitOps). Below
|
|
7
|
+
// 900px the rail stacks above its list; on a phone that stack fills the first
|
|
8
|
+
// screen, so there the header becomes a "Filters" button with the active count
|
|
9
|
+
// and the facets open in place under it. Elsewhere it renders the plain
|
|
10
|
+
// "Filters" header over the body, as before.
|
|
11
|
+
export function FilterRailPanel({
|
|
12
|
+
activeCount,
|
|
13
|
+
clear,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
activeCount: number
|
|
17
|
+
/** The Clear control for the header's right side; null hides it. */
|
|
18
|
+
clear: ReactNode
|
|
19
|
+
children: ReactNode
|
|
20
|
+
}) {
|
|
21
|
+
const isPhone = usePhoneLayout()
|
|
22
|
+
const [open, setOpen] = useState(false)
|
|
23
|
+
const { panelId, buttonProps } = useDisclosure(open)
|
|
24
|
+
|
|
25
|
+
if (!isPhone) {
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
|
|
29
|
+
<span className="text-sm font-medium text-theme-text-secondary">Filters</span>
|
|
30
|
+
{clear}
|
|
31
|
+
</div>
|
|
32
|
+
<div className="flex-1 overflow-y-auto">{children}</div>
|
|
33
|
+
</>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
{...buttonProps}
|
|
43
|
+
onClick={() => setOpen((v) => !v)}
|
|
44
|
+
className="-ml-1 flex min-h-9 items-center gap-2 rounded-md px-1 text-sm font-medium text-theme-text-primary"
|
|
45
|
+
>
|
|
46
|
+
<SlidersHorizontal className="h-4 w-4 text-theme-text-secondary" aria-hidden />
|
|
47
|
+
Filters
|
|
48
|
+
{activeCount > 0 && (
|
|
49
|
+
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-skyhook-500 px-1 text-[10px] font-semibold leading-none text-white">
|
|
50
|
+
{activeCount}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
<CollapseChevron open={open} />
|
|
54
|
+
</button>
|
|
55
|
+
{clear}
|
|
56
|
+
</div>
|
|
57
|
+
<Collapse open={open} id={panelId}>
|
|
58
|
+
<div className="max-h-56 overflow-y-auto border-t border-theme-border">{children}</div>
|
|
59
|
+
</Collapse>
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { ManagerAnswer } from '../../types/core'
|
|
7
|
+
import { evaluateGitOpsWriteGuard, type GitOpsWriteGuard } from '../../utils/gitops-write-guard'
|
|
8
|
+
import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
9
|
+
import { GitOpsWriteWarning } from './GitOpsWriteWarning'
|
|
10
|
+
|
|
11
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
12
|
+
|
|
13
|
+
const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
|
|
14
|
+
const b = { ...a, name: 'b' }
|
|
15
|
+
const object = { kind: 'Deployment', group: 'apps', namespace: 'shop', name: 'web' }
|
|
16
|
+
|
|
17
|
+
async function linksOf(manager: ManagerAnswer, onOpenManager?: (owner: GitOpsOwnerRef) => void, override?: (guard: GitOpsWriteGuard) => GitOpsWriteGuard) {
|
|
18
|
+
const evaluated = evaluateGitOpsWriteGuard({ target: object, owner: null, manager, writes: [{ scope: 'spec', paths: ['spec.replicas'] }] })
|
|
19
|
+
const guard = override ? override(evaluated) : evaluated
|
|
20
|
+
const element = document.createElement('div')
|
|
21
|
+
document.body.append(element)
|
|
22
|
+
const root = createRoot(element)
|
|
23
|
+
await act(async () => root.render(<GitOpsWriteWarning guard={guard} acknowledged={false} onAcknowledgedChange={() => {}} onOpenManager={onOpenManager} />))
|
|
24
|
+
const links = [...element.querySelectorAll<HTMLButtonElement>('button')]
|
|
25
|
+
return { links, text: element.textContent ?? '', done: async () => { await act(async () => root.unmount()); element.remove() } }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The most ambiguous answer is the one a reader most needs to inspect: each
|
|
29
|
+
// manager opens from where the reason names it, with no second mention.
|
|
30
|
+
it.each<[string, ManagerAnswer]>([
|
|
31
|
+
['a disagreement', { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }],
|
|
32
|
+
['a shared list with a lead', { state: 'shared', lead: a, direct: { object, recorders: [a, b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'recorded' }] } }],
|
|
33
|
+
])('%s links every manager it names', async (_, manager) => {
|
|
34
|
+
const opened: string[] = []
|
|
35
|
+
const { links, text, done } = await linksOf(manager, owner => opened.push(`${owner.kind}/${owner.namespace}/${owner.name}`))
|
|
36
|
+
expect(links.map(l => l.textContent)).toEqual(['Application argocd/a', 'Application argocd/b'])
|
|
37
|
+
expect(text).not.toContain('View')
|
|
38
|
+
expect(text.match(/argocd\/b/g)).toHaveLength(1)
|
|
39
|
+
await act(async () => { links[1].click() })
|
|
40
|
+
expect(opened).toEqual(['applications/argocd/b'])
|
|
41
|
+
await done()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("doesn't link a manager Radar didn't find", async () => {
|
|
45
|
+
const manager: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: { ...a, namespace: '' }, source: 'argo-instance-label', check: 'unresolved' }] } }
|
|
46
|
+
const { links, done } = await linksOf(manager, () => {})
|
|
47
|
+
expect(links.map(l => l.textContent)).toEqual(['Application argocd/b'])
|
|
48
|
+
await done()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a host without manager navigation names them without links', async () => {
|
|
52
|
+
const manager: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }
|
|
53
|
+
const { links, done } = await linksOf(manager)
|
|
54
|
+
expect(links).toHaveLength(0)
|
|
55
|
+
await done()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Two Applications of one name in different namespaces both list the object,
|
|
59
|
+
// and its label names that name without a namespace: the label's claim is
|
|
60
|
+
// recorded, yet says neither which one, so only the two qualified ones link.
|
|
61
|
+
it('a label naming two same-named Applications links only the qualified ones', async () => {
|
|
62
|
+
const teamA = { ...a, namespace: 'team-a', name: 'web' }
|
|
63
|
+
const teamB = { ...a, namespace: 'team-b', name: 'web' }
|
|
64
|
+
const manager: ManagerAnswer = { state: 'shared', direct: { object, recorders: [teamA, teamB], claims: [{ manager: { ...a, namespace: '', name: 'web' }, source: 'argo-instance-label', check: 'recorded' }] } }
|
|
65
|
+
const { links, done } = await linksOf(manager, () => {})
|
|
66
|
+
expect(links.map(l => l.textContent)).toEqual(['Application team-a/web', 'Application team-b/web'])
|
|
67
|
+
await done()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// A name that begins another (argocd/a in argocd/ab) links only where it is
|
|
71
|
+
// the whole name.
|
|
72
|
+
it('links a name only where it is the whole name', async () => {
|
|
73
|
+
const ab = { ...a, name: 'ab' }
|
|
74
|
+
const manager: ManagerAnswer = { state: 'shared', direct: { object, recorders: [ab, a] } }
|
|
75
|
+
const { links, done } = await linksOf(manager, () => {}, guard => ({ ...guard, perWrite: guard.perWrite.map(w => ({ ...w, reason: 'Application argocd/ab and Application argocd/a may overwrite these fields.' })) }))
|
|
76
|
+
expect(links.map(l => l.textContent)).toEqual(['Application argocd/ab', 'Application argocd/a'])
|
|
77
|
+
await done()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('a manager the reason never names keeps a link of its own', async () => {
|
|
81
|
+
const manager: ManagerAnswer = { state: 'shared', direct: { object, recorders: [a, b] } }
|
|
82
|
+
const { links, done } = await linksOf(manager, () => {}, guard => ({ ...guard, perWrite: guard.perWrite.map(w => ({ ...w, reason: 'Application argocd/a may overwrite these fields.' })) }))
|
|
83
|
+
expect(links.map(l => l.textContent)).toEqual(['Application argocd/a', 'View Application argocd/b'])
|
|
84
|
+
await done()
|
|
85
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from 'react'
|
|
1
2
|
import { ExternalLink, GitBranch, Info, Loader2 } from 'lucide-react'
|
|
2
3
|
import { clsx } from 'clsx'
|
|
3
4
|
|
|
@@ -9,6 +10,8 @@ import {
|
|
|
9
10
|
type GitOpsWriteAssessment,
|
|
10
11
|
type GitOpsWriteGuard,
|
|
11
12
|
} from '../../utils/gitops-write-guard'
|
|
13
|
+
import { gitOpsOwnerFromRef, managerChoices, managerRefLabel, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
14
|
+
import { escapeRegExp } from '../../utils/log-format'
|
|
12
15
|
|
|
13
16
|
export { canConfirmGitOpsWrite }
|
|
14
17
|
|
|
@@ -18,6 +21,9 @@ export interface GitOpsWriteWarningProps {
|
|
|
18
21
|
onAcknowledgedChange: (acknowledged: boolean) => void
|
|
19
22
|
/** Opens the GitOps owner (or Helm release) the guard names. */
|
|
20
23
|
onOpenOwner?: () => void
|
|
24
|
+
/** Opens one of several managers the guard names, so a reader can inspect
|
|
25
|
+
* each before deciding. Without it, they are named but not linked. */
|
|
26
|
+
onOpenManager?: (owner: GitOpsOwnerRef) => void
|
|
21
27
|
disabled?: boolean
|
|
22
28
|
className?: string
|
|
23
29
|
}
|
|
@@ -28,6 +34,58 @@ const OWNER_LINK_LABEL = {
|
|
|
28
34
|
helmreleases: 'View Flux HelmRelease',
|
|
29
35
|
} as const
|
|
30
36
|
|
|
37
|
+
// managerLinks: the managers a guard naming more than one can open, the lead
|
|
38
|
+
// among them, since a lead isn't a winner. One Radar didn't find (missing,
|
|
39
|
+
// withheld) or a label naming no namespace isn't linked: the label may name
|
|
40
|
+
// either of two Applications of that name.
|
|
41
|
+
function managerLinks(guard: GitOpsWriteGuard, onOpenManager: GitOpsWriteWarningProps['onOpenManager']): { label: string; owner: GitOpsOwnerRef }[] {
|
|
42
|
+
if (!onOpenManager || !guard.manager) return []
|
|
43
|
+
const choices = managerChoices(guard.manager)
|
|
44
|
+
if (choices.length < 2) return []
|
|
45
|
+
return choices.flatMap((c) => {
|
|
46
|
+
const owner = c.found && c.ref.namespace ? gitOpsOwnerFromRef(c.ref) : null
|
|
47
|
+
return owner ? [{ label: managerRefLabel(c.ref), owner }] : []
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ManagerLink = ReturnType<typeof managerLinks>[number]
|
|
52
|
+
|
|
53
|
+
// linkManagers turns the first mention of each linked manager, across all the
|
|
54
|
+
// lines, into its link, so the reason the reader is already reading is where
|
|
55
|
+
// they open it. A name followed by more of a name (argocd/a in argocd/ab) isn't
|
|
56
|
+
// a mention.
|
|
57
|
+
function linkManagers(lines: string[], links: ManagerLink[], onOpen: (owner: GitOpsOwnerRef) => void): { lines: ReactNode[]; unmentioned: ManagerLink[] } {
|
|
58
|
+
if (!links.length) return { lines, unmentioned: [] }
|
|
59
|
+
const byLabel = new Map(links.map((l) => [l.label, l]))
|
|
60
|
+
const pattern = new RegExp(`(${[...byLabel.keys()].map(escapeRegExp).join('|')})(?![\\w./-])`, 'g')
|
|
61
|
+
const linked = new Set<string>()
|
|
62
|
+
const out = lines.map((line, i) => {
|
|
63
|
+
const parts: ReactNode[] = []
|
|
64
|
+
let last = 0
|
|
65
|
+
for (const match of line.matchAll(pattern)) {
|
|
66
|
+
const link = byLabel.get(match[0])!
|
|
67
|
+
if (linked.has(link.label)) continue
|
|
68
|
+
linked.add(link.label)
|
|
69
|
+
parts.push(line.slice(last, match.index))
|
|
70
|
+
parts.push(
|
|
71
|
+
<button
|
|
72
|
+
key={`${i}:${link.label}`}
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => onOpen(link.owner)}
|
|
75
|
+
className="font-medium text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
|
76
|
+
>
|
|
77
|
+
{link.label}
|
|
78
|
+
</button>,
|
|
79
|
+
)
|
|
80
|
+
last = match.index! + match[0].length
|
|
81
|
+
}
|
|
82
|
+
if (!parts.length) return line
|
|
83
|
+
parts.push(line.slice(last))
|
|
84
|
+
return <Fragment key={i}>{parts}</Fragment>
|
|
85
|
+
})
|
|
86
|
+
return { lines: out, unmentioned: links.filter((l) => !linked.has(l.label)) }
|
|
87
|
+
}
|
|
88
|
+
|
|
31
89
|
function reasonLines(perWrite: GitOpsWriteAssessment[], levels: GitOpsWriteGuard['level'][]): string[] {
|
|
32
90
|
const relevant = perWrite.filter((entry) => levels.includes(entry.level) && entry.reason)
|
|
33
91
|
const distinct = new Set(relevant.map((entry) => entry.reason))
|
|
@@ -42,6 +100,7 @@ export function GitOpsWriteWarning({
|
|
|
42
100
|
acknowledged,
|
|
43
101
|
onAcknowledgedChange,
|
|
44
102
|
onOpenOwner,
|
|
103
|
+
onOpenManager,
|
|
45
104
|
disabled = false,
|
|
46
105
|
// Not undefined: AlertBanner would add its own mb-4, and a margin utility
|
|
47
106
|
// overrides the parent's space-y gap.
|
|
@@ -63,11 +122,35 @@ export function GitOpsWriteWarning({
|
|
|
63
122
|
if (guard.level === 'none') return null
|
|
64
123
|
|
|
65
124
|
const headline = gitOpsWriteHeadline(guard)
|
|
66
|
-
const
|
|
125
|
+
const links = managerLinks(guard, onOpenManager)
|
|
126
|
+
const { lines, unmentioned } = linkManagers(
|
|
127
|
+
reasonLines(guard.perWrite, guard.level === 'info' ? ['info'] : ['will-revert', 'may-revert']),
|
|
128
|
+
links,
|
|
129
|
+
(owner) => onOpenManager?.(owner),
|
|
130
|
+
)
|
|
131
|
+
// Several managers link where the reason names them; one it doesn't name
|
|
132
|
+
// still gets a link of its own.
|
|
133
|
+
const ownerLink = links.length ? (
|
|
134
|
+
unmentioned.length ? (
|
|
135
|
+
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
|
136
|
+
{unmentioned.map(({ label, owner }) => (
|
|
137
|
+
<button
|
|
138
|
+
key={label}
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={() => onOpenManager!(owner)}
|
|
141
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline"
|
|
142
|
+
>
|
|
143
|
+
View {label}
|
|
144
|
+
<ExternalLink className="h-3 w-3" />
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
) : null
|
|
149
|
+
) : onOpenOwner ? (
|
|
67
150
|
<button
|
|
68
151
|
type="button"
|
|
69
152
|
onClick={onOpenOwner}
|
|
70
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-
|
|
153
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline"
|
|
71
154
|
>
|
|
72
155
|
{guard.owner ? OWNER_LINK_LABEL[guard.owner.kind] : 'View Helm release'}
|
|
73
156
|
<ExternalLink className="h-3 w-3" />
|
|
@@ -75,7 +158,6 @@ export function GitOpsWriteWarning({
|
|
|
75
158
|
) : null
|
|
76
159
|
|
|
77
160
|
if (guard.level === 'info') {
|
|
78
|
-
const lines = reasonLines(guard.perWrite, ['info'])
|
|
79
161
|
return (
|
|
80
162
|
<div
|
|
81
163
|
className={clsx(
|
|
@@ -86,7 +168,8 @@ export function GitOpsWriteWarning({
|
|
|
86
168
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
87
169
|
<div className="min-w-0 space-y-1">
|
|
88
170
|
<p>
|
|
89
|
-
<span className="font-medium text-theme-text-primary">{headline}</span>
|
|
171
|
+
<span className="font-medium text-theme-text-primary">{headline}</span>
|
|
172
|
+
{lines.map((line, i) => <Fragment key={i}> {line}</Fragment>)}
|
|
90
173
|
</p>
|
|
91
174
|
{ownerLink}
|
|
92
175
|
</div>
|
|
@@ -95,7 +178,6 @@ export function GitOpsWriteWarning({
|
|
|
95
178
|
}
|
|
96
179
|
|
|
97
180
|
const tool = guardToolLabel(guard)
|
|
98
|
-
const lines = reasonLines(guard.perWrite, ['will-revert', 'may-revert'])
|
|
99
181
|
const durableFix = guard.owner
|
|
100
182
|
? 'For a lasting change, change it in the GitOps source.'
|
|
101
183
|
: guard.helmRelease
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import type { ManagerAnswer, ManagerClaim } from '../../types/core'
|
|
4
|
+
import { ManagedByChip } from './ManagedByChip'
|
|
5
|
+
|
|
6
|
+
vi.mock('../ui/Tooltip', () => ({ Tooltip: ({ children, content }: any) => <span>{children}<span>{content}</span></span> }))
|
|
7
|
+
const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
|
|
8
|
+
const b = { ...a, name: 'b' }
|
|
9
|
+
const object = { kind: 'Pod', namespace: 'demo', name: 'web-1' }
|
|
10
|
+
const claim: ManagerClaim = { manager: a, source: 'argo-instance-label', check: 'omitted' }
|
|
11
|
+
const render = (manager: ManagerAnswer) => renderToStaticMarkup(<ManagedByChip manager={manager} />).replaceAll(''', "'")
|
|
12
|
+
|
|
13
|
+
describe('manager answer chip', () => {
|
|
14
|
+
// Only a list and the metadata agreeing say a manager manages it; a list
|
|
15
|
+
// alone can name an object its manager doesn't sync.
|
|
16
|
+
it.each([['agree', 'Managed by'], ['listed', 'Listed by']] as const)('%s says %s even before the status fetch', (state, prefix) => {
|
|
17
|
+
const html = render({ state, lead: a, direct: { object, recorders: [a] } })
|
|
18
|
+
expect(html).toContain(prefix)
|
|
19
|
+
expect(html).toContain('Application argocd/a lists this resource in its status.')
|
|
20
|
+
})
|
|
21
|
+
it('agree says the tracking metadata names the listing manager, once', () => {
|
|
22
|
+
const html = render({ state: 'agree', lead: a, direct: { object, recorders: [a], claims: [{ ...claim, manager: { ...a, namespace: '' }, check: 'recorded' }] } })
|
|
23
|
+
expect(html).toContain("Application argocd/a lists this resource in its status, and this resource's tracking metadata names it.")
|
|
24
|
+
expect(html.match(/Application/g)?.length).toBe(1)
|
|
25
|
+
})
|
|
26
|
+
it.each([
|
|
27
|
+
['omitted', "This resource's label names Application argocd/a, but its status doesn't list it."],
|
|
28
|
+
['unreadable', "This resource's label names Application argocd/a, but Radar can't read its status."],
|
|
29
|
+
['remote', "This resource's label names Application argocd/a, but Radar can't confirm it deploys to this cluster."],
|
|
30
|
+
['missing', "This resource's label names Application argocd/a, but there's no such Application here."],
|
|
31
|
+
['withheld', "This resource's label names Application argocd/a, but you can't view it."],
|
|
32
|
+
['unresolved', "This resource's label names Application argocd/a, but Radar found no Application of that name listing it; it may be managed from another cluster."],
|
|
33
|
+
['unchecked', "This resource's label names Application argocd/a, but Radar couldn't check its status."],
|
|
34
|
+
] as const)('claimed explains %s', (check, reason) => {
|
|
35
|
+
const html = render({ state: 'claimed', lead: a, direct: { object, claims: [{ ...claim, check }] } })
|
|
36
|
+
expect(html).toContain('Tracked by')
|
|
37
|
+
expect(html).toContain(reason)
|
|
38
|
+
})
|
|
39
|
+
it('disagree names the claim and recorder with a warning', () => {
|
|
40
|
+
const html = render({ state: 'disagree', direct: { object, claims: [claim], recorders: [b] } })
|
|
41
|
+
expect(html).toContain('Tracked by')
|
|
42
|
+
expect(html).toContain("Application argocd/a: its status doesn't list this resource")
|
|
43
|
+
expect(html).toContain('Application argocd/b lists this resource in its status')
|
|
44
|
+
expect(html.indexOf('argocd/a: its status')).toBeLessThan(html.indexOf('argocd/b lists'))
|
|
45
|
+
expect(html).toMatch(/rounded-full[^\"]*bg-amber/)
|
|
46
|
+
})
|
|
47
|
+
it('shared lists multiple names with the lead first', () => {
|
|
48
|
+
const html = render({ state: 'shared', lead: b, direct: { object, recorders: [a, b] } })
|
|
49
|
+
expect(html).toContain('Listed by')
|
|
50
|
+
expect(html).toContain('argocd/b and argocd/a')
|
|
51
|
+
})
|
|
52
|
+
it('does not present the lead’s sync status as the status of several managers', () => {
|
|
53
|
+
const html = renderToStaticMarkup(<ManagedByChip manager={{ state: 'shared', lead: b, direct: { object, recorders: [a, b] } }} status={{ sync: 'Synced', health: 'Healthy', suspended: false }} />)
|
|
54
|
+
expect(html).toContain('Listed by')
|
|
55
|
+
expect(html).not.toContain('Synced')
|
|
56
|
+
})
|
|
57
|
+
it('several claims without a lead stay plural', () => {
|
|
58
|
+
const html = render({ state: 'claimed', direct: { object, claims: [claim, { ...claim, manager: b }] } })
|
|
59
|
+
expect(html).toContain('Tracked by')
|
|
60
|
+
expect(html).toContain('argocd/a and argocd/b')
|
|
61
|
+
})
|
|
62
|
+
it.each(['none', 'unknown'] as const)('%s has no chip', state => {
|
|
63
|
+
expect(render({ state, direct: { object } })).toBe('')
|
|
64
|
+
})
|
|
65
|
+
it('via composes the wording with the parent and keeps direct and between claims', () => {
|
|
66
|
+
const html = render({ state: 'listed', lead: b, direct: { object, claims: [claim] },
|
|
67
|
+
between: [{ object: { ...object, kind: 'ReplicaSet', name: 'web' }, claims: [{ ...claim, manager: { ...a, name: 'middle' }, copied: true }] }],
|
|
68
|
+
via: { object: { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }, recorders: [b] } })
|
|
69
|
+
expect(html).toContain('Listed by')
|
|
70
|
+
expect(html).toContain('Through Deployment demo/web')
|
|
71
|
+
expect(html).toContain('its own label names Application argocd/a')
|
|
72
|
+
expect(html).not.toContain('argocd/middle')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
it('Listed by names only recorders and explains an outside claim in the tooltip', () => {
|
|
78
|
+
const c = { ...a, name: 'outside' }
|
|
79
|
+
const manager: ManagerAnswer = { state: 'shared', lead: b, direct: { object, recorders: [a, b], claims: [{ ...claim, manager: c }] } }
|
|
80
|
+
const html = render(manager)
|
|
81
|
+
expect(html).toContain('argocd/b and argocd/a')
|
|
82
|
+
expect(html).not.toContain('argocd/b and argocd/a and argocd/outside')
|
|
83
|
+
expect(html).toContain("Application argocd/outside: its status doesn't list this resource")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('says on the chip itself that the answer comes through an ancestor', () => {
|
|
87
|
+
const object = { kind: 'Pod', namespace: 'demo', name: 'web-1' }
|
|
88
|
+
const lead = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
|
|
89
|
+
const html = renderToStaticMarkup(<ManagedByChip manager={{ state: 'listed', lead, direct: { object }, via: { object: { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }, recorders: [lead] } }} />)
|
|
90
|
+
expect(html).toMatch(/<span class="shrink-0 text-theme-text-tertiary">via Deployment<\/span>/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('manager answer chip wording', () => {
|
|
94
|
+
it("says up front, in one sentence, why one claim isn't confirmed", () => {
|
|
95
|
+
const html = render({ state: 'claimed', lead: a, direct: { object, claims: [claim] } })
|
|
96
|
+
expect(html).toContain("This resource's label names Application argocd/a, but its status doesn't list it.")
|
|
97
|
+
expect(html.match(/argocd\/a/g)).toHaveLength(2)
|
|
98
|
+
})
|
|
99
|
+
it("says up front that several claims aren't confirmed, then why each", () => {
|
|
100
|
+
const html = render({ state: 'claimed', direct: { object, claims: [claim, { ...claim, manager: b, source: 'argo-tracking-id' }] } })
|
|
101
|
+
expect(html).toContain("labels or annotations name Application argocd/a and Application argocd/b; Radar hasn't confirmed they manage it")
|
|
102
|
+
expect(html).toContain("Application argocd/b: its status doesn't list this resource.")
|
|
103
|
+
})
|
|
104
|
+
// A claim the walk passed shows even when no ancestor answered.
|
|
105
|
+
it('names claims on the way without via', () => {
|
|
106
|
+
const html = render({ state: 'claimed', lead: a, direct: { object, claims: [claim] },
|
|
107
|
+
between: [{ object: { ...object, kind: 'ReplicaSet', name: 'web' }, claims: [{ ...claim, manager: { ...a, name: 'rs-app' } }] }] })
|
|
108
|
+
expect(html).toContain('ReplicaSet demo/web: its own label names Application argocd/rs-app')
|
|
109
|
+
})
|
|
110
|
+
// The manager's Synced reads as this resource's state beside a list alone.
|
|
111
|
+
it.each([['agree', true], ['listed', false]] as const)('%s shows the manager status: %s', (state, shown) => {
|
|
112
|
+
const html = renderToStaticMarkup(<ManagedByChip manager={{ state, lead: a, direct: { object, recorders: [a] } }} status={{ sync: 'Synced', health: 'Healthy' } as never} />)
|
|
113
|
+
expect(html.includes('Synced')).toBe(shown)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import { expect, it, vi } from 'vitest'
|
|
6
|
+
import type { ManagerAnswer } from '../../types/core'
|
|
7
|
+
import { ManagedByChip } from './ManagedByChip'
|
|
8
|
+
import { managerChipPresentation } from '../../utils/gitops-owner'
|
|
9
|
+
|
|
10
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
11
|
+
|
|
12
|
+
const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
|
|
13
|
+
const b = { ...a, name: 'b' }
|
|
14
|
+
const object = { kind: 'Deployment', group: 'apps', namespace: 'shop', name: 'web' }
|
|
15
|
+
const disagree: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }
|
|
16
|
+
|
|
17
|
+
// A disagreement has no lead; its chip must still reach both managers.
|
|
18
|
+
it('opens a menu naming every candidate and what names each', async () => {
|
|
19
|
+
const opened: string[] = []
|
|
20
|
+
const element = document.createElement('div')
|
|
21
|
+
document.body.append(element)
|
|
22
|
+
const root = createRoot(element)
|
|
23
|
+
await act(async () => root.render(<ManagedByChip manager={disagree} onOpen={ref => opened.push(ref.name)} />))
|
|
24
|
+
await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click() })
|
|
25
|
+
const items = [...element.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
|
|
26
|
+
expect(items.map(i => i.textContent)).toEqual(['Application argocd/aThis metadata names it', 'Application argocd/bIts status lists this'])
|
|
27
|
+
await act(async () => { items[1].click() })
|
|
28
|
+
expect(opened).toEqual(['b'])
|
|
29
|
+
await act(async () => root.unmount())
|
|
30
|
+
element.remove()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a chip without a link is still reachable by keyboard', () => {
|
|
34
|
+
expect(renderToStaticMarkup(<ManagedByChip manager={disagree} />)).toContain('tabindex="0"')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// A claim whose label names no namespace is still a candidate: named, unlinked.
|
|
38
|
+
it('names an unresolved candidate without linking it', async () => {
|
|
39
|
+
const element = document.createElement('div')
|
|
40
|
+
document.body.append(element)
|
|
41
|
+
const root = createRoot(element)
|
|
42
|
+
const unresolved: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: { ...a, namespace: '' }, source: 'argo-instance-label', check: 'unresolved' }] } }
|
|
43
|
+
await act(async () => root.render(<ManagedByChip manager={unresolved} onOpen={() => {}} />))
|
|
44
|
+
await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click() })
|
|
45
|
+
const items = [...element.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
|
|
46
|
+
expect(items).toHaveLength(2)
|
|
47
|
+
const a0 = items.find(i => i.textContent!.startsWith('Application a'))!
|
|
48
|
+
expect(a0.disabled).toBe(true)
|
|
49
|
+
expect(a0.textContent).toContain('Radar found no Application of this name listing it; it may be managed from another cluster')
|
|
50
|
+
await act(async () => root.unmount())
|
|
51
|
+
element.remove()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// A claim of a manager Radar didn't find is named but not linked, so the chip
|
|
55
|
+
// never opens a page for an Application that isn't there.
|
|
56
|
+
it('links only managers Radar found', () => {
|
|
57
|
+
const missing: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [{ manager: a, source: 'argo-tracking-id', check: 'missing' }] } }
|
|
58
|
+
expect(renderToStaticMarkup(<ManagedByChip manager={missing} onOpen={() => {}} />)).not.toContain('<button')
|
|
59
|
+
const found: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }
|
|
60
|
+
expect(renderToStaticMarkup(<ManagedByChip manager={found} onOpen={() => {}} />)).toContain('<button')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// A newer Radar may send a check this UI doesn't know: render, don't link.
|
|
64
|
+
it('renders an unfamiliar check without linking it', () => {
|
|
65
|
+
const future: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [{ manager: a, source: 'argo-tracking-id', check: 'installation-mismatch' as never }] } }
|
|
66
|
+
expect(renderToStaticMarkup(<ManagedByChip manager={future} onOpen={() => {}} />)).not.toContain('<button')
|
|
67
|
+
expect(managerChipPresentation(future)?.tooltip).toContain("couldn't confirm")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// One claim that read the manager is enough to link it, whatever another says.
|
|
71
|
+
it('links a manager one claim found though another left it unresolved', () => {
|
|
72
|
+
const both: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [
|
|
73
|
+
{ manager: a, source: 'argo-tracking-id', check: 'omitted' },
|
|
74
|
+
{ manager: { ...a, namespace: '' }, source: 'argo-instance-label', check: 'unresolved' },
|
|
75
|
+
] } }
|
|
76
|
+
expect(renderToStaticMarkup(<ManagedByChip manager={both} onOpen={() => {}} />)).toContain('<button')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// A label naming an Application of a name more than one has, without its
|
|
80
|
+
// namespace, isn't unresolved unless its check says so: the menu says it
|
|
81
|
+
// doesn't pick one, not that none exists.
|
|
82
|
+
const teamA = { ...a, namespace: 'team-a', name: 'web' }
|
|
83
|
+
const teamB = { ...a, namespace: 'team-b', name: 'web' }
|
|
84
|
+
const label = { ...a, namespace: '', name: 'web' }
|
|
85
|
+
it.each<[string, ManagerAnswer]>([
|
|
86
|
+
['shared', { state: 'shared', direct: { object, recorders: [teamA, teamB], claims: [{ manager: label, source: 'argo-instance-label', check: 'recorded' }] } }],
|
|
87
|
+
['disagree', { state: 'disagree', direct: { object, recorders: [teamA], claims: [
|
|
88
|
+
{ manager: teamB, source: 'argo-tracking-id', check: 'omitted' },
|
|
89
|
+
{ manager: label, source: 'argo-instance-label', check: 'recorded' },
|
|
90
|
+
] } }],
|
|
91
|
+
['an unfamiliar check', { state: 'shared', direct: { object, recorders: [teamA, teamB], claims: [{ manager: label, source: 'argo-instance-label', check: 'installation-mismatch' as never }] } }],
|
|
92
|
+
])('a label naming no single Application of its name (%s) is not called unresolved', async (_, shared) => {
|
|
93
|
+
const element = document.createElement('div')
|
|
94
|
+
document.body.append(element)
|
|
95
|
+
const root = createRoot(element)
|
|
96
|
+
await act(async () => root.render(<ManagedByChip manager={shared} onOpen={() => {}} />))
|
|
97
|
+
await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click() })
|
|
98
|
+
const text = element.textContent!
|
|
99
|
+
expect(text).toContain("its label doesn't say which Application of this name")
|
|
100
|
+
expect(text).not.toContain('Radar found no Application')
|
|
101
|
+
await act(async () => root.unmount())
|
|
102
|
+
element.remove()
|
|
103
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ManagedByChip } from './ManagedByChip'
|
|
5
|
+
|
|
6
|
+
const owner = { tool: 'argocd' as const, kind: 'applications' as const, namespace: 'argocd', name: 'billing' }
|
|
7
|
+
|
|
8
|
+
describe('ManagedByChip', () => {
|
|
9
|
+
it('says Managed by for a verified owner its status confirms', () => {
|
|
10
|
+
const html = renderToString(<ManagedByChip owner={owner} verified listing={{ state: 'confirmed' }} />)
|
|
11
|
+
expect(html).toContain('Managed by')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('says Tracked by, with a warning, for an owner whose status does not list the resource while another does', () => {
|
|
15
|
+
const html = renderToString(
|
|
16
|
+
<ManagedByChip owner={owner} verified listing={{ state: 'conflict', listedBy: [{ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'hub' }] }} />,
|
|
17
|
+
)
|
|
18
|
+
expect(html).toContain('Tracked by')
|
|
19
|
+
expect(html).not.toContain('Managed by')
|
|
20
|
+
expect(html).toMatch(/rounded-full[^"]*bg-amber/)
|
|
21
|
+
})
|
|
22
|
+
})
|