@skyhook-io/k8s-ui 1.16.2 → 1.16.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 +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/utils/log-level.ts +14 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { CNPGBackupSummary, CNPGScheduledBackupSummary } from './CNPGBackupSummary'
|
|
4
|
+
import { CNPGObjectStoreSummary } from './CNPGObjectStoreSummary'
|
|
5
|
+
import { CNPGDatabaseSummary } from './CNPGDeclarativeSummary'
|
|
6
|
+
import { CNPGPoolerSummary } from './CNPGPoolerSummary'
|
|
7
|
+
import { CNPGImageCatalogSummary } from './CNPGImageCatalogSummary'
|
|
8
|
+
import { CNPG_WORKSPACE_KEYS, type CNPGWorkspaceKey, type CNPGWorkspaceResponse } from './workspace'
|
|
9
|
+
|
|
10
|
+
const PG = 'postgresql.cnpg.io/v1'
|
|
11
|
+
const PLUGIN = 'barman-cloud.cloudnative-pg.io'
|
|
12
|
+
const nav = () => {}
|
|
13
|
+
|
|
14
|
+
function ws(objects: Partial<Record<CNPGWorkspaceKey, any[]>>, over: Partial<CNPGWorkspaceResponse> = {}): CNPGWorkspaceResponse {
|
|
15
|
+
const coverage: CNPGWorkspaceResponse['coverage'] = {}
|
|
16
|
+
for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
|
|
17
|
+
return {
|
|
18
|
+
installed: true,
|
|
19
|
+
context: 'c',
|
|
20
|
+
namespaces: ['pg'],
|
|
21
|
+
coverage: { ...coverage, ...(over.coverage ?? {}) },
|
|
22
|
+
objects,
|
|
23
|
+
issues: over.issues ?? [],
|
|
24
|
+
audit: [],
|
|
25
|
+
backupsOmitted: over.backupsOmitted ?? 0,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function text(html: string): string {
|
|
30
|
+
return html
|
|
31
|
+
.split(/<[^>]*>/)
|
|
32
|
+
.join('')
|
|
33
|
+
.split(''').join("'")
|
|
34
|
+
.split('"').join('"')
|
|
35
|
+
.split('&').join('&')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mainCluster = {
|
|
39
|
+
apiVersion: PG,
|
|
40
|
+
kind: 'Cluster',
|
|
41
|
+
metadata: { name: 'main', namespace: 'pg' },
|
|
42
|
+
spec: { plugins: [{ name: PLUGIN, parameters: { barmanObjectName: 'store' } }], managed: { roles: [{ name: 'reporting' }] } },
|
|
43
|
+
status: { conditions: [{ type: 'ContinuousArchiving', status: 'False', message: 'upload failed' }] },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('CNPGBackupSummary', () => {
|
|
47
|
+
const b = {
|
|
48
|
+
apiVersion: PG,
|
|
49
|
+
kind: 'Backup',
|
|
50
|
+
metadata: { name: 'main-20260901', namespace: 'pg', labels: { 'cnpg.io/scheduled-backup': 'nightly' } },
|
|
51
|
+
spec: { cluster: { name: 'main' }, method: 'plugin', pluginConfiguration: { name: PLUGIN } },
|
|
52
|
+
status: {
|
|
53
|
+
phase: 'failed',
|
|
54
|
+
startedAt: '2026-09-01T00:00:00Z',
|
|
55
|
+
stoppedAt: '2026-09-01T00:02:30Z',
|
|
56
|
+
instanceID: { podName: 'main-2' },
|
|
57
|
+
error: 'can not upload',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('shows the outcome and relationships from the same status fields', () => {
|
|
62
|
+
const html = renderToString(<CNPGBackupSummary resource={b} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />)
|
|
63
|
+
const t = text(html)
|
|
64
|
+
expect(t).toContain('Failed')
|
|
65
|
+
expect(t).toContain('took 2m 30s')
|
|
66
|
+
expect(t).toContain('main-2')
|
|
67
|
+
expect(t).toContain('can not upload')
|
|
68
|
+
expect(t).toContain('ScheduledBackup nightly')
|
|
69
|
+
expect(t).toContain("ObjectStore store · from the Cluster's current configuration")
|
|
70
|
+
expect(t).not.toContain('On demand')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not link a same-name schedule that replaced the one that took the backup', () => {
|
|
74
|
+
const owned = { ...b, metadata: { ...b.metadata, ownerReferences: [{ apiVersion: PG, kind: 'ScheduledBackup', name: 'nightly', uid: 'old' }] } }
|
|
75
|
+
const sched = { apiVersion: PG, kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'pg', uid: 'new' } }
|
|
76
|
+
const t = text(renderToString(<CNPGBackupSummary resource={owned} workspace={ws({ clusters: [mainCluster], scheduledBackups: [sched] })} onNavigate={nav} />))
|
|
77
|
+
expect(t).toContain('an earlier schedule of that name')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('shows its own issues on top', () => {
|
|
81
|
+
const issues = [
|
|
82
|
+
{ id: 'i1', severity: 'critical' as const, kind: 'Backup', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main-20260901', reason: 'CNPGBackupFailed', message: 'Backup failed' },
|
|
83
|
+
{ id: 'i2', severity: 'critical' as const, kind: 'Backup', group: 'velero.io', namespace: 'pg', name: 'main-20260901', reason: 'VeleroBackupFailed', message: 'Velero backup failed' },
|
|
84
|
+
]
|
|
85
|
+
const t = text(renderToString(<CNPGBackupSummary resource={b} workspace={ws({ clusters: [mainCluster] }, { issues })} onNavigate={nav} />))
|
|
86
|
+
expect(t).toContain('Backup failed')
|
|
87
|
+
expect(t).not.toContain('Velero backup failed')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('CNPGScheduledBackupSummary', () => {
|
|
92
|
+
it('lists owned runs and notes omitted history', () => {
|
|
93
|
+
const sched = { apiVersion: PG, kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'main' }, schedule: '0 0 0 * * *' } }
|
|
94
|
+
const run = { apiVersion: PG, kind: 'Backup', metadata: { name: 'run-1', namespace: 'pg', labels: { 'cnpg.io/scheduled-backup': 'nightly' } }, status: { phase: 'completed', startedAt: '2026-09-01T00:00:00Z' } }
|
|
95
|
+
const t = text(renderToString(<CNPGScheduledBackupSummary resource={sched} workspace={ws({ backups: [run] }, { backupsOmitted: 3 })} onNavigate={nav} />))
|
|
96
|
+
expect(t).toContain('0 0 0 * * *')
|
|
97
|
+
expect(t).toContain('seconds first')
|
|
98
|
+
expect(t).not.toContain('Daily')
|
|
99
|
+
expect(t).toContain('run-1')
|
|
100
|
+
expect(t).toContain('Completed')
|
|
101
|
+
expect(t).toContain('Backups older than 7 days are not listed')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('says when Backups are not readable instead of listing none', () => {
|
|
105
|
+
const sched = { apiVersion: PG, kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'main' } } }
|
|
106
|
+
const t = text(renderToString(<CNPGScheduledBackupSummary resource={sched} workspace={ws({}, { coverage: { backups: { state: 'denied' } } })} />))
|
|
107
|
+
expect(t).toContain('No access to Backups')
|
|
108
|
+
expect(t).not.toContain('No Backups from this schedule')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('CNPGObjectStoreSummary', () => {
|
|
113
|
+
const store = {
|
|
114
|
+
apiVersion: 'barmancloud.cnpg.io/v1',
|
|
115
|
+
kind: 'ObjectStore',
|
|
116
|
+
metadata: { name: 'store', namespace: 'pg' },
|
|
117
|
+
spec: {
|
|
118
|
+
configuration: {
|
|
119
|
+
destinationPath: 's3://bucket/pg',
|
|
120
|
+
s3Credentials: { accessKeyId: { name: 's3-creds', key: 'ACCESS_KEY_ID' }, secretAccessKey: { name: 's3-creds', key: 'SECRET' } },
|
|
121
|
+
},
|
|
122
|
+
retentionPolicy: '30d',
|
|
123
|
+
},
|
|
124
|
+
status: {
|
|
125
|
+
serverRecoveryWindow: {
|
|
126
|
+
main: { firstRecoverabilityPoint: '2026-09-01T00:00:00Z', lastSuccessfulBackupTime: '2026-09-02T00:00:00Z', lastFailedBackupTime: '2026-09-03T00:00:00Z' },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('labels upload health as inferred and explains the stalled window', () => {
|
|
132
|
+
const t = text(renderToString(<CNPGObjectStoreSummary resource={store} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />))
|
|
133
|
+
expect(t).toContain('inferred')
|
|
134
|
+
expect(t).toContain("Inferred from 1 cluster's WAL archiving and backup results — ObjectStore has no health status")
|
|
135
|
+
expect(t).toContain('Uploads failing')
|
|
136
|
+
expect(t).toContain('A backup failed after the last recorded success.')
|
|
137
|
+
expect(t).not.toContain('restorable')
|
|
138
|
+
expect(t).toContain('s3-creds')
|
|
139
|
+
expect(t).toContain('s3://bucket/pg')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('never renders credential keys or Secret contents', () => {
|
|
143
|
+
const html = renderToString(<CNPGObjectStoreSummary resource={store} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />)
|
|
144
|
+
expect(html).not.toContain('ACCESS_KEY_ID')
|
|
145
|
+
expect(html).not.toContain('SECRET')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('records a failure with no success without claiming a recovery point', () => {
|
|
149
|
+
const failing = { ...store, status: { serverRecoveryWindow: { main: { lastFailedBackupTime: '2026-09-03T00:00:00Z' } } } }
|
|
150
|
+
const t = text(renderToString(<CNPGObjectStoreSummary resource={failing} workspace={ws({ clusters: [mainCluster] })} />))
|
|
151
|
+
expect(t).toContain('No successful backup recorded.')
|
|
152
|
+
expect(t).not.toContain('restorable')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('says when no visible cluster uses the store', () => {
|
|
156
|
+
const t = text(renderToString(<CNPGObjectStoreSummary resource={store} workspace={ws({ clusters: [] })} />))
|
|
157
|
+
expect(t).toContain('No visible cluster uses this store')
|
|
158
|
+
expect(t).not.toContain('Inferred from')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('CNPGDatabaseSummary', () => {
|
|
163
|
+
const db = (status: any) => ({
|
|
164
|
+
apiVersion: PG,
|
|
165
|
+
kind: 'Database',
|
|
166
|
+
metadata: { name: 'app-db', namespace: 'pg', generation: 2 },
|
|
167
|
+
spec: { cluster: { name: 'main' }, name: 'app', owner: 'app', databaseReclaimPolicy: 'delete' },
|
|
168
|
+
status,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('reports an unreconciled database as pending, not failed', () => {
|
|
172
|
+
const t = text(renderToString(<CNPGDatabaseSummary resource={db({})} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />))
|
|
173
|
+
expect(t).toContain('Pending')
|
|
174
|
+
expect(t).not.toContain('Not applied')
|
|
175
|
+
expect(t).toContain('drops it from PostgreSQL')
|
|
176
|
+
expect(t).toContain('GitOps source not recorded')
|
|
177
|
+
expect(t).not.toContain('Applied directly')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('reports a failure and the missing managed role beside it', () => {
|
|
181
|
+
const t = text(
|
|
182
|
+
renderToString(
|
|
183
|
+
<CNPGDatabaseSummary
|
|
184
|
+
resource={db({ applied: false, observedGeneration: 2, message: 'role "app" does not exist' })}
|
|
185
|
+
workspace={ws({ clusters: [mainCluster] })}
|
|
186
|
+
onNavigate={nav}
|
|
187
|
+
/>,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
expect(t).toContain('Not applied')
|
|
191
|
+
expect(t).toContain('“app” is not among main\'s managed roles')
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('CNPGPoolerSummary', () => {
|
|
196
|
+
it('reports unknown scheduled count and unmeasured pressure', () => {
|
|
197
|
+
const pooler = { apiVersion: PG, kind: 'Pooler', metadata: { name: 'main-rw', namespace: 'pg' }, spec: { cluster: { name: 'main' }, type: 'rw', instances: 2 } }
|
|
198
|
+
const t = text(renderToString(<CNPGPoolerSummary resource={pooler} workspace={ws({})} onNavigate={nav} />))
|
|
199
|
+
expect(t).toContain('Scheduled count not reported')
|
|
200
|
+
expect(t).toContain('Not measured')
|
|
201
|
+
expect(t).toContain('main-rw')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('CNPGImageCatalogSummary', () => {
|
|
206
|
+
it('lists users and flags a major the catalog lacks', () => {
|
|
207
|
+
const catalog = { apiVersion: PG, kind: 'ClusterImageCatalog', metadata: { name: 'pg' }, spec: { images: [{ major: 16, image: 'ghcr.io/cnpg/postgresql:16' }] } }
|
|
208
|
+
const clusters = [
|
|
209
|
+
{ apiVersion: PG, kind: 'Cluster', metadata: { name: 'a', namespace: 'x' }, spec: { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 16 } } },
|
|
210
|
+
{ apiVersion: PG, kind: 'Cluster', metadata: { name: 'b', namespace: 'y' }, spec: { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 17 } } },
|
|
211
|
+
]
|
|
212
|
+
const t = text(renderToString(<CNPGImageCatalogSummary resource={catalog} workspace={ws({ clusters })} onNavigate={nav} />))
|
|
213
|
+
expect(t).toContain('x/a')
|
|
214
|
+
expect(t).toContain('Requests PostgreSQL 17 · not in this catalog')
|
|
215
|
+
expect(t).toContain('clusters in namespaces you cannot read are not listed')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('keeps known users when cluster coverage is partial', () => {
|
|
219
|
+
const catalog = { apiVersion: PG, kind: 'ClusterImageCatalog', metadata: { name: 'pg' }, spec: { images: [{ major: 16, image: 'img:16' }] } }
|
|
220
|
+
const clusters = [{ apiVersion: PG, kind: 'Cluster', metadata: { name: 'a', namespace: 'x' }, spec: { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 16 } } }]
|
|
221
|
+
const t = text(
|
|
222
|
+
renderToString(
|
|
223
|
+
<CNPGImageCatalogSummary resource={catalog} workspace={ws({ clusters }, { coverage: { clusters: { state: 'partial', deniedNamespaces: ['y'] } } })} />,
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
expect(t).toContain('x/a')
|
|
227
|
+
expect(t).toContain('Only clusters in namespaces you can read are listed.')
|
|
228
|
+
expect(t).not.toContain('No access to Clusters')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('reserves the unavailable text for denied coverage', () => {
|
|
232
|
+
const catalog = { apiVersion: PG, kind: 'ClusterImageCatalog', metadata: { name: 'pg' }, spec: {} }
|
|
233
|
+
const t = text(renderToString(<CNPGImageCatalogSummary resource={catalog} workspace={ws({}, { coverage: { clusters: { state: 'denied' } } })} />))
|
|
234
|
+
expect(t).toContain('No access to Clusters')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getCNPGPoolerDeploymentName, getCNPGPoolerMode, getCNPGPoolerStatus, isCNPGPoolerPaused } from '../resources/resource-utils-cnpg'
|
|
2
|
+
import type { CNPGWorkspaceResponse } from './workspace'
|
|
3
|
+
import { FactGrid, FactRow, FactValue, RefLink, SummaryHeading, type CNPGNavigate } from './primitives'
|
|
4
|
+
import { ClusterLink, NotReported, Note, ObjectProblems, PhaseBadge, SummaryShell } from './CNPGSharedSummary'
|
|
5
|
+
import { refOf } from './relations'
|
|
6
|
+
|
|
7
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
8
|
+
rw: 'rw · routes to the primary',
|
|
9
|
+
ro: 'ro · routes to replicas',
|
|
10
|
+
r: 'r · routes to any instance',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CNPGPoolerSummary({
|
|
14
|
+
resource,
|
|
15
|
+
workspace,
|
|
16
|
+
onNavigate,
|
|
17
|
+
}: {
|
|
18
|
+
resource: any
|
|
19
|
+
workspace: CNPGWorkspaceResponse | null
|
|
20
|
+
onNavigate?: CNPGNavigate
|
|
21
|
+
}) {
|
|
22
|
+
const ns = resource?.metadata?.namespace ?? ''
|
|
23
|
+
const type = resource?.spec?.type
|
|
24
|
+
const desired = resource?.spec?.instances
|
|
25
|
+
const scheduled = resource?.status?.instances
|
|
26
|
+
const deployment = getCNPGPoolerDeploymentName(resource)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<SummaryShell>
|
|
30
|
+
<ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'Pooler')} onNavigate={onNavigate} />
|
|
31
|
+
|
|
32
|
+
<SummaryHeading>State</SummaryHeading>
|
|
33
|
+
<FactGrid>
|
|
34
|
+
<FactRow label="Status">
|
|
35
|
+
<PhaseBadge status={getCNPGPoolerStatus(resource)} />
|
|
36
|
+
{isCNPGPoolerPaused(resource) && <Note>PgBouncer is paused: it holds client connections instead of serving them.</Note>}
|
|
37
|
+
</FactRow>
|
|
38
|
+
<FactRow label="Instances">
|
|
39
|
+
<span>
|
|
40
|
+
{typeof scheduled === 'number' ? `${scheduled} scheduled` : <NotReported text="Scheduled count not reported" />}
|
|
41
|
+
<span className="text-theme-text-secondary">
|
|
42
|
+
{' · '}
|
|
43
|
+
{typeof desired === 'number' ? `${desired} desired` : 'desired not set'}
|
|
44
|
+
</span>
|
|
45
|
+
</span>
|
|
46
|
+
<Note>The Pooler counts scheduled pods, not ready ones; readiness is on its Deployment.</Note>
|
|
47
|
+
</FactRow>
|
|
48
|
+
<FactRow label="Deployment">
|
|
49
|
+
{deployment ? (
|
|
50
|
+
<RefLink refTo={{ kind: 'Deployment', group: 'apps', namespace: ns, name: deployment }} onNavigate={onNavigate} mono />
|
|
51
|
+
) : (
|
|
52
|
+
<NotReported />
|
|
53
|
+
)}
|
|
54
|
+
</FactRow>
|
|
55
|
+
<FactRow label="Connection pressure">
|
|
56
|
+
<FactValue fact={{ text: 'Not measured — needs PgBouncer metrics, which Radar does not read yet', tone: 'unknown' }} />
|
|
57
|
+
</FactRow>
|
|
58
|
+
</FactGrid>
|
|
59
|
+
|
|
60
|
+
<SummaryHeading>Routing</SummaryHeading>
|
|
61
|
+
<FactGrid>
|
|
62
|
+
<FactRow label="Cluster">
|
|
63
|
+
<ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
|
|
64
|
+
</FactRow>
|
|
65
|
+
<FactRow label="Type">{type ? TYPE_LABEL[type] ?? type : <NotReported text="Not set" />}</FactRow>
|
|
66
|
+
<FactRow label="Pool mode">{getCNPGPoolerMode(resource)}</FactRow>
|
|
67
|
+
</FactGrid>
|
|
68
|
+
</SummaryShell>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Badge } from '../ui/Badge'
|
|
3
|
+
import type { StatusBadge as StatusBadgeValue } from '../resources/resource-utils'
|
|
4
|
+
import { CNPG_GROUP } from '../resources/resource-utils-cnpg'
|
|
5
|
+
import type { CNPGWorkspaceIssue, CNPGWorkspaceResponse } from './workspace'
|
|
6
|
+
import { FactValue, ProblemCallout, RefLink, type CNPGNavigate } from './primitives'
|
|
7
|
+
import { clustersIn, healthSeverity, problemsForObject, relationUnavailable, targetCluster, type CNPGObjectRef } from './relations'
|
|
8
|
+
|
|
9
|
+
const MAX_PROBLEMS = 3
|
|
10
|
+
|
|
11
|
+
export function SummaryShell({ children }: { children: ReactNode }) {
|
|
12
|
+
return <div className="px-4 py-4">{children}</div>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The object's own Radar issues, most severe first. */
|
|
16
|
+
export function ObjectProblems({
|
|
17
|
+
issues,
|
|
18
|
+
subject,
|
|
19
|
+
onNavigate,
|
|
20
|
+
}: {
|
|
21
|
+
issues: CNPGWorkspaceIssue[] | undefined
|
|
22
|
+
subject: CNPGObjectRef
|
|
23
|
+
onNavigate?: CNPGNavigate
|
|
24
|
+
}) {
|
|
25
|
+
const problems = problemsForObject(issues, subject)
|
|
26
|
+
if (problems.length === 0) return null
|
|
27
|
+
const shown = problems.slice(0, MAX_PROBLEMS)
|
|
28
|
+
const rest = problems.length - shown.length
|
|
29
|
+
return (
|
|
30
|
+
<div className="mb-4 space-y-2">
|
|
31
|
+
{shown.map((p, i) => (
|
|
32
|
+
<ProblemCallout
|
|
33
|
+
key={p.id}
|
|
34
|
+
problem={p}
|
|
35
|
+
onNavigate={onNavigate}
|
|
36
|
+
subjectIsSelf
|
|
37
|
+
more={i === shown.length - 1 && rest > 0 ? <span>+{rest} more</span> : null}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function PhaseBadge({ status }: { status: StatusBadgeValue }) {
|
|
45
|
+
return (
|
|
46
|
+
<Badge severity={healthSeverity(status.level)} size="sm">
|
|
47
|
+
{status.text}
|
|
48
|
+
</Badge>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function NotReported({ text = 'Not reported' }: { text?: string }) {
|
|
53
|
+
return <span className="text-theme-text-tertiary">{text}</span>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A timestamp as an age, with the absolute time on hover. */
|
|
57
|
+
export function TimeAgo({ at, missing }: { at: unknown; missing?: string }) {
|
|
58
|
+
if (typeof at !== 'string' || !at || !Number.isFinite(Date.parse(at))) return <NotReported text={missing} />
|
|
59
|
+
return <FactValue fact={{ text: '', tone: 'neutral', at }} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Note({ children }: { children: ReactNode }) {
|
|
63
|
+
return <div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">{children}</div>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Cluster an object declares itself against, linked. */
|
|
67
|
+
export function ClusterLink({
|
|
68
|
+
resource,
|
|
69
|
+
workspace,
|
|
70
|
+
onNavigate,
|
|
71
|
+
}: {
|
|
72
|
+
resource: any
|
|
73
|
+
workspace: CNPGWorkspaceResponse | null
|
|
74
|
+
onNavigate?: CNPGNavigate
|
|
75
|
+
}) {
|
|
76
|
+
const name = resource?.spec?.cluster?.name
|
|
77
|
+
if (!name) return <NotReported text="Not set" />
|
|
78
|
+
const ns = resource?.metadata?.namespace ?? ''
|
|
79
|
+
const visible = !!targetCluster(resource, clustersIn(workspace))
|
|
80
|
+
return (
|
|
81
|
+
<span>
|
|
82
|
+
<RefLink refTo={{ kind: 'Cluster', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
|
|
83
|
+
{workspace && !visible && !relationUnavailable(workspace, 'clusters', ns, 'Clusters') && (
|
|
84
|
+
<span className="text-theme-text-tertiary"> · not found in this namespace</span>
|
|
85
|
+
)}
|
|
86
|
+
</span>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './workspace'
|
|
2
|
+
export * from './primitives'
|
|
3
|
+
export * from './CNPGClusterSummary'
|
|
4
|
+
export * from './CNPGBackupSummary'
|
|
5
|
+
export * from './CNPGObjectStoreSummary'
|
|
6
|
+
export * from './CNPGDeclarativeSummary'
|
|
7
|
+
export * from './CNPGPoolerSummary'
|
|
8
|
+
export * from './CNPGImageCatalogSummary'
|
|
9
|
+
export {
|
|
10
|
+
inferredObjectStoreHealth,
|
|
11
|
+
usersOfObjectStore,
|
|
12
|
+
type CNPGObjectStoreHealth,
|
|
13
|
+
type CNPGObjectStoreUser,
|
|
14
|
+
} from './relations'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import type { HealthLevel } from '../resources/resource-utils'
|
|
4
|
+
import { formatAge } from '../resources/resource-utils'
|
|
5
|
+
import { StatusDot } from '../ui/status-tone'
|
|
6
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
7
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
8
|
+
import { TONE_TEXT_CLASS } from '../ui/severity-tone'
|
|
9
|
+
import type { CNPGFact, CNPGProblem } from './workspace'
|
|
10
|
+
|
|
11
|
+
export interface CNPGRef {
|
|
12
|
+
kind: string
|
|
13
|
+
group?: string
|
|
14
|
+
namespace: string
|
|
15
|
+
name: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type CNPGNavigate = (ref: CNPGRef) => void
|
|
19
|
+
|
|
20
|
+
const TONE_TEXT: Record<HealthLevel, string> = {
|
|
21
|
+
healthy: 'text-theme-text-primary',
|
|
22
|
+
degraded: TONE_TEXT_CLASS.amber,
|
|
23
|
+
alert: TONE_TEXT_CLASS.orange,
|
|
24
|
+
unhealthy: TONE_TEXT_CLASS.red,
|
|
25
|
+
unknown: 'text-theme-text-tertiary',
|
|
26
|
+
neutral: 'text-theme-text-secondary',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const CNPG_PRIMARY_BUTTON = 'btn-brand inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium'
|
|
30
|
+
export const CNPG_SECONDARY_BUTTON =
|
|
31
|
+
'inline-flex items-center gap-1.5 rounded-lg border border-theme-border bg-theme-surface px-3 py-1.5 text-sm text-theme-text-primary transition-colors hover:bg-theme-hover'
|
|
32
|
+
|
|
33
|
+
export function toneTextClass(tone: HealthLevel): string {
|
|
34
|
+
return TONE_TEXT[tone]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function FactValue({ fact, className }: { fact: CNPGFact; className?: string }) {
|
|
38
|
+
const age = fact.at ? formatAge(fact.at) : null
|
|
39
|
+
const body = (
|
|
40
|
+
<span className={clsx(toneTextClass(fact.tone), className)}>
|
|
41
|
+
{fact.text}
|
|
42
|
+
{age && <span className="text-theme-text-secondary">{fact.text ? ' · ' : ''}{age} ago</span>}
|
|
43
|
+
</span>
|
|
44
|
+
)
|
|
45
|
+
if (!fact.source && !fact.at) return body
|
|
46
|
+
return (
|
|
47
|
+
<Tooltip content={[fact.at ? new Date(fact.at).toUTCString() : null, fact.source].filter(Boolean).join(' · ')} position="top">
|
|
48
|
+
{body}
|
|
49
|
+
</Tooltip>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function FactSource({ fact }: { fact: CNPGFact }) {
|
|
54
|
+
if (!fact.source) return null
|
|
55
|
+
return <div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">{fact.source}</div>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function FactGrid({ children }: { children: ReactNode }) {
|
|
59
|
+
return <dl className="grid grid-cols-[9.5rem_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">{children}</dl>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function FactRow({ label, children }: { label: ReactNode; children: ReactNode }) {
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<dt className="text-theme-text-tertiary">{label}</dt>
|
|
66
|
+
<dd className="min-w-0 break-words text-theme-text-primary">{children}</dd>
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SummaryHeading({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="mb-2 mt-5 flex items-baseline gap-2 first:mt-0">
|
|
74
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{children}</h3>
|
|
75
|
+
{hint && <span className="text-[11px] text-theme-text-tertiary">{hint}</span>}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function RefLink({ refTo, onNavigate, children, mono }: { refTo: CNPGRef; onNavigate?: CNPGNavigate; children?: ReactNode; mono?: boolean }) {
|
|
81
|
+
const label = children ?? refTo.name
|
|
82
|
+
if (!onNavigate) return <span className={clsx(mono && 'font-mono')}>{label}</span>
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => onNavigate(refTo)}
|
|
87
|
+
className={clsx('text-accent-text hover:underline text-left break-all', mono && 'font-mono')}
|
|
88
|
+
>
|
|
89
|
+
{label}
|
|
90
|
+
</button>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const PROBLEM_VARIANT: Record<CNPGProblem['severity'], 'error' | 'warning' | 'info'> = {
|
|
95
|
+
critical: 'error',
|
|
96
|
+
warning: 'warning',
|
|
97
|
+
posture: 'info',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ProblemCallout({
|
|
101
|
+
problem,
|
|
102
|
+
more,
|
|
103
|
+
onNavigate,
|
|
104
|
+
action,
|
|
105
|
+
subjectIsSelf,
|
|
106
|
+
}: {
|
|
107
|
+
problem: CNPGProblem
|
|
108
|
+
more?: ReactNode
|
|
109
|
+
onNavigate?: CNPGNavigate
|
|
110
|
+
action?: ReactNode
|
|
111
|
+
/** The callout sits on the subject's own page, so linking to it would loop. */
|
|
112
|
+
subjectIsSelf?: boolean
|
|
113
|
+
}) {
|
|
114
|
+
const aboutChild = !subjectIsSelf && problem.subject.kind !== 'Cluster'
|
|
115
|
+
return (
|
|
116
|
+
<AlertBanner variant={PROBLEM_VARIANT[problem.severity]} title={problem.title} message={problem.detail}>
|
|
117
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
|
|
118
|
+
{aboutChild && (
|
|
119
|
+
<span>
|
|
120
|
+
{problem.subject.kind}{' '}
|
|
121
|
+
<RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
|
|
122
|
+
</span>
|
|
123
|
+
)}
|
|
124
|
+
<span>{problem.source === 'audit' ? 'Radar check' : 'Radar issue'}</span>
|
|
125
|
+
{action}
|
|
126
|
+
{more}
|
|
127
|
+
</div>
|
|
128
|
+
</AlertBanner>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function ToneDot({ tone }: { tone: HealthLevel }) {
|
|
133
|
+
return <StatusDot tone={tone} size="sm" />
|
|
134
|
+
}
|