@skyhook-io/k8s-ui 1.17.0 → 1.17.1

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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
package/README.md CHANGED
@@ -29,3 +29,44 @@ command remains pending for the next connection.
29
29
  `YamlEditor` and `YamlDiffEditor` bundle Monaco, its editor worker, and the YAML language worker into the consuming application. They make no runtime CDN or internet requests, so they work in air-gapped environments.
30
30
 
31
31
  Consumers must use a bundler that supports module workers created with `new Worker(new URL(..., import.meta.url), { type: 'module' })`. The source package is compatible with Vite and webpack 5. Monaco and `monaco-yaml` are intentionally pinned as a compatible pair because their worker-factory APIs must move together. The package declares that exact Monaco version as a peer so the host and YAML runtime share one Monaco instance.
32
+
33
+ The YAML language worker comes from `monaco-yaml`, which imports Monaco's worker entry as `monaco-editor/esm/vs/editor/editor.worker.js`. Monaco's `exports` map does not serve that path, so the host bundler must alias it to the path Monaco does export. In Vite:
34
+
35
+ ```ts
36
+ resolve: {
37
+ alias: {
38
+ 'monaco-editor/esm/vs/editor/editor.worker.js': 'monaco-editor/editor/editor.worker.js',
39
+ },
40
+ },
41
+ ```
42
+
43
+ webpack 5 hosts need the same mapping in `resolve.alias`. Without it the build fails to resolve the worker import.
44
+
45
+ ## Resource kind views and certainty
46
+
47
+ `ResourcesView.renderKindView(kind)` optionally replaces a kind's table while
48
+ keeping the sidebar and resource drawer. Return `null` or `false` to use the table. Hosts
49
+ own data fetching; row/search/bulk shortcuts and column preferences are inactive
50
+ while a replacement is shown. Sidebar kind navigation remains available.
51
+
52
+ `CertaintyGlyph` displays `=`, `≥`, `≤` or `?` for an exact, lower-bound,
53
+ upper-bound or unknown value, with an accessible label and optional evidence
54
+ tooltip. `certaintyGlyph` and `certaintyValueLabel` expose the same vocabulary.
55
+
56
+ ## Optional flow controls
57
+
58
+ `CreateResourceDialog` defaults to Apply. Guided flows can set `initialMode="create"` and `lockMode` to require creation, and use `onBack(draftYaml)` to retain edits when returning to setup. Cancel closes the dialog. After a successful write, `onCreated(result, submittedYaml)` receives the first result and the exact submitted manifest; dry runs do not invoke it. `result.uid`, when supplied by Radar, identifies the persisted object. Partial creates name the objects already created. Unlocked flows switch to Apply for review when preview is available; direct creation keeps Create selected so a retry cannot silently update existing objects.
59
+
60
+ `useOpenTerminal` accepts `shell` (one executable name, without arguments), `title`, and `sessionNote`. Dock hosts must forward these fields to their terminal renderer; Radar does so. The shared `TerminalTab` accepts `command` to describe that executable, pin the container, and suppress shell debug advice, plus `note` for session context. `createSession` remains responsible for launching the command. Omitted options retain the normal shell session.
61
+
62
+ `AreaChart` supports optional `shadedRanges`, controlled `selection`, and `onSelectRange`. Range timestamps use Unix seconds. Dragging selects a range; with positive `stepSeconds`, clicking selects a step-wide window centered on the pointer, clipped at the chart edges. Arrow keys plus Enter select a window from the keyboard. Shaded range labels appear on hover. `AlertBanner.action` places a host-supplied control alongside the title. Omitting these props preserves existing charts and banners.
63
+
64
+ ## Facts, problems and reviewed actions
65
+
66
+ `FactValue`, `FactSource`, `FactGrid` and `FactRow` show an observed value and its source. Hosts supply explicit unknown or partial text; these components never infer completeness. `at` is a past event by default; `atMeaning="since"` describes a continuing state. `FoldSection` opens when its `attention` becomes true. `RefLink` is navigable only when the host supplies `onNavigate`; `ManagedByText` reuses the GitOps owner mapper for a plain relationship label.
67
+
68
+ `WorkspaceProblemCallout`, `ProblemList` and `ProblemMeta` show host-classified findings using non-healthy `HealthLevel` tones. `source` selects issue, audit or measurement provenance; source-specific fields cannot be combined. `rootKind` identifies the workspace's root; `subjectIsSelf` suppresses a link back to the current object. The host supplies `OpenIssueContext` only when it can open the matching Issues entry. CNPG maps its critical, warning and posture classifications to shared tones. `formatGrant` formats a permission for display; it performs no authorization.
69
+
70
+ `ActionConfirmDialog` adds context, reviewed writes and optional typed acknowledgement to the simpler `ConfirmDialog`. Hosts own loading, permission checks, stale-fact validation and result classification. Its `result` binds the state (`error`, `unknown` or `partial`), message and optional heading; unknown and partial results lock another submit. Retain that result outside the dialog across dismissal and remount, and clear it only after explicitly reviewing current state. The component does not infer outcomes from HTTP status. Remount it when changing the reviewed target or action, so acknowledgement and disclosure state start fresh. The default error heading makes no claim about whether a write occurred; use a stronger result title only when the producer proves it.
71
+
72
+ Existing CNPG primitive exports remain available and delegate to these shared implementations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -83,7 +83,7 @@
83
83
  "diff": ">=5.0.0",
84
84
  "elkjs": ">=0.9.0",
85
85
  "lucide-react": ">=0.400.0",
86
- "monaco-editor": "0.55.1",
86
+ "monaco-editor": "0.57.0",
87
87
  "react": ">=18.0.0",
88
88
  "react-dom": ">=18.0.0",
89
89
  "yaml": ">=2.0.0"
@@ -101,7 +101,7 @@
101
101
  "elkjs": "^0.11.1",
102
102
  "jsdom": "^29.1.1",
103
103
  "lucide-react": "^1.48.0",
104
- "monaco-editor": "0.55.1",
104
+ "monaco-editor": "0.57.0",
105
105
  "react": "^19.3.0",
106
106
  "react-dom": "^19.3.0",
107
107
  "typescript": "^6.0.2",
@@ -0,0 +1,118 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { beforeEach, afterEach, expect, it, vi } from 'vitest'
5
+ import { AreaChart } from './AreaChart'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+ let root: Root, host: HTMLDivElement
9
+ const series = [{ labels: {}, dataPoints: [{ timestamp: 1000, value: 1 }, { timestamp: 1600, value: 2 }] }]
10
+ beforeEach(() => {
11
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
12
+ vi.stubGlobal('requestAnimationFrame', () => 0)
13
+ Object.defineProperty(SVGElement.prototype, 'getScreenCTM', { configurable: true, value: () => ({ a: 2, e: 10 }) })
14
+ Object.defineProperty(SVGElement.prototype, 'setPointerCapture', { configurable: true, value: vi.fn() })
15
+ Object.defineProperty(SVGElement.prototype, 'hasPointerCapture', { configurable: true, value: () => true })
16
+ Object.defineProperty(SVGElement.prototype, 'releasePointerCapture', { configurable: true, value: vi.fn() })
17
+ host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host)
18
+ })
19
+ afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals() })
20
+ async function render(props: Partial<Parameters<typeof AreaChart>[0]> = {}) {
21
+ await act(async () => root.render(<AreaChart series={series} color="currentColor" fillColor="currentColor" unit="count" {...props} />))
22
+ return host.querySelector<SVGRectElement>('rect[fill="transparent"]')!
23
+ }
24
+ async function pointer(plot: SVGRectElement, type: string, x: number, button = 0, pointerType = 'mouse') {
25
+ await act(async () => {
26
+ const event = new MouseEvent(type, { bubbles: true, clientX: x * 2 + 10, button })
27
+ Object.defineProperty(event, 'pointerId', { value: 1 })
28
+ Object.defineProperty(event, 'pointerType', { value: pointerType })
29
+ plot.dispatchEvent(event)
30
+ })
31
+ }
32
+ it.each([[84, 960], [960, 84]])('uses the release position and orders forward/reverse ranges (%s,%s)', async (from, to) => {
33
+ const selected = vi.fn(); const plot = await render({ onSelectRange: selected })
34
+ await pointer(plot, 'pointerdown', from); await pointer(plot, 'pointerup', to)
35
+ expect(selected).toHaveBeenCalledWith({ start: 1000, end: 1600 })
36
+ })
37
+ it('clamps drag release beyond the plot without canceling and clears canceled gestures', async () => {
38
+ const selected = vi.fn(); const plot = await render({ onSelectRange: selected })
39
+ await pointer(plot, 'pointerdown', 84); await pointer(plot, 'pointermove', 1500); await pointer(plot, 'pointerup', 1500)
40
+ expect(selected).toHaveBeenLastCalledWith({ start: 1000, end: 1600 })
41
+ selected.mockClear()
42
+ await pointer(plot, 'pointerdown', 200); await pointer(plot, 'pointercancel', 800); await pointer(plot, 'pointerup', 800)
43
+ expect(selected).not.toHaveBeenCalled(); expect(host.querySelector('[data-chart-selection]')).toBeNull()
44
+ })
45
+ it('selects a bounded evaluation interval by click and keyboard', async () => {
46
+ const selected = vi.fn(); const plot = await render({ onSelectRange: selected, stepSeconds: 60 })
47
+ await pointer(plot, 'pointerdown', 84); await pointer(plot, 'pointerup', 84)
48
+ expect(selected).toHaveBeenLastCalledWith({ start: 1000, end: 1030 })
49
+ await act(async () => { plot.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' })) })
50
+ await act(async () => { plot.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) })
51
+ expect(selected).toHaveBeenLastCalledWith({ start: 1030, end: 1090 })
52
+ })
53
+ it('keeps default hover and exposes a gap reason even beside a nearby sample', async () => {
54
+ const plot = await render({ shadedRanges: [{ start: 1000, end: 1100, label: 'Metrics unavailable' }], stepSeconds: 600 })
55
+ expect(plot.style.cursor).toBe('crosshair'); expect(plot.getAttribute('tabindex')).toBeNull()
56
+ await pointer(plot, 'pointermove', 84)
57
+ expect(host.textContent).toContain('Metrics unavailable'); expect(host.textContent).toContain('1')
58
+ })
59
+ it('does not treat secondary pointer buttons as a selection', async () => {
60
+ const selected = vi.fn(); const plot = await render({ onSelectRange: selected })
61
+ await pointer(plot, 'pointerdown', 84, 2); await pointer(plot, 'pointerup', 960, 2)
62
+ expect(selected).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('shows keyboard focus and clears its tooltip after blur or Escape', async () => {
66
+ const plot = await render({ onSelectRange: vi.fn(), stepSeconds: 60 })
67
+ await act(async () => plot.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
68
+ expect(plot.getAttribute('stroke')).toBe('currentColor')
69
+ await act(async () => plot.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' })))
70
+ expect(host.querySelector('.pointer-events-none.absolute')).not.toBeNull()
71
+ await act(async () => plot.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
72
+ expect(plot.getAttribute('stroke')).toBeNull()
73
+ expect(host.querySelector('.pointer-events-none.absolute')).toBeNull()
74
+ await act(async () => plot.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' })))
75
+ await act(async () => plot.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })))
76
+ expect(host.querySelector('.pointer-events-none.absolute')).toBeNull()
77
+ })
78
+
79
+ it('retains a default-chart touch tooltip after a tap without pointermove, then clears on mouse leave', async () => {
80
+ const plot = await render()
81
+ await pointer(plot, 'pointerdown', 84, 0, 'touch')
82
+ await pointer(plot, 'pointerup', 84, 0, 'touch')
83
+ await pointer(plot, 'pointerout', 84, 0, 'touch')
84
+ expect(host.querySelector('.pointer-events-none.absolute')).not.toBeNull()
85
+ expect(host.textContent).toContain('1')
86
+ await act(async () => plot.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })))
87
+ expect(host.querySelector('.pointer-events-none.absolute')).toBeNull()
88
+ })
89
+
90
+ it('paints selected, shaded and dragged ranges above opaque fills and below chart lines', async () => {
91
+ const plot = await render({ fillColor: '#123456', selection: { start: 1000, end: 1100 }, shadedRanges: [{ start: 1100, end: 1200 }], onSelectRange: vi.fn() })
92
+ await pointer(plot, 'pointerdown', 84)
93
+ await pointer(plot, 'pointermove', 500)
94
+ const fill = host.querySelector('path[fill="#123456"]')!
95
+ const line = host.querySelector('path[fill="none"]')!
96
+ const bands = [...host.querySelectorAll('[data-chart-gap], [data-chart-selection], rect[opacity="0.2"]')]
97
+ expect(bands).toHaveLength(3)
98
+ for (const band of bands) {
99
+ expect(fill.compareDocumentPosition(band) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
100
+ expect(band.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
101
+ }
102
+ })
103
+
104
+ it('does not render a selectable empty chart without a finite timestamp domain', async () => {
105
+ const selected = vi.fn()
106
+ await render({ series: [{ labels: {}, dataPoints: [] }], onSelectRange: selected, stepSeconds: 60 })
107
+ expect(host.querySelector('svg')).toBeNull()
108
+ expect(host.querySelector('[role="button"]')).toBeNull()
109
+ expect(selected).not.toHaveBeenCalled()
110
+ })
111
+
112
+ it('keeps an empty chart selectable when an explicit valid domain supplies its bounds', async () => {
113
+ const selected = vi.fn()
114
+ const plot = await render({ series: [{ labels: {}, dataPoints: [] }], domain: { start: 1000, end: 1600 }, onSelectRange: selected, stepSeconds: 60 })
115
+ await pointer(plot, 'pointerdown', 84)
116
+ await pointer(plot, 'pointerup', 960)
117
+ expect(selected).toHaveBeenCalledWith({ start: 1000, end: 1600 })
118
+ })
@@ -171,3 +171,28 @@ describe('AreaChart compact layout and count axes', () => {
171
171
  expect(compactCount).not.toContain('>10<')
172
172
  })
173
173
  })
174
+
175
+ describe('AreaChart gaps and selection', () => {
176
+ const base = [series([[0, 1], [60, null], [120, 3], [180, 2]])]
177
+
178
+ it('hatches shaded ranges and draws the selection band inside the plot', () => {
179
+ const html = render({
180
+ series: base,
181
+ stepSeconds: 60,
182
+ shadedRanges: [{ start: t0 + 30, end: t0 + 90, label: 'No sample' }],
183
+ selection: { start: t0 + 120, end: t0 + 180 },
184
+ onSelectRange: () => {},
185
+ })
186
+ expect(html.match(/data-chart-gap/g)).toHaveLength(1)
187
+ expect(html).toMatch(/fill="url\(#hatch-[^"]+\)"/)
188
+ expect(html).toContain('data-chart-selection')
189
+ expect(html).toContain('cursor:col-resize')
190
+ })
191
+
192
+ it('draws nothing extra without the new props', () => {
193
+ const html = render({ series: base })
194
+ expect(html).not.toContain('data-chart-gap')
195
+ expect(html).not.toContain('data-chart-selection')
196
+ expect(html).toContain('cursor:crosshair')
197
+ })
198
+ })
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import type * as React from 'react'
3
3
  import { seriesColor, seriesFill, computeShortLabels, seriesDisplayLabels } from './colors'
4
4
  import { formatMetricValue, formatTimestamp } from './format'
@@ -13,7 +13,13 @@ import { nearestSample } from './nearestSample'
13
13
  export const ANNOTATION_LABEL_MIN_WIDTH_PX = 420
14
14
  const ANNOTATION_HOVER_TOLERANCE = 8
15
15
 
16
- export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full' }: {
16
+ /** A time range on the chart's X axis, in unix seconds. */
17
+ export interface ChartTimeRange {
18
+ start: number
19
+ end: number
20
+ }
21
+
22
+ export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full', shadedRanges, selection, onSelectRange }: {
17
23
  series: TimeSeries[]
18
24
  color: string
19
25
  fillColor: string
@@ -39,10 +45,23 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
39
45
  * keeps text and plot height stable as its container resizes.
40
46
  */
41
47
  layout?: 'full' | 'auto' | 'compact' | 'dashboard'
48
+ /** Ranges drawn hatched, e.g. where no sample exists; `label` says why and shows on hover. */
49
+ shadedRanges?: (ChartTimeRange & { label?: string })[]
50
+ /** A selected range, drawn as a band. */
51
+ selection?: ChartTimeRange | null
52
+ /**
53
+ * Enables range selection: drag across the plot, or click to pick the one
54
+ * step-wide window around the pointer (needs `stepSeconds`; clipped at the domain edges).
55
+ */
56
+ onSelectRange?: (range: ChartTimeRange) => void
42
57
  }) {
58
+ const hatchId = `hatch-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
59
+ const [drag, setDrag] = useState<{ from: number; to: number } | null>(null)
60
+ const dragRef = useRef<{ from: number; to: number } | null>(null)
43
61
  const svgRef = useRef<SVGSVGElement>(null)
44
62
  const wrapperRef = useRef<HTMLDivElement>(null)
45
63
  const [hoverX, setHoverX] = useState<number | null>(null)
64
+ const [keyboardFocus, setKeyboardFocus] = useState(false)
46
65
  const [labelsFit, setLabelsFit] = useState(true)
47
66
  const [renderedWidth, setRenderedWidth] = useState(0)
48
67
  const multiSeries = series.length > 1
@@ -70,6 +89,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
70
89
  minTs = domain.start
71
90
  maxTs = domain.end
72
91
  }
92
+ if (!Number.isFinite(minTs) || !Number.isFinite(maxTs)) return null
73
93
  if (minTs === maxTs) maxTs = minTs + 60
74
94
  if (maxVal === 0 && minVal === 0) {
75
95
  // Unit-appropriate floor so the Y-axis isn't misleadingly large.
@@ -264,17 +284,77 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
264
284
  p => Math.abs(p.x - clampedX) <= ANNOTATION_HOVER_TOLERANCE,
265
285
  )
266
286
 
267
- return { ts, x: clampedX, points, nearbyAnnotations }
268
- }, [hoverX, chartData, placedAnnotations, seriesLabels, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, stepSeconds])
287
+ const shaded = shadedRanges?.find(r => ts >= r.start && ts <= r.end && r.label)
288
+
289
+ return { ts, x: clampedX, points, nearbyAnnotations, shaded }
290
+ }, [hoverX, chartData, placedAnnotations, seriesLabels, marginLeft, plotWidth, marginTop, plotHeight, multiSeries, color, stepSeconds, shadedRanges])
269
291
 
270
- const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
271
- const svg = svgRef.current
272
- if (!svg) return
273
- const ctm = svg.getScreenCTM()
274
- if (!ctm) return
275
- setHoverX((e.clientX - ctm.e) / ctm.a)
292
+ const pointerX = useCallback((e: { clientX: number }) => {
293
+ const ctm = svgRef.current?.getScreenCTM()
294
+ return ctm ? (e.clientX - ctm.e) / ctm.a : null
276
295
  }, [])
277
296
 
297
+ const clampX = (x: number) => Math.max(marginLeft, Math.min(marginLeft + plotWidth, x))
298
+ const tsAt = (x: number) => {
299
+ if (!chartData) return 0
300
+ return chartData.minTs + ((clampX(x) - marginLeft) / plotWidth) * (chartData.maxTs - chartData.minTs)
301
+ }
302
+ const selectAt = (from: number, to: number) => {
303
+ if (!onSelectRange || !chartData) return
304
+ const a = tsAt(Math.min(from, to))
305
+ const b = tsAt(Math.max(from, to))
306
+ if (Math.abs(to - from) >= 6) {
307
+ onSelectRange({ start: Math.round(a), end: Math.round(b) })
308
+ } else if (stepSeconds && stepSeconds > 0) {
309
+ const half = stepSeconds / 2
310
+ onSelectRange({ start: Math.round(Math.max(chartData.minTs, a - half)), end: Math.round(Math.min(chartData.maxTs, a + half)) })
311
+ }
312
+ }
313
+ const cancelDrag = () => {
314
+ dragRef.current = null
315
+ setDrag(null)
316
+ }
317
+ const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
318
+ const x = pointerX(e)
319
+ if (x === null) return
320
+ setHoverX(x)
321
+ if (dragRef.current) {
322
+ dragRef.current = { ...dragRef.current, to: clampX(x) }
323
+ setDrag(dragRef.current)
324
+ }
325
+ }
326
+ const handlePointerDown = (e: React.PointerEvent<SVGRectElement>) => {
327
+ if (e.button !== 0) return
328
+ const x = pointerX(e)
329
+ if (x === null) return
330
+ setHoverX(x)
331
+ if (!onSelectRange) return
332
+ e.currentTarget.setPointerCapture(e.pointerId)
333
+ dragRef.current = { from: clampX(x), to: clampX(x) }
334
+ setDrag(dragRef.current)
335
+ }
336
+ const handlePointerUp = (e: React.PointerEvent<SVGRectElement>) => {
337
+ const current = dragRef.current
338
+ const x = pointerX(e)
339
+ cancelDrag()
340
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
341
+ if (current && x !== null) selectAt(current.from, clampX(x))
342
+ }
343
+ const handleKeyDown = (e: React.KeyboardEvent<SVGRectElement>) => {
344
+ if (!chartData || !onSelectRange) return
345
+ if (e.key === 'Escape') { cancelDrag(); setHoverX(null); return }
346
+ if (!stepSeconds || stepSeconds <= 0) return
347
+ const x = hoverX ?? marginLeft
348
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
349
+ e.preventDefault()
350
+ const step = (stepSeconds / (chartData.maxTs - chartData.minTs)) * plotWidth
351
+ setHoverX(clampX(x + (e.key === 'ArrowRight' ? step : -step)))
352
+ } else if (e.key === 'Enter' || e.key === ' ') {
353
+ e.preventDefault()
354
+ selectAt(x, x)
355
+ }
356
+ }
357
+
278
358
  // Hook calls above run unconditionally; bail out of rendering only after
279
359
  // every hook has been invoked (Rules of Hooks).
280
360
  if (!chartData) return null
@@ -292,6 +372,12 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
292
372
  preserveAspectRatio="xMidYMid meet"
293
373
  data-chart-layout={compact ? 'compact' : 'full'}
294
374
  >
375
+ <defs>
376
+ <pattern id={hatchId} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
377
+ <line x1="0" y1="0" x2="0" y2="6" stroke="currentColor" strokeWidth="2" className="text-theme-border" />
378
+ </pattern>
379
+ </defs>
380
+
295
381
  {/* Grid lines */}
296
382
  {yTicks.map((tick, i) => (
297
383
  <line
@@ -359,6 +445,32 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
359
445
  />
360
446
  ))}
361
447
 
448
+ {shadedRanges?.map((r, i) => {
449
+ const x1 = Math.max(marginLeft, toX(r.start))
450
+ const x2 = Math.min(marginLeft + plotWidth, toX(r.end))
451
+ if (x2 <= x1) return null
452
+ return <rect key={`shade-${i}`} data-chart-gap x={x1} y={marginTop} width={x2 - x1} height={plotHeight} fill={`url(#${hatchId})`} opacity="0.7" />
453
+ })}
454
+
455
+ {selection && (() => {
456
+ const x1 = Math.max(marginLeft, toX(selection.start))
457
+ const x2 = Math.min(marginLeft + plotWidth, toX(selection.end))
458
+ if (x2 < x1) return null
459
+ return <rect data-chart-selection x={x1} y={marginTop} width={Math.max(2, x2 - x1)} height={plotHeight} fill="currentColor" className="text-accent" opacity="0.15" />
460
+ })()}
461
+
462
+ {drag && Math.abs(drag.to - drag.from) >= 2 && (
463
+ <rect
464
+ x={Math.max(marginLeft, Math.min(drag.from, drag.to))}
465
+ y={marginTop}
466
+ width={Math.abs(drag.to - drag.from)}
467
+ height={plotHeight}
468
+ fill="currentColor"
469
+ className="text-accent"
470
+ opacity="0.2"
471
+ />
472
+ )}
473
+
362
474
  {/* Lines */}
363
475
  {paths.map(p => p && (
364
476
  <path
@@ -510,9 +622,23 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
510
622
  x={marginLeft} y={marginTop}
511
623
  width={plotWidth} height={plotHeight}
512
624
  fill="transparent"
513
- style={{ cursor: 'crosshair' }}
514
- onMouseMove={handleMouseMove}
625
+ onPointerMove={handlePointerMove}
626
+ onPointerDown={handlePointerDown}
627
+ onPointerUp={handlePointerUp}
628
+ onPointerCancel={cancelDrag}
629
+ onLostPointerCapture={cancelDrag}
630
+ onPointerLeave={(e) => { if (e.pointerType !== 'touch') setHoverX(null) }}
515
631
  onMouseLeave={() => setHoverX(null)}
632
+ tabIndex={onSelectRange && stepSeconds && stepSeconds > 0 ? 0 : undefined}
633
+ role={onSelectRange && stepSeconds && stepSeconds > 0 ? 'button' : undefined}
634
+ aria-label={onSelectRange && stepSeconds && stepSeconds > 0 ? 'Select chart interval: use arrow keys to move, then Enter to select' : undefined}
635
+ onKeyDown={handleKeyDown}
636
+ onFocus={() => setKeyboardFocus(true)}
637
+ onBlur={() => { setKeyboardFocus(false); setHoverX(null); cancelDrag() }}
638
+ stroke={keyboardFocus ? 'currentColor' : undefined}
639
+ strokeWidth={keyboardFocus ? 2 : undefined}
640
+ className={keyboardFocus ? 'text-accent' : undefined}
641
+ style={{ cursor: onSelectRange ? 'col-resize' : 'crosshair', touchAction: onSelectRange ? 'none' : undefined }}
516
642
  />
517
643
  </svg>
518
644
 
@@ -538,6 +664,9 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
538
664
  </span>
539
665
  </div>
540
666
  ))}
667
+ {hoverData.shaded && (
668
+ <div className="py-0.5 text-theme-text-secondary">{hoverData.shaded.label}</div>
669
+ )}
541
670
  {hoverData.points.map((p, i) => (
542
671
  <div key={i} className="flex items-center gap-2 py-0.5">
543
672
  <div
@@ -1,4 +1,5 @@
1
1
  export { AreaChart } from './AreaChart'
2
+ export type { ChartTimeRange } from './AreaChart'
2
3
  export { MetricsSummary } from './MetricsSummary'
3
4
  export { SeriesLegend } from './SeriesLegend'
4
5
  export {
@@ -16,6 +16,7 @@ import {
16
16
  SEVERITY_TEXT_CLASS,
17
17
  } from './severity'
18
18
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
19
+ import { CopyableCommand } from '../trace/TracePanel'
19
20
  import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
20
21
  import { CORE_RESOURCES } from '../../utils/api-resources'
21
22
 
@@ -28,6 +29,9 @@ const AUDIT_INPUT_LABELS = new Map<string, string>([
28
29
  ['replicaset-ownership', 'ReplicaSet ownership'],
29
30
  ['configmap-references', 'ConfigMap references'],
30
31
  ['secret-references', 'Secret references'],
32
+ ['pvc-consumers', 'PVC consumers'],
33
+ ['pv-deletion-events', 'PV deletion events'],
34
+ ['pv-release-time', 'PV release time'],
31
35
  ])
32
36
 
33
37
  // Leading severity glyph, one per tier of the 4-tier ladder: critical = octagon,
@@ -101,6 +105,9 @@ export interface ChecksViewProps {
101
105
  * tab governs hiding there). Operate on the whole check, fleet-wide. */
102
106
  onHideCheck?: (checkID: string, title: string) => void
103
107
  onHideCategory?: (category: string) => void
108
+ /** Per-resource opt-out annotation the source Radar honors. Omit when it
109
+ * doesn't, so the card never suggests an annotation that does nothing. */
110
+ ignoreChecksAnnotation?: string
104
111
  }
105
112
 
106
113
  // A check rolled up across every cluster it fires on — one row of the fleet
@@ -122,7 +129,7 @@ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
122
129
  q: { param: 'q', type: 'text' },
123
130
  })
124
131
 
125
- export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs = [], resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
132
+ export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs = [], resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory, ignoreChecksAnnotation }: ChecksViewProps) {
126
133
  // Severity / category / framework / search live in the URL (shareable,
127
134
  // bookmarkable audit links) via the shared filter-state contract. The cluster
128
135
  // facet is deliberately NOT here — it's a host-controlled seam (see
@@ -430,6 +437,7 @@ export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs
430
437
  onResourceClick={onResourceClick}
431
438
  onHideCheck={onHideCheck}
432
439
  onHideCategory={onHideCategory}
440
+ ignoreChecksAnnotation={ignoreChecksAnnotation}
433
441
  />
434
442
  ))}
435
443
  </ol>
@@ -720,6 +728,7 @@ function FleetCheckRow({
720
728
  onResourceClick,
721
729
  onHideCheck,
722
730
  onHideCategory,
731
+ ignoreChecksAnnotation,
723
732
  }: {
724
733
  fc: FleetCheck
725
734
  meta?: CheckMeta
@@ -730,6 +739,7 @@ function FleetCheckRow({
730
739
  onResourceClick?: (ref: CheckResourceRef) => void
731
740
  onHideCheck?: (checkID: string, title: string) => void
732
741
  onHideCategory?: (category: string) => void
742
+ ignoreChecksAnnotation?: string
733
743
  }) {
734
744
  const clusterCount = fc.clusters.length
735
745
  const single = clusterCount <= 1
@@ -762,6 +772,13 @@ function FleetCheckRow({
762
772
  >
763
773
  <CheckRemediationBlock description={meta?.description} remediation={meta?.remediation} references={meta?.references} />
764
774
 
775
+ {ignoreChecksAnnotation && (
776
+ <CardSection icon={EyeOff} label="Skip for one resource">
777
+ <CardBody>Add this annotation to a resource listed below. Its findings for this check are then hidden.</CardBody>
778
+ <CopyableCommand command={`${ignoreChecksAnnotation}: ${fc.checkID}`} />
779
+ </CardSection>
780
+ )}
781
+
765
782
  <CardSection
766
783
  icon={Layers}
767
784
  label="Affected resources"
@@ -7,10 +7,13 @@ import {
7
7
  getCNPGScheduledBackupStatus,
8
8
  } from '../resources/resource-utils-cnpg'
9
9
  import type { CNPGWorkspaceResponse } from './workspace'
10
- import { FactGrid, FactRow, RefLink, SummaryHeading, toneTextClass, type CNPGNavigate } from './primitives'
10
+ import { cnpgScheduleBasisNote, formatCNPGRunTime, type CNPGSchedulePreview } from './schedule'
11
+ import { Badge } from '../ui/Badge'
12
+ import { Tooltip } from '../ui/Tooltip'
11
13
  import { ClusterLink, NotReported, Note, ObjectProblems, PhaseBadge, SummaryShell, TimeAgo } from './CNPGSharedSummary'
12
14
  import {
13
15
  backupDestination,
16
+ cnpgScheduleDestinationBlocker,
14
17
  backupsForScheduledBackup,
15
18
  clustersIn,
16
19
  isBackupFromSchedule,
@@ -19,11 +22,15 @@ import {
19
22
  scheduledBackupOf,
20
23
  workspaceList,
21
24
  } from './relations'
25
+ import { type NavigateToRef, RefLink } from '../ui/RefLink'
26
+ import { toneTextClass } from '../ui/status-tone'
27
+ import { FactGrid, FactRow } from '../facts'
28
+ import { SectionHeading } from '../ui/FoldSection'
22
29
 
23
30
  interface SummaryProps {
24
31
  resource: any
25
32
  workspace: CNPGWorkspaceResponse | null
26
- onNavigate?: CNPGNavigate
33
+ onNavigate?: NavigateToRef
27
34
  }
28
35
 
29
36
  const RECENT_RUNS = 5
@@ -87,7 +94,7 @@ export function CNPGBackupSummary({ resource, workspace, onNavigate }: SummaryPr
87
94
  <SummaryShell>
88
95
  <ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'Backup')} onNavigate={onNavigate} />
89
96
 
90
- <SummaryHeading>Outcome</SummaryHeading>
97
+ <SectionHeading>Outcome</SectionHeading>
91
98
  <FactGrid>
92
99
  <FactRow label="Phase">
93
100
  <PhaseBadge status={status} />
@@ -109,7 +116,7 @@ export function CNPGBackupSummary({ resource, workspace, onNavigate }: SummaryPr
109
116
  )}
110
117
  </FactGrid>
111
118
 
112
- <SummaryHeading>Relationships</SummaryHeading>
119
+ <SectionHeading>Relationships</SectionHeading>
113
120
  <FactGrid>
114
121
  <FactRow label="Cluster">
115
122
  <ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
@@ -152,22 +159,62 @@ export function CNPGBackupSummary({ resource, workspace, onNavigate }: SummaryPr
152
159
  )
153
160
  }
154
161
 
155
- export function CNPGScheduledBackupSummary({ resource, workspace, onNavigate }: SummaryProps) {
162
+ export function CNPGSchedulePreviewFacts({ preview }: { preview: CNPGSchedulePreview }) {
163
+ if (!preview.valid) {
164
+ return (
165
+ <FactRow label="Reading">
166
+ <span className={toneTextClass('degraded')}>Not a schedule the operator can run: {preview.error}</span>
167
+ </FactRow>
168
+ )
169
+ }
170
+ return (
171
+ <>
172
+ {preview.description && <FactRow label="Reading">{preview.description}{!preview.clock?.declared && <span className="ml-1 text-theme-text-tertiary">· UTC estimate</span>}</FactRow>}
173
+ <FactRow label={preview.clock?.declared ? 'Calculated upcoming times' : 'Estimated upcoming times'}>
174
+ <ul className="space-y-0.5">
175
+ {(preview.nextRuns ?? []).map((r, i) => {
176
+ const t = formatCNPGRunTime(r)
177
+ return (
178
+ <li key={r} className="font-mono text-xs">
179
+ <Tooltip content={`Your time: ${t.local}`} position="top">
180
+ <span>{i === 0 && preview.runsImmediately ? `due (${t.utc})` : t.utc}</span>
181
+ </Tooltip>
182
+ </li>
183
+ )
184
+ })}
185
+ </ul>
186
+ <div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">{cnpgScheduleBasisNote(preview)}</div>
187
+ </FactRow>
188
+ </>
189
+ )
190
+ }
191
+
192
+ export function CNPGScheduledBackupSummary({
193
+ resource,
194
+ workspace,
195
+ onNavigate,
196
+ schedulePreview,
197
+ }: SummaryProps & {
198
+ /** The server's reading of spec.schedule; without it the schedule is shown verbatim only. */
199
+ schedulePreview?: CNPGSchedulePreview
200
+ }) {
156
201
  const ns = resource?.metadata?.namespace ?? ''
157
202
  const cron = resource?.spec?.schedule
158
203
  const next = getCNPGScheduledBackupNextSchedule(resource)
159
204
  const runsUnavailable = relationUnavailable(workspace, 'backups', ns, 'Backups')
160
205
  const runs = runsUnavailable ? [] : backupsForScheduledBackup(resource, workspaceList(workspace, 'backups'))
161
206
  const shown = runs.slice(0, RECENT_RUNS)
207
+ const blocker = resource?.spec?.suspend === true ? null : cnpgScheduleDestinationBlocker(resource, clustersIn(workspace))
162
208
 
163
209
  return (
164
210
  <SummaryShell>
165
211
  <ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'ScheduledBackup')} onNavigate={onNavigate} />
166
212
 
167
- <SummaryHeading>Schedule</SummaryHeading>
213
+ <SectionHeading>Schedule</SectionHeading>
168
214
  <FactGrid>
169
215
  <FactRow label="Status">
170
- <PhaseBadge status={getCNPGScheduledBackupStatus(resource)} />
216
+ <span className="inline-flex flex-wrap items-center gap-2"><PhaseBadge status={getCNPGScheduledBackupStatus(resource)} />{blocker && <Badge severity="warning" size="sm">{blocker}</Badge>}</span>
217
+ {blocker && <Note>The resource list status comes from the ScheduledBackup alone.</Note>}
171
218
  </FactRow>
172
219
  <FactRow label="Cluster">
173
220
  <ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
@@ -182,14 +229,15 @@ export function CNPGScheduledBackupSummary({ resource, workspace, onNavigate }:
182
229
  <NotReported text="Not set" />
183
230
  )}
184
231
  </FactRow>
232
+ {cron && schedulePreview && schedulePreview.schedule === cron && <CNPGSchedulePreviewFacts preview={schedulePreview} />}
185
233
  <FactRow label="Last scheduled">
186
234
  <TimeAgo at={resource?.status?.lastScheduleTime} missing="Never" />
187
235
  </FactRow>
188
- <FactRow label="Next scheduled">{next === '-' ? <NotReported /> : next}</FactRow>
236
+ <FactRow label="Next run reported by the operator">{next === '-' ? <NotReported /> : next}</FactRow>
189
237
  <FactRow label="Method">{methodText(resource) ?? 'Barman object store (in-tree) · default'}</FactRow>
190
238
  </FactGrid>
191
239
 
192
- <SummaryHeading hint={runs.length > shown.length ? `${shown.length} of ${runs.length}` : undefined}>Recent runs</SummaryHeading>
240
+ <SectionHeading hint={runs.length > shown.length ? `${shown.length} of ${runs.length}` : undefined}>Recent runs</SectionHeading>
193
241
  {runsUnavailable ? (
194
242
  <div className="text-sm">
195
243
  <NotReported text={runsUnavailable} />