@skyhook-io/k8s-ui 1.18.1 → 1.19.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.
Files changed (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
@@ -9,6 +9,7 @@ import {
9
9
  type GitOpsPathEvidence,
10
10
  type GitOpsWrite,
11
11
  type GitOpsWriteEvidence,
12
+ gitOpsWriteHeadline,
12
13
  type GitOpsWriteGuardInput,
13
14
  type GitOpsWritePolicyEvidence,
14
15
  } from './gitops-write-guard'
@@ -399,3 +400,334 @@ describe('copy', () => {
399
400
  }
400
401
  })
401
402
  })
403
+
404
+ describe('manager answer composition', () => {
405
+ const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
406
+ const b = { ...a, name: 'b' }
407
+ const subject = { kind: 'Pod', namespace: 'db', name: 'pg-1' }
408
+ const parent = { kind: 'Deployment', group: 'apps', namespace: 'db', name: 'pg' }
409
+ const claim = { manager: a, source: 'argo-instance-label' as const, check: 'omitted' as const, weak: true }
410
+ const shared: import('../types/core').ManagerAnswer = { state: 'shared', lead: a, direct: { object: subject, recorders: [a, b] } }
411
+ const via: import('../types/core').ManagerAnswer = { state: 'listed', lead: b, direct: { object: subject }, via: { object: parent, recorders: [b] } }
412
+ const run = (manager: import('../types/core').ManagerAnswer, writes = [hibernate], extra: Partial<GitOpsWriteGuardInput> = {}) =>
413
+ guard({ manager, writes, evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'effective' })]), ...extra })
414
+
415
+ it('disagree field writes warn with both names despite a self-heal policy', () => {
416
+ const g = run({ state: 'disagree', direct: { object: subject, recorders: [b], claims: [claim] } })
417
+ expect(g.level).toBe('may-revert')
418
+ expect(g.summary).toContain('Application argocd/a and Application argocd/b')
419
+ expect(g.owner).toBeNull()
420
+ })
421
+
422
+ it('shared field writes cannot use one candidate’s ignore rule or policy exemption', () => {
423
+ const g = run(shared)
424
+ expect(g.level).toBe('may-revert')
425
+ expect(g.requiresAck).toBe(true)
426
+ expect(g.summary).toContain('argocd/a')
427
+ expect(g.summary).toContain('argocd/b')
428
+ expect(g.syncPolicy).toBeNull()
429
+ })
430
+
431
+ it('several claims without a lead warn in the plural', () => {
432
+ const g = run({ state: 'claimed', direct: { object: subject, claims: [claim, { ...claim, manager: b }] } })
433
+ expect(g.level).toBe('may-revert')
434
+ expect(g.summary).toContain('argocd/a and Application argocd/b')
435
+ })
436
+
437
+ it.each(['metadata', 'spec', 'delete'] as const)('via without an active direct claim makes %s informational without acknowledgment', scope => {
438
+ const g = run({ ...via, direct: { object: subject, claims: [{ ...claim, copied: true }] } }, [{ scope, paths: [HIBERNATE] }])
439
+ expect(g.level).toBe('info')
440
+ expect(g.requiresAck).toBe(false)
441
+ expect(g.summary).toContain('Deployment db/pg')
442
+ expect(g.summary).toContain('Application argocd/b')
443
+ if (scope === 'delete') expect(g.summary).toContain('controller recreates it from')
444
+ })
445
+
446
+ it('native Helm annotations do not compete with a GitOps ancestor', () => {
447
+ const helm = { kind: 'HelmRelease', group: '', namespace: 'db', name: 'chart' }
448
+ const g = run({ ...via, direct: { object: subject, claims: [{ manager: helm, source: 'helm-release', check: 'not-recording', weak: true }] } })
449
+ expect(g.level).toBe('info')
450
+ expect(g.requiresAck).toBe(false)
451
+ expect(g.summary).not.toContain('HelmRelease')
452
+ })
453
+
454
+ it.each(['disagree', 'shared', 'claimed'] as const)('%s deletes without a lead warn with every candidate', state => {
455
+ const direct = state === 'claimed' ? { object: subject, claims: [claim, { ...claim, manager: b }] } : { object: subject, recorders: state === 'shared' ? [a, b] : [b], claims: [claim] }
456
+ const g = run({ state, direct }, [{ scope: 'delete' }])
457
+ expect(g.level).toBe('may-revert')
458
+ expect(g.summary).toContain('Application argocd/a')
459
+ expect(g.summary).toContain('Application argocd/b')
460
+ })
461
+
462
+ it('via with a weak non-copied direct claim keeps the field warning', () => {
463
+ const g = run({ ...via, direct: { object: subject, claims: [claim] } })
464
+ expect(g.level).toBe('may-revert')
465
+ expect(g.requiresAck).toBe(true)
466
+ expect(g.summary).toContain('argocd/a')
467
+ expect(g.summary).toContain('Deployment db/pg')
468
+ })
469
+
470
+ it.each([shared, via])('status writes remain none', manager => {
471
+ expect(run(manager, [{ scope: 'status' }]).level).toBe('none')
472
+ })
473
+
474
+ it('shared create-child stays informational', () => {
475
+ expect(run(shared, [{ scope: 'create-child' }]).level).toBe('info')
476
+ })
477
+
478
+ it('operator-owned delete stays informational', () => {
479
+ expect(run(shared, [{ scope: 'delete' }], { operatorOwner: { kind: 'Cluster', name: 'pg' } }).level).toBe('info')
480
+ })
481
+
482
+ it.each([shared, via])('empty wildcard stays none', manager => {
483
+ expect(run(manager, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ empty: true })]) }).level).toBe('none')
484
+ })
485
+
486
+ it('a single agreeing candidate with declared field evidence can still prove will-revert', () => {
487
+ const manager = { state: 'agree' as const, lead: a, direct: { object: subject, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id' as const, check: 'recorded' as const }] } }
488
+ expect(run(manager, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) }).level).toBe('will-revert')
489
+ })
490
+
491
+ // A list alone can name an object its manager never syncs: a copy carrying
492
+ // another object's tracking ID, a CRD a HelmRelease skips.
493
+ it.each([hibernate, { scope: 'delete' as const }])('a list without metadata naming its manager promises no revert (%o)', write => {
494
+ const manager = { state: 'listed' as const, lead: a, direct: { object: subject, recorders: [a], claims: [{ manager: b, source: 'argo-tracking-id' as const, check: 'missing' as const, copied: true }] } }
495
+ const g = run(manager, [write], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
496
+ expect(g.level).toBe('may-revert')
497
+ expect(g.summary).toContain("Radar can't confirm Application argocd/a applies this object")
498
+ // The field evidence stays, behind the condition.
499
+ expect(g.summary).toMatch(/If it does: .+/)
500
+ })
501
+
502
+ it('a parent the managers disagree on says which names it and which lists it', () => {
503
+ const g = run({ state: 'disagree', direct: { object: subject }, via: { object: parent, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } })
504
+ expect(g.level).toBe('info')
505
+ expect(g.summary).toContain('whose metadata names Application argocd/a, while Application argocd/b lists it in its status')
506
+ })
507
+
508
+ // A CronJob's Job runs once: deleting it, even one created by hand, gets no
509
+ // replacement, whatever manages the CronJob.
510
+ it("a CronJob's Job is not said to be recreated", () => {
511
+ const cron = { kind: 'CronJob', group: 'batch', namespace: 'db', name: 'backup' }
512
+ const g = run({ state: 'agree', lead: a, direct: { object: subject }, via: { object: cron, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id', check: 'recorded' }] } }, [{ scope: 'delete' }], { operatorOwner: { kind: 'CronJob', name: 'backup' } })
513
+ expect(g.level).toBe('info')
514
+ expect(g.summary).toContain('nothing is expected to recreate it')
515
+ expect(g.summary).toContain('CronJob db/backup can still create new ones on its schedule')
516
+ expect(g.summary).not.toContain('recreates it')
517
+ })
518
+
519
+ // A newer Radar's state with no lead, or one candidate of a kind this UI
520
+ // can't open, still warns.
521
+ it.each([
522
+ ['an unfamiliar state without a lead', { state: 'future-state' as never, direct: { object: subject, recorders: [a] } }],
523
+ ['an unfamiliar manager kind', { state: 'claimed' as const, lead: { kind: 'Stack', group: 'example.io', namespace: 'x', name: 's' }, direct: { object: subject, claims: [{ manager: { kind: 'Stack', group: 'example.io', namespace: 'x', name: 's' }, source: 'argo-tracking-id' as never, check: 'omitted' as const }] } }],
524
+ ])('%s still warns on field writes and deletes', (_, manager) => {
525
+ for (const write of [hibernate, { scope: 'delete' as const }]) {
526
+ const g = run(manager, [write])
527
+ expect(g.level).toBe('may-revert')
528
+ expect(g.requiresAck).toBe(true)
529
+ }
530
+ })
531
+
532
+ it('a chain end this UI does not know keeps the conservative warning', () => {
533
+ const g = run({ state: 'unknown', direct: { object: subject }, chainEnd: 'future-stop' as never }, [hibernate])
534
+ expect(g.level).toBe('may-revert')
535
+ expect(g.requiresAck).toBe(true)
536
+ })
537
+
538
+ it('an agreeing answer whose manager kind this UI does not know still warns', () => {
539
+ const stack = { kind: 'Stack', group: 'example.io', namespace: 'x', name: 's' }
540
+ for (const write of [hibernate, { scope: 'delete' as const }]) {
541
+ const g = run({ state: 'agree', lead: stack, direct: { object: subject, recorders: [stack], claims: [{ manager: stack, source: 'argo-tracking-id', check: 'recorded' }] } }, [write])
542
+ expect(g.level).toBe('may-revert')
543
+ }
544
+ })
545
+
546
+ // A fallback via whose walk stopped at an unreadable owner may have missed
547
+ // a manager above it: a delete isn't just informational.
548
+ it('a delete through a fallback via with an unread chain keeps the warning', () => {
549
+ const g = run({ state: 'claimed', lead: a, direct: { object: subject }, via: { object: parent, claims: [{ manager: a, source: 'argo-instance-label', check: 'omitted', weak: true }] }, chainEnd: 'unfollowed', stop: { at: parent, state: 'unknown' } }, [{ scope: 'delete' }])
550
+ expect(g.level).toBe('may-revert')
551
+ expect(g.requiresAck).toBe(true)
552
+ })
553
+
554
+ it('a state this UI does not know is read as claimed', () => {
555
+ const g = run({ state: 'future-state' as never, lead: a, direct: { object: subject, recorders: [a] } }, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
556
+ expect(g.level).toBe('may-revert')
557
+ })
558
+
559
+ // The schedule belongs to the Job's own CronJob, even when the answer came
560
+ // from an ancestor above it.
561
+ it("names the Job's own CronJob for future runs, not the answering ancestor", () => {
562
+ const op = { kind: 'Widget', group: 'example.io', namespace: 'db', name: 'pg' }
563
+ const g = run({ state: 'agree', lead: a, direct: { object: subject }, via: { object: op, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id', check: 'recorded' }] } }, [{ scope: 'delete' }], { operatorOwner: { kind: 'CronJob', name: 'backup' } })
564
+ expect(g.summary).toContain('but CronJob ')
565
+ expect(g.summary).toContain('backup can still create new ones on its schedule')
566
+ expect(g.summary).not.toContain('Widget db/pg can still')
567
+ })
568
+
569
+ it('an informational note on a list-only answer says listed, not managed', () => {
570
+ const g = run({ state: 'listed', lead: a, direct: { object: subject, recorders: [a] } }, [{ scope: 'create-child' }])
571
+ expect(g.level).toBe('info')
572
+ expect(gitOpsWriteHeadline(g)).toBe('Listed by Argo CD Application argocd/a.')
573
+ })
574
+
575
+ it('a parent the metadata alone names is not said to be managed', () => {
576
+ const g = run({ state: 'claimed', lead: a, direct: { object: subject }, via: { object: parent, claims: [{ manager: a, source: 'argo-tracking-id', check: 'missing' }] } })
577
+ expect(g.level).toBe('info')
578
+ expect(g.summary).toContain('whose metadata names Application argocd/a')
579
+ expect(g.summary).not.toContain('manages')
580
+ })
581
+
582
+ it('an adopted Helm release keeps its upgrade risk past an Argo CD ignore rule', () => {
583
+ const helm = { kind: 'HelmRelease', group: '', namespace: 'db', name: 'chart' }
584
+ const manager = { state: 'agree' as const, lead: a, direct: { object: subject, recorders: [a], claims: [
585
+ { manager: a, source: 'argo-tracking-id' as const, check: 'recorded' as const },
586
+ { manager: helm, source: 'helm-release' as const, check: 'not-recording' as const },
587
+ ] } }
588
+ const g = run(manager)
589
+ expect(g.level).toBe('may-revert')
590
+ expect(g.summary).toContain('Helm release db/chart')
591
+ })
592
+
593
+ const agree = { state: 'agree' as const, lead: a, direct: { object: subject, recorders: [a], claims: [{ manager: a, source: 'argo-tracking-id' as const, check: 'recorded' as const }] } }
594
+
595
+ it('a refused live answer drops the cached answer and the owner hint', () => {
596
+ const g = run(agree, [hibernate], { evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored: 'effective' })], { managerUnavailable: 'objects_changed' }) })
597
+ expect(g.level).toBe('may-revert')
598
+ expect(g.owner).toBeNull()
599
+ expect(g.summary).toContain("Radar couldn't check what manages this resource right now (the read failed)")
600
+ expect(g.summary).not.toContain('objects_changed')
601
+ })
602
+
603
+ it.each([
604
+ ['kind_sync_pending', '(still loading)', true],
605
+ ['read_error', '(the read failed)', true],
606
+ ['user_forbidden', '(you may not list it)', false],
607
+ // Spellings an older Radar sends read as the code they became.
608
+ ['kind_sync_failed', '(loading is overdue)', true],
609
+ ['rbac_denied', '(you may not list it)', false],
610
+ ])('a refused live answer explains %s in words', (code, words, reopen) => {
611
+ const g = run(agree, [hibernate], { evidence: evidence(selfHeal, [], { managerUnavailable: code }) })
612
+ expect(g.summary).toContain(words)
613
+ expect(g.summary).not.toContain(code)
614
+ expect(g.summary.includes('Close and reopen this dialog to check again.')).toBe(reopen)
615
+ })
616
+
617
+ it("a failed request keeps the request's own message", () => {
618
+ const g = run(agree, [hibernate], { evidence: undefined, evidenceError: 'Request timed out' })
619
+ expect(g.summary).toContain('(Request timed out)')
620
+ expect(g.summary).not.toContain('reopen')
621
+ })
622
+
623
+ it("a failed live check doesn't keep a cached via answer's reassurance", () => {
624
+ const g = run(via, [hibernate], { evidence: undefined, evidenceError: 'forbidden' })
625
+ expect(g.level).toBe('may-revert')
626
+ expect(g.requiresAck).toBe(true)
627
+ })
628
+
629
+ it('a failed live check of a cached none stays quiet', () => {
630
+ const g = run({ state: 'none', direct: { object: subject } }, [hibernate], { evidence: undefined, evidenceError: 'timeout' })
631
+ expect(g.level).toBe('none')
632
+ })
633
+
634
+ it.each(['unknown', 'none'] as const)('server %s replaces client owner, Helm and manager inputs', state => {
635
+ const negative = { state, direct: { object: subject } }
636
+ const g = run(shared, [hibernate], { helmRelease: { namespace: 'db', name: 'old' }, evidence: evidence(selfHeal, [], { manager: negative }) })
637
+ expect(g.level).toBe('none')
638
+ expect(g.owner).toBeNull()
639
+ expect(g.helmRelease).toBeNull()
640
+ })
641
+
642
+ it('a stopped ancestor read retains conservative ownershipError behavior', () => {
643
+ const g = run({ state: 'unknown', direct: { object: subject }, stop: { at: subject, state: 'unknown', owner: parent } })
644
+ expect(g.level).toBe('may-revert')
645
+ expect(g.ownershipError).toContain("couldn't follow")
646
+ })
647
+
648
+ // A gone or replaced owner ends the chain with an answer: nothing above it
649
+ // was missed, so an object no manager names gets no warning.
650
+ it.each(['gone', 'replaced'] as const)('a walk ended by a %s owner is not an unread chain', state => {
651
+ const g = run({ state: 'none', direct: { object: subject }, chainEnd: 'unfollowed', stop: { at: subject, state, owner: parent } })
652
+ expect(g.level).toBe('none')
653
+ expect(g.ownershipError).toBeNull()
654
+ })
655
+
656
+ it('a stopped walk with a weak claim cannot use that candidate’s exemption', () => {
657
+ const g = run({ state: 'claimed', lead: a, direct: { object: subject, claims: [claim] }, stop: { at: subject, state: 'unknown', owner: parent } })
658
+ expect(g.level).toBe('may-revert')
659
+ expect(g.ownershipError).toContain("couldn't follow")
660
+ })
661
+
662
+ it('a failed target read retains conservative ownershipError behavior', () => {
663
+ const g = run({ state: 'unknown', direct: { object: subject } }, [hibernate], { ownershipError: 'Target read failed' })
664
+ expect(g.level).toBe('may-revert')
665
+ expect(g.ownershipError).toBe('Target read failed')
666
+ })
667
+
668
+ it('absent manager preserves legacy evidence verdicts', () => {
669
+ for (const policy of [selfHeal, manual, null]) {
670
+ const input = { writes: [hibernate], evidence: evidence(policy, [pathEvidence({ lastApplied: 'present' })]) }
671
+ expect(guard({ ...input, manager: undefined })).toEqual(guard(input))
672
+ }
673
+ })
674
+ })
675
+
676
+
677
+ // A ReplicaSet carries its Helm-installed Deployment's release annotations;
678
+ // they are the Deployment's, so the ReplicaSet gets the generated-from note.
679
+ it('a ReplicaSet of a plain Helm Deployment is generated from it, not claimed', () => {
680
+ const rs = { kind: 'ReplicaSet', group: 'apps', namespace: 'db', name: 'web-1' }
681
+ const release = { kind: 'HelmRelease', namespace: 'db', name: 'web' }
682
+ const g = guard({
683
+ target: rs,
684
+ manager: { state: 'claimed', lead: release,
685
+ direct: { object: rs, claims: [{ manager: release, source: 'helm-release', check: 'not-recording', weak: true }] },
686
+ via: { object: { kind: 'Deployment', group: 'apps', namespace: 'db', name: 'web' }, claims: [{ manager: release, source: 'helm-release', check: 'not-recording' }] } },
687
+ writes: [hibernate],
688
+ })
689
+ expect(g.level).toBe('info')
690
+ expect(g.requiresAck).toBe(false)
691
+ })
692
+
693
+ describe('unconfirmed manager boundaries', () => {
694
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'candidate' }
695
+ const claimed: import('../types/core').ManagerAnswer = { state: 'claimed', lead: app, direct: { object: target, claims: [{ manager: app, source: 'argo-tracking-id', check: 'omitted' }] } }
696
+ it.each(['omitted', 'remote', 'unreadable', 'withheld', 'missing'] as const)('%s cannot prove a revert or waive acknowledgment', check => {
697
+ const manager = { ...claimed, direct: { object: target, claims: [{ ...claimed.direct.claims![0], check }] } }
698
+ for (const ignored of [undefined, 'effective'] as const) {
699
+ const g = guard({ manager, writes: [hibernate, { scope: 'delete' }], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present', ignored })]) })
700
+ expect(g.perWrite.map(w => w.level)).toEqual(['may-revert', 'may-revert'])
701
+ expect(g.requiresAck).toBe(true)
702
+ expect(g.syncPolicy).toBeNull()
703
+ }
704
+ })
705
+ // An opt-out on the target's own annotations holds whether or not the
706
+ // claimed manager's list names it.
707
+ it('an unconfirmed claim keeps an opt-out on the target itself', () => {
708
+ const g = guard({ manager: claimed, writes: [hibernate, { scope: 'delete' }], evidence: evidence({ ...selfHeal, objectReconcile: 'ignore' }, [pathEvidence({ lastApplied: 'present' })]) })
709
+ expect(g.perWrite.map(w => w.level)).toEqual(['info', 'info'])
710
+ expect(g.requiresAck).toBe(false)
711
+ })
712
+ // A tracking ID and an instance label on one Application are one manager.
713
+ it('a namespaced and an unqualified claim on one Application are one candidate', () => {
714
+ const manager = { ...claimed, direct: { object: target, claims: [
715
+ { manager: app, source: 'argo-tracking-id' as const, check: 'omitted' as const },
716
+ { manager: { ...app, namespace: '' }, source: 'argo-instance-label' as const, check: 'unresolved' as const },
717
+ ] } }
718
+ const g = guard({ manager, writes: [hibernate], evidence: evidence(selfHeal, [pathEvidence({ lastApplied: 'present' })]) })
719
+ expect(g.summary).not.toContain(' and ')
720
+ expect(g.summary).not.toContain("A single manager's ignore rules")
721
+ })
722
+ it.each(['cycle', 'truncated'] as const)('%s ancestry remains conservative without a stop object', chainEnd => {
723
+ const g = guard({ manager: { state: 'unknown', direct: { object: target }, chainEnd }, writes: [hibernate, { scope: 'delete' }] })
724
+ expect(g.perWrite.map(w => w.level)).toEqual(['may-revert', 'may-revert'])
725
+ expect(g.ownershipError).toContain("couldn't follow")
726
+ })
727
+ it('stopped claimed deletes retain the floor after the operator rule', () => {
728
+ const manager = { ...claimed, stop: { at: target, state: 'unknown' } }
729
+ const input = { manager, writes: [{ scope: 'delete' as const }], evidence: evidence(selfHeal) }
730
+ expect(guard(input).perWrite[0].reason).toContain("couldn't follow")
731
+ expect(guard({ ...input, operatorOwner: { kind: 'Cluster', name: 'pg' } }).level).toBe('info')
732
+ })
733
+ })
@@ -9,7 +9,10 @@
9
9
  // field missing from both can still be in the source. So absence of evidence
10
10
  // is "unknown" (may be overwritten), never "safe".
11
11
 
12
- import type { GitOpsOwnerRef, GitOpsOwnerTool } from './gitops-owner'
12
+ import { activeManagerClaims, gitOpsOwnerFromRef, managerCandidates, managerRefLabel, type GitOpsOwnerRef, type GitOpsOwnerTool } from './gitops-owner'
13
+ import type { ManagerAnswer, ResourceRef } from '../types/core'
14
+ import { dedupeResourceRefs } from './resource-refs'
15
+ import { gapReasonText, readActionFor } from './read-actions'
13
16
 
14
17
  export type GitOpsWriteScope = 'status' | 'metadata' | 'spec' | 'create-child' | 'delete'
15
18
 
@@ -100,6 +103,9 @@ export interface GitOpsWritePolicyEvidence {
100
103
  }
101
104
 
102
105
  export interface GitOpsWriteEvidence {
106
+ manager?: ManagerAnswer
107
+ /** Why the server couldn't answer what manages the live object; the owner hint wasn't used either. */
108
+ managerUnavailable?: string
103
109
  uid: string
104
110
  resourceVersion: string
105
111
  owner: { kind: string; group?: string; namespace: string; name: string } | null
@@ -114,6 +120,7 @@ export interface GitOpsWriteEvidence {
114
120
  // ---------------------------------------------------------------------------
115
121
 
116
122
  export interface GitOpsWriteGuardInput {
123
+ manager?: ManagerAnswer
117
124
  target: GitOpsWriteTarget
118
125
  owner: GitOpsOwnerRef | null
119
126
  /** Native Helm release that manages the object when no GitOps tool does. */
@@ -141,6 +148,7 @@ export interface GitOpsWriteAssessment {
141
148
  }
142
149
 
143
150
  export interface GitOpsWriteGuard {
151
+ manager?: ManagerAnswer
144
152
  owner: GitOpsOwnerRef | null
145
153
  helmRelease: GitOpsWriteHelmRelease | null
146
154
  pending: boolean
@@ -413,10 +421,68 @@ function classifyWrite(input: GitOpsWriteGuardInput, owner: GitOpsOwnerRef | nul
413
421
  if (write.scope === 'status') {
414
422
  return { level: 'none', reason: 'This writes controller status; ordinary GitOps sync does not manage these fields.' }
415
423
  }
424
+ const manager = input.manager
425
+ if (manager) {
426
+ const candidates = managerCandidates(manager)
427
+ // A Helm release's annotations on an object answered through an ancestor
428
+ // are its Deployment's, copied onto it.
429
+ const directClaims = activeManagerClaims(manager.direct).filter(c => !manager.via || c.source !== 'helm-release')
430
+ const names = dedupeResourceRefs([...candidates, ...(manager.via ? directClaims.map(c => c.manager) : [])]).map(managerRefLabel).join(' and ')
431
+ if (write.scope === 'create-child' && candidates.length) {
432
+ return { level: 'info', reason: `A new object isn't in the GitOps source, so ${names} is not expected to revert it.` }
433
+ }
434
+ if (write.scope === 'metadata' || write.scope === 'spec') {
435
+ if (write.paths?.length && write.paths.every(path => input.evidence?.paths.find(p => p.path === path)?.empty)) {
436
+ return { level: 'none', reason: '' }
437
+ }
438
+ if (chainUnread(manager)) {
439
+ return { level: 'may-revert', reason: input.ownershipError! }
440
+ }
441
+ }
442
+ // A walk that couldn't read its chain may have missed a manager above;
443
+ // a controller that recreates the object makes that moot.
444
+ if (write.scope === 'delete' && chainUnread(manager) && !operatorOwnerOf(input)) {
445
+ return { level: 'may-revert', reason: input.ownershipError! }
446
+ }
447
+ if (manager.via && !directClaims.length) {
448
+ const source = managerRefLabel(manager.via.object)
449
+ const parent = `${source}, ${parentManagedBy(manager, names)}`
450
+ return {
451
+ level: 'info',
452
+ reason: write.scope === 'delete'
453
+ ? !NON_RECREATING_CONTROLLERS.includes(operatorOwnerOf(input)?.kind ?? '')
454
+ ? `Its controller recreates it from ${parent}.`
455
+ : `It was created from ${parent}; nothing is expected to recreate it, but ${operatorOwnerOf(input)!.kind} ${input.target.namespace ? `${input.target.namespace}/` : ''}${operatorOwnerOf(input)!.name} can still create new ones on its schedule.`
456
+ : `This resource is generated from ${parent}; changes to the parent may replace it.`,
457
+ }
458
+ }
459
+ if ((write.scope === 'metadata' || write.scope === 'spec') &&
460
+ (manager.state === 'disagree' || manager.state === 'shared' || candidates.length > 1 || manager.via && directClaims.length > 0)) {
461
+ return { level: 'may-revert', reason: `${names} may overwrite these fields. A single manager's ignore rules or sync policy cannot exempt this change.${manager.via ? ` The controller also generates it from ${managerRefLabel(manager.via.object)}.` : ''}` }
462
+ }
463
+ if (unconfirmed(manager, owner, input, candidates) && !targetOptedOut(input) && (write.scope === 'metadata' || write.scope === 'spec')) {
464
+ return { level: 'may-revert', reason: `This resource's metadata names ${names}, but its recorded membership isn't confirmed. A sync may overwrite this change.` }
465
+ }
466
+ }
416
467
  if (write.scope === 'delete') {
417
468
  const operator = operatorOwnerOf(input)
418
469
  if (operator) {
419
- return { level: 'info', reason: `${operator.kind} ${operator.name} will recreate this ${input.target.kind}.` }
470
+ return {
471
+ level: 'info',
472
+ reason: !NON_RECREATING_CONTROLLERS.includes(operator.kind)
473
+ ? `${operator.kind} ${operator.name} will recreate this ${input.target.kind}.`
474
+ : `${operator.kind} ${operator.name} created this ${input.target.kind}; it is not expected to recreate it.`,
475
+ }
476
+ }
477
+ }
478
+ if (manager && write.scope === 'delete') {
479
+ const candidates = managerCandidates(manager)
480
+ if (chainUnread(manager)) return { level: 'may-revert', reason: input.ownershipError! }
481
+ if (unconfirmed(manager, owner, input, candidates) && !targetOptedOut(input)) {
482
+ return { level: 'may-revert', reason: `This resource's metadata names ${candidates.map(managerRefLabel).join(' and ')}, but its recorded membership isn't confirmed. A sync may recreate it.` }
483
+ }
484
+ if (manager.state === 'disagree' || manager.state === 'shared' || candidates.length > 1) {
485
+ return { level: 'may-revert', reason: `${candidates.map(managerRefLabel).join(' and ')} may recreate this resource on sync.` }
420
486
  }
421
487
  }
422
488
  if (owner) {
@@ -458,7 +524,8 @@ function classifyWrite(input: GitOpsWriteGuardInput, owner: GitOpsOwnerRef | nul
458
524
  return { level: 'none', reason: '' }
459
525
  }
460
526
 
461
- function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease'>, level: GitOpsWriteLevel): string {
527
+ function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease' | 'manager'>, level: GitOpsWriteLevel): string {
528
+ if (guard.manager?.via && level === 'info') return `Generated from ${managerRefLabel(guard.manager.via.object)}.`
462
529
  const who = guard.owner
463
530
  ? describeGitOpsOwner(guard.owner)
464
531
  : guard.helmRelease
@@ -466,7 +533,8 @@ function headline(guard: Pick<GitOpsWriteGuard, 'owner' | 'helmRelease'>, level:
466
533
  : 'A GitOps tool'
467
534
  if (level === 'will-revert') return `${who} will revert this change.`
468
535
  if (level === 'may-revert') return `${who} may revert this change.`
469
- if (level === 'info') return guard.owner || guard.helmRelease ? `Managed by ${who}.` : ''
536
+ // A list alone isn't management: say what the chip says.
537
+ if (level === 'info') return guard.owner || guard.helmRelease ? `${guard.manager?.state === 'listed' ? 'Listed by' : 'Managed by'} ${who}.` : ''
470
538
  return ''
471
539
  }
472
540
 
@@ -474,15 +542,110 @@ export function gitOpsWriteHeadline(guard: GitOpsWriteGuard): string {
474
542
  return headline(guard, guard.level)
475
543
  }
476
544
 
545
+ // unconfirmed reports whether the answer names candidates without confirming
546
+ // one Radar can act on: a claim, or a candidate this UI can't open (no lead,
547
+ // or a manager kind it doesn't know). A native Helm lead has its own rules.
548
+ function unconfirmed(manager: ManagerAnswer, owner: GitOpsOwnerRef | null, input: GitOpsWriteGuardInput, candidates: ResourceRef[]): boolean {
549
+ if (!candidates.length || !owner && input.helmRelease) return false
550
+ return manager.state === 'claimed' || !owner && manager.state !== 'none' && manager.state !== 'unknown'
551
+ }
552
+
553
+ // parentManagedBy says what the evidence says of a parent, in the words its
554
+ // state allows: only a list and metadata agreeing say a manager manages it.
555
+ function parentManagedBy(manager: ManagerAnswer, names: string): string {
556
+ const evidence = manager.via ?? manager.direct
557
+ const label = (refs: ResourceRef[]) => dedupeResourceRefs(refs).map(managerRefLabel).join(' and ')
558
+ const listing = (refs: ResourceRef[]) => `${label(refs)} ${refs.length > 1 ? 'list it in their statuses' : 'lists it in its status'}`
559
+ switch (manager.state) {
560
+ case 'disagree':
561
+ return `whose metadata names ${label(activeManagerClaims(evidence).map(c => c.manager))}, while ${listing(evidence.recorders ?? [])}`
562
+ case 'agree':
563
+ return `which ${names} manages`
564
+ case 'listed':
565
+ case 'shared':
566
+ return `which ${names} ${managerCandidates(manager).length > 1 ? 'list' : 'lists'} in ${managerCandidates(manager).length > 1 ? 'their statuses' : 'its status'}`
567
+ default:
568
+ return `whose metadata names ${names}`
569
+ }
570
+ }
571
+
572
+ // adoptedHelmRelease: a native Helm release's annotations on an object a GitOps
573
+ // manager other than Flux's HelmRelease answers for. Argo CD renders charts
574
+ // with helm template, and a Kustomization applies plain manifests, so the
575
+ // annotations mean a Helm install was adopted: its release can still upgrade
576
+ // or roll back the object, whatever the GitOps manager ignores.
577
+ function adoptedHelmRelease(manager: ManagerAnswer | undefined): string | null {
578
+ if (!manager?.lead || manager.via) return null
579
+ if (manager.lead.kind === 'HelmRelease') return null
580
+ const claim = (manager.direct.claims ?? []).find(c => c.source === 'helm-release' && !c.copied && !c.weak)
581
+ return claim ? `${claim.manager.namespace ? `${claim.manager.namespace}/` : ''}${claim.manager.name}` : null
582
+ }
583
+
584
+ // targetOptedOut: the target's own annotations opt it out of its manager's
585
+ // reconciliation, which holds whether or not that manager's list names it.
586
+ function targetOptedOut(input: GitOpsWriteGuardInput): boolean {
587
+ return input.evidence?.policy?.objectReconcile === 'ignore'
588
+ }
589
+
590
+ // chainUnread: the walk up the controller chain stopped at an owner Radar
591
+ // couldn't read or decide, so a manager above it may be missed. An owner that is
592
+ // gone or was replaced ends the chain with an answer.
593
+ function chainUnread(manager: ManagerAnswer): boolean {
594
+ // A chain end this UI doesn't know (from a newer Radar) stopped short too.
595
+ if (manager.chainEnd && manager.chainEnd !== 'unfollowed') return true
596
+ return Boolean(manager.stop && !['gone', 'replaced', 'excluded'].includes(manager.stop.state))
597
+ }
598
+
599
+ // The states this UI knows. A newer Radar may send another; it is read as
600
+ // claimed, which never proves a revert or waives a warning.
601
+ const MANAGER_STATES: Array<string> = ['shared', 'disagree', 'agree', 'listed', 'claimed', 'unknown', 'none']
602
+
603
+ // Controllers that don't replace an object of theirs that is deleted: a
604
+ // CronJob's Job (scheduled or created by hand) runs once.
605
+ const NON_RECREATING_CONTROLLERS = ['CronJob']
606
+
477
607
  export function evaluateGitOpsWriteGuard(input: GitOpsWriteGuardInput): GitOpsWriteGuard {
478
- const owner = input.owner ?? null
479
- const helmRelease = owner ? null : (input.helmRelease ?? null)
480
- const ownershipError = owner || helmRelease ? null : (input.ownershipError ?? null)
481
- const normalized: GitOpsWriteGuardInput = { ...input, owner, helmRelease, ownershipError }
608
+ // A live check the server couldn't answer, or one that failed while the
609
+ // cached answer names a manager, leaves that answer unconfirmed: an exemption
610
+ // read from it could belong to the object as it was.
611
+ const cachedNamesManager = Boolean(input.manager && (input.manager.via || !['none', 'unknown'].includes(input.manager.state)))
612
+ const unavailable = input.evidence?.managerUnavailable
613
+ const failed = !input.evidence && input.evidenceError && cachedNamesManager ? input.evidenceError : null
614
+ const unconfirmed = unavailable || failed
615
+ const answered = unconfirmed ? undefined : input.evidence?.manager ?? input.manager
616
+ const manager = answered && !MANAGER_STATES.includes(answered.state) ? { ...answered, state: 'claimed' as const } : answered
617
+ const owner = unconfirmed ? null : manager ? manager.lead ? gitOpsOwnerFromRef(manager.lead) : null : input.owner ?? null
618
+ const helmRelease = unconfirmed ? null : manager
619
+ ? manager.lead?.kind === 'HelmRelease' && !manager.lead.group ? { namespace: manager.lead.namespace, name: manager.lead.name } : null
620
+ : owner ? null : (input.helmRelease ?? null)
621
+ const ownershipError = unconfirmed
622
+ ? `Radar couldn't check what manages this resource right now (${unavailable ? gapReasonText(unavailable) : failed}), so it can't tell whether a GitOps tool or Helm reverts this change.${unavailable && ['retry', 'wait'].includes(readActionFor(unavailable) ?? '') ? ' Close and reopen this dialog to check again.' : ''}`
623
+ : manager && chainUnread(manager)
624
+ ? "Radar couldn't follow this resource's controller chain, so it can't tell whether a GitOps tool or Helm manages it."
625
+ : owner || helmRelease ? null : (input.ownershipError ?? null)
626
+ const normalized: GitOpsWriteGuardInput = { ...input, manager, owner, helmRelease, ownershipError, ownerMatchedByName: manager ? false : input.ownerMatchedByName }
627
+ const adopted = adoptedHelmRelease(manager)
482
628
 
483
629
  const perWrite = input.writes.map((write) => {
484
630
  const verdict = classifyWrite(normalized, owner, write)
485
- if (owner && input.ownerMatchedByName && verdict.level === 'info' && write.scope !== 'create-child') {
631
+ // A list alone doesn't show its manager syncs the object (a copy carrying
632
+ // another object's tracking ID, a CRD a HelmRelease skips): no promise,
633
+ // in the verdict or in its reason.
634
+ if (manager?.state === 'listed' && !manager.via && (verdict.level === 'will-revert' || verdict.level === 'may-revert')) {
635
+ return {
636
+ write,
637
+ level: 'may-revert' as const,
638
+ reason: `Radar can't confirm ${managerRefLabel(manager.lead!)} applies this object: its status lists it, but the object's metadata doesn't name it. If it does: ${verdict.reason}`,
639
+ }
640
+ }
641
+ if (adopted && verdict.level === 'info' && (write.scope === 'metadata' || write.scope === 'spec' || write.scope === 'delete')) {
642
+ return {
643
+ write,
644
+ level: 'may-revert' as const,
645
+ reason: `${verdict.reason} Its metadata also says Helm release ${adopted} installed it; the next helm upgrade or rollback of that release may ${write.scope === 'delete' ? 'recreate it' : 'overwrite this change'}.`,
646
+ }
647
+ }
648
+ if (owner && normalized.ownerMatchedByName && verdict.level === 'info' && write.scope !== 'create-child') {
486
649
  return {
487
650
  write,
488
651
  level: 'may-revert' as const,
@@ -493,16 +656,17 @@ export function evaluateGitOpsWriteGuard(input: GitOpsWriteGuardInput): GitOpsWr
493
656
  })
494
657
  const level = perWrite.reduce<GitOpsWriteLevel>((max, entry) => maxLevel(max, entry.level), 'none')
495
658
  const top = perWrite.find((entry) => entry.level === level)
496
- const summary = level === 'none' ? '' : [headline({ owner, helmRelease }, level), top?.reason ?? ''].filter(Boolean).join(' ')
659
+ const summary = level === 'none' ? '' : [headline({ owner, helmRelease, manager }, level), top?.reason ?? ''].filter(Boolean).join(' ')
497
660
 
498
661
  return {
662
+ ...(manager ? { manager } : {}),
499
663
  owner,
500
664
  helmRelease,
501
665
  pending: Boolean(input.ownerPending),
502
666
  level,
503
667
  perWrite,
504
668
  summary,
505
- syncPolicy: owner ? gitOpsSyncPolicy(input.evidence?.policy) : null,
669
+ syncPolicy: owner && (!manager || manager.state !== 'claimed' && managerCandidates(manager).length === 1) ? gitOpsSyncPolicy(input.evidence?.policy) : null,
506
670
  requiresAck: level === 'will-revert' || level === 'may-revert',
507
671
  ownershipError,
508
672
  }
@@ -11,6 +11,7 @@ export * from './log-export'
11
11
  export * from './log-format'
12
12
  export * from './download'
13
13
  export * from './clipboard'
14
+ export * from './touch-intent'
14
15
  export * from './env-from'
15
16
  export * from './extended-resources'
16
17
  export * from './api-resources'
@@ -54,3 +54,37 @@ export function readActionFor(code: unknown): ReadAction | undefined {
54
54
  const current = canonicalReadCode(code)
55
55
  return typeof current === 'string' ? (READ_ACTIONS[current] ?? 'change_request') : undefined
56
56
  }
57
+
58
+ /** Why a read failed, in a few plain words; an unknown code reads as a failed read. */
59
+ export function gapReasonText(reason: string): string {
60
+ switch (canonicalReadCode(reason)) {
61
+ case 'radar_kind_not_collected':
62
+ case 'radar_namespace_not_collected':
63
+ return "Radar doesn't collect it"
64
+ case 'kind_sync_pending':
65
+ return 'still loading'
66
+ case 'kind_sync_timed_out':
67
+ return 'loading is overdue'
68
+ case 'radar_forbidden':
69
+ case 'radar_credentials_rejected':
70
+ return 'Radar may not list it'
71
+ case 'kind_not_watchable':
72
+ return "its API can't be watched"
73
+ case 'user_permission_check_failed':
74
+ return "your access couldn't be checked"
75
+ case 'user_forbidden':
76
+ return 'you may not list it'
77
+ case 'kind_unknown':
78
+ return "Radar's API discovery doesn't list its kind or version"
79
+ case 'object_not_found':
80
+ return "Radar doesn't have it yet"
81
+ case 'cluster_connecting':
82
+ return 'Radar is still connecting'
83
+ case 'cluster_disconnected':
84
+ return 'Radar lost its connection'
85
+ case 'connection_changed':
86
+ return "Radar's connection changed meanwhile"
87
+ default:
88
+ return 'the read failed'
89
+ }
90
+ }