@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.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  3. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  4. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  5. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  6. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  7. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  8. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  9. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  10. package/src/components/cnpg/index.ts +14 -0
  11. package/src/components/cnpg/primitives.tsx +134 -0
  12. package/src/components/cnpg/relations.test.ts +304 -0
  13. package/src/components/cnpg/relations.ts +417 -0
  14. package/src/components/cnpg/workspace.test.ts +285 -0
  15. package/src/components/cnpg/workspace.ts +700 -0
  16. package/src/components/issues/IssuesView.tsx +10 -1
  17. package/src/components/issues/types.ts +24 -0
  18. package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
  19. package/src/components/logs/useLogBuffer.test.ts +17 -0
  20. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  21. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  22. package/src/components/resources/ResourcesView.tsx +5 -1
  23. package/src/components/resources/index.ts +1 -1
  24. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  25. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  26. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  27. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  28. package/src/components/resources/status-overclaim.test.ts +1 -1
  29. package/src/components/timeline/TimelineList.tsx +185 -23
  30. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  31. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  32. package/src/components/timeline/index.ts +1 -0
  33. package/src/components/timeline/timeline-filters.ts +7 -0
  34. package/src/components/workload/WorkloadView.tsx +96 -5
  35. package/src/components/workload/index.ts +1 -0
  36. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  37. package/src/index.ts +3 -0
  38. package/src/utils/log-level.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.16.2",
3
+ "version": "1.16.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -71,7 +71,7 @@
71
71
  "@monaco-editor/react": "^4.7.0",
72
72
  "html-to-image": "^1.11.0",
73
73
  "monaco-yaml": "5.5.1",
74
- "react-virtuoso": "^4.18.13",
74
+ "react-virtuoso": "^4.18.16",
75
75
  "shiki": "^4.4.2"
76
76
  },
77
77
  "peerDependencies": {
@@ -105,7 +105,7 @@
105
105
  "react": "^19.3.0",
106
106
  "react-dom": "^19.3.0",
107
107
  "typescript": "^6.0.2",
108
- "vitest": "^5.0.2",
108
+ "vitest": "^5.0.3",
109
109
  "yaml": "^2.9.0"
110
110
  }
111
111
  }
@@ -0,0 +1,223 @@
1
+ import { formatDuration } from '../resources/resource-utils'
2
+ import {
3
+ CNPG_BARMAN_OBJECTSTORE_GROUP,
4
+ CNPG_GROUP,
5
+ getCNPGBackupStatus,
6
+ getCNPGScheduledBackupNextSchedule,
7
+ getCNPGScheduledBackupStatus,
8
+ } from '../resources/resource-utils-cnpg'
9
+ import type { CNPGWorkspaceResponse } from './workspace'
10
+ import { FactGrid, FactRow, RefLink, SummaryHeading, toneTextClass, type CNPGNavigate } from './primitives'
11
+ import { ClusterLink, NotReported, Note, ObjectProblems, PhaseBadge, SummaryShell, TimeAgo } from './CNPGSharedSummary'
12
+ import {
13
+ backupDestination,
14
+ backupsForScheduledBackup,
15
+ clustersIn,
16
+ isBackupFromSchedule,
17
+ refOf,
18
+ relationUnavailable,
19
+ scheduledBackupOf,
20
+ workspaceList,
21
+ } from './relations'
22
+
23
+ interface SummaryProps {
24
+ resource: any
25
+ workspace: CNPGWorkspaceResponse | null
26
+ onNavigate?: CNPGNavigate
27
+ }
28
+
29
+ const RECENT_RUNS = 5
30
+
31
+ function methodText(resource: any): string | null {
32
+ const m = resource?.status?.method || resource?.spec?.method
33
+ if (!m) return null
34
+ switch (m) {
35
+ case 'plugin':
36
+ return resource?.spec?.pluginConfiguration?.name ? `Plugin · ${resource.spec.pluginConfiguration.name}` : 'Plugin'
37
+ case 'barmanObjectStore':
38
+ return 'Barman object store (in-tree)'
39
+ case 'volumeSnapshot':
40
+ return 'Volume snapshot'
41
+ default:
42
+ return m
43
+ }
44
+ }
45
+
46
+ function Timing({ resource }: { resource: any }) {
47
+ const started = resource?.status?.startedAt
48
+ const stopped = resource?.status?.stoppedAt
49
+ const startMs = started ? Date.parse(started) : NaN
50
+ const stopMs = stopped ? Date.parse(stopped) : NaN
51
+ const tookMs = Number.isFinite(startMs) && Number.isFinite(stopMs) && stopMs >= startMs ? stopMs - startMs : null
52
+ return (
53
+ <>
54
+ <FactRow label="Started">
55
+ <TimeAgo at={started} />
56
+ </FactRow>
57
+ <FactRow label="Stopped">
58
+ {stopped ? (
59
+ <span>
60
+ <TimeAgo at={stopped} />
61
+ {tookMs !== null && <span className="text-theme-text-secondary"> · took {formatDuration(tookMs, true)}</span>}
62
+ </span>
63
+ ) : Number.isFinite(startMs) ? (
64
+ <span className="text-theme-text-secondary">Not stopped · running for {formatDuration(Date.now() - startMs, true)}</span>
65
+ ) : (
66
+ <NotReported />
67
+ )}
68
+ </FactRow>
69
+ </>
70
+ )
71
+ }
72
+
73
+ export function CNPGBackupSummary({ resource, workspace, onNavigate }: SummaryProps) {
74
+ const ns = resource?.metadata?.namespace ?? ''
75
+ const status = getCNPGBackupStatus(resource)
76
+ const pod = resource?.status?.instanceID?.podName
77
+ const error = resource?.status?.error
78
+ const trigger = scheduledBackupOf(resource)
79
+ const liveSchedule = trigger
80
+ ? workspaceList(workspace, 'scheduledBackups').find((s) => s?.metadata?.namespace === ns && s?.metadata?.name === trigger)
81
+ : undefined
82
+ const scheduleReplaced = !!liveSchedule && !isBackupFromSchedule(resource, liveSchedule)
83
+ const dest = backupDestination(resource, clustersIn(workspace))
84
+ const method = methodText(resource)
85
+
86
+ return (
87
+ <SummaryShell>
88
+ <ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'Backup')} onNavigate={onNavigate} />
89
+
90
+ <SummaryHeading>Outcome</SummaryHeading>
91
+ <FactGrid>
92
+ <FactRow label="Phase">
93
+ <PhaseBadge status={status} />
94
+ </FactRow>
95
+ <Timing resource={resource} />
96
+ <FactRow label="Taken on">
97
+ {pod ? <RefLink refTo={{ kind: 'Pod', group: '', namespace: ns, name: pod }} onNavigate={onNavigate} mono /> : <NotReported />}
98
+ </FactRow>
99
+ <FactRow label="Backup ID">
100
+ {resource?.status?.backupId ? <span className="font-mono">{resource.status.backupId}</span> : <NotReported />}
101
+ </FactRow>
102
+ <FactRow label="Target">
103
+ {resource?.spec?.target ? resource.spec.target : <span className="text-theme-text-secondary">Not set · the Cluster's backup target applies</span>}
104
+ </FactRow>
105
+ {error && (
106
+ <FactRow label="Error">
107
+ <span className={toneTextClass('unhealthy')}>{error}</span>
108
+ </FactRow>
109
+ )}
110
+ </FactGrid>
111
+
112
+ <SummaryHeading>Relationships</SummaryHeading>
113
+ <FactGrid>
114
+ <FactRow label="Cluster">
115
+ <ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
116
+ </FactRow>
117
+ <FactRow label="Trigger">
118
+ {trigger ? (
119
+ <span>
120
+ ScheduledBackup{' '}
121
+ {scheduleReplaced ? (
122
+ <>
123
+ <span className="font-mono">{trigger}</span>
124
+ <span className="text-theme-text-tertiary"> · an earlier schedule of that name; the current one is a different object</span>
125
+ </>
126
+ ) : (
127
+ <RefLink refTo={{ kind: 'ScheduledBackup', group: CNPG_GROUP, namespace: ns, name: trigger }} onNavigate={onNavigate} mono />
128
+ )}
129
+ </span>
130
+ ) : (
131
+ 'On demand'
132
+ )}
133
+ </FactRow>
134
+ <FactRow label="Destination">
135
+ {dest.type === 'objectStore' ? (
136
+ <span>
137
+ ObjectStore{' '}
138
+ <RefLink refTo={{ kind: 'ObjectStore', group: CNPG_BARMAN_OBJECTSTORE_GROUP, namespace: ns, name: dest.name }} onNavigate={onNavigate} mono />
139
+ {dest.inferred && <span className="text-theme-text-tertiary"> · from the Cluster's current configuration</span>}
140
+ </span>
141
+ ) : dest.type === 'path' ? (
142
+ <span className="font-mono">{dest.path}</span>
143
+ ) : dest.type === 'volumeSnapshot' ? (
144
+ 'Volume snapshot'
145
+ ) : (
146
+ <NotReported />
147
+ )}
148
+ </FactRow>
149
+ <FactRow label="Method">{method ?? <NotReported />}</FactRow>
150
+ </FactGrid>
151
+ </SummaryShell>
152
+ )
153
+ }
154
+
155
+ export function CNPGScheduledBackupSummary({ resource, workspace, onNavigate }: SummaryProps) {
156
+ const ns = resource?.metadata?.namespace ?? ''
157
+ const cron = resource?.spec?.schedule
158
+ const next = getCNPGScheduledBackupNextSchedule(resource)
159
+ const runsUnavailable = relationUnavailable(workspace, 'backups', ns, 'Backups')
160
+ const runs = runsUnavailable ? [] : backupsForScheduledBackup(resource, workspaceList(workspace, 'backups'))
161
+ const shown = runs.slice(0, RECENT_RUNS)
162
+
163
+ return (
164
+ <SummaryShell>
165
+ <ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'ScheduledBackup')} onNavigate={onNavigate} />
166
+
167
+ <SummaryHeading>Schedule</SummaryHeading>
168
+ <FactGrid>
169
+ <FactRow label="Status">
170
+ <PhaseBadge status={getCNPGScheduledBackupStatus(resource)} />
171
+ </FactRow>
172
+ <FactRow label="Cluster">
173
+ <ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
174
+ </FactRow>
175
+ <FactRow label="Schedule">
176
+ {cron ? (
177
+ <span>
178
+ <span className="font-mono">{cron}</span>
179
+ <span className="text-theme-text-tertiary"> · six fields, seconds first</span>
180
+ </span>
181
+ ) : (
182
+ <NotReported text="Not set" />
183
+ )}
184
+ </FactRow>
185
+ <FactRow label="Last scheduled">
186
+ <TimeAgo at={resource?.status?.lastScheduleTime} missing="Never" />
187
+ </FactRow>
188
+ <FactRow label="Next scheduled">{next === '-' ? <NotReported /> : next}</FactRow>
189
+ <FactRow label="Method">{methodText(resource) ?? 'Barman object store (in-tree) · default'}</FactRow>
190
+ </FactGrid>
191
+
192
+ <SummaryHeading hint={runs.length > shown.length ? `${shown.length} of ${runs.length}` : undefined}>Recent runs</SummaryHeading>
193
+ {runsUnavailable ? (
194
+ <div className="text-sm">
195
+ <NotReported text={runsUnavailable} />
196
+ </div>
197
+ ) : shown.length === 0 ? (
198
+ <div className="text-sm text-theme-text-secondary">No Backups from this schedule are visible</div>
199
+ ) : (
200
+ <FactGrid>
201
+ {shown.map((b) => (
202
+ <FactRow
203
+ key={b.metadata?.name}
204
+ label={<RefLink refTo={{ kind: 'Backup', group: CNPG_GROUP, namespace: ns, name: b.metadata?.name }} onNavigate={onNavigate} mono />}
205
+ >
206
+ <span className="inline-flex flex-wrap items-center gap-2">
207
+ <PhaseBadge status={getCNPGBackupStatus(b)} />
208
+ {b.status?.startedAt ? (
209
+ <span className="text-theme-text-secondary">
210
+ started <TimeAgo at={b.status.startedAt} />
211
+ </span>
212
+ ) : (
213
+ <NotReported text="Not started" />
214
+ )}
215
+ </span>
216
+ </FactRow>
217
+ ))}
218
+ </FactGrid>
219
+ )}
220
+ {!runsUnavailable && (workspace?.backupsOmitted ?? 0) > 0 && <Note>Backups older than 7 days are not listed.</Note>}
221
+ </SummaryShell>
222
+ )
223
+ }
@@ -0,0 +1,222 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { Badge } from '../ui/Badge'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+ import { CNPG_BARMAN_OBJECTSTORE_GROUP, CNPG_GROUP } from '../resources/resource-utils-cnpg'
6
+ import type { CNPGFleetRow, CNPGInstance } from './workspace'
7
+ import {
8
+ FactGrid,
9
+ FactRow,
10
+ FactSource,
11
+ FactValue,
12
+ ProblemCallout,
13
+ RefLink,
14
+ SummaryHeading,
15
+ ToneDot,
16
+ toneTextClass,
17
+ CNPG_PRIMARY_BUTTON,
18
+ CNPG_SECONDARY_BUTTON,
19
+ type CNPGNavigate,
20
+ } from './primitives'
21
+
22
+ export interface CNPGSummaryAction {
23
+ label: string
24
+ onClick: () => void
25
+ primary?: boolean
26
+ }
27
+
28
+ function InstancePill({ pod, namespace, onNavigate }: { pod: CNPGInstance; namespace: string; onNavigate?: CNPGNavigate }) {
29
+ const tone = pod.ready === true ? 'healthy' : pod.ready === false ? 'unhealthy' : 'unknown'
30
+ const role = pod.role === 'primary' ? 'Primary' : pod.role === 'replica' ? 'Replica' : 'Role unknown'
31
+ const readiness = pod.ready === true ? 'Ready' : pod.ready === false ? 'Not ready' : 'Readiness unknown'
32
+ return (
33
+ <Tooltip content={`${pod.name} · ${role} · ${readiness}${pod.node ? ` · ${pod.node}` : ''}`} position="top">
34
+ <button
35
+ type="button"
36
+ onClick={() => onNavigate?.({ kind: 'Pod', group: '', namespace, name: pod.name })}
37
+ className={clsx(
38
+ 'inline-flex items-center gap-1.5 rounded-md border border-theme-border bg-theme-base px-2 py-0.5 text-xs',
39
+ onNavigate ? 'hover:border-accent' : 'cursor-default',
40
+ )}
41
+ >
42
+ <ToneDot tone={tone} />
43
+ <span className="font-mono">{pod.name}</span>
44
+ <span className="text-theme-text-tertiary">{pod.role === 'primary' ? 'P' : pod.role === 'replica' ? 'R' : '?'}</span>
45
+ </button>
46
+ </Tooltip>
47
+ )
48
+ }
49
+
50
+ export function CNPGClusterSummary({
51
+ row,
52
+ onNavigate,
53
+ actions,
54
+ problemsLink,
55
+ extra,
56
+ }: {
57
+ row: CNPGFleetRow
58
+ onNavigate?: CNPGNavigate
59
+ actions?: CNPGSummaryAction[]
60
+ /** Link to the complete list of this cluster's findings, shown when more than one exists. */
61
+ problemsLink?: (count: number) => ReactNode
62
+ extra?: ReactNode
63
+ }) {
64
+ const top = row.problems[0]
65
+ const rest = row.problems.length - 1
66
+ const p = row.protection
67
+ const ns = row.namespace
68
+ const radarFindings = row.problems.some((x) => x.severity !== 'posture')
69
+
70
+ return (
71
+ <div className="px-4 py-4">
72
+ {top && (
73
+ <ProblemCallout
74
+ problem={top}
75
+ onNavigate={onNavigate}
76
+ more={rest > 0 ? problemsLink?.(row.problems.length) ?? <span>+{rest} more</span> : null}
77
+ />
78
+ )}
79
+
80
+ {actions && actions.length > 0 && (
81
+ <div className="mb-4 flex flex-wrap gap-2">
82
+ {actions.map((a) => (
83
+ <button key={a.label} type="button" onClick={a.onClick} className={a.primary ? CNPG_PRIMARY_BUTTON : CNPG_SECONDARY_BUTTON}>
84
+ {a.label}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ )}
89
+
90
+ <SummaryHeading>State</SummaryHeading>
91
+ <FactGrid>
92
+ <FactRow label="Controller phase">
93
+ <span className="inline-flex flex-wrap items-center gap-2">
94
+ <Badge severity={row.controllerStatus.level === 'healthy' ? 'success' : row.controllerStatus.level === 'unhealthy' ? 'error' : row.controllerStatus.level === 'degraded' || row.controllerStatus.level === 'alert' ? 'warning' : 'neutral'} size="sm">
95
+ {row.controllerStatus.text}
96
+ </Badge>
97
+ <span className="text-xs text-theme-text-tertiary">
98
+ {radarFindings ? 'reported by CNPG · Radar findings above are separate' : 'reported by CNPG'}
99
+ </span>
100
+ </span>
101
+ </FactRow>
102
+ <FactRow label="Instances">
103
+ <div>
104
+ <span>
105
+ {row.instances.ready ?? '–'}/{row.instances.desired ?? '–'} ready
106
+ {row.cluster?.status?.currentPrimary && (
107
+ <span className="text-theme-text-secondary"> · primary <span className="font-mono">{row.cluster.status.currentPrimary}</span></span>
108
+ )}
109
+ </span>
110
+ {row.pods.length > 0 && (
111
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
112
+ {row.pods.map((pod) => (
113
+ <InstancePill key={pod.name} pod={pod} namespace={ns} onNavigate={onNavigate} />
114
+ ))}
115
+ </div>
116
+ )}
117
+ </div>
118
+ </FactRow>
119
+ <FactRow label="Replication">
120
+ <FactValue fact={row.replication} />
121
+ <FactSource fact={row.replication} />
122
+ </FactRow>
123
+ {row.replicaCluster && (
124
+ <FactRow label="Replica cluster">
125
+ Follows {row.replicaCluster.source ? <span className="font-mono">{row.replicaCluster.source}</span> : 'an external primary'}
126
+ </FactRow>
127
+ )}
128
+ <FactRow label="PostgreSQL">
129
+ {row.pgVersion ?? 'Unknown'}
130
+ {row.catalog && (
131
+ <span className="text-theme-text-secondary">
132
+ {' · '}
133
+ <RefLink
134
+ refTo={{ kind: row.catalog.kind, group: CNPG_GROUP, namespace: row.catalog.kind === 'ClusterImageCatalog' ? '' : ns, name: row.catalog.name }}
135
+ onNavigate={onNavigate}
136
+ >
137
+ {row.catalog.name}
138
+ </RefLink>
139
+ </span>
140
+ )}
141
+ </FactRow>
142
+ <FactRow label="Declarations">
143
+ <FactValue fact={row.declarations.summary} />
144
+ </FactRow>
145
+ <FactRow label="Poolers">
146
+ {row.poolers.length === 0 ? (
147
+ <span className={row.poolersKnown ? 'text-theme-text-secondary' : 'text-theme-text-tertiary'}>
148
+ {row.poolersKnown ? 'None' : 'No access to Poolers'}
149
+ </span>
150
+ ) : (
151
+ <span className="flex flex-wrap gap-x-3">
152
+ {row.poolers.map((name) => (
153
+ <RefLink key={name} refTo={{ kind: 'Pooler', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
154
+ ))}
155
+ </span>
156
+ )}
157
+ </FactRow>
158
+ {row.gitops && (
159
+ <FactRow label="Declared in">
160
+ {row.gitops.tool === 'argocd' ? 'Argo CD' : 'Flux'} <span className="font-mono">{row.gitops.name}</span>
161
+ </FactRow>
162
+ )}
163
+ </FactGrid>
164
+
165
+ <SummaryHeading>Protection</SummaryHeading>
166
+ <FactGrid>
167
+ <FactRow label="Schedule">
168
+ <FactValue fact={p.schedule} />
169
+ {p.schedule.names.length > 0 && (
170
+ <div className="mt-0.5 flex flex-wrap gap-x-3 text-xs">
171
+ {p.schedule.names.map((name) => (
172
+ <RefLink key={name} refTo={{ kind: 'ScheduledBackup', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
173
+ ))}
174
+ </div>
175
+ )}
176
+ </FactRow>
177
+ <FactRow label="Destination">
178
+ {p.destination.objectStore ? (
179
+ <RefLink refTo={{ kind: 'ObjectStore', group: CNPG_BARMAN_OBJECTSTORE_GROUP, namespace: ns, name: p.destination.objectStore }} onNavigate={onNavigate}>
180
+ ObjectStore {p.destination.objectStore}
181
+ </RefLink>
182
+ ) : (
183
+ <FactValue fact={p.destination} />
184
+ )}
185
+ </FactRow>
186
+ <FactRow label="Last successful backup">
187
+ <FactValue fact={p.lastSuccessfulBackup} />
188
+ <FactSource fact={p.lastSuccessfulBackup} />
189
+ </FactRow>
190
+ <FactRow label="WAL archiving">
191
+ <FactValue fact={p.walArchiving} />
192
+ </FactRow>
193
+ <FactRow label="Recovery window">
194
+ {p.recoveryWindow.from ? (
195
+ <div>
196
+ <span className={p.recoveryWindow.tone === 'degraded' ? toneTextClass('degraded') : undefined}>
197
+ from {new Date(p.recoveryWindow.from).toUTCString().replace(' GMT', ' UTC')} {p.recoveryWindow.tone === 'degraded' ? '· not advancing' : 'to the newest archived WAL'}
198
+ </span>
199
+ <FactSource fact={p.recoveryWindow} />
200
+ {p.recoveryWindow.tone === 'degraded' && (
201
+ <div className="text-[11.5px] text-theme-text-tertiary">WAL archiving is failing, so nothing written since the last archived WAL can be recovered.</div>
202
+ )}
203
+ </div>
204
+ ) : (
205
+ <FactValue fact={p.recoveryWindow} />
206
+ )}
207
+ </FactRow>
208
+ <FactRow label="Restore validation">
209
+ {p.restoreValidation.restoredInto ? (
210
+ <RefLink refTo={{ kind: 'Cluster', group: CNPG_GROUP, ...p.restoreValidation.restoredInto }} onNavigate={onNavigate}>
211
+ {p.restoreValidation.text}
212
+ </RefLink>
213
+ ) : (
214
+ <FactValue fact={p.restoreValidation} />
215
+ )}
216
+ <FactSource fact={p.restoreValidation} />
217
+ </FactRow>
218
+ </FactGrid>
219
+ {extra}
220
+ </div>
221
+ )
222
+ }