@skyhook-io/radar-app 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/App.tsx +2 -6
- package/src/api/client.ts +138 -6
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +15 -5
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
- package/src/components/workload/WorkloadView.tsx +55 -7
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DashboardNetworkPolicyCoverage } from '../../api/client'
|
|
2
2
|
import { ShieldCheck, ArrowRight } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
4
5
|
|
|
5
6
|
interface NetworkPolicyCoverageCardProps {
|
|
6
7
|
data: DashboardNetworkPolicyCoverage
|
|
@@ -8,9 +9,23 @@ interface NetworkPolicyCoverageCardProps {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function NetworkPolicyCoverageCard({ data, onNavigate }: NetworkPolicyCoverageCardProps) {
|
|
12
|
+
const hasStagedPolicies = (data.stagedPolicies ?? 0) > 0
|
|
13
|
+
// A staged policy can stage a deletion, so the projected coverage is allowed
|
|
14
|
+
// to be lower than today's. Clamping it would hide exactly the case an
|
|
15
|
+
// operator most needs to see before promoting the staged set.
|
|
16
|
+
const coveredIfStaged = data.coveredWorkloadsIfStaged ?? data.coveredWorkloads
|
|
17
|
+
const stagedDelta = coveredIfStaged - data.coveredWorkloads
|
|
11
18
|
const percentage = data.totalWorkloads > 0
|
|
12
19
|
? Math.round((data.coveredWorkloads / data.totalWorkloads) * 100)
|
|
13
20
|
: 0
|
|
21
|
+
const percentageIfStaged = data.totalWorkloads > 0
|
|
22
|
+
? Math.round((coveredIfStaged / data.totalWorkloads) * 100)
|
|
23
|
+
: 0
|
|
24
|
+
const enforcedPercentage = hasStagedPolicies ? Math.min(percentage, percentageIfStaged) : percentage
|
|
25
|
+
// Gated on the same condition as the segment that draws it, so the three
|
|
26
|
+
// widths always sum to the full track even for a host that supplies a
|
|
27
|
+
// projection without any staged policies.
|
|
28
|
+
const stagedDeltaPercentage = hasStagedPolicies ? Math.abs(percentageIfStaged - percentage) : 0
|
|
14
29
|
const hasPolicies = data.totalPolicies > 0
|
|
15
30
|
const accentColor = !hasPolicies
|
|
16
31
|
? 'text-theme-text-tertiary'
|
|
@@ -48,28 +63,56 @@ export function NetworkPolicyCoverageCard({ data, onNavigate }: NetworkPolicyCov
|
|
|
48
63
|
<>
|
|
49
64
|
<div className="flex items-center gap-3 w-full">
|
|
50
65
|
<div className="flex-1 h-3 rounded-full overflow-hidden bg-theme-hover flex">
|
|
51
|
-
{
|
|
66
|
+
{enforcedPercentage > 0 && (
|
|
52
67
|
<div
|
|
53
68
|
className="h-full bg-green-500"
|
|
54
|
-
style={{ width: `${
|
|
69
|
+
style={{ width: `${enforcedPercentage}%` }}
|
|
55
70
|
/>
|
|
56
71
|
)}
|
|
57
|
-
{
|
|
72
|
+
{hasStagedPolicies && stagedDeltaPercentage > 0 && (
|
|
73
|
+
// The width belongs on the flex child; the tooltip wrapper
|
|
74
|
+
// inside it carries the hover target.
|
|
75
|
+
<div className="h-full" style={{ width: `${stagedDeltaPercentage}%` }}>
|
|
76
|
+
<Tooltip
|
|
77
|
+
content={stagedDelta > 0
|
|
78
|
+
? `${stagedDelta} more workloads covered if staged policies are applied`
|
|
79
|
+
: `${-stagedDelta} workloads lose coverage if staged policies are applied`}
|
|
80
|
+
wrapperClassName="!block h-full w-full"
|
|
81
|
+
>
|
|
82
|
+
<div
|
|
83
|
+
className={clsx('h-full w-full', stagedDelta > 0 ? 'text-yellow-500' : 'text-red-500')}
|
|
84
|
+
style={{
|
|
85
|
+
backgroundImage: 'repeating-linear-gradient(135deg, currentColor 0, currentColor 2px, transparent 2px, transparent 5px)',
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
</Tooltip>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
{100 - enforcedPercentage - stagedDeltaPercentage > 0 && (
|
|
58
92
|
<div
|
|
59
93
|
className="h-full bg-theme-hover"
|
|
60
|
-
style={{ width: `${100 -
|
|
94
|
+
style={{ width: `${100 - enforcedPercentage - stagedDeltaPercentage}%` }}
|
|
61
95
|
/>
|
|
62
96
|
)}
|
|
63
97
|
</div>
|
|
64
|
-
<
|
|
65
|
-
{percentage}
|
|
66
|
-
|
|
98
|
+
<div className="flex shrink-0 flex-col items-end tabular-nums">
|
|
99
|
+
<span className={clsx('text-sm font-semibold', accentColor)}>{percentage}%</span>
|
|
100
|
+
{hasStagedPolicies && (
|
|
101
|
+
<span className="text-[10px] text-theme-text-tertiary">({percentageIfStaged}% if staged applied)</span>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
67
104
|
</div>
|
|
68
105
|
|
|
69
106
|
<div className="grid grid-cols-1 gap-y-2 mt-4 w-full">
|
|
70
107
|
<StatRow label="Policies" value={data.totalPolicies} />
|
|
71
108
|
<StatRow label="Covered workloads" value={data.coveredWorkloads} total={data.totalWorkloads} />
|
|
109
|
+
{hasStagedPolicies && (
|
|
110
|
+
<StatRow label="Covered if staged" value={coveredIfStaged} total={data.totalWorkloads} />
|
|
111
|
+
)}
|
|
72
112
|
<StatRow label="Uncovered workloads" value={data.totalWorkloads - data.coveredWorkloads} warn />
|
|
113
|
+
{hasStagedPolicies && (
|
|
114
|
+
<StatRow label="Uncovered if staged" value={data.totalWorkloads - coveredIfStaged} warn />
|
|
115
|
+
)}
|
|
73
116
|
</div>
|
|
74
117
|
</>
|
|
75
118
|
)}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import type { Topology } from '../../types'
|
|
3
|
-
import type { DashboardTopologySummary } from '../../api/client'
|
|
4
3
|
import { Network, ArrowRight } from 'lucide-react'
|
|
5
4
|
import { clsx } from 'clsx'
|
|
6
5
|
|
|
7
6
|
interface TopologyPreviewProps {
|
|
8
7
|
topology: Topology | null
|
|
9
|
-
|
|
8
|
+
namespaceSelected: boolean
|
|
10
9
|
onNavigate: () => void
|
|
11
10
|
}
|
|
12
11
|
|
|
@@ -133,9 +132,30 @@ const kindDotColors: Record<string, string> = {
|
|
|
133
132
|
ReplicaSet: 'bg-green-400', HPA: 'bg-pink-500', PVC: 'bg-cyan-400',
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
export function TopologyPreview({ topology,
|
|
135
|
+
export function TopologyPreview({ topology, namespaceSelected, onNavigate }: TopologyPreviewProps) {
|
|
137
136
|
const stats = useTopologyStats(topology)
|
|
138
137
|
|
|
138
|
+
// A large cluster with no namespace filter gets an empty graph carrying this
|
|
139
|
+
// flag instead of a build. There is no graph to preview and there never will
|
|
140
|
+
// be until the user filters, so the card says that rather than counting a
|
|
141
|
+
// refusal as zero.
|
|
142
|
+
//
|
|
143
|
+
// With a pick already active the same flag means something else entirely: SSE
|
|
144
|
+
// only learns to filter server-side after a flagged frame arrives, so a
|
|
145
|
+
// cluster-wide refusal still reaches a viewer whose namespace is on its way
|
|
146
|
+
// to the server. That resolves on its own, and asking for what the viewer
|
|
147
|
+
// already chose is the wrong thing to say while it does.
|
|
148
|
+
const needsNamespaceFilter = topology?.requiresNamespaceFilter === true && !namespaceSelected
|
|
149
|
+
|
|
150
|
+
// The graph this card draws is the only source for its caption. Node count is
|
|
151
|
+
// not the test for whether a frame counts — the stream holds topology at null
|
|
152
|
+
// until one lands and resets it there on reconnect and context switch, so a
|
|
153
|
+
// delivered graph with no nodes is an empty scope and an honest zero.
|
|
154
|
+
const counts = useMemo(() => {
|
|
155
|
+
if (!topology || topology.requiresNamespaceFilter) return null
|
|
156
|
+
return { nodeCount: topology.nodes.length, edgeCount: topology.edges.length }
|
|
157
|
+
}, [topology])
|
|
158
|
+
|
|
139
159
|
return (
|
|
140
160
|
<button
|
|
141
161
|
onClick={onNavigate}
|
|
@@ -147,13 +167,27 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
|
|
|
147
167
|
<Network className="w-4 h-4 text-theme-text-tertiary" />
|
|
148
168
|
<span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Topology</span>
|
|
149
169
|
</div>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
{counts ? (
|
|
171
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
172
|
+
{counts.nodeCount} resources · {counts.edgeCount} conn
|
|
173
|
+
</span>
|
|
174
|
+
) : needsNamespaceFilter ? null : (
|
|
175
|
+
<span className="h-3 w-28 rounded bg-theme-text-tertiary/20 animate-pulse" />
|
|
176
|
+
)}
|
|
153
177
|
</div>
|
|
154
178
|
|
|
155
179
|
{/* Stats (left) + Schematic (right) */}
|
|
156
180
|
<div className="flex-1 flex items-stretch min-h-0 px-3 py-1.5 gap-2">
|
|
181
|
+
{needsNamespaceFilter ? (
|
|
182
|
+
<div className="flex-1 flex items-center justify-center px-4">
|
|
183
|
+
<p className="text-[11px] leading-relaxed text-theme-text-tertiary text-center">
|
|
184
|
+
This cluster is too large to graph every namespace.
|
|
185
|
+
<br />
|
|
186
|
+
<span className="text-theme-text-secondary font-medium">Select a namespace</span> to view the topology.
|
|
187
|
+
</p>
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<>
|
|
157
191
|
{/* Left: compact stats */}
|
|
158
192
|
<div className="flex flex-col justify-center gap-0.5 min-w-0 w-[105px] shrink-0">
|
|
159
193
|
{stats ? (
|
|
@@ -187,16 +221,26 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
|
|
|
187
221
|
)}
|
|
188
222
|
</>
|
|
189
223
|
) : (
|
|
190
|
-
//
|
|
224
|
+
// No graph yet, or one with nothing in scope. Until a frame lands
|
|
225
|
+
// there is no honest number here — a zero would read as an empty
|
|
226
|
+
// cluster, so pulse instead.
|
|
191
227
|
<div className="flex flex-col gap-0.5">
|
|
192
228
|
<div className="flex items-center gap-1.5 text-[10px] leading-tight">
|
|
193
|
-
<span className=
|
|
194
|
-
|
|
229
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', counts ? 'bg-blue-400' : 'bg-theme-text-tertiary/30 animate-pulse')} />
|
|
230
|
+
{counts ? (
|
|
231
|
+
<span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{counts.nodeCount}</span>
|
|
232
|
+
) : (
|
|
233
|
+
<span className="h-3 w-5 rounded bg-theme-text-tertiary/20 animate-pulse" />
|
|
234
|
+
)}
|
|
195
235
|
<span className="text-theme-text-tertiary">resources</span>
|
|
196
236
|
</div>
|
|
197
237
|
<div className="flex items-center gap-1.5 text-[10px] leading-tight">
|
|
198
|
-
<span className=
|
|
199
|
-
|
|
238
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', counts ? 'bg-theme-text-tertiary' : 'bg-theme-text-tertiary/30 animate-pulse')} />
|
|
239
|
+
{counts ? (
|
|
240
|
+
<span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{counts.edgeCount}</span>
|
|
241
|
+
) : (
|
|
242
|
+
<span className="h-3 w-5 rounded bg-theme-text-tertiary/10 animate-pulse" />
|
|
243
|
+
)}
|
|
200
244
|
<span className="text-theme-text-tertiary">connections</span>
|
|
201
245
|
</div>
|
|
202
246
|
</div>
|
|
@@ -207,6 +251,8 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
|
|
|
207
251
|
<div className="flex-1 flex items-center min-w-0">
|
|
208
252
|
<TopologySchematic />
|
|
209
253
|
</div>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
210
256
|
</div>
|
|
211
257
|
|
|
212
258
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
|
|
@@ -52,13 +52,13 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
name: 'get_resource',
|
|
55
|
-
desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics data.',
|
|
55
|
+
desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics/change/revision data.',
|
|
56
56
|
params: [
|
|
57
57
|
{ arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
|
|
58
58
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
59
59
|
{ arg: 'namespace', desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
|
|
60
60
|
{ arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
|
|
61
|
-
{ arg: 'include', desc: 'events, metrics' },
|
|
61
|
+
{ arg: 'include', desc: 'events, metrics, changes, revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
|
|
62
62
|
{ arg: 'context', desc: 'resourceContext tier: basic (default) or none' },
|
|
63
63
|
],
|
|
64
64
|
},
|
|
@@ -260,14 +260,24 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
260
260
|
{
|
|
261
261
|
name: 'manage_workload',
|
|
262
262
|
write: true,
|
|
263
|
-
desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision.',
|
|
263
|
+
desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision. Rollout rollback re-runs every canary step — pair it with manage_rollout promote-full, or abort instead.',
|
|
264
264
|
params: [
|
|
265
265
|
{ arg: 'action', required: true, desc: 'restart, scale, or rollback' },
|
|
266
|
-
{ arg: 'kind', required: true, desc: 'deployment, statefulset, or
|
|
266
|
+
{ arg: 'kind', required: true, desc: 'deployment, statefulset, daemonset, or rollout' },
|
|
267
267
|
{ arg: 'namespace', required: true, desc: 'workload namespace' },
|
|
268
268
|
{ arg: 'name', required: true, desc: 'workload name' },
|
|
269
269
|
{ arg: 'replicas', desc: 'target replica count (for scale)' },
|
|
270
|
-
{ arg: 'revision', desc: 'target revision (for rollback)' },
|
|
270
|
+
{ arg: 'revision', desc: 'target revision (for rollback); list them with get_resource include=revisions' },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'manage_rollout',
|
|
275
|
+
write: true,
|
|
276
|
+
desc: 'Control an Argo Rollout progressive delivery: abort reverts traffic to the last stable version immediately, retry clears an abort, promote advances one step, promote-full skips all remaining steps/pauses/analysis, skip-step advances exactly one canary step. A Rollout paused on an inconclusive analysis names its AnalysisRun in status — read that first.',
|
|
277
|
+
params: [
|
|
278
|
+
{ arg: 'action', required: true, desc: 'abort, retry, promote, promote-full, or skip-step' },
|
|
279
|
+
{ arg: 'namespace', required: true, desc: 'rollout namespace' },
|
|
280
|
+
{ arg: 'name', required: true, desc: 'rollout name' },
|
|
271
281
|
],
|
|
272
282
|
},
|
|
273
283
|
{
|
|
@@ -49,6 +49,13 @@ const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
|
|
|
49
49
|
'apps/ReplicaSet',
|
|
50
50
|
'discovery.k8s.io/EndpointSlice',
|
|
51
51
|
])
|
|
52
|
+
// Kinds the server slims with ?include=summary: rows carry only the fields the
|
|
53
|
+
// table reads (5–8x smaller for pods — a production pod is ~9–13KB raw,
|
|
54
|
+
// ~1–2.5KB slimmed), so the browser holds far more rows before the guard must
|
|
55
|
+
// block. The detail drawer is unaffected — row clicks always refetch the full
|
|
56
|
+
// object.
|
|
57
|
+
const SUMMARY_LIST_KINDS = new Set(['Pod', 'apps/ReplicaSet'])
|
|
58
|
+
const SUMMARY_LIST_LIMIT = 50000
|
|
52
59
|
|
|
53
60
|
const deniedWorkloadWrites: WorkloadWritePermissions = {
|
|
54
61
|
deployments: false,
|
|
@@ -189,13 +196,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
189
196
|
const selectedCountKnown = selectedCountKey ? hasResourceCount(countsData?.counts, selectedCountKey) : false
|
|
190
197
|
const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
|
|
191
198
|
const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
|
|
199
|
+
const selectedKindSummaryServed = SUMMARY_LIST_KINDS.has(selectedCountKey)
|
|
200
|
+
const selectedKindRowLimit = selectedKindSummaryServed ? SUMMARY_LIST_LIMIT : LARGE_RESOURCE_LIST_LIMIT
|
|
192
201
|
const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
|
|
193
|
-
const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) >
|
|
202
|
+
const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) > selectedKindRowLimit))
|
|
194
203
|
const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
|
|
195
204
|
const podCount = countsData?.counts.Pod
|
|
196
205
|
const podCountKnown = hasResourceCount(countsData?.counts, 'Pod')
|
|
197
206
|
const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
|
|
198
|
-
|
|
207
|
+
// Metrics rows are ~100B each (ns/name + cpu/mem), so they track the pods
|
|
208
|
+
// guard rather than the raw-list limit.
|
|
209
|
+
const podCountAllowsBulkMetrics = countsData != null && podCountKnown && !podCountUnavailable && (podCount ?? 0) <= SUMMARY_LIST_LIMIT
|
|
199
210
|
const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
|
|
200
211
|
const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
|
|
201
212
|
// Node metrics back the Nodes table and, for the Pods table, the pod-vs-node
|
|
@@ -210,7 +221,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
210
221
|
kind: selectedKind.name,
|
|
211
222
|
count: selectedCountUnavailable ? undefined : selectedCount,
|
|
212
223
|
reason: selectedCountUnavailable ? 'count-unavailable' as const : 'too-many' as const,
|
|
213
|
-
limit:
|
|
224
|
+
limit: selectedKindRowLimit,
|
|
214
225
|
namespaces,
|
|
215
226
|
}
|
|
216
227
|
: null
|
|
@@ -223,6 +234,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
223
234
|
const params = new URLSearchParams()
|
|
224
235
|
if (namespaces.length > 0) params.set('namespaces', namespacesParam)
|
|
225
236
|
if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
|
|
237
|
+
if (selectedKindSummaryServed) params.set('include', 'summary')
|
|
226
238
|
const startedAt = performance.now()
|
|
227
239
|
debugNamespaceLog('resources:selected-kind-fetch-start', {
|
|
228
240
|
kind: selectedKind.name,
|
|
@@ -1 +1,116 @@
|
|
|
1
|
-
|
|
1
|
+
import { Database } from 'lucide-react'
|
|
2
|
+
import { CNPGClusterRenderer as BaseCNPGClusterRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGClusterRenderer'
|
|
3
|
+
import { Section, RelationshipGroup, getCNPGDeclarativeStatus } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
5
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { useResources } from '../../../api/client'
|
|
7
|
+
|
|
8
|
+
const CNPG_GROUP = 'postgresql.cnpg.io'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Host wrapper adding the reverse lookup: which Databases, Publications and
|
|
12
|
+
* Subscriptions are declared against this cluster.
|
|
13
|
+
*
|
|
14
|
+
* Each of those objects links forward to its cluster. Without this the
|
|
15
|
+
* relationship is one-way, so an operator reading a cluster has no way to see
|
|
16
|
+
* that three databases were declared against it and one of them never applied.
|
|
17
|
+
*/
|
|
18
|
+
export function CNPGClusterRenderer({
|
|
19
|
+
data,
|
|
20
|
+
onNavigate,
|
|
21
|
+
}: {
|
|
22
|
+
data: any
|
|
23
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
24
|
+
}) {
|
|
25
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
26
|
+
const name = data?.metadata?.name ?? ''
|
|
27
|
+
const enabled = !!namespace && !!name
|
|
28
|
+
|
|
29
|
+
const databases = useResources<any>('databases', namespace, CNPG_GROUP, { enabled })
|
|
30
|
+
const publications = useResources<any>('publications', namespace, CNPG_GROUP, { enabled })
|
|
31
|
+
const subscriptions = useResources<any>('subscriptions', namespace, CNPG_GROUP, { enabled })
|
|
32
|
+
|
|
33
|
+
const mine = (items: any[] | undefined) =>
|
|
34
|
+
(items ?? []).filter((o) => o?.spec?.cluster?.name === name)
|
|
35
|
+
|
|
36
|
+
const dbs = mine(databases.data)
|
|
37
|
+
const pubs = mine(publications.data)
|
|
38
|
+
const subs = mine(subscriptions.data)
|
|
39
|
+
const total = dbs.length + pubs.length + subs.length
|
|
40
|
+
|
|
41
|
+
const loading = databases.isLoading || publications.isLoading || subscriptions.isLoading
|
|
42
|
+
// Three independent lookups, so "it failed" is not all-or-nothing: two can
|
|
43
|
+
// return rows while the third does not, and every count below is then drawn
|
|
44
|
+
// from a population smaller than the real one.
|
|
45
|
+
const lookupErrors = [databases.error, publications.error, subscriptions.error]
|
|
46
|
+
const failed = lookupErrors.some(Boolean)
|
|
47
|
+
|
|
48
|
+
// Not applied is the state worth surfacing here: the manifest exists and
|
|
49
|
+
// PostgreSQL does not have the object. Counting it on the cluster is how an
|
|
50
|
+
// operator notices without opening each one.
|
|
51
|
+
const notApplied = [...dbs, ...pubs, ...subs].filter(
|
|
52
|
+
(o) => getCNPGDeclarativeStatus(o).level === 'unhealthy',
|
|
53
|
+
).length
|
|
54
|
+
|
|
55
|
+
const refs = (items: any[], kind: string) =>
|
|
56
|
+
items.map((o) => ({
|
|
57
|
+
kind,
|
|
58
|
+
namespace: o?.metadata?.namespace ?? '',
|
|
59
|
+
name: o?.metadata?.name ?? '',
|
|
60
|
+
group: CNPG_GROUP,
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<BaseCNPGClusterRenderer
|
|
66
|
+
data={data}
|
|
67
|
+
onNavigate={onNavigate}
|
|
68
|
+
declared={
|
|
69
|
+
<Section title="Declared Objects" icon={Database} defaultExpanded={notApplied > 0}>
|
|
70
|
+
{loading ? (
|
|
71
|
+
<div className="text-sm text-theme-text-tertiary">Looking for declared objects…</div>
|
|
72
|
+
) : total === 0 ? (
|
|
73
|
+
// "None declared" and "could not check" are different answers, and
|
|
74
|
+
// only one of them means the cluster has no declarative objects.
|
|
75
|
+
failed ? (
|
|
76
|
+
<LookupFailureNote
|
|
77
|
+
errors={lookupErrors}
|
|
78
|
+
what="which objects are declared against this cluster"
|
|
79
|
+
/>
|
|
80
|
+
) : (
|
|
81
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
82
|
+
No Database, Publication or Subscription is declared against this cluster.
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
) : (
|
|
86
|
+
<div className="space-y-3">
|
|
87
|
+
{notApplied > 0 && (
|
|
88
|
+
<div className="text-sm text-warning-text">
|
|
89
|
+
{`${notApplied} of ${total} could not be applied to PostgreSQL — the manifest exists, the object does not.`}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
{/* Rows arrived, so the failure is not "nothing found" — it is
|
|
93
|
+
that both counts above are drawn from less than the cluster
|
|
94
|
+
has, which is the reading they would otherwise invite. */}
|
|
95
|
+
<LookupFailureNote
|
|
96
|
+
errors={lookupErrors}
|
|
97
|
+
what="which objects are declared against this cluster"
|
|
98
|
+
incomplete
|
|
99
|
+
/>
|
|
100
|
+
{dbs.length > 0 && (
|
|
101
|
+
<RelationshipGroup label="Databases" refs={refs(dbs, 'Database')} onNavigate={onNavigate} />
|
|
102
|
+
)}
|
|
103
|
+
{pubs.length > 0 && (
|
|
104
|
+
<RelationshipGroup label="Publications" refs={refs(pubs, 'Publication')} onNavigate={onNavigate} />
|
|
105
|
+
)}
|
|
106
|
+
{subs.length > 0 && (
|
|
107
|
+
<RelationshipGroup label="Subscriptions" refs={refs(subs, 'Subscription')} onNavigate={onNavigate} />
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</Section>
|
|
112
|
+
}
|
|
113
|
+
/>
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Share2 } from 'lucide-react'
|
|
2
|
+
import {
|
|
3
|
+
CNPGDatabaseRenderer as BaseDatabase,
|
|
4
|
+
CNPGPublicationRenderer as BasePublication,
|
|
5
|
+
CNPGSubscriptionRenderer as BaseSubscription,
|
|
6
|
+
} from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGDeclarativeRenderer'
|
|
7
|
+
import { ResourceLink, Section, RelationshipGroup } from '@skyhook-io/k8s-ui'
|
|
8
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
9
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
10
|
+
import { useResources } from '../../../api/client'
|
|
11
|
+
import { splitCNPGDeclarativeByApplied } from '../resource-utils-cnpg'
|
|
12
|
+
|
|
13
|
+
const CNPG_GROUP = 'postgresql.cnpg.io'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a PostgreSQL-side name back to the CR that declares it.
|
|
17
|
+
*
|
|
18
|
+
* A Publication says it lives in database `demo_app`; the Database CR is called
|
|
19
|
+
* `demo-app` and carries `spec.name: demo_app`. The two are not the same string,
|
|
20
|
+
* so the page can name the database and still leave the reader with no way to
|
|
21
|
+
* open it — the dead end this exists to close.
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined when nothing matches, and the caller falls back to plain
|
|
24
|
+
* text: an unresolved name is still the truth, it just isn't a link.
|
|
25
|
+
*/
|
|
26
|
+
function useDeclaredRef(
|
|
27
|
+
plural: 'databases' | 'publications',
|
|
28
|
+
namespace: string,
|
|
29
|
+
cluster: string | undefined,
|
|
30
|
+
pgName: string | undefined,
|
|
31
|
+
onNavigate?: (ref: ResourceRef) => void,
|
|
32
|
+
) {
|
|
33
|
+
const enabled = !!namespace && !!cluster && !!pgName
|
|
34
|
+
const { data } = useResources<any>(plural, namespace, CNPG_GROUP, { enabled })
|
|
35
|
+
if (!enabled) return undefined
|
|
36
|
+
const match = (data ?? []).find(
|
|
37
|
+
(o: any) => o?.spec?.cluster?.name === cluster && o?.spec?.name === pgName,
|
|
38
|
+
)
|
|
39
|
+
if (!match) return undefined
|
|
40
|
+
return (
|
|
41
|
+
<ResourceLink
|
|
42
|
+
name={pgName as string}
|
|
43
|
+
kind={plural}
|
|
44
|
+
namespace={match?.metadata?.namespace ?? namespace}
|
|
45
|
+
group={CNPG_GROUP}
|
|
46
|
+
label={pgName}
|
|
47
|
+
onNavigate={
|
|
48
|
+
onNavigate
|
|
49
|
+
? () =>
|
|
50
|
+
onNavigate({
|
|
51
|
+
kind: plural === 'databases' ? 'Database' : 'Publication',
|
|
52
|
+
namespace: match?.metadata?.namespace ?? namespace,
|
|
53
|
+
name: match?.metadata?.name ?? '',
|
|
54
|
+
group: CNPG_GROUP,
|
|
55
|
+
})
|
|
56
|
+
: undefined
|
|
57
|
+
}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Database, with the objects that replicate out of it.
|
|
64
|
+
*
|
|
65
|
+
* The link only exists in one direction in the API: a Publication names its
|
|
66
|
+
* database, a Database names nothing. Without this, "what publishes from here"
|
|
67
|
+
* has no answer on the page that raises the question.
|
|
68
|
+
*/
|
|
69
|
+
export function CNPGDatabaseRenderer({
|
|
70
|
+
data,
|
|
71
|
+
onNavigate,
|
|
72
|
+
}: {
|
|
73
|
+
data: any
|
|
74
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
75
|
+
}) {
|
|
76
|
+
const ns = data?.metadata?.namespace ?? ''
|
|
77
|
+
const cluster = data?.spec?.cluster?.name
|
|
78
|
+
const dbname = data?.spec?.name
|
|
79
|
+
const enabled = !!ns && !!cluster && !!dbname
|
|
80
|
+
|
|
81
|
+
const publications = useResources<any>('publications', ns, CNPG_GROUP, { enabled })
|
|
82
|
+
const subscriptions = useResources<any>('subscriptions', ns, CNPG_GROUP, { enabled })
|
|
83
|
+
|
|
84
|
+
const inThisDatabase = (o: any) =>
|
|
85
|
+
o?.spec?.cluster?.name === cluster && o?.spec?.dbname === dbname
|
|
86
|
+
const pubs = (publications.data ?? []).filter(inThisDatabase)
|
|
87
|
+
const subs = (subscriptions.data ?? []).filter(inThisDatabase)
|
|
88
|
+
|
|
89
|
+
const toRef = (kind: 'Publication' | 'Subscription') => (o: any) => ({
|
|
90
|
+
kind,
|
|
91
|
+
namespace: o?.metadata?.namespace ?? ns,
|
|
92
|
+
name: o?.metadata?.name ?? '',
|
|
93
|
+
group: CNPG_GROUP,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Three states, not two — the same distinction the status badge and the issue
|
|
97
|
+
// detector make. Applied is replicating. `applied: false` is a failed apply
|
|
98
|
+
// the operator can explain. ABSENT is not yet reconciled, and calling that
|
|
99
|
+
// "exists in Kubernetes and not in PostgreSQL" condemns every object in its
|
|
100
|
+
// first seconds.
|
|
101
|
+
const p = splitCNPGDeclarativeByApplied(pubs)
|
|
102
|
+
const sub = splitCNPGDeclarativeByApplied(subs)
|
|
103
|
+
const tagged = (list: any[], kind: 'Publication' | 'Subscription') =>
|
|
104
|
+
list.map((o: any) => ({ o, kind }))
|
|
105
|
+
const livePubs = p.applied
|
|
106
|
+
const liveSubs = sub.applied
|
|
107
|
+
const notApplied = [...tagged(p.notApplied, 'Publication'), ...tagged(sub.notApplied, 'Subscription')]
|
|
108
|
+
const pending = [...tagged(p.pending, 'Publication'), ...tagged(sub.pending, 'Subscription')]
|
|
109
|
+
|
|
110
|
+
const loading = publications.isLoading || subscriptions.isLoading
|
|
111
|
+
// Two independent lookups: one can return rows while the other does not, and
|
|
112
|
+
// the groups below would then describe half the replication as all of it.
|
|
113
|
+
const lookupErrors = [publications.error, subscriptions.error]
|
|
114
|
+
const failed = lookupErrors.some(Boolean)
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<BaseDatabase
|
|
118
|
+
data={data}
|
|
119
|
+
onNavigate={onNavigate}
|
|
120
|
+
usedBy={
|
|
121
|
+
<Section title="Replication" icon={Share2} defaultExpanded>
|
|
122
|
+
{loading ? (
|
|
123
|
+
<div className="text-sm text-theme-text-tertiary">Looking for publications…</div>
|
|
124
|
+
) : pubs.length === 0 && subs.length === 0 ? (
|
|
125
|
+
failed ? (
|
|
126
|
+
<LookupFailureNote
|
|
127
|
+
errors={lookupErrors}
|
|
128
|
+
what="what replicates out of this database"
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
132
|
+
Nothing publishes from or subscribes to this database.
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
) : (
|
|
136
|
+
<div className="space-y-3">
|
|
137
|
+
{/* Rows are showing, so the failure means this list is short —
|
|
138
|
+
not that nothing replicates. */}
|
|
139
|
+
<LookupFailureNote
|
|
140
|
+
errors={lookupErrors}
|
|
141
|
+
what="what replicates out of this database"
|
|
142
|
+
incomplete
|
|
143
|
+
/>
|
|
144
|
+
{livePubs.length > 0 && (
|
|
145
|
+
<RelationshipGroup
|
|
146
|
+
label="Publishes from here"
|
|
147
|
+
refs={livePubs.map(toRef('Publication'))}
|
|
148
|
+
onNavigate={onNavigate}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
{liveSubs.length > 0 && (
|
|
152
|
+
<RelationshipGroup
|
|
153
|
+
label="Subscribes into here"
|
|
154
|
+
refs={liveSubs.map(toRef('Subscription'))}
|
|
155
|
+
onNavigate={onNavigate}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{notApplied.length > 0 && (
|
|
159
|
+
<div className="space-y-1.5">
|
|
160
|
+
<RelationshipGroup
|
|
161
|
+
label="Declared, but not replicating"
|
|
162
|
+
refs={notApplied.map(({ o, kind }) => toRef(kind)(o))}
|
|
163
|
+
onNavigate={onNavigate}
|
|
164
|
+
/>
|
|
165
|
+
<div className="text-xs text-warning-text">
|
|
166
|
+
{notApplied.length === 1
|
|
167
|
+
? 'This exists in Kubernetes and not in PostgreSQL, so no data moves through it. Open it for the operator’s reason.'
|
|
168
|
+
: 'These exist in Kubernetes and not in PostgreSQL, so no data moves through them. Open one for the operator’s reason.'}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
{pending.length > 0 && (
|
|
173
|
+
<div className="space-y-1.5">
|
|
174
|
+
<RelationshipGroup
|
|
175
|
+
label="Not reconciled yet"
|
|
176
|
+
refs={pending.map(({ o, kind }) => toRef(kind)(o))}
|
|
177
|
+
onNavigate={onNavigate}
|
|
178
|
+
/>
|
|
179
|
+
<div className="text-xs text-theme-text-secondary">
|
|
180
|
+
The operator has not reported on these yet. They have not failed.
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</Section>
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function CNPGPublicationRenderer({
|
|
193
|
+
data,
|
|
194
|
+
onNavigate,
|
|
195
|
+
}: {
|
|
196
|
+
data: any
|
|
197
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
198
|
+
}) {
|
|
199
|
+
const ns = data?.metadata?.namespace ?? ''
|
|
200
|
+
const cluster = data?.spec?.cluster?.name
|
|
201
|
+
const database = useDeclaredRef('databases', ns, cluster, data?.spec?.dbname, onNavigate)
|
|
202
|
+
return <BasePublication data={data} onNavigate={onNavigate} links={{ database }} />
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function CNPGSubscriptionRenderer({
|
|
206
|
+
data,
|
|
207
|
+
onNavigate,
|
|
208
|
+
}: {
|
|
209
|
+
data: any
|
|
210
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
211
|
+
}) {
|
|
212
|
+
const ns = data?.metadata?.namespace ?? ''
|
|
213
|
+
const cluster = data?.spec?.cluster?.name
|
|
214
|
+
const database = useDeclaredRef('databases', ns, cluster, data?.spec?.dbname, onNavigate)
|
|
215
|
+
// The publication a subscription reads from lives on the *upstream* cluster in
|
|
216
|
+
// a real topology. Resolving locally is right for the single-cluster demo and
|
|
217
|
+
// simply finds nothing otherwise, which falls back to plain text rather than
|
|
218
|
+
// linking to the wrong object.
|
|
219
|
+
const publication = useDeclaredRef(
|
|
220
|
+
'publications',
|
|
221
|
+
ns,
|
|
222
|
+
cluster,
|
|
223
|
+
data?.spec?.publicationName,
|
|
224
|
+
onNavigate,
|
|
225
|
+
)
|
|
226
|
+
return <BaseSubscription data={data} onNavigate={onNavigate} links={{ database, publication }} />
|
|
227
|
+
}
|