@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,7 +5,7 @@
5
5
  *--------------------------------------------------------------------------
6
6
  */
7
7
 
8
- import { useState, useEffect, useMemo } from 'react'
8
+ import { useState, useEffect, useMemo, useRef } from 'react'
9
9
  import { observer } from 'mobx-react-lite'
10
10
  import Editor from '@monaco-editor/react'
11
11
  import {
@@ -15,6 +15,10 @@ import {
15
15
  Snackbar,
16
16
  makeStyles,
17
17
  Button,
18
+ Tab,
19
+ Tabs,
20
+ Radio,
21
+ RadioGroup,
18
22
  } from '@material-ui/core'
19
23
  import Box from '@mui/material/Box'
20
24
 
@@ -45,6 +49,10 @@ import {
45
49
  InputWithTooltip,
46
50
  } from './InputWithTooltip'
47
51
 
52
+ import { SimpleMode, buildProjectionFromProposed } from './SimpleMode'
53
+ import { ConfigMode, getInitialConfigMode } from './configMode'
54
+ import { PhysicalMode } from './PhysicalMode'
55
+
48
56
  import styles from './styles.module.scss'
49
57
  import { SeImages } from '@postgres.ai/shared/types/api/endpoints/getSeImages'
50
58
  import {
@@ -58,8 +66,6 @@ type PgOptionsType = {
58
66
  pgRestoreOptions: string[]
59
67
  }
60
68
 
61
- const NON_LOGICAL_RETRIEVAL_MESSAGE =
62
- 'Configuration editing is only available in logical mode'
63
69
  const PREVENT_MODIFYING_MESSAGE = 'Editing is disabled by admin'
64
70
 
65
71
  const useStyles = makeStyles(
@@ -80,13 +86,11 @@ export const Configuration = observer(
80
86
  instanceId,
81
87
  switchActiveTab,
82
88
  reload,
83
- isConfigurationActive,
84
89
  disableConfigModification,
85
90
  }: {
86
91
  instanceId: string
87
92
  switchActiveTab: (_: null, activeTab: number) => void
88
93
  reload: () => void
89
- isConfigurationActive: boolean
90
94
  disableConfigModification?: boolean
91
95
  }) => {
92
96
  const classes = useStyles()
@@ -106,8 +110,7 @@ export const Configuration = observer(
106
110
 
107
111
  const configData: MainStore['config'] =
108
112
  config && JSON.parse(JSON.stringify(config))
109
- const isConfigurationDisabled =
110
- !isConfigurationActive || disableConfigModification
113
+ const isConfigurationDisabled = disableConfigModification
111
114
 
112
115
  const [dleEdition, setDledition] = useState('')
113
116
  const isCeEdition = dleEdition === 'community'
@@ -118,6 +121,25 @@ export const Configuration = observer(
118
121
  )
119
122
  : dockerImageOptions
120
123
 
124
+ const [userPickedMode, setUserPickedMode] = useState<ConfigMode | null>(
125
+ null,
126
+ )
127
+ // Seeded lazily — only once configData transitions from null to non-null.
128
+ // Prevents the Simple/Expert default from flipping mid-session when an
129
+ // Apply triggers a refetch and the recomputed default would differ.
130
+ const [initialMode, setInitialMode] = useState<ConfigMode | null>(null)
131
+ useEffect(() => {
132
+ if (initialMode === null && configData) {
133
+ setInitialMode(
134
+ getInitialConfigMode(configData.host, configData.retrievalMode),
135
+ )
136
+ }
137
+ }, [configData, initialMode])
138
+ const configMode: ConfigMode =
139
+ userPickedMode ?? initialMode ?? 'simple'
140
+ const setConfigMode = setUserPickedMode
141
+ const portInitFromConfig = useRef(false)
142
+
121
143
  const [isModalOpen, setIsModalOpen] = useState(false)
122
144
  const [submitState, setSubmitState] = useState({
123
145
  status: '',
@@ -172,6 +194,8 @@ export const Configuration = observer(
172
194
  await updateConfig(
173
195
  {
174
196
  ...values,
197
+ // Preserve configs that originally had no port: key.
198
+ ...(omitPortOnSubmit && { port: '' }),
175
199
  tuningParams: formatTuningParamsToObj(
176
200
  values.tuningParams,
177
201
  ) as unknown as string,
@@ -194,8 +218,18 @@ export const Configuration = observer(
194
218
  }
195
219
  })
196
220
  }
197
- const [{ formik, connectionData, isConnectionDataValid }] =
198
- useForm(onSubmit)
221
+ const [
222
+ {
223
+ formik,
224
+ connectionData,
225
+ isConnectionDataValid,
226
+ connectionString,
227
+ connectionStringError,
228
+ onConnectionStringChange,
229
+ markPortInitialState,
230
+ omitPortOnSubmit,
231
+ },
232
+ ] = useForm(onSubmit)
199
233
 
200
234
  // Memoized enhanced Docker images to avoid recreation on every render
201
235
  // This combines predefined images with any custom image from configuration
@@ -578,6 +612,12 @@ export const Configuration = observer(
578
612
  // Set initial data, empty string for password
579
613
  useEffect(() => {
580
614
  if (configData) {
615
+ if (!portInitFromConfig.current) {
616
+ markPortInitialState(
617
+ configData.port == null || configData.port === '',
618
+ )
619
+ portInitFromConfig.current = true
620
+ }
581
621
  for (const [key, value] of Object.entries(configData)) {
582
622
  if (key !== 'password') {
583
623
  formik.setFieldValue(key, value)
@@ -681,9 +721,7 @@ export const Configuration = observer(
681
721
  message={
682
722
  Boolean(dockerState.error)
683
723
  ? dockerState.error
684
- : disableConfigModification
685
- ? PREVENT_MODIFYING_MESSAGE
686
- : NON_LOGICAL_RETRIEVAL_MESSAGE
724
+ : PREVENT_MODIFYING_MESSAGE
687
725
  }
688
726
  className={styles.snackbar}
689
727
  />
@@ -693,7 +731,37 @@ export const Configuration = observer(
693
731
  </div>
694
732
  ) : (
695
733
  <Box>
696
- <Header retrievalMode="logical" setOpen={handleModalClick} />
734
+ <Header
735
+ retrievalMode={formik.values.retrievalMode}
736
+ setOpen={handleModalClick}
737
+ />
738
+ <Tabs
739
+ value={configMode}
740
+ onChange={(_, value: ConfigMode) => setConfigMode(value)}
741
+ indicatorColor="primary"
742
+ textColor="primary"
743
+ aria-label="Configuration mode"
744
+ >
745
+ <Tab value="simple" label="Simple" />
746
+ <Tab value="expert" label="Expert" />
747
+ </Tabs>
748
+ {configMode === 'simple' ? (
749
+ <SimpleMode
750
+ instanceId={instanceId}
751
+ disabled={isConfigurationDisabled}
752
+ onApplied={switchTab}
753
+ onEdit={(proposed, password) => {
754
+ const projection = buildProjectionFromProposed(
755
+ proposed,
756
+ password,
757
+ )
758
+ formik.setValues({ ...formik.values, ...projection })
759
+ setConfigMode('expert')
760
+ }}
761
+ />
762
+ ) : null}
763
+ {configMode === 'expert' ? (
764
+ <>
697
765
  <Box>
698
766
  <Box>
699
767
  <FormControlLabel
@@ -715,6 +783,45 @@ export const Configuration = observer(
715
783
  </Box>
716
784
  <Box mb={1} mt={1}>
717
785
  <ConfigSectionTitle tag="retrieval" />
786
+ <Box mt={1} mb={1}>
787
+ <Typography className={styles.subsection}>
788
+ Retrieval mode
789
+ </Typography>
790
+ <RadioGroup
791
+ row
792
+ aria-label="retrieval mode"
793
+ value={formik.values.retrievalMode}
794
+ onChange={(_, value) => {
795
+ formik.setFieldValue('retrievalMode', value)
796
+ }}
797
+ >
798
+ <FormControlLabel
799
+ value="logical"
800
+ control={<Radio disabled={isConfigurationDisabled} />}
801
+ label="Logical (dump/restore)"
802
+ />
803
+ <FormControlLabel
804
+ value="physical"
805
+ control={<Radio disabled={isConfigurationDisabled} />}
806
+ label="Physical (WAL-G / pgBackRest)"
807
+ />
808
+ </RadioGroup>
809
+ </Box>
810
+ {formik.values.retrievalMode === 'physical' && (
811
+ <PhysicalMode
812
+ values={formik.values}
813
+ onChange={(key, value) =>
814
+ formik.setFieldValue(key, value, key === 'physicalEnvs')
815
+ }
816
+ disabled={isConfigurationDisabled}
817
+ envsKeyErrors={(
818
+ formik.errors.physicalEnvs as
819
+ | Array<{ key?: string } | undefined>
820
+ | undefined
821
+ )?.map((row) => row?.key)}
822
+ />
823
+ )}
824
+ {formik.values.retrievalMode === 'logical' && (
718
825
  <Box mt={1}>
719
826
  <Typography className={styles.subsection}>
720
827
  Subsection "retrieval.spec.logicalDump"
@@ -723,35 +830,35 @@ export const Configuration = observer(
723
830
  Source database credentials and dumping options.
724
831
  </span>
725
832
  <InputWithTooltip
726
- label="source.connection.host *"
727
- value={formik.values.host}
728
- error={formik.errors.host}
729
- tooltipText={tooltipText.host}
730
- disabled={isConfigurationDisabled}
731
- onChange={(e) =>
732
- formik.setFieldValue('host', e.target.value)
833
+ label="Connection string *"
834
+ value={connectionString}
835
+ error={
836
+ connectionStringError ||
837
+ formik.errors.host ||
838
+ formik.errors.username ||
839
+ formik.errors.dbname
733
840
  }
734
- />
735
- <InputWithTooltip
736
- label="source.connection.port *"
737
- value={formik.values.port}
738
- error={formik.errors.port}
739
- tooltipText={tooltipText.port}
841
+ tooltipText={() => (
842
+ <>
843
+ URI form: <code>postgres://user@host:5432/dbname</code>
844
+ . DSN form is also accepted. Do not include the
845
+ password here — use the Password field below.
846
+ </>
847
+ )}
740
848
  disabled={isConfigurationDisabled}
741
- onChange={(e) =>
742
- formik.setFieldValue('port', e.target.value)
743
- }
744
- />
745
- <InputWithTooltip
746
- label="source.connection.username *"
747
- value={formik.values.username}
748
- error={formik.errors.username}
749
- tooltipText={tooltipText.username}
750
- disabled={isConfigurationDisabled}
751
- onChange={(e) =>
752
- formik.setFieldValue('username', e.target.value)
753
- }
849
+ onChange={(e) => onConnectionStringChange(e.target.value)}
754
850
  />
851
+ <Box mt={0.5} mb={1}>
852
+ <span
853
+ className={classes.grayText}
854
+ data-testid="connection-string-parsed"
855
+ >
856
+ host: {formik.values.host || '—'} | port:{' '}
857
+ {formik.values.port || '5432 (default)'} | user:{' '}
858
+ {formik.values.username || '—'} | dbname:{' '}
859
+ {formik.values.dbname || '—'}
860
+ </span>
861
+ </Box>
755
862
  <InputWithTooltip
756
863
  type="password"
757
864
  value={formik.values.password}
@@ -762,16 +869,6 @@ export const Configuration = observer(
762
869
  formik.setFieldValue('password', e.target.value)
763
870
  }
764
871
  />
765
- <InputWithTooltip
766
- label="source.connection.dbname *"
767
- value={formik.values.dbname}
768
- error={formik.errors.dbname}
769
- tooltipText={tooltipText.dbname}
770
- disabled={isConfigurationDisabled}
771
- onChange={(e) =>
772
- formik.setFieldValue('dbname', e.target.value)
773
- }
774
- />
775
872
  <InputWithChip
776
873
  id="databases"
777
874
  value={formik.values.databases}
@@ -863,6 +960,7 @@ export const Configuration = observer(
863
960
  label={'Ignore errors during logical data dump'}
864
961
  />
865
962
  </Box>
963
+ )}
866
964
  </Box>
867
965
  <Box mb={2} mt={1}>
868
966
  <ConfigSectionTitle tag="databaseContainer" />
@@ -1090,6 +1188,7 @@ export const Configuration = observer(
1090
1188
  />
1091
1189
  ) : null}
1092
1190
  </Box>
1191
+ {formik.values.retrievalMode === 'logical' && (
1093
1192
  <Box>
1094
1193
  <Box>
1095
1194
  <Typography className={styles.subsection}>
@@ -1151,6 +1250,7 @@ export const Configuration = observer(
1151
1250
  label={'Ignore errors during logical data restore'}
1152
1251
  />
1153
1252
  </Box>
1253
+ )}
1154
1254
  <Box mt={1}>
1155
1255
  <Typography className={styles.subsection}>
1156
1256
  Subsection "retrieval.refresh"
@@ -1219,6 +1319,8 @@ export const Configuration = observer(
1219
1319
  message={configError || submitState.response}
1220
1320
  />
1221
1321
  ) : null}
1322
+ </>
1323
+ ) : null}
1222
1324
  </Box>
1223
1325
  )}
1224
1326
  <Modal
@@ -5,9 +5,20 @@
5
5
  *--------------------------------------------------------------------------
6
6
  */
7
7
 
8
+ import { useMemo, useState } from 'react'
8
9
  import { useFormik } from 'formik'
9
10
  import * as Yup from 'yup'
10
11
 
12
+ import {
13
+ ConnectionStringError,
14
+ connectionStringFromFields,
15
+ connectionStringToFields,
16
+ } from './connectionString'
17
+
18
+ export type RetrievalMode = 'logical' | 'physical'
19
+ export type PhysicalTool = 'walg' | 'pgbackrest' | 'customTool' | ''
20
+ export type PhysicalEnv = { key: string; value: string }
21
+
11
22
  export type FormValues = {
12
23
  debug: boolean
13
24
  dockerImage: string
@@ -31,14 +42,72 @@ export type FormValues = {
31
42
  restoreConfigs: string
32
43
  pgDumpCustomOptions: string
33
44
  pgRestoreCustomOptions: string
45
+ retrievalMode: RetrievalMode
46
+ physicalTool: PhysicalTool
47
+ physicalDockerImage: string
48
+ physicalSyncEnabled: boolean
49
+ physicalWalgBackupName: string
50
+ physicalPgbackrestStanza: string
51
+ physicalPgbackrestDelta: boolean
52
+ physicalEnvs: PhysicalEnv[]
34
53
  }
35
54
 
55
+ // Logical-mode required fields. Validation skips the source-connection block
56
+ // when retrievalMode is "physical" — that branch uses tool-specific inputs
57
+ // instead of a connection URL.
36
58
  const Schema = Yup.object().shape({
37
59
  dockerImage: Yup.string().required('Docker image is required'),
38
- dbname: Yup.string().required('Dbname is required'),
39
- host: Yup.string().required('Host is required'),
40
- port: Yup.string().required('Port is required'),
41
- username: Yup.string().required('Username is required'),
60
+ dbname: Yup.string().when('retrievalMode', {
61
+ is: 'logical',
62
+ then: (s) => s.required('Dbname is required'),
63
+ }),
64
+ host: Yup.string().when('retrievalMode', {
65
+ is: 'logical',
66
+ then: (s) => s.required('Host is required'),
67
+ }),
68
+ port: Yup.string().when('retrievalMode', {
69
+ is: 'logical',
70
+ then: (s) => s.required('Port is required'),
71
+ }),
72
+ username: Yup.string().when('retrievalMode', {
73
+ is: 'logical',
74
+ then: (s) => s.required('Username is required'),
75
+ }),
76
+ physicalEnvs: Yup.array()
77
+ .of(
78
+ Yup.object().shape({
79
+ key: Yup.string(),
80
+ value: Yup.string(),
81
+ }),
82
+ )
83
+ .test('unique-keys', '', function (envs) {
84
+ if (!envs) return true
85
+
86
+ const positions = new Map<string, number[]>()
87
+ envs.forEach((row, i) => {
88
+ const trimmed = (row?.key ?? '').trim()
89
+ if (!trimmed) return
90
+ const list = positions.get(trimmed) ?? []
91
+ list.push(i)
92
+ positions.set(trimmed, list)
93
+ })
94
+
95
+ const dupes: Yup.ValidationError[] = []
96
+ for (const indices of positions.values()) {
97
+ if (indices.length < 2) continue
98
+ for (const i of indices) {
99
+ dupes.push(
100
+ this.createError({
101
+ path: `${this.path}[${i}].key`,
102
+ message: 'Duplicate key',
103
+ }),
104
+ )
105
+ }
106
+ }
107
+
108
+ if (dupes.length === 0) return true
109
+ return new Yup.ValidationError(dupes)
110
+ }),
42
111
  })
43
112
 
44
113
  export const useForm = (onSubmit: (values: FormValues) => void) => {
@@ -66,6 +135,14 @@ export const useForm = (onSubmit: (values: FormValues) => void) => {
66
135
  pgRestoreCustomOptions: '',
67
136
  dumpIgnoreErrors: false,
68
137
  restoreIgnoreErrors: false,
138
+ retrievalMode: 'logical',
139
+ physicalTool: '',
140
+ physicalDockerImage: '',
141
+ physicalSyncEnabled: false,
142
+ physicalWalgBackupName: '',
143
+ physicalPgbackrestStanza: '',
144
+ physicalPgbackrestDelta: false,
145
+ physicalEnvs: [],
69
146
  },
70
147
  validationSchema: Schema,
71
148
  onSubmit,
@@ -73,6 +150,67 @@ export const useForm = (onSubmit: (values: FormValues) => void) => {
73
150
  validateOnChange: false,
74
151
  })
75
152
 
153
+ const [originalPortWasUnset, setOriginalPortWasUnset] = useState(false)
154
+ const [portDirty, setPortDirty] = useState(false)
155
+ const [connectionStringError, setConnectionStringError] = useState<string | null>(null)
156
+
157
+ const connectionString = useMemo(
158
+ () =>
159
+ connectionStringFromFields(
160
+ {
161
+ host: formik.values.host,
162
+ port: formik.values.port || '5432',
163
+ username: formik.values.username,
164
+ dbname: formik.values.dbname,
165
+ },
166
+ { omitDefaultPort: originalPortWasUnset && !portDirty },
167
+ ),
168
+ [
169
+ formik.values.host,
170
+ formik.values.port,
171
+ formik.values.username,
172
+ formik.values.dbname,
173
+ originalPortWasUnset,
174
+ portDirty,
175
+ ],
176
+ )
177
+
178
+ const onConnectionStringChange = (s: string) => {
179
+ if (!s.trim()) {
180
+ setConnectionStringError(null)
181
+ formik.setValues({
182
+ ...formik.values,
183
+ host: '',
184
+ port: '',
185
+ username: '',
186
+ dbname: '',
187
+ })
188
+ return
189
+ }
190
+
191
+ try {
192
+ const parsed = connectionStringToFields(s)
193
+ setConnectionStringError(null)
194
+
195
+ if (parsed.portWasExplicit) setPortDirty(true)
196
+
197
+ formik.setValues({
198
+ ...formik.values,
199
+ host: parsed.fields.host,
200
+ port: parsed.fields.port,
201
+ username: parsed.fields.username,
202
+ dbname: parsed.fields.dbname,
203
+ })
204
+ } catch (err) {
205
+ if (err instanceof ConnectionStringError) setConnectionStringError(err.message)
206
+ else throw err
207
+ }
208
+ }
209
+
210
+ const markPortInitialState = (wasUnset: boolean) => setOriginalPortWasUnset(wasUnset)
211
+ const markPortDirty = () => setPortDirty(true)
212
+ const omitPortOnSubmit = originalPortWasUnset && !portDirty
213
+
76
214
  const formatDatabaseArray = (database: string) => {
77
215
  let databases = []
78
216
  const splitDatabaseArray = database.split(/[,(\s)(\n)(\r)(\t)(\r\n)]/)
@@ -106,5 +244,17 @@ export const useForm = (onSubmit: (values: FormValues) => void) => {
106
244
  formik.values.username &&
107
245
  formik.values.dbname
108
246
 
109
- return [{ formik, connectionData, isConnectionDataValid }]
247
+ return [
248
+ {
249
+ formik,
250
+ connectionData,
251
+ isConnectionDataValid,
252
+ connectionString,
253
+ connectionStringError,
254
+ onConnectionStringChange,
255
+ markPortInitialState,
256
+ markPortDirty,
257
+ omitPortOnSubmit,
258
+ },
259
+ ]
110
260
  }
@@ -1,26 +1,10 @@
1
1
  import { DatabaseType } from '@postgres.ai/shared/types/api/entities/config'
2
2
 
3
3
  import { dockerImageOptions } from '../configOptions'
4
+ import { dockerImagesConfig, genericImagePrefix } from '../dockerCatalog'
4
5
  import { FormValues } from '../useForm'
5
6
 
6
7
  const seContainerRegistry = 'se-images'
7
- const genericImagePrefix = 'postgresai/extended-postgres'
8
- // Predefined list of Docker images for UI display
9
- // This list is shown to users for convenient selection
10
- // IMPORTANT: if user specified an image in config that's not in this list,
11
- // it will be automatically added via createEnhancedDockerImages()
12
- const dockerImagesConfig = {
13
- '9.6': ['0.5.3', '0.5.2', '0.5.1'],
14
- '10': ['0.5.3', '0.5.2', '0.5.1'],
15
- '11': ['0.5.3', '0.5.2', '0.5.1'],
16
- '12': ['0.5.3', '0.5.2', '0.5.1'],
17
- '13': ['0.5.3', '0.5.2', '0.5.1'],
18
- '14': ['0.5.3', '0.5.2', '0.5.1'],
19
- '15': ['0.5.3', '0.5.2', '0.5.1'],
20
- '16': ['0.5.3', '0.5.2', '0.5.1'],
21
- '17': ['0.5.3', '0.5.2', '0.5.1'],
22
- '18': ['0.6.1'],
23
- }
24
8
 
25
9
  export type FormValuesKey = keyof FormValues
26
10
 
@@ -21,7 +21,7 @@ const useStyles = makeStyles(
21
21
  position: 'relative',
22
22
  cursor: 'default',
23
23
  flex: `0 0 ${CELL_SIZE}px`,
24
- background: '#f4f4f4',
24
+ background: 'rgba(128, 128, 128, 0.1)',
25
25
  height: `${CELL_SIZE}px`,
26
26
  display: 'flex',
27
27
  borderRadius: `${CELL_SIZE / 2}px`,
@@ -31,7 +31,7 @@ const useStyles = makeStyles(
31
31
  fontSize: '12px',
32
32
  },
33
33
  rootHasSnapshots: {
34
- background: colors.secondary2.lightLight,
34
+ background: 'rgba(15, 135, 157, 0.32)',
35
35
  cursor: 'pointer',
36
36
  },
37
37
  rootCurrent: {
@@ -46,7 +46,7 @@ const useStyles = makeStyles(
46
46
  right: '-6px',
47
47
  position: 'absolute',
48
48
  fontSize: '8px',
49
- backgroundColor: colors.white,
49
+ backgroundColor: 'inherit',
50
50
  border: `1px solid ${colors.secondary2.lightLight}`,
51
51
  borderRadius: '8px',
52
52
  height: '16px',
@@ -50,6 +50,16 @@ const useStyles = makeStyles(
50
50
  header: {
51
51
  fontWeight: 500,
52
52
  },
53
+ protectedBadge: {
54
+ marginLeft: '8px',
55
+ padding: '1px 6px',
56
+ fontSize: '11px',
57
+ fontWeight: 400,
58
+ color: '#1a7f37',
59
+ border: '1px solid #1a7f37',
60
+ borderRadius: '4px',
61
+ whiteSpace: 'nowrap',
62
+ },
53
63
  infoContent: {
54
64
  fontSize: '12px',
55
65
  color: '#808080',
@@ -139,7 +149,17 @@ const SnapshotListItem = ({
139
149
  <div className={classes.commitItem}>
140
150
  <div className={classes.gridContainer}>
141
151
  <div className={classes.infoBlock}>
142
- <div className={classes.header}>{snapshot.message || '-'}</div>
152
+ <div className={classes.header}>
153
+ {snapshot.message || '-'}
154
+ {snapshot.protected && (
155
+ <span
156
+ className={classes.protectedBadge}
157
+ title="Protected from deletion"
158
+ >
159
+ Protected
160
+ </span>
161
+ )}
162
+ </div>
143
163
  <div className={classes.infoContent} title={snapshot.dataStateAt}>
144
164
  {timeAgo}
145
165
  </div>
@@ -44,6 +44,10 @@ const useStyles = makeStyles(
44
44
  { index: 1 },
45
45
  )
46
46
 
47
+ // listRefreshIntervalMs is how often the snapshot list is silently refreshed so background
48
+ // auto-deletion does not leave stale rows in the list.
49
+ const listRefreshIntervalMs = 30_000
50
+
47
51
  interface SnapshotsProps {
48
52
  instanceId: string
49
53
  }
@@ -97,11 +101,17 @@ export const Snapshots: React.FC<SnapshotsProps> = observer(
97
101
  }, [])
98
102
 
99
103
  useEffect(() => {
100
- if (selectedBranch) {
101
- stores.main.reloadSnapshots(
102
- selectedBranch === 'All branches' ? '' : selectedBranch,
103
- )
104
- }
104
+ if (!selectedBranch) return
105
+
106
+ const branchName = selectedBranch === 'All branches' ? '' : selectedBranch
107
+
108
+ stores.main.reloadSnapshots(branchName)
109
+
110
+ const intervalId = setInterval(() => {
111
+ stores.main.reloadSnapshots(branchName, true)
112
+ }, listRefreshIntervalMs)
113
+
114
+ return () => clearInterval(intervalId)
105
115
  }, [selectedBranch])
106
116
 
107
117
  if (!instance && !snapshots.isLoading) return <></>