@postgres.ai/shared 4.0.2-pr-1148.1 → 4.0.2-pr-1200

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 (61) hide show
  1. package/components/UpgradeCloneModal/index.d.ts +11 -0
  2. package/components/UpgradeCloneModal/index.js +75 -0
  3. package/package.json +1 -1
  4. package/pages/Branches/Branch/index.js +27 -2
  5. package/pages/Branches/Branch/stores/Main.d.ts +5 -0
  6. package/pages/Branches/Branch/stores/Main.js +29 -0
  7. package/pages/Branches/components/BranchesTable/index.js +2 -2
  8. package/pages/Branches/index.js +10 -1
  9. package/pages/Clone/Status/index.js +3 -0
  10. package/pages/Clone/index.js +12 -4
  11. package/pages/Clone/stores/Main.d.ts +9 -1
  12. package/pages/Clone/stores/Main.js +40 -3
  13. package/pages/CreateBranch/stores/Main.d.ts +3 -0
  14. package/pages/CreateClone/index.js +2 -0
  15. package/pages/CreateClone/stores/Main.d.ts +5 -1
  16. package/pages/CreateClone/stores/Main.js +8 -3
  17. package/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.d.ts +2 -0
  18. package/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.js +8 -1
  19. package/pages/Instance/Configuration/SimpleMode/PreviewCard.d.ts +3 -1
  20. package/pages/Instance/Configuration/SimpleMode/PreviewCard.js +7 -7
  21. package/pages/Instance/Configuration/SimpleMode/index.d.ts +6 -1
  22. package/pages/Instance/Configuration/SimpleMode/index.js +81 -22
  23. package/pages/Instance/Configuration/configOptions.d.ts +10 -0
  24. package/pages/Instance/Configuration/configOptions.js +24 -1
  25. package/pages/Instance/Info/Snapshots/Calendar/Day/index.js +3 -3
  26. package/pages/Instance/Info/Snapshots/Calendar/utils.d.ts +3 -0
  27. package/pages/Instance/Info/Snapshots/utils.d.ts +6 -0
  28. package/pages/Instance/Snapshots/components/SnapshotsList/index.js +11 -1
  29. package/pages/Instance/Snapshots/index.js +11 -3
  30. package/pages/Instance/Snapshots/utils/index.d.ts +3 -0
  31. package/pages/Instance/Tabs/styles.js +6 -6
  32. package/pages/Instance/stores/Main.d.ts +2 -2
  33. package/pages/Instance/stores/Main.js +17 -9
  34. package/pages/Snapshots/Snapshot/index.js +28 -2
  35. package/pages/Snapshots/Snapshot/stores/Main.d.ts +5 -0
  36. package/pages/Snapshots/Snapshot/stores/Main.js +29 -0
  37. package/stores/Snapshots.d.ts +1 -1
  38. package/stores/Snapshots.js +12 -6
  39. package/types/api/endpoints/getBranches.d.ts +6 -0
  40. package/types/api/endpoints/testDbSource.d.ts +3 -1
  41. package/types/api/endpoints/testDbSource.js +6 -0
  42. package/types/api/endpoints/updateBranch.d.ts +11 -0
  43. package/types/api/endpoints/updateBranch.js +1 -0
  44. package/types/api/endpoints/updateSnapshot.d.ts +11 -0
  45. package/types/api/endpoints/updateSnapshot.js +1 -0
  46. package/types/api/endpoints/upgradeClone.d.ts +8 -0
  47. package/types/api/endpoints/upgradeClone.js +1 -0
  48. package/types/api/entities/branchSnapshots.d.ts +3 -0
  49. package/types/api/entities/clone.d.ts +9 -2
  50. package/types/api/entities/instance.d.ts +11 -1
  51. package/types/api/entities/instanceState.d.ts +16 -1
  52. package/types/api/entities/snapshot.d.ts +6 -0
  53. package/utils/api.d.ts +1 -1
  54. package/utils/api.js +6 -0
  55. package/utils/clone.d.ts +3 -1
  56. package/utils/clone.js +7 -1
  57. package/utils/clonePoller.d.ts +12 -0
  58. package/utils/clonePoller.js +41 -0
  59. package/utils/date.js +12 -12
  60. package/utils/protection.d.ts +5 -0
  61. package/utils/protection.js +23 -0
@@ -0,0 +1,11 @@
1
+ /// <reference types="react" />
2
+ import { Clone } from '@postgres.ai/shared/types/api/entities/clone';
3
+ declare type Props = {
4
+ isOpen: boolean;
5
+ onClose: () => void;
6
+ clone: Clone;
7
+ targetVersion: number;
8
+ onUpgradeClone: (dockerImage?: string) => void;
9
+ };
10
+ export declare const UpgradeCloneModal: (props: Props) => JSX.Element;
11
+ export {};
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /*--------------------------------------------------------------------------
3
+ * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai
4
+ * All Rights Reserved. Proprietary and confidential.
5
+ * Unauthorized copying of this file, via any medium is strictly prohibited
6
+ *--------------------------------------------------------------------------
7
+ */
8
+ import { useEffect, useState } from 'react';
9
+ import { makeStyles, TextField } from '@material-ui/core';
10
+ import { Text } from '@postgres.ai/shared/components/Text';
11
+ import { Modal } from '@postgres.ai/shared/components/Modal';
12
+ import { ImportantText } from '@postgres.ai/shared/components/ImportantText';
13
+ import { SimpleModalControls } from '@postgres.ai/shared/components/SimpleModalControls';
14
+ import { colors } from '@postgres.ai/shared/styles/vars';
15
+ // The upgrade image ships binaries for the four majors preceding its own, which is the same
16
+ // bound the engine enforces on the request.
17
+ const MAX_MAJOR_JUMP = 4;
18
+ const useStyles = makeStyles({
19
+ field: {
20
+ margin: '16px 0 0 0',
21
+ },
22
+ hint: {
23
+ margin: '8px 0 0 0',
24
+ },
25
+ error: {
26
+ margin: '8px 0 0 0',
27
+ color: colors.status.error,
28
+ },
29
+ }, { index: 1 });
30
+ export const UpgradeCloneModal = (props) => {
31
+ const { isOpen, onClose, clone, targetVersion, onUpgradeClone } = props;
32
+ const classes = useStyles();
33
+ const [dockerImage, setDockerImage] = useState('');
34
+ // The modal stays mounted while the clone page is open, so without this it reopens holding the
35
+ // previous attempt's image, which would ride along unnoticed.
36
+ useEffect(() => {
37
+ if (isOpen)
38
+ setDockerImage('');
39
+ }, [isOpen]);
40
+ const currentVersion = clone.dbVersion ? parseInt(clone.dbVersion, 10) : NaN;
41
+ // Nothing here is decidable without the current version, so the engine has the last word: it
42
+ // reads the major out of the data directory once the upgrade starts.
43
+ const getBlockingReason = () => {
44
+ if (Number.isNaN(currentVersion))
45
+ return null;
46
+ if (targetVersion <= currentVersion) {
47
+ return `This clone already runs PostgreSQL ${currentVersion}, and the instance upgrades to ${targetVersion}.`;
48
+ }
49
+ if (targetVersion - currentVersion > MAX_MAJOR_JUMP) {
50
+ return `The upgrade image covers at most ${MAX_MAJOR_JUMP} preceding majors, and this clone runs PostgreSQL ${currentVersion}.`;
51
+ }
52
+ return null;
53
+ };
54
+ const blockingReason = getBlockingReason();
55
+ const handleClickUpgrade = () => {
56
+ if (blockingReason)
57
+ return;
58
+ onUpgradeClone(dockerImage || undefined);
59
+ onClose();
60
+ };
61
+ return (_jsxs(Modal, { title: `Upgrade clone ${clone.id}`, isOpen: isOpen, onClose: onClose, children: [_jsxs(Text, { children: ["Clone ", _jsx(ImportantText, { children: clone.id }), clone.dbVersion
62
+ ? ` currently runs PostgreSQL ${clone.dbVersion}. `
63
+ : ' has no recorded PostgreSQL version. ', "This instance upgrades clones to", ' ', _jsxs(ImportantText, { children: ["PostgreSQL ", targetVersion] }), ", the major its configured upgrade image is built on."] }), blockingReason ? (_jsx("div", { className: classes.error, children: _jsx(Text, { children: blockingReason }) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: classes.hint, children: _jsx(Text, { children: "The data directory is upgraded in place, and the clone restarts on the target version. Resetting the clone afterwards returns it to the version the instance is configured with." }) }), _jsx("div", { className: classes.hint, children: _jsx(Text, { children: "If the upgrade fails before any data is converted, the clone keeps running on its current version. If it fails afterwards, the clone is restored from its snapshot and changes made since it was created are lost." }) })] })), _jsx(TextField, { label: "Docker image (optional)", value: dockerImage, onChange: (e) => setDockerImage(e.target.value), helperText: "Leave empty to keep the current image and change only its major version", fullWidth: true, className: classes.field }), _jsx(SimpleModalControls, { items: [
64
+ {
65
+ text: 'Cancel',
66
+ onClick: onClose,
67
+ },
68
+ {
69
+ text: 'Upgrade',
70
+ onClick: handleClickUpgrade,
71
+ variant: 'primary',
72
+ isDisabled: Boolean(blockingReason),
73
+ },
74
+ ] })] }));
75
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postgres.ai/shared",
3
- "version": "4.0.2-pr-1148.1",
3
+ "version": "4.0.2-pr-1200",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "peerDependencies": {
@@ -13,8 +13,11 @@ import { makeStyles, Button, TextField, IconButton, Table, TableHead, TableRow,
13
13
  import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub';
14
14
  import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner';
15
15
  import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle';
16
+ import { Select } from '@postgres.ai/shared/components/Select';
16
17
  import { Spinner } from '@postgres.ai/shared/components/Spinner';
17
18
  import { Tooltip } from '@postgres.ai/shared/components/Tooltip';
19
+ import { parseDate } from '@postgres.ai/shared/utils/date';
20
+ import { protectionOptions, getProtectionSelectValue, } from '@postgres.ai/shared/utils/protection';
18
21
  import { icons } from '@postgres.ai/shared/styles/icons';
19
22
  import { styles } from '@postgres.ai/shared/styles/styles';
20
23
  import { DeleteBranchModal } from '@postgres.ai/shared/pages/Branches/components/Modals/DeleteBranchModal';
@@ -73,6 +76,13 @@ const useStyles = makeStyles(() => ({
73
76
  summary: {
74
77
  marginTop: 20,
75
78
  },
79
+ protectionControl: {
80
+ display: 'flex',
81
+ alignItems: 'center',
82
+ marginTop: '8px',
83
+ marginBottom: '8px',
84
+ maxWidth: 525,
85
+ },
76
86
  text: {
77
87
  marginTop: '4px',
78
88
  },
@@ -120,8 +130,15 @@ export const BranchesPage = observer((props) => {
120
130
  const history = useHistory();
121
131
  const stores = useCreatedStores(props);
122
132
  const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false);
123
- const { branch, snapshotList, deleteBranch, reload, load, isReloading, isBranchesLoading, getBranchesError, snapshotListError, getBranchError, } = stores.main;
133
+ const { branch, snapshotList, deleteBranch, reload, load, isReloading, isBranchesLoading, isUpdatingBranch, updateBranchProtection, updateBranchError, getBranchesError, snapshotListError, getBranchError, } = stores.main;
124
134
  const hasBranchError = getBranchesError || getBranchError || snapshotListError;
135
+ const protectedTillDate = (branch === null || branch === void 0 ? void 0 : branch.protectedTill)
136
+ ? parseDate(branch.protectedTill)
137
+ : null;
138
+ const handleProtectionChange = (e) => {
139
+ const value = e.target.value;
140
+ updateBranchProtection(props.branchId, props.instanceId, value === 'none' ? null : parseInt(value, 10));
141
+ };
125
142
  const branchLogLength = snapshotList === null || snapshotList === void 0 ? void 0 : snapshotList.reduce((acc, snapshot) => {
126
143
  var _a;
127
144
  if ((snapshot === null || snapshot === void 0 ? void 0 : snapshot.branch) !== null) {
@@ -144,7 +161,15 @@ export const BranchesPage = observer((props) => {
144
161
  (getBranchError === null || getBranchError === void 0 ? void 0 : getBranchError.message) ||
145
162
  (snapshotListError === null || snapshotListError === void 0 ? void 0 : snapshotListError.message), className: classes.marginTop })] }));
146
163
  }
147
- return (_jsxs(_Fragment, { children: [headRendered, _jsxs("div", { className: classes.wrapper, children: [_jsxs("div", { className: classes.container, children: [_jsxs("div", { className: classes.actions, children: [_jsx(Button, { variant: "contained", color: "primary", onClick: () => history.push(props.routes.createClone(props.branchId)), disabled: isReloading, title: 'Create clone', className: classes.actionButton, children: "Create clone" }), _jsx(Button, { variant: "contained", color: "primary", onClick: () => setIsOpenDestroyModal(true), disabled: isReloading, title: 'Delete this branch', className: classes.actionButton, children: "Delete branch" }), _jsxs(Button, { variant: "outlined", color: "secondary", onClick: () => reload(props.branchId, props.instanceId), disabled: isReloading, title: 'Refresh branch information', className: classes.actionButton, children: ["Reload info", isReloading && _jsx(Spinner, { size: "sm", className: classes.spinner })] })] }), _jsx("br", {}), _jsxs("div", { children: [_jsxs("div", { children: [_jsxs("p", { children: [_jsx("strong", { children: "Data state at" }), "\u00A0", _jsx(Tooltip, { content: _jsxs(_Fragment, { children: [_jsx("strong", { children: "Data state time" }), " is a time at which data is\u00A0 recovered for this branch."] }), children: icons.infoIcon })] }), _jsx("p", { className: classes.text, children: (branch === null || branch === void 0 ? void 0 : branch.dataStateAt) || '-' })] }), _jsxs("div", { className: classes.summary, children: [_jsxs("p", { children: [_jsx("strong", { children: "Summary" }), "\u00A0"] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Branch name:" }), branch === null || branch === void 0 ? void 0 : branch.name] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Parent branch:" }), branch === null || branch === void 0 ? void 0 : branch.parent] })] }), _jsx("br", {}), _jsx("p", { children: _jsx("strong", { children: "Snapshot info" }) }), _jsxs("div", { className: classes.copyFieldContainer, children: [_jsx(TextField, { variant: "outlined", label: "Snapshot ID", value: branch === null || branch === void 0 ? void 0 : branch.snapshotID, className: classes.textField, margin: "normal", fullWidth: true, InputLabelProps: {
164
+ return (_jsxs(_Fragment, { children: [headRendered, _jsxs("div", { className: classes.wrapper, children: [_jsxs("div", { className: classes.container, children: [_jsxs("div", { className: classes.actions, children: [_jsx(Button, { variant: "contained", color: "primary", onClick: () => history.push(props.routes.createClone(props.branchId)), disabled: isReloading, title: 'Create clone', className: classes.actionButton, children: "Create clone" }), _jsx(Button, { variant: "contained", color: "primary", onClick: () => setIsOpenDestroyModal(true), disabled: isReloading, title: 'Delete this branch', className: classes.actionButton, children: "Delete branch" }), _jsxs(Button, { variant: "outlined", color: "secondary", onClick: () => reload(props.branchId, props.instanceId), disabled: isReloading, title: 'Refresh branch information', className: classes.actionButton, children: ["Reload info", isReloading && _jsx(Spinner, { size: "sm", className: classes.spinner })] })] }), _jsx("br", {}), _jsxs("div", { children: [_jsxs("div", { children: [_jsxs("p", { children: [_jsx("strong", { children: "Data state at" }), "\u00A0", _jsx(Tooltip, { content: _jsxs(_Fragment, { children: [_jsx("strong", { children: "Data state time" }), " is a time at which data is\u00A0 recovered for this branch."] }), children: icons.infoIcon })] }), _jsx("p", { className: classes.text, children: (branch === null || branch === void 0 ? void 0 : branch.dataStateAt) || '-' })] }), _jsxs("div", { className: classes.summary, children: [_jsxs("p", { children: [_jsx("strong", { children: "Summary" }), "\u00A0"] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Branch name:" }), branch === null || branch === void 0 ? void 0 : branch.name] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Parent branch:" }), branch === null || branch === void 0 ? void 0 : branch.parent] })] }), _jsxs("div", { className: classes.summary, children: [_jsx("p", { children: _jsx("strong", { children: "Deletion protection" }) }), _jsxs("div", { className: classes.protectionControl, children: [_jsx(Select, { label: "Deletion protection", items: (branch === null || branch === void 0 ? void 0 : branch.protected) && protectedTillDate
165
+ ? [
166
+ {
167
+ value: 'current',
168
+ children: `Protected until ${protectedTillDate.toLocaleString()}`,
169
+ },
170
+ ...protectionOptions,
171
+ ]
172
+ : protectionOptions, value: getProtectionSelectValue(Boolean(branch === null || branch === void 0 ? void 0 : branch.protected), protectedTillDate), onChange: handleProtectionChange, disabled: isReloading || isUpdatingBranch, fullWidth: true }), isUpdatingBranch && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), _jsx("p", { className: classes.text, children: "Protected branches cannot be deleted manually or automatically." }), updateBranchError && (_jsx(ErrorStub, { title: "Update error", message: updateBranchError, className: classes.marginTop }))] }), _jsx("br", {}), _jsx("p", { children: _jsx("strong", { children: "Snapshot info" }) }), _jsxs("div", { className: classes.copyFieldContainer, children: [_jsx(TextField, { variant: "outlined", label: "Snapshot ID", value: branch === null || branch === void 0 ? void 0 : branch.snapshotID, className: classes.textField, margin: "normal", fullWidth: true, InputLabelProps: {
148
173
  shrink: true,
149
174
  style: styles.inputFieldLabel,
150
175
  }, FormHelperTextProps: {
@@ -1,6 +1,7 @@
1
1
  import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches';
2
2
  import { Branch } from '@postgres.ai/shared/types/api/endpoints/getBranches';
3
3
  import { DeleteBranch } from '@postgres.ai/shared/types/api/endpoints/deleteBranch';
4
+ import { UpdateBranch } from '@postgres.ai/shared/types/api/endpoints/updateBranch';
4
5
  import { SnapshotList, GetSnapshotList } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList';
5
6
  import { InitWS } from '@postgres.ai/shared/types/api/endpoints/initWS';
6
7
  declare type Error = {
@@ -11,6 +12,7 @@ export declare type Api = {
11
12
  getBranches: GetBranches;
12
13
  deleteBranch: DeleteBranch;
13
14
  getSnapshotList: GetSnapshotList;
15
+ updateBranch?: UpdateBranch;
14
16
  initWS?: InitWS;
15
17
  };
16
18
  export declare class MainStore {
@@ -19,6 +21,8 @@ export declare class MainStore {
19
21
  getBranchesError: Error | null;
20
22
  isReloading: boolean;
21
23
  isBranchesLoading: boolean;
24
+ isUpdatingBranch: boolean;
25
+ updateBranchError: string | null;
22
26
  branches: Branch[] | null;
23
27
  branch: Branch | null;
24
28
  snapshotList: SnapshotList[] | null;
@@ -28,6 +32,7 @@ export declare class MainStore {
28
32
  reload: (branchId: string, instanceId: string) => Promise<void>;
29
33
  getBranches: (branchId: string, instanceId: string) => Promise<Branch[] | null | undefined>;
30
34
  getBranch: (branchId: string, instanceId: string) => Promise<boolean>;
35
+ updateBranchProtection: (branchName: string, instanceId: string, durationMinutes: number | null) => Promise<void>;
31
36
  deleteBranch: (branchName: string, instanceId: string) => Promise<{
32
37
  response: Response | null;
33
38
  error: globalThis.Error | null;
@@ -5,6 +5,7 @@
5
5
  *--------------------------------------------------------------------------
6
6
  */
7
7
  import { makeAutoObservable } from 'mobx';
8
+ import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api';
8
9
  export class MainStore {
9
10
  constructor(api) {
10
11
  this.getBranchError = null;
@@ -12,6 +13,8 @@ export class MainStore {
12
13
  this.getBranchesError = null;
13
14
  this.isReloading = false;
14
15
  this.isBranchesLoading = false;
16
+ this.isUpdatingBranch = false;
17
+ this.updateBranchError = null;
15
18
  this.branches = null;
16
19
  this.branch = null;
17
20
  this.snapshotList = null;
@@ -60,6 +63,32 @@ export class MainStore {
60
63
  }
61
64
  return !!currentBranch;
62
65
  };
66
+ this.updateBranchProtection = async (branchName, instanceId, durationMinutes) => {
67
+ if (!this.api.updateBranch || !this.branch)
68
+ return;
69
+ this.isUpdatingBranch = true;
70
+ this.updateBranchError = null;
71
+ const prevProtected = this.branch.protected;
72
+ const isProtected = durationMinutes !== null;
73
+ this.branch.protected = isProtected;
74
+ const { response, error } = await this.api.updateBranch({
75
+ instanceId,
76
+ branchName,
77
+ branch: {
78
+ isProtected,
79
+ protectionDurationMinutes: durationMinutes !== null && durationMinutes !== void 0 ? durationMinutes : undefined,
80
+ },
81
+ });
82
+ if (response) {
83
+ await this.reload(branchName, instanceId);
84
+ }
85
+ else if (this.branch) {
86
+ this.branch.protected = prevProtected;
87
+ }
88
+ if (error)
89
+ this.updateBranchError = await getTextFromUnknownApiError(error);
90
+ this.isUpdatingBranch = false;
91
+ };
63
92
  this.deleteBranch = async (branchName, instanceId) => {
64
93
  var _a;
65
94
  if (!branchName)
@@ -77,7 +77,7 @@ export const BranchesTable = ({ branches, emptyTableText, deleteBranch, branches
77
77
  if (!state.branches.length) {
78
78
  return _jsx("p", { className: classes.marginTop, children: emptyTableText });
79
79
  }
80
- return (_jsx(HorizontalScrollContainer, { children: _jsxs(Table, { children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, {}), _jsx(TableHeaderCell, { children: "Branch" }), _jsx(TableHeaderCell, { children: _jsxs("div", { onClick: handlesortByParent, className: cn(classes.interactiveRow, classes.verticalCentered), children: ["Parent", _jsx(ArrowDropDownIcon, { className: cn(state.sortByParent === 'asc' && classes.sortIconUp, classes.sortIcon) })] }) }), _jsx(TableHeaderCell, { children: "Data state time" }), _jsx(TableHeaderCell, { children: "Latest Snapshot ID" }), _jsx(TableHeaderCell, { children: "Number of snapshots" })] }) }), _jsx(TableBody, { children: (_a = state.branches) === null || _a === void 0 ? void 0 : _a.map((branch) => {
80
+ return (_jsx(HorizontalScrollContainer, { children: _jsxs(Table, { children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, {}), _jsx(TableHeaderCell, { children: "Branch" }), _jsx(TableHeaderCell, { children: _jsxs("div", { onClick: handlesortByParent, className: cn(classes.interactiveRow, classes.verticalCentered), children: ["Parent", _jsx(ArrowDropDownIcon, { className: cn(state.sortByParent === 'asc' && classes.sortIconUp, classes.sortIcon) })] }) }), _jsx(TableHeaderCell, { children: "Data state time" }), _jsx(TableHeaderCell, { children: "Latest Snapshot ID" }), _jsx(TableHeaderCell, { children: "Number of snapshots" }), _jsx(TableHeaderCell, { children: "Protected" })] }) }), _jsx(TableBody, { children: (_a = state.branches) === null || _a === void 0 ? void 0 : _a.map((branch) => {
81
81
  const branchPagePath = host.routes.branch(branch.name);
82
82
  return (_jsxs(TableRow, { hover: true, onClick: () => history.push(branchPagePath), className: classes.pointerCursor, children: [_jsx(TableBodyCellMenu, { actions: [
83
83
  {
@@ -91,7 +91,7 @@ export const BranchesTable = ({ branches, emptyTableText, deleteBranch, branches
91
91
  setIsOpenDestroyModal(true);
92
92
  },
93
93
  },
94
- ] }), _jsx(TableBodyCell, { children: branch.name }), _jsx(TableBodyCell, { children: branch.parent }), _jsx(TableBodyCell, { children: formatDateWithDistance(branch.dataStateAt, new Date(branch.dataStateAt)) }), _jsx(TableBodyCell, { children: branch.snapshotID }), _jsx(TableBodyCell, { children: branch.numSnapshots })] }, branch.name));
94
+ ] }), _jsx(TableBodyCell, { children: branch.name }), _jsx(TableBodyCell, { children: branch.parent }), _jsx(TableBodyCell, { children: formatDateWithDistance(branch.dataStateAt, new Date(branch.dataStateAt)) }), _jsx(TableBodyCell, { children: branch.snapshotID }), _jsx(TableBodyCell, { children: branch.numSnapshots }), _jsx(TableBodyCell, { children: branch.protected ? 'Yes' : '—' })] }, branch.name));
95
95
  }) }), _jsx(DeleteBranchModal, { isOpen: isOpenDestroyModal, onClose: () => {
96
96
  setIsOpenDestroyModal(false);
97
97
  setBranchId('');
@@ -33,6 +33,9 @@ const useStyles = makeStyles({
33
33
  transform: 'translate(-50%, -50%)',
34
34
  },
35
35
  }, { index: 1 });
36
+ // listRefreshIntervalMs is how often the branch list is silently refreshed so background
37
+ // auto-deletion does not leave stale rows in the table.
38
+ const listRefreshIntervalMs = 30000;
36
39
  export const Branches = observer(({ instanceId }) => {
37
40
  const host = useHost();
38
41
  const stores = useStores();
@@ -51,7 +54,13 @@ export const Branches = observer(({ instanceId }) => {
51
54
  };
52
55
  useEffect(() => {
53
56
  loadBranches();
54
- }, []);
57
+ const intervalId = setInterval(() => {
58
+ getBranches(instanceId, true).then((response) => {
59
+ response && setBranches(response);
60
+ });
61
+ }, listRefreshIntervalMs);
62
+ return () => clearInterval(intervalId);
63
+ }, [instanceId]);
55
64
  if (!instance && !isBranchesLoading)
56
65
  return _jsx(_Fragment, {});
57
66
  if (getBranchesError)
@@ -21,6 +21,9 @@ const useStyles = makeStyles({
21
21
  },
22
22
  message: {
23
23
  margin: '4px 0 0 0',
24
+ // A failed upgrade appends up to 20 lines of pg_upgrade log to the WARNING message, and
25
+ // HTML would otherwise collapse them into one unreadable run of text.
26
+ whiteSpace: 'pre-wrap',
24
27
  },
25
28
  errorMessage: {
26
29
  marginTop: '8px',
@@ -18,6 +18,7 @@ import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner';
18
18
  import { DestroyCloneRestrictionModal } from '@postgres.ai/shared/components/DestroyCloneRestrictionModal';
19
19
  import { DestroyCloneModal } from '@postgres.ai/shared/components/DestroyCloneModal';
20
20
  import { ResetCloneModal } from '@postgres.ai/shared/components/ResetCloneModal';
21
+ import { UpgradeCloneModal } from '@postgres.ai/shared/components/UpgradeCloneModal';
21
22
  import { Spinner } from '@postgres.ai/shared/components/Spinner';
22
23
  import { round } from '@postgres.ai/shared/utils/numbers';
23
24
  import { Tooltip } from '@postgres.ai/shared/components/Tooltip';
@@ -172,11 +173,14 @@ export const Clone = observer((props) => {
172
173
  const [isOpenRestrictionModal, setIsOpenRestrictionModal] = useState(false);
173
174
  const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false);
174
175
  const [isOpenResetModal, setIsOpenResetModal] = useState(false);
176
+ const [isOpenUpgradeModal, setIsOpenUpgradeModal] = useState(false);
175
177
  // Initial loading data.
176
178
  useEffect(() => {
177
179
  stores.main.load(props.instanceId, props.cloneId);
178
180
  }, []);
179
- const { instance, snapshots, clone, isResettingClone, isDestroyingClone, isReloading, isUpdatingClone, isCloneStable, } = stores.main;
181
+ // Stop clone status polling on unmount.
182
+ useEffect(() => () => stores.main.stopPolling(), []);
183
+ const { instance, snapshots, clone, isResettingClone, isUpgradingClone, isDestroyingClone, isReloading, isUpdatingClone, isCloneStable, isUpgradeSupported, upgradeTargetVersion, } = stores.main;
180
184
  const headRendered = (_jsxs(_Fragment, { children: [_jsx("style", { children: 'p { margin: 0;}' }), props.elements.breadcrumbs, _jsx(SectionTitle, { className: classes.title, tag: "h1", level: 1, text: `Clone ${props.cloneId}`, children: _jsx(InstanceTabs, { tab: TABS_INDEX.CLONES, isPlatform: props.isPlatform, instanceId: props.instanceId, hasLogs: props.api.initWS !== undefined, hideInstanceTabs: props.hideBranchingFeatures }) })] }));
181
185
  const cloneErrorMessage = ((_a = stores.main.instanceError) === null || _a === void 0 ? void 0 : _a.message) || ((_b = stores.main.cloneError) === null || _b === void 0 ? void 0 : _b.message);
182
186
  const cloneErrorTitle = ((_c = stores.main.instanceError) === null || _c === void 0 ? void 0 : _c.title) || ((_d = stores.main.cloneError) === null || _d === void 0 ? void 0 : _d.title);
@@ -189,6 +193,9 @@ export const Clone = observer((props) => {
189
193
  // Clone reset.
190
194
  const requestResetClone = () => setIsOpenResetModal(true);
191
195
  const resetClone = (snapshotId) => stores.main.resetClone(snapshotId);
196
+ // Clone major upgrade.
197
+ const requestUpgradeClone = () => setIsOpenUpgradeModal(true);
198
+ const upgradeClone = (dockerImage) => stores.main.upgradeClone(dockerImage);
192
199
  // Clone destroy.
193
200
  const requestDestroyClone = () => {
194
201
  if (clone.protected) {
@@ -254,12 +261,13 @@ export const Clone = observer((props) => {
254
261
  const hasConnectionInfo = sshPortForwardingUrl || jdbcConnectionStr || psqlConnectionStr;
255
262
  // Controls.
256
263
  const isDisabledControls = isResettingClone ||
264
+ isUpgradingClone ||
257
265
  isDestroyingClone ||
258
266
  isReloading ||
259
267
  isUpdatingClone ||
260
268
  !isCloneStable;
261
- return (_jsxs(_Fragment, { children: [headRendered, _jsxs("div", { className: classes.wrapper, children: [_jsxs("div", { className: classes.summary, children: [_jsxs("div", { className: classes.actions, children: [_jsxs(Button, { variant: "contained", color: "primary", onClick: requestResetClone, disabled: isDisabledControls, title: 'Reset clone', className: classes.actionButton, children: ["Reset clone", isResettingClone && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), _jsxs(Button, { variant: "contained", color: "primary", onClick: requestDestroyClone, disabled: isDisabledControls, title: 'Delete this clone', className: classes.actionButton, children: ["Delete clone", isDestroyingClone && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), !props.hideBranchingFeatures && _jsxs(Button, { variant: "contained", color: "primary", onClick: createSnapshot, disabled: isDisabledControls, title: 'Create snapshot', className: classes.actionButton, children: ["Create snapshot", snapshots.snapshotDataLoading && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), _jsxs(Button, { variant: "outlined", color: "secondary", onClick: reloadClone, disabled: isDisabledControls, title: 'Refresh clone information', className: classes.actionButton, children: ["Reload info", isReloading && _jsx(Spinner, { size: "sm", className: classes.spinner })] })] }), stores.main.destroyCloneError ||
262
- (stores.main.resetCloneError && (_jsx(ErrorStub, { title: 'Resetting error', message: stores.main.resetCloneError, className: classes.errorStub }))), !props.hideBranchingFeatures && _jsxs("div", { children: [_jsx("p", { children: _jsx("strong", { children: "Branch" }) }), _jsx("p", { className: classes.text, children: clone.branch })] }), _jsxs("div", { className: classes.title, children: [_jsx("p", { children: _jsx("strong", { children: "Created" }) }), _jsx("p", { className: classes.text, children: clone.createdAt })] }), _jsx("br", {}), _jsxs("div", { children: [_jsxs("p", { children: [_jsx("strong", { children: "Data state at" }), "\u00A0", _jsx(Tooltip, { content: _jsxs(_Fragment, { children: [_jsx("strong", { children: "Data state time" }), " is a time at which data is\u00A0 recovered for this clone."] }), children: icons.infoIcon })] }), _jsx("p", { className: classes.text, children: (_h = clone.snapshot) === null || _h === void 0 ? void 0 : _h.dataStateAt })] }), _jsx("br", {}), _jsxs("div", { children: [_jsx("p", { children: _jsx("strong", { children: "Status" }) }), _jsx(Status, { rawClone: clone, className: classes.status })] }), _jsx("br", {}), _jsxs("div", { children: [_jsxs("p", { children: [_jsx("strong", { children: "Summary" }), "\u00A0", _jsx(Tooltip, { content: _jsxs(_Fragment, { children: [_jsx("strong", { children: "Logical data size" }), " is a logical size of files in PGDATA which is being thin-cloned.", _jsx("br", {}), _jsx("br", {}), _jsx("strong", { children: "Physical data diff size" }), " is an actual size of a\u00A0 thin clone. On creation there is no diff between clone\u2019s\u00A0 and initial data state, all data blocks match. During work\u00A0 with the clone data diverges and data diff size increases.", _jsx("br", {}), _jsx("br", {}), _jsx("strong", { children: "Clone creation time" }), " is time which was\u00A0 spent to provision the clone."] }), children: icons.infoIcon })] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Logical data size:" }), ((_j = instance.state) === null || _j === void 0 ? void 0 : _j.dataSize)
269
+ return (_jsxs(_Fragment, { children: [headRendered, _jsxs("div", { className: classes.wrapper, children: [_jsxs("div", { className: classes.summary, children: [_jsxs("div", { className: classes.actions, children: [_jsxs(Button, { variant: "contained", color: "primary", onClick: requestResetClone, disabled: isDisabledControls, title: 'Reset clone', className: classes.actionButton, children: ["Reset clone", isResettingClone && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), isUpgradeSupported && (_jsxs(Button, { variant: "contained", color: "primary", onClick: requestUpgradeClone, disabled: isDisabledControls, title: 'Upgrade clone to a newer PostgreSQL major version', className: classes.actionButton, children: ["Upgrade clone", isUpgradingClone && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] })), _jsxs(Button, { variant: "contained", color: "primary", onClick: requestDestroyClone, disabled: isDisabledControls, title: 'Delete this clone', className: classes.actionButton, children: ["Delete clone", isDestroyingClone && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), !props.hideBranchingFeatures && _jsxs(Button, { variant: "contained", color: "primary", onClick: createSnapshot, disabled: isDisabledControls, title: 'Create snapshot', className: classes.actionButton, children: ["Create snapshot", snapshots.snapshotDataLoading && (_jsx(Spinner, { size: "sm", className: classes.spinner }))] }), _jsxs(Button, { variant: "outlined", color: "secondary", onClick: reloadClone, disabled: isDisabledControls, title: 'Refresh clone information', className: classes.actionButton, children: ["Reload info", isReloading && _jsx(Spinner, { size: "sm", className: classes.spinner })] })] }), stores.main.destroyCloneError ||
270
+ (stores.main.resetCloneError && (_jsx(ErrorStub, { title: 'Resetting error', message: stores.main.resetCloneError, className: classes.errorStub }))), stores.main.upgradeCloneError && (_jsx(ErrorStub, { title: 'Upgrade error', message: stores.main.upgradeCloneError, className: classes.errorStub })), !props.hideBranchingFeatures && _jsxs("div", { children: [_jsx("p", { children: _jsx("strong", { children: "Branch" }) }), _jsx("p", { className: classes.text, children: clone.branch })] }), _jsxs("div", { className: classes.title, children: [_jsx("p", { children: _jsx("strong", { children: "Created" }) }), _jsx("p", { className: classes.text, children: clone.createdAt })] }), _jsx("br", {}), _jsxs("div", { children: [_jsxs("p", { children: [_jsx("strong", { children: "Data state at" }), "\u00A0", _jsx(Tooltip, { content: _jsxs(_Fragment, { children: [_jsx("strong", { children: "Data state time" }), " is a time at which data is\u00A0 recovered for this clone."] }), children: icons.infoIcon })] }), _jsx("p", { className: classes.text, children: (_h = clone.snapshot) === null || _h === void 0 ? void 0 : _h.dataStateAt })] }), _jsx("br", {}), _jsxs("div", { children: [_jsx("p", { children: _jsx("strong", { children: "Status" }) }), _jsx(Status, { rawClone: clone, className: classes.status })] }), clone.dbVersion && (_jsxs(_Fragment, { children: [_jsx("br", {}), _jsxs("div", { children: [_jsx("p", { children: _jsx("strong", { children: "PostgreSQL version" }) }), _jsx("p", { className: classes.text, children: clone.dbVersion })] })] })), _jsx("br", {}), _jsxs("div", { children: [_jsxs("p", { children: [_jsx("strong", { children: "Summary" }), "\u00A0", _jsx(Tooltip, { content: _jsxs(_Fragment, { children: [_jsx("strong", { children: "Logical data size" }), " is a logical size of files in PGDATA which is being thin-cloned.", _jsx("br", {}), _jsx("br", {}), _jsx("strong", { children: "Physical data diff size" }), " is an actual size of a\u00A0 thin clone. On creation there is no diff between clone\u2019s\u00A0 and initial data state, all data blocks match. During work\u00A0 with the clone data diverges and data diff size increases.", _jsx("br", {}), _jsx("br", {}), _jsx("strong", { children: "Clone creation time" }), " is time which was\u00A0 spent to provision the clone."] }), children: icons.infoIcon })] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Logical data size:" }), ((_j = instance.state) === null || _j === void 0 ? void 0 : _j.dataSize)
263
271
  ? formatBytesIEC(instance.state.dataSize)
264
272
  : '-'] }), _jsxs("p", { className: classes.text, children: [_jsx("span", { className: classes.paramTitle, children: "Physical data diff size:" }), clone.metadata.cloneDiffSize
265
273
  ? formatBytesIEC(clone.metadata.cloneDiffSize)
@@ -291,5 +299,5 @@ export const Clone = observer((props) => {
291
299
  { value: 'current', children: `Protected until ${clone.protectedTillDate.toLocaleString()}` },
292
300
  ...protectionOptions,
293
301
  ]
294
- : protectionOptions, value: getCurrentProtectionValue(), onChange: handleProtectionChange, disabled: isDisabledControls, style: { minWidth: 200 } }), isUpdatingClone && _jsx(Spinner, { size: "sm", className: classes.spinner })] }), _jsxs("span", { className: classes.remark, children: ["Select how long this clone should be protected from deletion. Protected clones cannot be deleted manually or automatically.", _jsx("br", {}), "Check disk space regularly and delete this clone once your work is done."] }), stores.main.updateCloneError && (_jsx(ErrorStub, { title: "Updating error", message: stores.main.updateCloneError, className: classes.errorStub }))] }), _jsxs("div", { className: classes.snippetContainer, children: [_jsx(SectionTitle, { tag: "h2", level: 2, text: 'Reset clone using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can reset the clone using CLI using the following command:" }), _jsx(SyntaxHighlight, { content: getCliResetCloneCommand(props.cloneId) }), _jsx(SectionTitle, { className: classes.title, tag: "h2", level: 2, text: 'Delete clone using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can delete the clone using CLI using the following command:" }), _jsx(SyntaxHighlight, { content: getCliDestroyCloneCommand(props.cloneId) }), _jsx(SectionTitle, { className: classes.title, tag: "h2", level: 2, text: 'Toggle deletion protection using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can toggle deletion protection using CLI for this clone using the following command:" }), _jsx(SyntaxHighlight, { content: getCliProtectedCloneCommand(true) }), _jsx(SyntaxHighlight, { content: getCliProtectedCloneCommand(false) }), _jsx(SectionTitle, { className: classes.title, tag: "h2", level: 2, text: 'Create snapshot for this clone using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can create a snapshot for this clone using CLI using the following command:" }), _jsx(SyntaxHighlight, { content: getCreateSnapshotCommand(props.cloneId) })] }), _jsxs(_Fragment, { children: [_jsx(DestroyCloneRestrictionModal, { isOpen: isOpenRestrictionModal, onClose: () => setIsOpenRestrictionModal(false), cloneId: clone.id }), _jsx(DestroyCloneModal, { isOpen: isOpenDestroyModal, onClose: () => setIsOpenDestroyModal(false), cloneId: clone.id, onDestroyClone: destroyClone }), _jsx(ResetCloneModal, { isOpen: isOpenResetModal, onClose: () => setIsOpenResetModal(false), clone: clone, snapshots: snapshots.data, onResetClone: resetClone, version: (_k = instance.state) === null || _k === void 0 ? void 0 : _k.engine.version })] })] })] }));
302
+ : protectionOptions, value: getCurrentProtectionValue(), onChange: handleProtectionChange, disabled: isDisabledControls, style: { minWidth: 200 } }), isUpdatingClone && _jsx(Spinner, { size: "sm", className: classes.spinner })] }), _jsxs("span", { className: classes.remark, children: ["Select how long this clone should be protected from deletion. Protected clones cannot be deleted manually or automatically.", _jsx("br", {}), "Check disk space regularly and delete this clone once your work is done."] }), stores.main.updateCloneError && (_jsx(ErrorStub, { title: "Updating error", message: stores.main.updateCloneError, className: classes.errorStub }))] }), _jsxs("div", { className: classes.snippetContainer, children: [_jsx(SectionTitle, { tag: "h2", level: 2, text: 'Reset clone using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can reset the clone using CLI using the following command:" }), _jsx(SyntaxHighlight, { content: getCliResetCloneCommand(props.cloneId) }), _jsx(SectionTitle, { className: classes.title, tag: "h2", level: 2, text: 'Delete clone using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can delete the clone using CLI using the following command:" }), _jsx(SyntaxHighlight, { content: getCliDestroyCloneCommand(props.cloneId) }), _jsx(SectionTitle, { className: classes.title, tag: "h2", level: 2, text: 'Toggle deletion protection using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can toggle deletion protection using CLI for this clone using the following command:" }), _jsx(SyntaxHighlight, { content: getCliProtectedCloneCommand(true) }), _jsx(SyntaxHighlight, { content: getCliProtectedCloneCommand(false) }), _jsx(SectionTitle, { className: classes.title, tag: "h2", level: 2, text: 'Create snapshot for this clone using CLI' }), _jsx("p", { className: classes.tooltip, children: "You can create a snapshot for this clone using CLI using the following command:" }), _jsx(SyntaxHighlight, { content: getCreateSnapshotCommand(props.cloneId) })] }), _jsxs(_Fragment, { children: [_jsx(DestroyCloneRestrictionModal, { isOpen: isOpenRestrictionModal, onClose: () => setIsOpenRestrictionModal(false), cloneId: clone.id }), _jsx(DestroyCloneModal, { isOpen: isOpenDestroyModal, onClose: () => setIsOpenDestroyModal(false), cloneId: clone.id, onDestroyClone: destroyClone }), _jsx(ResetCloneModal, { isOpen: isOpenResetModal, onClose: () => setIsOpenResetModal(false), clone: clone, snapshots: snapshots.data, onResetClone: resetClone, version: (_k = instance.state) === null || _k === void 0 ? void 0 : _k.engine.version }), isUpgradeSupported && upgradeTargetVersion && (_jsx(UpgradeCloneModal, { isOpen: isOpenUpgradeModal, onClose: () => setIsOpenUpgradeModal(false), clone: clone, targetVersion: upgradeTargetVersion, onUpgradeClone: upgradeClone }))] })] })] }));
295
303
  });
@@ -1,6 +1,7 @@
1
1
  import { GetInstance } from '@postgres.ai/shared/types/api/endpoints/getInstance';
2
2
  import { GetClone } from '@postgres.ai/shared/types/api/endpoints/getClone';
3
3
  import { ResetClone } from '@postgres.ai/shared/types/api/endpoints/resetClone';
4
+ import { UpgradeClone } from '@postgres.ai/shared/types/api/endpoints/upgradeClone';
4
5
  import { DestroyClone } from '@postgres.ai/shared/types/api/endpoints/destroyClone';
5
6
  import { UpdateClone } from '@postgres.ai/shared/types/api/endpoints/updateClone';
6
7
  import { SnapshotsStore, SnapshotsApi } from '@postgres.ai/shared/stores/Snapshots';
@@ -11,6 +12,7 @@ export declare type Api = SnapshotsApi & {
11
12
  getInstance: GetInstance;
12
13
  getClone: GetClone;
13
14
  resetClone: ResetClone;
15
+ upgradeClone?: UpgradeClone;
14
16
  destroyClone: DestroyClone;
15
17
  updateClone: UpdateClone;
16
18
  initWS?: InitWS;
@@ -27,20 +29,26 @@ export declare class MainStore {
27
29
  readonly snapshots: SnapshotsStore;
28
30
  isResettingClone: boolean;
29
31
  resetCloneError: string | null;
32
+ isUpgradingClone: boolean;
33
+ upgradeCloneError: string | null;
30
34
  isDestroyingClone: boolean;
31
35
  destroyCloneError: string | null;
32
36
  isUpdatingClone: boolean;
33
37
  updateCloneError: string | null;
34
38
  isReloading: boolean;
35
- private cloneUpdateTimeout?;
39
+ private readonly poller;
36
40
  private readonly api;
37
41
  constructor(api: Api);
42
+ get isUpgradeSupported(): boolean;
43
+ get upgradeTargetVersion(): number | undefined;
38
44
  get isCloneStable(): boolean;
39
45
  load: (instanceId: string, cloneId: string) => Promise<boolean | undefined>;
40
46
  reload: () => Promise<boolean | undefined>;
47
+ stopPolling: () => void;
41
48
  private loadInstance;
42
49
  private loadClone;
43
50
  resetClone: (snapshotId: string) => Promise<boolean>;
51
+ upgradeClone: (dockerImage?: string) => Promise<boolean>;
44
52
  destroyClone: () => Promise<boolean>;
45
53
  updateCloneProtection: (durationMinutes: number | null) => Promise<void>;
46
54
  }
@@ -7,8 +7,8 @@
7
7
  import { makeAutoObservable } from 'mobx';
8
8
  import { SnapshotsStore, } from '@postgres.ai/shared/stores/Snapshots';
9
9
  import { checkIsCloneStable } from '@postgres.ai/shared/utils/clone';
10
+ import { ClonePoller } from '@postgres.ai/shared/utils/clonePoller';
10
11
  import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api';
11
- const UNSTABLE_CLONE_UPDATE_TIMEOUT = 1000;
12
12
  export class MainStore {
13
13
  constructor(api) {
14
14
  this.instance = null;
@@ -17,12 +17,16 @@ export class MainStore {
17
17
  this.cloneError = null;
18
18
  this.isResettingClone = false;
19
19
  this.resetCloneError = null;
20
+ this.isUpgradingClone = false;
21
+ this.upgradeCloneError = null;
20
22
  this.isDestroyingClone = false;
21
23
  this.destroyCloneError = null;
22
24
  this.isUpdatingClone = false;
23
25
  this.updateCloneError = null;
24
26
  this.isReloading = false;
27
+ this.poller = new ClonePoller();
25
28
  this.load = async (instanceId, cloneId) => {
29
+ this.poller.start();
26
30
  const [isInstanceOk, isCloneOk, isSnapshotsLoaded] = await Promise.all([
27
31
  this.loadInstance(instanceId),
28
32
  this.loadClone(instanceId, cloneId),
@@ -38,6 +42,7 @@ export class MainStore {
38
42
  this.isReloading = false;
39
43
  return isSuccess;
40
44
  };
45
+ this.stopPolling = () => this.poller.stop();
41
46
  this.loadInstance = async (instanceId) => {
42
47
  const { response, error } = await this.api.getInstance({
43
48
  instanceId,
@@ -59,12 +64,14 @@ export class MainStore {
59
64
  return Boolean(response);
60
65
  };
61
66
  this.loadClone = async (instanceId, cloneId) => {
62
- window.clearTimeout(this.cloneUpdateTimeout);
67
+ if (this.poller.isStopped)
68
+ return false;
69
+ this.poller.cancel();
63
70
  const { response, error } = await this.api.getClone({ instanceId, cloneId });
64
71
  if (response) {
65
72
  this.clone = response;
66
73
  if (!this.isCloneStable)
67
- this.cloneUpdateTimeout = window.setTimeout(() => this.loadClone(instanceId, cloneId), UNSTABLE_CLONE_UPDATE_TIMEOUT);
74
+ this.poller.scheduleNext(() => this.loadClone(instanceId, cloneId));
68
75
  }
69
76
  if (error) {
70
77
  if (error.status === 404) {
@@ -97,6 +104,23 @@ export class MainStore {
97
104
  this.isResettingClone = false;
98
105
  return Boolean(response);
99
106
  };
107
+ this.upgradeClone = async (dockerImage) => {
108
+ if (!this.instance || !this.clone || !this.api.upgradeClone)
109
+ return false;
110
+ this.isUpgradingClone = true;
111
+ this.upgradeCloneError = null;
112
+ const { response, error } = await this.api.upgradeClone({
113
+ instanceId: this.instance.id,
114
+ cloneId: this.clone.id,
115
+ dockerImage,
116
+ });
117
+ if (response)
118
+ await this.loadClone(this.instance.id, this.clone.id);
119
+ if (error)
120
+ this.upgradeCloneError = await getTextFromUnknownApiError(error);
121
+ this.isUpgradingClone = false;
122
+ return Boolean(response);
123
+ };
100
124
  this.destroyClone = async () => {
101
125
  if (!this.instance || !this.clone)
102
126
  return false;
@@ -139,6 +163,19 @@ export class MainStore {
139
163
  this.api = api;
140
164
  makeAutoObservable(this);
141
165
  }
166
+ // The closed Platform app implements the same Api and may not provide the endpoint, and the
167
+ // engine only accepts an upgrade once one is configured, so the action is hidden rather than
168
+ // offered and then refused.
169
+ get isUpgradeSupported() {
170
+ return Boolean(this.api.upgradeClone) && Boolean(this.upgradeTargetVersion);
171
+ }
172
+ // The major an upgrade lands on. It follows from the instance's upgrade image, so the UI reads
173
+ // it instead of asking for one.
174
+ get upgradeTargetVersion() {
175
+ var _a, _b;
176
+ const cloneUpgrade = (_b = (_a = this.instance) === null || _a === void 0 ? void 0 : _a.state) === null || _b === void 0 ? void 0 : _b.cloneUpgrade;
177
+ return (cloneUpgrade === null || cloneUpgrade === void 0 ? void 0 : cloneUpgrade.available) ? cloneUpgrade.targetVersion : undefined;
178
+ }
142
179
  get isCloneStable() {
143
180
  if (!this.clone)
144
181
  return false;
@@ -38,6 +38,9 @@ export declare class MainStore {
38
38
  logicalSize: number;
39
39
  message: string;
40
40
  branch: string;
41
+ protected: boolean;
42
+ protectedTill?: string | undefined;
43
+ deleteAt?: string | undefined;
41
44
  }[] | null | undefined>;
42
45
  }
43
46
  export {};
@@ -122,6 +122,8 @@ export const CreateClone = observer((props) => {
122
122
  return;
123
123
  history.push(props.routes.clone(stores.main.clone.id));
124
124
  }, [stores.main.clone, stores.main.isCloneStable]);
125
+ // Stop clone status polling on unmount.
126
+ useEffect(() => () => stores.main.stopPolling(), []);
125
127
  const headRendered = (_jsxs(_Fragment, { children: [_jsx("style", { children: 'p { margin: 0; }' }), props.elements.breadcrumbs, _jsx(SectionTitle, { tag: "h1", level: 1, text: "Create clone", className: styles.pageTitle, children: _jsx(InstanceTabs, { tab: TABS_INDEX.CLONES, isPlatform: props.isPlatform, instanceId: props.instanceId, hasLogs: props.api.initWS !== undefined, hideInstanceTabs: props.hideBranchingFeatures }) })] }));
126
128
  // Initial loading spinner.
127
129
  if (!stores.main.instance || isLoadingSnapshots)
@@ -23,7 +23,7 @@ export declare class MainStore {
23
23
  getSnapshotsError: Error | null;
24
24
  clone: Clone | null;
25
25
  cloneError: string | null;
26
- private cloneUpdateTimeout?;
26
+ private readonly poller;
27
27
  private readonly api;
28
28
  readonly snapshots?: SnapshotsStore;
29
29
  constructor(api: MainStoreApi);
@@ -44,6 +44,10 @@ export declare class MainStore {
44
44
  logicalSize: number;
45
45
  message: string;
46
46
  branch: string;
47
+ protected: boolean;
48
+ protectedTill?: string | undefined;
49
+ deleteAt?: string | undefined;
47
50
  }[] | null | undefined>;
51
+ stopPolling: () => void;
48
52
  private updateCloneUntilStable;
49
53
  }
@@ -2,7 +2,7 @@ import { makeAutoObservable } from 'mobx';
2
2
  import { SnapshotsStore, } from '@postgres.ai/shared/stores/Snapshots';
3
3
  import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api';
4
4
  import { checkIsCloneStable } from '@postgres.ai/shared/utils/clone';
5
- const UNSTABLE_CLONE_UPDATE_TIMEOUT = 1000;
5
+ import { ClonePoller } from '@postgres.ai/shared/utils/clonePoller';
6
6
  export class MainStore {
7
7
  constructor(api) {
8
8
  this.instance = null;
@@ -11,6 +11,7 @@ export class MainStore {
11
11
  this.getSnapshotsError = null;
12
12
  this.clone = null;
13
13
  this.cloneError = null;
14
+ this.poller = new ClonePoller();
14
15
  this.load = async (instanceId) => {
15
16
  var _a, _b;
16
17
  const [instance, isLoadedSnapshots] = await Promise.all([
@@ -26,6 +27,7 @@ export class MainStore {
26
27
  this.createClone = async (data) => {
27
28
  if (!this.instance)
28
29
  return false;
30
+ this.poller.start();
29
31
  const { response, error } = await this.api.createClone({
30
32
  ...data,
31
33
  instanceId: this.instance.id,
@@ -61,13 +63,16 @@ export class MainStore {
61
63
  this.getSnapshotsError = await error.json().then((err) => err);
62
64
  return response;
63
65
  };
66
+ this.stopPolling = () => this.poller.stop();
64
67
  this.updateCloneUntilStable = async (args) => {
65
- window.clearTimeout(this.cloneUpdateTimeout);
68
+ if (this.poller.isStopped)
69
+ return;
70
+ this.poller.cancel();
66
71
  const { response, error } = await this.api.getClone(args);
67
72
  if (response) {
68
73
  this.clone = response;
69
74
  if (!this.isCloneStable)
70
- this.cloneUpdateTimeout = window.setTimeout(() => this.updateCloneUntilStable(args), UNSTABLE_CLONE_UPDATE_TIMEOUT);
75
+ this.poller.scheduleNext(() => this.updateCloneUntilStable(args));
71
76
  }
72
77
  if (error)
73
78
  this.cloneError = await error.json().then((err) => (err === null || err === void 0 ? void 0 : err.message) || err);
@@ -7,5 +7,7 @@ declare type Props = {
7
7
  disabled?: boolean;
8
8
  keyErrors?: (string | undefined)[];
9
9
  };
10
+ export declare const MASKED_ENV_VALUE = "****";
11
+ export declare const isMaskedEnvValue: (value: string) => boolean;
10
12
  export declare const EnvsEditor: ({ envs, onChange, suggestions, disabled, keyErrors, }: Props) => JSX.Element;
11
13
  export {};