@skyhook-io/k8s-ui 1.11.0 → 1.13.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 +4 -4
- package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
- package/src/components/gitops/detail-helpers.ts +4 -1
- 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 +412 -5
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
- 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 +71 -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/CNPGPoolerRenderer.tsx +11 -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/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
- 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/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- 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 +159 -0
- package/src/components/resources/renderers/PolicySection.tsx +197 -0
- 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/WorkloadRenderer.tsx +15 -1
- 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 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +90 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -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 +117 -0
- package/src/components/resources/resource-utils-cnpg.ts +324 -3
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
- 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 +12 -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/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- 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 +3 -0
- package/src/types/core.ts +29 -0
- package/src/types/gitops-flux-status.test.ts +163 -0
- package/src/types/gitops.ts +80 -24
- package/src/types/index.ts +1 -0
- package/src/types/policy.ts +193 -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,235 @@
|
|
|
1
|
+
import { Archive, Clock, HardDrive } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
4
|
+
import { HEALTH_BADGE_COLORS } from '../../../utils/badge-colors'
|
|
5
|
+
import { RecoveryTime } from './CNPGShared'
|
|
6
|
+
import {
|
|
7
|
+
getCNPGObjectStoreProvider,
|
|
8
|
+
getCNPGObjectStoreCredentialSecret,
|
|
9
|
+
getCNPGObjectStoreRecoveryWindows,
|
|
10
|
+
getCNPGObjectStoreDestination,
|
|
11
|
+
getCNPGObjectStoreRetention,
|
|
12
|
+
type CNPGObjectStoreRecoveryWindow,
|
|
13
|
+
} from '../resource-utils-cnpg'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ObjectStore (barmancloud.cnpg.io/v1) — where a plugin-backed CNPG Cluster's
|
|
17
|
+
* backups actually live.
|
|
18
|
+
*
|
|
19
|
+
* The Cluster page already tells operators the recovery window is tracked here,
|
|
20
|
+
* because a plugin-backed Cluster stops publishing recovery-point fields of its
|
|
21
|
+
* own. Until this renderer existed that sentence pointed at a page showing raw
|
|
22
|
+
* fields, so the restorable range was effectively unreachable in the product.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function CNPGObjectStoreRenderer({
|
|
26
|
+
data,
|
|
27
|
+
clusterForServer,
|
|
28
|
+
archivingFailing,
|
|
29
|
+
onNavigate,
|
|
30
|
+
}: {
|
|
31
|
+
data: any
|
|
32
|
+
/** Server key -> the Cluster archiving under it, so a key is only linked when
|
|
33
|
+
* something is behind it. See RecoveryWindowRow. */
|
|
34
|
+
clusterForServer?: Map<string, string>
|
|
35
|
+
/** Servers whose Cluster reports WAL archiving stopped. The window keeps its
|
|
36
|
+
* last successful backup and reads as recoverable, while the recovery point
|
|
37
|
+
* has stopped advancing — a claim this status alone cannot correct. */
|
|
38
|
+
archivingFailing?: Set<string>
|
|
39
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
40
|
+
}) {
|
|
41
|
+
const windows = getCNPGObjectStoreRecoveryWindows(data)
|
|
42
|
+
const provider = getCNPGObjectStoreProvider(data)
|
|
43
|
+
const retention = getCNPGObjectStoreRetention(data)
|
|
44
|
+
const credentialSecret = getCNPGObjectStoreCredentialSecret(data)
|
|
45
|
+
const cfg = data?.spec?.configuration ?? {}
|
|
46
|
+
const failing = windows.filter((w) => w.failingSinceLastSuccess)
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
{failing.length > 0 && (
|
|
51
|
+
<AlertBanner
|
|
52
|
+
variant="error"
|
|
53
|
+
title={`Backups failing for ${failing.length === 1 ? failing[0].server : `${failing.length} servers`}`}
|
|
54
|
+
message="The most recent backup attempt failed after the last success, so the recoverable range has stopped moving forward."
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
<Section title="Recovery Window" icon={Clock} defaultExpanded>
|
|
59
|
+
{windows.length === 0 ? (
|
|
60
|
+
// Configured but empty is NOT the same as healthy. Saying nothing here
|
|
61
|
+
// would read as "backups are fine" on a store holding nothing.
|
|
62
|
+
<div className="text-sm text-theme-text-secondary">
|
|
63
|
+
No server has reported a backup yet, so there is nothing to restore from this store.
|
|
64
|
+
</div>
|
|
65
|
+
) : (
|
|
66
|
+
<div className="space-y-2">
|
|
67
|
+
{windows.map((w) => (
|
|
68
|
+
<RecoveryWindowRow
|
|
69
|
+
archivingFailing={archivingFailing}
|
|
70
|
+
clusterForServer={clusterForServer}
|
|
71
|
+
key={w.server}
|
|
72
|
+
window={w}
|
|
73
|
+
retention={retention}
|
|
74
|
+
namespace={data?.metadata?.namespace ?? ''}
|
|
75
|
+
onNavigate={onNavigate}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</Section>
|
|
81
|
+
|
|
82
|
+
<Section title="Destination" icon={Archive} defaultExpanded>
|
|
83
|
+
<PropertyList>
|
|
84
|
+
<Property label="Path" value={getCNPGObjectStoreDestination(data)} />
|
|
85
|
+
{cfg.endpointURL && <Property label="Endpoint" value={cfg.endpointURL} />}
|
|
86
|
+
{/* Only when set — a custom CA is the difference between "cannot reach
|
|
87
|
+
the bucket" and "does not trust it", and it is otherwise invisible. */}
|
|
88
|
+
{(cfg.endpointCA?.name || cfg.endpointCA?.key) && (
|
|
89
|
+
<Property
|
|
90
|
+
label="Endpoint CA"
|
|
91
|
+
value={cfg.endpointCA?.name ? `${cfg.endpointCA.name}${cfg.endpointCA.key ? ` · ${cfg.endpointCA.key}` : ''}` : cfg.endpointCA.key}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
{provider && <Property label="Provider" value={provider} />}
|
|
95
|
+
{credentialSecret && (
|
|
96
|
+
<Property
|
|
97
|
+
label="Credentials"
|
|
98
|
+
value={
|
|
99
|
+
<ResourceLink
|
|
100
|
+
name={credentialSecret}
|
|
101
|
+
kind="secrets"
|
|
102
|
+
namespace={data?.metadata?.namespace ?? ''}
|
|
103
|
+
onNavigate={onNavigate}
|
|
104
|
+
/>
|
|
105
|
+
}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
{cfg.serverName && <Property label="Server Name" value={cfg.serverName} />}
|
|
109
|
+
{retention && <Property label="Retention" value={retention} />}
|
|
110
|
+
</PropertyList>
|
|
111
|
+
</Section>
|
|
112
|
+
|
|
113
|
+
{(cfg.wal || cfg.data) && (
|
|
114
|
+
<Section title="Backup Settings" icon={HardDrive} defaultExpanded={false}>
|
|
115
|
+
<PropertyList>
|
|
116
|
+
{cfg.wal?.compression && <Property label="WAL Compression" value={cfg.wal.compression} />}
|
|
117
|
+
{cfg.wal?.encryption && <Property label="WAL Encryption" value={cfg.wal.encryption} />}
|
|
118
|
+
{cfg.wal?.maxParallel != null && (
|
|
119
|
+
<Property label="WAL Max Parallel" value={String(cfg.wal.maxParallel)} />
|
|
120
|
+
)}
|
|
121
|
+
{cfg.data?.compression && <Property label="Data Compression" value={cfg.data.compression} />}
|
|
122
|
+
{cfg.data?.encryption && <Property label="Data Encryption" value={cfg.data.encryption} />}
|
|
123
|
+
{cfg.data?.jobs != null && <Property label="Data Jobs" value={String(cfg.data.jobs)} />}
|
|
124
|
+
{cfg.data?.immediateCheckpoint != null && (
|
|
125
|
+
<Property label="Immediate Checkpoint" value={cfg.data.immediateCheckpoint ? 'Yes' : 'No'} />
|
|
126
|
+
)}
|
|
127
|
+
</PropertyList>
|
|
128
|
+
</Section>
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One server's restorable range. The two timestamps are the answer to "how far
|
|
136
|
+
* back can I go, and how current is it" — the question the whole plugin path
|
|
137
|
+
* moved out of the Cluster.
|
|
138
|
+
*/
|
|
139
|
+
function RecoveryWindowRow({
|
|
140
|
+
window: w,
|
|
141
|
+
retention,
|
|
142
|
+
namespace,
|
|
143
|
+
clusterForServer,
|
|
144
|
+
archivingFailing,
|
|
145
|
+
onNavigate,
|
|
146
|
+
}: {
|
|
147
|
+
window: CNPGObjectStoreRecoveryWindow
|
|
148
|
+
retention?: string
|
|
149
|
+
namespace: string
|
|
150
|
+
/** Server key -> the Cluster archiving under it. The key defaults to the
|
|
151
|
+
* cluster's name but the plugin's `serverName` parameter overrides it, so the
|
|
152
|
+
* two are not interchangeable and the mapping has to come from the plugin
|
|
153
|
+
* parameters rather than from string equality.
|
|
154
|
+
*
|
|
155
|
+
* Absent means unresolved, not "assume it resolves": the lookup may still be
|
|
156
|
+
* loading, it may have failed, or the host may not be able to ask. All three
|
|
157
|
+
* render the key as text, which is the part that is always true. */
|
|
158
|
+
clusterForServer?: Map<string, string>
|
|
159
|
+
/** Servers whose Cluster reports WAL archiving stopped. */
|
|
160
|
+
archivingFailing?: Set<string>
|
|
161
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
162
|
+
}) {
|
|
163
|
+
const stalled = archivingFailing?.has(w.server)
|
|
164
|
+
const tone = w.failingSinceLastSuccess || stalled
|
|
165
|
+
? 'degraded'
|
|
166
|
+
: w.lastSuccessfulBackupTime
|
|
167
|
+
? 'healthy'
|
|
168
|
+
: 'unknown'
|
|
169
|
+
return (
|
|
170
|
+
<div className="card-inner">
|
|
171
|
+
<div className="flex items-center gap-2 mb-1">
|
|
172
|
+
{/* The server key is usually the Cluster this window belongs to, and
|
|
173
|
+
naming it without a route leaves the reader knowing a cluster is in
|
|
174
|
+
trouble and unable to open it. Only usually, though — see
|
|
175
|
+
`clusterNames`. */}
|
|
176
|
+
<span className="text-sm font-medium text-theme-text-primary">
|
|
177
|
+
{clusterForServer?.get(w.server) ? (
|
|
178
|
+
<ResourceLink
|
|
179
|
+
name={clusterForServer.get(w.server) as string}
|
|
180
|
+
label={w.server}
|
|
181
|
+
kind="clusters"
|
|
182
|
+
namespace={namespace}
|
|
183
|
+
group="postgresql.cnpg.io"
|
|
184
|
+
onNavigate={onNavigate}
|
|
185
|
+
/>
|
|
186
|
+
) : (
|
|
187
|
+
w.server
|
|
188
|
+
)}
|
|
189
|
+
</span>
|
|
190
|
+
<span className={clsx('badge', HEALTH_BADGE_COLORS[tone as keyof typeof HEALTH_BADGE_COLORS])}>
|
|
191
|
+
{w.failingSinceLastSuccess
|
|
192
|
+
? 'Backups failing'
|
|
193
|
+
: stalled
|
|
194
|
+
? 'Not advancing'
|
|
195
|
+
: w.lastSuccessfulBackupTime
|
|
196
|
+
? 'Recoverable'
|
|
197
|
+
: 'No backups yet'}
|
|
198
|
+
</span>
|
|
199
|
+
</div>
|
|
200
|
+
{stalled && !w.failingSinceLastSuccess && (
|
|
201
|
+
<div className="text-xs text-warning-text mb-1">
|
|
202
|
+
{/* The timestamps below are real and still describe the last backup that
|
|
203
|
+
worked. What they no longer describe is a window still growing, and
|
|
204
|
+
"Recoverable" alone invites exactly that reading on the screen
|
|
205
|
+
someone checks before a restore. */}
|
|
206
|
+
WAL archiving has stopped on the cluster behind this server, so the times below are the
|
|
207
|
+
last ones recorded rather than a window still advancing.
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
<PropertyList>
|
|
211
|
+
{w.firstRecoverabilityPoint && (
|
|
212
|
+
<Property
|
|
213
|
+
label="Restorable From"
|
|
214
|
+
value={<RecoveryTime at={w.firstRecoverabilityPoint} />}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
{w.lastSuccessfulBackupTime ? (
|
|
218
|
+
<Property label="Last Successful Backup" value={<RecoveryTime at={w.lastSuccessfulBackupTime} />} />
|
|
219
|
+
) : (
|
|
220
|
+
<Property label="Last Successful Backup" value="never" />
|
|
221
|
+
)}
|
|
222
|
+
{w.lastFailedBackupTime && (
|
|
223
|
+
<Property label="Last Failed Attempt" value={<RecoveryTime at={w.lastFailedBackupTime} />} />
|
|
224
|
+
)}
|
|
225
|
+
{retention && <Property label="Retention" value={retention} />}
|
|
226
|
+
</PropertyList>
|
|
227
|
+
{w.failingSinceLastSuccess && (
|
|
228
|
+
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
229
|
+
Every backup since the last success has failed. The oldest restorable point will still age
|
|
230
|
+
out under the retention policy, so the window is shrinking from both ends.
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getCNPGPoolerParameters,
|
|
9
9
|
getCNPGPoolerAuthQuery,
|
|
10
10
|
getCNPGPoolerAuthQuerySecret,
|
|
11
|
+
isCNPGPoolerPaused,
|
|
11
12
|
} from '../resource-utils-cnpg'
|
|
12
13
|
|
|
13
14
|
interface CNPGPoolerRendererProps {
|
|
@@ -23,6 +24,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
|
|
|
23
24
|
// CR never establishes.
|
|
24
25
|
const scheduled = data.status?.instances ?? 0
|
|
25
26
|
const isDegraded = desired > 0 && scheduled < desired
|
|
27
|
+
const isPaused = isCNPGPoolerPaused(data)
|
|
26
28
|
const clusterName = getCNPGPoolerCluster(data)
|
|
27
29
|
const parameters = getCNPGPoolerParameters(data)
|
|
28
30
|
const authQuery = getCNPGPoolerAuthQuery(data)
|
|
@@ -39,12 +41,21 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
|
|
|
39
41
|
/>
|
|
40
42
|
)}
|
|
41
43
|
|
|
44
|
+
{isPaused && (
|
|
45
|
+
<AlertBanner
|
|
46
|
+
variant="warning"
|
|
47
|
+
title="Pooler Paused"
|
|
48
|
+
message="PgBouncer is paused, so client connections are held rather than served. The pods stay scheduled and Ready throughout, so nothing else about this Pooler will look wrong."
|
|
49
|
+
/>
|
|
50
|
+
)}
|
|
51
|
+
|
|
42
52
|
{/* Pooler Configuration */}
|
|
43
53
|
<Section title="Pooler Configuration" icon={Users} defaultExpanded>
|
|
44
54
|
<PropertyList>
|
|
45
55
|
<Property label="Type" value={getCNPGPoolerType(data)} />
|
|
46
56
|
<Property label="Pool Mode" value={getCNPGPoolerMode(data)} />
|
|
47
57
|
<Property label="Instances Scheduled" value={getCNPGPoolerInstances(data)} />
|
|
58
|
+
{isPaused && <Property label="PgBouncer" value="Paused" />}
|
|
48
59
|
</PropertyList>
|
|
49
60
|
</Section>
|
|
50
61
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Clock, Database } from 'lucide-react'
|
|
2
2
|
import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
3
|
+
import { CronValue } from '../../ui/ScheduleValue'
|
|
3
4
|
import {
|
|
4
5
|
getCNPGScheduledBackupCluster,
|
|
5
6
|
getCNPGScheduleCron,
|
|
@@ -8,7 +9,9 @@ import {
|
|
|
8
9
|
getCNPGScheduledBackupNextSchedule,
|
|
9
10
|
getCNPGScheduledBackupIsSuspended,
|
|
10
11
|
getCNPGScheduledBackupIsImmediate,
|
|
12
|
+
getCNPGBackupPlugin,
|
|
11
13
|
getCNPGScheduledBackupOwnerRef,
|
|
14
|
+
CNPG_BARMAN_OBJECTSTORE_GROUP,
|
|
12
15
|
} from '../resource-utils-cnpg'
|
|
13
16
|
|
|
14
17
|
interface CNPGScheduledBackupRendererProps {
|
|
@@ -19,6 +22,7 @@ interface CNPGScheduledBackupRendererProps {
|
|
|
19
22
|
export function CNPGScheduledBackupRenderer({ data, onNavigate }: CNPGScheduledBackupRendererProps) {
|
|
20
23
|
const isSuspended = getCNPGScheduledBackupIsSuspended(data)
|
|
21
24
|
const clusterName = getCNPGScheduledBackupCluster(data)
|
|
25
|
+
const schedulePlugin = getCNPGBackupPlugin(data)
|
|
22
26
|
|
|
23
27
|
return (
|
|
24
28
|
<>
|
|
@@ -34,7 +38,7 @@ export function CNPGScheduledBackupRenderer({ data, onNavigate }: CNPGScheduledB
|
|
|
34
38
|
{/* Schedule */}
|
|
35
39
|
<Section title="Schedule" icon={Clock} defaultExpanded>
|
|
36
40
|
<PropertyList>
|
|
37
|
-
<Property label="Cron Expression" value={getCNPGScheduleCron(data)} />
|
|
41
|
+
<Property label="Cron Expression" value={<CronValue cron={getCNPGScheduleCron(data)} dialect="seconds" />} />
|
|
38
42
|
<Property label="Last Schedule" value={getCNPGScheduledBackupLastSchedule(data)} />
|
|
39
43
|
<Property label="Next Schedule" value={getCNPGScheduledBackupNextSchedule(data)} />
|
|
40
44
|
<Property label="Suspended" value={isSuspended ? 'Yes' : 'No'} />
|
|
@@ -60,6 +64,24 @@ export function CNPGScheduledBackupRenderer({ data, onNavigate }: CNPGScheduledB
|
|
|
60
64
|
return clusterName
|
|
61
65
|
})()} />
|
|
62
66
|
<Property label="Method" value={getCNPGScheduledBackupMethod(data)} />
|
|
67
|
+
{/* Under the plugin method the destination lives on an ObjectStore in
|
|
68
|
+
another API group, so naming it is the only route from here to
|
|
69
|
+
where these backups will actually land. */}
|
|
70
|
+
{schedulePlugin && <Property label="Plugin" value={schedulePlugin.name} />}
|
|
71
|
+
{schedulePlugin?.parameters?.barmanObjectName && (
|
|
72
|
+
<Property
|
|
73
|
+
label="Object Store"
|
|
74
|
+
value={
|
|
75
|
+
<ResourceLink
|
|
76
|
+
name={schedulePlugin.parameters.barmanObjectName}
|
|
77
|
+
kind="objectstores"
|
|
78
|
+
group={CNPG_BARMAN_OBJECTSTORE_GROUP}
|
|
79
|
+
namespace={data.metadata?.namespace || ''}
|
|
80
|
+
onNavigate={onNavigate}
|
|
81
|
+
/>
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
63
85
|
<Property label="Owner Reference" value={getCNPGScheduledBackupOwnerRef(data)} />
|
|
64
86
|
</PropertyList>
|
|
65
87
|
</Section>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { formatAge } from '../resource-utils'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A recovery point, stated absolutely with the interval beside it.
|
|
5
|
+
*
|
|
6
|
+
* "1d ago" is the wrong unit for this question on its own. An RPO conversation
|
|
7
|
+
* needs a timestamp — and "Last Successful Backup 1d ago" next to "Last Failed
|
|
8
|
+
* Attempt 1d ago" is the same string for events a week apart in the failure
|
|
9
|
+
* history, which is precisely when you are reading this screen. Same treatment
|
|
10
|
+
* the certificate rows already use.
|
|
11
|
+
*
|
|
12
|
+
* Shared by the Cluster and ObjectStore pages: the same recovery point is
|
|
13
|
+
* reachable from both depending on whether backups run through the plugin, and
|
|
14
|
+
* the two must not describe it differently.
|
|
15
|
+
*/
|
|
16
|
+
export function RecoveryTime({ at }: { at: string }) {
|
|
17
|
+
const d = new Date(at)
|
|
18
|
+
if (Number.isNaN(d.getTime())) return <>{at}</>
|
|
19
|
+
return (
|
|
20
|
+
<span className="inline-flex items-baseline gap-2 min-w-0">
|
|
21
|
+
<span>{d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</span>
|
|
22
|
+
{/* One interpolation, so the age and its unit stay a single text node
|
|
23
|
+
rather than being split by a renderer comment marker. */}
|
|
24
|
+
<span className="text-xs text-theme-text-tertiary shrink-0">{`${formatAge(at)} ago`}</span>
|
|
25
|
+
</span>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Network } from 'lucide-react'
|
|
2
|
+
import { Badge } from '../../ui/Badge'
|
|
3
|
+
import { ResourceLink, Section } from '../../ui/drawer-components'
|
|
4
|
+
|
|
5
|
+
interface CalicoHostEndpointRendererProps {
|
|
6
|
+
data: any
|
|
7
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CalicoHostEndpointRenderer({ data, onNavigate }: CalicoHostEndpointRendererProps) {
|
|
11
|
+
const spec = data?.spec ?? {}
|
|
12
|
+
const expectedIPs: string[] = spec.expectedIPs ?? []
|
|
13
|
+
const profiles: string[] = spec.profiles ?? []
|
|
14
|
+
const ports: Array<{ name?: string; port?: number; protocol?: string }> = spec.ports ?? []
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Section title="Host Endpoint" icon={Network}>
|
|
18
|
+
<div className="card-inner text-sm space-y-3">
|
|
19
|
+
<div className="flex items-start justify-between gap-3">
|
|
20
|
+
<div className="min-w-0">
|
|
21
|
+
<div className="text-xs text-theme-text-tertiary mb-0.5">Node</div>
|
|
22
|
+
<div className="font-medium text-theme-text-primary break-all">
|
|
23
|
+
{spec.node ? <ResourceLink name={spec.node} kind="nodes" namespace="" onNavigate={onNavigate} /> : 'Not specified'}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
{spec.interfaceName && (
|
|
27
|
+
<div className="shrink-0 text-right">
|
|
28
|
+
<div className="text-xs text-theme-text-tertiary mb-0.5">Interface Name</div>
|
|
29
|
+
<Badge tone="structural" size="sm" className="font-mono">{spec.interfaceName}</Badge>
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{expectedIPs.length > 0 && (
|
|
35
|
+
<div className="border-t border-theme-border-subtle pt-3">
|
|
36
|
+
<div className="text-xs text-theme-text-tertiary mb-1.5">Expected IPs</div>
|
|
37
|
+
<div className="flex flex-wrap gap-1">
|
|
38
|
+
{expectedIPs.map((ip) => (
|
|
39
|
+
<Badge key={ip} tone="structural" size="sm" className="font-mono">{ip}</Badge>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
{profiles.length > 0 && (
|
|
46
|
+
<div className="border-t border-theme-border-subtle pt-3">
|
|
47
|
+
<div className="text-xs text-theme-text-tertiary mb-1.5">Profiles</div>
|
|
48
|
+
<div className="flex flex-wrap gap-1">
|
|
49
|
+
{profiles.map((profile) => (
|
|
50
|
+
<Badge key={profile} tone="structural" size="sm">{profile}</Badge>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
{ports.length > 0 && (
|
|
57
|
+
<div className="border-t border-theme-border-subtle pt-3">
|
|
58
|
+
<div className="text-xs text-theme-text-tertiary mb-1.5">Named Ports</div>
|
|
59
|
+
<div className="flex flex-wrap gap-1">
|
|
60
|
+
{ports.map((port, index) => (
|
|
61
|
+
<Badge key={`${port.name}-${port.protocol}-${port.port}-${index}`} tone="structural" size="sm" className="font-mono">
|
|
62
|
+
{`${port.name ? `${port.name}: ` : ''}${port.protocol}/${port.port}`}
|
|
63
|
+
</Badge>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</Section>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Network } from 'lucide-react'
|
|
2
|
+
import { Property, PropertyList, Section } from '../../ui/drawer-components'
|
|
3
|
+
import {
|
|
4
|
+
getCalicoIPPoolAllowedUses,
|
|
5
|
+
getCalicoIPPoolBlockSize,
|
|
6
|
+
} from '../resource-utils-calico'
|
|
7
|
+
|
|
8
|
+
interface CalicoIPPoolRendererProps {
|
|
9
|
+
data: any
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function CalicoIPPoolRenderer({ data }: CalicoIPPoolRendererProps) {
|
|
13
|
+
const spec = data?.spec ?? {}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Section title="IP Pool" icon={Network}>
|
|
17
|
+
<PropertyList>
|
|
18
|
+
<Property label="Allowed Uses" value={getCalicoIPPoolAllowedUses(data)} />
|
|
19
|
+
<Property label="Assignment Mode" value={spec.assignmentMode ?? 'Automatic'} />
|
|
20
|
+
<Property label="Block Size" value={getCalicoIPPoolBlockSize(data)} />
|
|
21
|
+
<Property label="CIDR" value={spec.cidr} />
|
|
22
|
+
<Property label="IP-in-IP Mode" value={spec.ipipMode ?? 'Never'} />
|
|
23
|
+
<Property label="VXLAN Mode" value={spec.vxlanMode ?? 'Never'} />
|
|
24
|
+
<Property label="NAT Outgoing" value={spec.natOutgoing ? 'Yes' : 'No'} />
|
|
25
|
+
<Property label="Disabled" value={spec.disabled ? 'Yes' : 'No'} />
|
|
26
|
+
<Property label="BGP Export Disabled" value={spec.disableBGPExport ? 'Yes' : 'No'} />
|
|
27
|
+
<Property label="Node Selector" value={spec.nodeSelector ?? 'all()'} />
|
|
28
|
+
<Property label="Namespace Selector" value={spec.namespaceSelector} />
|
|
29
|
+
</PropertyList>
|
|
30
|
+
</Section>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { CalicoNetworkPolicyDiagram } from './CalicoNetworkPolicyDiagram'
|
|
4
|
+
|
|
5
|
+
describe('CalicoNetworkPolicyDiagram', () => {
|
|
6
|
+
it('shows ordered actions and Calico match fields in both directions', () => {
|
|
7
|
+
const html = renderToString(
|
|
8
|
+
<CalicoNetworkPolicyDiagram
|
|
9
|
+
spec={{
|
|
10
|
+
selector: "app == 'api'",
|
|
11
|
+
namespaceSelector: "project == 'prod'",
|
|
12
|
+
serviceAccountSelector: "role == 'api'",
|
|
13
|
+
types: ['Ingress', 'Egress'],
|
|
14
|
+
ingress: [
|
|
15
|
+
{
|
|
16
|
+
action: 'Allow',
|
|
17
|
+
protocol: 'TCP',
|
|
18
|
+
source: {
|
|
19
|
+
selector: "role == 'frontend'",
|
|
20
|
+
nets: ['10.0.0.0/8'],
|
|
21
|
+
serviceAccounts: { names: ['frontend'] },
|
|
22
|
+
},
|
|
23
|
+
destination: { ports: [80] },
|
|
24
|
+
},
|
|
25
|
+
{ action: 'Deny', source: { notSelector: "app == 'debug'" } },
|
|
26
|
+
],
|
|
27
|
+
egress: [
|
|
28
|
+
{
|
|
29
|
+
action: 'Log',
|
|
30
|
+
protocol: 'UDP',
|
|
31
|
+
source: { serviceAccounts: { selector: "role == 'api'" } },
|
|
32
|
+
destination: {
|
|
33
|
+
selector: "app == 'db'",
|
|
34
|
+
nets: ['192.168.0.0/16'],
|
|
35
|
+
ports: [{ port: 5432, endPort: 5433 }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{ action: 'Pass', destination: { notNets: ['0.0.0.0/0'] } },
|
|
39
|
+
],
|
|
40
|
+
}}
|
|
41
|
+
/>,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
for (const value of [
|
|
45
|
+
'app == 'api'',
|
|
46
|
+
'role == 'api'',
|
|
47
|
+
'Ingress',
|
|
48
|
+
'Egress',
|
|
49
|
+
'Deny',
|
|
50
|
+
'Log',
|
|
51
|
+
'Pass',
|
|
52
|
+
'TCP',
|
|
53
|
+
'UDP',
|
|
54
|
+
'role == 'frontend'',
|
|
55
|
+
'10.0.0.0/8',
|
|
56
|
+
'frontend',
|
|
57
|
+
'TCP/80',
|
|
58
|
+
'app == 'db'',
|
|
59
|
+
'192.168.0.0/16',
|
|
60
|
+
'UDP/5432-5433',
|
|
61
|
+
'Not Nets',
|
|
62
|
+
'0.0.0.0/0',
|
|
63
|
+
]) {
|
|
64
|
+
expect(html).toContain(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(html).not.toContain('Policy target')
|
|
68
|
+
expect(html).not.toContain('Source')
|
|
69
|
+
expect(html).not.toContain('Destination')
|
|
70
|
+
expect(html).not.toContain('Rule 1')
|
|
71
|
+
expect(html).toContain('Allow')
|
|
72
|
+
expect(html).not.toMatch(/badge-sm[^>]*>TCP<\/span>/)
|
|
73
|
+
expect(html).not.toMatch(/badge-sm[^>]*>UDP<\/span>/)
|
|
74
|
+
expect(html).toContain('text-warning-text')
|
|
75
|
+
expect(html).toContain('line-through')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps port constraints readable and distinguishes source exclusions', () => {
|
|
79
|
+
const html = renderToString(
|
|
80
|
+
<CalicoNetworkPolicyDiagram
|
|
81
|
+
spec={{
|
|
82
|
+
types: ['Egress'],
|
|
83
|
+
egress: [
|
|
84
|
+
{
|
|
85
|
+
protocol: 'UDP',
|
|
86
|
+
source: {
|
|
87
|
+
ports: [8080],
|
|
88
|
+
notPorts: [{ protocol: 'UDP', port: 5353 }],
|
|
89
|
+
},
|
|
90
|
+
destination: {
|
|
91
|
+
ports: [
|
|
92
|
+
{ protocol: 'TCP', port: 8080 },
|
|
93
|
+
{ protocol: 'TCP', port: 8443 },
|
|
94
|
+
],
|
|
95
|
+
notPorts: [443],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
}}
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for (const value of [
|
|
104
|
+
'src:UDP/8080',
|
|
105
|
+
'src:!UDP/5353',
|
|
106
|
+
'TCP/8080',
|
|
107
|
+
'TCP/8443',
|
|
108
|
+
'!UDP/443',
|
|
109
|
+
]) {
|
|
110
|
+
expect(html).toContain(value)
|
|
111
|
+
}
|
|
112
|
+
expect(html).not.toContain('dst:')
|
|
113
|
+
expect(html).not.toContain('max-w-14')
|
|
114
|
+
expect(html).not.toContain('truncate font-mono text-[8px]')
|
|
115
|
+
expect(html).not.toMatch(/badge-sm[^>]*>UDP<\/span>/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('renders protocol-only rules as wildcard arrow labels', () => {
|
|
119
|
+
const html = renderToString(
|
|
120
|
+
<CalicoNetworkPolicyDiagram
|
|
121
|
+
spec={{
|
|
122
|
+
types: ['Ingress'],
|
|
123
|
+
ingress: [
|
|
124
|
+
{
|
|
125
|
+
action: 'Deny',
|
|
126
|
+
protocol: 'TCP',
|
|
127
|
+
notProtocol: 'UDP',
|
|
128
|
+
source: { selector: "app == 'client'" },
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}}
|
|
132
|
+
/>,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(html).toContain('TCP/*')
|
|
136
|
+
expect(html).not.toMatch(/badge-sm[^>]*>TCP<\/span>/)
|
|
137
|
+
expect(html).toContain('not')
|
|
138
|
+
expect(html).toContain('UDP')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('uses native peer colors with stable match priority', () => {
|
|
142
|
+
const renderDestination = (destination: any) =>
|
|
143
|
+
renderToString(
|
|
144
|
+
<CalicoNetworkPolicyDiagram
|
|
145
|
+
spec={{
|
|
146
|
+
selector: "app == 'target'",
|
|
147
|
+
types: ['Egress'],
|
|
148
|
+
egress: [{ destination }],
|
|
149
|
+
}}
|
|
150
|
+
/>,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const combined = renderDestination({
|
|
154
|
+
selector: "app == 'workload'",
|
|
155
|
+
namespaceSelector: "team == 'prod'",
|
|
156
|
+
nets: ['10.0.0.0/8'],
|
|
157
|
+
})
|
|
158
|
+
const namespace = renderDestination({
|
|
159
|
+
namespaceSelector: "team == 'prod'",
|
|
160
|
+
})
|
|
161
|
+
const network = renderDestination({ nets: ['10.0.0.0/8'] })
|
|
162
|
+
const serviceAccount = renderDestination({
|
|
163
|
+
serviceAccountSelector: "role == 'api'",
|
|
164
|
+
})
|
|
165
|
+
const unknown = renderDestination({ unsupportedMatch: 'value' })
|
|
166
|
+
|
|
167
|
+
expect(combined).toContain('border-emerald-500/30 bg-emerald-500/8')
|
|
168
|
+
expect(combined).not.toContain('border-sky-500/30 bg-sky-500/8')
|
|
169
|
+
expect(combined).not.toContain('border-amber-500/30 bg-amber-500/8')
|
|
170
|
+
expect(namespace).toContain('border-sky-500/30 bg-sky-500/8')
|
|
171
|
+
expect(network).toContain('border-amber-500/30 bg-amber-500/8')
|
|
172
|
+
expect(serviceAccount).toContain('border-emerald-500/30 bg-emerald-500/8')
|
|
173
|
+
expect(unknown).toContain('border-theme-border bg-theme-elevated/50')
|
|
174
|
+
expect(combined).toContain('border-indigo-500/30 bg-indigo-500/8')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does not imply deny-all when a policy has no explicit rules', () => {
|
|
178
|
+
const html = renderToString(
|
|
179
|
+
<CalicoNetworkPolicyDiagram spec={{ types: ['Ingress'], ingress: [] }} />,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
expect(html).toContain('No explicit')
|
|
183
|
+
expect(html).toContain('ingress')
|
|
184
|
+
expect(html).not.toContain('Deny all')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('renders staged rules as non-enforced dashed paths', () => {
|
|
188
|
+
const html = renderToString(
|
|
189
|
+
<CalicoNetworkPolicyDiagram
|
|
190
|
+
staged
|
|
191
|
+
spec={{
|
|
192
|
+
selector: "app == 'api'",
|
|
193
|
+
types: ['Ingress'],
|
|
194
|
+
ingress: [{ action: 'Allow', source: {} }],
|
|
195
|
+
}}
|
|
196
|
+
/>,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
expect(html).toContain('Staged preview')
|
|
200
|
+
expect(html).toContain('not enforced')
|
|
201
|
+
expect(html).toContain('stroke-dasharray="4 3"')
|
|
202
|
+
expect(html).toContain('Allow')
|
|
203
|
+
expect(html).not.toContain('Rule 1')
|
|
204
|
+
})
|
|
205
|
+
})
|