@postgres.ai/shared 4.1.2 → 4.2.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.
Files changed (137) hide show
  1. package/.gitlab-ci.yml +7 -3
  2. package/components/UpgradeCloneModal/index.tsx +145 -0
  3. package/dist/components/UpgradeCloneModal/index.d.ts +11 -0
  4. package/dist/components/UpgradeCloneModal/index.js +75 -0
  5. package/dist/package.json +2 -2
  6. package/dist/pages/Branches/Branch/index.js +27 -2
  7. package/dist/pages/Branches/Branch/stores/Main.d.ts +5 -0
  8. package/dist/pages/Branches/Branch/stores/Main.js +29 -0
  9. package/dist/pages/Branches/components/BranchesTable/index.js +2 -2
  10. package/dist/pages/Branches/index.js +10 -1
  11. package/dist/pages/Clone/Status/index.js +3 -0
  12. package/dist/pages/Clone/index.js +12 -4
  13. package/dist/pages/Clone/stores/Main.d.ts +9 -1
  14. package/dist/pages/Clone/stores/Main.js +40 -3
  15. package/dist/pages/CreateBranch/stores/Main.d.ts +3 -0
  16. package/dist/pages/CreateClone/index.js +2 -0
  17. package/dist/pages/CreateClone/stores/Main.d.ts +5 -1
  18. package/dist/pages/CreateClone/stores/Main.js +8 -3
  19. package/dist/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.d.ts +11 -0
  20. package/dist/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.js +24 -0
  21. package/dist/pages/Instance/Configuration/PhysicalMode/PgBackRest/index.d.ts +10 -0
  22. package/dist/pages/Instance/Configuration/PhysicalMode/PgBackRest/index.js +29 -0
  23. package/dist/pages/Instance/Configuration/PhysicalMode/Sync/index.d.ts +9 -0
  24. package/dist/pages/Instance/Configuration/PhysicalMode/Sync/index.js +14 -0
  25. package/dist/pages/Instance/Configuration/PhysicalMode/Walg/index.d.ts +10 -0
  26. package/dist/pages/Instance/Configuration/PhysicalMode/Walg/index.js +21 -0
  27. package/dist/pages/Instance/Configuration/PhysicalMode/index.d.ts +10 -0
  28. package/dist/pages/Instance/Configuration/PhysicalMode/index.js +17 -0
  29. package/dist/pages/Instance/Configuration/SimpleMode/PreviewCard.d.ts +13 -0
  30. package/dist/pages/Instance/Configuration/SimpleMode/PreviewCard.js +14 -0
  31. package/dist/pages/Instance/Configuration/SimpleMode/index.d.ts +19 -0
  32. package/dist/pages/Instance/Configuration/SimpleMode/index.js +166 -0
  33. package/dist/pages/Instance/Configuration/configMode.d.ts +2 -0
  34. package/dist/pages/Instance/Configuration/configMode.js +7 -0
  35. package/dist/pages/Instance/Configuration/configOptions.d.ts +16 -0
  36. package/dist/pages/Instance/Configuration/configOptions.js +71 -0
  37. package/dist/pages/Instance/Configuration/connectionString.d.ts +20 -0
  38. package/dist/pages/Instance/Configuration/connectionString.js +129 -0
  39. package/dist/pages/Instance/Configuration/dockerCatalog.d.ts +2 -0
  40. package/dist/pages/Instance/Configuration/dockerCatalog.js +19 -0
  41. package/dist/pages/Instance/Configuration/index.d.ts +1 -2
  42. package/dist/pages/Instance/Configuration/index.js +115 -86
  43. package/dist/pages/Instance/Configuration/useForm.d.ts +20 -0
  44. package/dist/pages/Instance/Configuration/useForm.js +126 -5
  45. package/dist/pages/Instance/Configuration/utils/index.js +1 -17
  46. package/dist/pages/Instance/Info/Snapshots/Calendar/Day/index.js +3 -3
  47. package/dist/pages/Instance/Info/Snapshots/Calendar/utils.d.ts +3 -0
  48. package/dist/pages/Instance/Info/Snapshots/utils.d.ts +6 -0
  49. package/dist/pages/Instance/Snapshots/components/SnapshotsList/index.js +11 -1
  50. package/dist/pages/Instance/Snapshots/index.js +11 -3
  51. package/dist/pages/Instance/Snapshots/utils/index.d.ts +3 -0
  52. package/dist/pages/Instance/Tabs/styles.js +6 -6
  53. package/dist/pages/Instance/index.js +1 -3
  54. package/dist/pages/Instance/stores/Main.d.ts +22 -2
  55. package/dist/pages/Instance/stores/Main.js +26 -9
  56. package/dist/pages/Snapshots/Snapshot/index.js +28 -2
  57. package/dist/pages/Snapshots/Snapshot/stores/Main.d.ts +5 -0
  58. package/dist/pages/Snapshots/Snapshot/stores/Main.js +29 -0
  59. package/dist/stores/Snapshots.d.ts +1 -1
  60. package/dist/stores/Snapshots.js +12 -6
  61. package/dist/types/api/endpoints/getBranches.d.ts +6 -0
  62. package/dist/types/api/endpoints/probeSource.d.ts +32 -0
  63. package/dist/types/api/endpoints/probeSource.js +1 -0
  64. package/dist/types/api/endpoints/testDbSource.d.ts +3 -1
  65. package/dist/types/api/endpoints/testDbSource.js +6 -0
  66. package/dist/types/api/endpoints/updateBranch.d.ts +11 -0
  67. package/dist/types/api/endpoints/updateBranch.js +1 -0
  68. package/dist/types/api/endpoints/updateSnapshot.d.ts +11 -0
  69. package/dist/types/api/endpoints/updateSnapshot.js +1 -0
  70. package/dist/types/api/endpoints/upgradeClone.d.ts +8 -0
  71. package/dist/types/api/endpoints/upgradeClone.js +1 -0
  72. package/dist/types/api/entities/branchSnapshots.d.ts +3 -0
  73. package/dist/types/api/entities/clone.d.ts +9 -2
  74. package/dist/types/api/entities/config.d.ts +32 -0
  75. package/dist/types/api/entities/config.js +45 -18
  76. package/dist/types/api/entities/instance.d.ts +11 -1
  77. package/dist/types/api/entities/instanceState.d.ts +16 -1
  78. package/dist/types/api/entities/snapshot.d.ts +6 -0
  79. package/dist/utils/api.d.ts +1 -1
  80. package/dist/utils/api.js +6 -0
  81. package/dist/utils/clone.d.ts +3 -1
  82. package/dist/utils/clone.js +7 -1
  83. package/dist/utils/clonePoller.d.ts +12 -0
  84. package/dist/utils/clonePoller.js +41 -0
  85. package/dist/utils/date.js +12 -12
  86. package/dist/utils/protection.d.ts +5 -0
  87. package/dist/utils/protection.js +23 -0
  88. package/package.json +2 -2
  89. package/pages/Branches/Branch/index.tsx +70 -0
  90. package/pages/Branches/Branch/stores/Main.ts +40 -0
  91. package/pages/Branches/components/BranchesTable/index.tsx +2 -0
  92. package/pages/Branches/index.tsx +13 -1
  93. package/pages/Clone/Status/index.tsx +3 -0
  94. package/pages/Clone/index.tsx +58 -0
  95. package/pages/Clone/stores/Main.ts +51 -8
  96. package/pages/CreateClone/index.tsx +3 -0
  97. package/pages/CreateClone/stores/Main.ts +10 -8
  98. package/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.tsx +129 -0
  99. package/pages/Instance/Configuration/PhysicalMode/PgBackRest/index.tsx +89 -0
  100. package/pages/Instance/Configuration/PhysicalMode/Sync/index.tsx +64 -0
  101. package/pages/Instance/Configuration/PhysicalMode/Walg/index.tsx +63 -0
  102. package/pages/Instance/Configuration/PhysicalMode/index.tsx +93 -0
  103. package/pages/Instance/Configuration/SimpleMode/PreviewCard.tsx +176 -0
  104. package/pages/Instance/Configuration/SimpleMode/index.tsx +303 -0
  105. package/pages/Instance/Configuration/configMode.ts +12 -0
  106. package/pages/Instance/Configuration/configOptions.ts +111 -0
  107. package/pages/Instance/Configuration/connectionString.ts +157 -0
  108. package/pages/Instance/Configuration/dockerCatalog.ts +21 -0
  109. package/pages/Instance/Configuration/index.tsx +151 -49
  110. package/pages/Instance/Configuration/useForm.ts +155 -5
  111. package/pages/Instance/Configuration/utils/index.ts +1 -17
  112. package/pages/Instance/Info/Snapshots/Calendar/Day/index.tsx +3 -3
  113. package/pages/Instance/Snapshots/components/SnapshotsList/index.tsx +21 -1
  114. package/pages/Instance/Snapshots/index.tsx +15 -5
  115. package/pages/Instance/Tabs/styles.ts +7 -7
  116. package/pages/Instance/index.tsx +0 -4
  117. package/pages/Instance/stores/Main.ts +29 -7
  118. package/pages/Snapshots/Snapshot/index.tsx +71 -0
  119. package/pages/Snapshots/Snapshot/stores/Main.ts +40 -0
  120. package/postgres.ai-shared-4.2.0.tgz +0 -0
  121. package/stores/Snapshots.ts +16 -7
  122. package/types/api/endpoints/getBranches.ts +3 -0
  123. package/types/api/endpoints/probeSource.ts +34 -0
  124. package/types/api/endpoints/testDbSource.ts +10 -1
  125. package/types/api/endpoints/updateBranch.ts +11 -0
  126. package/types/api/endpoints/updateSnapshot.ts +11 -0
  127. package/types/api/endpoints/upgradeClone.ts +8 -0
  128. package/types/api/entities/clone.ts +13 -1
  129. package/types/api/entities/config.ts +46 -0
  130. package/types/api/entities/instanceState.ts +6 -0
  131. package/types/api/entities/snapshot.ts +3 -0
  132. package/utils/api.ts +8 -0
  133. package/utils/clone.ts +7 -1
  134. package/utils/clonePoller.ts +47 -0
  135. package/utils/date.ts +19 -14
  136. package/utils/protection.ts +25 -0
  137. package/postgres.ai-shared-4.1.2.tgz +0 -0
@@ -5,23 +5,23 @@
5
5
  *--------------------------------------------------------------------------
6
6
  */
7
7
  import { parse, startOfDay, isSameDay, startOfMonth, startOfWeek, endOfMonth, endOfWeek, differenceInDays, addDays, addMonths, isSameMonth, format, formatDistanceToNowStrict, } from 'date-fns';
8
+ const compactDatePattern = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/;
9
+ // formatDateToISO converts a compact UTC timestamp ('20060102150405') to RFC3339 ('2006-01-02T15:04:05Z').
10
+ // The value is a UTC wall-clock, so it must never be interpreted in the browser timezone.
8
11
  export const formatDateToISO = (dateString) => {
9
- // Handle empty or invalid date strings
10
- if (!dateString || dateString.trim() === '') {
12
+ var _a;
13
+ const parts = compactDatePattern.exec((_a = dateString === null || dateString === void 0 ? void 0 : dateString.trim()) !== null && _a !== void 0 ? _a : '');
14
+ if (!parts) {
11
15
  return '';
12
16
  }
13
- try {
14
- const parsedDate = parse(dateString, 'yyyyMMddHHmmss', new Date());
15
- // Additional validation of parsed date
16
- if (!isValidDate(parsedDate) || isNaN(parsedDate.getTime())) {
17
- return '';
18
- }
19
- return format(parsedDate, "yyyy-MM-dd'T'HH:mm:ssXXX");
20
- }
21
- catch (error) {
22
- // Return empty string for invalid date formats
17
+ const [compact, year, month, day, hours, minutes, seconds] = parts;
18
+ const parsedDate = new Date(Date.UTC(+year, +month - 1, +day, +hours, +minutes, +seconds));
19
+ if (!isValidDate(parsedDate)) {
23
20
  return '';
24
21
  }
22
+ const isoDate = parsedDate.toISOString().replace(/\.\d+Z$/, 'Z');
23
+ // reject values rolled over by Date.UTC, e.g. month 13 or hour 25.
24
+ return isoDate.replace(/[-:TZ]/g, '') === compact ? isoDate : '';
25
25
  };
26
26
  // parseDate parses date of both format: '2006-01-02 15:04:05 UTC' and `2006-01-02T15:04:05Z` (RFC3339).
27
27
  export const parseDate = (dateStr) => parse(dateStr.replace(' UTC', 'Z').replace('T', ' '), 'yyyy-MM-dd HH:mm:ssX', new Date());
@@ -0,0 +1,5 @@
1
+ export declare const protectionOptions: {
2
+ value: string;
3
+ children: string;
4
+ }[];
5
+ export declare const getProtectionSelectValue: (isProtected: boolean, protectedTillDate: Date | null) => string;
@@ -0,0 +1,23 @@
1
+ // protectionOptions lists the deletion-protection choices shared by the branch and snapshot
2
+ // detail pages: no protection, a set of bounded durations, and forever. The values are the
3
+ // protectionDurationMinutes passed to the update endpoints ('0' means forever, 'none' clears
4
+ // protection).
5
+ export const protectionOptions = [
6
+ { value: 'none', children: 'No protection' },
7
+ { value: '60', children: '1 hour' },
8
+ { value: '720', children: '12 hours' },
9
+ { value: '1440', children: '1 day' },
10
+ { value: '4320', children: '3 days' },
11
+ { value: '10080', children: '7 days' },
12
+ { value: '43200', children: '30 days' },
13
+ { value: '0', children: 'Forever' },
14
+ ];
15
+ // getProtectionSelectValue maps an entity's protection state to the Select value: 'none' when
16
+ // unprotected, 'current' when protected until a specific time, '0' when protected forever.
17
+ export const getProtectionSelectValue = (isProtected, protectedTillDate) => {
18
+ if (!isProtected)
19
+ return 'none';
20
+ if (!protectedTillDate)
21
+ return '0';
22
+ return 'current';
23
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postgres.ai/shared",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "scripts": {
5
5
  "build": "tsc -p tsconfig.build.json && node scripts/copy-assets.js",
6
6
  "pack": "node scripts/pack.js"
@@ -17,7 +17,7 @@
17
17
  "@material-ui/styles": "^4.11.4",
18
18
  "@monaco-editor/react": "^4.4.5",
19
19
  "@mui/material": "^5.10.12",
20
- "@types/node": "^12.20.33",
20
+ "@types/node": "^22.0.0",
21
21
  "@types/react": "^17.0.30",
22
22
  "@types/react-dom": "^17.0.10",
23
23
  "@types/react-router": "^5.1.17",
@@ -23,8 +23,14 @@ import {
23
23
  import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub'
24
24
  import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner'
25
25
  import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle'
26
+ import { Select } from '@postgres.ai/shared/components/Select'
26
27
  import { Spinner } from '@postgres.ai/shared/components/Spinner'
27
28
  import { Tooltip } from '@postgres.ai/shared/components/Tooltip'
29
+ import { parseDate } from '@postgres.ai/shared/utils/date'
30
+ import {
31
+ protectionOptions,
32
+ getProtectionSelectValue,
33
+ } from '@postgres.ai/shared/utils/protection'
28
34
  import { icons } from '@postgres.ai/shared/styles/icons'
29
35
  import { styles } from '@postgres.ai/shared/styles/styles'
30
36
  import { DeleteBranchModal } from '@postgres.ai/shared/pages/Branches/components/Modals/DeleteBranchModal'
@@ -96,6 +102,13 @@ const useStyles = makeStyles(
96
102
  summary: {
97
103
  marginTop: 20,
98
104
  },
105
+ protectionControl: {
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ marginTop: '8px',
109
+ marginBottom: '8px',
110
+ maxWidth: 525,
111
+ },
99
112
  text: {
100
113
  marginTop: '4px',
101
114
  },
@@ -156,6 +169,9 @@ export const BranchesPage = observer((props: Props) => {
156
169
  load,
157
170
  isReloading,
158
171
  isBranchesLoading,
172
+ isUpdatingBranch,
173
+ updateBranchProtection,
174
+ updateBranchError,
159
175
  getBranchesError,
160
176
  snapshotListError,
161
177
  getBranchError,
@@ -163,6 +179,19 @@ export const BranchesPage = observer((props: Props) => {
163
179
 
164
180
  const hasBranchError = getBranchesError || getBranchError || snapshotListError
165
181
 
182
+ const protectedTillDate = branch?.protectedTill
183
+ ? parseDate(branch.protectedTill)
184
+ : null
185
+
186
+ const handleProtectionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
187
+ const value = e.target.value
188
+ updateBranchProtection(
189
+ props.branchId,
190
+ props.instanceId,
191
+ value === 'none' ? null : parseInt(value, 10),
192
+ )
193
+ }
194
+
166
195
  const branchLogLength = snapshotList?.reduce((acc, snapshot) => {
167
196
  if (snapshot?.branch !== null) {
168
197
  return acc + snapshot.branch?.length
@@ -290,6 +319,47 @@ export const BranchesPage = observer((props: Props) => {
290
319
  {branch?.parent}
291
320
  </p>
292
321
  </div>
322
+ <div className={classes.summary}>
323
+ <p>
324
+ <strong>Deletion protection</strong>
325
+ </p>
326
+ <div className={classes.protectionControl}>
327
+ <Select
328
+ label="Deletion protection"
329
+ items={
330
+ branch?.protected && protectedTillDate
331
+ ? [
332
+ {
333
+ value: 'current',
334
+ children: `Protected until ${protectedTillDate.toLocaleString()}`,
335
+ },
336
+ ...protectionOptions,
337
+ ]
338
+ : protectionOptions
339
+ }
340
+ value={getProtectionSelectValue(
341
+ Boolean(branch?.protected),
342
+ protectedTillDate,
343
+ )}
344
+ onChange={handleProtectionChange}
345
+ disabled={isReloading || isUpdatingBranch}
346
+ fullWidth
347
+ />
348
+ {isUpdatingBranch && (
349
+ <Spinner size="sm" className={classes.spinner} />
350
+ )}
351
+ </div>
352
+ <p className={classes.text}>
353
+ Protected branches cannot be deleted manually or automatically.
354
+ </p>
355
+ {updateBranchError && (
356
+ <ErrorStub
357
+ title="Update error"
358
+ message={updateBranchError}
359
+ className={classes.marginTop}
360
+ />
361
+ )}
362
+ </div>
293
363
  <br />
294
364
  <p>
295
365
  <strong>Snapshot info</strong>
@@ -10,11 +10,13 @@ import { makeAutoObservable } from 'mobx'
10
10
  import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches'
11
11
  import { Branch } from '@postgres.ai/shared/types/api/endpoints/getBranches'
12
12
  import { DeleteBranch } from '@postgres.ai/shared/types/api/endpoints/deleteBranch'
13
+ import { UpdateBranch } from '@postgres.ai/shared/types/api/endpoints/updateBranch'
13
14
  import {
14
15
  SnapshotList,
15
16
  GetSnapshotList,
16
17
  } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList'
17
18
  import { InitWS } from '@postgres.ai/shared/types/api/endpoints/initWS'
19
+ import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api'
18
20
 
19
21
  type Error = {
20
22
  title?: string
@@ -25,6 +27,7 @@ export type Api = {
25
27
  getBranches: GetBranches
26
28
  deleteBranch: DeleteBranch
27
29
  getSnapshotList: GetSnapshotList
30
+ updateBranch?: UpdateBranch
28
31
  initWS?: InitWS
29
32
  }
30
33
 
@@ -35,6 +38,8 @@ export class MainStore {
35
38
 
36
39
  isReloading = false
37
40
  isBranchesLoading = false
41
+ isUpdatingBranch = false
42
+ updateBranchError: string | null = null
38
43
 
39
44
  branches: Branch[] | null = null
40
45
  branch: Branch | null = null
@@ -99,6 +104,41 @@ export class MainStore {
99
104
  return !!currentBranch
100
105
  }
101
106
 
107
+ updateBranchProtection = async (
108
+ branchName: string,
109
+ instanceId: string,
110
+ durationMinutes: number | null,
111
+ ) => {
112
+ if (!this.api.updateBranch || !this.branch) return
113
+
114
+ this.isUpdatingBranch = true
115
+ this.updateBranchError = null
116
+
117
+ const prevProtected = this.branch.protected
118
+ const isProtected = durationMinutes !== null
119
+
120
+ this.branch.protected = isProtected
121
+
122
+ const { response, error } = await this.api.updateBranch({
123
+ instanceId,
124
+ branchName,
125
+ branch: {
126
+ isProtected,
127
+ protectionDurationMinutes: durationMinutes ?? undefined,
128
+ },
129
+ })
130
+
131
+ if (response) {
132
+ await this.reload(branchName, instanceId)
133
+ } else if (this.branch) {
134
+ this.branch.protected = prevProtected
135
+ }
136
+
137
+ if (error) this.updateBranchError = await getTextFromUnknownApiError(error)
138
+
139
+ this.isUpdatingBranch = false
140
+ }
141
+
102
142
  deleteBranch = async (branchName: string, instanceId: string) => {
103
143
  if (!branchName) return
104
144
 
@@ -134,6 +134,7 @@ export const BranchesTable = ({
134
134
  <TableHeaderCell>Data state time</TableHeaderCell>
135
135
  <TableHeaderCell>Latest Snapshot ID</TableHeaderCell>
136
136
  <TableHeaderCell>Number of snapshots</TableHeaderCell>
137
+ <TableHeaderCell>Protected</TableHeaderCell>
137
138
  </TableRow>
138
139
  </TableHead>
139
140
  <TableBody>
@@ -170,6 +171,7 @@ export const BranchesTable = ({
170
171
  </TableBodyCell>
171
172
  <TableBodyCell>{branch.snapshotID}</TableBodyCell>
172
173
  <TableBodyCell>{branch.numSnapshots}</TableBodyCell>
174
+ <TableBodyCell>{branch.protected ? 'Yes' : '—'}</TableBodyCell>
173
175
  </TableRow>
174
176
  )
175
177
  })}
@@ -41,6 +41,10 @@ const useStyles = makeStyles(
41
41
  { index: 1 },
42
42
  )
43
43
 
44
+ // listRefreshIntervalMs is how often the branch list is silently refreshed so background
45
+ // auto-deletion does not leave stale rows in the table.
46
+ const listRefreshIntervalMs = 30_000
47
+
44
48
  interface BranchesProps {
45
49
  instanceId: string
46
50
  }
@@ -72,7 +76,15 @@ export const Branches: React.FC<BranchesProps> = observer(({ instanceId }) => {
72
76
 
73
77
  useEffect(() => {
74
78
  loadBranches()
75
- }, [])
79
+
80
+ const intervalId = setInterval(() => {
81
+ getBranches(instanceId, true).then((response) => {
82
+ response && setBranches(response)
83
+ })
84
+ }, listRefreshIntervalMs)
85
+
86
+ return () => clearInterval(intervalId)
87
+ }, [instanceId])
76
88
 
77
89
  if (!instance && !isBranchesLoading) return <></>
78
90
 
@@ -35,6 +35,9 @@ const useStyles = makeStyles(
35
35
  },
36
36
  message: {
37
37
  margin: '4px 0 0 0',
38
+ // A failed upgrade appends up to 20 lines of pg_upgrade log to the WARNING message, and
39
+ // HTML would otherwise collapse them into one unreadable run of text.
40
+ whiteSpace: 'pre-wrap',
38
41
  },
39
42
  errorMessage: {
40
43
  marginTop: '8px',
@@ -29,6 +29,7 @@ import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner'
29
29
  import { DestroyCloneRestrictionModal } from '@postgres.ai/shared/components/DestroyCloneRestrictionModal'
30
30
  import { DestroyCloneModal } from '@postgres.ai/shared/components/DestroyCloneModal'
31
31
  import { ResetCloneModal } from '@postgres.ai/shared/components/ResetCloneModal'
32
+ import { UpgradeCloneModal } from '@postgres.ai/shared/components/UpgradeCloneModal'
32
33
  import { Spinner } from '@postgres.ai/shared/components/Spinner'
33
34
  import { round } from '@postgres.ai/shared/utils/numbers'
34
35
  import { Tooltip } from '@postgres.ai/shared/components/Tooltip'
@@ -199,21 +200,28 @@ export const Clone = observer((props: Props) => {
199
200
  const [isOpenRestrictionModal, setIsOpenRestrictionModal] = useState(false)
200
201
  const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false)
201
202
  const [isOpenResetModal, setIsOpenResetModal] = useState(false)
203
+ const [isOpenUpgradeModal, setIsOpenUpgradeModal] = useState(false)
202
204
 
203
205
  // Initial loading data.
204
206
  useEffect(() => {
205
207
  stores.main.load(props.instanceId, props.cloneId)
206
208
  }, [])
207
209
 
210
+ // Stop clone status polling on unmount.
211
+ useEffect(() => () => stores.main.stopPolling(), [])
212
+
208
213
  const {
209
214
  instance,
210
215
  snapshots,
211
216
  clone,
212
217
  isResettingClone,
218
+ isUpgradingClone,
213
219
  isDestroyingClone,
214
220
  isReloading,
215
221
  isUpdatingClone,
216
222
  isCloneStable,
223
+ isUpgradeSupported,
224
+ upgradeTargetVersion,
217
225
  } = stores.main
218
226
 
219
227
  const headRendered = (
@@ -270,6 +278,12 @@ export const Clone = observer((props: Props) => {
270
278
 
271
279
  const resetClone = (snapshotId: string) => stores.main.resetClone(snapshotId)
272
280
 
281
+ // Clone major upgrade.
282
+ const requestUpgradeClone = () => setIsOpenUpgradeModal(true)
283
+
284
+ const upgradeClone = (dockerImage?: string) =>
285
+ stores.main.upgradeClone(dockerImage)
286
+
273
287
  // Clone destroy.
274
288
  const requestDestroyClone = () => {
275
289
  if (clone.protected) {
@@ -343,6 +357,7 @@ export const Clone = observer((props: Props) => {
343
357
  // Controls.
344
358
  const isDisabledControls =
345
359
  isResettingClone ||
360
+ isUpgradingClone ||
346
361
  isDestroyingClone ||
347
362
  isReloading ||
348
363
  isUpdatingClone ||
@@ -367,6 +382,21 @@ export const Clone = observer((props: Props) => {
367
382
  <Spinner size="sm" className={classes.spinner} />
368
383
  )}
369
384
  </Button>
385
+ {isUpgradeSupported && (
386
+ <Button
387
+ variant="contained"
388
+ color="primary"
389
+ onClick={requestUpgradeClone}
390
+ disabled={isDisabledControls}
391
+ title={'Upgrade clone to a newer PostgreSQL major version'}
392
+ className={classes.actionButton}
393
+ >
394
+ Upgrade clone
395
+ {isUpgradingClone && (
396
+ <Spinner size="sm" className={classes.spinner} />
397
+ )}
398
+ </Button>
399
+ )}
370
400
  <Button
371
401
  variant="contained"
372
402
  color="primary"
@@ -413,6 +443,13 @@ export const Clone = observer((props: Props) => {
413
443
  className={classes.errorStub}
414
444
  />
415
445
  ))}
446
+ {stores.main.upgradeCloneError && (
447
+ <ErrorStub
448
+ title={'Upgrade error'}
449
+ message={stores.main.upgradeCloneError}
450
+ className={classes.errorStub}
451
+ />
452
+ )}
416
453
  {!props.hideBranchingFeatures && <div>
417
454
  <p>
418
455
  <strong>Branch</strong>
@@ -451,6 +488,17 @@ export const Clone = observer((props: Props) => {
451
488
 
452
489
  <Status rawClone={clone} className={classes.status} />
453
490
  </div>
491
+ {clone.dbVersion && (
492
+ <>
493
+ <br />
494
+ <div>
495
+ <p>
496
+ <strong>PostgreSQL version</strong>
497
+ </p>
498
+ <p className={classes.text}>{clone.dbVersion}</p>
499
+ </div>
500
+ </>
501
+ )}
454
502
  <br />
455
503
  <div>
456
504
  <p>
@@ -749,6 +797,16 @@ export const Clone = observer((props: Props) => {
749
797
  onResetClone={resetClone}
750
798
  version={instance.state?.engine.version}
751
799
  />
800
+
801
+ {isUpgradeSupported && upgradeTargetVersion && (
802
+ <UpgradeCloneModal
803
+ isOpen={isOpenUpgradeModal}
804
+ onClose={() => setIsOpenUpgradeModal(false)}
805
+ clone={clone}
806
+ targetVersion={upgradeTargetVersion}
807
+ onUpgradeClone={upgradeClone}
808
+ />
809
+ )}
752
810
  </>
753
811
  </div>
754
812
  </>
@@ -10,6 +10,7 @@ import { makeAutoObservable } from 'mobx'
10
10
  import { GetInstance } from '@postgres.ai/shared/types/api/endpoints/getInstance'
11
11
  import { GetClone } from '@postgres.ai/shared/types/api/endpoints/getClone'
12
12
  import { ResetClone } from '@postgres.ai/shared/types/api/endpoints/resetClone'
13
+ import { UpgradeClone } from '@postgres.ai/shared/types/api/endpoints/upgradeClone'
13
14
  import { DestroyClone } from '@postgres.ai/shared/types/api/endpoints/destroyClone'
14
15
  import { UpdateClone } from '@postgres.ai/shared/types/api/endpoints/updateClone'
15
16
  import {
@@ -19,15 +20,15 @@ import {
19
20
  import { Clone } from '@postgres.ai/shared/types/api/entities/clone'
20
21
  import { Instance } from '@postgres.ai/shared/types/api/entities/instance'
21
22
  import { checkIsCloneStable } from '@postgres.ai/shared/utils/clone'
23
+ import { ClonePoller } from '@postgres.ai/shared/utils/clonePoller'
22
24
  import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api'
23
25
  import { InitWS } from '@postgres.ai/shared/types/api/endpoints/initWS'
24
26
 
25
- const UNSTABLE_CLONE_UPDATE_TIMEOUT = 1000
26
-
27
27
  export type Api = SnapshotsApi & {
28
28
  getInstance: GetInstance
29
29
  getClone: GetClone
30
30
  resetClone: ResetClone
31
+ upgradeClone?: UpgradeClone
31
32
  destroyClone: DestroyClone
32
33
  updateClone: UpdateClone
33
34
  initWS?: InitWS
@@ -50,6 +51,9 @@ export class MainStore {
50
51
  isResettingClone = false
51
52
  resetCloneError: string | null = null
52
53
 
54
+ isUpgradingClone = false
55
+ upgradeCloneError: string | null = null
56
+
53
57
  isDestroyingClone = false
54
58
  destroyCloneError: string | null = null
55
59
 
@@ -58,7 +62,7 @@ export class MainStore {
58
62
 
59
63
  isReloading = false
60
64
 
61
- private cloneUpdateTimeout?: number
65
+ private readonly poller = new ClonePoller()
62
66
 
63
67
  private readonly api: Api
64
68
 
@@ -69,12 +73,29 @@ export class MainStore {
69
73
  makeAutoObservable(this)
70
74
  }
71
75
 
76
+ // The closed Platform app implements the same Api and may not provide the endpoint, and the
77
+ // engine only accepts an upgrade once one is configured, so the action is hidden rather than
78
+ // offered and then refused.
79
+ get isUpgradeSupported() {
80
+ return Boolean(this.api.upgradeClone) && Boolean(this.upgradeTargetVersion)
81
+ }
82
+
83
+ // The major an upgrade lands on. It follows from the instance's upgrade image, so the UI reads
84
+ // it instead of asking for one.
85
+ get upgradeTargetVersion() {
86
+ const cloneUpgrade = this.instance?.state?.cloneUpgrade
87
+
88
+ return cloneUpgrade?.available ? cloneUpgrade.targetVersion : undefined
89
+ }
90
+
72
91
  get isCloneStable() {
73
92
  if (!this.clone) return false
74
93
  return checkIsCloneStable(this.clone)
75
94
  }
76
95
 
77
96
  load = async (instanceId: string, cloneId: string) => {
97
+ this.poller.start()
98
+
78
99
  const [isInstanceOk, isCloneOk, isSnapshotsLoaded] = await Promise.all([
79
100
  this.loadInstance(instanceId),
80
101
  this.loadClone(instanceId, cloneId),
@@ -92,6 +113,8 @@ export class MainStore {
92
113
  return isSuccess
93
114
  }
94
115
 
116
+ stopPolling = () => this.poller.stop()
117
+
95
118
  private loadInstance = async (instanceId: string) => {
96
119
  const { response, error } = await this.api.getInstance({
97
120
  instanceId,
@@ -116,7 +139,9 @@ export class MainStore {
116
139
  }
117
140
 
118
141
  private loadClone = async (instanceId: string, cloneId: string) => {
119
- window.clearTimeout(this.cloneUpdateTimeout)
142
+ if (this.poller.isStopped) return false
143
+
144
+ this.poller.cancel()
120
145
 
121
146
  const { response, error } = await this.api.getClone({ instanceId, cloneId })
122
147
 
@@ -124,10 +149,7 @@ export class MainStore {
124
149
  this.clone = response
125
150
 
126
151
  if (!this.isCloneStable)
127
- this.cloneUpdateTimeout = window.setTimeout(
128
- () => this.loadClone(instanceId, cloneId),
129
- UNSTABLE_CLONE_UPDATE_TIMEOUT,
130
- )
152
+ this.poller.scheduleNext(() => this.loadClone(instanceId, cloneId))
131
153
  }
132
154
 
133
155
  if (error) {
@@ -166,6 +188,27 @@ export class MainStore {
166
188
  return Boolean(response)
167
189
  }
168
190
 
191
+ upgradeClone = async (dockerImage?: string) => {
192
+ if (!this.instance || !this.clone || !this.api.upgradeClone) return false
193
+
194
+ this.isUpgradingClone = true
195
+ this.upgradeCloneError = null
196
+
197
+ const { response, error } = await this.api.upgradeClone({
198
+ instanceId: this.instance.id,
199
+ cloneId: this.clone.id,
200
+ dockerImage,
201
+ })
202
+
203
+ if (response) await this.loadClone(this.instance.id, this.clone.id)
204
+
205
+ if (error) this.upgradeCloneError = await getTextFromUnknownApiError(error)
206
+
207
+ this.isUpgradingClone = false
208
+
209
+ return Boolean(response)
210
+ }
211
+
169
212
  destroyClone = async () => {
170
213
  if (!this.instance || !this.clone) return false
171
214
 
@@ -166,6 +166,9 @@ export const CreateClone = observer((props: Props) => {
166
166
  history.push(props.routes.clone(stores.main.clone.id))
167
167
  }, [stores.main.clone, stores.main.isCloneStable])
168
168
 
169
+ // Stop clone status polling on unmount.
170
+ useEffect(() => () => stores.main.stopPolling(), [])
171
+
169
172
  const headRendered = (
170
173
  <>
171
174
  {/* //TODO: make global reset styles. */}
@@ -13,12 +13,11 @@ import {
13
13
  } from '@postgres.ai/shared/stores/Snapshots'
14
14
  import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api'
15
15
  import { checkIsCloneStable } from '@postgres.ai/shared/utils/clone'
16
+ import { ClonePoller } from '@postgres.ai/shared/utils/clonePoller'
16
17
 
17
18
  import { FormValues } from '../useForm'
18
19
  import { InitWS } from '@postgres.ai/shared/types/api/endpoints/initWS'
19
20
 
20
- const UNSTABLE_CLONE_UPDATE_TIMEOUT = 1000
21
-
22
21
  export type MainStoreApi = SnapshotsApi & {
23
22
  getInstance: GetInstance
24
23
  createClone: CreateClone
@@ -37,7 +36,7 @@ export class MainStore {
37
36
  clone: Clone | null = null
38
37
  cloneError: string | null = null
39
38
 
40
- private cloneUpdateTimeout?: number
39
+ private readonly poller = new ClonePoller()
41
40
 
42
41
  private readonly api: MainStoreApi
43
42
 
@@ -73,6 +72,8 @@ export class MainStore {
73
72
  createClone = async (data: FormValues) => {
74
73
  if (!this.instance) return false
75
74
 
75
+ this.poller.start()
76
+
76
77
  const { response, error } = await this.api.createClone({
77
78
  ...data,
78
79
  instanceId: this.instance.id,
@@ -115,11 +116,15 @@ export class MainStore {
115
116
  return response
116
117
  }
117
118
 
119
+ stopPolling = () => this.poller.stop()
120
+
118
121
  private updateCloneUntilStable = async (args: {
119
122
  instanceId: string
120
123
  cloneId: string
121
124
  }) => {
122
- window.clearTimeout(this.cloneUpdateTimeout)
125
+ if (this.poller.isStopped) return
126
+
127
+ this.poller.cancel()
123
128
 
124
129
  const { response, error } = await this.api.getClone(args)
125
130
 
@@ -127,10 +132,7 @@ export class MainStore {
127
132
  this.clone = response
128
133
 
129
134
  if (!this.isCloneStable)
130
- this.cloneUpdateTimeout = window.setTimeout(
131
- () => this.updateCloneUntilStable(args),
132
- UNSTABLE_CLONE_UPDATE_TIMEOUT,
133
- )
135
+ this.poller.scheduleNext(() => this.updateCloneUntilStable(args))
134
136
  }
135
137
 
136
138
  if (error)