@skyhook-io/k8s-ui 1.12.0 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/applications/ApplicationDetail.tsx +2 -2
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +419 -9
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +13 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +5 -2
- package/src/types/core.ts +36 -4
- package/src/types/policy.ts +153 -2
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { TopologyFilterSidebar } from "./TopologyFilterSidebar";
|
|
4
|
+
|
|
5
|
+
describe("TopologyFilterSidebar Calico policy category", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
"CalicoNetworkPolicy",
|
|
8
|
+
"CalicoGlobalNetworkPolicy",
|
|
9
|
+
"CalicoStagedNetworkPolicy",
|
|
10
|
+
"CalicoStagedGlobalNetworkPolicy",
|
|
11
|
+
"CalicoStagedKubernetesNetworkPolicy",
|
|
12
|
+
])("places %s under Custom Resources with its kind color", (kind) => {
|
|
13
|
+
const html = renderToString(
|
|
14
|
+
<TopologyFilterSidebar
|
|
15
|
+
nodes={[
|
|
16
|
+
{ id: "policy", kind, name: "policy", status: "healthy", data: {} },
|
|
17
|
+
]}
|
|
18
|
+
visibleKinds={new Set([kind])}
|
|
19
|
+
onToggleKind={() => {}}
|
|
20
|
+
onShowAll={() => {}}
|
|
21
|
+
onHideAll={() => {}}
|
|
22
|
+
/>,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(html).toContain("Custom Resources");
|
|
26
|
+
expect(html).not.toContain(">Networking<");
|
|
27
|
+
expect(html).toContain(
|
|
28
|
+
kind.includes("Staged") ? "text-amber-400" : "text-teal-400",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -34,6 +34,11 @@ const RESOURCE_KINDS: {
|
|
|
34
34
|
{ kind: 'TCPRoute', label: 'TCPRoute', icon: getTopologyIcon('TCPRoute'), color: 'text-purple-300', category: 'networking' },
|
|
35
35
|
{ kind: 'TLSRoute', label: 'TLSRoute', icon: getTopologyIcon('TLSRoute'), color: 'text-purple-300', category: 'networking' },
|
|
36
36
|
{ kind: 'Service', label: 'Service', icon: getTopologyIcon('Service'), color: 'text-blue-400', category: 'networking' },
|
|
37
|
+
{ kind: 'CalicoNetworkPolicy', label: 'Calico NetworkPolicy', icon: getTopologyIcon('CalicoNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
|
|
38
|
+
{ kind: 'CalicoGlobalNetworkPolicy', label: 'Calico GlobalNetworkPolicy', icon: getTopologyIcon('CalicoGlobalNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
|
|
39
|
+
{ kind: 'CalicoStagedNetworkPolicy', label: 'Calico StagedNetworkPolicy', icon: getTopologyIcon('CalicoStagedNetworkPolicy'), color: 'text-amber-400', category: 'custom' },
|
|
40
|
+
{ kind: 'CalicoStagedGlobalNetworkPolicy', label: 'Calico StagedGlobalNetworkPolicy', icon: getTopologyIcon('CalicoStagedGlobalNetworkPolicy'), color: 'text-amber-400', category: 'custom' },
|
|
41
|
+
{ kind: 'CalicoStagedKubernetesNetworkPolicy', label: 'Calico StagedKubernetesNetworkPolicy', icon: getTopologyIcon('CalicoStagedKubernetesNetworkPolicy'), color: 'text-amber-400', category: 'custom' },
|
|
37
42
|
|
|
38
43
|
// Workloads
|
|
39
44
|
{ kind: 'Deployment', label: 'Deployment', icon: getTopologyIcon('Deployment'), color: 'text-emerald-400', category: 'workloads' },
|
|
@@ -68,15 +68,15 @@ const EDGE_LEGEND: { label: string; color: string }[] = [
|
|
|
68
68
|
// Memoized edge style cache to avoid creating new objects on every render
|
|
69
69
|
const edgeStyleCache = new Map<string, React.CSSProperties>()
|
|
70
70
|
|
|
71
|
-
function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean): React.CSSProperties {
|
|
72
|
-
const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}`
|
|
71
|
+
function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean, partial: boolean): React.CSSProperties {
|
|
72
|
+
const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}-${partial}`
|
|
73
73
|
let style = edgeStyleCache.get(cacheKey)
|
|
74
74
|
if (!style) {
|
|
75
75
|
const edgeColor = getEdgeColor(type, isTrafficView)
|
|
76
76
|
style = {
|
|
77
77
|
stroke: edgeColor,
|
|
78
78
|
strokeWidth: isTrafficView ? 2 : 1.5,
|
|
79
|
-
strokeDasharray: isTrafficView && isTrafficEdge && animated ? '5 5' : undefined,
|
|
79
|
+
strokeDasharray: partial ? '6 3' : isTrafficView && isTrafficEdge && animated ? '5 5' : undefined,
|
|
80
80
|
}
|
|
81
81
|
edgeStyleCache.set(cacheKey, style)
|
|
82
82
|
}
|
|
@@ -168,7 +168,7 @@ function buildEdges(
|
|
|
168
168
|
// label in the key: two route edges between the same source/target/type but
|
|
169
169
|
// with different outcomes (one verified, one unreachable) are DISTINCT routes -
|
|
170
170
|
// collapsing them would drop the failing route from the diagram.
|
|
171
|
-
const edgeId = `${source}-${target}-${edge.type}${edge.reachOutcome ? `-${edge.reachOutcome}` : ''}${edge.label ? `-${edge.label}` : ''}`
|
|
171
|
+
const edgeId = `${source}-${target}-${edge.type}${edge.partial ? '-partial' : ''}${edge.reachOutcome ? `-${edge.reachOutcome}` : ''}${edge.label ? `-${edge.label}` : ''}`
|
|
172
172
|
if (seenEdgeIds.has(edgeId)) continue
|
|
173
173
|
seenEdgeIds.add(edgeId)
|
|
174
174
|
|
|
@@ -176,7 +176,7 @@ function buildEdges(
|
|
|
176
176
|
const edgeColor = reach ? (REACH_COLORS[reach] || '#94a3b8') : getEdgeColor(edge.type, isTrafficView)
|
|
177
177
|
const isTrafficEdge = edge.type === 'routes-to' || edge.type === 'exposes'
|
|
178
178
|
// A reachability edge never animates (a dashed "blocked" must not look like flow).
|
|
179
|
-
const animated = enableAnimations && isTrafficView && isTrafficEdge && !reach
|
|
179
|
+
const animated = enableAnimations && isTrafficView && isTrafficEdge && !reach && !edge.partial
|
|
180
180
|
|
|
181
181
|
edges.push({
|
|
182
182
|
id: edgeId,
|
|
@@ -196,7 +196,7 @@ function buildEdges(
|
|
|
196
196
|
width: 12,
|
|
197
197
|
height: 12,
|
|
198
198
|
},
|
|
199
|
-
style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated),
|
|
199
|
+
style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated, edge.partial === true),
|
|
200
200
|
})
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -51,6 +51,13 @@ function getKindColor(kind: string): string {
|
|
|
51
51
|
return 'text-purple-300'
|
|
52
52
|
case 'Service':
|
|
53
53
|
return 'text-blue-400'
|
|
54
|
+
case 'CalicoNetworkPolicy':
|
|
55
|
+
case 'CalicoGlobalNetworkPolicy':
|
|
56
|
+
return 'text-teal-400'
|
|
57
|
+
case 'CalicoStagedNetworkPolicy':
|
|
58
|
+
case 'CalicoStagedGlobalNetworkPolicy':
|
|
59
|
+
case 'CalicoStagedKubernetesNetworkPolicy':
|
|
60
|
+
return 'text-amber-400'
|
|
54
61
|
case 'Deployment':
|
|
55
62
|
case 'Rollout':
|
|
56
63
|
return 'text-emerald-400'
|
|
@@ -865,6 +865,9 @@ const KIND_PRIORITY: Record<string, number> = {
|
|
|
865
865
|
'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
|
|
866
866
|
'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
|
|
867
867
|
'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7,
|
|
868
|
+
'CalicoNetworkPolicy': 10, 'CalicoGlobalNetworkPolicy': 10,
|
|
869
|
+
'CalicoStagedNetworkPolicy': 10, 'CalicoStagedGlobalNetworkPolicy': 10,
|
|
870
|
+
'CalicoStagedKubernetesNetworkPolicy': 10,
|
|
868
871
|
'ReplicaSet': 8, 'Pod': 9, 'PodGroup': 9,
|
|
869
872
|
'ConfigMap': 10, 'Secret': 10, 'ServiceAccount': 10, 'SealedSecret': 10, 'ServiceMonitor': 10, 'PodMonitor': 10, 'WorkflowTemplate': 10, 'ClusterWorkflowTemplate': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10,
|
|
870
873
|
'KnativeService': 1, 'KnativeConfiguration': 3, 'KnativeRevision': 4, 'KnativeRoute': 2,
|
|
@@ -270,6 +270,7 @@ function ReachabilityBoard(props: BoardProps) {
|
|
|
270
270
|
actions={
|
|
271
271
|
<ReachActions
|
|
272
272
|
{...props}
|
|
273
|
+
runVantage={trace.runVantage}
|
|
273
274
|
inClusterRunnable={traceInClusterRunnable(trace)}
|
|
274
275
|
inClusterTested={origins.some((o) => o.id === 'incluster' && o.mark !== 'untested')}
|
|
275
276
|
supportsHTTPPath={(trace.routes ?? []).some((r) => {
|
|
@@ -87,6 +87,7 @@ export interface InClusterCapability {
|
|
|
87
87
|
allowed: boolean
|
|
88
88
|
reason?: string
|
|
89
89
|
cluster?: string
|
|
90
|
+
clusterKey?: string
|
|
90
91
|
namespace: string
|
|
91
92
|
/** Per-call ceiling on probe Pods. Routes past it never run - ordering is
|
|
92
93
|
* deterministic, so re-running starts from the same first maxProbes. */
|
|
@@ -384,7 +385,7 @@ function ProbeOptionsMenu({ probePath, onApplyProbePath }: { probePath?: string;
|
|
|
384
385
|
// test" (proxy/laptop vantage) plus, once allowed, the secondary "Test inside the
|
|
385
386
|
// cluster" (real pod-to-pod) - one grouped control instead of two split corners.
|
|
386
387
|
// Uses the design-system button classes (.btn-brand / .btn-brand-muted).
|
|
387
|
-
export function ReachActions({ onRunProbes, probeRequested, probed, onRunInCluster, inClusterRunning, inClusterAllowed, inClusterRunnable, inClusterTested, probePath, onApplyProbePath, supportsHTTPPath = true }: {
|
|
388
|
+
export function ReachActions({ onRunProbes, probeRequested, probed, onRunInCluster, inClusterRunning, inClusterAllowed, inClusterRunnable, inClusterTested, probePath, onApplyProbePath, supportsHTTPPath = true, runVantage }: {
|
|
388
389
|
onRunProbes?: () => void
|
|
389
390
|
probeRequested?: boolean
|
|
390
391
|
// probed/inClusterTested: each test has already produced a result, so its
|
|
@@ -403,6 +404,10 @@ export function ReachActions({ onRunProbes, probeRequested, probed, onRunInClust
|
|
|
403
404
|
probePath?: string
|
|
404
405
|
onApplyProbePath?: (p: string) => void
|
|
405
406
|
supportsHTTPPath?: boolean
|
|
407
|
+
/** Where Radar itself runs. The primary test is sent by the Radar PROCESS, so
|
|
408
|
+
* "from your machine" is only true when Radar is on one - a hosted or
|
|
409
|
+
* in-cluster Radar dials from its own Pod. */
|
|
410
|
+
runVantage?: 'in-cluster' | 'local'
|
|
406
411
|
}) {
|
|
407
412
|
// The in-cluster test stays available whenever the cluster allows it - NEVER
|
|
408
413
|
// gated on the verdict. Hiding it after it succeeds (when the verdict turns
|
|
@@ -437,7 +442,13 @@ export function ReachActions({ onRunProbes, probeRequested, probed, onRunInClust
|
|
|
437
442
|
{/* Primary: the reachability test. The refresh icon signals it's
|
|
438
443
|
re-runnable (it auto-ran on load), so re-running reads as a refresh. */}
|
|
439
444
|
{onRunProbes && (
|
|
440
|
-
<Tooltip
|
|
445
|
+
<Tooltip
|
|
446
|
+
content={
|
|
447
|
+
runVantage === 'in-cluster'
|
|
448
|
+
? 'Tests reachability from Radar’s own Pod and through the API server. Covers EVERY path on this resource, not only the one selected above.'
|
|
449
|
+
: 'Tests reachability from your machine and through the API server. Covers EVERY path on this resource, not only the one selected above.'
|
|
450
|
+
}
|
|
451
|
+
>
|
|
441
452
|
<button
|
|
442
453
|
type="button"
|
|
443
454
|
onClick={onRunProbes}
|
|
@@ -12,7 +12,7 @@ import type { Trace, PodStatus, ProbeResult } from './types'
|
|
|
12
12
|
* so two disagreeing origins cannot both be retained. Faking it here would
|
|
13
13
|
* imply a capability the model does not have.
|
|
14
14
|
*/
|
|
15
|
-
export type DevState = 'mixed' | 'break' | 'front' | 'synth' | 'scale' | 'fanout' | 'none' | 'running' | 'stale' | 'rbac'
|
|
15
|
+
export type DevState = 'mixed' | 'break' | 'front' | 'synth' | 'scale' | 'fanout' | 'none' | 'running' | 'stale' | 'rbac' | 'hosted'
|
|
16
16
|
|
|
17
17
|
export const DEV_STATES: { id: DevState; label: string }[] = [
|
|
18
18
|
{ id: 'mixed', label: '1 · Mixed vantages' },
|
|
@@ -25,6 +25,7 @@ export const DEV_STATES: { id: DevState; label: string }[] = [
|
|
|
25
25
|
{ id: 'running', label: 'Probe running' },
|
|
26
26
|
{ id: 'stale', label: 'Stale + changed' },
|
|
27
27
|
{ id: 'rbac', label: 'Probe not permitted' },
|
|
28
|
+
{ id: 'hosted', label: 'Radar runs in-cluster' },
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
const NS = 'payments'
|
|
@@ -233,6 +234,30 @@ export function devTrace(state: DevState): Trace {
|
|
|
233
234
|
}
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
// Radar deployed as a Pod - the hosted and self-hosted-in-cluster shape. The
|
|
238
|
+
// only way to see the vantage rail as those users see it without running a
|
|
239
|
+
// cluster-side Radar: no workstation vantage exists there, and Radar's own
|
|
240
|
+
// process is a first-class origin rather than the throwaway Job.
|
|
241
|
+
if (state === 'hosted') {
|
|
242
|
+
return {
|
|
243
|
+
...t,
|
|
244
|
+
runVantage: 'in-cluster',
|
|
245
|
+
headline: 'Reachable in-cluster on :80 — the front door is still unproven',
|
|
246
|
+
coverage: { tested: 3, passed: 3, failed: 0, skipped: 1 },
|
|
247
|
+
downstream: [
|
|
248
|
+
serviceHop(),
|
|
249
|
+
podsHop(PODS, [
|
|
250
|
+
...okProbes.map((p) => ({ ...p, source: 'radar' as const })),
|
|
251
|
+
probe({ path: 'apiserver', target: 'checkout-api port 80', detail: 'HTTP 200 via proxy', latencyNs: 12_000_000 }),
|
|
252
|
+
]),
|
|
253
|
+
],
|
|
254
|
+
routes: [
|
|
255
|
+
{ route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'verified', confidence: 'real', evidence: 'DNS 2 ms · TCP 1 ms · HTTP 200 · 11 ms' },
|
|
256
|
+
],
|
|
257
|
+
notTested: [{ route: 'front door · primary-gateway', reason: 'no request has entered through the Gateway', reasonClass: 'coverage' }],
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
if (state === 'synth') {
|
|
237
262
|
return {
|
|
238
263
|
...t,
|
|
@@ -65,6 +65,26 @@ describe('buildGraph lanes', () => {
|
|
|
65
65
|
}
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
// The board must not offer a vantage the deployment cannot use: a Radar
|
|
69
|
+
// running as a Pod dials from that Pod, never from the operator's machine.
|
|
70
|
+
it('draws no workstation capsule when Radar runs in the cluster', () => {
|
|
71
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ source: 'radar' })])
|
|
72
|
+
t.runVantage = 'in-cluster'
|
|
73
|
+
const os = originsFor(t)
|
|
74
|
+
const g = buildGraph({ trace: t, route: route(), origin: os.find((o) => o.id === 'radar-incluster')!, origins: os })
|
|
75
|
+
expect(g.nodes.find((n) => n.id === 'origin:local')).toBeUndefined()
|
|
76
|
+
expect(g.nodes.filter((n) => n.isOrigin).map((n) => n.id).sort()).toEqual(['origin:apiserver', 'origin:incluster', 'origin:radar-incluster'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('drops the two-mechanism control-lane note with it', () => {
|
|
80
|
+
const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
|
|
81
|
+
t.runVantage = 'in-cluster'
|
|
82
|
+
const os = originsFor(t)
|
|
83
|
+
const g = buildGraph({ trace: t, route: route(), origin: os.find((o) => o.id === 'apiserver')!, origins: os })
|
|
84
|
+
expect(g.laneControl!.help).not.toMatch(/your machine/)
|
|
85
|
+
expect(g.laneControl!.help).toMatch(/control plane/)
|
|
86
|
+
})
|
|
87
|
+
|
|
68
88
|
it('the origin is drawn as a vantage capsule, never as a Kubernetes resource', () => {
|
|
69
89
|
const t = trace([pod('a', true, '10.0.0.1')], [p({})])
|
|
70
90
|
const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
|
|
@@ -1425,10 +1425,17 @@ export function buildGraph({ trace, route, origin, origins, stale, running }: Bu
|
|
|
1425
1425
|
const y1 = Math.max(...list.map((n) => n.y + n.h)) + LANE_PAD.bottom
|
|
1426
1426
|
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0, label, help, color, dashed }
|
|
1427
1427
|
}
|
|
1428
|
+
// The two-mechanism wording only holds while the workstation vantage is drawn.
|
|
1429
|
+
// With Radar running as a Pod there is no dial from a machine outside, and
|
|
1430
|
+
// contrasting it with the relay described a lane with one member as if it had
|
|
1431
|
+
// two.
|
|
1432
|
+
const laptopDrawn = nodes.some((n) => n.id === 'origin:local')
|
|
1428
1433
|
const laneControl = boxFor(
|
|
1429
1434
|
nodes.filter((n) => n.lane === 'control'),
|
|
1430
1435
|
'NOT FROM INSIDE THE CLUSTER',
|
|
1431
|
-
|
|
1436
|
+
laptopDrawn
|
|
1437
|
+
? 'Neither request originates inside the cluster’s dataplane, but they differ: a dial from your machine sends real traffic from outside - through a real entry it exercises the path from the entry inward (routing, NetworkPolicy, mesh included). A relayed request is carried by the Kubernetes control plane and bypasses the traffic path entirely.'
|
|
1438
|
+
: 'This request is carried by the Kubernetes control plane rather than sent along the traffic path: the API server relays it to the target, so routing, NetworkPolicy and the mesh are all bypassed.',
|
|
1432
1439
|
'var(--color-info)',
|
|
1433
1440
|
true,
|
|
1434
1441
|
)
|
|
@@ -218,6 +218,37 @@ describe('Radar-as-a-Pod is not the throwaway probe Job', () => {
|
|
|
218
218
|
expect(radar?.unsupported).toBeFalsy()
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
// The mirror rule. A Radar that runs as a Pod has no process on the operator's
|
|
222
|
+
// machine, so a workstation vantage there is not a test anyone can run - it is
|
|
223
|
+
// a control that would never do anything, standing where a real gap should be.
|
|
224
|
+
it('omits the workstation vantage entirely when Radar runs in the cluster', () => {
|
|
225
|
+
const t = traceWith([p({ vantage: 'in-cluster', path: 'data', source: 'radar', ok: true })])
|
|
226
|
+
t.runVantage = 'in-cluster'
|
|
227
|
+
const os = buildOrigins(t)
|
|
228
|
+
expect(os.find((o) => o.id === 'local')).toBeUndefined()
|
|
229
|
+
expect(os.map((o) => o.id)).toEqual(['caller', 'external', 'incluster', 'radar-incluster', 'apiserver'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('keeps the external gap declared there — it is what nothing can prove', () => {
|
|
233
|
+
const t = traceWith([p({ vantage: 'in-cluster', path: 'data', source: 'radar', ok: true })])
|
|
234
|
+
t.runVantage = 'in-cluster'
|
|
235
|
+
expect(buildOrigins(t).find((o) => o.id === 'external')?.unsupported).toBe(true)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Hiding a vantage must never hide evidence. If a laptop-side result somehow
|
|
239
|
+
// arrives on an in-cluster trace, the row it belongs to has to stay.
|
|
240
|
+
it('keeps the workstation vantage when it actually produced a result', () => {
|
|
241
|
+
const t = traceWith([p({ vantage: 'local', path: 'data', ok: true })])
|
|
242
|
+
t.runVantage = 'in-cluster'
|
|
243
|
+
expect(buildOrigins(t).find((o) => o.id === 'local')).toBeDefined()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('keeps it on a laptop trace that has not been probed yet', () => {
|
|
247
|
+
const t = traceWith([])
|
|
248
|
+
t.runVantage = 'local'
|
|
249
|
+
expect(buildOrigins(t).find((o) => o.id === 'local')).toBeDefined()
|
|
250
|
+
})
|
|
251
|
+
|
|
221
252
|
it('describes Radar-in-cluster with Radar\'s own identity, not the Job\'s', () => {
|
|
222
253
|
const t = traceWith([p({ vantage: 'in-cluster', path: 'data', source: 'radar', ok: true })])
|
|
223
254
|
t.runVantage = 'in-cluster'
|
|
@@ -357,7 +357,17 @@ export function buildOrigins(trace: Trace | undefined, ctx: OriginContext = {}):
|
|
|
357
357
|
// same ground. Listing it as "never tested" would pad the coverage caveat with
|
|
358
358
|
// a deployment detail, next to genuine holes like "as your application".
|
|
359
359
|
const radarIsAbsent = radarProbes.length === 0 && trace?.runVantage !== 'in-cluster'
|
|
360
|
-
|
|
360
|
+
// The mirror image, and the same reasoning. When Radar runs as a Pod - hosted,
|
|
361
|
+
// or self-hosted in-cluster - there is no Radar on the operator's machine to
|
|
362
|
+
// dial from, so this vantage is not one that can be run and then isn't. The
|
|
363
|
+
// browser being on a laptop is irrelevant: the request is sent by the Radar
|
|
364
|
+
// process. What a machine outside the cluster would see is still declared, by
|
|
365
|
+
// the external origin, which is a real gap and stays. Guarded on having no
|
|
366
|
+
// probes so a mislabelled result can never hide evidence that exists.
|
|
367
|
+
const laptopIsAbsent = localProbes.length === 0 && trace?.runVantage === 'in-cluster'
|
|
368
|
+
return ORIGIN_STRENGTH.filter(
|
|
369
|
+
(id) => !(id === 'radar-incluster' && radarIsAbsent) && !(id === 'local' && laptopIsAbsent),
|
|
370
|
+
).map((id) => all[id])
|
|
361
371
|
}
|
|
362
372
|
|
|
363
373
|
/**
|
|
@@ -102,6 +102,11 @@ const KIND: Record<string, string> = {
|
|
|
102
102
|
PersistentVolumeClaim: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
|
|
103
103
|
PodDisruptionBudget: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
|
|
104
104
|
NetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
105
|
+
CalicoNetworkPolicy: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-300 dark:border-teal-700/40',
|
|
106
|
+
CalicoGlobalNetworkPolicy: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-300 dark:border-teal-700/40',
|
|
107
|
+
CalicoStagedNetworkPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
108
|
+
CalicoStagedGlobalNetworkPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
109
|
+
CalicoStagedKubernetesNetworkPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
105
110
|
CiliumNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
106
111
|
CiliumClusterwideNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
107
112
|
ClusterNetworkPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
@@ -263,6 +268,11 @@ export function getKindColorClass(kind: string): string {
|
|
|
263
268
|
persistentvolumeclaims: 'PersistentVolumeClaim',
|
|
264
269
|
poddisruptionbudgets: 'PodDisruptionBudget',
|
|
265
270
|
networkpolicies: 'NetworkPolicy',
|
|
271
|
+
caliconetworkpolicies: 'CalicoNetworkPolicy',
|
|
272
|
+
calicoglobalnetworkpolicies: 'CalicoGlobalNetworkPolicy',
|
|
273
|
+
calicostagednetworkpolicies: 'CalicoStagedNetworkPolicy',
|
|
274
|
+
calicostagedglobalnetworkpolicies: 'CalicoStagedGlobalNetworkPolicy',
|
|
275
|
+
calicostagedkubernetesnetworkpolicies: 'CalicoStagedKubernetesNetworkPolicy',
|
|
266
276
|
ciliumnetworkpolicies: 'CiliumNetworkPolicy',
|
|
267
277
|
ciliumclusterwidenetworkpolicies: 'CiliumClusterwideNetworkPolicy',
|
|
268
278
|
clusternetworkpolicies: 'ClusterNetworkPolicy',
|
|
@@ -13,6 +13,7 @@ export interface ConsentRequest {
|
|
|
13
13
|
interface InClusterConsentDialogProps {
|
|
14
14
|
open: boolean
|
|
15
15
|
cluster?: string
|
|
16
|
+
clusterKey?: string
|
|
16
17
|
namespace: string
|
|
17
18
|
/** The requests this run will send, one per declared route. */
|
|
18
19
|
requests?: ConsentRequest[]
|
|
@@ -85,7 +86,7 @@ function cluster(name?: string): string {
|
|
|
85
86
|
// Confirms the mutating in-cluster reachability test before it spawns a Job/pod,
|
|
86
87
|
// naming the cluster it lands in. Permission is enforced upstream (the button only
|
|
87
88
|
// renders when the capability SSAR allows), so this is a safety confirm, not authz.
|
|
88
|
-
export function InClusterConsentDialog({ open, cluster, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
|
|
89
|
+
export function InClusterConsentDialog({ open, cluster, clusterKey, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
|
|
89
90
|
const [dontAskAgain, setDontAskAgain] = useState(false)
|
|
90
91
|
|
|
91
92
|
function handleClose() {
|
|
@@ -94,7 +95,7 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
function handleConfirm() {
|
|
97
|
-
if (dontAskAgain &&
|
|
98
|
+
if (dontAskAgain && clusterKey) rememberInClusterConsent(clusterKey)
|
|
98
99
|
setDontAskAgain(false)
|
|
99
100
|
onConfirm()
|
|
100
101
|
}
|
|
@@ -117,11 +118,11 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
|
|
|
117
118
|
cancelLabel="Cancel"
|
|
118
119
|
variant="warning"
|
|
119
120
|
>
|
|
120
|
-
{/* "Don't ask again" is stored per cluster. Without a
|
|
121
|
+
{/* "Don't ask again" is stored per cluster source. Without a source identity
|
|
121
122
|
there is nothing safe to key it on (one origin can front many
|
|
122
123
|
clusters), so consent is never remembered - hide the checkbox rather
|
|
123
124
|
than promise a persistence that doesn't stick. */}
|
|
124
|
-
{
|
|
125
|
+
{clusterKey ? (
|
|
125
126
|
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
126
127
|
<input
|
|
127
128
|
type="checkbox"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { CronValue, TimeValue, cronHint } from './ScheduleValue'
|
|
4
|
+
|
|
5
|
+
const render = (node: React.ReactElement) => renderToString(node)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The expression stays on screen verbatim — it is the data operators copy and
|
|
9
|
+
* diff. What is under test is the annotation around it, and above all the cases
|
|
10
|
+
* where there must not be one.
|
|
11
|
+
*/
|
|
12
|
+
describe('CronValue', () => {
|
|
13
|
+
it('keeps the expression exactly as written', () => {
|
|
14
|
+
expect(render(<CronValue cron="0 2 * * *" />)).toContain('0 2 * * *')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// The tooltip renders into a portal on hover, so the markup carries the
|
|
18
|
+
// affordance rather than the wording. Without the affordance it is a secret.
|
|
19
|
+
it('marks a schedule it understands as worth hovering', () => {
|
|
20
|
+
const html = render(<CronValue cron="0 2 * * *" />)
|
|
21
|
+
expect(html).toContain('cursor-help')
|
|
22
|
+
expect(html).toContain('border-dotted')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reads the shapes these policies actually use', () => {
|
|
26
|
+
expect(cronHint('0 2 * * *')).toBe('Daily at 2:00')
|
|
27
|
+
expect(cronHint('0 */6 * * *')).toBe('Every 6 hours')
|
|
28
|
+
expect(cronHint('* * * * *')).toBe('Every minute')
|
|
29
|
+
expect(cronHint('30 3 * * 1-5')).toBe('Weekdays at 3:30')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('offers no reading it cannot stand behind', () => {
|
|
33
|
+
expect(cronHint('0 2 1 * *')).toBeUndefined()
|
|
34
|
+
expect(cronHint('0 0 2 * * *', 'seconds')).toBeUndefined()
|
|
35
|
+
expect(cronHint('0 2 * * *', 'seconds')).toBeUndefined()
|
|
36
|
+
expect(cronHint('')).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// A tooltip that repeats the value teaches the reader that the underline is
|
|
40
|
+
// not worth hovering, which costs more than the one it would have explained.
|
|
41
|
+
it('stays a plain value when it cannot describe the schedule', () => {
|
|
42
|
+
const monthly = render(<CronValue cron="0 2 1 * *" />)
|
|
43
|
+
expect(monthly).toContain('0 2 1 * *')
|
|
44
|
+
expect(monthly).not.toContain('cursor-help')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// CloudNativePG counts seconds first. Read as five fields, `0 0 2 * * *`
|
|
48
|
+
// describes midnight rather than 2am — a backup window stated an hour wrong.
|
|
49
|
+
it('never reads a seconds-first expression as if it were five fields', () => {
|
|
50
|
+
const html = render(<CronValue cron="0 0 2 * * *" dialect="seconds" />)
|
|
51
|
+
expect(html).toContain('0 0 2 * * *')
|
|
52
|
+
expect(html).not.toContain('cursor-help')
|
|
53
|
+
expect(html).not.toContain('Daily at')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// The same guard from the other direction: even a five-field expression is
|
|
57
|
+
// left alone once the field is declared seconds-first, so a hand-written
|
|
58
|
+
// value cannot pick up the wrong dialect's meaning.
|
|
59
|
+
it('leaves a five-field expression alone in a seconds-first field', () => {
|
|
60
|
+
const html = render(<CronValue cron="0 2 * * *" dialect="seconds" />)
|
|
61
|
+
expect(html).not.toContain('Daily at 2:00')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders nothing surprising for an empty schedule', () => {
|
|
65
|
+
expect(render(<CronValue cron="" />)).not.toContain('cursor-help')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('TimeValue', () => {
|
|
70
|
+
it('answers "is this recent" rather than printing a machine timestamp', () => {
|
|
71
|
+
const html = render(<TimeValue timestamp={new Date(Date.now() - 6 * 3600_000).toISOString()} />)
|
|
72
|
+
expect(html).toContain('cursor-help')
|
|
73
|
+
// The RFC3339 string is what the hover carries, not what the row shows.
|
|
74
|
+
expect(html).not.toMatch(/\dT\d\d:\d\d:\d\dZ/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('says so when the thing never happened', () => {
|
|
78
|
+
expect(render(<TimeValue timestamp={undefined} fallback="Never" />)).toContain('Never')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Tooltip } from './Tooltip'
|
|
2
|
+
import { cronToHuman, formatAge } from '../resources/resource-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hover affordance used across the drawer: without it a tooltip is a secret.
|
|
6
|
+
* Matches the Created row in drawer-components and the QoS label in PodRenderer.
|
|
7
|
+
*/
|
|
8
|
+
const HOVERABLE = 'border-b border-dotted border-theme-text-tertiary cursor-help'
|
|
9
|
+
|
|
10
|
+
/** Which cron dialect the operator that owns this field parses. */
|
|
11
|
+
export type CronDialect =
|
|
12
|
+
/** Five fields, minute first. Kubernetes CronJob, Kyverno, Velero, Argo. */
|
|
13
|
+
| 'standard'
|
|
14
|
+
/** Six fields, seconds first. CloudNativePG. */
|
|
15
|
+
| 'seconds'
|
|
16
|
+
|
|
17
|
+
interface CronValueProps {
|
|
18
|
+
cron: string
|
|
19
|
+
/**
|
|
20
|
+
* Defaults to the Kubernetes dialect. Pass 'seconds' for CloudNativePG: read
|
|
21
|
+
* as five fields, `0 0 2 * * *` describes midnight instead of 2am, and a
|
|
22
|
+
* backup window stated an hour wrong is worse than one left unexplained.
|
|
23
|
+
*/
|
|
24
|
+
dialect?: CronDialect
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A cron expression, verbatim, with its meaning on hover.
|
|
29
|
+
*
|
|
30
|
+
* The expression stays the value because it is the data — operators copy it,
|
|
31
|
+
* diff it against Git, and paste it into `kubectl`. The plain-English reading
|
|
32
|
+
* is the annotation, not the replacement.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The plain-English reading to offer for an expression, or undefined when there
|
|
36
|
+
* is none worth offering.
|
|
37
|
+
*
|
|
38
|
+
* cronToHuman returns its input when it cannot describe an expression with
|
|
39
|
+
* confidence — a monthly schedule, an unusual step. A tooltip repeating the
|
|
40
|
+
* value teaches the reader that the underline is not worth hovering, which
|
|
41
|
+
* costs more than the one it would have explained.
|
|
42
|
+
*
|
|
43
|
+
* Separate from the component because the tooltip renders into a portal on
|
|
44
|
+
* hover, so the wording is not in the server-rendered markup and cannot be
|
|
45
|
+
* asserted there.
|
|
46
|
+
*/
|
|
47
|
+
export function cronHint(cron: string, dialect: CronDialect = 'standard'): string | undefined {
|
|
48
|
+
if (!cron || dialect !== 'standard') return undefined
|
|
49
|
+
const human = cronToHuman(cron)
|
|
50
|
+
return human === cron || human === '-' ? undefined : human
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function CronValue({ cron, dialect = 'standard' }: CronValueProps) {
|
|
54
|
+
const hint = cronHint(cron, dialect)
|
|
55
|
+
if (!hint) return <span className="font-mono">{cron}</span>
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Tooltip content={hint} position="right">
|
|
59
|
+
<span className={`font-mono ${HOVERABLE}`}>{cron}</span>
|
|
60
|
+
</Tooltip>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface TimeValueProps {
|
|
65
|
+
/** RFC3339 from the apiserver. */
|
|
66
|
+
timestamp?: string
|
|
67
|
+
/** Shown when there is no timestamp. "Never" reads better than a dash on a
|
|
68
|
+
* field that records something having happened. */
|
|
69
|
+
fallback?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A moment in time as an age, with the exact local time on hover.
|
|
74
|
+
*
|
|
75
|
+
* "6h ago" answers the question actually being asked — is this recent? — and
|
|
76
|
+
* the raw RFC3339 answers it in UTC, which is nobody's wall clock.
|
|
77
|
+
*/
|
|
78
|
+
export function TimeValue({ timestamp, fallback = '-' }: TimeValueProps) {
|
|
79
|
+
if (!timestamp) return <>{fallback}</>
|
|
80
|
+
return (
|
|
81
|
+
<Tooltip content={new Date(timestamp).toLocaleString()} position="right">
|
|
82
|
+
<span className={HOVERABLE}>{formatAge(timestamp)}</span>
|
|
83
|
+
</Tooltip>
|
|
84
|
+
)
|
|
85
|
+
}
|