From 5c12303e43679f8fbe6e6484d3808df885bccec4 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Thu, 10 Nov 2022 10:17:36 +0000 Subject: [PATCH 001/114] feat(engine): create snapshot API handlers (#65) * create a new snapshot * delete the existing snapshot * add new commands to CLI, * add tests --- engine/cmd/cli/commands/snapshot/actions.go | 49 +++++++++ .../cmd/cli/commands/snapshot/command_list.go | 28 +++++ engine/internal/cloning/base.go | 12 ++- engine/internal/cloning/snapshots.go | 17 +++ .../internal/provision/pool/pool_manager.go | 7 +- .../internal/provision/resources/resources.go | 12 +-- .../internal/provision/thinclones/zfs/zfs.go | 2 +- engine/internal/retrieval/retrieval.go | 10 +- engine/internal/srv/routes.go | 100 ++++++++++++++++++ engine/internal/srv/server.go | 2 + engine/pkg/client/dblabapi/snapshot.go | 56 ++++++++++ engine/pkg/client/dblabapi/types/clone.go | 10 ++ engine/pkg/util/clones.go | 9 +- engine/test/1.synthetic.sh | 18 +++- 14 files changed, 313 insertions(+), 19 deletions(-) diff --git a/engine/cmd/cli/commands/snapshot/actions.go b/engine/cmd/cli/commands/snapshot/actions.go index 0ac175a5..a597b5c2 100644 --- a/engine/cmd/cli/commands/snapshot/actions.go +++ b/engine/cmd/cli/commands/snapshot/actions.go @@ -12,6 +12,7 @@ import ( "github.com/urfave/cli/v2" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" + "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) @@ -44,3 +45,51 @@ func list(cliCtx *cli.Context) error { return err } + +// create runs a request to create a new snapshot. +func create(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + snapshotRequest := types.SnapshotCreateRequest{ + PoolName: cliCtx.String("pool"), + } + + snapshot, err := dblabClient.CreateSnapshot(cliCtx.Context, snapshotRequest) + if err != nil { + return err + } + + commandResponse, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + + _, err = fmt.Fprintln(cliCtx.App.Writer, string(commandResponse)) + + return err +} + +// deleteSnapshot runs a request to delete existing snapshot. +func deleteSnapshot(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + snapshotID := cliCtx.Args().First() + + snapshotRequest := types.SnapshotDestroyRequest{ + SnapshotID: snapshotID, + } + + if err := dblabClient.DeleteSnapshot(cliCtx.Context, snapshotRequest); err != nil { + return err + } + + _, err = fmt.Fprintf(cliCtx.App.Writer, "The snapshot has been successfully deleted: %s\n", snapshotID) + + return err +} diff --git a/engine/cmd/cli/commands/snapshot/command_list.go b/engine/cmd/cli/commands/snapshot/command_list.go index 3fd6e3cb..0e167762 100644 --- a/engine/cmd/cli/commands/snapshot/command_list.go +++ b/engine/cmd/cli/commands/snapshot/command_list.go @@ -6,6 +6,8 @@ package snapshot import ( "github.com/urfave/cli/v2" + + "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" ) // CommandList returns available commands for a snapshot management. @@ -20,7 +22,33 @@ func CommandList() []*cli.Command { Usage: "list all existing snapshots", Action: list, }, + { + Name: "create", + Usage: "create a snapshot", + Action: create, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "pool", + Usage: "pool name", + }, + }, + }, + { + Name: "delete", + Usage: "delete existing snapshot", + Action: deleteSnapshot, + ArgsUsage: "SNAPSHOT_ID", + Before: checkSnapshotIDBefore, + }, }, }, } } + +func checkSnapshotIDBefore(c *cli.Context) error { + if c.NArg() == 0 { + return commands.NewActionError("SNAPSHOT_ID argument is required") + } + + return nil +} diff --git a/engine/internal/cloning/base.go b/engine/internal/cloning/base.go index 47cf2d13..1b9995b4 100644 --- a/engine/internal/cloning/base.go +++ b/engine/internal/cloning/base.go @@ -275,6 +275,10 @@ func (c *Base) DestroyClone(cloneID string) error { return models.New(models.ErrCodeBadRequest, "clone is protected") } + if c.hasDependentSnapshots(w) { + return models.New(models.ErrCodeBadRequest, "clone has dependent snapshots") + } + if err := c.UpdateCloneStatus(cloneID, models.Status{ Code: models.StatusDeleting, Message: models.CloneMessageDeleting, @@ -486,6 +490,11 @@ func (c *Base) GetSnapshots() ([]models.Snapshot, error) { return c.getSnapshotList(), nil } +// ReloadSnapshots reloads snapshot list. +func (c *Base) ReloadSnapshots() error { + return c.fetchSnapshots() +} + // GetClones returns the list of clones descend ordered by creation time. func (c *Base) GetClones() []*models.Clone { clones := make([]*models.Clone, 0, c.lenClones()) @@ -618,7 +627,8 @@ func (c *Base) isIdleClone(wrapper *CloneWrapper) (bool, error) { idleDuration := time.Duration(c.config.MaxIdleMinutes) * time.Minute minimumTime := currentTime.Add(-idleDuration) - if wrapper.Clone.Protected || wrapper.Clone.Status.Code == models.StatusExporting || wrapper.TimeStartedAt.After(minimumTime) { + if wrapper.Clone.Protected || wrapper.Clone.Status.Code == models.StatusExporting || wrapper.TimeStartedAt.After(minimumTime) || + c.hasDependentSnapshots(wrapper) { return false, nil } diff --git a/engine/internal/cloning/snapshots.go b/engine/internal/cloning/snapshots.go index d59f5b09..3ccc1fde 100644 --- a/engine/internal/cloning/snapshots.go +++ b/engine/internal/cloning/snapshots.go @@ -6,12 +6,14 @@ package cloning import ( "sort" + "strings" "sync" "github.com/pkg/errors" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util" ) // SnapshotBox contains instance snapshots. @@ -166,3 +168,18 @@ func (c *Base) getSnapshotList() []models.Snapshot { return snapshots } + +func (c *Base) hasDependentSnapshots(w *CloneWrapper) bool { + c.snapshotBox.snapshotMutex.RLock() + defer c.snapshotBox.snapshotMutex.RUnlock() + + poolName := util.GetPoolName(w.Clone.Snapshot.Pool, util.GetCloneNameStr(w.Clone.DB.Port)) + + for name := range c.snapshotBox.items { + if strings.HasPrefix(name, poolName) { + return true + } + } + + return false +} diff --git a/engine/internal/provision/pool/pool_manager.go b/engine/internal/provision/pool/pool_manager.go index fb56f80e..4fbfd315 100644 --- a/engine/internal/provision/pool/pool_manager.go +++ b/engine/internal/provision/pool/pool_manager.go @@ -30,6 +30,9 @@ const ( ext4 = "ext4" ) +// ErrNoPools means that there no available pools. +var ErrNoPools = errors.New("no available pools") + // Manager describes a pool manager. type Manager struct { cfg *Config @@ -144,7 +147,7 @@ func (pm *Manager) GetFSManager(name string) (FSManager, error) { pm.mu.Unlock() if !ok { - return nil, errors.New("pool manager not found") + return nil, fmt.Errorf("pool manager not found: %s", name) } return fsm, nil @@ -240,7 +243,7 @@ func (pm *Manager) ReloadPools() error { fsPools, fsManagerList := pm.examineEntries(dirEntries) if len(fsPools) == 0 { - return errors.New("no available pools") + return ErrNoPools } pm.mu.Lock() diff --git a/engine/internal/provision/resources/resources.go b/engine/internal/provision/resources/resources.go index 201f9e11..8b847f41 100644 --- a/engine/internal/provision/resources/resources.go +++ b/engine/internal/provision/resources/resources.go @@ -33,12 +33,12 @@ type EphemeralUser struct { // Snapshot defines snapshot of the data with related meta-information. type Snapshot struct { - ID string - CreatedAt time.Time - DataStateAt time.Time - Used uint64 - LogicalReferenced uint64 - Pool string + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + DataStateAt time.Time `json:"dataStateAt"` + Used uint64 `json:"used"` + LogicalReferenced uint64 `json:"logicalReferenced"` + Pool string `json:"pool"` } // SessionState defines current state of a Session. diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index bcd6254f..8742fc81 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -272,7 +272,7 @@ func (m *Manager) CreateSnapshot(poolSuffix, dataStateAt string) (string, error) poolName := m.config.Pool.Name if poolSuffix != "" { - poolName += "/" + poolSuffix + poolName = util.GetPoolName(m.config.Pool.Name, poolSuffix) } originalDSA := dataStateAt diff --git a/engine/internal/retrieval/retrieval.go b/engine/internal/retrieval/retrieval.go index b6824f2a..6992d399 100644 --- a/engine/internal/retrieval/retrieval.go +++ b/engine/internal/retrieval/retrieval.go @@ -51,6 +51,8 @@ const ( pendingFilename = "pending.retrieval" ) +var errNoJobs = errors.New("no jobs to snapshot pool data") + type jobGroup string // Retrieval describes a data retrieval. @@ -337,7 +339,7 @@ func (r *Retrieval) run(ctx context.Context, fsm pool.FSManager) (err error) { r.State.cleanAlerts() } - if err := r.SnapshotData(ctx, poolName); err != nil { + if err := r.SnapshotData(ctx, poolName); err != nil && err != errNoJobs { return err } @@ -423,8 +425,8 @@ func (r *Retrieval) SnapshotData(ctx context.Context, poolName string) error { } if len(jobs) == 0 { - log.Dbg("no jobs to snapshot pool data:", fsm.Pool()) - return nil + log.Dbg(errNoJobs, fsm.Pool()) + return errNoJobs } log.Dbg("Taking a snapshot on the pool: ", fsm.Pool()) @@ -653,7 +655,7 @@ func preparePoolToRefresh(poolToUpdate pool.FSManager) error { for _, snapshotEntry := range snapshots { if err := poolToUpdate.DestroySnapshot(snapshotEntry.ID); err != nil { - return errors.Wrap(err, "failed to destroy the existing snapshot") + return errors.Wrap(err, "failed to destroy existing snapshot") } } diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 33c78d4a..91c9d504 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "strconv" + "strings" "time" "github.com/gorilla/mux" @@ -16,6 +17,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/estimator" "gitlab.com/postgres-ai/database-lab/v3/internal/observer" + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/pool" "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/activity" "gitlab.com/postgres-ai/database-lab/v3/internal/srv/api" wsPackage "gitlab.com/postgres-ai/database-lab/v3/internal/srv/ws" @@ -111,6 +113,104 @@ func (s *Server) getSnapshots(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) createSnapshot(w http.ResponseWriter, r *http.Request) { + var poolName string + + if r.Body != http.NoBody { + var createRequest types.SnapshotCreateRequest + if err := api.ReadJSON(r, &createRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + poolName = createRequest.PoolName + } + + if poolName == "" { + firstFSM := s.pm.First() + + if firstFSM == nil || firstFSM.Pool() == nil { + api.SendBadRequestError(w, r, pool.ErrNoPools.Error()) + return + } + + poolName = firstFSM.Pool().Name + } + + if err := s.Retrieval.SnapshotData(context.Background(), poolName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + fsManager, err := s.pm.GetFSManager(poolName) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + fsManager.RefreshSnapshotList() + + snapshotList := fsManager.SnapshotList() + + if len(snapshotList) == 0 { + api.SendBadRequestError(w, r, "No snapshots at pool: "+poolName) + return + } + + latestSnapshot := snapshotList[0] + + if err := api.WriteJSON(w, http.StatusOK, latestSnapshot); err != nil { + api.SendError(w, r, err) + return + } +} + +func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { + var destroyRequest types.SnapshotDestroyRequest + if err := api.ReadJSON(r, &destroyRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + const snapshotParts = 2 + + parts := strings.Split(destroyRequest.SnapshotID, "@") + if len(parts) != snapshotParts { + api.SendBadRequestError(w, r, fmt.Sprintf("invalid snapshot name given: %s", destroyRequest.SnapshotID)) + return + } + + rootParts := strings.Split(parts[0], "/") + if len(rootParts) < 1 { + api.SendBadRequestError(w, r, fmt.Sprintf("invalid root part of snapshot name given: %s", destroyRequest.SnapshotID)) + return + } + + poolName := rootParts[0] + + fsm, err := s.pm.GetFSManager(poolName) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err = fsm.DestroySnapshot(destroyRequest.SnapshotID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + log.Dbg(fmt.Sprintf("Snapshot %s has been deleted", destroyRequest.SnapshotID)) + + if err := api.WriteJSON(w, http.StatusOK, ""); err != nil { + api.SendError(w, r, err) + return + } + + if err := s.Cloning.ReloadSnapshots(); err != nil { + log.Dbg("Failed to reload snapshots", err.Error()) + } +} + func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { var cloneRequest *types.CloneCreateRequest if err := api.ReadJSON(r, &cloneRequest); err != nil { diff --git a/engine/internal/srv/server.go b/engine/internal/srv/server.go index 53efb549..04644add 100644 --- a/engine/internal/srv/server.go +++ b/engine/internal/srv/server.go @@ -196,6 +196,8 @@ func (s *Server) InitHandlers() { r.HandleFunc("/status", authMW.Authorized(s.getInstanceStatus)).Methods(http.MethodGet) r.HandleFunc("/snapshots", authMW.Authorized(s.getSnapshots)).Methods(http.MethodGet) + r.HandleFunc("/snapshot/create", authMW.Authorized(s.createSnapshot)).Methods(http.MethodPost) + r.HandleFunc("/snapshot/delete", authMW.Authorized(s.deleteSnapshot)).Methods(http.MethodPost) r.HandleFunc("/clone", authMW.Authorized(s.createClone)).Methods(http.MethodPost) r.HandleFunc("/clone/{id}", authMW.Authorized(s.destroyClone)).Methods(http.MethodDelete) r.HandleFunc("/clone/{id}", authMW.Authorized(s.patchClone)).Methods(http.MethodPatch) diff --git a/engine/pkg/client/dblabapi/snapshot.go b/engine/pkg/client/dblabapi/snapshot.go index 8e2a5cfd..b6afedee 100644 --- a/engine/pkg/client/dblabapi/snapshot.go +++ b/engine/pkg/client/dblabapi/snapshot.go @@ -5,6 +5,7 @@ package dblabapi import ( + "bytes" "context" "encoding/json" "io" @@ -12,6 +13,7 @@ import ( "github.com/pkg/errors" + "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) @@ -49,3 +51,57 @@ func (c *Client) ListSnapshotsRaw(ctx context.Context) (io.ReadCloser, error) { return response.Body, nil } + +// CreateSnapshot creates a new snapshot. +func (c *Client) CreateSnapshot(ctx context.Context, snapshotRequest types.SnapshotCreateRequest) (*models.Snapshot, error) { + u := c.URL("/snapshot/create") + + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(snapshotRequest); err != nil { + return nil, errors.Wrap(err, "failed to encode SnapshotCreateRequest") + } + + request, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return nil, errors.Wrap(err, "failed to make a request") + } + + response, err := c.Do(ctx, request) + if err != nil { + return nil, errors.Wrap(err, "failed to get response") + } + + defer func() { _ = response.Body.Close() }() + + var snapshot *models.Snapshot + + if err := json.NewDecoder(response.Body).Decode(&snapshot); err != nil { + return nil, errors.Wrap(err, "failed to get response") + } + + return snapshot, nil +} + +// DeleteSnapshot deletes snapshot. +func (c *Client) DeleteSnapshot(ctx context.Context, snapshotRequest types.SnapshotDestroyRequest) error { + u := c.URL("/snapshot/delete") + + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(snapshotRequest); err != nil { + return errors.Wrap(err, "failed to encode snapshotDestroyRequest") + } + + request, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return errors.Wrap(err, "failed to make a request") + } + + response, err := c.Do(ctx, request) + if err != nil { + return errors.Wrap(err, "failed to get response") + } + + defer func() { _ = response.Body.Close() }() + + return nil +} diff --git a/engine/pkg/client/dblabapi/types/clone.go b/engine/pkg/client/dblabapi/types/clone.go index c9b9e7b4..0b25f55f 100644 --- a/engine/pkg/client/dblabapi/types/clone.go +++ b/engine/pkg/client/dblabapi/types/clone.go @@ -37,3 +37,13 @@ type ResetCloneRequest struct { SnapshotID string `json:"snapshotID"` Latest bool `json:"latest"` } + +// SnapshotCreateRequest describes params for a creating snapshot request. +type SnapshotCreateRequest struct { + PoolName string `json:"poolName"` +} + +// SnapshotDestroyRequest describes params for a destroying snapshot request. +type SnapshotDestroyRequest struct { + SnapshotID string `json:"snapshotID"` +} diff --git a/engine/pkg/util/clones.go b/engine/pkg/util/clones.go index 4e868651..18048b77 100644 --- a/engine/pkg/util/clones.go +++ b/engine/pkg/util/clones.go @@ -13,12 +13,17 @@ const ( ClonePrefix = "dblab_clone_" ) -// GetCloneName returns a clone name. +// GetCloneName returns clone name. func GetCloneName(port uint) string { return ClonePrefix + strconv.FormatUint(uint64(port), 10) } -// GetCloneNameStr returns a clone name. +// GetCloneNameStr returns clone name. func GetCloneNameStr(port string) string { return ClonePrefix + port } + +// GetPoolName returns pool name. +func GetPoolName(basePool, snapshotSuffix string) string { + return basePool + "/" + snapshotSuffix +} diff --git a/engine/test/1.synthetic.sh b/engine/test/1.synthetic.sh index 1d50e07d..6d54ff2b 100644 --- a/engine/test/1.synthetic.sh +++ b/engine/test/1.synthetic.sh @@ -162,9 +162,21 @@ dblab init \ dblab instance status # Check the snapshot list - if [[ $(dblab snapshot list | jq length) -eq 0 ]] ; then - echo "No snapshot found" && exit 1 - fi +if [[ $(dblab snapshot list | jq length) -eq 0 ]] ; then + echo "No snapshot found" && exit 1 +fi + +dblab snapshot delete "$(dblab snapshot list | jq -r .[0].id)" + +if [[ $(dblab snapshot list | jq length) -ne 0 ]] ; then + echo "Snapshot has not been deleted" && exit 1 +fi + +dblab snapshot create + +if [[ $(dblab snapshot list | jq length) -eq 0 ]] ; then + echo "Snapshot has not been created" && exit 1 +fi ## Create a clone dblab clone create \ From 14d04e6ff654b3d20ab8682ae260e7cd7364ec6c Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Wed, 28 Dec 2022 12:03:41 +0000 Subject: [PATCH 002/114] [DLE 4.0] feat(engine): DLE Branching prototype (#441) * [x] create branch * [x] list branches * [x] switch branch * [x] create clone on branch * [x] snapshot clone * [x] branch log * [x] delete branch * [x] output format (json / plain text) * [x] delete snapshot * [x] e2e tests (CLI) --- engine/Makefile | 4 +- engine/cmd/cli/commands/branch/actions.go | 297 ++++++++++++ .../cmd/cli/commands/branch/command_list.go | 54 +++ engine/cmd/cli/commands/client.go | 1 + engine/cmd/cli/commands/clone/actions.go | 37 ++ engine/cmd/cli/commands/clone/command_list.go | 4 + engine/cmd/cli/commands/config/environment.go | 20 + engine/cmd/cli/commands/config/file.go | 27 +- engine/cmd/cli/commands/global/actions.go | 3 +- engine/cmd/cli/commands/snapshot/actions.go | 50 +- .../cmd/cli/commands/snapshot/command_list.go | 4 + engine/cmd/cli/main.go | 15 + engine/internal/cloning/base.go | 31 ++ engine/internal/provision/mode_local_test.go | 73 +++ engine/internal/provision/pool/manager.go | 24 + engine/internal/provision/resources/pool.go | 13 + .../provision/thinclones/lvm/lvmanager.go | 127 +++++ .../internal/provision/thinclones/manager.go | 6 + .../provision/thinclones/zfs/branching.go | 456 ++++++++++++++++++ .../internal/provision/thinclones/zfs/zfs.go | 73 ++- .../internal/retrieval/dbmarker/dbmarker.go | 286 ++++++++++- .../engine/postgres/snapshot/physical.go | 7 +- engine/internal/retrieval/retrieval.go | 4 + engine/internal/runci/config.go | 8 +- engine/internal/srv/branch.go | 450 +++++++++++++++++ engine/internal/srv/routes.go | 87 ++++ engine/internal/srv/server.go | 9 + engine/pkg/client/dblabapi/branch.go | 171 +++++++ engine/pkg/client/dblabapi/client.go | 4 +- engine/pkg/client/dblabapi/snapshot.go | 22 +- engine/pkg/client/dblabapi/types/clone.go | 38 +- engine/pkg/models/branch.go | 39 ++ engine/pkg/models/clone.go | 1 + engine/pkg/models/status.go | 8 + engine/test/1.synthetic.sh | 34 ++ engine/test/2.logical_generic.sh | 3 + engine/test/4.physical_basebackup.sh | 3 + 37 files changed, 2435 insertions(+), 58 deletions(-) create mode 100644 engine/cmd/cli/commands/branch/actions.go create mode 100644 engine/cmd/cli/commands/branch/command_list.go create mode 100644 engine/internal/provision/thinclones/zfs/branching.go create mode 100644 engine/internal/srv/branch.go create mode 100644 engine/pkg/client/dblabapi/branch.go create mode 100644 engine/pkg/models/branch.go diff --git a/engine/Makefile b/engine/Makefile index 5e01b831..e5afa34e 100644 --- a/engine/Makefile +++ b/engine/Makefile @@ -23,8 +23,8 @@ LDFLAGS = -ldflags "-s -w \ GOBUILD = GO111MODULE=on CGO_ENABLED=0 GOARCH=${GOARCH} go build ${LDFLAGS} GOTEST = GO111MODULE=on go test -race -CLIENT_PLATFORMS=darwin linux freebsd windows -ARCHITECTURES=amd64 +CLIENT_PLATFORMS=darwin linux # freebsd windows +ARCHITECTURES=amd64 arm64 help: ## Display the help message @echo "Please use \`make \` where is one of:" diff --git a/engine/cmd/cli/commands/branch/actions.go b/engine/cmd/cli/commands/branch/actions.go new file mode 100644 index 00000000..ec7585ee --- /dev/null +++ b/engine/cmd/cli/commands/branch/actions.go @@ -0,0 +1,297 @@ +/* +2022 © Postgres.ai +*/ + +// Package branch provides commands to manage DLE branches. +package branch + +import ( + "errors" + "fmt" + "os" + "strings" + "text/template" + "time" + + "github.com/urfave/cli/v2" + + "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" + "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands/config" + "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util" +) + +const ( + defaultBranch = "main" + + snapshotTemplate = `{{range .}}snapshot {{.ID}} {{.Branch | formatBranch}} +DataStateAt: {{.DataStateAt | formatDSA }}{{if and (ne .Message "-") (ne .Message "")}} + {{.Message}}{{end}} + +{{end}}` +) + +// Create a new template and parse the letter into it. +var logTemplate = template.Must(template.New("branchLog").Funcs( + template.FuncMap{ + "formatDSA": func(dsa string) string { + p, err := time.Parse(util.DataStateAtFormat, dsa) + if err != nil { + return "" + } + return p.Format(time.RFC1123Z) + }, + "formatBranch": func(dsa []string) string { + if len(dsa) == 0 { + return "" + } + + return "(HEAD -> " + strings.Join(dsa, ", ") + ")" + }, + }).Parse(snapshotTemplate)) + +func switchLocalContext(branchName string) error { + dirname, err := config.GetDirname() + if err != nil { + return err + } + + filename := config.BuildFileName(dirname) + + cfg, err := config.Load(filename) + if err != nil && !os.IsNotExist(err) { + return err + } + + if len(cfg.Environments) == 0 { + return errors.New("no environments found. Use `dblab init` to create a new environment before branching") + } + + currentEnv := cfg.Environments[cfg.CurrentEnvironment] + currentEnv.Branching.CurrentBranch = branchName + + cfg.Environments[cfg.CurrentEnvironment] = currentEnv + + if err := config.SaveConfig(filename, cfg); err != nil { + return commands.ToActionError(err) + } + + return err +} + +func list(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + // Create a new branch. + if branchName := cliCtx.Args().First(); branchName != "" { + return create(cliCtx) + } + + // Delete branch. + if branchName := cliCtx.String("delete"); branchName != "" { + return deleteBranch(cliCtx) + } + + // List branches. + branches, err := dblabClient.ListBranches(cliCtx.Context) + if err != nil { + return err + } + + if len(branches) == 0 { + _, err = fmt.Fprintln(cliCtx.App.Writer, "No branches found") + return err + } + + formatted := formatBranchList(cliCtx, branches) + + _, err = fmt.Fprint(cliCtx.App.Writer, formatted) + + return err +} + +func formatBranchList(cliCtx *cli.Context, branches []string) string { + baseBranch := getBaseBranch(cliCtx) + + s := strings.Builder{} + + for _, branch := range branches { + var prefixStar = " " + + if baseBranch == branch { + prefixStar = "* " + branch = "\033[1;32m" + branch + "\033[0m" + } + + s.WriteString(prefixStar + branch + "\n") + } + + return s.String() +} + +func switchBranch(cliCtx *cli.Context) error { + branchName := cliCtx.Args().First() + + if branchName == "" { + return errors.New("branch name must not be empty") + } + + if err := isBranchExist(cliCtx, branchName); err != nil { + return fmt.Errorf("cannot confirm if branch exists: %w", err) + } + + if err := switchLocalContext(branchName); err != nil { + return commands.ToActionError(err) + } + + _, err := fmt.Fprintf(cliCtx.App.Writer, "Switched to branch '%s'\n", branchName) + + return err +} + +func isBranchExist(cliCtx *cli.Context, branchName string) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + branches, err := dblabClient.ListBranches(cliCtx.Context) + if err != nil { + return err + } + + for _, branch := range branches { + if branch == branchName { + return nil + } + } + + return fmt.Errorf("invalid reference: %s", branchName) +} + +func create(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + branchName := cliCtx.Args().First() + + branchRequest := types.BranchCreateRequest{ + BranchName: branchName, + BaseBranch: getBaseBranch(cliCtx), + } + + branch, err := dblabClient.CreateBranch(cliCtx.Context, branchRequest) + if err != nil { + return err + } + + if err := switchLocalContext(branchName); err != nil { + return commands.ToActionError(err) + } + + _, err = fmt.Fprintf(cliCtx.App.Writer, "Switched to new branch '%s'\n", branch.Name) + + return err +} + +func getBaseBranch(cliCtx *cli.Context) string { + baseBranch := cliCtx.String(commands.CurrentBranch) + + if baseBranch == "" { + baseBranch = defaultBranch + } + + return baseBranch +} + +func deleteBranch(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + branchName := cliCtx.String("delete") + + if err = dblabClient.DeleteBranch(cliCtx.Context, types.BranchDeleteRequest{ + BranchName: branchName, + }); err != nil { + return err + } + + if err := switchLocalContext(defaultBranch); err != nil { + return commands.ToActionError(err) + } + + _, err = fmt.Fprintf(cliCtx.App.Writer, "Deleted branch '%s'\n", branchName) + + return err +} + +func commit(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + cloneID := cliCtx.String("clone-id") + message := cliCtx.String("message") + + snapshotRequest := types.SnapshotCloneCreateRequest{ + CloneID: cloneID, + Message: message, + } + + snapshot, err := dblabClient.CreateSnapshotForBranch(cliCtx.Context, snapshotRequest) + if err != nil { + return err + } + + _, err = fmt.Fprintf(cliCtx.App.Writer, "Created new snapshot '%s'\n", snapshot.SnapshotID) + + return err +} + +func history(cliCtx *cli.Context) error { + dblabClient, err := commands.ClientByCLIContext(cliCtx) + if err != nil { + return err + } + + branchName := cliCtx.Args().First() + + if branchName == "" { + branchName = getBaseBranch(cliCtx) + } + + logRequest := types.LogRequest{BranchName: branchName} + + snapshots, err := dblabClient.BranchLog(cliCtx.Context, logRequest) + if err != nil { + return err + } + + formattedLog, err := formatSnapshotLog(snapshots) + if err != nil { + return err + } + + _, err = fmt.Fprint(cliCtx.App.Writer, formattedLog) + + return err +} + +func formatSnapshotLog(snapshots []models.SnapshotDetails) (string, error) { + sb := &strings.Builder{} + + if err := logTemplate.Execute(sb, snapshots); err != nil { + return "", fmt.Errorf("executing template: %w", err) + } + + return sb.String(), nil +} diff --git a/engine/cmd/cli/commands/branch/command_list.go b/engine/cmd/cli/commands/branch/command_list.go new file mode 100644 index 00000000..ea8b65ba --- /dev/null +++ b/engine/cmd/cli/commands/branch/command_list.go @@ -0,0 +1,54 @@ +/* +2020 © Postgres.ai +*/ + +package branch + +import ( + "github.com/urfave/cli/v2" +) + +// List provides commands for getting started. +func List() []*cli.Command { + return []*cli.Command{ + { + Name: "branch", + Usage: "list, create, or delete branches", + Action: list, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "delete", + Aliases: []string{"d"}, + }, + }, + ArgsUsage: "BRANCH_NAME", + }, + { + Name: "switch", + Usage: "switch to a specified branch", + Action: switchBranch, + }, + { + Name: "commit", + Usage: "create a new snapshot containing the current state of data and the given log message describing the changes", + Action: commit, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "clone-id", + Usage: "clone ID", + }, + &cli.StringFlag{ + Name: "message", + Usage: "use the given message as the commit message", + Aliases: []string{"m"}, + }, + }, + }, + { + Name: "log", + Usage: "shows the snapshot logs", + Action: history, + ArgsUsage: "BRANCH_NAME", + }, + } +} diff --git a/engine/cmd/cli/commands/client.go b/engine/cmd/cli/commands/client.go index cde42073..d4e45f2d 100644 --- a/engine/cmd/cli/commands/client.go +++ b/engine/cmd/cli/commands/client.go @@ -24,6 +24,7 @@ const ( FwLocalPortKey = "forwarding-local-port" IdentityFileKey = "identity-file" TZKey = "tz" + CurrentBranch = "current-branch" ) // ClientByCLIContext creates a new Database Lab API client. diff --git a/engine/cmd/cli/commands/clone/actions.go b/engine/cmd/cli/commands/clone/actions.go index 81d9ccbf..026b0cac 100644 --- a/engine/cmd/cli/commands/clone/actions.go +++ b/engine/cmd/cli/commands/clone/actions.go @@ -105,6 +105,7 @@ func create(cliCtx *cli.Context) error { Restricted: cliCtx.Bool("restricted"), DBName: cliCtx.String("db-name"), }, + Branch: cliCtx.String("branch"), } if cliCtx.IsSet("snapshot-id") { @@ -125,6 +126,11 @@ func create(cliCtx *cli.Context) error { return err } + if clone.Branch != "" { + _, err = fmt.Fprintln(cliCtx.App.Writer, buildCloneOutput(clone)) + return err + } + viewClone, err := convertCloneView(clone) if err != nil { return err @@ -140,6 +146,37 @@ func create(cliCtx *cli.Context) error { return err } +func buildCloneOutput(clone *models.Clone) string { + const ( + outputAlign = 2 + id = "ID" + branch = "Branch" + snapshot = "Snapshot" + connectionString = "Connection string" + maxNameLen = len(connectionString) + ) + + s := strings.Builder{} + + s.WriteString(id + ":" + strings.Repeat(" ", maxNameLen-len(id)+outputAlign)) + s.WriteString(clone.ID) + s.WriteString("\n") + + s.WriteString(branch + ":" + strings.Repeat(" ", maxNameLen-len(branch)+outputAlign)) + s.WriteString(clone.Branch) + s.WriteString("\n") + + s.WriteString(snapshot + ":" + strings.Repeat(" ", maxNameLen-len(snapshot)+outputAlign)) + s.WriteString(clone.Snapshot.ID) + s.WriteString("\n") + + s.WriteString(connectionString + ":" + strings.Repeat(" ", maxNameLen-len(connectionString)+outputAlign)) + s.WriteString(clone.DB.ConnStr) + s.WriteString("\n") + + return s.String() +} + // update runs a request to update an existing clone. func update(cliCtx *cli.Context) error { dblabClient, err := commands.ClientByCLIContext(cliCtx) diff --git a/engine/cmd/cli/commands/clone/command_list.go b/engine/cmd/cli/commands/clone/command_list.go index e3269aa6..01b393d4 100644 --- a/engine/cmd/cli/commands/clone/command_list.go +++ b/engine/cmd/cli/commands/clone/command_list.go @@ -64,6 +64,10 @@ func CommandList() []*cli.Command { Name: "snapshot-id", Usage: "snapshot ID (optional)", }, + &cli.StringFlag{ + Name: "branch", + Usage: "branch name (optional)", + }, &cli.BoolFlag{ Name: "protected", Usage: "mark instance as protected from deletion", diff --git a/engine/cmd/cli/commands/config/environment.go b/engine/cmd/cli/commands/config/environment.go index 4e6146e6..0130a604 100644 --- a/engine/cmd/cli/commands/config/environment.go +++ b/engine/cmd/cli/commands/config/environment.go @@ -11,6 +11,9 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" ) +// DefaultBranch defines the name of data branch. +const DefaultBranch = "main" + // CLIConfig defines a format of CLI configuration. type CLIConfig struct { CurrentEnvironment string `yaml:"current_environment" json:"current_environment"` @@ -26,6 +29,7 @@ type Environment struct { Insecure bool `yaml:"insecure" json:"insecure"` RequestTimeout Duration `yaml:"request_timeout,omitempty" json:"request_timeout,omitempty"` Forwarding Forwarding `yaml:"forwarding" json:"forwarding"` + Branching Branching `yaml:"branching" json:"branching"` } // Forwarding defines configuration for port forwarding. @@ -40,6 +44,11 @@ type Settings struct { TZ string `yaml:"tz" json:"tz"` } +// Branching defines branching context. +type Branching struct { + CurrentBranch string `yaml:"current_branch" json:"current_branch"` +} + // AddEnvironmentToConfig adds a new environment to CLIConfig. func AddEnvironmentToConfig(c *cli.Context, cfg *CLIConfig, environmentID string) error { if environmentID == "" { @@ -60,6 +69,13 @@ func AddEnvironmentToConfig(c *cli.Context, cfg *CLIConfig, environmentID string LocalPort: c.String(commands.FwLocalPortKey), IdentityFile: c.String(commands.IdentityFileKey), }, + Branching: Branching{ + CurrentBranch: c.String(commands.CurrentBranch), + }, + } + + if env.Branching.CurrentBranch == "" { + env.Branching.CurrentBranch = DefaultBranch } if cfg.Environments == nil { @@ -117,6 +133,10 @@ func updateEnvironmentInConfig(c *cli.Context, cfg *CLIConfig, environmentID str newEnvironment.Forwarding.IdentityFile = c.String(commands.IdentityFileKey) } + if c.IsSet(commands.CurrentBranch) { + newEnvironment.Branching.CurrentBranch = c.String(commands.CurrentBranch) + } + if newEnvironment == environment { return errors.New("config unchanged. Set different option values to update.") // nolint } diff --git a/engine/cmd/cli/commands/config/file.go b/engine/cmd/cli/commands/config/file.go index 557d45b1..24de4b96 100644 --- a/engine/cmd/cli/commands/config/file.go +++ b/engine/cmd/cli/commands/config/file.go @@ -8,6 +8,7 @@ import ( "os" "os/user" "path" + "path/filepath" "gopkg.in/yaml.v2" ) @@ -16,6 +17,12 @@ const ( dblabDir = ".dblab" configPath = "cli" configFilename = "cli.yml" + envs = "envs" +) + +const ( + branches = "branches" + snapshots = "snapshots" ) // GetDirname returns the CLI config path located in the current user's home directory. @@ -40,19 +47,35 @@ func GetFilename() (string, error) { return BuildFileName(dirname), nil } +// BuildBranchPath builds a path to the branch dir. +func BuildBranchPath(dirname string) string { + return filepath.Join(dirname, envs, branches) +} + +// BuildSnapshotPath builds a path to the snapshot dir. +func BuildSnapshotPath(dirname string) string { + return filepath.Join(dirname, envs, snapshots) +} + // BuildFileName builds a config filename. func BuildFileName(dirname string) string { return path.Join(dirname, configFilename) } +// BuildEnvsDirName builds envs directory name. +func BuildEnvsDirName(dirname string) string { + return path.Join(dirname, envs) +} + // Load loads a CLI config by a provided filename. func Load(filename string) (*CLIConfig, error) { + cfg := &CLIConfig{} + configData, err := os.ReadFile(filename) if err != nil { - return nil, err + return cfg, err } - cfg := &CLIConfig{} if err := yaml.Unmarshal(configData, cfg); err != nil { return nil, err } diff --git a/engine/cmd/cli/commands/global/actions.go b/engine/cmd/cli/commands/global/actions.go index cb267215..5c9b63fb 100644 --- a/engine/cmd/cli/commands/global/actions.go +++ b/engine/cmd/cli/commands/global/actions.go @@ -10,7 +10,6 @@ import ( "net/url" "os" - "github.com/pkg/errors" "github.com/urfave/cli/v2" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" @@ -25,7 +24,7 @@ func initCLI(c *cli.Context) error { } if err := os.MkdirAll(dirname, 0755); err != nil { - return errors.Wrapf(err, "Cannot create config directory %s", dirname) + return fmt.Errorf("cannot create config directory %s: %w", dirname, err) } filename := config.BuildFileName(dirname) diff --git a/engine/cmd/cli/commands/snapshot/actions.go b/engine/cmd/cli/commands/snapshot/actions.go index a597b5c2..45f2221b 100644 --- a/engine/cmd/cli/commands/snapshot/actions.go +++ b/engine/cmd/cli/commands/snapshot/actions.go @@ -7,11 +7,13 @@ package snapshot import ( "encoding/json" + "errors" "fmt" "github.com/urfave/cli/v2" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" + "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi" "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) @@ -53,16 +55,16 @@ func create(cliCtx *cli.Context) error { return err } - snapshotRequest := types.SnapshotCreateRequest{ - PoolName: cliCtx.String("pool"), - } + cloneID := cliCtx.String("clone-id") - snapshot, err := dblabClient.CreateSnapshot(cliCtx.Context, snapshotRequest) - if err != nil { - return err + var commandResponse []byte + + if cloneID != "" { + commandResponse, err = createFromClone(cliCtx, dblabClient) + } else { + commandResponse, err = createOnPool(cliCtx, dblabClient) } - commandResponse, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return err } @@ -72,6 +74,36 @@ func create(cliCtx *cli.Context) error { return err } +// createOnPool runs a request to create a new snapshot. +func createOnPool(cliCtx *cli.Context, client *dblabapi.Client) ([]byte, error) { + snapshotRequest := types.SnapshotCreateRequest{ + PoolName: cliCtx.String("pool"), + } + + snapshot, err := client.CreateSnapshot(cliCtx.Context, snapshotRequest) + if err != nil { + return nil, err + } + + return json.MarshalIndent(snapshot, "", " ") +} + +// createFromClone runs a request to create a new snapshot from clone. +func createFromClone(cliCtx *cli.Context, client *dblabapi.Client) ([]byte, error) { + cloneID := cliCtx.String("clone-id") + + snapshotRequest := types.SnapshotCloneCreateRequest{ + CloneID: cloneID, + } + + snapshot, err := client.CreateSnapshotFromClone(cliCtx.Context, snapshotRequest) + if err != nil { + return nil, err + } + + return json.MarshalIndent(snapshot, "", " ") +} + // deleteSnapshot runs a request to delete existing snapshot. func deleteSnapshot(cliCtx *cli.Context) error { dblabClient, err := commands.ClientByCLIContext(cliCtx) @@ -86,10 +118,10 @@ func deleteSnapshot(cliCtx *cli.Context) error { } if err := dblabClient.DeleteSnapshot(cliCtx.Context, snapshotRequest); err != nil { - return err + return errors.Unwrap(err) } - _, err = fmt.Fprintf(cliCtx.App.Writer, "The snapshot has been successfully deleted: %s\n", snapshotID) + _, err = fmt.Fprintf(cliCtx.App.Writer, "Deleted snapshot '%s'\n", snapshotID) return err } diff --git a/engine/cmd/cli/commands/snapshot/command_list.go b/engine/cmd/cli/commands/snapshot/command_list.go index 0e167762..a25bf753 100644 --- a/engine/cmd/cli/commands/snapshot/command_list.go +++ b/engine/cmd/cli/commands/snapshot/command_list.go @@ -31,6 +31,10 @@ func CommandList() []*cli.Command { Name: "pool", Usage: "pool name", }, + &cli.StringFlag{ + Name: "clone-id", + Usage: "create a snapshot from existing clone", + }, }, }, { diff --git a/engine/cmd/cli/main.go b/engine/cmd/cli/main.go index 5d8d8b95..c05e3c92 100644 --- a/engine/cmd/cli/main.go +++ b/engine/cmd/cli/main.go @@ -9,6 +9,7 @@ import ( "github.com/urfave/cli/v2" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands" + "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands/branch" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands/clone" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands/config" "gitlab.com/postgres-ai/database-lab/v3/cmd/cli/commands/global" @@ -30,6 +31,9 @@ func main() { // Config commands. global.List(), + // Branching. + branch.List(), + // Database Lab API. clone.CommandList(), instance.CommandList(), @@ -80,6 +84,11 @@ func main() { Usage: "run in debug mode", EnvVars: []string{"DBLAB_CLI_DEBUG"}, }, + &cli.StringFlag{ + Name: "current-branch", + Usage: "current branch", + EnvVars: []string{"DBLAB_CLI_CURRENT_BRANCH"}, + }, }, EnableBashCompletion: true, } @@ -157,6 +166,12 @@ func loadEnvironmentParams(c *cli.Context) error { return err } } + + if env.Branching.CurrentBranch == "" { + if err := c.Set(commands.CurrentBranch, config.DefaultBranch); err != nil { + return err + } + } } return nil diff --git a/engine/internal/cloning/base.go b/engine/internal/cloning/base.go index 1b9995b4..9ad173b2 100644 --- a/engine/internal/cloning/base.go +++ b/engine/internal/cloning/base.go @@ -123,6 +123,16 @@ func (c *Base) cleanupInvalidClones() error { return nil } +// GetLatestSnapshot returns the latest snapshot. +func (c *Base) GetLatestSnapshot() (*models.Snapshot, error) { + snapshot, err := c.getLatestSnapshot() + if err != nil { + return nil, fmt.Errorf("failed to find the latest snapshot: %w", err) + } + + return snapshot, err +} + // CreateClone creates a new clone. func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clone, error) { cloneRequest.ID = strings.TrimSpace(cloneRequest.ID) @@ -157,6 +167,7 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon clone := &models.Clone{ ID: cloneRequest.ID, Snapshot: snapshot, + Branch: cloneRequest.Branch, Protected: cloneRequest.Protected, CreatedAt: models.NewLocalTime(createdAt), Status: models.Status{ @@ -388,6 +399,21 @@ func (c *Base) UpdateCloneStatus(cloneID string, status models.Status) error { return nil } +// UpdateCloneSnapshot updates clone snapshot. +func (c *Base) UpdateCloneSnapshot(cloneID string, snapshot *models.Snapshot) error { + c.cloneMutex.Lock() + defer c.cloneMutex.Unlock() + + w, ok := c.clones[cloneID] + if !ok { + return errors.Errorf("clone %q not found", cloneID) + } + + w.Clone.Snapshot = snapshot + + return nil +} + // ResetClone resets clone to chosen snapshot. func (c *Base) ResetClone(cloneID string, resetOptions types.ResetCloneRequest) error { w, ok := c.findWrapper(cloneID) @@ -490,6 +516,11 @@ func (c *Base) GetSnapshots() ([]models.Snapshot, error) { return c.getSnapshotList(), nil } +// GetSnapshotByID returns snapshot by ID. +func (c *Base) GetSnapshotByID(snapshotID string) (*models.Snapshot, error) { + return c.getSnapshotByID(snapshotID) +} + // ReloadSnapshots reloads snapshot list. func (c *Base) ReloadSnapshots() error { return c.fetchSnapshots() diff --git a/engine/internal/provision/mode_local_test.go b/engine/internal/provision/mode_local_test.go index ef01652b..33bbaee0 100644 --- a/engine/internal/provision/mode_local_test.go +++ b/engine/internal/provision/mode_local_test.go @@ -12,6 +12,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/provision/pool" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/resources" + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) @@ -103,6 +104,78 @@ func (m mockFSManager) Pool() *resources.Pool { return m.pool } +func (m mockFSManager) InitBranching() error { + return nil +} + +func (m mockFSManager) VerifyBranchMetadata() error { + return nil +} + +func (m mockFSManager) CreateBranch(_, _ string) error { + return nil +} + +func (m mockFSManager) Snapshot(_ string) error { + return nil +} + +func (m mockFSManager) Reset(_ string, _ thinclones.ResetOptions) error { + return nil +} + +func (m mockFSManager) ListBranches() (map[string]string, error) { + return nil, nil +} + +func (m mockFSManager) AddBranchProp(_, _ string) error { + return nil +} + +func (m mockFSManager) DeleteBranchProp(_, _ string) error { + return nil +} + +func (m mockFSManager) SetRelation(_, _ string) error { + return nil +} + +func (m mockFSManager) SetRoot(_, _ string) error { + return nil +} + +func (m mockFSManager) GetRepo() (*models.Repo, error) { + return nil, nil +} + +func (m mockFSManager) SetDSA(_, _ string) error { + return nil +} + +func (m mockFSManager) SetMessage(_, _ string) error { + return nil +} + +func (m mockFSManager) SetMountpoint(_, _ string) error { + return nil +} + +func (m mockFSManager) Rename(_, _ string) error { + return nil +} + +func (m mockFSManager) DeleteBranch(_ string) error { + return nil +} + +func (m mockFSManager) DeleteChildProp(_, _ string) error { + return nil +} + +func (m mockFSManager) DeleteRootProp(_, _ string) error { + return nil +} + func TestBuildPoolEntry(t *testing.T) { testCases := []struct { pool *resources.Pool diff --git a/engine/internal/provision/pool/manager.go b/engine/internal/provision/pool/manager.go index 74c41171..cda760da 100644 --- a/engine/internal/provision/pool/manager.go +++ b/engine/internal/provision/pool/manager.go @@ -13,6 +13,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/provision/resources" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/runners" + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones/lvm" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones/zfs" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" @@ -25,6 +26,7 @@ type FSManager interface { Snapshotter StateReporter Pooler + Branching } // Cloner describes methods of clone management. @@ -49,6 +51,28 @@ type Snapshotter interface { RefreshSnapshotList() } +// Branching describes methods for data branching. +type Branching interface { + InitBranching() error + VerifyBranchMetadata() error + CreateBranch(branchName, snapshotID string) error + ListBranches() (map[string]string, error) + GetRepo() (*models.Repo, error) + SetRelation(parent, snapshotName string) error + Snapshot(snapshotName string) error + SetMountpoint(path, branch string) error + Rename(oldName, branch string) error + AddBranchProp(branch, snapshotName string) error + DeleteBranchProp(branch, snapshotName string) error + DeleteChildProp(childSnapshot, snapshotName string) error + DeleteRootProp(branch, snapshotName string) error + DeleteBranch(branch string) error + SetRoot(branch, snapshotName string) error + SetDSA(dsa, snapshotName string) error + SetMessage(message, snapshotName string) error + Reset(snapshotID string, options thinclones.ResetOptions) error +} + // Pooler describes methods for Pool providing. type Pooler interface { Pool() *resources.Pool diff --git a/engine/internal/provision/resources/pool.go b/engine/internal/provision/resources/pool.go index 1fd5b28e..78664a7e 100644 --- a/engine/internal/provision/resources/pool.go +++ b/engine/internal/provision/resources/pool.go @@ -22,6 +22,9 @@ const ( RefreshingPool PoolStatus = "refreshing" // EmptyPool defines the status of an inactive pool. EmptyPool PoolStatus = "empty" + + // branchDir defines branch directory in the pool. + branchDir = "branch" ) // Pool describes a storage pool. @@ -84,6 +87,16 @@ func (p *Pool) SocketCloneDir(name string) string { return path.Join(p.SocketDir(), name) } +// BranchDir returns a path to the branch directory of the storage pool. +func (p *Pool) BranchDir() string { + return path.Join(p.MountDir, p.PoolDirName, branchDir) +} + +// BranchPath returns a path to the specific branch in the storage pool. +func (p *Pool) BranchPath(branchName string) string { + return path.Join(p.BranchDir(), branchName) +} + // Status gets the pool status. func (p *Pool) Status() PoolStatus { p.mu.RLock() diff --git a/engine/internal/provision/thinclones/lvm/lvmanager.go b/engine/internal/provision/thinclones/lvm/lvmanager.go index 35da7082..06eebe35 100644 --- a/engine/internal/provision/thinclones/lvm/lvmanager.go +++ b/engine/internal/provision/thinclones/lvm/lvmanager.go @@ -12,6 +12,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/provision/resources" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/runners" + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) @@ -140,3 +141,129 @@ func (m *LVManager) GetFilesystemState() (models.FileSystem, error) { // TODO(anatoly): Implement. return models.FileSystem{Mode: PoolMode}, nil } + +// InitBranching inits data branching. +func (m *LVManager) InitBranching() error { + log.Msg("InitBranching is not supported for LVM. Skip the operation") + + return nil +} + +// VerifyBranchMetadata checks snapshot metadata. +func (m *LVManager) VerifyBranchMetadata() error { + log.Msg("VerifyBranchMetadata is not supported for LVM. Skip the operation") + + return nil +} + +// CreateBranch clones data as a new branch. +func (m *LVManager) CreateBranch(_, _ string) error { + log.Msg("CreateBranch is not supported for LVM. Skip the operation") + + return nil +} + +// Snapshot takes a snapshot of the current data state. +func (m *LVManager) Snapshot(_ string) error { + log.Msg("Snapshot is not supported for LVM. Skip the operation") + + return nil +} + +// Reset rollbacks data to ZFS snapshot. +func (m *LVManager) Reset(_ string, _ thinclones.ResetOptions) error { + log.Msg("Reset is not supported for LVM. Skip the operation") + + return nil +} + +// ListBranches lists data pool branches. +func (m *LVManager) ListBranches() (map[string]string, error) { + log.Msg("ListBranches is not supported for LVM. Skip the operation") + + return nil, nil +} + +// AddBranchProp adds branch to snapshot property. +func (m *LVManager) AddBranchProp(_, _ string) error { + log.Msg("AddBranchProp is not supported for LVM. Skip the operation") + + return nil +} + +// DeleteBranchProp deletes branch from snapshot property. +func (m *LVManager) DeleteBranchProp(_, _ string) error { + log.Msg("DeleteBranchProp is not supported for LVM. Skip the operation") + + return nil +} + +// DeleteChildProp deletes child from snapshot property. +func (m *LVManager) DeleteChildProp(_, _ string) error { + log.Msg("DeleteChildProp is not supported for LVM. Skip the operation") + + return nil +} + +// DeleteRootProp deletes root from snapshot property. +func (m *LVManager) DeleteRootProp(_, _ string) error { + log.Msg("DeleteRootProp is not supported for LVM. Skip the operation") + + return nil +} + +// SetRelation sets relation between snapshots. +func (m *LVManager) SetRelation(_, _ string) error { + log.Msg("SetRelation is not supported for LVM. Skip the operation") + + return nil +} + +// SetRoot marks snapshot as a root of branch. +func (m *LVManager) SetRoot(_, _ string) error { + log.Msg("SetRoot is not supported for LVM. Skip the operation") + + return nil +} + +// GetRepo provides data repository details. +func (m *LVManager) GetRepo() (*models.Repo, error) { + log.Msg("GetRepo is not supported for LVM. Skip the operation") + + return nil, nil +} + +// SetDSA sets value of DataStateAt to snapshot. +func (m *LVManager) SetDSA(dsa, snapshotName string) error { + log.Msg("SetDSA is not supported for LVM. Skip the operation") + + return nil +} + +// SetMessage sets commit message to snapshot. +func (m *LVManager) SetMessage(message, snapshotName string) error { + log.Msg("SetMessage is not supported for LVM. Skip the operation") + + return nil +} + +// SetMountpoint sets clone mount point. +func (m *LVManager) SetMountpoint(_, _ string) error { + log.Msg("SetMountpoint is not supported for LVM. Skip the operation") + + return nil +} + +// Rename renames clone. +func (m *LVManager) Rename(_, _ string) error { + log.Msg("Rename is not supported for LVM. Skip the operation") + + return nil +} + +// DeleteBranch deletes branch. +func (m *LVManager) DeleteBranch(_ string) error { + log.Msg("DeleteBranch is not supported for LVM. Skip the operation") + + return nil +} diff --git a/engine/internal/provision/thinclones/manager.go b/engine/internal/provision/thinclones/manager.go index b830fad9..16bf2785 100644 --- a/engine/internal/provision/thinclones/manager.go +++ b/engine/internal/provision/thinclones/manager.go @@ -9,6 +9,12 @@ import ( "fmt" ) +// ResetOptions defines reset options. +type ResetOptions struct { + // -f + // -r +} + // SnapshotExistsError defines an error when snapshot already exists. type SnapshotExistsError struct { name string diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go new file mode 100644 index 00000000..26cd2103 --- /dev/null +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -0,0 +1,456 @@ +/* +2022 © Postgres.ai +*/ + +package zfs + +import ( + "bytes" + "encoding/base64" + "fmt" + "path/filepath" + "strings" + + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones" + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" +) + +const ( + branchProp = "dle:branch" + parentProp = "dle:parent" + childProp = "dle:child" + rootProp = "dle:root" + messageProp = "dle:message" + branchSep = "," + empty = "-" + defaultBranch = "main" +) + +// InitBranching inits data branching. +func (m *Manager) InitBranching() error { + branches, err := m.ListBranches() + if err != nil { + return err + } + + if len(branches) > 0 { + log.Dbg("data branching is already initialized") + + return nil + } + + snapshots := m.SnapshotList() + + numberSnapshots := len(snapshots) + + if numberSnapshots == 0 { + log.Dbg("no snapshots to init data branching") + return nil + } + + latest := snapshots[0] + + for i := numberSnapshots; i > 1; i-- { + if err := m.SetRelation(snapshots[i-1].ID, snapshots[i-2].ID); err != nil { + return fmt.Errorf("failed to set snapshot relations: %w", err) + } + } + + if err := m.AddBranchProp(defaultBranch, latest.ID); err != nil { + return fmt.Errorf("failed to add branch property: %w", err) + } + + log.Msg("data branching has been successfully initialized") + + return nil +} + +// VerifyBranchMetadata verifies data branching metadata. +func (m *Manager) VerifyBranchMetadata() error { + snapshots := m.SnapshotList() + + numberSnapshots := len(snapshots) + + if numberSnapshots == 0 { + log.Dbg("no snapshots to verify data branching") + return nil + } + + latest := snapshots[0] + + brName, err := m.getProperty(branchProp, latest.ID) + if err != nil { + log.Dbg("cannot find branch for snapshot", latest.ID, err.Error()) + } + + for i := numberSnapshots; i > 1; i-- { + if err := m.SetRelation(snapshots[i-1].ID, snapshots[i-2].ID); err != nil { + return fmt.Errorf("failed to set snapshot relations: %w", err) + } + + if brName == "" { + brName, err = m.getProperty(branchProp, snapshots[i-1].ID) + if err != nil { + log.Dbg("cannot find branch for snapshot", snapshots[i-1].ID, err.Error()) + } + } + } + + if brName == "" { + brName = defaultBranch + } + + if err := m.AddBranchProp(brName, latest.ID); err != nil { + return fmt.Errorf("failed to add branch property: %w", err) + } + + log.Msg("data branching has been verified") + + return nil +} + +// CreateBranch clones data as a new branch. +func (m *Manager) CreateBranch(branchName, snapshotID string) error { + branchPath := m.config.Pool.BranchPath(branchName) + + // zfs clone -p pool@snapshot_20221019094237 pool/branch/001-branch + cmd := []string{ + "zfs clone -p", snapshotID, branchPath, + } + + out, err := m.runner.Run(strings.Join(cmd, " ")) + if err != nil { + return fmt.Errorf("zfs clone error: %w. Out: %v", err, out) + } + + return nil +} + +// Snapshot takes a snapshot of the current data state. +func (m *Manager) Snapshot(snapshotName string) error { + cmd := []string{ + "zfs snapshot -r", snapshotName, + } + + out, err := m.runner.Run(strings.Join(cmd, " ")) + if err != nil { + return fmt.Errorf("zfs snapshot error: %w. Out: %v", err, out) + } + + return nil +} + +// Rename renames clone. +func (m *Manager) Rename(oldName, newName string) error { + cmd := []string{ + "zfs rename -p", oldName, newName, + } + + out, err := m.runner.Run(strings.Join(cmd, " ")) + if err != nil { + return fmt.Errorf("zfs renaming error: %w. Out: %v", err, out) + } + + return nil +} + +// SetMountpoint sets clone mount point. +func (m *Manager) SetMountpoint(path, name string) error { + cmd := []string{ + "zfs set", "mountpoint=" + path, name, + } + + out, err := m.runner.Run(strings.Join(cmd, " ")) + if err != nil { + return fmt.Errorf("zfs mountpoint error: %w. Out: %v", err, out) + } + + return nil +} + +// ListBranches lists data pool branches. +func (m *Manager) ListBranches() (map[string]string, error) { + cmd := fmt.Sprintf( + `zfs list -H -t snapshot -o %s,name | grep -v "^-" | cat`, branchProp, + ) + + out, err := m.runner.Run(cmd) + if err != nil { + return nil, fmt.Errorf("failed to list branches: %w. Out: %v", err, out) + } + + branches := make(map[string]string) + lines := strings.Split(strings.TrimSpace(out), "\n") + + const expectedColumns = 2 + + for _, line := range lines { + fields := strings.Fields(line) + + if len(fields) != expectedColumns { + continue + } + + if !strings.Contains(fields[0], branchSep) { + branches[fields[0]] = fields[1] + continue + } + + for _, branchName := range strings.Split(fields[0], branchSep) { + branches[branchName] = fields[1] + } + } + + return branches, nil +} + +var repoFields = []any{"name", parentProp, childProp, branchProp, rootProp, dataStateAtLabel, messageProp} + +// GetRepo provides repository details about snapshots and branches. +func (m *Manager) GetRepo() (*models.Repo, error) { + strFields := bytes.TrimRight(bytes.Repeat([]byte(`%s,`), len(repoFields)), ",") + + cmd := fmt.Sprintf( + `zfs list -H -t snapshot -o `+string(strFields), repoFields..., + ) + + out, err := m.runner.Run(cmd) + if err != nil { + return nil, fmt.Errorf("failed to list branches: %w. Out: %v", err, out) + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + + repo := models.NewRepo() + + for _, line := range lines { + fields := strings.Fields(line) + + if len(fields) != len(repoFields) { + log.Dbg(fmt.Sprintf("Skip invalid line: %#v\n", line)) + + continue + } + + snDetail := models.SnapshotDetails{ + ID: fields[0], + Parent: fields[1], + Child: unwindField(fields[2]), + Branch: unwindField(fields[3]), + Root: unwindField(fields[4]), + DataStateAt: strings.Trim(fields[5], empty), + Message: decodeCommitMessage(fields[6]), + } + + repo.Snapshots[fields[0]] = snDetail + + for _, sn := range snDetail.Branch { + if sn == "" { + continue + } + + repo.Branches[sn] = fields[0] + } + } + + return repo, nil +} + +func decodeCommitMessage(field string) string { + if field == "" || field == empty { + return field + } + + decodedString, err := base64.StdEncoding.DecodeString(field) + if err != nil { + log.Dbg(fmt.Sprintf("Unable to decode commit message: %#v\n", field)) + return field + } + + return string(decodedString) +} + +func unwindField(field string) []string { + trimValue := strings.Trim(field, empty) + + if len(trimValue) == 0 { + return nil + } + + if !strings.Contains(field, branchSep) { + return []string{trimValue} + } + + items := make([]string, 0) + for _, item := range strings.Split(field, branchSep) { + items = append(items, strings.Trim(item, empty)) + } + + return items +} + +// AddBranchProp adds branch to snapshot property. +func (m *Manager) AddBranchProp(branch, snapshotName string) error { + return m.addToSet(branchProp, snapshotName, branch) +} + +// DeleteBranchProp deletes branch from snapshot property. +func (m *Manager) DeleteBranchProp(branch, snapshotName string) error { + return m.deleteFromSet(branchProp, branch, snapshotName) +} + +// SetRelation sets up relation between two snapshots. +func (m *Manager) SetRelation(parent, snapshotName string) error { + if err := m.setParent(parent, snapshotName); err != nil { + return err + } + + if err := m.addChild(parent, snapshotName); err != nil { + return err + } + + return nil +} + +// DeleteChildProp deletes child from snapshot property. +func (m *Manager) DeleteChildProp(childSnapshot, snapshotName string) error { + return m.deleteFromSet(childProp, childSnapshot, snapshotName) +} + +// DeleteRootProp deletes root from snapshot property. +func (m *Manager) DeleteRootProp(branch, snapshotName string) error { + return m.deleteFromSet(rootProp, branch, snapshotName) +} + +func (m *Manager) setParent(parent, snapshotName string) error { + return m.setProperty(parentProp, parent, snapshotName) +} + +func (m *Manager) addChild(parent, snapshotName string) error { + return m.addToSet(childProp, parent, snapshotName) +} + +// SetRoot marks snapshot as a root of branch. +func (m *Manager) SetRoot(branch, snapshotName string) error { + return m.addToSet(rootProp, snapshotName, branch) +} + +// SetDSA sets value of DataStateAt to snapshot. +func (m *Manager) SetDSA(dsa, snapshotName string) error { + return m.setProperty(dataStateAtLabel, dsa, snapshotName) +} + +// SetMessage uses the given message as the commit message. +func (m *Manager) SetMessage(message, snapshotName string) error { + encodedMessage := base64.StdEncoding.EncodeToString([]byte(message)) + return m.setProperty(messageProp, encodedMessage, snapshotName) +} + +func (m *Manager) addToSet(property, snapshot, value string) error { + original, err := m.getProperty(property, snapshot) + if err != nil { + return err + } + + dirtyList := append(strings.Split(original, branchSep), value) + uniqueList := unique(dirtyList) + + return m.setProperty(property, strings.Join(uniqueList, branchSep), snapshot) +} + +// deleteFromSet deletes specific value from snapshot property. +func (m *Manager) deleteFromSet(prop, branch, snapshotName string) error { + propertyValue, err := m.getProperty(prop, snapshotName) + if err != nil { + return err + } + + originalList := strings.Split(propertyValue, branchSep) + resultList := make([]string, 0, len(originalList)-1) + + for _, item := range originalList { + if item != branch { + resultList = append(resultList, item) + } + } + + value := strings.Join(resultList, branchSep) + + if value == "" { + value = empty + } + + return m.setProperty(prop, value, snapshotName) +} + +func (m *Manager) getProperty(property, snapshotName string) (string, error) { + cmd := fmt.Sprintf("zfs get -H -o value %s %s", property, snapshotName) + + out, err := m.runner.Run(cmd) + if err != nil { + return "", fmt.Errorf("error when trying to get property: %w. Out: %v", err, out) + } + + value := strings.Trim(strings.TrimSpace(out), "-") + + return value, nil +} + +func (m *Manager) setProperty(property, value, snapshotName string) error { + if value == "" { + value = empty + } + + cmd := fmt.Sprintf("zfs set %s=%q %s", property, value, snapshotName) + + out, err := m.runner.Run(cmd) + if err != nil { + return fmt.Errorf("error when trying to set property: %w. Out: %v", err, out) + } + + return nil +} + +func unique(originalList []string) []string { + keys := make(map[string]struct{}, 0) + branchList := make([]string, 0, len(originalList)) + + for _, item := range originalList { + if _, ok := keys[item]; !ok { + if item == "" || item == "-" { + continue + } + + keys[item] = struct{}{} + + branchList = append(branchList, item) + } + } + + return branchList +} + +// Reset rollbacks data to ZFS snapshot. +func (m *Manager) Reset(snapshotID string, _ thinclones.ResetOptions) error { + // zfs rollback pool@snapshot_20221019094237 + cmd := fmt.Sprintf("zfs rollback %s", snapshotID) + + if out, err := m.runner.Run(cmd, true); err != nil { + return fmt.Errorf("failed to rollback a snapshot: %w. Out: %v", err, out) + } + + return nil +} + +// DeleteBranch deletes branch. +func (m *Manager) DeleteBranch(branch string) error { + branchName := filepath.Join(m.Pool().Name, branch) + cmd := fmt.Sprintf("zfs destroy -R %s", branchName) + + if out, err := m.runner.Run(cmd, true); err != nil { + return fmt.Errorf("failed to destroy branch: %w. Out: %v", err, out) + } + + return nil +} diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index 8742fc81..b0f6b50b 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -223,10 +223,14 @@ func (m *Manager) DestroyClone(cloneName string) error { // this function to delete clones used during the preparation // of baseline snapshots, we need to omit `-R`, to avoid // unexpected deletion of users' clones. - cmd := fmt.Sprintf("zfs destroy -R %s/%s", m.config.Pool.Name, cloneName) + cmd := fmt.Sprintf("zfs destroy %s/%s", m.config.Pool.Name, cloneName) if _, err = m.runner.Run(cmd); err != nil { - return errors.Wrap(err, "failed to run command") + if strings.Contains(cloneName, "clone_pre") { + return errors.Wrap(err, "failed to run command") + } + + log.Dbg(err) } return nil @@ -345,26 +349,69 @@ func getSnapshotName(pool, dataStateAt string) string { return fmt.Sprintf("%s@snapshot_%s", pool, dataStateAt) } -// RollbackSnapshot rollbacks ZFS snapshot. -func RollbackSnapshot(r runners.Runner, pool string, snapshot string) error { - cmd := fmt.Sprintf("zfs rollback -f -r %s", snapshot) +// DestroySnapshot destroys the snapshot. +func (m *Manager) DestroySnapshot(snapshotName string) error { + rel, err := m.detectBranching(snapshotName) + if err != nil { + return fmt.Errorf("failed to inspect snapshot properties: %w", err) + } + + cmd := fmt.Sprintf("zfs destroy %s", snapshotName) - if _, err := r.Run(cmd, true); err != nil { - return errors.Wrap(err, "failed to rollback a snapshot") + if _, err := m.runner.Run(cmd); err != nil { + return fmt.Errorf("failed to run command: %w", err) } + if rel != nil { + if err := m.moveBranchPointer(rel, snapshotName); err != nil { + return err + } + } + + m.removeSnapshotFromList(snapshotName) + return nil } -// DestroySnapshot destroys the snapshot. -func (m *Manager) DestroySnapshot(snapshotName string) error { - cmd := fmt.Sprintf("zfs destroy -R %s", snapshotName) +type snapshotRelation struct { + parent string + branch string +} - if _, err := m.runner.Run(cmd); err != nil { - return errors.Wrap(err, "failed to run command") +func (m *Manager) detectBranching(snapshotName string) (*snapshotRelation, error) { + cmd := fmt.Sprintf("zfs list -H -o dle:parent,dle:branch %s", snapshotName) + + out, err := m.runner.Run(cmd) + if err != nil { + return nil, errors.Wrap(err, "failed to run command") } - m.removeSnapshotFromList(snapshotName) + response := strings.Fields(out) + + const fieldsCounter = 2 + + if len(response) != fieldsCounter || response[0] == "-" || response[1] == "-" { + return nil, nil + } + + return &snapshotRelation{ + parent: response[0], + branch: response[1], + }, nil +} + +func (m *Manager) moveBranchPointer(rel *snapshotRelation, snapshotName string) error { + if rel == nil { + return nil + } + + if err := m.DeleteChildProp(snapshotName, rel.parent); err != nil { + return fmt.Errorf("failed to delete a child property from snapshot %s: %w", rel.parent, err) + } + + if err := m.AddBranchProp(rel.branch, rel.parent); err != nil { + return fmt.Errorf("failed to set a branch property to snapshot %s: %w", rel.parent, err) + } return nil } diff --git a/engine/internal/retrieval/dbmarker/dbmarker.go b/engine/internal/retrieval/dbmarker/dbmarker.go index dee938a7..b567b58e 100644 --- a/engine/internal/retrieval/dbmarker/dbmarker.go +++ b/engine/internal/retrieval/dbmarker/dbmarker.go @@ -6,13 +6,34 @@ package dbmarker import ( + "bytes" + "fmt" "os" "path" + "strings" "github.com/pkg/errors" "gopkg.in/yaml.v2" ) +const ( + configDir = ".dblab" + configFilename = "dbmarker" + + refsDir = "refs" + branchesDir = "branch" + snapshotsDir = "snapshot" + headFile = "HEAD" + logsFile = "logs" + mainBranch = "main" + + // LogicalDataType defines a logical data type. + LogicalDataType = "logical" + + // PhysicalDataType defines a physical data type. + PhysicalDataType = "physical" +) + // Marker marks database data depends on a retrieval process. type Marker struct { dataPath string @@ -31,16 +52,18 @@ type Config struct { DataType string `yaml:"dataType"` } -const ( - configDir = ".dblab" - configFilename = "dbmarker" - - // LogicalDataType defines a logical data type. - LogicalDataType = "logical" +// Head describes content of HEAD file. +type Head struct { + Ref string `yaml:"ref"` +} - // PhysicalDataType defines a physical data type. - PhysicalDataType = "physical" -) +// SnapshotInfo describes snapshot info. +type SnapshotInfo struct { + ID string + Parent string + CreatedAt string + StateAt string +} // Init inits DB marker for the data directory. func (m *Marker) initDBLabDirectory() error { @@ -58,7 +81,7 @@ func (m *Marker) CreateConfig() error { return errors.Wrap(err, "failed to init DBMarker") } - dbMarkerFile, err := os.OpenFile(m.buildFileName(), os.O_RDWR|os.O_CREATE, 0600) + dbMarkerFile, err := os.OpenFile(m.buildFileName(configFilename), os.O_RDWR|os.O_CREATE, 0600) if err != nil { return err } @@ -70,7 +93,7 @@ func (m *Marker) CreateConfig() error { // GetConfig provides a loaded DBMarker config. func (m *Marker) GetConfig() (*Config, error) { - configData, err := os.ReadFile(m.buildFileName()) + configData, err := os.ReadFile(m.buildFileName(configFilename)) if err != nil { return nil, err } @@ -95,14 +118,247 @@ func (m *Marker) SaveConfig(cfg *Config) error { return err } - if err := os.WriteFile(m.buildFileName(), configData, 0600); err != nil { + if err := os.WriteFile(m.buildFileName(configFilename), configData, 0600); err != nil { return err } return nil } -// buildFileName builds a DBMarker config filename. -func (m *Marker) buildFileName() string { - return path.Join(m.dataPath, configDir, configFilename) +// buildFileName builds a DBMarker filename. +func (m *Marker) buildFileName(filename string) string { + return path.Join(m.dataPath, configDir, filename) +} + +// InitBranching creates structures for data branching. +func (m *Marker) InitBranching() error { + branchesDir := m.buildBranchesPath() + if err := os.MkdirAll(branchesDir, 0755); err != nil { + return fmt.Errorf("cannot create branches directory %s: %w", branchesDir, err) + } + + snapshotsDir := m.buildSnapshotsPath() + if err := os.MkdirAll(snapshotsDir, 0755); err != nil { + return fmt.Errorf("cannot create snapshots directory %s: %w", snapshotsDir, err) + } + + f, err := os.Create(m.buildFileName(headFile)) + if err != nil { + return fmt.Errorf("cannot create HEAD file: %w", err) + } + + _ = f.Close() + + return nil +} + +// InitMainBranch creates a new main branch. +func (m *Marker) InitMainBranch(infos []SnapshotInfo) error { + var head Head + + mainDir := m.buildBranchName(mainBranch) + if err := os.MkdirAll(mainDir, 0755); err != nil { + return fmt.Errorf("cannot create branches directory %s: %w", mainDir, err) + } + + var bb bytes.Buffer + + for _, info := range infos { + if err := m.storeSnapshotInfo(info); err != nil { + return err + } + + head.Ref = buildSnapshotRef(info.ID) + log := strings.Join([]string{info.Parent, info.ID, info.CreatedAt, info.StateAt}, " ") + "\n" + bb.WriteString(log) + } + + if err := os.WriteFile(m.buildBranchArtifactPath(mainBranch, logsFile), bb.Bytes(), 0755); err != nil { + return fmt.Errorf("cannot store file with HEAD metadata: %w", err) + } + + headData, err := yaml.Marshal(head) + if err != nil { + return fmt.Errorf("cannot prepare HEAD metadata: %w", err) + } + + if err := os.WriteFile(m.buildFileName(headFile), headData, 0755); err != nil { + return fmt.Errorf("cannot store file with HEAD metadata: %w", err) + } + + if err := os.WriteFile(m.buildBranchArtifactPath(mainBranch, headFile), headData, 0755); err != nil { + return fmt.Errorf("cannot store file with HEAD metadata: %w", err) + } + + return nil +} + +func (m *Marker) storeSnapshotInfo(info SnapshotInfo) error { + snapshotName := m.buildSnapshotName(info.ID) + + data, err := yaml.Marshal(info) + if err != nil { + return fmt.Errorf("cannot prepare snapshot metadata %s: %w", snapshotName, err) + } + + if err := os.WriteFile(snapshotName, data, 0755); err != nil { + return fmt.Errorf("cannot store file with snapshot metadata %s: %w", snapshotName, err) + } + + return nil +} + +// CreateBranch creates a new DLE data branch. +func (m *Marker) CreateBranch(branch, base string) error { + dirname := m.buildBranchName(branch) + if err := os.MkdirAll(dirname, 0755); err != nil { + return fmt.Errorf("cannot create branches directory %s: %w", dirname, err) + } + + headPath := m.buildBranchArtifactPath(base, headFile) + + readData, err := os.ReadFile(headPath) + if err != nil { + return fmt.Errorf("cannot read file %s: %w", headPath, err) + } + + branchPath := m.buildBranchArtifactPath(branch, headFile) + + if err := os.WriteFile(branchPath, readData, 0755); err != nil { + return fmt.Errorf("cannot write file %s: %w", branchPath, err) + } + + return nil +} + +// ListBranches returns branch list. +func (m *Marker) ListBranches() ([]string, error) { + branches := []string{} + + dirs, err := os.ReadDir(m.buildBranchesPath()) + if err != nil { + return nil, fmt.Errorf("failed to read repository: %w", err) + } + + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + + branches = append(branches, dir.Name()) + } + + return branches, nil +} + +// GetSnapshotID returns snapshot pointer for branch. +func (m *Marker) GetSnapshotID(branch string) (string, error) { + headPath := m.buildBranchArtifactPath(branch, headFile) + + readData, err := os.ReadFile(headPath) + if err != nil { + return "", fmt.Errorf("cannot read file %s: %w", headPath, err) + } + + h := &Head{} + if err := yaml.Unmarshal(readData, &h); err != nil { + return "", fmt.Errorf("cannot read reference: %w", err) + } + + snapshotsPath := m.buildPathFromRef(h.Ref) + + snapshotData, err := os.ReadFile(snapshotsPath) + if err != nil { + return "", fmt.Errorf("cannot read file %s: %w", snapshotsPath, err) + } + + snInfo := &SnapshotInfo{} + + if err := yaml.Unmarshal(snapshotData, &snInfo); err != nil { + return "", fmt.Errorf("cannot read reference: %w", err) + } + + return snInfo.ID, nil +} + +// SaveSnapshotRef stores snapshot reference for branch. +func (m *Marker) SaveSnapshotRef(branch, snapshotID string) error { + h, err := m.getBranchHead(branch) + if err != nil { + return err + } + + h.Ref = buildSnapshotRef(snapshotID) + + if err := m.writeBranchHead(h, branch); err != nil { + return err + } + + return nil +} + +func (m *Marker) getBranchHead(branch string) (*Head, error) { + headPath := m.buildBranchArtifactPath(branch, headFile) + + readData, err := os.ReadFile(headPath) + if err != nil { + return nil, fmt.Errorf("cannot read file %s: %w", headPath, err) + } + + h := &Head{} + if err := yaml.Unmarshal(readData, &h); err != nil { + return nil, fmt.Errorf("cannot read reference: %w", err) + } + + return h, nil +} + +func (m *Marker) writeBranchHead(h *Head, branch string) error { + headPath := m.buildBranchArtifactPath(branch, headFile) + + writeData, err := yaml.Marshal(h) + if err != nil { + return fmt.Errorf("cannot marshal structure: %w", err) + } + + if err := os.WriteFile(headPath, writeData, 0755); err != nil { + return fmt.Errorf("cannot write file %s: %w", headPath, err) + } + + return nil +} + +// buildBranchesPath builds path of branches dir. +func (m *Marker) buildBranchesPath() string { + return path.Join(m.dataPath, configDir, refsDir, branchesDir) +} + +// buildBranchName builds a branch name. +func (m *Marker) buildBranchName(branch string) string { + return path.Join(m.buildBranchesPath(), branch) +} + +// buildBranchArtifactPath builds a branch artifact name. +func (m *Marker) buildBranchArtifactPath(branch, artifact string) string { + return path.Join(m.buildBranchName(branch), artifact) +} + +// buildSnapshotsPath builds path of snapshots dir. +func (m *Marker) buildSnapshotsPath() string { + return path.Join(m.dataPath, configDir, refsDir, snapshotsDir) +} + +// buildSnapshotName builds a snapshot file name. +func (m *Marker) buildSnapshotName(snapshotID string) string { + return path.Join(m.buildSnapshotsPath(), snapshotID) +} + +// buildSnapshotRef builds snapshot ref. +func buildSnapshotRef(snapshotID string) string { + return path.Join(refsDir, snapshotsDir, snapshotID) +} + +// buildPathFromRef builds path from ref. +func (m *Marker) buildPathFromRef(ref string) string { + return path.Join(m.dataPath, configDir, ref) } diff --git a/engine/internal/retrieval/engine/postgres/snapshot/physical.go b/engine/internal/retrieval/engine/postgres/snapshot/physical.go index 76e635a2..e2ec9b37 100644 --- a/engine/internal/retrieval/engine/postgres/snapshot/physical.go +++ b/engine/internal/retrieval/engine/postgres/snapshot/physical.go @@ -913,7 +913,9 @@ func (p *PhysicalInitial) checkRecovery(ctx context.Context, containerID string) return output, err } -/* "Data state at" (DSA) is a timestamp that represents the database's state. This function tries to +/* + "Data state at" (DSA) is a timestamp that represents the database's state. This function tries to + determine its value based on various sources. If it fails, an error is reported. Using the current time as a last resort would be misleading, especially in the case when the "sync" container is running, and users deal with multiple snapshots. @@ -930,7 +932,8 @@ and the source doesn't have enough activity. Step 3. Use the timestamp of the latest checkpoint. This is extracted from PGDATA using the pg_controldata utility. Note that this is not an exact value of the latest activity in the source -before we took a copy of PGDATA, but we suppose it is not far from it. */ +before we took a copy of PGDATA, but we suppose it is not far from it. +*/ func (p *PhysicalInitial) extractDataStateAt( ctx context.Context, containerID, dataDir string, pgVersion float64, defaultDSA string, diff --git a/engine/internal/retrieval/retrieval.go b/engine/internal/retrieval/retrieval.go index 6992d399..880290a7 100644 --- a/engine/internal/retrieval/retrieval.go +++ b/engine/internal/retrieval/retrieval.go @@ -348,6 +348,10 @@ func (r *Retrieval) run(ctx context.Context, fsm pool.FSManager) (err error) { r.State.cleanAlerts() } + if err := fsm.InitBranching(); err != nil { + return fmt.Errorf("failed to init branching: %w", err) + } + return nil } diff --git a/engine/internal/runci/config.go b/engine/internal/runci/config.go index 29e3765a..9d09c182 100644 --- a/engine/internal/runci/config.go +++ b/engine/internal/runci/config.go @@ -6,15 +6,13 @@ package runci import ( - "io/ioutil" + "os" "github.com/pkg/errors" "gopkg.in/yaml.v2" - "gitlab.com/postgres-ai/database-lab/v3/internal/runci/source" - "gitlab.com/postgres-ai/database-lab/v3/internal/platform" - + "gitlab.com/postgres-ai/database-lab/v3/internal/runci/source" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" ) @@ -59,7 +57,7 @@ func LoadConfiguration() (*Config, error) { return nil, errors.Wrap(err, "failed to get config path") } - b, err := ioutil.ReadFile(configPath) + b, err := os.ReadFile(configPath) if err != nil { return nil, errors.Errorf("error loading %s config file", configPath) } diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go new file mode 100644 index 00000000..385a5aef --- /dev/null +++ b/engine/internal/srv/branch.go @@ -0,0 +1,450 @@ +package srv + +import ( + "fmt" + "net/http" + "time" + + "github.com/gorilla/mux" + + "gitlab.com/postgres-ai/database-lab/v3/internal/provision/pool" + "gitlab.com/postgres-ai/database-lab/v3/internal/srv/api" + "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util" +) + +// listBranches returns branch list. +func (s *Server) listBranches(w http.ResponseWriter, r *http.Request) { + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + branches, err := fsm.ListBranches() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + repo, err := fsm.GetRepo() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + branchDetails := make([]models.BranchView, 0, len(branches)) + + for branchName, snapshotID := range branches { + snapshotDetails, ok := repo.Snapshots[snapshotID] + if !ok { + continue + } + + branchDetails = append(branchDetails, + models.BranchView{ + Name: branchName, + Parent: findBranchParent(repo.Snapshots, snapshotDetails.ID, branchName), + DataStateAt: snapshotDetails.DataStateAt, + SnapshotID: snapshotDetails.ID, + }) + } + + if err := api.WriteJSON(w, http.StatusOK, branchDetails); err != nil { + api.SendError(w, r, err) + return + } +} + +func findBranchParent(snapshots map[string]models.SnapshotDetails, parentID, branch string) string { + for i := len(snapshots); i > 0; i-- { + snapshotPointer := snapshots[parentID] + + if containsString(snapshotPointer.Root, branch) { + if len(snapshotPointer.Branch) > 0 { + return snapshotPointer.Branch[0] + } + + break + } + + if snapshotPointer.Parent == "-" { + break + } + + parentID = snapshotPointer.Parent + } + + return "-" +} + +func containsString(slice []string, s string) bool { + for _, str := range slice { + if str == s { + return true + } + } + + return false +} + +func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { + var createRequest types.BranchCreateRequest + if err := api.ReadJSON(r, &createRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if createRequest.BranchName == "" { + api.SendBadRequestError(w, r, "branchName must not be empty") + return + } + + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + snapshotID := createRequest.SnapshotID + + if snapshotID == "" { + branches, err := fsm.ListBranches() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + branchPointer, ok := branches[createRequest.BaseBranch] + if !ok { + api.SendBadRequestError(w, r, "branch not found") + return + } + + snapshotID = branchPointer + } + + if err := fsm.AddBranchProp(createRequest.BranchName, snapshotID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := fsm.SetRoot(createRequest.BranchName, snapshotID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + branch := models.Branch{Name: createRequest.BranchName} + + if err := api.WriteJSON(w, http.StatusOK, branch); err != nil { + api.SendError(w, r, err) + return + } +} + +func (s *Server) getSnapshot(w http.ResponseWriter, r *http.Request) { + snapshotID := mux.Vars(r)["id"] + + if snapshotID == "" { + api.SendBadRequestError(w, r, "snapshotID must not be empty") + return + } + + snapshot, err := s.Cloning.GetSnapshotByID(snapshotID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := api.WriteJSON(w, http.StatusOK, snapshot); err != nil { + api.SendError(w, r, err) + return + } +} + +func (s *Server) getCommit(w http.ResponseWriter, r *http.Request) { + snapshotID := mux.Vars(r)["id"] + + if snapshotID == "" { + api.SendBadRequestError(w, r, "snapshotID must not be empty") + return + } + + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + repo, err := fsm.GetRepo() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + snapshotPointer, ok := repo.Snapshots[snapshotID] + + if !ok { + api.SendNotFoundError(w, r) + return + } + + if err := api.WriteJSON(w, http.StatusOK, snapshotPointer); err != nil { + api.SendError(w, r, err) + return + } +} + +func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { + var snapshotRequest types.SnapshotCloneCreateRequest + if err := api.ReadJSON(r, &snapshotRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + clone, err := s.Cloning.GetClone(snapshotRequest.CloneID) + if err != nil { + api.SendBadRequestError(w, r, "clone not found") + return + } + + if clone.Branch == "" { + api.SendBadRequestError(w, r, "clone was not created on branch") + return + } + + branches, err := fsm.ListBranches() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + currentSnapshotID, ok := branches[clone.Branch] + if !ok { + api.SendBadRequestError(w, r, "branch not found: "+clone.Branch) + return + } + + dataStateAt := time.Now().Format(util.DataStateAtFormat) + + snapshotBase := fmt.Sprintf("%s/%s", clone.Snapshot.Pool, util.GetCloneNameStr(clone.DB.Port)) + snapshotName := fmt.Sprintf("%s@%s", snapshotBase, dataStateAt) + + if err := fsm.Snapshot(snapshotName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + newSnapshotName := fmt.Sprintf("%s/%s/%s", fsm.Pool().Name, clone.Branch, dataStateAt) + + if err := fsm.Rename(snapshotBase, newSnapshotName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + snapshotPath := fmt.Sprintf("%s/%s@%s", fsm.Pool().ClonesDir(), clone.Branch, dataStateAt) + if err := fsm.SetMountpoint(snapshotPath, newSnapshotName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := fsm.AddBranchProp(clone.Branch, newSnapshotName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := fsm.DeleteBranchProp(clone.Branch, currentSnapshotID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + childID := newSnapshotName + "@" + dataStateAt + if err := fsm.SetRelation(currentSnapshotID, childID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := fsm.SetDSA(dataStateAt, childID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := fsm.SetMessage(snapshotRequest.Message, childID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + fsm.RefreshSnapshotList() + + if err := s.Cloning.ReloadSnapshots(); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + newSnapshot, err := s.Cloning.GetSnapshotByID(childID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := s.Cloning.UpdateCloneSnapshot(clone.ID, newSnapshot); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := api.WriteJSON(w, http.StatusOK, types.SnapshotResponse{SnapshotID: childID}); err != nil { + api.SendError(w, r, err) + return + } +} + +func (s *Server) log(w http.ResponseWriter, r *http.Request) { + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + var logRequest types.LogRequest + if err := api.ReadJSON(r, &logRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + repo, err := fsm.GetRepo() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + snapshotID, ok := repo.Branches[logRequest.BranchName] + if !ok { + api.SendBadRequestError(w, r, "branch not found: "+logRequest.BranchName) + return + } + + snapshotPointer := repo.Snapshots[snapshotID] + + logList := []models.SnapshotDetails{snapshotPointer} + + // Limit the number of iterations to the number of snapshots. + for i := len(repo.Snapshots); i > 1; i-- { + snapshotPointer = repo.Snapshots[snapshotPointer.Parent] + logList = append(logList, snapshotPointer) + + if snapshotPointer.Parent == "-" || snapshotPointer.Parent == "" { + break + } + } + + if err := api.WriteJSON(w, http.StatusOK, logList); err != nil { + api.SendError(w, r, err) + return + } +} + +func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { + var deleteRequest types.BranchDeleteRequest + if err := api.ReadJSON(r, &deleteRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + repo, err := fsm.GetRepo() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + snapshotID, ok := repo.Branches[deleteRequest.BranchName] + if !ok { + api.SendBadRequestError(w, r, "branch not found: "+deleteRequest.BranchName) + return + } + + if hasSnapshots(repo, snapshotID, deleteRequest.BranchName) { + if err := fsm.DeleteBranch(deleteRequest.BranchName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + } + + // Re-request the repository as the list of snapshots may change significantly. + repo, err = fsm.GetRepo() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := cleanupSnapshotProperties(repo, fsm, deleteRequest.BranchName); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if err := api.WriteJSON(w, http.StatusOK, models.Response{ + Status: models.ResponseOK, + Message: "Deleted branch", + }); err != nil { + api.SendError(w, r, err) + return + } +} + +func cleanupSnapshotProperties(repo *models.Repo, fsm pool.FSManager, branchName string) error { + for _, snap := range repo.Snapshots { + for _, rootBranch := range snap.Root { + if rootBranch == branchName { + if err := fsm.DeleteRootProp(branchName, snap.ID); err != nil { + return err + } + + if err := fsm.DeleteBranchProp(branchName, snap.ID); err != nil { + return err + } + + for _, child := range snap.Child { + if _, ok := repo.Snapshots[child]; !ok { + if err := fsm.DeleteChildProp(child, snap.ID); err != nil { + return err + } + } + } + + break + } + } + } + + return nil +} + +func hasSnapshots(repo *models.Repo, snapshotID, branchName string) bool { + snapshotPointer := repo.Snapshots[snapshotID] + + for _, rootBranch := range snapshotPointer.Root { + if rootBranch == branchName { + return false + } + } + + return true +} diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 91c9d504..5b35aa94 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -157,6 +157,13 @@ func (s *Server) createSnapshot(w http.ResponseWriter, r *http.Request) { return } + if err := fsManager.InitBranching(); err != nil { + api.SendBadRequestError(w, r, "Cannot verify branch metadata: "+err.Error()) + return + } + + // TODO: set branching metadata. + latestSnapshot := snapshotList[0] if err := api.WriteJSON(w, http.StatusOK, latestSnapshot); err != nil { @@ -199,6 +206,8 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { return } + // TODO: update branching metadata. + log.Dbg(fmt.Sprintf("Snapshot %s has been deleted", destroyRequest.SnapshotID)) if err := api.WriteJSON(w, http.StatusOK, ""); err != nil { @@ -206,11 +215,66 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { return } + fsm.RefreshSnapshotList() + if err := s.Cloning.ReloadSnapshots(); err != nil { log.Dbg("Failed to reload snapshots", err.Error()) } } +func (s *Server) createSnapshotClone(w http.ResponseWriter, r *http.Request) { + if r.Body == http.NoBody { + api.SendBadRequestError(w, r, "request body cannot be empty") + return + } + + var createRequest types.SnapshotCloneCreateRequest + if err := api.ReadJSON(r, &createRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if createRequest.CloneID == "" { + api.SendBadRequestError(w, r, "cloneID cannot be empty") + return + } + + clone, err := s.Cloning.GetClone(createRequest.CloneID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + fsm, err := s.pm.GetFSManager(clone.Snapshot.Pool) + if err != nil { + api.SendBadRequestError(w, r, fmt.Sprintf("failed to find filesystem manager: %s", err.Error())) + return + } + + cloneName := util.GetCloneNameStr(clone.DB.Port) + + snapshotID, err := fsm.CreateSnapshot(cloneName, time.Now().Format(util.DataStateAtFormat)) + if err != nil { + api.SendBadRequestError(w, r, fmt.Sprintf("failed to create a snapshot: %s", err.Error())) + return + } + + if err := s.Cloning.ReloadSnapshots(); err != nil { + log.Dbg("Failed to reload snapshots", err.Error()) + } + + snapshot, err := s.Cloning.GetSnapshotByID(snapshotID) + if err != nil { + api.SendBadRequestError(w, r, fmt.Sprintf("failed to find a new snapshot: %s", err.Error())) + return + } + + if err := api.WriteJSON(w, http.StatusOK, snapshot); err != nil { + api.SendError(w, r, err) + return + } +} + func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { var cloneRequest *types.CloneCreateRequest if err := api.ReadJSON(r, &cloneRequest); err != nil { @@ -223,6 +287,29 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { return } + if cloneRequest.Branch != "" { + fsm := s.pm.First() + + if fsm == nil { + api.SendBadRequestError(w, r, "no available pools") + return + } + + branches, err := fsm.ListBranches() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + snapshotID, ok := branches[cloneRequest.Branch] + if !ok { + api.SendBadRequestError(w, r, "branch not found") + return + } + + cloneRequest.Snapshot = &types.SnapshotCloneFieldRequest{ID: snapshotID} + } + newClone, err := s.Cloning.CreateClone(cloneRequest) if err != nil { var reqErr *models.Error diff --git a/engine/internal/srv/server.go b/engine/internal/srv/server.go index 04644add..8d35a94a 100644 --- a/engine/internal/srv/server.go +++ b/engine/internal/srv/server.go @@ -196,8 +196,10 @@ func (s *Server) InitHandlers() { r.HandleFunc("/status", authMW.Authorized(s.getInstanceStatus)).Methods(http.MethodGet) r.HandleFunc("/snapshots", authMW.Authorized(s.getSnapshots)).Methods(http.MethodGet) + r.HandleFunc("/snapshot/{id:.*}", authMW.Authorized(s.getSnapshot)).Methods(http.MethodGet) r.HandleFunc("/snapshot/create", authMW.Authorized(s.createSnapshot)).Methods(http.MethodPost) r.HandleFunc("/snapshot/delete", authMW.Authorized(s.deleteSnapshot)).Methods(http.MethodPost) + r.HandleFunc("/snapshot/clone", authMW.Authorized(s.createSnapshotClone)).Methods(http.MethodPost) r.HandleFunc("/clone", authMW.Authorized(s.createClone)).Methods(http.MethodPost) r.HandleFunc("/clone/{id}", authMW.Authorized(s.destroyClone)).Methods(http.MethodDelete) r.HandleFunc("/clone/{id}", authMW.Authorized(s.patchClone)).Methods(http.MethodPatch) @@ -210,6 +212,13 @@ func (s *Server) InitHandlers() { r.HandleFunc("/estimate", s.startEstimator).Methods(http.MethodGet) r.HandleFunc("/instance/retrieval", authMW.Authorized(s.retrievalState)).Methods(http.MethodGet) + r.HandleFunc("/branch/list", authMW.Authorized(s.listBranches)).Methods(http.MethodGet) + r.HandleFunc("/branch/snapshot/{id:.*}", authMW.Authorized(s.getCommit)).Methods(http.MethodGet) + r.HandleFunc("/branch/create", authMW.Authorized(s.createBranch)).Methods(http.MethodPost) + r.HandleFunc("/branch/snapshot", authMW.Authorized(s.snapshot)).Methods(http.MethodPost) + r.HandleFunc("/branch/log", authMW.Authorized(s.log)).Methods(http.MethodPost) + r.HandleFunc("/branch/delete", authMW.Authorized(s.deleteBranch)).Methods(http.MethodPost) + // Sub-route /admin adminR := r.PathPrefix("/admin").Subrouter() adminR.Use(authMW.AdminMW) diff --git a/engine/pkg/client/dblabapi/branch.go b/engine/pkg/client/dblabapi/branch.go new file mode 100644 index 00000000..5fd2c51f --- /dev/null +++ b/engine/pkg/client/dblabapi/branch.go @@ -0,0 +1,171 @@ +/* +2019 © Postgres.ai +*/ + +package dblabapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + + "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" +) + +// ListBranches returns branches list. +func (c *Client) ListBranches(ctx context.Context) ([]string, error) { + u := c.URL("/branch/list") + + request, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to make a request: %w", err) + } + + response, err := c.Do(ctx, request) + if err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + defer func() { _ = response.Body.Close() }() + + branches := make([]models.BranchView, 0) + + if err := json.NewDecoder(response.Body).Decode(&branches); err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + listBranches := make([]string, 0, len(branches)) + + for _, branchView := range branches { + listBranches = append(listBranches, branchView.Name) + } + + sort.Strings(listBranches) + + return listBranches, nil +} + +// CreateBranch creates a new DLE data branch. +// +//nolint:dupl +func (c *Client) CreateBranch(ctx context.Context, branchRequest types.BranchCreateRequest) (*models.Branch, error) { + u := c.URL("/branch/create") + + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(branchRequest); err != nil { + return nil, fmt.Errorf("failed to encode BranchCreateRequest: %w", err) + } + + request, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to make a request: %w", err) + } + + response, err := c.Do(ctx, request) + if err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + defer func() { _ = response.Body.Close() }() + + var branch *models.Branch + + if err := json.NewDecoder(response.Body).Decode(&branch); err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + return branch, nil +} + +// CreateSnapshotForBranch creates a new snapshot for branch. +// +//nolint:dupl +func (c *Client) CreateSnapshotForBranch( + ctx context.Context, + snapshotRequest types.SnapshotCloneCreateRequest) (*types.SnapshotResponse, error) { + u := c.URL("/branch/snapshot") + + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(snapshotRequest); err != nil { + return nil, fmt.Errorf("failed to encode SnapshotCreateRequest: %w", err) + } + + request, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to make a request: %w", err) + } + + response, err := c.Do(ctx, request) + if err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + defer func() { _ = response.Body.Close() }() + + var snapshot *types.SnapshotResponse + + if err := json.NewDecoder(response.Body).Decode(&snapshot); err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + return snapshot, nil +} + +// BranchLog provides snapshot list for branch. +func (c *Client) BranchLog(ctx context.Context, logRequest types.LogRequest) ([]models.SnapshotDetails, error) { + u := c.URL("/branch/log") + + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(logRequest); err != nil { + return nil, fmt.Errorf("failed to encode LogRequest: %w", err) + } + + request, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to make a request: %w", err) + } + + response, err := c.Do(ctx, request) + if err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + defer func() { _ = response.Body.Close() }() + + var snapshots []models.SnapshotDetails + + if err := json.NewDecoder(response.Body).Decode(&snapshots); err != nil { + return nil, fmt.Errorf("failed to get response: %w", err) + } + + return snapshots, nil +} + +// DeleteBranch deletes data branch. +//nolint:dupl +func (c *Client) DeleteBranch(ctx context.Context, r types.BranchDeleteRequest) error { + u := c.URL("/branch/delete") + + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(r); err != nil { + return fmt.Errorf("failed to encode BranchDeleteRequest: %w", err) + } + + request, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return fmt.Errorf("failed to make a request: %w", err) + } + + response, err := c.Do(ctx, request) + if err != nil { + return fmt.Errorf("failed to get response: %w", err) + } + + defer func() { _ = response.Body.Close() }() + + return nil +} diff --git a/engine/pkg/client/dblabapi/client.go b/engine/pkg/client/dblabapi/client.go index 342ad931..9dc2b5f2 100644 --- a/engine/pkg/client/dblabapi/client.go +++ b/engine/pkg/client/dblabapi/client.go @@ -18,8 +18,6 @@ import ( "strings" "time" - "github.com/pkg/errors" - "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) @@ -136,7 +134,7 @@ func (c *Client) Do(ctx context.Context, request *http.Request) (response *http. errModel := models.Error{} if err = json.Unmarshal(b, &errModel); err != nil { - return response, errors.Wrapf(err, "failed to parse an error message: %s", (string(b))) + return response, fmt.Errorf("failed to parse an error message: %s, %w", string(b), err) } return response, errModel diff --git a/engine/pkg/client/dblabapi/snapshot.go b/engine/pkg/client/dblabapi/snapshot.go index b6afedee..3e19e3f4 100644 --- a/engine/pkg/client/dblabapi/snapshot.go +++ b/engine/pkg/client/dblabapi/snapshot.go @@ -8,8 +8,10 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" + "net/url" "github.com/pkg/errors" @@ -56,6 +58,19 @@ func (c *Client) ListSnapshotsRaw(ctx context.Context) (io.ReadCloser, error) { func (c *Client) CreateSnapshot(ctx context.Context, snapshotRequest types.SnapshotCreateRequest) (*models.Snapshot, error) { u := c.URL("/snapshot/create") + return c.createRequest(ctx, snapshotRequest, u) +} + +// CreateSnapshotFromClone creates a new snapshot from clone. +func (c *Client) CreateSnapshotFromClone( + ctx context.Context, + snapshotRequest types.SnapshotCloneCreateRequest) (*models.Snapshot, error) { + u := c.URL("/snapshot/clone") + + return c.createRequest(ctx, snapshotRequest, u) +} + +func (c *Client) createRequest(ctx context.Context, snapshotRequest any, u *url.URL) (*models.Snapshot, error) { body := bytes.NewBuffer(nil) if err := json.NewEncoder(body).Encode(snapshotRequest); err != nil { return nil, errors.Wrap(err, "failed to encode SnapshotCreateRequest") @@ -83,22 +98,23 @@ func (c *Client) CreateSnapshot(ctx context.Context, snapshotRequest types.Snaps } // DeleteSnapshot deletes snapshot. +//nolint:dupl func (c *Client) DeleteSnapshot(ctx context.Context, snapshotRequest types.SnapshotDestroyRequest) error { u := c.URL("/snapshot/delete") body := bytes.NewBuffer(nil) if err := json.NewEncoder(body).Encode(snapshotRequest); err != nil { - return errors.Wrap(err, "failed to encode snapshotDestroyRequest") + return fmt.Errorf("failed to encode snapshotDestroyRequest: %w", err) } request, err := http.NewRequest(http.MethodPost, u.String(), body) if err != nil { - return errors.Wrap(err, "failed to make a request") + return fmt.Errorf("failed to make a request: %w", err) } response, err := c.Do(ctx, request) if err != nil { - return errors.Wrap(err, "failed to get response") + return fmt.Errorf("failed to get response: %w", err) } defer func() { _ = response.Body.Close() }() diff --git a/engine/pkg/client/dblabapi/types/clone.go b/engine/pkg/client/dblabapi/types/clone.go index 0b25f55f..5dda13aa 100644 --- a/engine/pkg/client/dblabapi/types/clone.go +++ b/engine/pkg/client/dblabapi/types/clone.go @@ -12,6 +12,7 @@ type CloneCreateRequest struct { DB *DatabaseRequest `json:"db"` Snapshot *SnapshotCloneFieldRequest `json:"snapshot"` ExtraConf map[string]string `json:"extra_conf"` + Branch string `json:"branch"` } // CloneUpdateRequest represents params of an update request. @@ -38,12 +39,45 @@ type ResetCloneRequest struct { Latest bool `json:"latest"` } -// SnapshotCreateRequest describes params for a creating snapshot request. +// SnapshotCreateRequest describes params for creating snapshot request. type SnapshotCreateRequest struct { PoolName string `json:"poolName"` } -// SnapshotDestroyRequest describes params for a destroying snapshot request. +// SnapshotDestroyRequest describes params for destroying snapshot request. type SnapshotDestroyRequest struct { SnapshotID string `json:"snapshotID"` } + +// SnapshotCloneCreateRequest describes params for creating snapshot request from clone. +type SnapshotCloneCreateRequest struct { + CloneID string `json:"cloneID"` + Message string `json:"message"` +} + +// BranchCreateRequest describes params for creating branch request. +type BranchCreateRequest struct { + BranchName string `json:"branchName"` + BaseBranch string `json:"baseBranch"` + SnapshotID string `json:"snapshotID"` +} + +// SnapshotResponse describes commit response. +type SnapshotResponse struct { + SnapshotID string `json:"snapshotID"` +} + +// ResetRequest describes params for reset request. +type ResetRequest struct { + SnapshotID string `json:"snapshotID"` +} + +// LogRequest describes params for log request. +type LogRequest struct { + BranchName string `json:"branchName"` +} + +// BranchDeleteRequest describes params for deleting branch request. +type BranchDeleteRequest struct { + BranchName string `json:"branchName"` +} diff --git a/engine/pkg/models/branch.go b/engine/pkg/models/branch.go new file mode 100644 index 00000000..1a223c4a --- /dev/null +++ b/engine/pkg/models/branch.go @@ -0,0 +1,39 @@ +package models + +// Branch defines a branch entity. +type Branch struct { + Name string `json:"name"` +} + +// Repo describes data repository with details about snapshots and branches. +type Repo struct { + Snapshots map[string]SnapshotDetails `json:"snapshots"` + Branches map[string]string `json:"branches"` +} + +// NewRepo creates a new Repo. +func NewRepo() *Repo { + return &Repo{ + Snapshots: make(map[string]SnapshotDetails), + Branches: make(map[string]string), + } +} + +// SnapshotDetails describes snapshot. +type SnapshotDetails struct { + ID string `json:"id"` + Parent string `json:"parent"` + Child []string `json:"child"` + Branch []string `json:"branch"` + Root []string `json:"root"` + DataStateAt string `json:"dataStateAt"` + Message string `json:"message"` +} + +// BranchView describes branch view. +type BranchView struct { + Name string `json:"name"` + Parent string `json:"parent"` + DataStateAt string `json:"dataStateAt"` + SnapshotID string `json:"snapshotID"` +} diff --git a/engine/pkg/models/clone.go b/engine/pkg/models/clone.go index 6b4520ff..93e027cd 100644 --- a/engine/pkg/models/clone.go +++ b/engine/pkg/models/clone.go @@ -8,6 +8,7 @@ package models type Clone struct { ID string `json:"id"` Snapshot *Snapshot `json:"snapshot"` + Branch string `json:"branch"` Protected bool `json:"protected"` DeleteAt *LocalTime `json:"deleteAt"` CreatedAt *LocalTime `json:"createdAt"` diff --git a/engine/pkg/models/status.go b/engine/pkg/models/status.go index 784d7667..4e5d890a 100644 --- a/engine/pkg/models/status.go +++ b/engine/pkg/models/status.go @@ -10,6 +10,12 @@ type Status struct { Message string `json:"message"` } +// Response defines the response structure. +type Response struct { + Status string `json:"status"` + Message string `json:"message"` +} + // StatusCode defines the status code of clones and instance. type StatusCode string @@ -37,4 +43,6 @@ const ( SyncStatusDown StatusCode = "Down" SyncStatusNotAvailable StatusCode = "Not available" SyncStatusError StatusCode = "Error" + + ResponseOK = "OK" ) diff --git a/engine/test/1.synthetic.sh b/engine/test/1.synthetic.sh index 6d54ff2b..2821ff24 100644 --- a/engine/test/1.synthetic.sh +++ b/engine/test/1.synthetic.sh @@ -70,9 +70,12 @@ sudo docker rm dblab_pg_initdb configDir="$HOME/.dblab/engine/configs" metaDir="$HOME/.dblab/engine/meta" +logsDir="$HOME/.dblab/engine/logs" # Copy the contents of configuration example mkdir -p "${configDir}" +mkdir -p "${metaDir}" +mkdir -p "${logsDir}" curl https://gitlab.com/postgres-ai/database-lab/-/raw/"${TAG:-master}"/engine/configs/config.example.logical_generic.yml \ --output "${configDir}/server.yml" @@ -119,6 +122,7 @@ sudo docker run \ --volume ${DLE_TEST_MOUNT_DIR}:${DLE_TEST_MOUNT_DIR}/:rshared \ --volume "${configDir}":/home/dblab/configs \ --volume "${metaDir}":/home/dblab/meta \ + --volume "${logsDir}":/home/dblab/logs \ --volume /sys/kernel/debug:/sys/kernel/debug:rw \ --volume /lib/modules:/lib/modules:ro \ --volume /proc:/host_proc:ro \ @@ -249,6 +253,36 @@ PGPASSWORD=secret_password psql \ dblab clone destroy testclone dblab clone list +### Data branching. +dblab branch || (echo "Failed when data branching is not initialized" && exit 1) +dblab branch 001-branch || (echo "Failed to create a data branch" && exit 1) +dblab branch + +dblab clone create \ + --username john \ + --password test \ + --branch 001-branch \ + --id branchclone001 || (echo "Failed to create a clone on branch" && exit 1) + +dblab commit --clone-id branchclone001 --message branchclone001 || (echo "Failed to create a snapshot" && exit 1) + +dblab clone create \ + --username alice \ + --password password \ + --branch 001-branch \ + --id branchclone002 || (echo "Failed to create a clone on branch" && exit 1) + +dblab commit --clone-id branchclone002 -m branchclone002 || (echo "Failed to create a snapshot" && exit 1) + +dblab log 001-branch || (echo "Failed to show branch history" && exit 1) + +dblab clone destroy branchclone001 || (echo "Failed to destroy clone" && exit 1) +dblab clone destroy branchclone002 || (echo "Failed to destroy clone" && exit 1) + +dblab branch --delete 001-branch || (echo "Failed to delete data branch" && exit 1) + +dblab branch + ## Stop DLE. sudo docker stop ${DLE_SERVER_NAME} diff --git a/engine/test/2.logical_generic.sh b/engine/test/2.logical_generic.sh index a6ff6618..f70c7060 100644 --- a/engine/test/2.logical_generic.sh +++ b/engine/test/2.logical_generic.sh @@ -79,10 +79,12 @@ source "${DIR}/_zfs.file.sh" configDir="$HOME/.dblab/engine/configs" metaDir="$HOME/.dblab/engine/meta" +logsDir="$HOME/.dblab/engine/logs" # Copy the contents of configuration example mkdir -p "${configDir}" mkdir -p "${metaDir}" +mkdir -p "${logsDir}" curl https://gitlab.com/postgres-ai/database-lab/-/raw/"${TAG:-master}"/engine/configs/config.example.logical_generic.yml \ --output "${configDir}/server.yml" @@ -132,6 +134,7 @@ sudo docker run \ --volume ${DLE_TEST_MOUNT_DIR}:${DLE_TEST_MOUNT_DIR}/:rshared \ --volume "${configDir}":/home/dblab/configs \ --volume "${metaDir}":/home/dblab/meta \ + --volume "${logsDir}":/home/dblab/logs \ --volume /sys/kernel/debug:/sys/kernel/debug:rw \ --volume /lib/modules:/lib/modules:ro \ --volume /proc:/host_proc:ro \ diff --git a/engine/test/4.physical_basebackup.sh b/engine/test/4.physical_basebackup.sh index abd18985..ad4a32da 100644 --- a/engine/test/4.physical_basebackup.sh +++ b/engine/test/4.physical_basebackup.sh @@ -94,9 +94,11 @@ source "${DIR}/_zfs.file.sh" configDir="$HOME/.dblab/engine/configs" metaDir="$HOME/.dblab/engine/meta" +logsDir="$HOME/.dblab/engine/logs" # Copy the contents of configuration example mkdir -p "${configDir}" +mkdir -p "${logsDir}" curl https://gitlab.com/postgres-ai/database-lab/-/raw/"${TAG:-master}"/engine/configs/config.example.physical_generic.yml \ --output "${configDir}/server.yml" @@ -146,6 +148,7 @@ sudo docker run \ --volume ${DLE_TEST_MOUNT_DIR}:${DLE_TEST_MOUNT_DIR}/:rshared \ --volume "${configDir}":/home/dblab/configs \ --volume "${metaDir}":/home/dblab/meta \ + --volume "${logsDir}":/home/dblab/logs \ --volume /sys/kernel/debug:/sys/kernel/debug:rw \ --volume /lib/modules:/lib/modules:ro \ --volume /proc:/host_proc:ro \ From 9fbe2acc7ef6325e837668f49fa5b146398d5ffe Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Tue, 10 Jan 2023 06:21:50 +0000 Subject: [PATCH 003/114] [DLE 4.0] feat(ui): Branches --- .../App/Instance/Branches/Branch/index.tsx | 52 ++ .../ce/src/App/Instance/Branches/index.tsx | 21 + .../src/App/Instance/Clones/Clone/index.tsx | 7 +- .../App/Instance/Clones/CreateClone/index.tsx | 8 +- .../ce/src/App/Instance/Clones/index.tsx | 7 + .../ce/src/App/Instance/Page/index.tsx | 17 +- .../App/Instance/Snapshots/Snapshot/index.tsx | 54 ++ .../ce/src/App/Instance/Snapshots/index.tsx | 22 + ui/packages/ce/src/App/Instance/index.tsx | 8 + .../ce/src/api/branches/createBranch.ts | 26 + .../ce/src/api/branches/deleteBranch.ts | 22 + .../ce/src/api/branches/getBranches.ts | 17 + .../ce/src/api/branches/getSnapshotList.ts | 22 + ui/packages/ce/src/api/clones/createClone.ts | 1 + .../ce/src/api/configs/updateConfig.ts | 2 +- .../ce/src/api/snapshots/createSnapshot.ts | 25 + .../ce/src/api/snapshots/destroySnapshot.ts | 24 + .../ce/src/api/snapshots/getBranchSnapshot.ts | 17 + ui/packages/ce/src/config/routes.tsx | 36 +- .../components/DbLabSession/DbLabSession.tsx | 10 +- .../DbLabSessions/DbLabSessions.tsx | 13 +- .../src/components/JoeHistory/JoeHistory.tsx | 13 +- .../src/pages/JoeSessionCommand/index.js | 11 +- .../components/ResetCloneModal/index.tsx | 9 +- ui/packages/shared/icons/PostgresSQL/icon.svg | 22 + ui/packages/shared/icons/PostgresSQL/index.ts | 3 + .../shared/pages/Branches/Branch/context.ts | 21 + .../shared/pages/Branches/Branch/index.tsx | 342 ++++++++++ .../pages/Branches/Branch/stores/Main.ts | 131 ++++ .../pages/Branches/Branch/useCreatedStores.ts | 10 + .../components/BranchesTable/index.tsx | 97 +++ .../Modals/CreateBranchModal/index.tsx | 193 ++++++ .../Modals/CreateBranchModal/useForm.ts | 37 + .../Modals/DeleteBranchModal/index.tsx | 77 +++ .../components/Modals/styles.module.scss | 27 + .../pages/Branches/components/Modals/types.ts | 4 + ui/packages/shared/pages/Branches/index.tsx | 114 ++++ ui/packages/shared/pages/Clone/index.tsx | 32 +- .../shared/pages/Configuration/index.tsx | 644 ------------------ .../shared/pages/CreateClone/index.tsx | 38 +- .../shared/pages/CreateClone/stores/Main.ts | 13 +- .../shared/pages/CreateClone/useForm.ts | 2 + .../ClonesList/ConnectionModal/index.tsx | 0 .../ClonesList/MenuCell/index.tsx | 0 .../ClonesList/MenuCell/utils.ts | 0 .../ClonesList/index.tsx | 23 +- .../ClonesList/styles.module.scss | 0 .../{ => Clones}/ClonesModal/index.tsx | 2 +- .../{ => Clones}/ClonesModal/utils.ts | 0 .../Instance/Clones/Header/styles.module.scss | 5 +- .../shared/pages/Instance/Clones/index.tsx | 44 +- .../Configuration/Header/index.tsx | 0 .../Configuration/InputWithTooltip/index.tsx | 5 +- .../Configuration/ResponseMessage/index.tsx | 0 .../Configuration/configOptions.ts | 0 .../pages/Instance/Configuration/index.tsx | 616 +++++++++++++++++ .../Configuration/styles.module.scss | 1 + .../Configuration/tooltipText.tsx | 0 .../{ => Instance}/Configuration/useForm.ts | 0 .../Configuration/utils/index.ts | 0 .../pages/Instance/Info/Disks/Disk/index.tsx | 10 +- .../pages/Instance/Info/Snapshots/index.tsx | 17 +- .../components/CreateSnapshotModal/index.tsx | 158 +++++ .../components/CreateSnapshotModal/useForm.ts | 33 + .../components/SnapshotsModal/index.tsx | 84 +++ .../components}/SnapshotsModal/utils.ts | 0 .../components/SnapshotsTable/index.tsx | 152 +++++ .../Snapshots/components/styles.module.scss | 31 + .../shared/pages/Instance/Snapshots/index.tsx | 117 ++++ .../pages/Instance/Snapshots/utils/index.ts | 8 + .../pages/Instance/SnapshotsModal/index.tsx | 168 ----- .../shared/pages/Instance/Tabs/index.tsx | 69 +- ui/packages/shared/pages/Instance/context.ts | 3 +- ui/packages/shared/pages/Instance/index.tsx | 62 +- .../shared/pages/Instance/stores/Main.ts | 87 ++- .../Snapshot/DestorySnapshotModal/index.tsx | 79 +++ .../pages/Snapshots/Snapshot/context.ts | 22 + .../shared/pages/Snapshots/Snapshot/index.tsx | 350 ++++++++++ .../pages/Snapshots/Snapshot/stores/Main.ts | 109 +++ .../Snapshots/Snapshot/useCreatedStores.ts | 10 + ui/packages/shared/stores/Snapshots.ts | 23 + .../types/api/endpoints/createBranch.ts | 13 + .../shared/types/api/endpoints/createClone.ts | 1 + .../types/api/endpoints/createSnapshot.ts | 9 + .../types/api/endpoints/deleteBranch.ts | 3 + .../types/api/endpoints/destroySnapshot.ts | 4 + .../types/api/endpoints/getBranchSnapshot.ts | 5 + .../shared/types/api/endpoints/getBranches.ts | 11 + .../types/api/endpoints/getSnapshotList.ts | 11 + .../types/api/entities/branchSnapshot.ts | 8 + .../shared/types/api/entities/config.ts | 2 +- .../shared/types/api/entities/createBranch.ts | 7 + .../types/api/entities/createSnapshot.ts | 7 + .../shared/types/api/entities/snapshot.ts | 4 +- ui/packages/shared/utils/date.ts | 4 + 95 files changed, 3699 insertions(+), 929 deletions(-) create mode 100644 ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx create mode 100644 ui/packages/ce/src/App/Instance/Branches/index.tsx create mode 100644 ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx create mode 100644 ui/packages/ce/src/App/Instance/Snapshots/index.tsx create mode 100644 ui/packages/ce/src/api/branches/createBranch.ts create mode 100644 ui/packages/ce/src/api/branches/deleteBranch.ts create mode 100644 ui/packages/ce/src/api/branches/getBranches.ts create mode 100644 ui/packages/ce/src/api/branches/getSnapshotList.ts create mode 100644 ui/packages/ce/src/api/snapshots/createSnapshot.ts create mode 100644 ui/packages/ce/src/api/snapshots/destroySnapshot.ts create mode 100644 ui/packages/ce/src/api/snapshots/getBranchSnapshot.ts create mode 100644 ui/packages/shared/icons/PostgresSQL/icon.svg create mode 100644 ui/packages/shared/icons/PostgresSQL/index.ts create mode 100644 ui/packages/shared/pages/Branches/Branch/context.ts create mode 100644 ui/packages/shared/pages/Branches/Branch/index.tsx create mode 100644 ui/packages/shared/pages/Branches/Branch/stores/Main.ts create mode 100644 ui/packages/shared/pages/Branches/Branch/useCreatedStores.ts create mode 100644 ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx create mode 100644 ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/index.tsx create mode 100644 ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/useForm.ts create mode 100644 ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx create mode 100644 ui/packages/shared/pages/Branches/components/Modals/styles.module.scss create mode 100644 ui/packages/shared/pages/Branches/components/Modals/types.ts create mode 100644 ui/packages/shared/pages/Branches/index.tsx delete mode 100644 ui/packages/shared/pages/Configuration/index.tsx rename ui/packages/shared/pages/Instance/{components => Clones}/ClonesList/ConnectionModal/index.tsx (100%) rename ui/packages/shared/pages/Instance/{components => Clones}/ClonesList/MenuCell/index.tsx (100%) rename ui/packages/shared/pages/Instance/{components => Clones}/ClonesList/MenuCell/utils.ts (100%) rename ui/packages/shared/pages/Instance/{components => Clones}/ClonesList/index.tsx (91%) rename ui/packages/shared/pages/Instance/{components => Clones}/ClonesList/styles.module.scss (100%) rename ui/packages/shared/pages/Instance/{ => Clones}/ClonesModal/index.tsx (96%) rename ui/packages/shared/pages/Instance/{ => Clones}/ClonesModal/utils.ts (100%) rename ui/packages/shared/pages/{ => Instance}/Configuration/Header/index.tsx (100%) rename ui/packages/shared/pages/{ => Instance}/Configuration/InputWithTooltip/index.tsx (98%) rename ui/packages/shared/pages/{ => Instance}/Configuration/ResponseMessage/index.tsx (100%) rename ui/packages/shared/pages/{ => Instance}/Configuration/configOptions.ts (100%) create mode 100644 ui/packages/shared/pages/Instance/Configuration/index.tsx rename ui/packages/shared/pages/{ => Instance}/Configuration/styles.module.scss (98%) rename ui/packages/shared/pages/{ => Instance}/Configuration/tooltipText.tsx (100%) rename ui/packages/shared/pages/{ => Instance}/Configuration/useForm.ts (100%) rename ui/packages/shared/pages/{ => Instance}/Configuration/utils/index.ts (100%) create mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/index.tsx create mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts create mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsModal/index.tsx rename ui/packages/shared/pages/Instance/{ => Snapshots/components}/SnapshotsModal/utils.ts (100%) create mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsTable/index.tsx create mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/styles.module.scss create mode 100644 ui/packages/shared/pages/Instance/Snapshots/index.tsx create mode 100644 ui/packages/shared/pages/Instance/Snapshots/utils/index.ts delete mode 100644 ui/packages/shared/pages/Instance/SnapshotsModal/index.tsx create mode 100644 ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx create mode 100644 ui/packages/shared/pages/Snapshots/Snapshot/context.ts create mode 100644 ui/packages/shared/pages/Snapshots/Snapshot/index.tsx create mode 100644 ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts create mode 100644 ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts create mode 100644 ui/packages/shared/types/api/endpoints/createBranch.ts create mode 100644 ui/packages/shared/types/api/endpoints/createSnapshot.ts create mode 100644 ui/packages/shared/types/api/endpoints/deleteBranch.ts create mode 100644 ui/packages/shared/types/api/endpoints/destroySnapshot.ts create mode 100644 ui/packages/shared/types/api/endpoints/getBranchSnapshot.ts create mode 100644 ui/packages/shared/types/api/endpoints/getBranches.ts create mode 100644 ui/packages/shared/types/api/endpoints/getSnapshotList.ts create mode 100644 ui/packages/shared/types/api/entities/branchSnapshot.ts create mode 100644 ui/packages/shared/types/api/entities/createBranch.ts create mode 100644 ui/packages/shared/types/api/entities/createSnapshot.ts diff --git a/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx b/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx new file mode 100644 index 00000000..06c1cf2d --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx @@ -0,0 +1,52 @@ +import { useParams } from 'react-router-dom' + +import { getBranches } from 'api/branches/getBranches' +import { deleteBranch } from 'api/branches/deleteBranch' +import { getSnapshotList } from 'api/branches/getSnapshotList' + +import { PageContainer } from 'components/PageContainer' +import { NavPath } from 'components/NavPath' +import { ROUTES } from 'config/routes' +import { BranchesPage } from '@postgres.ai/shared/pages/Branches/Branch' + +type Params = { + branchId: string +} + +export const Branch = () => { + const { branchId } = useParams() + + const api = { + getBranches, + deleteBranch, + getSnapshotList, + } + + const elements = { + breadcrumbs: ( + + ), + } + + return ( + + ROUTES.INSTANCE.BRANCHES.BRANCHES.path, + }} + /> + + ) +} diff --git a/ui/packages/ce/src/App/Instance/Branches/index.tsx b/ui/packages/ce/src/App/Instance/Branches/index.tsx new file mode 100644 index 00000000..5dfb787e --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Branches/index.tsx @@ -0,0 +1,21 @@ +import { Switch, Route, Redirect } from 'react-router-dom' + +import { ROUTES } from 'config/routes' +import { TABS_INDEX } from '@postgres.ai/shared/pages/Instance/Tabs' + +import { Page } from '../Page' +import { Branch } from './Branch' + +export const Branches = () => { + return ( + + + + + + + + + + ) +} diff --git a/ui/packages/ce/src/App/Instance/Clones/Clone/index.tsx b/ui/packages/ce/src/App/Instance/Clones/Clone/index.tsx index f5bc914d..9cad3c38 100644 --- a/ui/packages/ce/src/App/Instance/Clones/Clone/index.tsx +++ b/ui/packages/ce/src/App/Instance/Clones/Clone/index.tsx @@ -9,6 +9,8 @@ import { getClone } from 'api/clones/getClone' import { resetClone } from 'api/clones/resetClone' import { destroyClone } from 'api/clones/destroyClone' import { updateClone } from 'api/clones/updateClone' +import { createSnapshot } from 'api/snapshots/createSnapshot' + import { PageContainer } from 'components/PageContainer' import { NavPath } from 'components/NavPath' import { ROUTES } from 'config/routes' @@ -28,6 +30,7 @@ export const Clone = () => { resetClone, destroyClone, updateClone, + createSnapshot, } const elements = { @@ -35,9 +38,9 @@ export const Clone = () => { { const routes = { @@ -21,12 +22,17 @@ export const CreateClone = () => { getInstanceRetrieval, createClone, getClone, + getBranches, } const elements = { breadcrumbs: ( ), } diff --git a/ui/packages/ce/src/App/Instance/Clones/index.tsx b/ui/packages/ce/src/App/Instance/Clones/index.tsx index 390f3e11..a39efa94 100644 --- a/ui/packages/ce/src/App/Instance/Clones/index.tsx +++ b/ui/packages/ce/src/App/Instance/Clones/index.tsx @@ -1,9 +1,12 @@ import { Switch, Route, Redirect } from 'react-router-dom' +import { TABS_INDEX } from '@postgres.ai/shared/pages/Instance/Tabs' + import { ROUTES } from 'config/routes' import { CreateClone } from './CreateClone' import { Clone } from './Clone' +import { Page } from '../Page' export const Clones = () => { return ( @@ -16,6 +19,10 @@ export const Clones = () => { + + + + ) diff --git a/ui/packages/ce/src/App/Instance/Page/index.tsx b/ui/packages/ce/src/App/Instance/Page/index.tsx index 4db8cfc1..3fe7cd6a 100644 --- a/ui/packages/ce/src/App/Instance/Page/index.tsx +++ b/ui/packages/ce/src/App/Instance/Page/index.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react' + import { Instance } from '@postgres.ai/shared/pages/Instance' import { PageContainer } from 'components/PageContainer' @@ -6,6 +8,7 @@ import { ROUTES } from 'config/routes' import { getInstance } from 'api/instances/getInstance' import { getInstanceRetrieval } from 'api/instances/getInstanceRetrieval' import { getSnapshots } from 'api/snapshots/getSnapshots' +import { createSnapshot } from 'api/snapshots/createSnapshot' import { destroyClone } from 'api/clones/destroyClone' import { resetClone } from 'api/clones/resetClone' import { getWSToken } from 'api/engine/getWSToken' @@ -15,8 +18,11 @@ import { getFullConfig } from 'api/configs/getFullConfig' import { updateConfig } from 'api/configs/updateConfig' import { testDbSource } from 'api/configs/testDbSource' import { getEngine } from 'api/engine/getEngine' +import { createBranch } from 'api/branches/createBranch' +import { getBranches } from 'api/branches/getBranches' +import { getSnapshotList } from 'api/branches/getSnapshotList' -export const Page = () => { +export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { const routes = { createClone: () => ROUTES.INSTANCE.CLONES.CREATE.path, clone: (cloneId: string) => @@ -27,6 +33,7 @@ export const Page = () => { getInstance, getInstanceRetrieval, getSnapshots, + createSnapshot, destroyClone, resetClone, getWSToken, @@ -36,12 +43,19 @@ export const Page = () => { testDbSource, initWS, getEngine, + createBranch, + getBranches, + getSnapshotList, } const elements = { breadcrumbs: , } + useEffect(() => { + window.history.replaceState({}, document.title, ROUTES.INSTANCE.path) + }, []) + return ( { routes={routes} api={api} elements={elements} + renderCurrentTab={renderCurrentTab} /> ) diff --git a/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx b/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx new file mode 100644 index 00000000..6cd12e90 --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx @@ -0,0 +1,54 @@ +import { useParams } from 'react-router-dom' + +import { SnapshotPage } from '@postgres.ai/shared/pages/Snapshots/Snapshot' + +import { NavPath } from 'components/NavPath' +import { ROUTES } from 'config/routes' +import { PageContainer } from 'components/PageContainer' + +import { destroySnapshot } from 'api/snapshots/destroySnapshot' +import { getSnapshots } from 'api/snapshots/getSnapshots' +import { getBranchSnapshot } from 'api/snapshots/getBranchSnapshot' + +type Params = { + snapshotId: string +} + +export const Snapshot = () => { + const { snapshotId } = useParams() + + const api = { + destroySnapshot, + getSnapshots, + getBranchSnapshot, + } + + const elements = { + breadcrumbs: ( + + ), + } + + return ( + + ROUTES.INSTANCE.SNAPSHOTS.SNAPSHOTS.path, + }} + api={api} + elements={elements} + /> + + ) +} diff --git a/ui/packages/ce/src/App/Instance/Snapshots/index.tsx b/ui/packages/ce/src/App/Instance/Snapshots/index.tsx new file mode 100644 index 00000000..cbb77f8c --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Snapshots/index.tsx @@ -0,0 +1,22 @@ +import { Switch, Route, Redirect } from 'react-router-dom' + +import { TABS_INDEX } from '@postgres.ai/shared/pages/Instance/Tabs' + +import { ROUTES } from 'config/routes' + +import { Page } from '../Page' +import { Snapshot } from './Snapshot' + +export const Snapshots = () => { + return ( + + + + + + + + + + ) +} diff --git a/ui/packages/ce/src/App/Instance/index.tsx b/ui/packages/ce/src/App/Instance/index.tsx index 65422988..d2326f2c 100644 --- a/ui/packages/ce/src/App/Instance/index.tsx +++ b/ui/packages/ce/src/App/Instance/index.tsx @@ -4,6 +4,8 @@ import { ROUTES } from 'config/routes' import { Page } from './Page' import { Clones } from './Clones' +import { Snapshots } from './Snapshots' +import { Branches } from './Branches' export const Instance = () => { return ( @@ -14,6 +16,12 @@ export const Instance = () => { + + + + + + ) diff --git a/ui/packages/ce/src/api/branches/createBranch.ts b/ui/packages/ce/src/api/branches/createBranch.ts new file mode 100644 index 00000000..6b16d938 --- /dev/null +++ b/ui/packages/ce/src/api/branches/createBranch.ts @@ -0,0 +1,26 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +import { CreateBranchFormValues } from '@postgres.ai/shared/types/api/endpoints/createBranch' + +export const createBranch = async (req: CreateBranchFormValues) => { + const response = await request('/branch/create', { + method: 'POST', + body: JSON.stringify({ + branchName: req.branchName, + ...(req.baseBranch && { baseBranch: req.baseBranch }), + ...(req.snapshotID && { snapshotID: req.snapshotID }), + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/api/branches/deleteBranch.ts b/ui/packages/ce/src/api/branches/deleteBranch.ts new file mode 100644 index 00000000..b9cae513 --- /dev/null +++ b/ui/packages/ce/src/api/branches/deleteBranch.ts @@ -0,0 +1,22 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export const deleteBranch = async (branchName: string) => { + const response = await request('/branch/delete', { + method: 'POST', + body: JSON.stringify({ + branchName: branchName, + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/api/branches/getBranches.ts b/ui/packages/ce/src/api/branches/getBranches.ts new file mode 100644 index 00000000..849b2e19 --- /dev/null +++ b/ui/packages/ce/src/api/branches/getBranches.ts @@ -0,0 +1,17 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export const getBranches = async () => { + const response = await request(`/branch/list`) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/api/branches/getSnapshotList.ts b/ui/packages/ce/src/api/branches/getSnapshotList.ts new file mode 100644 index 00000000..f9d47832 --- /dev/null +++ b/ui/packages/ce/src/api/branches/getSnapshotList.ts @@ -0,0 +1,22 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export const getSnapshotList = async (branchName: string) => { + const response = await request('/branch/log', { + method: 'POST', + body: JSON.stringify({ + branchName: branchName, + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/api/clones/createClone.ts b/ui/packages/ce/src/api/clones/createClone.ts index 5ca1f168..e3fbacd1 100644 --- a/ui/packages/ce/src/api/clones/createClone.ts +++ b/ui/packages/ce/src/api/clones/createClone.ts @@ -15,6 +15,7 @@ export const createClone: CreateClone = async (req) => { id: req.snapshotId, }, protected: req.isProtected, + ...(req.branch && { branch: req.branch }), db: { username: req.dbUser, password: req.dbPassword, diff --git a/ui/packages/ce/src/api/configs/updateConfig.ts b/ui/packages/ce/src/api/configs/updateConfig.ts index 87f9b93b..f5cf267d 100644 --- a/ui/packages/ce/src/api/configs/updateConfig.ts +++ b/ui/packages/ce/src/api/configs/updateConfig.ts @@ -1,7 +1,7 @@ import { postUniqueCustomOptions, postUniqueDatabases, -} from '@postgres.ai/shared/pages/Configuration/utils' +} from '@postgres.ai/shared/pages/Instance/Configuration/utils' import { Config } from '@postgres.ai/shared/types/api/entities/config' import { request } from 'helpers/request' diff --git a/ui/packages/ce/src/api/snapshots/createSnapshot.ts b/ui/packages/ce/src/api/snapshots/createSnapshot.ts new file mode 100644 index 00000000..212d6245 --- /dev/null +++ b/ui/packages/ce/src/api/snapshots/createSnapshot.ts @@ -0,0 +1,25 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { CreateSnapshot } from '@postgres.ai/shared/types/api/endpoints/createSnapshot' + +import { request } from 'helpers/request' + +export const createSnapshot: CreateSnapshot = async (cloneId, message) => { + const response = await request(`/branch/snapshot`, { + method: 'POST', + body: JSON.stringify({ + cloneID: cloneId, + ...(message && { message: message }), + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/api/snapshots/destroySnapshot.ts b/ui/packages/ce/src/api/snapshots/destroySnapshot.ts new file mode 100644 index 00000000..c18a5217 --- /dev/null +++ b/ui/packages/ce/src/api/snapshots/destroySnapshot.ts @@ -0,0 +1,24 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { DestroySnapshot } from '@postgres.ai/shared/types/api/endpoints/destroySnapshot' + +import { request } from 'helpers/request' + +export const destroySnapshot: DestroySnapshot = async (snapshotId) => { + const response = await request(`/snapshot/delete`, { + method: 'POST', + body: JSON.stringify({ + snapshotID: snapshotId, + }), + }) + + return { + response: response.ok ? true : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/api/snapshots/getBranchSnapshot.ts b/ui/packages/ce/src/api/snapshots/getBranchSnapshot.ts new file mode 100644 index 00000000..26f0e2ce --- /dev/null +++ b/ui/packages/ce/src/api/snapshots/getBranchSnapshot.ts @@ -0,0 +1,17 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export const getBranchSnapshot = async (snapshotId: string) => { + const response = await request(`/branch/snapshot/${snapshotId}`) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/ce/src/config/routes.tsx b/ui/packages/ce/src/config/routes.tsx index 47e29a6d..ca51aa8f 100644 --- a/ui/packages/ce/src/config/routes.tsx +++ b/ui/packages/ce/src/config/routes.tsx @@ -1,5 +1,5 @@ export const ROUTES = { - name: 'Database Lab', + name: 'Database Lab Engine', path: '/', AUTH: { @@ -11,6 +11,35 @@ export const ROUTES = { path: `/instance`, name: 'Instance', + SNAPSHOTS: { + path: `/instance/snapshots`, + + SNAPSHOTS: { + name: 'Snapshots', + path: `/instance/snapshots`, + }, + + SNAPSHOT: { + name: 'Snapshot', + createPath: (snapshotId = ':snapshotId') => + `/instance/snapshots/${snapshotId}`, + }, + }, + BRANCHES: { + path: `/instance/branches`, + + BRANCHES: { + name: 'Branches', + path: `/instance/branches`, + }, + + BRANCH: { + name: 'Branches', + createPath: (branchId = ':branchId') => + `/instance/branches/${branchId}`, + }, + }, + CLONES: { path: `/instance/clones`, @@ -19,6 +48,11 @@ export const ROUTES = { path: `/instance/clones/create`, }, + CLONES: { + name: 'Clones', + path: `/instance/clones`, + }, + CLONE: { name: 'Clone', createPath: (cloneId = ':cloneId') => `/instance/clones/${cloneId}`, diff --git a/ui/packages/platform/src/components/DbLabSession/DbLabSession.tsx b/ui/packages/platform/src/components/DbLabSession/DbLabSession.tsx index 7d0884ee..b79caa03 100644 --- a/ui/packages/platform/src/components/DbLabSession/DbLabSession.tsx +++ b/ui/packages/platform/src/components/DbLabSession/DbLabSession.tsx @@ -27,6 +27,7 @@ import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' import { Spinner } from '@postgres.ai/shared/components/Spinner' import { icons } from '@postgres.ai/shared/styles/icons' import { ClassesType } from '@postgres.ai/platform/src/components/types' +import { isValidDate } from '@postgres.ai/shared/utils/date' import Store from '../../stores/store' import Actions from '../../actions/actions' @@ -501,10 +502,11 @@ class DbLabSession extends Component< Created: - {session && - formatDistanceToNowStrict(new Date(session.started_at), { - addSuffix: true, - })} + {session && isValidDate(new Date(session.started_at)) + ? formatDistanceToNowStrict(new Date(session.started_at), { + addSuffix: true, + }) + : '-'}
diff --git a/ui/packages/platform/src/components/DbLabSessions/DbLabSessions.tsx b/ui/packages/platform/src/components/DbLabSessions/DbLabSessions.tsx index 7a1ae7a6..9da2aa86 100644 --- a/ui/packages/platform/src/components/DbLabSessions/DbLabSessions.tsx +++ b/ui/packages/platform/src/components/DbLabSessions/DbLabSessions.tsx @@ -23,6 +23,7 @@ import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' import { Spinner } from '@postgres.ai/shared/components/Spinner' import { icons } from '@postgres.ai/shared/styles/icons' import { ClassesType } from '@postgres.ai/platform/src/components/types' +import { isValidDate } from '@postgres.ai/shared/utils/date' import Store from '../../stores/store' import Actions from '../../actions/actions' @@ -344,10 +345,14 @@ class DbLabSessions extends Component {
{icons.calendar} created  - {formatDistanceToNowStrict( - new Date(s.started_at), - { addSuffix: true }, - )} + {isValidDate(new Date(s.started_at)) + ? formatDistanceToNowStrict( + new Date(s.started_at), + { + addSuffix: true, + }, + ) + : '-'} {s.tags && s.tags.launched_by ? ( by {s.tags.launched_by} ) : ( diff --git a/ui/packages/platform/src/components/JoeHistory/JoeHistory.tsx b/ui/packages/platform/src/components/JoeHistory/JoeHistory.tsx index 45132ef7..3e75205c 100644 --- a/ui/packages/platform/src/components/JoeHistory/JoeHistory.tsx +++ b/ui/packages/platform/src/components/JoeHistory/JoeHistory.tsx @@ -31,6 +31,7 @@ import { Spinner } from '@postgres.ai/shared/components/Spinner' import { icons } from '@postgres.ai/shared/styles/icons' import { Link } from '@postgres.ai/shared/components/Link2' import { ClassesType } from '@postgres.ai/platform/src/components/types' +import { isValidDate } from '@postgres.ai/shared/utils/date' import Store from '../../stores/store' import Actions from '../../actions/actions' @@ -834,10 +835,14 @@ class JoeHistory extends Component { classes={{ tooltip: classes.toolTip }} > - {formatDistanceToNowStrict( - new Date(c.created_at), - { addSuffix: true }, - )} + {isValidDate(new Date(c.created_at)) + ? formatDistanceToNowStrict( + new Date(c.created_at), + { + addSuffix: true, + }, + ) + : '-'}
diff --git a/ui/packages/platform/src/pages/JoeSessionCommand/index.js b/ui/packages/platform/src/pages/JoeSessionCommand/index.js index f625dbfd..8c627da9 100644 --- a/ui/packages/platform/src/pages/JoeSessionCommand/index.js +++ b/ui/packages/platform/src/pages/JoeSessionCommand/index.js @@ -22,6 +22,7 @@ import { formatDistanceToNowStrict } from 'date-fns'; import { FormattedText } from '@postgres.ai/shared/components/FormattedText'; import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner'; import { Spinner } from '@postgres.ai/shared/components/Spinner'; +import { isValidDate } from '@postgres.ai/shared/utils/date' import Store from 'stores/store'; import Actions from 'actions/actions'; @@ -406,9 +407,13 @@ class JoeSessionCommand extends Component {

Details:

- Uploaded: { - formatDistanceToNowStrict(new Date(data.createdAt), { addSuffix: true }) - }  + Uploaded:  + {isValidDate(new Date(data.createdAt)) + ? formatDistanceToNowStrict(new Date(data.createdAt), { + addSuffix: true, + }) + : '-'} +   ({ format.formatTimestampUtc(data.createdAt) }) diff --git a/ui/packages/shared/components/ResetCloneModal/index.tsx b/ui/packages/shared/components/ResetCloneModal/index.tsx index c3b26fcf..c32e249b 100644 --- a/ui/packages/shared/components/ResetCloneModal/index.tsx +++ b/ui/packages/shared/components/ResetCloneModal/index.tsx @@ -19,6 +19,7 @@ import { Spinner } from '@postgres.ai/shared/components/Spinner' import { SimpleModalControls } from '@postgres.ai/shared/components/SimpleModalControls' import { compareSnapshotsDesc } from '@postgres.ai/shared/utils/snapshot' import { InstanceState } from '@postgres.ai/shared/types/api/entities/instanceState' +import { isValidDate } from '@postgres.ai/shared/utils/date' type Props = { isOpen: boolean @@ -112,10 +113,10 @@ export const ResetCloneModal = (props: Props) => { children: ( <> {snapshot.dataStateAt} ( - {formatDistanceToNowStrict(snapshot.dataStateAtDate, { - addSuffix: true, - })} - ) + {isValidDate(snapshot.dataStateAtDate) && + formatDistanceToNowStrict(snapshot.dataStateAtDate, { + addSuffix: true, + })} {isLatest && ( Latest )} diff --git a/ui/packages/shared/icons/PostgresSQL/icon.svg b/ui/packages/shared/icons/PostgresSQL/icon.svg new file mode 100644 index 00000000..4d358ef1 --- /dev/null +++ b/ui/packages/shared/icons/PostgresSQL/icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/packages/shared/icons/PostgresSQL/index.ts b/ui/packages/shared/icons/PostgresSQL/index.ts new file mode 100644 index 00000000..7fd8995f --- /dev/null +++ b/ui/packages/shared/icons/PostgresSQL/index.ts @@ -0,0 +1,3 @@ +import { ReactComponent } from './icon.svg' + +export const PostgresSQL = ReactComponent diff --git a/ui/packages/shared/pages/Branches/Branch/context.ts b/ui/packages/shared/pages/Branches/Branch/context.ts new file mode 100644 index 00000000..ed6144ab --- /dev/null +++ b/ui/packages/shared/pages/Branches/Branch/context.ts @@ -0,0 +1,21 @@ +import { createStrictContext } from '@postgres.ai/shared/utils/react' + +import { Api } from './stores/Main' +import { Stores } from './useCreatedStores' + +export type Host = { + branchId: string + routes: { + branch: () => string + } + api: Api + elements: { + breadcrumbs: React.ReactNode + } +} + +export const { useStrictContext: useHost, Provider: HostProvider } = + createStrictContext() + +export const { useStrictContext: useStores, Provider: StoresProvider } = + createStrictContext() diff --git a/ui/packages/shared/pages/Branches/Branch/index.tsx b/ui/packages/shared/pages/Branches/Branch/index.tsx new file mode 100644 index 00000000..900d7698 --- /dev/null +++ b/ui/packages/shared/pages/Branches/Branch/index.tsx @@ -0,0 +1,342 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useEffect, useState } from 'react' +import { useHistory } from 'react-router' +import { observer } from 'mobx-react-lite' +import copyToClipboard from 'copy-to-clipboard' +import { + makeStyles, + Button, + TextField, + IconButton, + Table, + TableHead, + TableRow, + TableBody, +} from '@material-ui/core' + +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' +import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' +import { Spinner } from '@postgres.ai/shared/components/Spinner' +import { Tooltip } from '@postgres.ai/shared/components/Tooltip' +import { icons } from '@postgres.ai/shared/styles/icons' +import { styles } from '@postgres.ai/shared/styles/styles' +import { DeleteBranchModal } from '@postgres.ai/shared/pages/Branches/components/Modals/DeleteBranchModal' +import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' +import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' +import { + TableBodyCell, + TableBodyCellMenu, + TableHeaderCell, +} from '@postgres.ai/shared/components/Table' + +import { useCreatedStores } from './useCreatedStores' +import { Host } from './context' + +type Props = Host + +const useStyles = makeStyles( + () => ({ + marginTop: { + marginTop: '16px', + }, + container: { + maxWidth: '100%', + marginTop: '16px', + + '& p,span': { + fontSize: 14, + }, + }, + actions: { + display: 'flex', + marginRight: '-16px', + }, + spinner: { + marginLeft: '8px', + }, + actionButton: { + marginRight: '16px', + }, + summary: { + marginTop: 20, + }, + text: { + marginTop: '4px', + }, + paramTitle: { + display: 'inline-block', + width: 200, + }, + copyFieldContainer: { + position: 'relative', + display: 'block', + maxWidth: 400, + width: '100%', + }, + textField: { + ...styles.inputField, + 'max-width': 400, + display: 'inline-block', + '& .MuiOutlinedInput-input': { + paddingRight: '32px!important', + }, + }, + tableContainer: { + position: 'relative', + maxWidth: 400, + width: '100%', + }, + copyButton: { + position: 'absolute', + top: 16, + right: 0, + zIndex: 100, + width: 32, + height: 32, + padding: 8, + }, + pointerCursor: { + cursor: 'pointer', + }, + }), + { index: 1 }, +) + +export const BranchesPage = observer((props: Props) => { + const classes = useStyles() + const history = useHistory() + const stores = useCreatedStores(props) + + const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false) + + const { + branch, + snapshotList, + deleteBranch, + reload, + load, + isReloading, + isBranchesLoading, + getBranchesError, + snapshotListError, + deleteBranchError, + getBranchError, + } = stores.main + + const handleDestroyBranch = async () => { + const isSuccess = await deleteBranch(props.branchId) + if (isSuccess) history.push(props.routes.branch()) + } + + const hasBranchError = getBranchesError || getBranchError || snapshotListError + + const branchLogLength = snapshotList?.reduce((acc, snapshot) => { + if (snapshot?.branch !== null) { + return acc + snapshot.branch?.length + } else { + return acc + } + }, 0) + + const BranchHeader = () => { + return ( + <> + {props.elements.breadcrumbs} + + + ) + } + + useEffect(() => { + load(props.branchId) + }, []) + + if (isBranchesLoading) return + + if (hasBranchError) { + return ( + <> + + + + ) + } + + return ( + <> + +
+
+ + +
+
+
+
+

+ Name +

+

{branch?.name}

+
+
+
+

+ Data state at  + + Data state time is a time at which data + is  recovered for this snapshot. + + } + > + {icons.infoIcon} + +

+

{branch?.dataStateAt || '-'}

+
+
+

+ Summary  +

+

+ Parent branch: + {branch?.parent} +

+
+
+

+ Snapshot info +

+
+ + copyToClipboard(String(branch?.snapshotID))} + > + {icons.copyIcon} + +
+
+ {Number(branchLogLength) > 0 && ( + <> + Branch log ({branchLogLength}) + + + + + + Name + Data state at + Comment + + + {snapshotList?.map((snapshot, id) => ( + + {snapshot?.branch?.map((item, id) => ( + + generateSnapshotPageId(snapshot.id) && + history.push( + `/instance/snapshots/${generateSnapshotPageId( + snapshot.id, + )}`, + ) + } + > + copyToClipboard(item), + }, + ]} + /> + {item} + + {snapshot.dataStateAt || '-'} + + + {snapshot.comment ?? '-'} + + + ))} + + ))} +
+
+ + )} +
+ setIsOpenDestroyModal(false)} + deleteBranchError={deleteBranchError} + deleteBranch={handleDestroyBranch} + branchName={props.branchId} + /> +
+ + ) +}) diff --git a/ui/packages/shared/pages/Branches/Branch/stores/Main.ts b/ui/packages/shared/pages/Branches/Branch/stores/Main.ts new file mode 100644 index 00000000..052b9f9d --- /dev/null +++ b/ui/packages/shared/pages/Branches/Branch/stores/Main.ts @@ -0,0 +1,131 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { makeAutoObservable } from 'mobx' + +import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { GetBranchesResponseType } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { DeleteBranch } from '@postgres.ai/shared/types/api/endpoints/deleteBranch' +import { + GetSnapshotList, + GetSnapshotListResponseType, +} from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' + +type Error = { + title?: string + message: string +} + +export type Api = { + getBranches: GetBranches + deleteBranch: DeleteBranch + getSnapshotList: GetSnapshotList +} + +export class MainStore { + getBranchError: Error | null = null + snapshotListError: Error | null = null + deleteBranchError: Error | null = null + getBranchesError: Error | null = null + + isReloading = false + isBranchesLoading = false + + branches: GetBranchesResponseType[] = [] + branch: GetBranchesResponseType | null = null + snapshotList: GetSnapshotListResponseType[] | null = null + + private readonly api: Api + + constructor(api: Api) { + this.api = api + makeAutoObservable(this) + } + + load = async (branchId: string) => { + if (!branchId) return + + this.isBranchesLoading = true + + await this.getBranches(branchId) + } + + reload = async (branchId: string) => { + if (!branchId) return + + this.isReloading = true + await this.getBranches(branchId) + this.isReloading = false + } + + getBranches = async (branchId: string) => { + if (!this.api.getBranches) return + const { response, error } = await this.api.getBranches() + + if (error) { + this.isBranchesLoading = false + this.getBranchesError = await error.json().then((err) => err) + } + + if (response) { + this.branches = response + this.getBranch(branchId) + } + + return response + } + + getBranch = async (branchId: string) => { + const currentBranch = this.branches?.filter((s) => { + return s.name === branchId + }) + + if (currentBranch && currentBranch?.length > 0) { + this.branch = currentBranch[0] + this.getSnapshotList(currentBranch[0].name) + } else { + this.getBranchError = { + title: 'Error', + message: `Branch "${branchId}" not found`, + } + } + + return !!currentBranch + } + + deleteBranch = async (branchName: string) => { + if (!branchName) return + + this.deleteBranchError = null + + const { response, error } = await this.api.deleteBranch(branchName) + + if (error) { + this.deleteBranchError = await error.json().then((err) => err) + } + + return response + } + + getSnapshotList = async (branchName: string) => { + if (!this.api.getSnapshotList) return + + const { response, error } = await this.api.getSnapshotList(branchName) + + this.isBranchesLoading = false + + if (error) { + this.snapshotListError = await error.json().then((err) => err) + } + + if (response) { + this.snapshotList = response + } + + return response + } +} diff --git a/ui/packages/shared/pages/Branches/Branch/useCreatedStores.ts b/ui/packages/shared/pages/Branches/Branch/useCreatedStores.ts new file mode 100644 index 00000000..7164757e --- /dev/null +++ b/ui/packages/shared/pages/Branches/Branch/useCreatedStores.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react' + +import { MainStore } from './stores/Main' +import { Host } from './context' + +export const useCreatedStores = (host: Host) => ({ + main: useMemo(() => new MainStore(host.api), []), +}) + +export type Stores = ReturnType diff --git a/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx b/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx new file mode 100644 index 00000000..25fded20 --- /dev/null +++ b/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx @@ -0,0 +1,97 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import copy from 'copy-to-clipboard' +import { makeStyles } from '@material-ui/core' +import { useHistory } from 'react-router-dom' + +import { GetBranchesResponseType } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' +import { + Table, + TableHead, + TableRow, + TableBody, + TableHeaderCell, + TableBodyCell, + TableBodyCellMenu, +} from '@postgres.ai/shared/components/Table' + +const useStyles = makeStyles( + { + cellContentCentered: { + display: 'flex', + alignItems: 'center', + }, + pointerCursor: { + cursor: 'pointer', + }, + sortIcon: { + marginLeft: '8px', + width: '10px', + }, + marginTop: { + marginTop: '16px', + }, + }, + { index: 1 }, +) + +export const BranchesTable = ({ + branchesData, + emptyTableText, +}: { + branchesData: GetBranchesResponseType[] + emptyTableText: string +}) => { + const history = useHistory() + const classes = useStyles() + + if (!branchesData.length) { + return

{emptyTableText}

+ } + + return ( + + + + + + Branch + Parent + Data state time + Snapshot ID + + + + {branchesData?.map((branch) => ( + history.push(`/instance/branches/${branch.name}`)} + className={classes.pointerCursor} + > + copy(branch.snapshotID), + }, + ]} + /> + + {branch.name} + {branch.parent} + {branch.dataStateAt || '-'} + {branch.snapshotID} + + ))} + +
+
+ ) +} diff --git a/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/index.tsx b/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/index.tsx new file mode 100644 index 00000000..fa91753b --- /dev/null +++ b/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/index.tsx @@ -0,0 +1,193 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useHistory } from 'react-router' +import { useState, useEffect } from 'react' +import { TextField, makeStyles } from '@material-ui/core' + +import { Modal } from '@postgres.ai/shared/components/Modal' +import { Button } from '@postgres.ai/shared/components/Button' +import { ResponseMessage } from '@postgres.ai/shared/pages/Instance/Configuration/ResponseMessage' +import { Select } from '@postgres.ai/shared/components/Select' +import { MainStore } from '@postgres.ai/shared/pages/Instance/stores/Main' +import { ModalProps } from '@postgres.ai/shared/pages/Branches/components/Modals/types' +import { CreateBranchFormValues } from '@postgres.ai/shared/types/api/endpoints/createBranch' +import { GetBranchesResponseType } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { GetSnapshotListResponseType } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' + +import { useForm } from './useForm' + +import styles from '../styles.module.scss' + +interface CreateBranchModalProps extends ModalProps { + createBranchError: string | null + snapshotListError: string | null + createBranch: MainStore['createBranch'] + branchesList: GetBranchesResponseType[] | null + getSnapshotList: MainStore['getSnapshotList'] +} + +const useStyles = makeStyles( + { + marginBottom: { + marginBottom: '8px', + }, + marginTop: { + marginTop: '8px', + }, + }, + { index: 1 }, +) + +export const CreateBranchModal = ({ + isOpen, + onClose, + createBranchError, + snapshotListError, + createBranch, + branchesList, + getSnapshotList, +}: CreateBranchModalProps) => { + const classes = useStyles() + const history = useHistory() + const [branchError, setBranchError] = useState(createBranchError) + const [snapshotsList, setSnapshotsList] = useState< + GetSnapshotListResponseType[] | null + >() + + const handleClose = () => { + formik.resetForm() + setBranchError('') + onClose() + } + + const handleSubmit = async (values: CreateBranchFormValues) => { + await createBranch(values).then((branch) => { + if (branch && branch?.name) { + history.push(`/instance/branches/${branch.name}`) + } + }) + } + + const [{ formik, isFormDisabled }] = useForm(handleSubmit) + + useEffect(() => { + setBranchError(createBranchError || snapshotListError) + }, [createBranchError, snapshotListError]) + + useEffect(() => { + if (isOpen) { + getSnapshotList(formik.values.baseBranch).then((res) => { + if (res) { + const filteredSnapshots = res.filter((snapshot) => snapshot.id) + setSnapshotsList(filteredSnapshots) + formik.setFieldValue('snapshotID', filteredSnapshots[0]?.id) + } + }) + } + }, [isOpen, formik.values.baseBranch]) + + return ( + +
+ formik.setFieldValue('branchName', e.target.value)} + /> + Parent branch +

+ Choose an existing branch. The new branch will initially point at the + same snapshot as the parent branch but going further, their evolution + paths will be independent - new snapshots can be created for both + branches. +

+ formik.setFieldValue('snapshotID', e.target.value)} + error={Boolean(formik.errors.baseBranch)} + items={ + snapshotsList + ? snapshotsList.map((snapshot, i) => { + const isLatest = i === 0 + return { + value: snapshot.id, + children: ( +
+ + {snapshot?.id} {isLatest && Latest} + + {snapshot?.dataStateAt && ( +

Data state at: {snapshot?.dataStateAt}

+ )} +
+ ), + } + }) + : [] + } + /> + + {branchError || + (snapshotListError && ( + + ))} +
+
+ ) +} diff --git a/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/useForm.ts b/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/useForm.ts new file mode 100644 index 00000000..9c93a1cd --- /dev/null +++ b/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/useForm.ts @@ -0,0 +1,37 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useFormik } from 'formik' +import { CreateBranchFormValues } from '@postgres.ai/shared/types/api/endpoints/createBranch' + +import * as Yup from 'yup' + +const Schema = Yup.object().shape({ + branchName: Yup.string().required('Branch name is required'), +}) + +export const useForm = (onSubmit: (values: CreateBranchFormValues) => void) => { + const formik = useFormik({ + initialValues: { + branchName: '', + baseBranch: 'main', + snapshotID: '', + creationType: 'branch', + }, + validationSchema: Schema, + onSubmit, + validateOnBlur: false, + validateOnChange: false, + }) + + const isFormDisabled = + formik.isSubmitting || + !formik.values.branchName || + (!formik.values.snapshotID && !formik.values.baseBranch) + + return [{ formik, isFormDisabled }] +} diff --git a/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx b/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx new file mode 100644 index 00000000..d874696b --- /dev/null +++ b/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx @@ -0,0 +1,77 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useEffect, useState } from 'react' +import { makeStyles } from '@material-ui/core' + +import { Modal } from '@postgres.ai/shared/components/Modal' +import { ModalProps } from '@postgres.ai/shared/pages/Branches/components/Modals/types' +import { SimpleModalControls } from '@postgres.ai/shared/components/SimpleModalControls' +import { ImportantText } from '@postgres.ai/shared/components/ImportantText' +import { Text } from '@postgres.ai/shared/components/Text' +interface DeleteBranchModalProps extends ModalProps { + deleteBranchError: { title?: string; message: string } | null + deleteBranch: (branchName: string) => void + branchName: string +} + +const useStyles = makeStyles( + { + errorMessage: { + color: 'red', + marginTop: '10px', + }, + }, + { index: 1 }, +) + +export const DeleteBranchModal = ({ + isOpen, + onClose, + deleteBranchError, + deleteBranch, + branchName, +}: DeleteBranchModalProps) => { + const classes = useStyles() + const [deleteError, setDeleteError] = useState(deleteBranchError?.message) + + const handleSubmit = () => { + deleteBranch(branchName) + } + + const handleClose = () => { + setDeleteError('') + onClose() + } + + useEffect(() => { + setDeleteError(deleteBranchError?.message) + }, [deleteBranchError]) + + return ( + + + Are you sure you want to destroy branch{' '} + {branchName}? + + {deleteError &&

{deleteError}

} + +
+ ) +} diff --git a/ui/packages/shared/pages/Branches/components/Modals/styles.module.scss b/ui/packages/shared/pages/Branches/components/Modals/styles.module.scss new file mode 100644 index 00000000..f00804f6 --- /dev/null +++ b/ui/packages/shared/pages/Branches/components/Modals/styles.module.scss @@ -0,0 +1,27 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +.modalInputContainer { + width: 100%; + display: flex; + flex-direction: column; + gap: 5px; + + div:nth-child(1) { + width: 100%; + } + + button { + width: 120px; + } +} + +.snapshotOverflow { + width: 100%; + word-wrap: break-word; + white-space: initial; +} diff --git a/ui/packages/shared/pages/Branches/components/Modals/types.ts b/ui/packages/shared/pages/Branches/components/Modals/types.ts new file mode 100644 index 00000000..c7226170 --- /dev/null +++ b/ui/packages/shared/pages/Branches/components/Modals/types.ts @@ -0,0 +1,4 @@ +export interface ModalProps { + isOpen: boolean + onClose: () => void +} diff --git a/ui/packages/shared/pages/Branches/index.tsx b/ui/packages/shared/pages/Branches/index.tsx new file mode 100644 index 00000000..4f700ea3 --- /dev/null +++ b/ui/packages/shared/pages/Branches/index.tsx @@ -0,0 +1,114 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { observer } from 'mobx-react-lite' +import { makeStyles } from '@material-ui/core' +import React, { useEffect, useState } from 'react' + +import { useStores } from '@postgres.ai/shared/pages/Instance/context' +import { Button } from '@postgres.ai/shared/components/Button2' +import { GetBranchesResponseType } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { BranchesTable } from '@postgres.ai/shared/pages/Branches/components/BranchesTable' +import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' +import { CreateBranchModal } from '@postgres.ai/shared/pages/Branches/components/Modals/CreateBranchModal' +import { Tooltip } from '@postgres.ai/shared/components/Tooltip' +import { InfoIcon } from '@postgres.ai/shared/icons/Info' + +const useStyles = makeStyles( + { + container: { + marginTop: '16px', + }, + infoIcon: { + height: '12px', + width: '12px', + marginLeft: '8px', + color: '#808080', + }, + }, + { index: 1 }, +) + +export const Branches = observer((): React.ReactElement => { + const stores = useStores() + const classes = useStyles() + const [branchesList, setBranchesList] = useState( + [], + ) + const [isCreateBranchOpen, setIsCreateBranchOpen] = useState(false) + + const { + instance, + getBranches, + getSnapshotList, + snapshotListError, + isBranchesLoading, + getBranchesError, + createBranch, + createBranchError, + } = stores.main + + useEffect(() => { + getBranches().then((response) => { + response && setBranchesList(response) + }) + }, []) + + if (!instance && !isBranchesLoading) return <> + + if (getBranchesError) + return ( + + ) + + if (isBranchesLoading) return + + return ( +
+ + + + {!branchesList.length && ( + + + + )} + + } + /> + + setIsCreateBranchOpen(false)} + createBranch={createBranch} + createBranchError={createBranchError} + branchesList={branchesList} + getSnapshotList={getSnapshotList} + snapshotListError={snapshotListError} + /> +
+ ) +}) diff --git a/ui/packages/shared/pages/Clone/index.tsx b/ui/packages/shared/pages/Clone/index.tsx index a1d9409e..72d89497 100644 --- a/ui/packages/shared/pages/Clone/index.tsx +++ b/ui/packages/shared/pages/Clone/index.tsx @@ -35,12 +35,13 @@ import { Tooltip } from '@postgres.ai/shared/components/Tooltip' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { icons } from '@postgres.ai/shared/styles/icons' import { styles } from '@postgres.ai/shared/styles/styles' +import { CreateSnapshotModal } from '@postgres.ai/shared/pages/Instance/Snapshots/components/CreateSnapshotModal' import { Status } from './Status' import { useCreatedStores } from './useCreatedStores' import { Host } from './context' -const textFieldWidth = 400 +const textFieldWidth = 575 const useStyles = makeStyles( (theme) => ({ @@ -48,7 +49,7 @@ const useStyles = makeStyles( marginTop: '16px', }, container: { - maxWidth: '425px', + maxWidth: textFieldWidth + 25, marginTop: '16px', }, text: { @@ -89,6 +90,8 @@ const useStyles = makeStyles( actions: { display: 'flex', marginRight: '-16px', + flexWrap: 'wrap', + rowGap: '16px', }, actionButton: { marginRight: '16px', @@ -164,6 +167,7 @@ export const Clone = observer((props: Props) => { const [isOpenRestrictionModal, setIsOpenRestrictionModal] = useState(false) const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false) const [isOpenResetModal, setIsOpenResetModal] = useState(false) + const [isCreateSnapshotOpen, setIsCreateSnapshotOpen] = useState(false) // Initial loading data. useEffect(() => { @@ -172,6 +176,7 @@ export const Clone = observer((props: Props) => { const { instance, + snapshots, clone, isResettingClone, isDestroyingClone, @@ -230,6 +235,8 @@ export const Clone = observer((props: Props) => { ) } + const clonesList = instance?.state?.cloning.clones || [] + // Clone reset. const requestResetClone = () => setIsOpenResetModal(true) @@ -313,6 +320,16 @@ export const Clone = observer((props: Props) => { Reload info {isReloading && } + {stores.main.resetCloneError && ( @@ -623,10 +640,19 @@ export const Clone = observer((props: Props) => { isOpen={isOpenResetModal} onClose={() => setIsOpenResetModal(false)} clone={clone} - snapshots={stores.main.snapshots.data} + snapshots={snapshots.data} onResetClone={resetClone} version={instance.state.engine.version} /> + + setIsCreateSnapshotOpen(false)} + createSnapshot={snapshots.createSnapshot} + createSnapshotError={snapshots.snapshotDataError} + clones={clonesList} + currentClone={props.cloneId} + /> diff --git a/ui/packages/shared/pages/Configuration/index.tsx b/ui/packages/shared/pages/Configuration/index.tsx deleted file mode 100644 index 217adc54..00000000 --- a/ui/packages/shared/pages/Configuration/index.tsx +++ /dev/null @@ -1,644 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any small is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useState, useEffect } from 'react' -import { observer } from 'mobx-react-lite' -import Editor from '@monaco-editor/react' -import { - Checkbox, - FormControlLabel, - Typography, - Snackbar, - makeStyles, -} from '@material-ui/core' -import Box from '@mui/material/Box' - -import { Modal } from '@postgres.ai/shared/components/Modal' -import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' -import { Button } from '@postgres.ai/shared/components/Button' -import { ExternalIcon } from '@postgres.ai/shared/icons/External' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { useStores } from '@postgres.ai/shared/pages/Instance/context' -import { MainStore } from '@postgres.ai/shared/pages/Instance/stores/Main' - -import { tooltipText } from './tooltipText' -import { FormValues, useForm } from './useForm' -import { ResponseMessage } from './ResponseMessage' -import { ConfigSectionTitle, Header, ModalTitle } from './Header' -import { - dockerImageOptions, - defaultPgDumpOptions, - defaultPgRestoreOptions, -} from './configOptions' -import { formatDockerImageArray, FormValuesKey, uniqueChipValue } from './utils' -import { - SelectWithTooltip, - InputWithChip, - InputWithTooltip, -} from './InputWithTooltip' - -import styles from './styles.module.scss' - -type PgOptionsType = { - optionType: string - addDefaultOptions: string[] -} - -const NON_LOGICAL_RETRIEVAL_MESSAGE = - 'Configuration editing is only available in logical mode' -const PREVENT_MODIFYING_MESSAGE = 'Editing is disabled by admin' - -const useStyles = makeStyles( - { - checkboxRoot: { - padding: '9px 10px', - }, - grayText: { - color: '#8a8a8a', - fontSize: '12px', - }, - }, - { index: 1 }, -) - -export const Configuration = observer( - ({ - switchActiveTab, - reload, - isConfigurationActive, - disableConfigModification, - }: { - switchActiveTab: (_: null, activeTab: number) => void - reload: () => void - isConfigurationActive: boolean - disableConfigModification?: boolean - }) => { - const classes = useStyles() - const stores = useStores() - const { - config, - updateConfig, - getFullConfig, - fullConfig, - testDbSource, - configError, - dbSourceError, - getFullConfigError, - getEngine, - } = stores.main - const configData: MainStore['config'] = - config && JSON.parse(JSON.stringify(config)) - const isConfigurationDisabled = - !isConfigurationActive || disableConfigModification - const [submitMessage, setSubmitMessage] = useState< - string | React.ReactNode | null - >('') - const [dleEdition, setDledition] = useState('') - const [submitStatus, setSubmitStatus] = useState('') - const [connectionStatus, setConnectionStatus] = useState('') - const [isModalOpen, setIsModalOpen] = useState(false) - const [isConnectionLoading, setIsConnectionLoading] = useState(false) - const [connectionRes, setConnectionRes] = useState(null) - const [dockerImages, setDockerImages] = useState([]) - - const switchTab = async () => { - reload() - switchActiveTab(null, 0) - } - - const onSubmit = async (values: FormValues) => { - setSubmitMessage(null) - await updateConfig(values).then((response) => { - if (response?.ok) { - setSubmitStatus('success') - setSubmitMessage( -

- Changes applied.{' '} - - Switch to Overview - {' '} - to see details and to work with clones -

, - ) - } - }) - } - const [{ formik, connectionData, isConnectionDataValid }] = - useForm(onSubmit) - - const onTestConnectionClick = async () => { - setConnectionRes(null) - Object.keys(connectionData).map(function (key: string) { - if (key !== 'password' && key !== 'db_list') { - formik.validateField(key) - } - }) - if (isConnectionDataValid) { - setIsConnectionLoading(true) - testDbSource(connectionData) - .then((response) => { - if (response) { - setConnectionStatus(response.status) - setConnectionRes(response.message) - setIsConnectionLoading(false) - } - }) - .finally(() => { - setIsConnectionLoading(false) - }) - } - } - - const handleModalClick = async () => { - await getFullConfig() - setIsModalOpen(true) - } - - const handleDeleteChip = ( - _: React.FormEvent, - uniqueValue: string, - id: string, - ) => { - if (formik.values[id as FormValuesKey]) { - let newValues = '' - const currentValues = uniqueChipValue( - String(formik.values[id as FormValuesKey]), - ) - const splitValues = currentValues.split(' ') - const curDividers = String(formik.values[id as FormValuesKey]).match( - /[,(\s)(\n)(\r)(\t)(\r\n)]/gm, - ) - for (let i in splitValues) { - if (curDividers && splitValues[i] !== uniqueValue) { - newValues = - newValues + - splitValues[i] + - (curDividers[i] ? curDividers[i] : '') - } - } - formik.setFieldValue(id, newValues) - } - } - - const handleSelectPgOptions = ( - e: React.ChangeEvent, - formikName: string, - formikValue: string, - initialValue: string | undefined, - pgOptions: PgOptionsType[], - ) => { - let pgValue = formikValue - // set initial value on change - formik.setFieldValue(formikName, initialValue) - - const selectedPgOptions = pgOptions.filter( - (pg) => e.target.value === pg.optionType, - ) - - // add options to formik field - selectedPgOptions.forEach((pg) => { - pg.addDefaultOptions.forEach((addOption) => { - if (!pgValue?.includes(addOption)) { - const addOptionWithSpace = addOption + ' ' - formik.setFieldValue(formikName, (pgValue += addOptionWithSpace)) - } - }) - }) - } - - const handleDockerImageSelect = ( - e: React.ChangeEvent, - ) => { - const newDockerImages = formatDockerImageArray(e.target.value) - setDockerImages(newDockerImages) - handleSelectPgOptions( - e, - 'pgDumpCustomOptions', - formik.values.pgDumpCustomOptions, - configData?.pgDumpCustomOptions, - defaultPgDumpOptions, - ) - handleSelectPgOptions( - e, - 'pgRestoreCustomOptions', - formik.values.pgRestoreCustomOptions, - configData?.pgRestoreCustomOptions, - defaultPgRestoreOptions, - ) - formik.setFieldValue('dockerImageType', e.target.value) - - // select latest Postgres version on dockerImage change - if ( - configData?.dockerImageType !== e.target.value && - e.target.value !== 'custom' - ) { - formik.setFieldValue('dockerImage', newDockerImages.slice(-1)[0]) - } else if (e.target.value === 'custom') { - formik.setFieldValue('dockerImage', '') - } else { - formik.setFieldValue('dockerImage', configData?.dockerImage) - } - } - - // Set initial data, empty string for password - useEffect(() => { - if (configData) { - for (const [key, value] of Object.entries(configData)) { - if (key !== 'password') { - formik.setFieldValue(key, value) - } - setDockerImages( - formatDockerImageArray(configData?.dockerImageType || ''), - ) - } - } - }, [config]) - - useEffect(() => { - // Clear response message on tab change and set dockerImageType - setConnectionRes(null) - setSubmitMessage(null) - getEngine().then((res) => { - setDledition(String(res?.edition)) - }) - }, []) - - return ( -
- - {!config || !dleEdition ? ( -
- -
- ) : ( - -
- - - - formik.setFieldValue('debug', e.target.checked) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Debug mode'} - /> - - - - - DLE manages various database containers, such as clones. This - section defines default container settings. - - {dleEdition !== 'community' ? ( -
- { - return { - value: image.type, - children: image.name, - } - })} - onChange={handleDockerImageSelect} - /> - {formik.values.dockerImageType === 'custom' ? ( - - formik.setFieldValue('dockerImage', e.target.value) - } - /> - ) : ( - { - return { - value: image, - children: image.split(':')[1], - } - })} - onChange={(e) => - formik.setFieldValue('dockerImage', e.target.value) - } - /> - )} - - Haven't found the image you need? Contact support:{' '} - - https://postgres.ai/contact - - - -
- ) : ( - - formik.setFieldValue('dockerImage', e.target.value) - } - /> - )} -
- - - - Default Postgres configuration used for all Postgres instances - running in containers managed by DLE. - - - formik.setFieldValue('sharedBuffers', e.target.value) - } - /> - - formik.setFieldValue( - 'sharedPreloadLibraries', - e.target.value, - ) - } - /> - - - - - - Subsection "retrieval.spec.logicalDump" - - - Source database credentials and dumping options. - - - formik.setFieldValue('host', e.target.value) - } - /> - - formik.setFieldValue('port', e.target.value) - } - /> - - formik.setFieldValue('username', e.target.value) - } - /> - - formik.setFieldValue('password', e.target.value) - } - /> - - formik.setFieldValue('dbname', e.target.value) - } - /> - - formik.setFieldValue('databases', e.target.value) - } - /> - - - - {(connectionStatus && connectionRes) || dbSourceError ? ( - - ) : null} - - - - formik.setFieldValue('dumpParallelJobs', e.target.value) - } - /> - - formik.setFieldValue('restoreParallelJobs', e.target.value) - } - /> - {dleEdition !== 'community' && ( - <> - - formik.setFieldValue( - 'pgDumpCustomOptions', - e.target.value, - ) - } - /> - - formik.setFieldValue( - 'pgRestoreCustomOptions', - e.target.value, - ) - } - /> - - )} - - - Subsection "retrieval.refresh" - - - - Define full data refresh on schedule. The process requires at - least one additional filesystem mount point. The schedule is to - be specified using{' '} - - crontab format - - - . - - - formik.setFieldValue('timetable', e.target.value) - } - /> -
- - - - - - - {(submitStatus && submitMessage) || configError ? ( - - ) : null} - - )} - } - onClose={() => setIsModalOpen(false)} - isOpen={isModalOpen} - size="xl" - > - } - theme="vs-light" - options={{ domReadOnly: true, readOnly: true }} - /> - -
- ) - }, -) diff --git a/ui/packages/shared/pages/CreateClone/index.tsx b/ui/packages/shared/pages/CreateClone/index.tsx index 491d5a2a..5e9d0387 100644 --- a/ui/packages/shared/pages/CreateClone/index.tsx +++ b/ui/packages/shared/pages/CreateClone/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import { observer } from 'mobx-react-lite' import { useTimer } from 'use-timer' @@ -38,6 +38,7 @@ export const CreateClone = observer((props: Props) => { const history = useHistory() const stores = useCreatedStores(props.api) const timer = useTimer() + const [branchesList, setBranchesList] = useState([]) // Form. const onSubmit = async (values: FormValues) => { @@ -56,6 +57,12 @@ export const CreateClone = observer((props: Props) => { // Initial loading data. useEffect(() => { stores.main.load(props.instanceId) + + stores.main.getBranches().then((response) => { + if (response) { + setBranchesList(response.map((branch) => branch.name)) + } + }) }, []) // Redirect when clone is created and stable. @@ -103,13 +110,17 @@ export const CreateClone = observer((props: Props) => { ) - // Instance getting error. - if (stores.main.instanceError) + // Instance/branches getting error. + if (stores.main.instanceError || stores.main.getBranchesError) return ( <> {headRendered} - + ) @@ -134,6 +145,25 @@ export const CreateClone = observer((props: Props) => { )}
+ {branchesList && branchesList.length > 0 && ( + formik.setFieldValue('cloneID', e.target.value)} + error={Boolean(formik.errors.cloneID)} + items={ + clones + ? clones.map((clone, i) => { + const isLatest = i === 0 + const isCurrent = currentClone === clone?.id + return { + value: clone.id, + children: ( +
+ + {clone.id} {isLatest && Latest} + + {isCurrent && ( + + Current{' '} + + )} +

Created: {clone?.snapshot?.createdAt}

+

Data state at: {clone?.snapshot?.dataStateAt}

+
+ ), + } + }) + : [] + } + /> + Comment +

+ Optional comment to be added to the snapshot. +

+ formik.setFieldValue('comment', e.target.value)} + /> +
+ + {snapshotError && ( + + )} +
+ + ) +} diff --git a/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts b/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts new file mode 100644 index 00000000..2b237f0e --- /dev/null +++ b/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts @@ -0,0 +1,33 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useFormik } from 'formik' +import * as Yup from 'yup' + +export type FormValues = { + cloneID: string + comment?: string +} + +const Schema = Yup.object().shape({ + cloneID: Yup.string().required('Branch name is required'), +}) + +export const useForm = (onSubmit: (values: FormValues) => void) => { + const formik = useFormik({ + initialValues: { + cloneID: '', + comment: '', + }, + validationSchema: Schema, + onSubmit, + validateOnBlur: false, + validateOnChange: false, + }) + + return [{ formik }] +} diff --git a/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsModal/index.tsx b/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsModal/index.tsx new file mode 100644 index 00000000..a970e02e --- /dev/null +++ b/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsModal/index.tsx @@ -0,0 +1,84 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { observer } from 'mobx-react-lite' +import { makeStyles } from '@material-ui/core' + +import { useStores } from '@postgres.ai/shared/pages/Instance/context' +import { Modal as ModalBase } from '@postgres.ai/shared/components/Modal' +import { isSameDayUTC } from '@postgres.ai/shared/utils/date' + +import { Tags } from '@postgres.ai/shared/pages/Instance/components/Tags' +import { ModalReloadButton } from '@postgres.ai/shared/pages/Instance/components/ModalReloadButton' +import { SnapshotsTable } from '@postgres.ai/shared/pages/Instance/Snapshots/components/SnapshotsTable' + +import { getTags } from './utils' + +const useStyles = makeStyles( + { + root: { + fontSize: '14px', + marginTop: 0, + }, + emptyStub: { + marginTop: '16px', + }, + }, + { index: 1 }, +) + +export const SnapshotsModal = observer(() => { + const classes = useStyles() + const stores = useStores() + + const { snapshots } = stores.main + if (!snapshots.data) return null + + const filteredSnapshots = snapshots.data.filter((snapshot) => { + const isMatchedByDate = + !stores.snapshotsModal.date || + isSameDayUTC(snapshot.dataStateAtDate, stores.snapshotsModal.date) + + const isMatchedByPool = + !stores.snapshotsModal.pool || + snapshot.pool === stores.snapshotsModal.pool + + return isMatchedByDate && isMatchedByPool + }) + + const isEmpty = !filteredSnapshots.length + + return ( + + } + headerContent={ + + } + > + {!isEmpty ? ( + + ) : ( +

No snapshots found

+ )} +
+ ) +}) diff --git a/ui/packages/shared/pages/Instance/SnapshotsModal/utils.ts b/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsModal/utils.ts similarity index 100% rename from ui/packages/shared/pages/Instance/SnapshotsModal/utils.ts rename to ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsModal/utils.ts diff --git a/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsTable/index.tsx b/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsTable/index.tsx new file mode 100644 index 00000000..260da0f3 --- /dev/null +++ b/ui/packages/shared/pages/Instance/Snapshots/components/SnapshotsTable/index.tsx @@ -0,0 +1,152 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { observer } from 'mobx-react-lite' +import { makeStyles } from '@material-ui/core' +import { formatDistanceToNowStrict } from 'date-fns' +import copy from 'copy-to-clipboard' +import { useHistory } from 'react-router-dom' + +import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' +import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' +import { useStores } from '@postgres.ai/shared/pages/Instance/context' +import { ArrowDropDownIcon } from '@postgres.ai/shared/icons/ArrowDropDown' +import { formatBytesIEC } from '@postgres.ai/shared/utils/units' +import { isSameDayUTC, isValidDate } from '@postgres.ai/shared/utils/date' +import { + Table, + TableHead, + TableRow, + TableBody, + TableHeaderCell, + TableBodyCell, + TableBodyCellMenu, +} from '@postgres.ai/shared/components/Table' + +const useStyles = makeStyles( + { + cellContentCentered: { + display: 'flex', + alignItems: 'center', + }, + pointerCursor: { + cursor: 'pointer', + }, + sortIcon: { + marginLeft: '8px', + width: '10px', + }, + }, + { index: 1 }, +) + +export const SnapshotsTable = observer(() => { + const history = useHistory() + const classes = useStyles() + const stores = useStores() + + const { snapshots } = stores.main + if (!snapshots.data) return null + + const filteredSnapshots = snapshots.data.filter((snapshot) => { + const isMatchedByDate = + !stores.snapshotsModal.date || + isSameDayUTC(snapshot.dataStateAtDate, stores.snapshotsModal.date) + + const isMatchedByPool = + !stores.snapshotsModal.pool || + snapshot.pool === stores.snapshotsModal.pool + + return isMatchedByDate && isMatchedByPool + }) + + return ( + + + + + + Data state time + +
+ Created + +
+
+ Pool + Number of clones + Logical Size + Physical Size + Comment +
+
+ + {filteredSnapshots.map((snapshot) => { + const snapshotPageId = generateSnapshotPageId(snapshot.id) + return ( + + snapshotPageId && + history.push(`/instance/snapshots/${snapshotPageId}`) + } + className={classes.pointerCursor} + > + copy(snapshot.id), + }, + { + name: 'Show related clones', + onClick: () => + stores.clonesModal.openModal({ + snapshotId: snapshot.id, + }), + }, + ]} + /> + + {snapshot.dataStateAt} + {isValidDate(snapshot.dataStateAtDate) + ? formatDistanceToNowStrict(snapshot.dataStateAtDate, { + addSuffix: true, + }) + : '-'} + + + {snapshot.createdAt} ( + {isValidDate(snapshot.createdAtDate) + ? formatDistanceToNowStrict(snapshot.createdAtDate, { + addSuffix: true, + }) + : '-'} + ) + + {snapshot.pool ?? '-'} + {snapshot.numClones ?? '-'} + + {snapshot.physicalSize + ? formatBytesIEC(snapshot.logicalSize) + : '-'} + + + {snapshot.physicalSize + ? formatBytesIEC(snapshot.physicalSize) + : '-'} + + {snapshot.comment ?? '-'} + + ) + })} + +
+
+ ) +}) diff --git a/ui/packages/shared/pages/Instance/Snapshots/components/styles.module.scss b/ui/packages/shared/pages/Instance/Snapshots/components/styles.module.scss new file mode 100644 index 00000000..1fc4f19b --- /dev/null +++ b/ui/packages/shared/pages/Instance/Snapshots/components/styles.module.scss @@ -0,0 +1,31 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +.modalInputContainer { + width: 100%; + display: flex; + flex-direction: column; + gap: 5px; + + div:nth-child(1) { + width: 100%; + } + + button { + width: 120px; + } +} + +.selectContainer { + p { + font-size: 13px; + } +} + +.marginBottom { + margin-bottom: 14px; +} diff --git a/ui/packages/shared/pages/Instance/Snapshots/index.tsx b/ui/packages/shared/pages/Instance/Snapshots/index.tsx new file mode 100644 index 00000000..19a0d8fd --- /dev/null +++ b/ui/packages/shared/pages/Instance/Snapshots/index.tsx @@ -0,0 +1,117 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useState } from 'react' +import { observer } from 'mobx-react-lite' +import { makeStyles } from '@material-ui/core' + +import { useStores } from '@postgres.ai/shared/pages/Instance/context' +import { SnapshotsTable } from '@postgres.ai/shared/pages/Instance/Snapshots/components/SnapshotsTable' +import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' +import { isSameDayUTC } from '@postgres.ai/shared/utils/date' +import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { Button } from '@postgres.ai/shared/components/Button2' +import { CreateSnapshotModal } from '@postgres.ai/shared/pages/Instance/Snapshots/components/CreateSnapshotModal' +import { Tooltip } from '@postgres.ai/shared/components/Tooltip' +import { InfoIcon } from '@postgres.ai/shared/icons/Info' + +const useStyles = makeStyles( + { + marginTop: { + marginTop: '16px', + }, + infoIcon: { + height: '12px', + width: '12px', + marginLeft: '8px', + color: '#808080', + }, + }, + { index: 1 }, +) + +export const Snapshots = observer(() => { + const stores = useStores() + const classes = useStyles() + + const [isCreateSnapshotOpen, setIsCreateSnapshotOpen] = useState(false) + + const { snapshots, instance, createSnapshot, createSnapshotError } = + stores.main + + const filteredSnapshots = + snapshots?.data && + snapshots.data.filter((snapshot) => { + const isMatchedByDate = + !stores.snapshotsModal.date || + isSameDayUTC(snapshot.dataStateAtDate, stores.snapshotsModal.date) + + const isMatchedByPool = + !stores.snapshotsModal.pool || + snapshot.pool === stores.snapshotsModal.pool + + return isMatchedByDate && isMatchedByPool + }) + + const clonesList = instance?.state?.cloning.clones || [] + const isEmpty = !filteredSnapshots?.length + const hasClones = Boolean(clonesList?.length) + + if (!instance && !snapshots.isLoading) return <> + + if (snapshots?.error) + return ( + + ) + + if (snapshots.isLoading) return + + return ( +
+ + + + {!hasClones && ( + + + + )} + + } + /> + {!isEmpty ? ( + + ) : ( +

+ This instance has no active snapshots +

+ )} + setIsCreateSnapshotOpen(false)} + createSnapshot={createSnapshot} + createSnapshotError={createSnapshotError} + clones={clonesList} + /> +
+ ) +}) diff --git a/ui/packages/shared/pages/Instance/Snapshots/utils/index.ts b/ui/packages/shared/pages/Instance/Snapshots/utils/index.ts new file mode 100644 index 00000000..b0ccd8b9 --- /dev/null +++ b/ui/packages/shared/pages/Instance/Snapshots/utils/index.ts @@ -0,0 +1,8 @@ +export const generateSnapshotPageId = (id: string) => { + const splitSnapshotId = id?.split(`@`)[1] + const snapshotPageId = splitSnapshotId?.includes('snapshot_') + ? splitSnapshotId?.split('snapshot_')[1] + : splitSnapshotId + + return snapshotPageId +} diff --git a/ui/packages/shared/pages/Instance/SnapshotsModal/index.tsx b/ui/packages/shared/pages/Instance/SnapshotsModal/index.tsx deleted file mode 100644 index c9fd5f35..00000000 --- a/ui/packages/shared/pages/Instance/SnapshotsModal/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { observer } from 'mobx-react-lite' -import { makeStyles } from '@material-ui/core' -import { formatDistanceToNowStrict } from 'date-fns' -import copy from 'copy-to-clipboard' - -import { useStores } from '@postgres.ai/shared/pages/Instance/context' -import { ArrowDropDownIcon } from '@postgres.ai/shared/icons/ArrowDropDown' -import { Modal as ModalBase } from '@postgres.ai/shared/components/Modal' -import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' -import { - Table, - TableHead, - TableRow, - TableBody, - TableHeaderCell, - TableBodyCell, - TableBodyCellMenu, -} from '@postgres.ai/shared/components/Table' -import { formatBytesIEC } from '@postgres.ai/shared/utils/units' -import { isSameDayUTC } from '@postgres.ai/shared/utils/date' - -import { Tags } from '@postgres.ai/shared/pages/Instance/components/Tags' -import { ModalReloadButton } from '@postgres.ai/shared/pages/Instance/components/ModalReloadButton' - -import { getTags } from './utils' - -const useStyles = makeStyles( - { - root: { - fontSize: '14px', - marginTop: 0, - }, - container: { - maxHeight: '400px', - }, - cellContentCentered: { - display: 'flex', - alignItems: 'center', - }, - sortIcon: { - marginLeft: '8px', - width: '10px', - }, - emptyStub: { - marginTop: '16px', - }, - }, - { index: 1 }, -) - -export const SnapshotsModal = observer(() => { - const classes = useStyles() - const stores = useStores() - - const { snapshots } = stores.main - if (!snapshots.data) return null - - const filteredSnapshots = snapshots.data.filter((snapshot) => { - const isMatchedByDate = - !stores.snapshotsModal.date || - isSameDayUTC(snapshot.dataStateAtDate, stores.snapshotsModal.date) - - const isMatchedByPool = - !stores.snapshotsModal.pool || - snapshot.pool === stores.snapshotsModal.pool - - return isMatchedByDate && isMatchedByPool - }) - - const isEmpty = !filteredSnapshots.length - - return ( - - } - headerContent={ - - } - > - {isEmpty &&

No snapshots found

} - - {!isEmpty && ( - - - - - - Data state time - -
- Created - -
-
- Disk - Size -
-
- - {filteredSnapshots.map((snapshot) => { - return ( - - copy(snapshot.id), - }, - { - name: 'Show related clones', - onClick: () => - stores.clonesModal.openModal({ - snapshotId: snapshot.id, - }), - }, - ]} - /> - - {snapshot.dataStateAt} ( - {formatDistanceToNowStrict(snapshot.dataStateAtDate, { - addSuffix: true, - })} - ) - - - {snapshot.createdAt} ( - {formatDistanceToNowStrict(snapshot.createdAtDate, { - addSuffix: true, - })} - ) - - {snapshot.pool ?? '-'} - - {snapshot.physicalSize - ? formatBytesIEC(snapshot.physicalSize) - : '-'} - - - ) - })} - -
-
- )} -
- ) -}) diff --git a/ui/packages/shared/pages/Instance/Tabs/index.tsx b/ui/packages/shared/pages/Instance/Tabs/index.tsx index 694fc0d5..31229fab 100644 --- a/ui/packages/shared/pages/Instance/Tabs/index.tsx +++ b/ui/packages/shared/pages/Instance/Tabs/index.tsx @@ -11,13 +11,39 @@ import { Tab as TabComponent, Tabs as TabsComponent, } from '@material-ui/core' + import { colors } from '@postgres.ai/shared/styles/colors' +import { PostgresSQL } from '@postgres.ai/shared/icons/PostgresSQL' + +export const TABS_INDEX = { + OVERVIEW: 0, + BRANCHES: 1, + SNAPSHOTS: 2, + CLONES: 3, + LOGS: 4, + CONFIGURATION: 5, +} const useStyles = makeStyles( { tabsRoot: { minHeight: 0, marginTop: '-8px', + + '& .MuiTabs-fixed': { + overflowX: 'auto!important', + }, + + '& .postgres-logo': { + width: '18px', + height: '18px', + }, + }, + + flexRow: { + display: 'flex', + flexDirection: 'row', + gap: '5px', }, tabsIndicator: { height: '3px', @@ -64,35 +90,52 @@ export const Tabs = (props: Props) => { classes={{ root: classes.tabsRoot, indicator: classes.tabsIndicator }} > + + + Clones + + } classes={{ root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, }} - value={1} + value={TABS_INDEX.CLONES} /> - {/* // TODO(Anton): Probably will be later. */} - {/* */} + value={TABS_INDEX.CONFIGURATION} + /> ) } diff --git a/ui/packages/shared/pages/Instance/context.ts b/ui/packages/shared/pages/Instance/context.ts index 4ed154cc..a97c1f34 100644 --- a/ui/packages/shared/pages/Instance/context.ts +++ b/ui/packages/shared/pages/Instance/context.ts @@ -24,9 +24,10 @@ export type Host = { } elements: { breadcrumbs: React.ReactNode - }, + } wsHost?: string hideInstanceTabs?: boolean + renderCurrentTab?: number } // Host context. diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index a60f9092..d01b4ecd 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -14,13 +14,15 @@ import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { Tabs } from './Tabs' +import { TABS_INDEX, Tabs } from './Tabs' import { Logs } from '../Logs' import { Clones } from './Clones' import { Info } from './Info' -import { Configuration } from '../Configuration' -import { ClonesModal } from './ClonesModal' -import { SnapshotsModal } from './SnapshotsModal' +import { Configuration } from './Configuration' +import { Branches } from '../Branches' +import { Snapshots } from './Snapshots' +import { SnapshotsModal } from './Snapshots/components/SnapshotsModal' +import { ClonesModal } from './Clones/ClonesModal' import { Host, HostProvider, StoresProvider } from './context' import PropTypes from 'prop-types' @@ -80,7 +82,7 @@ export const Instance = observer((props: Props) => { instance?.state.retrieving?.status === 'pending' && isConfigurationActive ) { - setActiveTab(2) + setActiveTab(TABS_INDEX.CONFIGURATION) } if (instance && !instance?.state?.pools) { if (!props.callbacks) return @@ -90,7 +92,9 @@ export const Instance = observer((props: Props) => { } }, [instance]) - const [activeTab, setActiveTab] = React.useState(0) + const [activeTab, setActiveTab] = React.useState( + props?.renderCurrentTab || TABS_INDEX.OVERVIEW, + ) const switchTab = (_: React.ChangeEvent<{}> | null, tabID: number) => { const contentElement = document.getElementById('content-container') @@ -130,45 +134,54 @@ export const Instance = observer((props: Props) => { )} - - {!instanceError && ( -
- {!instance || - (!instance?.state.retrieving?.status && )} - - {instance ? ( + +
+ {!instanceError && + (instance ? ( <> ) : ( - )} -
- )} + ))} +
- - {activeTab === 1 && } + + {activeTab === TABS_INDEX.CLONES && ( +
+ {!instanceError && + (instance ? : )} +
+ )} +
+ + + {activeTab === TABS_INDEX.LOGS && } - - {activeTab === 2 && ( + + {activeTab === TABS_INDEX.CONFIGURATION && ( stores.main.load(props.instanceId)} /> )} + + + {activeTab === TABS_INDEX.SNAPSHOTS && } + + + {activeTab === TABS_INDEX.BRANCHES && } + ) @@ -184,6 +197,7 @@ function TabPanel(props: PropTypes.InferProps) { hidden={value !== index} id={`scrollable-auto-tabpanel-${index}`} aria-labelledby={`scrollable-auto-tab-${index}`} + style={{ height: '100%', position: 'relative' }} {...other} > diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index 2e049e6a..968982c0 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -7,6 +7,7 @@ import { makeAutoObservable } from 'mobx' import { GetSnapshots } from '@postgres.ai/shared/types/api/endpoints/getSnapshots' +import { CreateSnapshot } from '@postgres.ai/shared/types/api/endpoints/createSnapshot' import { GetInstance } from '@postgres.ai/shared/types/api/endpoints/getInstance' import { Config } from '@postgres.ai/shared/types/api/entities/config' import { GetConfig } from '@postgres.ai/shared/types/api/endpoints/getConfig' @@ -25,6 +26,12 @@ import { GetFullConfig } from '@postgres.ai/shared/types/api/endpoints/getFullCo import { GetInstanceRetrieval } from '@postgres.ai/shared/types/api/endpoints/getInstanceRetrieval' import { InstanceRetrievalType } from '@postgres.ai/shared/types/api/entities/instanceRetrieval' import { GetEngine } from '@postgres.ai/shared/types/api/endpoints/getEngine' +import { + CreateBranch, + CreateBranchFormValues, +} from '@postgres.ai/shared/types/api/endpoints/createBranch' +import { GetSnapshotList } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' +import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' const POLLING_TIME = 2000 @@ -33,6 +40,7 @@ const UNSTABLE_CLONE_STATUS_CODES = ['CREATING', 'RESETTING', 'DELETING'] export type Api = { getInstance: GetInstance getSnapshots: GetSnapshots + createSnapshot?: CreateSnapshot refreshInstance?: RefreshInstance destroyClone: DestroyClone resetClone: ResetClone @@ -44,6 +52,9 @@ export type Api = { getFullConfig?: GetFullConfig getEngine?: GetEngine getInstanceRetrieval?: GetInstanceRetrieval + createBranch?: CreateBranch + getBranches?: GetBranches + getSnapshotList?: GetSnapshotList } type Error = { @@ -56,10 +67,16 @@ export class MainStore { instanceRetrieval: InstanceRetrievalType | null = null config: Config | null = null fullConfig?: string + dleEdition?: string + instanceError: Error | null = null configError: string | null = null dbSourceError: string | null = null getFullConfigError: string | null = null + createBranchError: string | null = null + getBranchesError: Error | null = null + createSnapshotError: string | null = null + snapshotListError: string | null = null unstableClones = new Set() private updateInstanceTimeoutId: number | null = null @@ -68,6 +85,8 @@ export class MainStore { isReloadingClones = false isReloadingInstanceRetrieval = false + isBranchesLoading = false + isConfigLoading = false private readonly api: Api @@ -86,9 +105,14 @@ export class MainStore { load = (instanceId: string) => { this.instance = null this.loadInstance(instanceId) + this.getBranches() this.loadInstanceRetrieval(instanceId).then(() => { if (this.instanceRetrieval?.mode !== 'physical') { - this.getConfig() + this.getConfig().then((res) => { + if (res) { + this.getEngine() + } + }) } }) this.snapshots.load(instanceId) @@ -167,8 +191,12 @@ export class MainStore { getConfig = async () => { if (!this.api.getConfig) return + this.isConfigLoading = true + const { response, error } = await this.api.getConfig() + this.isConfigLoading = false + if (response) { this.config = response this.configError = null @@ -213,6 +241,10 @@ export class MainStore { const { response, error } = await this.api.getEngine() + if (response) { + this.dleEdition = response.edition + } + if (error) await getTextFromUnknownApiError(error) return response } @@ -285,4 +317,57 @@ export class MainStore { await this.loadInstanceRetrieval(this.instance.id) this.isReloadingClones = false } + + createBranch = async (values: CreateBranchFormValues) => { + if (!this.api.createBranch) return + + this.createBranchError = null + + const { response, error } = await this.api.createBranch(values) + + if (error) + this.createBranchError = await error.json().then((err) => err.message) + + return response + } + + getBranches = async () => { + if (!this.api.getBranches) return + this.isBranchesLoading = true + + const { response, error } = await this.api.getBranches() + + this.isBranchesLoading = false + + if (error) this.getBranchesError = await error.json().then((err) => err) + + return response + } + + getSnapshotList = async (branchName: string) => { + if (!this.api.getSnapshotList) return + + const { response, error } = await this.api.getSnapshotList(branchName) + + this.isBranchesLoading = false + + if (error) { + this.snapshotListError = await error.json().then((err) => err.message) + } + + return response + } + + createSnapshot = async (cloneID: string, message?: string) => { + if (!this.api.createSnapshot) return + + this.createSnapshotError = null + + const { response, error } = await this.api.createSnapshot(cloneID, message) + + if (error) + this.createSnapshotError = await error.json().then((err) => err.message) + + return response + } } diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx b/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx new file mode 100644 index 00000000..b07cb251 --- /dev/null +++ b/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx @@ -0,0 +1,79 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useEffect, useState } from 'react' +import { makeStyles } from '@material-ui/core' + +import { Modal } from '@postgres.ai/shared/components/Modal' +import { ImportantText } from '@postgres.ai/shared/components/ImportantText' +import { Text } from '@postgres.ai/shared/components/Text' +import { SimpleModalControls } from '@postgres.ai/shared/components/SimpleModalControls' + +type Props = { + snapshotId: string + isOpen: boolean + onClose: () => void + onDestroySnapshot: () => void + destroySnapshotError: { title?: string; message: string } | null +} + +const useStyles = makeStyles( + { + errorMessage: { + color: 'red', + marginTop: '10px', + }, + }, + { index: 1 }, +) + +export const DestroySnapshotModal = ({ + snapshotId, + isOpen, + onClose, + onDestroySnapshot, + destroySnapshotError, +}: Props) => { + const classes = useStyles() + const [deleteError, setDeleteError] = useState(destroySnapshotError?.message) + + const handleClickDestroy = () => { + onDestroySnapshot() + } + + const handleClose = () => { + setDeleteError('') + onClose() + } + + useEffect(() => { + setDeleteError(destroySnapshotError?.message) + }, [destroySnapshotError]) + + return ( + + + Are you sure you want to destroy snapshot{' '} + {snapshotId}? + + {deleteError &&

{deleteError}

} + +
+ ) +} diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/context.ts b/ui/packages/shared/pages/Snapshots/Snapshot/context.ts new file mode 100644 index 00000000..0b33b138 --- /dev/null +++ b/ui/packages/shared/pages/Snapshots/Snapshot/context.ts @@ -0,0 +1,22 @@ +import { createStrictContext } from '@postgres.ai/shared/utils/react' + +import { Api } from './stores/Main' +import { Stores } from './useCreatedStores' + +export type Host = { + instanceId: string + snapshotId: string + routes: { + snapshot: () => string + } + api: Api + elements: { + breadcrumbs: React.ReactNode + } +} + +export const { useStrictContext: useHost, Provider: HostProvider } = + createStrictContext() + +export const { useStrictContext: useStores, Provider: StoresProvider } = + createStrictContext() diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx b/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx new file mode 100644 index 00000000..b6fbb5ec --- /dev/null +++ b/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx @@ -0,0 +1,350 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { useEffect, useState } from 'react' +import { useHistory } from 'react-router' +import { observer } from 'mobx-react-lite' +import copyToClipboard from 'copy-to-clipboard' +import { + makeStyles, + Button, + TextField, + IconButton, + Table, + TableHead, + TableRow, + TableBody, +} from '@material-ui/core' + +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' +import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' +import { DestroySnapshotModal } from '@postgres.ai/shared/pages/Snapshots/Snapshot/DestorySnapshotModal' +import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' +import { Tooltip } from '@postgres.ai/shared/components/Tooltip' +import { icons } from '@postgres.ai/shared/styles/icons' +import { formatBytesIEC } from '@postgres.ai/shared/utils/units' +import { styles } from '@postgres.ai/shared/styles/styles' +import { + TableBodyCell, + TableBodyCellMenu, + TableHeaderCell, +} from '@postgres.ai/shared/components/Table' + +import { useCreatedStores } from './useCreatedStores' +import { Host } from './context' + +type Props = Host + +const useStyles = makeStyles( + () => ({ + marginTop: { + marginTop: '16px', + }, + container: { + maxWidth: '100%', + marginTop: '16px', + + '& p,span': { + fontSize: 14, + }, + }, + actions: { + display: 'flex', + marginRight: '-16px', + }, + spinner: { + marginLeft: '8px', + }, + actionButton: { + marginRight: '16px', + }, + summary: { + marginTop: 20, + }, + text: { + marginTop: '4px', + }, + paramTitle: { + display: 'inline-block', + width: 200, + }, + copyFieldContainer: { + position: 'relative', + display: 'block', + maxWidth: 400, + width: '100%', + }, + tableContainer: { + position: 'relative', + maxWidth: 400, + width: '100%', + }, + textField: { + ...styles.inputField, + 'max-width': 400, + display: 'inline-block', + '& .MuiOutlinedInput-input': { + paddingRight: '32px!important', + }, + }, + copyButton: { + position: 'absolute', + top: 16, + right: 0, + zIndex: 100, + width: 32, + height: 32, + padding: 8, + }, + pointerCursor: { + cursor: 'pointer', + }, + }), + { index: 1 }, +) + +export const SnapshotPage = observer((props: Props) => { + const classes = useStyles() + const history = useHistory() + const stores = useCreatedStores(props) + + const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false) + + const { + snapshot, + branchSnapshot, + isSnapshotsLoading, + snapshotError, + branchSnapshotError, + destroySnapshotError, + load, + } = stores.main + + const destroySnapshot = async () => { + const isSuccess = await stores.main.destroySnapshot(String(snapshot?.id)) + if (isSuccess) history.push(props.routes.snapshot()) + } + + const BranchHeader = () => { + return ( + <> + {props.elements.breadcrumbs} + + + ) + } + + useEffect(() => { + load(props.snapshotId, props.instanceId) + }, []) + + if (isSnapshotsLoading) return + + if (snapshotError || branchSnapshotError) { + return ( + <> + + + + ) + } + + return ( + <> + +
+
+ +
+
+
+
+

+ Created +

+

{snapshot?.createdAt}

+
+
+
+

+ Data state at  + + Data state time is a time at which data + is  recovered for this snapshot. + + } + > + {icons.infoIcon} + +

+

{snapshot?.dataStateAt || '-'}

+
+
+

+ Summary  +

+

+ Number of clones: + {snapshot?.numClones} +

+

+ Logical data size: + {snapshot?.logicalSize + ? formatBytesIEC(snapshot.logicalSize) + : '-'} +

+

+ + Physical data diff size: + + {snapshot?.physicalSize + ? formatBytesIEC(snapshot.physicalSize) + : '-'} +

+ {branchSnapshot?.message && ( +

+ Message: + {branchSnapshot.message} +

+ )} +
+
+

+ Snapshot info +

+ {snapshot?.pool && ( +
+ + copyToClipboard(snapshot.pool)} + > + {icons.copyIcon} + +
+ )} +
+ + copyToClipboard(String(snapshot?.id))} + > + {icons.copyIcon} + +
+
+ {branchSnapshot?.branch && branchSnapshot.branch?.length > 0 && ( + <> +

+ + Related branches ({branchSnapshot.branch.length}) + +   + List of branches pointing at the same snapshot.   + } + > + {icons.infoIcon} + +

+ + + + + + Name + + + + {branchSnapshot.branch.map((branch: string, id: number) => ( + + history.push(`/instance/branches/${branch}`) + } + > + copyToClipboard(branch), + }, + ]} + /> + {branch} + + ))} + +
+
+ + )} +
+ setIsOpenDestroyModal(false)} + snapshotId={props.snapshotId} + onDestroySnapshot={destroySnapshot} + destroySnapshotError={destroySnapshotError} + /> +
+ + ) +}) diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts b/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts new file mode 100644 index 00000000..7e919526 --- /dev/null +++ b/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts @@ -0,0 +1,109 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { makeAutoObservable } from 'mobx' + +import { + SnapshotsStore, + SnapshotsApi, +} from '@postgres.ai/shared/stores/Snapshots' +import { DestroySnapshot } from '@postgres.ai/shared/types/api/endpoints/destroySnapshot' +import { SnapshotDto } from '@postgres.ai/shared/types/api/entities/snapshot' +import { GetBranchSnapshot } from '@postgres.ai/shared/types/api/endpoints/getBranchSnapshot' +import { BranchSnapshotDto } from '@postgres.ai/shared/types/api/entities/branchSnapshot' +import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' + +type Error = { + title?: string + message: string +} + +export type Api = SnapshotsApi & { + destroySnapshot: DestroySnapshot + getBranchSnapshot: GetBranchSnapshot +} + +export class MainStore { + snapshot: SnapshotDto | null = null + branchSnapshot: BranchSnapshotDto | null = null + + snapshotError: Error | null = null + branchSnapshotError: Error | null = null + destroySnapshotError: Error | null = null + + isSnapshotsLoading = false + + private readonly api: Api + readonly snapshots: SnapshotsStore + + constructor(api: Api) { + this.api = api + this.snapshots = new SnapshotsStore(api) + makeAutoObservable(this) + } + + load = async (snapshotId: string, instanceId: string) => { + if (!snapshotId) return + + this.isSnapshotsLoading = true + + await this.snapshots.load(instanceId).then((loaded) => { + loaded && this.getSnapshot(snapshotId) + }) + } + getSnapshot = async (snapshotId: string) => { + if (!snapshotId) return + + const allSnapshots = this.snapshots.data + const snapshot = allSnapshots?.filter((s: SnapshotDto) => { + return snapshotId === generateSnapshotPageId(s.id) + }) + + if (snapshot && snapshot?.length > 0) { + this.snapshot = snapshot[0] + this.getBranchSnapshot(snapshot[0].id) + } else { + this.isSnapshotsLoading = false + this.snapshotError = { + title: 'Error', + message: `Snapshot "${snapshotId}" not found`, + } + } + + return !!snapshot + } + + getBranchSnapshot = async (snapshotId: string) => { + if (!snapshotId) return + + const { response, error } = await this.api.getBranchSnapshot(snapshotId) + + this.isSnapshotsLoading = false + + if (error) { + this.branchSnapshotError = await error.json().then((err) => err) + } + + if (response) { + this.branchSnapshot = response + } + + return response + } + + destroySnapshot = async (snapshotId: string) => { + if (!this.api.destroySnapshot || !snapshotId) return + + const { response, error } = await this.api.destroySnapshot(snapshotId) + + if (error) { + this.destroySnapshotError = await error.json().then((err) => err) + } + + return response + } +} diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts b/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts new file mode 100644 index 00000000..7164757e --- /dev/null +++ b/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react' + +import { MainStore } from './stores/Main' +import { Host } from './context' + +export const useCreatedStores = (host: Host) => ({ + main: useMemo(() => new MainStore(host.api), []), +}) + +export type Stores = ReturnType diff --git a/ui/packages/shared/stores/Snapshots.ts b/ui/packages/shared/stores/Snapshots.ts index 7f87dbb0..c62ebafd 100644 --- a/ui/packages/shared/stores/Snapshots.ts +++ b/ui/packages/shared/stores/Snapshots.ts @@ -10,9 +10,11 @@ import { makeAutoObservable } from 'mobx' import { Snapshot } from '@postgres.ai/shared/types/api/entities/snapshot' import { getTextFromUnknownApiError } from '@postgres.ai/shared/utils/api' import { GetSnapshots } from '@postgres.ai/shared/types/api/endpoints/getSnapshots' +import { CreateSnapshot } from '@postgres.ai/shared/types/api/endpoints/createSnapshot' export type SnapshotsApi = { getSnapshots: GetSnapshots + createSnapshot?: CreateSnapshot } type Error = { @@ -23,6 +25,8 @@ export class SnapshotsStore { data: Snapshot[] | null = null error: Error | null = null isLoading = false + snapshotData: boolean | null = null + snapshotDataError: Error | null = null private readonly api: SnapshotsApi @@ -40,6 +44,25 @@ export class SnapshotsStore { reload = (instanceId: string) => this.loadData(instanceId) + createSnapshot = async (cloneId: string) => { + if (!this.api.createSnapshot || !cloneId) return + + this.snapshotDataError = null + + const { response, error } = await this.api.createSnapshot(cloneId) + + if (response) { + this.snapshotData = !!response + this.reload('') + } + + if (error) { + this.snapshotDataError = await error.json().then((err) => err) + } + + return response + } + private loadData = async (instanceId: string) => { this.isLoading = true diff --git a/ui/packages/shared/types/api/endpoints/createBranch.ts b/ui/packages/shared/types/api/endpoints/createBranch.ts new file mode 100644 index 00000000..1efe348f --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/createBranch.ts @@ -0,0 +1,13 @@ +import { CreateBranchResponse } from '@postgres.ai/shared/types/api/entities/createBranch' + +export type CreateBranchFormValues = { + branchName: string + baseBranch: string + snapshotID: string + creationType?: 'branch' | 'snapshot' +} + +export type CreateBranch = (values: CreateBranchFormValues) => Promise<{ + response: CreateBranchResponse | null + error: Response | null +}> diff --git a/ui/packages/shared/types/api/endpoints/createClone.ts b/ui/packages/shared/types/api/endpoints/createClone.ts index 5c4ce144..afa56f3e 100644 --- a/ui/packages/shared/types/api/endpoints/createClone.ts +++ b/ui/packages/shared/types/api/endpoints/createClone.ts @@ -7,4 +7,5 @@ export type CreateClone = (args: { dbUser: string dbPassword: string isProtected: boolean + branch?: string }) => Promise<{ response: Clone | null; error: Response | null }> diff --git a/ui/packages/shared/types/api/endpoints/createSnapshot.ts b/ui/packages/shared/types/api/endpoints/createSnapshot.ts new file mode 100644 index 00000000..25d71ed2 --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/createSnapshot.ts @@ -0,0 +1,9 @@ +import { CreateSnapshotResponse } from '@postgres.ai/shared/types/api/entities/createSnapshot' + +export type CreateSnapshot = ( + cloneID: string, + message?: string, +) => Promise<{ + response: CreateSnapshotResponse | null + error: Response | null +}> diff --git a/ui/packages/shared/types/api/endpoints/deleteBranch.ts b/ui/packages/shared/types/api/endpoints/deleteBranch.ts new file mode 100644 index 00000000..e1631537 --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/deleteBranch.ts @@ -0,0 +1,3 @@ +export type DeleteBranch = ( + branchName: string, +) => Promise<{ response: Response | null; error: Response | null }> diff --git a/ui/packages/shared/types/api/endpoints/destroySnapshot.ts b/ui/packages/shared/types/api/endpoints/destroySnapshot.ts new file mode 100644 index 00000000..d38c9365 --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/destroySnapshot.ts @@ -0,0 +1,4 @@ +export type DestroySnapshot = (snapshotId: string) => Promise<{ + response: true | null + error: Response | null +}> diff --git a/ui/packages/shared/types/api/endpoints/getBranchSnapshot.ts b/ui/packages/shared/types/api/endpoints/getBranchSnapshot.ts new file mode 100644 index 00000000..59bc8496 --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/getBranchSnapshot.ts @@ -0,0 +1,5 @@ +import { BranchSnapshotDto } from '@postgres.ai/shared/types/api/entities/branchSnapshot' + +export type GetBranchSnapshot = ( + snapshotId: string, +) => Promise<{ response: BranchSnapshotDto | null; error: Response | null }> diff --git a/ui/packages/shared/types/api/endpoints/getBranches.ts b/ui/packages/shared/types/api/endpoints/getBranches.ts new file mode 100644 index 00000000..d5825686 --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/getBranches.ts @@ -0,0 +1,11 @@ +export interface GetBranchesResponseType { + name: string + parent: string + dataStateAt: string + snapshotID: string +} + +export type GetBranches = () => Promise<{ + response: GetBranchesResponseType[] | null + error: Response | null +}> diff --git a/ui/packages/shared/types/api/endpoints/getSnapshotList.ts b/ui/packages/shared/types/api/endpoints/getSnapshotList.ts new file mode 100644 index 00000000..0c8f277e --- /dev/null +++ b/ui/packages/shared/types/api/endpoints/getSnapshotList.ts @@ -0,0 +1,11 @@ +export interface GetSnapshotListResponseType { + branch: string[] + id: string + dataStateAt: string + comment?: string +} + +export type GetSnapshotList = (branchName: string) => Promise<{ + response: GetSnapshotListResponseType[] | null + error: Response | null +}> diff --git a/ui/packages/shared/types/api/entities/branchSnapshot.ts b/ui/packages/shared/types/api/entities/branchSnapshot.ts new file mode 100644 index 00000000..69257425 --- /dev/null +++ b/ui/packages/shared/types/api/entities/branchSnapshot.ts @@ -0,0 +1,8 @@ +export type BranchSnapshotDTO = { + message: string + branch: string[] +} + +export const formatBranchSnapshotDto = (dto: BranchSnapshotDTO) => dto + +export type BranchSnapshotDto = ReturnType diff --git a/ui/packages/shared/types/api/entities/config.ts b/ui/packages/shared/types/api/entities/config.ts index b4fa3a20..f5725a77 100644 --- a/ui/packages/shared/types/api/entities/config.ts +++ b/ui/packages/shared/types/api/entities/config.ts @@ -2,7 +2,7 @@ import { formatDatabases, formatDumpCustomOptions, getImageType, -} from '@postgres.ai/shared/pages/Configuration/utils' +} from '@postgres.ai/shared/pages/Instance/Configuration/utils' export interface DatabaseType { [name: string]: string | Object diff --git a/ui/packages/shared/types/api/entities/createBranch.ts b/ui/packages/shared/types/api/entities/createBranch.ts new file mode 100644 index 00000000..6b656fee --- /dev/null +++ b/ui/packages/shared/types/api/entities/createBranch.ts @@ -0,0 +1,7 @@ +export type CreateBranchDTO = { + name: string +} + +export const formatCreateBranchDto = (dto: CreateBranchDTO) => dto + +export type CreateBranchResponse = ReturnType diff --git a/ui/packages/shared/types/api/entities/createSnapshot.ts b/ui/packages/shared/types/api/entities/createSnapshot.ts new file mode 100644 index 00000000..6ce75e6c --- /dev/null +++ b/ui/packages/shared/types/api/entities/createSnapshot.ts @@ -0,0 +1,7 @@ +export type CreateSnapshotDTO = { + snapshotID: string +} + +export const formatCreateSnapshotDto = (dto: CreateSnapshotDTO) => dto + +export type CreateSnapshotResponse = ReturnType diff --git a/ui/packages/shared/types/api/entities/snapshot.ts b/ui/packages/shared/types/api/entities/snapshot.ts index 3d29912e..d6aac3f4 100644 --- a/ui/packages/shared/types/api/entities/snapshot.ts +++ b/ui/packages/shared/types/api/entities/snapshot.ts @@ -1,18 +1,20 @@ import { parseDate } from '@postgres.ai/shared/utils/date' export type SnapshotDto = { + numClones: string createdAt: string dataStateAt: string id: string pool: string physicalSize: number logicalSize: number + comment?: string } export const formatSnapshotDto = (dto: SnapshotDto) => ({ ...dto, createdAtDate: parseDate(dto.createdAt), - dataStateAtDate: parseDate(dto.dataStateAt) + dataStateAtDate: parseDate(dto.dataStateAt), }) export type Snapshot = ReturnType diff --git a/ui/packages/shared/utils/date.ts b/ui/packages/shared/utils/date.ts index d818c901..21393ba8 100644 --- a/ui/packages/shared/utils/date.ts +++ b/ui/packages/shared/utils/date.ts @@ -81,3 +81,7 @@ export const formatDateStd = ( `${formatUTC(date, 'yyyy-MM-dd HH:mm:ss')} UTC ${ options?.withDistance ? `(${formatDistanceStd(date)})` : '' }` + +export const isValidDate = (dateObject: Date) => { + return new Date(dateObject).toString() !== 'Invalid Date' +} \ No newline at end of file From 50a907f07955ed2de5069bd64f2a2523b3323cd1 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Thu, 12 Jan 2023 05:32:39 +0000 Subject: [PATCH 004/114] fix: check branch creation parameters (#462) --- engine/internal/srv/branch.go | 23 +++++++++++++++++++---- engine/pkg/client/dblabapi/branch.go | 1 + engine/pkg/client/dblabapi/snapshot.go | 1 + 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 385a5aef..20b4967f 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -102,6 +102,11 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } + if createRequest.BranchName == createRequest.BaseBranch { + api.SendBadRequestError(w, r, "new and base branches must have different names") + return + } + fsm := s.pm.First() if fsm == nil { @@ -109,18 +114,28 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } + branches, err := fsm.ListBranches() + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + if _, ok := branches[createRequest.BranchName]; ok { + api.SendBadRequestError(w, r, fmt.Sprintf("branch '%s' already exists", createRequest.BranchName)) + return + } + snapshotID := createRequest.SnapshotID if snapshotID == "" { - branches, err := fsm.ListBranches() - if err != nil { - api.SendBadRequestError(w, r, err.Error()) + if createRequest.BaseBranch == "" { + api.SendBadRequestError(w, r, "either base branch name or base snapshot ID must be specified") return } branchPointer, ok := branches[createRequest.BaseBranch] if !ok { - api.SendBadRequestError(w, r, "branch not found") + api.SendBadRequestError(w, r, "base branch not found") return } diff --git a/engine/pkg/client/dblabapi/branch.go b/engine/pkg/client/dblabapi/branch.go index 5fd2c51f..b8b12efa 100644 --- a/engine/pkg/client/dblabapi/branch.go +++ b/engine/pkg/client/dblabapi/branch.go @@ -146,6 +146,7 @@ func (c *Client) BranchLog(ctx context.Context, logRequest types.LogRequest) ([] } // DeleteBranch deletes data branch. +// //nolint:dupl func (c *Client) DeleteBranch(ctx context.Context, r types.BranchDeleteRequest) error { u := c.URL("/branch/delete") diff --git a/engine/pkg/client/dblabapi/snapshot.go b/engine/pkg/client/dblabapi/snapshot.go index 3e19e3f4..379b48c9 100644 --- a/engine/pkg/client/dblabapi/snapshot.go +++ b/engine/pkg/client/dblabapi/snapshot.go @@ -98,6 +98,7 @@ func (c *Client) createRequest(ctx context.Context, snapshotRequest any, u *url. } // DeleteSnapshot deletes snapshot. +// //nolint:dupl func (c *Client) DeleteSnapshot(ctx context.Context, snapshotRequest types.SnapshotDestroyRequest) error { u := c.URL("/snapshot/delete") From 81a80c2bda4f545298bf8f6fbeed8ba48749ee77 Mon Sep 17 00:00:00 2001 From: akartasov Date: Fri, 13 Jan 2023 10:38:34 +0100 Subject: [PATCH 005/114] fix: branch switching and current branch definition --- engine/cmd/cli/main.go | 12 ++++++++---- engine/pkg/client/dblabapi/branch.go | 1 + engine/pkg/client/dblabapi/snapshot.go | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/engine/cmd/cli/main.go b/engine/cmd/cli/main.go index c05e3c92..c8b6fec5 100644 --- a/engine/cmd/cli/main.go +++ b/engine/cmd/cli/main.go @@ -167,10 +167,14 @@ func loadEnvironmentParams(c *cli.Context) error { } } - if env.Branching.CurrentBranch == "" { - if err := c.Set(commands.CurrentBranch, config.DefaultBranch); err != nil { - return err - } + currentBranch := config.DefaultBranch + + if env.Branching.CurrentBranch != "" { + currentBranch = env.Branching.CurrentBranch + } + + if err := c.Set(commands.CurrentBranch, currentBranch); err != nil { + return err } } diff --git a/engine/pkg/client/dblabapi/branch.go b/engine/pkg/client/dblabapi/branch.go index 5fd2c51f..b8b12efa 100644 --- a/engine/pkg/client/dblabapi/branch.go +++ b/engine/pkg/client/dblabapi/branch.go @@ -146,6 +146,7 @@ func (c *Client) BranchLog(ctx context.Context, logRequest types.LogRequest) ([] } // DeleteBranch deletes data branch. +// //nolint:dupl func (c *Client) DeleteBranch(ctx context.Context, r types.BranchDeleteRequest) error { u := c.URL("/branch/delete") diff --git a/engine/pkg/client/dblabapi/snapshot.go b/engine/pkg/client/dblabapi/snapshot.go index 3e19e3f4..379b48c9 100644 --- a/engine/pkg/client/dblabapi/snapshot.go +++ b/engine/pkg/client/dblabapi/snapshot.go @@ -98,6 +98,7 @@ func (c *Client) createRequest(ctx context.Context, snapshotRequest any, u *url. } // DeleteSnapshot deletes snapshot. +// //nolint:dupl func (c *Client) DeleteSnapshot(ctx context.Context, snapshotRequest types.SnapshotDestroyRequest) error { u := c.URL("/snapshot/delete") From 0e2a5f1b28f417d2d983d9101ad038b7c12adf44 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Fri, 27 Jan 2023 16:37:11 +0000 Subject: [PATCH 006/114] feat(engine): DLE branching and pool data refreshing (#441) * move branch head to a new snapshot on the main branch * rewrite snapshot properties (relations, branches) on initialization * multiple snapshots on logical mode --- .../provision/thinclones/zfs/branching.go | 64 +++++++++++++++---- .../internal/provision/thinclones/zfs/zfs.go | 2 +- engine/internal/retrieval/retrieval.go | 35 +--------- engine/internal/srv/branch.go | 6 +- 4 files changed, 55 insertions(+), 52 deletions(-) diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go index 26cd2103..38cdf797 100644 --- a/engine/internal/provision/thinclones/zfs/branching.go +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -29,17 +29,6 @@ const ( // InitBranching inits data branching. func (m *Manager) InitBranching() error { - branches, err := m.ListBranches() - if err != nil { - return err - } - - if len(branches) > 0 { - log.Dbg("data branching is already initialized") - - return nil - } - snapshots := m.SnapshotList() numberSnapshots := len(snapshots) @@ -51,21 +40,68 @@ func (m *Manager) InitBranching() error { latest := snapshots[0] - for i := numberSnapshots; i > 1; i-- { - if err := m.SetRelation(snapshots[i-1].ID, snapshots[i-2].ID); err != nil { - return fmt.Errorf("failed to set snapshot relations: %w", err) + if getPoolPrefix(latest.ID) != m.config.Pool.Name { + for _, s := range snapshots { + if s.Pool == m.config.Pool.Name { + latest = s + break + } } } + latestBranchProperty, err := m.getProperty(branchProp, latest.ID) + if err != nil { + return fmt.Errorf("failed to read snapshot property: %w", err) + } + + if latestBranchProperty != "" && latestBranchProperty != "-" { + log.Dbg("data branching is already initialized") + + return nil + } + if err := m.AddBranchProp(defaultBranch, latest.ID); err != nil { return fmt.Errorf("failed to add branch property: %w", err) } + leader := latest + + for i := 1; i < numberSnapshots; i++ { + follower := snapshots[i] + + if getPoolPrefix(leader.ID) != getPoolPrefix(follower.ID) { + continue + } + + if err := m.SetRelation(leader.ID, follower.ID); err != nil { + return fmt.Errorf("failed to set snapshot relations: %w", err) + } + + brProperty, err := m.getProperty(branchProp, follower.ID) + if err != nil { + return fmt.Errorf("failed to read branch property: %w", err) + } + + if brProperty == defaultBranch { + if err := m.DeleteBranchProp(defaultBranch, follower.ID); err != nil { + return fmt.Errorf("failed to delete default branch property: %w", err) + } + + break + } + + leader = follower + } + log.Msg("data branching has been successfully initialized") return nil } +func getPoolPrefix(pool string) string { + return strings.Split(pool, "@")[0] +} + // VerifyBranchMetadata verifies data branching metadata. func (m *Manager) VerifyBranchMetadata() error { snapshots := m.SnapshotList() diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index b0f6b50b..10583d9d 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -301,7 +301,7 @@ func (m *Manager) CreateSnapshot(poolSuffix, dataStateAt string) (string, error) } } - cmd := fmt.Sprintf("zfs snapshot -r %s", snapshotName) + cmd := fmt.Sprintf("zfs snapshot %s", snapshotName) if _, err := m.runner.Run(cmd, true); err != nil { return "", errors.Wrap(err, "failed to create snapshot") diff --git a/engine/internal/retrieval/retrieval.go b/engine/internal/retrieval/retrieval.go index c48409d9..e88f78e0 100644 --- a/engine/internal/retrieval/retrieval.go +++ b/engine/internal/retrieval/retrieval.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "github.com/docker/docker/api/types" @@ -604,11 +603,7 @@ func (r *Retrieval) FullRefresh(ctx context.Context) error { return errors.Wrap(err, "failed to get FSManager") } - log.Msg("Pool to a full refresh: ", poolToUpdate.Pool()) - - if err := preparePoolToRefresh(poolToUpdate); err != nil { - return errors.Wrap(err, "failed to prepare the pool to a full refresh") - } + log.Msg("Pool selected to perform full refresh: ", poolToUpdate.Pool()) // Stop service containers: sync-instance, etc. if cleanUpErr := cont.CleanUpControlContainers(runCtx, r.docker, r.engineProps.InstanceID); cleanUpErr != nil { @@ -639,34 +634,6 @@ func (r *Retrieval) stopScheduler() { } } -func preparePoolToRefresh(poolToUpdate pool.FSManager) error { - cloneList, err := poolToUpdate.ListClonesNames() - if err != nil { - return errors.Wrap(err, "failed to check running clones") - } - - if len(cloneList) > 0 { - return errors.Errorf("there are active clones in the requested pool: %s\nDestroy them to perform a full refresh", - strings.Join(cloneList, " ")) - } - - poolToUpdate.RefreshSnapshotList() - - snapshots := poolToUpdate.SnapshotList() - if len(snapshots) == 0 { - log.Msg(fmt.Sprintf("no snapshots for pool %s", poolToUpdate.Pool().Name)) - return nil - } - - for _, snapshotEntry := range snapshots { - if err := poolToUpdate.DestroySnapshot(snapshotEntry.ID); err != nil { - return errors.Wrap(err, "failed to destroy existing snapshot") - } - } - - return nil -} - // ReportState collects the current restore state. func (r *Retrieval) ReportState() telemetry.Restore { return telemetry.Restore{ diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 20b4967f..076ae59b 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -356,12 +356,12 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { // Limit the number of iterations to the number of snapshots. for i := len(repo.Snapshots); i > 1; i-- { - snapshotPointer = repo.Snapshots[snapshotPointer.Parent] - logList = append(logList, snapshotPointer) - if snapshotPointer.Parent == "-" || snapshotPointer.Parent == "" { break } + + snapshotPointer = repo.Snapshots[snapshotPointer.Parent] + logList = append(logList, snapshotPointer) } if err := api.WriteJSON(w, http.StatusOK, logList); err != nil { From d3773c590ad7cb1c21fbc16039be93eb5258a8ad Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Sat, 11 Feb 2023 18:12:46 +0000 Subject: [PATCH 007/114] [DLE 4.0] feat(ui): CLI/API snippets in UI (#472) --- ui/packages/ce/package.json | 2 + .../Instance/Branches/CreateBranch/index.tsx | 35 ++ .../ce/src/App/Instance/Branches/index.tsx | 4 + .../ce/src/App/Instance/Page/index.tsx | 2 + .../Snapshots/CreateSnapshot/index.tsx | 33 ++ .../ce/src/App/Instance/Snapshots/index.tsx | 4 + ui/packages/ce/src/config/routes.tsx | 10 + .../components/SyntaxHighlight/index.tsx | 67 ++++ ui/packages/shared/package.json | 4 +- .../shared/pages/Branches/Branch/index.tsx | 334 ++++++++++------- .../Modals/CreateBranchModal/index.tsx | 193 ---------- .../components/Modals/styles.module.scss | 27 -- ui/packages/shared/pages/Branches/index.tsx | 32 +- ui/packages/shared/pages/Clone/index.tsx | 208 ++++++----- ui/packages/shared/pages/Clone/utils/index.ts | 15 + .../shared/pages/CreateBranch/index.tsx | 273 ++++++++++++++ .../shared/pages/CreateBranch/stores/Main.ts | 105 ++++++ .../pages/CreateBranch/useCreatedStores.ts | 11 + .../useForm.ts | 0 .../shared/pages/CreateBranch/utils/index.ts | 7 + .../shared/pages/CreateClone/index.tsx | 328 ++++++++-------- .../pages/CreateClone/styles.module.scss | 29 +- .../shared/pages/CreateClone/utils/index.ts | 21 ++ .../shared/pages/CreateSnapshot/index.tsx | 215 +++++++++++ .../pages/CreateSnapshot/stores/Main.ts | 60 +++ .../pages/CreateSnapshot/useCreatedStores.ts | 11 + .../useForm.ts | 2 +- .../pages/CreateSnapshot/utils/index.ts | 3 + .../Instance/Clones/ClonesList/index.tsx | 3 +- .../shared/pages/Instance/Info/index.tsx | 1 + .../components/CreateSnapshotModal/index.tsx | 158 -------- .../Snapshots/components/styles.module.scss | 31 -- .../shared/pages/Instance/Snapshots/index.tsx | 22 +- ui/packages/shared/pages/Instance/context.ts | 2 + .../shared/pages/Instance/stores/Main.ts | 45 +-- ui/packages/shared/pages/Logs/wsLogs.ts | 2 + .../shared/pages/Snapshots/Snapshot/index.tsx | 352 ++++++++++-------- ui/packages/shared/stores/Snapshots.ts | 4 +- ui/pnpm-lock.yaml | 103 ++++- 39 files changed, 1740 insertions(+), 1018 deletions(-) create mode 100644 ui/packages/ce/src/App/Instance/Branches/CreateBranch/index.tsx create mode 100644 ui/packages/ce/src/App/Instance/Snapshots/CreateSnapshot/index.tsx create mode 100644 ui/packages/shared/components/SyntaxHighlight/index.tsx delete mode 100644 ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/index.tsx delete mode 100644 ui/packages/shared/pages/Branches/components/Modals/styles.module.scss create mode 100644 ui/packages/shared/pages/Clone/utils/index.ts create mode 100644 ui/packages/shared/pages/CreateBranch/index.tsx create mode 100644 ui/packages/shared/pages/CreateBranch/stores/Main.ts create mode 100644 ui/packages/shared/pages/CreateBranch/useCreatedStores.ts rename ui/packages/shared/pages/{Branches/components/Modals/CreateBranchModal => CreateBranch}/useForm.ts (100%) create mode 100644 ui/packages/shared/pages/CreateBranch/utils/index.ts create mode 100644 ui/packages/shared/pages/CreateClone/utils/index.ts create mode 100644 ui/packages/shared/pages/CreateSnapshot/index.tsx create mode 100644 ui/packages/shared/pages/CreateSnapshot/stores/Main.ts create mode 100644 ui/packages/shared/pages/CreateSnapshot/useCreatedStores.ts rename ui/packages/shared/pages/{Instance/Snapshots/components/CreateSnapshotModal => CreateSnapshot}/useForm.ts (93%) create mode 100644 ui/packages/shared/pages/CreateSnapshot/utils/index.ts delete mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/index.tsx delete mode 100644 ui/packages/shared/pages/Instance/Snapshots/components/styles.module.scss diff --git a/ui/packages/ce/package.json b/ui/packages/ce/package.json index 1ec34303..d52c0c8f 100644 --- a/ui/packages/ce/package.json +++ b/ui/packages/ce/package.json @@ -18,6 +18,7 @@ "@types/react-dom": "^17.0.10", "@types/react-router": "^5.1.17", "@types/react-router-dom": "^5.3.1", + "@types/react-syntax-highlighter": "^15.5.6", "byte-size": "^8.1.0", "classnames": "^2.3.1", "clsx": "^1.1.1", @@ -36,6 +37,7 @@ "react-router": "^5.1.2", "react-router-dom": "^5.1.2", "react-scripts": "^5.0.0", + "react-syntax-highlighter": "^15.5.0", "stream-browserify": "^3.0.0", "typescript": "^4.4.4", "use-timer": "^2.0.1", diff --git a/ui/packages/ce/src/App/Instance/Branches/CreateBranch/index.tsx b/ui/packages/ce/src/App/Instance/Branches/CreateBranch/index.tsx new file mode 100644 index 00000000..67ce5679 --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Branches/CreateBranch/index.tsx @@ -0,0 +1,35 @@ +import { getBranches } from 'api/branches/getBranches' +import { createBranch } from 'api/branches/createBranch' +import { getSnapshotList } from 'api/branches/getSnapshotList' + +import { CreateBranchPage } from '@postgres.ai/shared/pages/CreateBranch' + +import { PageContainer } from 'components/PageContainer' +import { NavPath } from 'components/NavPath' +import { ROUTES } from 'config/routes' + +export const CreateBranch = () => { + const api = { + getBranches, + createBranch, + getSnapshotList, + } + + const elements = { + breadcrumbs: ( + + ), + } + + return ( + + + + ) +} diff --git a/ui/packages/ce/src/App/Instance/Branches/index.tsx b/ui/packages/ce/src/App/Instance/Branches/index.tsx index 5dfb787e..ecf327b9 100644 --- a/ui/packages/ce/src/App/Instance/Branches/index.tsx +++ b/ui/packages/ce/src/App/Instance/Branches/index.tsx @@ -5,10 +5,14 @@ import { TABS_INDEX } from '@postgres.ai/shared/pages/Instance/Tabs' import { Page } from '../Page' import { Branch } from './Branch' +import { CreateBranch } from './CreateBranch' export const Branches = () => { return ( + + + diff --git a/ui/packages/ce/src/App/Instance/Page/index.tsx b/ui/packages/ce/src/App/Instance/Page/index.tsx index 3fe7cd6a..ebc27c9d 100644 --- a/ui/packages/ce/src/App/Instance/Page/index.tsx +++ b/ui/packages/ce/src/App/Instance/Page/index.tsx @@ -25,6 +25,8 @@ import { getSnapshotList } from 'api/branches/getSnapshotList' export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { const routes = { createClone: () => ROUTES.INSTANCE.CLONES.CREATE.path, + createBranch: () => ROUTES.INSTANCE.BRANCHES.CREATE.path, + createSnapshot: () => ROUTES.INSTANCE.SNAPSHOTS.CREATE.path, clone: (cloneId: string) => ROUTES.INSTANCE.CLONES.CLONE.createPath(cloneId), } diff --git a/ui/packages/ce/src/App/Instance/Snapshots/CreateSnapshot/index.tsx b/ui/packages/ce/src/App/Instance/Snapshots/CreateSnapshot/index.tsx new file mode 100644 index 00000000..48096f56 --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Snapshots/CreateSnapshot/index.tsx @@ -0,0 +1,33 @@ +import { createSnapshot } from 'api/snapshots/createSnapshot' +import { getInstance } from 'api/instances/getInstance' + +import { CreateSnapshotPage } from '@postgres.ai/shared/pages/CreateSnapshot' + +import { PageContainer } from 'components/PageContainer' +import { NavPath } from 'components/NavPath' +import { ROUTES } from 'config/routes' + +export const CreateSnapshot = () => { + const api = { + createSnapshot, + getInstance, + } + + const elements = { + breadcrumbs: ( + + ), + } + + return ( + + + + ) +} diff --git a/ui/packages/ce/src/App/Instance/Snapshots/index.tsx b/ui/packages/ce/src/App/Instance/Snapshots/index.tsx index cbb77f8c..d1521a6e 100644 --- a/ui/packages/ce/src/App/Instance/Snapshots/index.tsx +++ b/ui/packages/ce/src/App/Instance/Snapshots/index.tsx @@ -6,10 +6,14 @@ import { ROUTES } from 'config/routes' import { Page } from '../Page' import { Snapshot } from './Snapshot' +import { CreateSnapshot } from './CreateSnapshot' export const Snapshots = () => { return ( + + + diff --git a/ui/packages/ce/src/config/routes.tsx b/ui/packages/ce/src/config/routes.tsx index ca51aa8f..c0145ace 100644 --- a/ui/packages/ce/src/config/routes.tsx +++ b/ui/packages/ce/src/config/routes.tsx @@ -14,6 +14,11 @@ export const ROUTES = { SNAPSHOTS: { path: `/instance/snapshots`, + CREATE: { + name: 'Create snapshot', + path: `/instance/snapshots/create`, + }, + SNAPSHOTS: { name: 'Snapshots', path: `/instance/snapshots`, @@ -28,6 +33,11 @@ export const ROUTES = { BRANCHES: { path: `/instance/branches`, + CREATE: { + name: 'Create branch', + path: `/instance/branches/create`, + }, + BRANCHES: { name: 'Branches', path: `/instance/branches`, diff --git a/ui/packages/shared/components/SyntaxHighlight/index.tsx b/ui/packages/shared/components/SyntaxHighlight/index.tsx new file mode 100644 index 00000000..1c3846cc --- /dev/null +++ b/ui/packages/shared/components/SyntaxHighlight/index.tsx @@ -0,0 +1,67 @@ +import copyToClipboard from 'copy-to-clipboard' +import { makeStyles, IconButton } from '@material-ui/core' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' + +import { icons } from '@postgres.ai/shared/styles/icons' +import { Tooltip } from '@postgres.ai/shared/components/Tooltip' + +const useStyles = makeStyles( + { + copyFieldContainer: { + position: 'relative', + display: 'inline-block', + maxWidth: '100%', + width: '100%', + + '& code': { + whiteSpace: 'inherit !important', + }, + }, + copyButton: { + position: 'absolute', + top: 15, + right: 4, + zIndex: 10, + width: 26, + height: 26, + padding: 8, + backgroundColor: 'rgba(128, 128, 128, 0.15)', + transition: 'background-color 0.2s ease-in-out, color 0.2s ease-in-out', + + '&:hover': { + backgroundColor: 'rgba(128, 128, 128, 0.25)', + }, + }, + }, + { index: 1 }, +) + +export const SyntaxHighlight = ({ content }: { content: string }) => { + const classes = useStyles() + + return ( +
+ + {content} + + copyToClipboard(content)} + > + {icons.copyIcon} + +
+ ) +} diff --git a/ui/packages/shared/package.json b/ui/packages/shared/package.json index 564780b2..73064b71 100644 --- a/ui/packages/shared/package.json +++ b/ui/packages/shared/package.json @@ -13,13 +13,14 @@ "@material-ui/styles": "^4.11.4", "@monaco-editor/react": "^4.4.5", "@mui/material": "^5.10.12", - "@postgres.ai/shared": "link:../shared", "@postgres.ai/ce": "link:../ce", + "@postgres.ai/shared": "link:../shared", "@types/node": "^12.20.33", "@types/react": "^17.0.30", "@types/react-dom": "^17.0.10", "@types/react-router": "^5.1.17", "@types/react-router-dom": "^5.3.1", + "@types/react-syntax-highlighter": "^15.5.6", "classnames": "^2.3.1", "clsx": "^1.1.1", "copy-to-clipboard": "^3.3.1", @@ -35,6 +36,7 @@ "react": "^17.0.2", "react-countdown-hook": "^1.1.0", "react-scripts": "^5.0.0", + "react-syntax-highlighter": "^15.5.0", "stream-browserify": "^3.0.0", "typescript": "^4.8.3", "use-timer": "^2.0.1", diff --git a/ui/packages/shared/pages/Branches/Branch/index.tsx b/ui/packages/shared/pages/Branches/Branch/index.tsx index 900d7698..6f2227b2 100644 --- a/ui/packages/shared/pages/Branches/Branch/index.tsx +++ b/ui/packages/shared/pages/Branches/Branch/index.tsx @@ -30,6 +30,8 @@ import { styles } from '@postgres.ai/shared/styles/styles' import { DeleteBranchModal } from '@postgres.ai/shared/pages/Branches/components/Modals/DeleteBranchModal' import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' +import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' +import { getCliBranchListCommand } from '@postgres.ai/shared/pages/CreateBranch/utils' import { TableBodyCell, TableBodyCellMenu, @@ -43,17 +45,38 @@ type Props = Host const useStyles = makeStyles( () => ({ + wrapper: { + display: 'flex', + gap: '60px', + maxWidth: '1200px', + fontSize: '14px', + marginTop: '20px', + + '@media (max-width: 1300px)': { + flexDirection: 'column', + gap: '20px', + }, + }, marginTop: { marginTop: '16px', }, container: { maxWidth: '100%', - marginTop: '16px', + flex: '1 1 0', + minWidth: 0, '& p,span': { fontSize: 14, }, }, + snippetContainer: { + flex: '1 1 0', + minWidth: 0, + boxShadow: 'rgba(0, 0, 0, 0.1) 0px 4px 12px', + padding: '10px 20px 10px 20px', + height: 'max-content', + borderRadius: '4px', + }, actions: { display: 'flex', marginRight: '-16px', @@ -70,6 +93,9 @@ const useStyles = makeStyles( text: { marginTop: '4px', }, + cliText: { + marginTop: '8px', + }, paramTitle: { display: 'inline-block', width: 200, @@ -77,12 +103,12 @@ const useStyles = makeStyles( copyFieldContainer: { position: 'relative', display: 'block', - maxWidth: 400, + maxWidth: 525, width: '100%', }, textField: { ...styles.inputField, - 'max-width': 400, + 'max-width': 525, display: 'inline-block', '& .MuiOutlinedInput-input': { paddingRight: '32px!important', @@ -90,7 +116,7 @@ const useStyles = makeStyles( }, tableContainer: { position: 'relative', - maxWidth: 400, + maxWidth: 525, width: '100%', }, copyButton: { @@ -189,145 +215,181 @@ export const BranchesPage = observer((props: Props) => { return ( <> -
-
- - -
-
-
-
-

- Name -

-

{branch?.name}

+
+
+
+ +

+
+

+ Data state at  + + Data state time is a time at which data + is  recovered for this branch. + + } + > + {icons.infoIcon} + +

+

{branch?.dataStateAt || '-'}

+
+
+

+ Summary  +

+

+ Branch name: + {branch?.name} +

+

+ Parent branch: + {branch?.parent} +

+
+

- Data state at  - - Data state time is a time at which data - is  recovered for this snapshot. - - } - > - {icons.infoIcon} - -

-

{branch?.dataStateAt || '-'}

-
-
-

- Summary  -

-

- Parent branch: - {branch?.parent} + Snapshot info

+
+ + copyToClipboard(String(branch?.snapshotID))} + > + {icons.copyIcon} + +
+
+ {Number(branchLogLength) > 0 && ( + <> + Branch log ({branchLogLength}) + + + + + + Name + Data state at + Comment + + + {snapshotList?.map((snapshot, id) => ( + + {snapshot?.branch?.map((item, id) => ( + + generateSnapshotPageId(snapshot.id) && + history.push( + `/instance/snapshots/${generateSnapshotPageId( + snapshot.id, + )}`, + ) + } + > + copyToClipboard(item), + }, + ]} + /> + {item} + + {snapshot.dataStateAt || '-'} + + + {snapshot.comment ?? '-'} + + + ))} + + ))} +
+
+ + )}
-
-

- Snapshot info +

+
+ +

+ You can delete this branch using the CLI. To do this, run the + command below:

-
- - copyToClipboard(String(branch?.snapshotID))} - > - {icons.copyIcon} - -
-
- {Number(branchLogLength) > 0 && ( - <> - Branch log ({branchLogLength}) - - - - - - Name - Data state at - Comment - - - {snapshotList?.map((snapshot, id) => ( - - {snapshot?.branch?.map((item, id) => ( - - generateSnapshotPageId(snapshot.id) && - history.push( - `/instance/snapshots/${generateSnapshotPageId( - snapshot.id, - )}`, - ) - } - > - copyToClipboard(item), - }, - ]} - /> - {item} - - {snapshot.dataStateAt || '-'} - - - {snapshot.comment ?? '-'} - - - ))} - - ))} -
-
- - )} + + + +

+ You can get a list of all branches using the CLI. Copy the command + below and paste it into your terminal. +

+ + + +

+ You can get a list of snapshots for this branch using the CLI. To do + this, run the command below: +

+
{ - const classes = useStyles() - const history = useHistory() - const [branchError, setBranchError] = useState(createBranchError) - const [snapshotsList, setSnapshotsList] = useState< - GetSnapshotListResponseType[] | null - >() - - const handleClose = () => { - formik.resetForm() - setBranchError('') - onClose() - } - - const handleSubmit = async (values: CreateBranchFormValues) => { - await createBranch(values).then((branch) => { - if (branch && branch?.name) { - history.push(`/instance/branches/${branch.name}`) - } - }) - } - - const [{ formik, isFormDisabled }] = useForm(handleSubmit) - - useEffect(() => { - setBranchError(createBranchError || snapshotListError) - }, [createBranchError, snapshotListError]) - - useEffect(() => { - if (isOpen) { - getSnapshotList(formik.values.baseBranch).then((res) => { - if (res) { - const filteredSnapshots = res.filter((snapshot) => snapshot.id) - setSnapshotsList(filteredSnapshots) - formik.setFieldValue('snapshotID', filteredSnapshots[0]?.id) - } - }) - } - }, [isOpen, formik.values.baseBranch]) - - return ( - -
- formik.setFieldValue('branchName', e.target.value)} - /> - Parent branch -

- Choose an existing branch. The new branch will initially point at the - same snapshot as the parent branch but going further, their evolution - paths will be independent - new snapshots can be created for both - branches. -

- formik.setFieldValue('snapshotID', e.target.value)} - error={Boolean(formik.errors.baseBranch)} - items={ - snapshotsList - ? snapshotsList.map((snapshot, i) => { - const isLatest = i === 0 - return { - value: snapshot.id, - children: ( -
- - {snapshot?.id} {isLatest && Latest} - - {snapshot?.dataStateAt && ( -

Data state at: {snapshot?.dataStateAt}

- )} -
- ), - } - }) - : [] - } - /> - - {branchError || - (snapshotListError && ( - - ))} -
-
- ) -} diff --git a/ui/packages/shared/pages/Branches/components/Modals/styles.module.scss b/ui/packages/shared/pages/Branches/components/Modals/styles.module.scss deleted file mode 100644 index f00804f6..00000000 --- a/ui/packages/shared/pages/Branches/components/Modals/styles.module.scss +++ /dev/null @@ -1,27 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -.modalInputContainer { - width: 100%; - display: flex; - flex-direction: column; - gap: 5px; - - div:nth-child(1) { - width: 100%; - } - - button { - width: 120px; - } -} - -.snapshotOverflow { - width: 100%; - word-wrap: break-word; - white-space: initial; -} diff --git a/ui/packages/shared/pages/Branches/index.tsx b/ui/packages/shared/pages/Branches/index.tsx index 4f700ea3..903b428c 100644 --- a/ui/packages/shared/pages/Branches/index.tsx +++ b/ui/packages/shared/pages/Branches/index.tsx @@ -6,17 +6,17 @@ */ import { observer } from 'mobx-react-lite' +import { useHistory } from 'react-router' import { makeStyles } from '@material-ui/core' import React, { useEffect, useState } from 'react' -import { useStores } from '@postgres.ai/shared/pages/Instance/context' +import { useStores, useHost } from '@postgres.ai/shared/pages/Instance/context' import { Button } from '@postgres.ai/shared/components/Button2' import { GetBranchesResponseType } from '@postgres.ai/shared/types/api/endpoints/getBranches' import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' import { BranchesTable } from '@postgres.ai/shared/pages/Branches/components/BranchesTable' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' -import { CreateBranchModal } from '@postgres.ai/shared/pages/Branches/components/Modals/CreateBranchModal' import { Tooltip } from '@postgres.ai/shared/components/Tooltip' import { InfoIcon } from '@postgres.ai/shared/icons/Info' @@ -36,23 +36,18 @@ const useStyles = makeStyles( ) export const Branches = observer((): React.ReactElement => { + const host = useHost() const stores = useStores() const classes = useStyles() + const history = useHistory() const [branchesList, setBranchesList] = useState( [], ) - const [isCreateBranchOpen, setIsCreateBranchOpen] = useState(false) - const { - instance, - getBranches, - getSnapshotList, - snapshotListError, - isBranchesLoading, - getBranchesError, - createBranch, - createBranchError, - } = stores.main + const { instance, getBranches, isBranchesLoading, getBranchesError } = + stores.main + + const goToBranchAddPage = () => history.push(host.routes.createBranch()) useEffect(() => { getBranches().then((response) => { @@ -83,7 +78,7 @@ export const Branches = observer((): React.ReactElement => { @@ -100,15 +95,6 @@ export const Branches = observer((): React.ReactElement => { branchesData={branchesList} emptyTableText="This instance has no active branches" /> - setIsCreateBranchOpen(false)} - createBranch={createBranch} - createBranchError={createBranchError} - branchesList={branchesList} - getSnapshotList={getSnapshotList} - snapshotListError={snapshotListError} - />
) }) diff --git a/ui/packages/shared/pages/Clone/index.tsx b/ui/packages/shared/pages/Clone/index.tsx index 72d89497..81368a7a 100644 --- a/ui/packages/shared/pages/Clone/index.tsx +++ b/ui/packages/shared/pages/Clone/index.tsx @@ -6,15 +6,15 @@ */ import { useEffect, useState } from 'react' -import copyToClipboard from 'copy-to-clipboard' import { observer } from 'mobx-react-lite' import { useHistory } from 'react-router-dom' +import copyToClipboard from 'copy-to-clipboard' import { makeStyles, - TextField, Button, FormControlLabel, Checkbox, + TextField, IconButton, } from '@material-ui/core' @@ -35,19 +35,40 @@ import { Tooltip } from '@postgres.ai/shared/components/Tooltip' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { icons } from '@postgres.ai/shared/styles/icons' import { styles } from '@postgres.ai/shared/styles/styles' -import { CreateSnapshotModal } from '@postgres.ai/shared/pages/Instance/Snapshots/components/CreateSnapshotModal' +import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' import { Status } from './Status' import { useCreatedStores } from './useCreatedStores' import { Host } from './context' +import { + getCliDestroyCloneCommand, + getCliProtectedCloneCommand, + getCliResetCloneCommand, + getCreateSnapshotCommand, +} from './utils' -const textFieldWidth = 575 +const textFieldWidth = 525 const useStyles = makeStyles( (theme) => ({ + wrapper: { + display: 'flex', + gap: '60px', + maxWidth: '1200px', + fontSize: '14px !important', + marginTop: '20px', + + '@media (max-width: 1300px)': { + flexDirection: 'column', + gap: '20px', + }, + }, title: { marginTop: '16px', }, + tooltip: { + marginTop: '8px', + }, container: { maxWidth: textFieldWidth + 25, marginTop: '16px', @@ -62,7 +83,16 @@ const useStyles = makeStyles( marginLeft: '8px', }, summary: { - marginTop: 20, + flex: '1 1 0', + minWidth: 0, + }, + snippetContainer: { + flex: '1 1 0', + minWidth: 0, + boxShadow: 'rgba(0, 0, 0, 0.1) 0px 4px 12px', + padding: '10px 20px 10px 20px', + height: 'max-content', + borderRadius: '4px', }, paramTitle: { display: 'inline-block', @@ -89,9 +119,9 @@ const useStyles = makeStyles( }, actions: { display: 'flex', - marginRight: '-16px', flexWrap: 'wrap', rowGap: '16px', + marginBottom: '20px', }, actionButton: { marginRight: '16px', @@ -140,6 +170,9 @@ const useStyles = makeStyles( maxWidth: textFieldWidth, width: '100%', }, + status: { + maxWidth: `${textFieldWidth}px`, + }, copyButton: { position: 'absolute', top: 16, @@ -149,9 +182,6 @@ const useStyles = makeStyles( height: 32, padding: 8, }, - status: { - maxWidth: `${textFieldWidth}px`, - }, }), { index: 1 }, ) @@ -167,7 +197,6 @@ export const Clone = observer((props: Props) => { const [isOpenRestrictionModal, setIsOpenRestrictionModal] = useState(false) const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false) const [isOpenResetModal, setIsOpenResetModal] = useState(false) - const [isCreateSnapshotOpen, setIsCreateSnapshotOpen] = useState(false) // Initial loading data. useEffect(() => { @@ -235,8 +264,6 @@ export const Clone = observer((props: Props) => { ) } - const clonesList = instance?.state?.cloning.clones || [] - // Clone reset. const requestResetClone = () => setIsOpenResetModal(true) @@ -281,57 +308,7 @@ export const Clone = observer((props: Props) => { return ( <> {headRendered} -
-
- - - - -
- +
{stores.main.resetCloneError && ( { )}
+
+ + + +

Created

{clone.createdAt}

-
-

Data state at  @@ -374,9 +388,7 @@ export const Clone = observer((props: Props) => {

{clone.snapshot?.dataStateAt}

-
-

Status @@ -384,9 +396,7 @@ export const Clone = observer((props: Props) => {

-
-

Summary  @@ -572,9 +582,7 @@ export const Clone = observer((props: Props) => { )} )} -
-

Password was set during clone creation. It’s not being stored. @@ -582,13 +590,10 @@ export const Clone = observer((props: Props) => { You would need to recreate a clone if the password is lost.
-
-

Protection

-

{ and delete this clone once the work is done.

- {stores.main.updateCloneError && ( { )}
+
+ +

+ You can reset the clone using the CLI using the following command: +

+ + + +

+ You can destroy the clone using the CLI using the following command: +

+ + + +

+ You can toggle deletion protection using the CLI for this clone + using the following command: +

+ + + + + +

+ You can create a snapshot for this clone using the CLI using the + following command: +

+ +
+ <> { onResetClone={resetClone} version={instance.state.engine.version} /> - - setIsCreateSnapshotOpen(false)} - createSnapshot={snapshots.createSnapshot} - createSnapshotError={snapshots.snapshotDataError} - clones={clonesList} - currentClone={props.cloneId} - />
diff --git a/ui/packages/shared/pages/Clone/utils/index.ts b/ui/packages/shared/pages/Clone/utils/index.ts new file mode 100644 index 00000000..f99ade66 --- /dev/null +++ b/ui/packages/shared/pages/Clone/utils/index.ts @@ -0,0 +1,15 @@ +export const getCliResetCloneCommand = (cloneId: string) => { + return `dblab clone reset ${cloneId ? cloneId : ``}` +} + +export const getCliDestroyCloneCommand = (cloneId: string) => { + return `dblab clone destroy ${cloneId ? cloneId : ``}` +} + +export const getCliProtectedCloneCommand = (enabled: boolean) => { + return `dblab clone update --protected ${enabled ? '' : 'false'}` +} + +export const getCreateSnapshotCommand = (cloneId: string) => { + return `dblab branch snapshot --clone-id ${cloneId}` +} diff --git a/ui/packages/shared/pages/CreateBranch/index.tsx b/ui/packages/shared/pages/CreateBranch/index.tsx new file mode 100644 index 00000000..d9c37d49 --- /dev/null +++ b/ui/packages/shared/pages/CreateBranch/index.tsx @@ -0,0 +1,273 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import cn from 'classnames' +import { useHistory } from 'react-router' +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' +import { TextField, makeStyles } from '@material-ui/core' + +import { Button } from '@postgres.ai/shared/components/Button' +import { ResponseMessage } from '@postgres.ai/shared/pages/Instance/Configuration/ResponseMessage' +import { Select } from '@postgres.ai/shared/components/Select' +import { CreateBranchFormValues } from '@postgres.ai/shared/types/api/endpoints/createBranch' +import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' +import { StubSpinner } from '@postgres.ai/shared/components/StubSpinnerFlex' +import { Spinner } from '@postgres.ai/shared/components/Spinner' + +import { useForm } from './useForm' +import { MainStoreApi } from './stores/Main' +import { useCreatedStores } from './useCreatedStores' +import { getCliBranchListCommand, getCliCreateBranchCommand } from './utils' + +interface CreateBranchProps { + api: MainStoreApi + elements: { + breadcrumbs: React.ReactNode + } +} + +const useStyles = makeStyles( + { + wrapper: { + display: 'flex', + gap: '60px', + maxWidth: '1200px', + fontSize: '14px', + marginTop: '20px', + + '@media (max-width: 1300px)': { + flexDirection: 'column', + gap: '20px', + }, + }, + container: { + maxWidth: '100%', + flex: '1 1 0', + minWidth: 0, + + '& p,span': { + fontSize: 14, + }, + }, + snippetContainer: { + flex: '1 1 0', + minWidth: 0, + boxShadow: 'rgba(0, 0, 0, 0.1) 0px 4px 12px', + padding: '10px 20px 10px 20px', + height: 'max-content', + borderRadius: '4px', + }, + marginBottom: { + marginBottom: '8px', + }, + marginBottom2x: { + marginBottom: '16px', + }, + marginTop: { + marginTop: '8px', + }, + form: { + marginTop: '16px', + }, + spinner: { + marginLeft: '8px', + color: '#fff', + }, + snapshotOverflow: { + width: '100%', + wordWrap: 'break-word', + whiteSpace: 'initial', + }, + }, + { index: 1 }, +) + +export const CreateBranchPage = observer( + ({ api, elements }: CreateBranchProps) => { + const stores = useCreatedStores(api) + const classes = useStyles() + const history = useHistory() + + const { + load, + snapshotListError, + getBranchesError, + createBranch, + createBranchError, + isBranchesLoading, + isCreatingBranch, + branchesList, + snapshotsList, + } = stores.main + + const handleSubmit = async (values: CreateBranchFormValues) => { + await createBranch(values).then((branch) => { + if (branch && branch?.name) { + history.push(`/instance/branches/${branch.name}`) + } + }) + } + + const [{ formik }] = useForm(handleSubmit) + + useEffect(() => { + load(formik.values.baseBranch) + }, [formik.values.baseBranch]) + + useEffect(() => { + if (snapshotsList?.length) { + formik.setFieldValue('snapshotID', snapshotsList[0]?.id) + } + }, [snapshotsList]) + + if (isBranchesLoading) { + return + } + + return ( + <> + {elements.breadcrumbs} +
+
+ + {(snapshotListError || getBranchesError) && ( +
+ +
+ )} +
+ + formik.setFieldValue('branchName', e.target.value) + } + /> +

+ Choose an existing branch. The new branch will initially point + at the same snapshot as the parent branch but going further, + their evolution paths will be independent - new snapshots can be + created for both branches. +

+ + formik.setFieldValue('snapshotID', e.target.value) + } + error={Boolean(formik.errors.baseBranch)} + items={ + snapshotsList + ? snapshotsList.map((snapshot, i) => { + const isLatest = i === 0 + return { + value: snapshot.id, + children: ( +
+ + {snapshot?.id} {isLatest && Latest} + + {snapshot?.dataStateAt && ( +

Data state at: {snapshot?.dataStateAt}

+ )} +
+ ), + } + }) + : [] + } + /> + + {createBranchError && ( + + )} +
+
{' '} +
+ +

+ Alternatively, you can create a new branch using the CLI. Fill the + form, copy the command below and paste it into your terminal. +

+ + +

+ You can get a list of all branches using the CLI. Copy the command + below and paste it into your terminal. +

+ +
+
+ + ) + }, +) diff --git a/ui/packages/shared/pages/CreateBranch/stores/Main.ts b/ui/packages/shared/pages/CreateBranch/stores/Main.ts new file mode 100644 index 00000000..6907b98e --- /dev/null +++ b/ui/packages/shared/pages/CreateBranch/stores/Main.ts @@ -0,0 +1,105 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { makeAutoObservable } from 'mobx' + +import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { + CreateBranch, + CreateBranchFormValues, +} from '@postgres.ai/shared/types/api/endpoints/createBranch' +import { + GetSnapshotList, + GetSnapshotListResponseType, +} from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' +import { GetBranchesResponseType } from '@postgres.ai/shared/types/api/endpoints/getBranches' + +type Error = { + title?: string + message: string +} + +export type MainStoreApi = { + getBranches: GetBranches + createBranch: CreateBranch + getSnapshotList: GetSnapshotList +} + +export class MainStore { + snapshotListError: Error | null = null + getBranchesError: Error | null = null + createBranchError: Error | null = null + + isBranchesLoading = false + isCreatingBranch = false + + branchesList: GetBranchesResponseType[] = [] + snapshotsList: GetSnapshotListResponseType[] = [] + private readonly api: MainStoreApi + + constructor(api: MainStoreApi) { + this.api = api + makeAutoObservable(this) + } + + load = async (baseBranch: string) => { + await this.getBranches() + .then((response) => { + if (response) { + this.branchesList = response + } + }) + .then(() => { + this.getSnapshotList(baseBranch).then((res) => { + if (res) { + const filteredSnapshots = res.filter((snapshot) => snapshot.id) + this.snapshotsList = filteredSnapshots + } + }) + }) + } + + createBranch = async (values: CreateBranchFormValues) => { + if (!this.api.createBranch) return + + this.isCreatingBranch = true + this.createBranchError = null + + const { response, error } = await this.api.createBranch(values) + + this.isCreatingBranch = false + + if (error) this.createBranchError = await error.json().then((err) => err) + + return response + } + + getBranches = async () => { + if (!this.api.getBranches) return + this.isBranchesLoading = true + + const { response, error } = await this.api.getBranches() + + if (error) this.getBranchesError = await error.json().then((err) => err) + + return response + } + + getSnapshotList = async (branchName: string) => { + if (!this.api.getSnapshotList) return + + const { response, error } = await this.api.getSnapshotList(branchName) + + this.isBranchesLoading = false + + if (error) { + this.snapshotListError = await error.json().then((err) => err) + } + + return response + } +} diff --git a/ui/packages/shared/pages/CreateBranch/useCreatedStores.ts b/ui/packages/shared/pages/CreateBranch/useCreatedStores.ts new file mode 100644 index 00000000..304e97f7 --- /dev/null +++ b/ui/packages/shared/pages/CreateBranch/useCreatedStores.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react' + +import { MainStore, MainStoreApi } from './stores/Main' + +export const useCreatedStores = (api: MainStoreApi) => ({ + main: useMemo(() => new MainStore(api), []), +}) + +export type Stores = ReturnType + +export type { MainStoreApi } diff --git a/ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/useForm.ts b/ui/packages/shared/pages/CreateBranch/useForm.ts similarity index 100% rename from ui/packages/shared/pages/Branches/components/Modals/CreateBranchModal/useForm.ts rename to ui/packages/shared/pages/CreateBranch/useForm.ts diff --git a/ui/packages/shared/pages/CreateBranch/utils/index.ts b/ui/packages/shared/pages/CreateBranch/utils/index.ts new file mode 100644 index 00000000..f645c224 --- /dev/null +++ b/ui/packages/shared/pages/CreateBranch/utils/index.ts @@ -0,0 +1,7 @@ +export const getCliCreateBranchCommand = (branchName: string) => { + return `dblab branch create ${branchName ? branchName : ``}` +} + +export const getCliBranchListCommand = () => { + return `dblab branch list` +} diff --git a/ui/packages/shared/pages/CreateClone/index.tsx b/ui/packages/shared/pages/CreateClone/index.tsx index 5e9d0387..9ef091b0 100644 --- a/ui/packages/shared/pages/CreateClone/index.tsx +++ b/ui/packages/shared/pages/CreateClone/index.tsx @@ -15,9 +15,11 @@ import { compareSnapshotsDesc } from '@postgres.ai/shared/utils/snapshot' import { round } from '@postgres.ai/shared/utils/numbers' import { formatBytesIEC } from '@postgres.ai/shared/utils/units' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' +import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' import { useCreatedStores, MainStoreApi } from './useCreatedStores' import { useForm, FormValues } from './useForm' +import { getCliCloneStatus, getCliCreateCloneCommand } from './utils' import styles from './styles.module.scss' @@ -91,12 +93,6 @@ export const CreateClone = observer((props: Props) => { {props.elements.breadcrumbs} - ) @@ -136,174 +132,208 @@ export const CreateClone = observer((props: Props) => { return ( <> {headRendered} +
+
+ + {stores.main.cloneError && ( +
+ +
+ )} -
- {stores.main.cloneError && (
- -
- )} + {branchesList && branchesList.length > 0 && ( + formik.setFieldValue('branch', e.target.value)} - error={Boolean(formik.errors.branch)} + label="Data state time *" + value={formik.values.snapshotId} + disabled={!sortedSnapshots || isCreatingClone} + onChange={(e) => + formik.setFieldValue('snapshotId', e.target.value) + } + error={Boolean(formik.errors.snapshotId)} items={ - branchesList?.map((snapshot) => { + sortedSnapshots?.map((snapshot, i) => { + const isLatest = i === 0 return { - value: snapshot, - children: snapshot, + value: snapshot.id, + children: ( + <> + {snapshot.dataStateAt} + {isLatest && ( + Latest + )} + + ), } }) ?? [] } /> - )} - formik.setFieldValue('cloneId', e.target.value)} - error={Boolean(formik.errors.cloneId)} - disabled={isCreatingClone} - /> - - + formik.setFieldValue('cloneID', e.target.value) + } + error={Boolean(formik.errors.cloneID)} + items={ + clonesList + ? clonesList.map((clone, i) => { + const isLatest = i === 0 + return { + value: clone.id, + children: ( +
+ + {clone.id} {isLatest && Latest} + +

Created: {clone?.snapshot?.createdAt}

+

+ Data state at: {clone?.snapshot?.dataStateAt} +

+
+ ), + } + }) + : [] + } + /> + Comment +

+ Optional comment to be added to the snapshot. +

+ + formik.setFieldValue('comment', e.target.value) + } + /> + + {snapshotError && ( + + )} +
+
{' '} +
+ +

+ Alternatively, you can create a new snapshot using the CLI. Fill + the form, copy the command below and paste it into your terminal. +

+ +
+
+ + ) + }, +) diff --git a/ui/packages/shared/pages/CreateSnapshot/stores/Main.ts b/ui/packages/shared/pages/CreateSnapshot/stores/Main.ts new file mode 100644 index 00000000..20f15a99 --- /dev/null +++ b/ui/packages/shared/pages/CreateSnapshot/stores/Main.ts @@ -0,0 +1,60 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { makeAutoObservable } from 'mobx' + +import { CreateSnapshot } from '@postgres.ai/shared/types/api/endpoints/createSnapshot' +import { + MainStore as InstanceStore, + Api as InstanceStoreApi, +} from '@postgres.ai/shared/pages/Instance/stores/Main' + +type Error = { + title?: string + message: string +} + +export type MainStoreApi = InstanceStoreApi & { + createSnapshot: CreateSnapshot +} + +export class MainStore { + snapshotError: Error | null = null + + isCreatingSnapshot = false + + readonly instance: InstanceStore + + private readonly api: MainStoreApi + + constructor(api: MainStoreApi) { + this.api = api + this.instance = new InstanceStore(api) + + makeAutoObservable(this) + } + + load = async () => { + this.instance.load('') + } + + createSnapshot = async (cloneID: string, message?: string) => { + if (!this.api.createSnapshot) return + + this.snapshotError = null + this.isCreatingSnapshot = true + + const { response, error } = await this.api.createSnapshot(cloneID, message) + + this.isCreatingSnapshot = false + + if (error) + this.snapshotError = await error.json().then((err) => err.message) + + return response + } +} diff --git a/ui/packages/shared/pages/CreateSnapshot/useCreatedStores.ts b/ui/packages/shared/pages/CreateSnapshot/useCreatedStores.ts new file mode 100644 index 00000000..304e97f7 --- /dev/null +++ b/ui/packages/shared/pages/CreateSnapshot/useCreatedStores.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react' + +import { MainStore, MainStoreApi } from './stores/Main' + +export const useCreatedStores = (api: MainStoreApi) => ({ + main: useMemo(() => new MainStore(api), []), +}) + +export type Stores = ReturnType + +export type { MainStoreApi } diff --git a/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts b/ui/packages/shared/pages/CreateSnapshot/useForm.ts similarity index 93% rename from ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts rename to ui/packages/shared/pages/CreateSnapshot/useForm.ts index 2b237f0e..4e7ee74a 100644 --- a/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/useForm.ts +++ b/ui/packages/shared/pages/CreateSnapshot/useForm.ts @@ -14,7 +14,7 @@ export type FormValues = { } const Schema = Yup.object().shape({ - cloneID: Yup.string().required('Branch name is required'), + cloneID: Yup.string().required('Clone ID is required'), }) export const useForm = (onSubmit: (values: FormValues) => void) => { diff --git a/ui/packages/shared/pages/CreateSnapshot/utils/index.ts b/ui/packages/shared/pages/CreateSnapshot/utils/index.ts new file mode 100644 index 00000000..c323b7db --- /dev/null +++ b/ui/packages/shared/pages/CreateSnapshot/utils/index.ts @@ -0,0 +1,3 @@ +export const getCliCreateSnapshotCommand = (cloneID: string) => { + return `dblab branch create ${cloneID ? cloneID : ``}` +} diff --git a/ui/packages/shared/pages/Instance/Clones/ClonesList/index.tsx b/ui/packages/shared/pages/Instance/Clones/ClonesList/index.tsx index 73b24de4..1fc7f840 100644 --- a/ui/packages/shared/pages/Instance/Clones/ClonesList/index.tsx +++ b/ui/packages/shared/pages/Instance/Clones/ClonesList/index.tsx @@ -160,7 +160,7 @@ export const ClonesList = (props: Props) => { {clone.snapshot ? ( <> - {clone.snapshot.dataStateAt} ( + {clone.snapshot.dataStateAt} {isValidDate(clone.snapshot.dataStateAtDate) ? formatDistanceToNowStrict( clone.snapshot.dataStateAtDate, @@ -169,7 +169,6 @@ export const ClonesList = (props: Props) => { }, ) : '-'} - ) ) : ( '-' diff --git a/ui/packages/shared/pages/Instance/Info/index.tsx b/ui/packages/shared/pages/Instance/Info/index.tsx index e8b18a2d..41a7980c 100644 --- a/ui/packages/shared/pages/Instance/Info/index.tsx +++ b/ui/packages/shared/pages/Instance/Info/index.tsx @@ -26,6 +26,7 @@ const useStyles = makeStyles( [theme.breakpoints.down('sm')]: { flex: '1 1 100%', marginTop: '20px', + width: '100%', }, }, }), diff --git a/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/index.tsx b/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/index.tsx deleted file mode 100644 index f5db8ade..00000000 --- a/ui/packages/shared/pages/Instance/Snapshots/components/CreateSnapshotModal/index.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useHistory } from 'react-router' -import { useEffect, useState } from 'react' -import { TextField } from '@material-ui/core' - -import { Modal } from '@postgres.ai/shared/components/Modal' -import { Button } from '@postgres.ai/shared/components/Button' -import { ResponseMessage } from '@postgres.ai/shared/pages/Instance/Configuration/ResponseMessage' -import { Select } from '@postgres.ai/shared/components/Select' -import { MainStore } from '@postgres.ai/shared/pages/Instance/stores/Main' -import { ModalProps } from '@postgres.ai/shared/pages/Branches/components/Modals/types' -import { Clone } from '@postgres.ai/shared/types/api/entities/clone' -import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' - -import { FormValues, useForm } from './useForm' - -import styles from '../styles.module.scss' - -interface CreateSnapshotModalProps extends ModalProps { - createSnapshotError: - | string - | null - | { - title?: string - message: string - } - createSnapshot: MainStore['createSnapshot'] - clones: Clone[] - currentClone?: string -} - -export const CreateSnapshotModal = ({ - isOpen, - onClose, - createSnapshotError, - createSnapshot, - clones, - currentClone, -}: CreateSnapshotModalProps) => { - const history = useHistory() - const [snapshotError, setSnapshotError] = useState(createSnapshotError) - - const handleClose = () => { - formik.resetForm() - setSnapshotError('') - onClose() - } - - const handleSubmit = async (values: FormValues) => { - await createSnapshot(values.cloneID, values.comment).then((snapshot) => { - if (snapshot && generateSnapshotPageId(snapshot.snapshotID)) { - history.push( - `/instance/snapshots/${generateSnapshotPageId(snapshot.snapshotID)}`, - ) - } - }) - } - - useEffect(() => { - setSnapshotError(createSnapshotError) - }, [createSnapshotError]) - - useEffect(() => { - if (currentClone) formik.setFieldValue('cloneID', currentClone) - }, [currentClone, isOpen]) - - const [{ formik }] = useForm(handleSubmit) - - return ( - -
- Clone ID -

- Choose a clone ID from the dropdown below. This will be the starting - point for your new snapshot. -

- void) => { restoreParallelJobs: '', pgDumpCustomOptions: '', pgRestoreCustomOptions: '', - dumpIgnoreErrors: false, - restoreIgnoreErrors: false, }, validationSchema: Schema, onSubmit, @@ -87,18 +83,13 @@ export const useForm = (onSubmit: (values: FormValues) => void) => { ...(formik.values.databases && { db_list: formatDatabaseArray(formik.values.databases), }), - ...(formik.values.dockerImageType === 'custom' && { - dockerImage: formik.values.dockerImage, - }), } const isConnectionDataValid = formik.values.host && formik.values.port && formik.values.username && - formik.values.dbname && - formik.values.dockerImageType === 'custom' && - formik.values.dockerImage + formik.values.dbname return [{ formik, connectionData, isConnectionDataValid }] } diff --git a/ui/packages/shared/pages/Instance/Configuration/utils/index.ts b/ui/packages/shared/pages/Instance/Configuration/utils/index.ts index 60bbf517..ec18bb3e 100644 --- a/ui/packages/shared/pages/Instance/Configuration/utils/index.ts +++ b/ui/packages/shared/pages/Instance/Configuration/utils/index.ts @@ -114,7 +114,3 @@ export const postUniqueCustomOptions = (options: string) => { ) return uniqueOptions } - -export const isRetrievalUnknown = (mode: string | undefined) => { - return mode === 'unknown' || mode === '' -} diff --git a/ui/packages/shared/pages/Instance/Info/Retrieval/RetrievalModal/index.tsx b/ui/packages/shared/pages/Instance/Info/Retrieval/RetrievalModal/index.tsx index 1c22d653..bf56e6f6 100644 --- a/ui/packages/shared/pages/Instance/Info/Retrieval/RetrievalModal/index.tsx +++ b/ui/packages/shared/pages/Instance/Info/Retrieval/RetrievalModal/index.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react' - import { Modal } from '@postgres.ai/shared/components/Modal' import { useStores } from '@postgres.ai/shared/pages/Instance/context' import { ModalReloadButton } from '@postgres.ai/shared/pages/Instance/components/ModalReloadButton' @@ -23,10 +21,6 @@ export const RetrievalModal = ({ const stores = useStores() const { isReloadingInstanceRetrieval, reloadInstanceRetrieval } = stores.main - useEffect(() => { - reloadInstanceRetrieval() - },[]) - return ( { if (!instanceRetrieval) return null const { mode, status, activity } = instanceRetrieval - const isVisible = mode !== 'physical' && !isRetrievalUnknown(mode) + const isVisible = mode !== 'physical' const isActive = mode === 'logical' && status === 'refreshing' return ( diff --git a/ui/packages/shared/pages/Instance/Tabs/index.tsx b/ui/packages/shared/pages/Instance/Tabs/index.tsx index fb1bee78..31229fab 100644 --- a/ui/packages/shared/pages/Instance/Tabs/index.tsx +++ b/ui/packages/shared/pages/Instance/Tabs/index.tsx @@ -76,14 +76,12 @@ type Props = { handleChange: (event: React.ChangeEvent<{}>, newValue: number) => void hasLogs: boolean hideInstanceTabs?: boolean - isConfigActive?: boolean } export const Tabs = (props: Props) => { const classes = useStyles() - const { value, handleChange, hasLogs, isConfigActive, hideInstanceTabs } = - props + const { value, handleChange, hasLogs } = props return ( {
} classes={{ - root: hideInstanceTabs ? classes.tabHidden : classes.tabRoot, + root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, }} value={TABS_INDEX.CLONES} /> @@ -127,10 +125,7 @@ export const Tabs = (props: Props) => { label="📓 Logs" disabled={!hasLogs} classes={{ - root: - props.hideInstanceTabs || !isConfigActive - ? classes.tabHidden - : classes.tabRoot, + root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, }} value={TABS_INDEX.LOGS} /> diff --git a/ui/packages/shared/pages/Instance/components/ModalReloadButton/index.tsx b/ui/packages/shared/pages/Instance/components/ModalReloadButton/index.tsx index d57a0534..b2662af1 100644 --- a/ui/packages/shared/pages/Instance/components/ModalReloadButton/index.tsx +++ b/ui/packages/shared/pages/Instance/components/ModalReloadButton/index.tsx @@ -35,7 +35,6 @@ export const ModalReloadButton = (props: Props) => { onClick={props.onReload} className={classes.content} isLoading={props.isReloading} - isDisabled={props.isReloading} > Reload info diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index 3b42baff..d01b4ecd 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -13,7 +13,6 @@ import { Button } from '@postgres.ai/shared/components/Button2' import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Instance/Configuration/utils' import { TABS_INDEX, Tabs } from './Tabs' import { Logs } from '../Logs' @@ -26,6 +25,7 @@ import { SnapshotsModal } from './Snapshots/components/SnapshotsModal' import { ClonesModal } from './Clones/ClonesModal' import { Host, HostProvider, StoresProvider } from './context' +import PropTypes from 'prop-types' import Typography from '@material-ui/core/Typography' import Box from '@mui/material/Box' @@ -68,21 +68,13 @@ export const Instance = observer((props: Props) => { const { instanceId, api } = props const stores = useCreatedStores(props) - const { - instance, - instanceError, - instanceRetrieval, - load, - isReloadingInstance, - } = stores.main useEffect(() => { - load(instanceId) + stores.main.load(instanceId) }, [instanceId]) - const isConfigurationActive = - !isRetrievalUnknown(instanceRetrieval?.mode) && - instanceRetrieval?.mode !== 'physical' + const { instance, instanceError, instanceRetrieval } = stores.main + const isConfigurationActive = instanceRetrieval?.mode !== 'physical' useEffect(() => { if ( @@ -122,10 +114,8 @@ export const Instance = observer((props: Props) => { className={classes.title} rightContent={ - - ) - } - useEffect(() => { if (api.initWS != undefined) { establishConnection(api) } }, [api]) - useEffect(() => { - localStorage.setItem('logsFilter', JSON.stringify(state)) - }, [state]) - useEffect(() => { const config = { attributes: false, childList: true, subtree: true } + const targetNode = document.getElementById('logs-container') as HTMLElement if (isLoading && targetNode?.querySelectorAll('p').length === 1) { setIsLoading(false) @@ -220,47 +48,21 @@ export const Logs = ({ api }: { api: Api }) => { const observer = new MutationObserver(callback) targetNode && observer.observe(targetNode, config) - }, [isLoading, targetNode]) + }, [isLoading]) return ( <> - Sensitive values are masked. - You can see the raw log data connecting to the machine and running{' '} - 'docker logs --since 5m -f dblab_server'. + Sensitive data are masked. + You can see the raw log data connecting to the machine and running the{' '} + 'docker logs' command. - {window.innerWidth > LAPTOP_WIDTH_PX && ( - <> -
- {Object.keys(state) - .slice(0, 3) - .map((key) => ( - - ))} -
-
- {Object.keys(state) - .slice(3, 10) - .map((key) => ( - - ))} -
- - )} -
- {isLoading ? ( +
+ {isLoading && (
- ) : null} + )}
) diff --git a/ui/packages/shared/pages/Logs/utils/index.ts b/ui/packages/shared/pages/Logs/utils/index.ts deleted file mode 100644 index 7a8ef092..00000000 --- a/ui/packages/shared/pages/Logs/utils/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const stringWithoutBrackets = (val: string | undefined) => - String(val).replace(/[\[\]]/g, '') - -export const stringContainsPattern = ( - target: string, - pattern = [ - 'base.go', - 'runners.go', - 'snapshots.go', - 'util.go', - 'logging.go', - 'ws.go', - ], -) => { - let value: number = 0 - pattern.forEach(function (word) { - value = value + Number(target.includes(word)) - }) - return value === 1 -} diff --git a/ui/packages/shared/pages/Logs/wsLogs.ts b/ui/packages/shared/pages/Logs/wsLogs.ts index 55270879..19b30c3f 100644 --- a/ui/packages/shared/pages/Logs/wsLogs.ts +++ b/ui/packages/shared/pages/Logs/wsLogs.ts @@ -1,15 +1,10 @@ -import moment from 'moment' - -import { - LOGS_ENDPOINT, - LOGS_LINE_LIMIT, - LOGS_TIME_LIMIT, -} from '@postgres.ai/shared/pages/Logs/constants' -import { Api } from '@postgres.ai/shared/pages/Instance/stores/Main' -import { - stringWithoutBrackets, - stringContainsPattern, -} from '@postgres.ai/shared/pages/Logs/utils' +import moment from 'moment'; +import { Api } from '../Instance/stores/Main'; + +const logsEndpoint = '/instance/logs'; + +const LOGS_TIME_LIMIT = 20 +const LOGS_LINE_LIMIT = 1000 export const establishConnection = async (api: Api) => { if (!api.getWSToken) return @@ -23,38 +18,13 @@ export const establishConnection = async (api: Api) => { const appendLogElement = (logEntry: string, logType?: string) => { const tag = document.createElement('p') - const logLevel = logEntry.split(' ')[3] - const logInitiator = logEntry.split(' ')[2] - const logsFilterState = JSON.parse(localStorage.getItem('logsFilter') || '') - - const filterInitiators = Object.keys(logsFilterState).some((state) => { - if (logsFilterState[state]) { - if (state === '[other]') { - return !stringContainsPattern(logInitiator) - } - return logInitiator.includes(stringWithoutBrackets(state)) - } - }) - - if ( - filterInitiators && - (logsFilterState[logInitiator] || logsFilterState[logLevel]) - ) { - tag.appendChild(document.createTextNode(logEntry)) - logElement.appendChild(tag) - } - - // we need to check both second and third element of logEntry, - // since the pattern of the response returned isn't always consistent - if (logInitiator === '[ERROR]' || logLevel === '[ERROR]') { - tag.classList.add('error-log') - } + tag.appendChild(document.createTextNode(logEntry)) + logElement.appendChild(tag) if (logType === 'message') { - const logEntryTime = logElement.children[1]?.innerHTML - .split(' ') - .slice(0, 2) - .join(' ') + const logEntryTime = moment.utc( + logElement.children[0].innerHTML.split(' ').slice(0, 2).join(' '), + ) const timeDifference = moment(logEntryTime).isValid() && @@ -64,9 +34,16 @@ export const establishConnection = async (api: Api) => { logElement.childElementCount > LOGS_LINE_LIMIT && timeDifference > LOGS_TIME_LIMIT ) { - logElement.removeChild(logElement.children[1]) + logElement.removeChild(logElement.children[0]) } } + + if ( + logEntry.split(' ')[2] === '[ERROR]' || + logEntry.split(' ')[3] === '[ERROR]' + ) { + tag.classList.add('error-log') + } } const { response, error } = await api.getWSToken({ @@ -85,7 +62,7 @@ export const establishConnection = async (api: Api) => { return } - const socket = api.initWS(LOGS_ENDPOINT, response.token) + const socket = api.initWS(logsEndpoint, response.token) socket.onopen = () => { console.log('Successfully Connected'); diff --git a/ui/packages/shared/pages/Logs/wsSnackbar.ts b/ui/packages/shared/pages/Logs/wsSnackbar.ts index f2a7961e..e21f9da4 100644 --- a/ui/packages/shared/pages/Logs/wsSnackbar.ts +++ b/ui/packages/shared/pages/Logs/wsSnackbar.ts @@ -1,4 +1,5 @@ -import { LOGS_NEW_DATA_MESSAGE } from '@postgres.ai/shared/pages/Logs/constants' +const LOGS_NEW_DATA_MESSAGE = + 'New data arrived below - scroll down to see it 👇🏻' export const wsSnackbar = (clientAtBottom: boolean, isNewData: boolean) => { const targetNode = document.getElementById('logs-container') @@ -8,16 +9,14 @@ export const wsSnackbar = (clientAtBottom: boolean, isNewData: boolean) => { if (!targetNode?.querySelector('.snackbar-tag')) { targetNode?.appendChild(snackbarTag) snackbarTag.classList.add('snackbar-tag') - if ( - snackbarTag.childNodes.length === 0 && - targetNode?.querySelector('p')?.textContent !== 'Not authorized' - ) { + if (snackbarTag.childNodes.length === 0) { snackbarTag.appendChild(document.createTextNode(LOGS_NEW_DATA_MESSAGE)) } snackbarTag.onclick = () => { - targetNode?.scroll({ - top: targetNode.scrollHeight, + targetNode?.scrollIntoView({ behavior: 'smooth', + block: 'end', + inline: 'end', }) } } diff --git a/ui/packages/shared/types/api/entities/config.ts b/ui/packages/shared/types/api/entities/config.ts index 73554df5..f5725a77 100644 --- a/ui/packages/shared/types/api/entities/config.ts +++ b/ui/packages/shared/types/api/entities/config.ts @@ -31,7 +31,6 @@ export type configTypes = { customOptions?: string[] databases?: DatabaseType | null parallelJobs?: string | number - ignoreErrors?: boolean source?: { connection?: { dbname?: string @@ -47,7 +46,6 @@ export type configTypes = { options?: { customOptions?: string[] parallelJobs?: string | number - ignoreErrors?: boolean } } } @@ -83,12 +81,8 @@ export const formatConfig = (config: configTypes) => { ), dumpParallelJobs: config.retrieval?.spec?.logicalDump?.options?.parallelJobs, - dumpIgnoreErrors: - config.retrieval?.spec?.logicalDump?.options?.ignoreErrors, restoreParallelJobs: config.retrieval?.spec?.logicalRestore?.options?.parallelJobs, - restoreIgnoreErrors: - config.retrieval?.spec?.logicalRestore?.options?.ignoreErrors, pgDumpCustomOptions: formatDumpCustomOptions( (config.retrieval?.spec?.logicalDump?.options ?.customOptions as string[]) || null, From f4d5353f9f4f21b1a3faa1e4b32abeba7d727fea Mon Sep 17 00:00:00 2001 From: akartasov Date: Thu, 27 Apr 2023 16:48:32 -0300 Subject: [PATCH 009/114] fix loading config --- .../pages/Instance/Configuration/utils/index.ts | 4 ++++ .../shared/pages/Instance/Info/Retrieval/index.tsx | 3 ++- ui/packages/shared/pages/Instance/Tabs/index.tsx | 13 +++++++++---- ui/packages/shared/pages/Instance/index.tsx | 13 +++++++------ ui/packages/shared/pages/Instance/stores/Main.ts | 6 +++++- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/ui/packages/shared/pages/Instance/Configuration/utils/index.ts b/ui/packages/shared/pages/Instance/Configuration/utils/index.ts index ec18bb3e..60bbf517 100644 --- a/ui/packages/shared/pages/Instance/Configuration/utils/index.ts +++ b/ui/packages/shared/pages/Instance/Configuration/utils/index.ts @@ -114,3 +114,7 @@ export const postUniqueCustomOptions = (options: string) => { ) return uniqueOptions } + +export const isRetrievalUnknown = (mode: string | undefined) => { + return mode === 'unknown' || mode === '' +} diff --git a/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx b/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx index 2c812ad3..310073b4 100644 --- a/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx +++ b/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx @@ -9,6 +9,7 @@ import { formatDateStd } from '@postgres.ai/shared/utils/date' import { Button } from '@postgres.ai/shared/components/Button2' import { Tooltip } from '@postgres.ai/shared/components/Tooltip' import { InfoIcon } from '@postgres.ai/shared/icons/Info' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/utils' import { Section } from '../components/Section' import { Property } from '../components/Property' @@ -46,7 +47,7 @@ export const Retrieval = observer(() => { if (!instanceRetrieval) return null const { mode, status, activity } = instanceRetrieval - const isVisible = mode !== 'physical' + const isVisible = mode !== 'physical' && !isRetrievalUnknown(mode) const isActive = mode === 'logical' && status === 'refreshing' return ( diff --git a/ui/packages/shared/pages/Instance/Tabs/index.tsx b/ui/packages/shared/pages/Instance/Tabs/index.tsx index 31229fab..7cb34e28 100644 --- a/ui/packages/shared/pages/Instance/Tabs/index.tsx +++ b/ui/packages/shared/pages/Instance/Tabs/index.tsx @@ -76,12 +76,14 @@ type Props = { handleChange: (event: React.ChangeEvent<{}>, newValue: number) => void hasLogs: boolean hideInstanceTabs?: boolean + isConfigActive?: boolean } export const Tabs = (props: Props) => { const classes = useStyles() - const { value, handleChange, hasLogs } = props + const { value, handleChange, hasLogs, isConfigActive, hideInstanceTabs } = + props return ( {
} classes={{ - root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, + root: hideInstanceTabs ? classes.tabHidden : classes.tabRoot, }} value={TABS_INDEX.CLONES} /> @@ -125,14 +127,17 @@ export const Tabs = (props: Props) => { label="📓 Logs" disabled={!hasLogs} classes={{ - root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, + root: + props.hideInstanceTabs || !isConfigActive + ? classes.tabHidden + : classes.tabRoot, }} value={TABS_INDEX.LOGS} /> diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index 15295d98..f2087940 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -13,6 +13,7 @@ import { Button } from '@postgres.ai/shared/components/Button2' import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/utils' import { TABS_INDEX, Tabs } from './Tabs' import { Logs } from '../Logs' @@ -71,11 +72,12 @@ export const Instance = observer((props: Props) => { const { instance, instanceError, instanceRetrieval, load } = stores.main useEffect(() => { - stores.main.load(instanceId) + load(instanceId) }, [instanceId]) - const { instance, instanceError, instanceRetrieval } = stores.main - const isConfigurationActive = instanceRetrieval?.mode !== 'physical' + const isConfigurationActive = + !isRetrievalUnknown(instanceRetrieval?.mode) && + instanceRetrieval?.mode !== 'physical' useEffect(() => { if ( @@ -93,9 +95,7 @@ export const Instance = observer((props: Props) => { } }, [instance]) - const [activeTab, setActiveTab] = React.useState( - props?.renderCurrentTab || TABS_INDEX.OVERVIEW, - ) + const [activeTab, setActiveTab] = React.useState(0) const switchTab = (_: React.ChangeEvent<{}> | null, tabID: number) => { const contentElement = document.getElementById('content-container') @@ -128,6 +128,7 @@ export const Instance = observer((props: Props) => { handleChange={switchTab} hasLogs={api.initWS != undefined} hideInstanceTabs={props?.hideInstanceTabs} + isConfigActive={!isRetrievalUnknown(instanceRetrieval?.mode)} />
diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index 95312a15..7c8beeb4 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -26,6 +26,7 @@ import { GetFullConfig } from '@postgres.ai/shared/types/api/endpoints/getFullCo import { GetInstanceRetrieval } from '@postgres.ai/shared/types/api/endpoints/getInstanceRetrieval' import { InstanceRetrievalType } from '@postgres.ai/shared/types/api/entities/instanceRetrieval' import { GetEngine } from '@postgres.ai/shared/types/api/endpoints/getEngine' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/utils' import { GetSnapshotList } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' @@ -77,6 +78,7 @@ export class MainStore { readonly snapshots: SnapshotsStore isReloadingClones = false + isConfigurationLoading = false isReloadingInstanceRetrieval = false isBranchesLoading = false isConfigLoading = false @@ -100,7 +102,7 @@ export class MainStore { this.loadInstance(instanceId) this.getBranches() this.loadInstanceRetrieval(instanceId).then(() => { - if (this.instanceRetrieval?.mode !== 'physical') { + if (!isRetrievalUnknown(this.instanceRetrieval?.mode)) { this.getConfig().then((res) => { if (res) { this.getEngine() @@ -184,10 +186,12 @@ export class MainStore { getConfig = async () => { if (!this.api.getConfig) return + this.isConfigurationLoading = true this.isConfigLoading = true const { response, error } = await this.api.getConfig() + this.isConfigurationLoading = false this.isConfigLoading = false if (response) { From f331c14b81d8dba7d8403298882aaf6d7e26a934 Mon Sep 17 00:00:00 2001 From: akartasov Date: Thu, 27 Apr 2023 20:22:52 -0300 Subject: [PATCH 010/114] fix imports --- ui/packages/shared/pages/Configuration/index.tsx | 16 ++++++++-------- .../pages/Instance/Info/Retrieval/index.tsx | 2 +- ui/packages/shared/pages/Instance/index.tsx | 2 +- ui/packages/shared/pages/Instance/stores/Main.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ui/packages/shared/pages/Configuration/index.tsx b/ui/packages/shared/pages/Configuration/index.tsx index e7473222..273f4bc8 100644 --- a/ui/packages/shared/pages/Configuration/index.tsx +++ b/ui/packages/shared/pages/Configuration/index.tsx @@ -25,23 +25,23 @@ import { Spinner } from '@postgres.ai/shared/components/Spinner' import { useStores } from '@postgres.ai/shared/pages/Instance/context' import { MainStore } from '@postgres.ai/shared/pages/Instance/stores/Main' -import { tooltipText } from './tooltipText' -import { FormValues, useForm } from './useForm' -import { ResponseMessage } from './ResponseMessage' -import { ConfigSectionTitle, Header, ModalTitle } from './Header' +import { tooltipText } from '../Instance/Configuration/tooltipText' +import { FormValues, useForm } from '../Instance/Configuration/useForm' +import { ResponseMessage } from '../Instance/Configuration/ResponseMessage' +import { ConfigSectionTitle, Header, ModalTitle } from '../Instance/Configuration/Header' import { dockerImageOptions, defaultPgDumpOptions, defaultPgRestoreOptions, -} from './configOptions' -import { formatDockerImageArray, FormValuesKey, uniqueChipValue } from './utils' +} from '../Instance/Configuration/configOptions' +import { formatDockerImageArray, FormValuesKey, uniqueChipValue } from '../Instance/Configuration/utils' import { SelectWithTooltip, InputWithChip, InputWithTooltip, -} from './InputWithTooltip' +} from '../Instance/Configuration/InputWithTooltip' -import styles from './styles.module.scss' +import styles from '../Instance/Configuration/styles.module.scss' type PgOptionsType = { optionType: string diff --git a/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx b/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx index 310073b4..5c6fb1c4 100644 --- a/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx +++ b/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx @@ -9,7 +9,7 @@ import { formatDateStd } from '@postgres.ai/shared/utils/date' import { Button } from '@postgres.ai/shared/components/Button2' import { Tooltip } from '@postgres.ai/shared/components/Tooltip' import { InfoIcon } from '@postgres.ai/shared/icons/Info' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/utils' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/index' import { Section } from '../components/Section' import { Property } from '../components/Property' diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index f2087940..861ef48e 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -13,7 +13,7 @@ import { Button } from '@postgres.ai/shared/components/Button2' import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/utils' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/index' import { TABS_INDEX, Tabs } from './Tabs' import { Logs } from '../Logs' diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index 7c8beeb4..bad876eb 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -26,7 +26,7 @@ import { GetFullConfig } from '@postgres.ai/shared/types/api/endpoints/getFullCo import { GetInstanceRetrieval } from '@postgres.ai/shared/types/api/endpoints/getInstanceRetrieval' import { InstanceRetrievalType } from '@postgres.ai/shared/types/api/entities/instanceRetrieval' import { GetEngine } from '@postgres.ai/shared/types/api/endpoints/getEngine' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/utils' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/index' import { GetSnapshotList } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' From f10b153c6836e8cd05cd6247a6bd35de794f04ca Mon Sep 17 00:00:00 2001 From: akartasov Date: Thu, 27 Apr 2023 20:45:08 -0300 Subject: [PATCH 011/114] fix imports --- ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx | 2 +- ui/packages/shared/pages/Instance/index.tsx | 2 +- ui/packages/shared/pages/Instance/stores/Main.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx b/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx index 5c6fb1c4..5732c97b 100644 --- a/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx +++ b/ui/packages/shared/pages/Instance/Info/Retrieval/index.tsx @@ -9,7 +9,7 @@ import { formatDateStd } from '@postgres.ai/shared/utils/date' import { Button } from '@postgres.ai/shared/components/Button2' import { Tooltip } from '@postgres.ai/shared/components/Tooltip' import { InfoIcon } from '@postgres.ai/shared/icons/Info' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/index' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Instance/Configuration/utils' import { Section } from '../components/Section' import { Property } from '../components/Property' diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index 861ef48e..4d82b510 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -13,7 +13,7 @@ import { Button } from '@postgres.ai/shared/components/Button2' import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/index' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Instance/Configuration/utils' import { TABS_INDEX, Tabs } from './Tabs' import { Logs } from '../Logs' diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index bad876eb..7ba3b8ba 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -26,7 +26,7 @@ import { GetFullConfig } from '@postgres.ai/shared/types/api/endpoints/getFullCo import { GetInstanceRetrieval } from '@postgres.ai/shared/types/api/endpoints/getInstanceRetrieval' import { InstanceRetrievalType } from '@postgres.ai/shared/types/api/entities/instanceRetrieval' import { GetEngine } from '@postgres.ai/shared/types/api/endpoints/getEngine' -import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Configuration/index' +import { isRetrievalUnknown } from '@postgres.ai/shared/pages/Instance/Configuration/utils' import { GetSnapshotList } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' From 223992ffecf35bb1290349b6f93af9b586ef7cd6 Mon Sep 17 00:00:00 2001 From: LashaKakabadze Date: Wed, 3 May 2023 23:39:21 +0400 Subject: [PATCH 012/114] hide sticky banner for ce --- ui/packages/ce/src/App/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/packages/ce/src/App/index.tsx b/ui/packages/ce/src/App/index.tsx index 18bd2931..479e60ff 100644 --- a/ui/packages/ce/src/App/index.tsx +++ b/ui/packages/ce/src/App/index.tsx @@ -20,10 +20,15 @@ export const App = observer(() => { if (appStore.engine.isLoading || appStore.engine.data === undefined) return + const displayStickyBanner = + appStore.isValidAuthToken && + !appStore.engine.isLoading && + appStore.engine.data?.edition !== 'community' + return ( } > {appStore.isValidAuthToken ? ( From 14069574bac8be0efd5fd31b8fe763a23473d1ef Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Tue, 9 May 2023 09:20:26 +0400 Subject: [PATCH 013/114] hide password, remove articles, show text on hover --- .../shared/components/MenuButton/index.tsx | 10 +++++++++- .../shared/pages/Branches/Branch/index.tsx | 12 ++++++------ ui/packages/shared/pages/Clone/index.tsx | 16 ++++++++-------- ui/packages/shared/pages/CreateBranch/index.tsx | 6 +++--- ui/packages/shared/pages/CreateClone/index.tsx | 2 +- .../shared/pages/CreateClone/utils/index.ts | 2 +- .../shared/pages/CreateSnapshot/index.tsx | 2 +- ui/packages/shared/pages/Instance/Info/index.tsx | 7 +++++-- .../shared/pages/Snapshots/Snapshot/index.tsx | 8 ++++---- 9 files changed, 38 insertions(+), 27 deletions(-) diff --git a/ui/packages/shared/components/MenuButton/index.tsx b/ui/packages/shared/components/MenuButton/index.tsx index 94b0e611..68606974 100644 --- a/ui/packages/shared/components/MenuButton/index.tsx +++ b/ui/packages/shared/components/MenuButton/index.tsx @@ -10,6 +10,8 @@ type BaseProps = { className?: string icon?: React.ReactNode isCollapsed?: boolean + onMouseEnter?: React.MouseEventHandler + onMouseLeave?: React.MouseEventHandler } type ButtonProps = BaseProps & { @@ -53,7 +55,13 @@ export const Button = (props: Props) => { if (!props.type || props.type === 'button' || props.type === 'submit') return ( - ) diff --git a/ui/packages/shared/pages/Branches/Branch/index.tsx b/ui/packages/shared/pages/Branches/Branch/index.tsx index 6f2227b2..7668d972 100644 --- a/ui/packages/shared/pages/Branches/Branch/index.tsx +++ b/ui/packages/shared/pages/Branches/Branch/index.tsx @@ -359,10 +359,10 @@ export const BranchesPage = observer((props: Props) => { className={classes.marginTop} tag="h2" level={2} - text={'Delete branch using the CLI'} + text={'Delete branch using CLI'} />

- You can delete this branch using the CLI. To do this, run the + You can delete this branch using CLI. To do this, run the command below:

@@ -371,10 +371,10 @@ export const BranchesPage = observer((props: Props) => { className={classes.marginTop} tag="h2" level={2} - text={'Get branches using the CLI'} + text={'Get branches using CLI'} />

- You can get a list of all branches using the CLI. Copy the command + You can get a list of all branches using CLI. Copy the command below and paste it into your terminal.

@@ -383,10 +383,10 @@ export const BranchesPage = observer((props: Props) => { className={classes.marginTop} tag="h2" level={2} - text={'Get snapshots for this branch using the CLI'} + text={'Get snapshots for this branch using CLI'} />

- You can get a list of snapshots for this branch using the CLI. To do + You can get a list of snapshots for this branch using CLI. To do this, run the command below:

diff --git a/ui/packages/shared/pages/Clone/index.tsx b/ui/packages/shared/pages/Clone/index.tsx index 81368a7a..e793ab84 100644 --- a/ui/packages/shared/pages/Clone/index.tsx +++ b/ui/packages/shared/pages/Clone/index.tsx @@ -627,9 +627,9 @@ export const Clone = observer((props: Props) => {
- +

- You can reset the clone using the CLI using the following command: + You can reset the clone using CLI using the following command:

@@ -637,10 +637,10 @@ export const Clone = observer((props: Props) => { className={classes.title} tag="h2" level={2} - text={'Destroy clone using the CLI'} + text={'Destroy clone using CLI'} />

- You can destroy the clone using the CLI using the following command: + You can destroy the clone using CLI using the following command:

@@ -648,10 +648,10 @@ export const Clone = observer((props: Props) => { className={classes.title} tag="h2" level={2} - text={'Toggle deletion protection using the CLI'} + text={'Toggle deletion protection using CLI'} />

- You can toggle deletion protection using the CLI for this clone + You can toggle deletion protection using CLI for this clone using the following command:

@@ -662,10 +662,10 @@ export const Clone = observer((props: Props) => { className={classes.title} tag="h2" level={2} - text={'Create snapshot for this clone using the CLI'} + text={'Create snapshot for this clone using CLI'} />

- You can create a snapshot for this clone using the CLI using the + You can create a snapshot for this clone using CLI using the following command:

diff --git a/ui/packages/shared/pages/CreateBranch/index.tsx b/ui/packages/shared/pages/CreateBranch/index.tsx index d9c37d49..17ef8efd 100644 --- a/ui/packages/shared/pages/CreateBranch/index.tsx +++ b/ui/packages/shared/pages/CreateBranch/index.tsx @@ -248,7 +248,7 @@ export const CreateBranchPage = observer(

- Alternatively, you can create a new branch using the CLI. Fill the + Alternatively, you can create a new branch using CLI. Fill the form, copy the command below and paste it into your terminal.

- You can get a list of all branches using the CLI. Copy the command + You can get a list of all branches using CLI. Copy the command below and paste it into your terminal.

diff --git a/ui/packages/shared/pages/CreateClone/index.tsx b/ui/packages/shared/pages/CreateClone/index.tsx index 9ef091b0..247f2af3 100644 --- a/ui/packages/shared/pages/CreateClone/index.tsx +++ b/ui/packages/shared/pages/CreateClone/index.tsx @@ -317,7 +317,7 @@ export const CreateClone = observer((props: Props) => { text="The same using CLI" />

- Alternatively, you can create a new clone using the CLI. Fill the + Alternatively, you can create a new clone using CLI. Fill the form, copy the command below and paste it into your terminal.

diff --git a/ui/packages/shared/pages/CreateClone/utils/index.ts b/ui/packages/shared/pages/CreateClone/utils/index.ts index 9c41a197..2b06778f 100644 --- a/ui/packages/shared/pages/CreateClone/utils/index.ts +++ b/ui/packages/shared/pages/CreateClone/utils/index.ts @@ -7,7 +7,7 @@ export const getCliCreateCloneCommand = (values: FormValues) => { --username ${dbUser ? dbUser : ``} \ - --password ${dbPassword ? dbPassword : ``} \ + --password ${dbPassword ? dbPassword.replace(/./g, '*') : ``} \ ${branch ? `--branch ${branch}` : ``} \ diff --git a/ui/packages/shared/pages/CreateSnapshot/index.tsx b/ui/packages/shared/pages/CreateSnapshot/index.tsx index 3c9ff8cc..97202008 100644 --- a/ui/packages/shared/pages/CreateSnapshot/index.tsx +++ b/ui/packages/shared/pages/CreateSnapshot/index.tsx @@ -201,7 +201,7 @@ export const CreateSnapshotPage = observer(

- Alternatively, you can create a new snapshot using the CLI. Fill + Alternatively, you can create a new snapshot using CLI. Fill the form, copy the command below and paste it into your terminal.

{ const classes = useStyles() const width = useWindowDimensions() + const [onHover, setOnHover] = useState(false) const isMobileScreen = width <= SMALL_BREAKPOINT_PX const [isCollapsed, setIsCollapsed] = useState( @@ -107,9 +107,12 @@ export const Info = () => { > {!isMobileScreen && ( )} diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx b/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx index 4349a8aa..36ec6c23 100644 --- a/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx +++ b/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx @@ -373,10 +373,10 @@ export const SnapshotPage = observer((props: Props) => { className={classes.marginTop} tag="h2" level={2} - text={'Delete snapshot using the CLI'} + text={'Delete snapshot using CLI'} />

- You can delete this snapshot using the CLI. To do this, run the + You can delete this snapshot using CLI. To do this, run the command below:

{ className={classes.marginTop} tag="h2" level={2} - text={'Get snapshots using the CLI'} + text={'Get snapshots using CLI'} />

- You can get a list of all snapshots using the CLI. To do this, run + You can get a list of all snapshots using CLI. To do this, run the command below:

From 51027e3bedbd9863e24554f2b6f0d0e8fbe7ef05 Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Tue, 9 May 2023 12:46:26 +0400 Subject: [PATCH 014/114] add back changes that were lost during merge --- .../src/App/Instance/Configuration/index.tsx | 10 ++ ui/packages/ce/src/config/routes.tsx | 10 ++ .../pages/Instance/Configuration/useForm.ts | 7 + ui/packages/shared/pages/Instance/index.tsx | 38 +++-- .../shared/pages/Logs/Icons/PlusIcon.tsx | 9 ++ .../shared/pages/Logs/constants/index.ts | 7 + ui/packages/shared/pages/Logs/index.tsx | 134 ++++++++++++++++-- 7 files changed, 184 insertions(+), 31 deletions(-) create mode 100644 ui/packages/ce/src/App/Instance/Configuration/index.tsx create mode 100644 ui/packages/shared/pages/Logs/Icons/PlusIcon.tsx create mode 100644 ui/packages/shared/pages/Logs/constants/index.ts diff --git a/ui/packages/ce/src/App/Instance/Configuration/index.tsx b/ui/packages/ce/src/App/Instance/Configuration/index.tsx new file mode 100644 index 00000000..93981d6c --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Configuration/index.tsx @@ -0,0 +1,10 @@ +import { TABS_INDEX } from '@postgres.ai/shared/pages/Instance/Tabs' +import { ROUTES } from 'config/routes' +import { Route } from 'react-router' +import { Page } from '../Page' + +export const Configuration = () => ( + + + +) diff --git a/ui/packages/ce/src/config/routes.tsx b/ui/packages/ce/src/config/routes.tsx index c0145ace..28d3e951 100644 --- a/ui/packages/ce/src/config/routes.tsx +++ b/ui/packages/ce/src/config/routes.tsx @@ -11,6 +11,16 @@ export const ROUTES = { path: `/instance`, name: 'Instance', + CONFIGURATION: { + name: 'Configuration', + path: `/instance/configuration`, + }, + + LOGS: { + name: 'Logs', + path: `/instance/logs`, + }, + SNAPSHOTS: { path: `/instance/snapshots`, diff --git a/ui/packages/shared/pages/Instance/Configuration/useForm.ts b/ui/packages/shared/pages/Instance/Configuration/useForm.ts index b9132cc6..28186666 100644 --- a/ui/packages/shared/pages/Instance/Configuration/useForm.ts +++ b/ui/packages/shared/pages/Instance/Configuration/useForm.ts @@ -22,7 +22,9 @@ export type FormValues = { password: string databases: string dumpParallelJobs: string + dumpIgnoreErrors: boolean restoreParallelJobs: string + restoreIgnoreErrors: boolean pgDumpCustomOptions: string pgRestoreCustomOptions: string } @@ -54,6 +56,8 @@ export const useForm = (onSubmit: (values: FormValues) => void) => { restoreParallelJobs: '', pgDumpCustomOptions: '', pgRestoreCustomOptions: '', + dumpIgnoreErrors: false, + restoreIgnoreErrors: false, }, validationSchema: Schema, onSubmit, @@ -83,6 +87,9 @@ export const useForm = (onSubmit: (values: FormValues) => void) => { ...(formik.values.databases && { db_list: formatDatabaseArray(formik.values.databases), }), + ...(formik.values.dockerImageType === 'custom' && { + dockerImage: formik.values.dockerImage, + }), } const isConnectionDataValid = diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index 4d82b510..426c38d4 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -19,14 +19,13 @@ import { TABS_INDEX, Tabs } from './Tabs' import { Logs } from '../Logs' import { Clones } from './Clones' import { Info } from './Info' -import { Configuration } from './Configuration' -import { Branches } from '../Branches' import { Snapshots } from './Snapshots' -import { SnapshotsModal } from './Snapshots/components/SnapshotsModal' +import { Branches } from '../Branches' +import { Configuration } from '../Configuration' import { ClonesModal } from './Clones/ClonesModal' +import { SnapshotsModal } from './Snapshots/components/SnapshotsModal' import { Host, HostProvider, StoresProvider } from './context' -import PropTypes from 'prop-types' import Typography from '@material-ui/core/Typography' import Box from '@mui/material/Box' @@ -115,7 +114,7 @@ export const Instance = observer((props: Props) => { className={classes.title} rightContent={ + {isBranchesLoading ? ( + + ) : ( + <> + + - {!branchesList.length && ( - - - - )} - - } - /> - + {!branchesList.length && ( + + + + )} + + } + /> + + + )}
) }) From 10a44c2c55bd7868e44b8084c103e690ced26eda Mon Sep 17 00:00:00 2001 From: LashaKakabadze Date: Thu, 11 May 2023 12:22:05 +0400 Subject: [PATCH 020/114] remove go enterprise button --- ui/packages/ce/src/App/Menu/Header/index.tsx | 22 +++++--------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/ui/packages/ce/src/App/Menu/Header/index.tsx b/ui/packages/ce/src/App/Menu/Header/index.tsx index 0b5924bc..85b87d32 100644 --- a/ui/packages/ce/src/App/Menu/Header/index.tsx +++ b/ui/packages/ce/src/App/Menu/Header/index.tsx @@ -1,16 +1,11 @@ import cn from 'classnames' import { Link } from 'react-router-dom' -import { linksConfig } from '@postgres.ai/shared/config/links' -import { Button } from '@postgres.ai/shared/components/MenuButton' - import { ROUTES } from 'config/routes' import logoIconUrl from './icons/logo.svg' -import { ReactComponent as StarsIcon } from './icons/stars.svg' import styles from './styles.module.scss' -import { DLEEdition } from "helpers/edition"; type Props = { isCollapsed: boolean @@ -23,7 +18,11 @@ export const Header = (props: Props) => { to={ROUTES.path} className={cn(styles.header, props.isCollapsed && styles.collapsed)} > - Database Lab logo + Database Lab logo {!props.isCollapsed && (

@@ -33,17 +32,6 @@ export const Header = (props: Props) => {

)} - - {!props.isCollapsed && ( - - )} ) } From 8e51b6e53485afa57a4ad73a4c7d6b415d0ed204 Mon Sep 17 00:00:00 2001 From: LashaKakabadze Date: Thu, 11 May 2023 12:39:20 +0400 Subject: [PATCH 021/114] add missing import --- ui/packages/ce/src/App/Menu/Header/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/packages/ce/src/App/Menu/Header/index.tsx b/ui/packages/ce/src/App/Menu/Header/index.tsx index 85b87d32..11ec4a39 100644 --- a/ui/packages/ce/src/App/Menu/Header/index.tsx +++ b/ui/packages/ce/src/App/Menu/Header/index.tsx @@ -6,6 +6,7 @@ import { ROUTES } from 'config/routes' import logoIconUrl from './icons/logo.svg' import styles from './styles.module.scss' +import { DLEEdition } from 'helpers/edition' type Props = { isCollapsed: boolean From 008287be9238c243d67cacf2492c79811ca56ec9 Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Fri, 12 May 2023 00:56:03 +0000 Subject: [PATCH 022/114] fix incorrect snippet for branch list 'git branch list' creates new branch (current CLI behavior) - fix it the snippet omitting 'list' --- ui/packages/shared/pages/CreateBranch/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/shared/pages/CreateBranch/utils/index.ts b/ui/packages/shared/pages/CreateBranch/utils/index.ts index f645c224..0d3c9002 100644 --- a/ui/packages/shared/pages/CreateBranch/utils/index.ts +++ b/ui/packages/shared/pages/CreateBranch/utils/index.ts @@ -3,5 +3,5 @@ export const getCliCreateBranchCommand = (branchName: string) => { } export const getCliBranchListCommand = () => { - return `dblab branch list` + return `dblab branch` } From 1e45d78dd8801099d99362ea1b1dc6bbd7f3a4ff Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Tue, 16 May 2023 00:39:12 +0000 Subject: [PATCH 023/114] DLE->DBLab; Apache 2.0 license for all code in this repo --- LICENSE | 220 +++++++++++- LICENSE-AGPL | 661 ----------------------------------- README.md | 56 +-- ui/packages/ce/LICENSE | 661 ----------------------------------- ui/packages/platform/LICENSE | 13 - ui/packages/shared/LICENSE | 661 ----------------------------------- 6 files changed, 231 insertions(+), 2041 deletions(-) delete mode 100644 LICENSE-AGPL delete mode 100644 ui/packages/ce/LICENSE delete mode 100644 ui/packages/platform/LICENSE delete mode 100644 ui/packages/shared/LICENSE diff --git a/LICENSE b/LICENSE index fd32533a..cb43d4eb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,19 +1,201 @@ -Copyright © 2018-present, Postgres.ai (https://postgres.ai), Nikolay Samokhvalov nik@postgres.ai - -Portions of this software are licensed as follows: -- UI components: - - All content that resides in the "./ui/packages/platform" directory of this repository is licensed under the - license defined in "./ui/packages/platform/LICENSE" - - All content that resides in the "./ui/packages/ce" directory of this repository is licensed under the "AGPLv3" - license defined in "./LICENSE" - - All content that resides in the "./ui/packages/shared" directory of this repository is licensed under the "AGPLv3" - license defined in "./LICENSE" -- All third party components incorporated into the Database Lab Engine software are licensed under the original license -provided by the owner of the applicable component. -- Content outside of the above mentioned directories above is licensed under the "AGPLv3" license defined -in "./LICENSE" - -In plain language: this repository contains open-source software licensed under an OSI-approved license AGPLv3 (see -https://opensource.org/) except "./ui/packages/platform" that defines user interfaces and business logic for the -"Platform" version of Database Lab, which is not open source and can be used only with commercial license obtained -from Postgres.ai (see https://postgres.ai/pricing). + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Postgres.ai https://postgres.ai/ + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/LICENSE-AGPL b/LICENSE-AGPL deleted file mode 100644 index e308d63a..00000000 --- a/LICENSE-AGPL +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - Database Lab – instant database clones to boost development - Copyright © 2018-present, Postgres.ai (https://postgres.ai), Nikolay Samokhvalov - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/README.md b/README.md index daa48447..4c50cd82 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@
-

Database Lab Engine (DLE)

+

Database Lab Engine (DBLab)

@@ -44,9 +44,9 @@
--- - * For a managed PostgreSQL cloud service such as AWS RDS or Heroku, where physical connection and access to PGDATA are not available, DLE is supposed to be running on a separate VM in the same region, performing periodical automated full refresh of data and serving itself as a database-as-a-service solution providing thin database clones for development and testing purposes. + * For a managed PostgreSQL cloud service such as AWS RDS or Heroku, where physical connection and access to PGDATA are not available, DBLab is supposed to be running on a separate VM in the same region, performing periodical automated full refresh of data and serving itself as a database-as-a-service solution providing thin database clones for development and testing purposes. -## Why DLE? +## Why DBLab? - Build dev/QA/staging environments based on full-size production-like databases. - Provide temporary full-size database clones for SQL query analysis and optimization (see also: [SQL optimization chatbot Joe](https://gitlab.com/postgres-ai/joe)). - Automatically test database changes in CI/CD pipelines to avoid incidents in production. @@ -57,10 +57,11 @@ For example, cloning a 1 TiB PostgreSQL database takes ~10 seconds. Dozens of in Try it yourself right now: - enter [the Database Lab Platform](https://console.postgres.ai/), join the "Demo" organization, and test cloning of ~1 TiB demo database, or -- check out another demo setup, DLE CE: https://demo.aws.postgres.ai:446/instance, use the token `demo_token` to enter +- check out another demo setup (DBLab 3.4): https://demo.aws.postgres.ai:446/instance, use the token `demo_token` to enter +- if you are looking for DBLab 4.0, with branching and snapshotting support in API/CLI/UI, check out this demo instance: https://branching.aws.postgres.ai:446/instance, use the token `demo_token` to enter ## How it works -Thin cloning is fast because it uses [Copy-on-Write (CoW)](https://en.wikipedia.org/wiki/Copy-on-write#In_computer_storage). DLE supports two technologies to enable CoW and thin cloning: [ZFS](https://en.wikipedia.org/wiki/ZFS) (default) and [LVM](https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)). +Thin cloning is fast because it uses [Copy-on-Write (CoW)](https://en.wikipedia.org/wiki/Copy-on-write#In_computer_storage). DBLab supports two technologies to enable CoW and thin cloning: [ZFS](https://en.wikipedia.org/wiki/ZFS) (default) and [LVM](https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)). With ZFS, Database Lab Engine periodically creates a new snapshot of the data directory and maintains a set of snapshots, cleaning up old and unused ones. When requesting a new clone, users can choose which snapshot to use. @@ -87,25 +88,25 @@ Read more: - Two technologies are supported to enable thin cloning ([CoW](https://en.wikipedia.org/wiki/Copy-on-write)): [ZFS](https://en.wikipedia.org/wiki/ZFS) and [LVM](https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)). - All components are packaged in Docker containers. - UI to make manual work more convenient. -- API and CLI to automate the work with DLE snapshots and clones. +- API and CLI to automate the work with DBLab snapshots, branches, and clones (Postgres endpoints). - By default, PostgreSQL containers include many popular extensions ([docs](https://postgres.ai/docs/database-lab/supported-databases#extensions-included-by-default)). - PostgreSQL containers can be customized ([docs](https://postgres.ai/docs/database-lab/supported-databases#how-to-add-more-extensions)). - Source database can be located anywhere (self-managed Postgres, AWS RDS, GCP CloudSQL, Azure, Timescale Cloud, and so on) and does NOT require any adjustments. There are NO requirements to install ZFS or Docker to the source (production) databases. - Initial data provisioning can be done at either the physical (pg_basebackup, backup / archiving tools such as WAL-G or pgBackRest) or logical (dump/restore directly from the source or from files stored at AWS S3) level. - For logical mode, partial data retrieval is supported (specific databases, specific tables). -- For physical mode, a continuously updated state is supported ("sync container"), making DLE a specialized version of standby Postgres. -- For logical mode, periodic full refresh is supported, automated, and controlled by DLE. It is possible to use multiple disks containing different versions of the database, so full refresh won't require downtime. -- Fast Point in Time Recovery (PITR) to the points available in DLE snapshots. +- For physical mode, a continuously updated state is supported ("sync container"), making DBLab a specialized version of standby Postgres. +- For logical mode, periodic full refresh is supported, automated, and controlled by DBLab. It is possible to use multiple disks containing different versions of the database, so full refresh won't require downtime. +- Fast Point in Time Recovery (PITR) to the points available in DBLab snapshots. - Unused clones are automatically deleted. - "Deletion protection" flag can be used to block automatic or manual deletion of clones. -- Snapshot retention policies supported in DLE configuration. -- Persistent clones: clones survive DLE restarts (including full VM reboots). +- Snapshot retention policies supported in DBLab configuration. +- Persistent clones: clones survive DBLab restarts (including full VM reboots). - The "reset" command can be used to switch to a different version of data. - DB Migration Checker component collects various artifacts useful for DB testing in CI ([docs](https://postgres.ai/docs/db-migration-checker)). - SSH port forwarding for API and Postgres connections. -- Docker container config parameters can be specified in the DLE config. +- Docker container config parameters can be specified in the DBLab config. - Resource usage quotas for clones: CPU, RAM (container quotas, supported by Docker) -- Postgres config parameters can be specified in the DLE config (separately for clones, the "sync" container, and the "promote" container). +- Postgres config parameters can be specified in the DBLab config (separately for clones, the "sync" container, and the "promote" container). - Monitoring: auth-free `/healthz` API endpoint, extended `/status` (requires auth), [Netdata module](https://gitlab.com/postgres-ai/netdata_for_dle). ## How to contribute @@ -117,7 +118,7 @@ The easiest way to contribute is to give the project a GitHub/GitLab star: ### Spread the word Post a tweet mentioning [@Database_Lab](https://twitter.com/Database_Lab) or share the link to this repo in your favorite social network. -If you are actively using DLE, tell others about your experience. You can use the logo referenced below and stored in the `./assets` folder. Feel free to put them in your documents, slide decks, application, and website interfaces to show that you use DLE. +If you are actively using DBLab, tell others about your experience. You can use the logo referenced below and stored in the `./assets` folder. Feel free to put them in your documents, slide decks, application, and website interfaces to show that you use DBLab. HTML snippet for lighter backgrounds:

@@ -151,29 +152,32 @@ Check out our [contributing guide](./CONTRIBUTING.md) for more details. Making Database Lab Engine more accessible to engineers around the Globe is a great help for the project. Check details in the [translation section of contributing guide](./CONTRIBUTING.md#Translation). ### Reference guides -- [DLE components](https://postgres.ai/docs/reference-guides/database-lab-engine-components) -- [DLE configuration reference](https://postgres.ai/docs/database-lab/config-reference) -- [DLE API reference](https://postgres.ai/swagger-ui/dblab/) -- [Client CLI reference](https://postgres.ai/docs/database-lab/cli-reference) +- [DBLab components](https://postgres.ai/docs/reference-guides/database-lab-engine-components) +- [DBLab configuration reference](https://postgres.ai/docs/database-lab/config-reference) +- [DBLab API reference](https://postgres.ai/swagger-ui/dblab/) +- [DBLab Client CLI (`dblab`) reference](https://postgres.ai/docs/database-lab/cli-reference) ### How-to guides -- [How to install Database Lab with Terraform on AWS](https://postgres.ai/docs/how-to-guides/administration/install-database-lab-with-terraform) -- [How to install and initialize Database Lab CLI](https://postgres.ai/docs/how-to-guides/cli/cli-install-init) -- [How to manage DLE](https://postgres.ai/docs/how-to-guides/administration) +- [How to install DBLab SE](XXXXXXX) – TBD +- [How to install and initialize DBLab CLI `dblab`](https://postgres.ai/docs/how-to-guides/cli/cli-install-init) +- [How to manage DBLab](https://postgres.ai/docs/how-to-guides/administration) - [How to work with clones](https://postgres.ai/docs/how-to-guides/cloning) +- [How to work with branches](XXXXXXX) – TBD +- [How to integrate DBLab with GitHub Actions](XXXXXXX) – TBD +- [How to integrate DBLab with GitLab CI/CD](XXXXXXX) – TBD More you can find in [the "How-to guides" section](https://postgres.ai/docs/how-to-guides) of the docs. ### Miscellaneous -- [DLE Docker images](https://hub.docker.com/r/postgresai/dblab-server) +- [DBLab Docker images](https://hub.docker.com/r/postgresai/dblab-server) - [Extended Docker images for PostgreSQL (with plenty of extensions)](https://hub.docker.com/r/postgresai/extended-postgres) - [SQL Optimization chatbot (Joe Bot)](https://postgres.ai/docs/joe-bot) - [DB Migration Checker](https://postgres.ai/docs/db-migration-checker) ## License -DLE source code is licensed under the OSI-approved open source license GNU Affero General Public License version 3 (AGPLv3). +DBLab source code is licensed under the OSI-approved open-source license Apache License 2.0 -Reach out to the Postgres.ai team if you want a trial or commercial license that does not contain the GPL clauses: [Contact page](https://postgres.ai/contact). +Reach out to the Postgres.ai team if you use or want to start using DBLab Standard Edition (DBLab SE) or Enterprise Edition (DBLab EE): [Contact page](https://postgres.ai/contact). [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fpostgres-ai%2Fdatabase-lab-engine.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fpostgres-ai%2Fdatabase-lab-engine?ref=badge_large) @@ -181,7 +185,7 @@ Reach out to the Postgres.ai team if you want a trial or commercial license that - ["Database Lab Engine Community Covenant Code of Conduct"](./CODE_OF_CONDUCT.md) - Where to get help: [Contact page](https://postgres.ai/contact) - [Community Slack](https://slack.postgres.ai) -- If you need to report a security issue, follow instructions in ["Database Lab Engine security guidelines"](./SECURITY.md). +- If you need to report a security issue, follow instructions in ["DBLab security guidelines"](./SECURITY.md). [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?color=blue)](./CODE_OF_CONDUCT.md) diff --git a/ui/packages/ce/LICENSE b/ui/packages/ce/LICENSE deleted file mode 100644 index dc3e38e1..00000000 --- a/ui/packages/ce/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ -GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - Database Lab – instant database clones to boost development - Copyright © 2018-present, Postgres.ai (https://postgres.ai), Nikolay Samokhvalov - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/ui/packages/platform/LICENSE b/ui/packages/platform/LICENSE deleted file mode 100644 index 9d1317ac..00000000 --- a/ui/packages/platform/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -------------------------------------------------------------------------- -Copyright (c) 2018-present, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - -All Rights Reserved. Proprietary. - -Unauthorized using or copying of files located under this directory -(when used directly or after being compiled, arranged, augmented, -or combined), via any medium is strictly prohibited. - -See pricing: https://postgres.ai/pricing - -Reach out to the sales team: sales@postgres.ai -------------------------------------------------------------------------- diff --git a/ui/packages/shared/LICENSE b/ui/packages/shared/LICENSE deleted file mode 100644 index dc3e38e1..00000000 --- a/ui/packages/shared/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ -GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - Database Lab – instant database clones to boost development - Copyright © 2018-present, Postgres.ai (https://postgres.ai), Nikolay Samokhvalov - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. From da6a4a2361fef7e7c18ef4acf1654a7874db6ab5 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Tue, 16 May 2023 01:33:00 +0000 Subject: [PATCH 024/114] feat: add listing endpoints, complete branches swagger-spec --- .../swagger-spec/dblab_server_swagger.yaml | 255 +++++++++++++++++- engine/internal/srv/routes.go | 9 + engine/internal/srv/server.go | 3 +- engine/pkg/client/dblabapi/branch.go | 2 +- .../ce/src/api/branches/getBranches.ts | 2 +- 5 files changed, 264 insertions(+), 7 deletions(-) diff --git a/engine/api/swagger-spec/dblab_server_swagger.yaml b/engine/api/swagger-spec/dblab_server_swagger.yaml index 7bb2b6a5..0269e05b 100644 --- a/engine/api/swagger-spec/dblab_server_swagger.yaml +++ b/engine/api/swagger-spec/dblab_server_swagger.yaml @@ -1,8 +1,8 @@ swagger: "2.0" info: - description: "This is a Database Lab Engine sample server." - version: "3.2.0" - title: "Database Lab Engine API" + description: "This page provides the OpenAPI specification for the Database Lab (DBLab) API, previously recognized as the DLE API (Database Lab Engine API)." + version: "4.0.0-alpha.3" + title: "DBLab API" contact: email: "team@postgres.ai" license: @@ -74,6 +74,24 @@ paths: schema: $ref: "#/definitions/Error" + /clones: + get: + tags: + - "clone" + description: Retrieve a list of clones + parameters: + - in: header + name: Verification-Token + type: string + required: true + responses: + 200: + description: OK + schema: + type: array + items: + $ref: "#/definitions/Clone" + /clone: post: tags: @@ -598,6 +616,193 @@ paths: schema: $ref: "#/definitions/Error" + /branches: + get: + tags: + - "branch" + description: Retrieve a list of branches + parameters: + - in: header + name: Verification-Token + type: string + required: true + responses: + 200: + description: OK + schema: + type: array + items: + $ref: "#/definitions/Branch" + + /branch/snapshot/{id}: + get: + description: Retrieves information about the specified branch + parameters: + - name: id + in: path + description: ID of the branch snapshot + required: true + type: string + - in: header + name: Verification-Token + type: string + required: true + responses: + 200: + description: OK + schema: + $ref: "#/definitions/SnapshotDetails" + 400: + description: "Bad request" + schema: + $ref: "#/definitions/Error" + 404: + description: "Not found" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error" + schema: + $ref: "#/definitions/Error" + + /branch/create: + post: + tags: + - "branch" + description: Create a new branch + parameters: + - in: header + name: Verification-Token + type: string + required: true + - name: body + in: body + description: "Parameters required for branch creation: `branchName` – the name of the new branch; `baseBranch` – the name of the parent branch used for branch creation, or `snapshotID` – the snapshot ID used for branch creation" + required: true + schema: + type: object + properties: + branchName: + type: string + baseBranch: + type: string + snapshotID: + type: string + responses: + 200: + description: OK + schema: + type: object + properties: + name: + type: string + 400: + description: "Bad request" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error" + schema: + $ref: "#/definitions/Error" + + /branch/snapshot: + post: + tags: + - "branch" + description: Create a new snapshot for the specified clone + parameters: + - in: header + name: Verification-Token + type: string + required: true + - name: body + in: body + description: "Parameters necessary for snapshot creation: `cloneID` – the ID of the clone, `message` – description of the snapshot + required: true + schema: + type: object + properties: + cloneID: + type: string + message: + type: string + responses: + 200: + description: OK + schema: + type: object + properties: + snapshotID: + type: string + 400: + description: "Bad request" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error" + schema: + $ref: "#/definitions/Error" + + /branch/delete: + post: + tags: + - "branch" + description: Delete the specified branch + parameters: + - in: header + name: Verification-Token + type: string + required: true + - name: body + in: body + description: "Parameters required for branch deletion: `branchName` – the name of the branch to be deleted + required: true + schema: + type: object + properties: + branchName: + type: string + responses: + 200: + description: OK + schema: + $ref: "#/definitions/ResponseStatus" + 400: + description: "Bad request" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error" + schema: + $ref: "#/definitions/Error" + + /branch/log: + post: + tags: + - "branch" + description: Retrieve a log of a given branch + parameters: + - in: header + name: Verification-Token + type: string + required: true + - name: body + in: body + description: "Parameters required to access the log of the given branch: `branchName` – the name of the branch + required: false + schema: + type: object + properties: + branchName: + type: string + responses: + 200: + description: OK + schema: + type: array + items: + $ref: "#/definitions/SnapshotDetails" + definitions: Instance: type: "object" @@ -1042,6 +1247,14 @@ definitions: hint: type: "string" + ResponseStatus: + type: "object" + properties: + status: + type: "string" + message: + type: "string" + Config: type: object @@ -1070,7 +1283,41 @@ definitions: type: "string" description: "WebSocket token" + Branch: + type: object + properties: + name: + type: string + parent: + type: string + dataStateAt: + type: string + format: date-time + snapshotID: + type: string + + SnapshotDetails: + type: object + properties: + id: + type: string + parent: + type: string + child: + type: string + branch: + type: array + items: + type: string + root: + type: string + dataStateAt: + type: string + format: date-time + message: + type: string + externalDocs: - description: "Database Lab Docs" + description: "DBLab Docs" url: "https://gitlab.com/postgres-ai/docs/tree/master/docs/database-lab" diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 1edfe86a..d85c34a5 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -271,6 +271,15 @@ func (s *Server) createSnapshotClone(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) clones(w http.ResponseWriter, r *http.Request) { + cloningState := s.Cloning.GetCloningState() + + if err := api.WriteJSON(w, http.StatusOK, cloningState.Clones); err != nil { + api.SendError(w, r, err) + return + } +} + func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { if s.engProps.GetEdition() == global.StandardEdition { if err := s.engProps.CheckBilling(); err != nil { diff --git a/engine/internal/srv/server.go b/engine/internal/srv/server.go index c417b416..5a047fb7 100644 --- a/engine/internal/srv/server.go +++ b/engine/internal/srv/server.go @@ -198,6 +198,7 @@ func (s *Server) InitHandlers() { r.HandleFunc("/snapshot/create", authMW.Authorized(s.createSnapshot)).Methods(http.MethodPost) r.HandleFunc("/snapshot/delete", authMW.Authorized(s.deleteSnapshot)).Methods(http.MethodPost) r.HandleFunc("/snapshot/clone", authMW.Authorized(s.createSnapshotClone)).Methods(http.MethodPost) + r.HandleFunc("/clones", authMW.Authorized(s.clones)).Methods(http.MethodGet) r.HandleFunc("/clone", authMW.Authorized(s.createClone)).Methods(http.MethodPost) r.HandleFunc("/clone/{id}", authMW.Authorized(s.destroyClone)).Methods(http.MethodDelete) r.HandleFunc("/clone/{id}", authMW.Authorized(s.patchClone)).Methods(http.MethodPatch) @@ -209,7 +210,7 @@ func (s *Server) InitHandlers() { r.HandleFunc("/observation/download", authMW.Authorized(s.downloadArtifact)).Methods(http.MethodGet) r.HandleFunc("/instance/retrieval", authMW.Authorized(s.retrievalState)).Methods(http.MethodGet) - r.HandleFunc("/branch/list", authMW.Authorized(s.listBranches)).Methods(http.MethodGet) + r.HandleFunc("/branches", authMW.Authorized(s.listBranches)).Methods(http.MethodGet) r.HandleFunc("/branch/snapshot/{id:.*}", authMW.Authorized(s.getCommit)).Methods(http.MethodGet) r.HandleFunc("/branch/create", authMW.Authorized(s.createBranch)).Methods(http.MethodPost) r.HandleFunc("/branch/snapshot", authMW.Authorized(s.snapshot)).Methods(http.MethodPost) diff --git a/engine/pkg/client/dblabapi/branch.go b/engine/pkg/client/dblabapi/branch.go index b8b12efa..4a76f8f7 100644 --- a/engine/pkg/client/dblabapi/branch.go +++ b/engine/pkg/client/dblabapi/branch.go @@ -18,7 +18,7 @@ import ( // ListBranches returns branches list. func (c *Client) ListBranches(ctx context.Context) ([]string, error) { - u := c.URL("/branch/list") + u := c.URL("/branches") request, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { diff --git a/ui/packages/ce/src/api/branches/getBranches.ts b/ui/packages/ce/src/api/branches/getBranches.ts index 849b2e19..a9f351ba 100644 --- a/ui/packages/ce/src/api/branches/getBranches.ts +++ b/ui/packages/ce/src/api/branches/getBranches.ts @@ -8,7 +8,7 @@ import { request } from 'helpers/request' export const getBranches = async () => { - const response = await request(`/branch/list`) + const response = await request(`/branches`) return { response: response.ok ? await response.json() : null, From 3721a1d6b5556f0247828d30d99684c512fc7c20 Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Tue, 16 May 2023 02:11:08 +0000 Subject: [PATCH 025/114] Few more wording edits; fix syntax --- .../swagger-spec/dblab_server_swagger.yaml | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/engine/api/swagger-spec/dblab_server_swagger.yaml b/engine/api/swagger-spec/dblab_server_swagger.yaml index 0269e05b..b8ac327b 100644 --- a/engine/api/swagger-spec/dblab_server_swagger.yaml +++ b/engine/api/swagger-spec/dblab_server_swagger.yaml @@ -1,19 +1,19 @@ swagger: "2.0" info: description: "This page provides the OpenAPI specification for the Database Lab (DBLab) API, previously recognized as the DLE API (Database Lab Engine API)." - version: "4.0.0-alpha.3" - title: "DBLab API" + version: "4.0.0-alpha.5" + title: "DBLab API v4.0" contact: email: "team@postgres.ai" license: - name: "Database Lab License" - url: "https://gitlab.com/postgres-ai/database-lab/blob/master/LICENSE" + name: "DBLab v4.0 uses Apache Licence Version 2.0" + url: "https://gitlab.com/postgres-ai/database-lab/blob/dle-4-0/LICENSE" basePath: "/" tags: - - name: "Database Lab Engine" - description: "API Reference" + - name: "DBLab" + description: "DBLab API Reference" externalDocs: - description: "Database Lab Engine Docs" + description: "DBLab Docs" url: "https://postgres.ai/docs/database-lab" schemes: - "https" @@ -24,7 +24,7 @@ paths: get: tags: - "instance" - summary: "Get the status of the instance we are working with" + summary: "Get the status of the instance" description: "" operationId: "getInstanceStatus" consumes: @@ -50,7 +50,7 @@ paths: get: tags: - "instance" - summary: "Get the list of snapshots" + summary: "Retrieve a list of snapshots" description: "" operationId: "getSnapshots" consumes: @@ -717,7 +717,7 @@ paths: required: true - name: body in: body - description: "Parameters necessary for snapshot creation: `cloneID` – the ID of the clone, `message` – description of the snapshot + description: "Parameters necessary for snapshot creation: `cloneID` – the ID of the clone, `message` – description of the snapshot" required: true schema: type: object From d44c498530c58e80f62ab690d62ab629d26f427c Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Tue, 16 May 2023 02:15:06 +0000 Subject: [PATCH 026/114] One more syntax fix in swagger yaml --- engine/api/swagger-spec/dblab_server_swagger.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/api/swagger-spec/dblab_server_swagger.yaml b/engine/api/swagger-spec/dblab_server_swagger.yaml index b8ac327b..4df5282e 100644 --- a/engine/api/swagger-spec/dblab_server_swagger.yaml +++ b/engine/api/swagger-spec/dblab_server_swagger.yaml @@ -755,7 +755,7 @@ paths: required: true - name: body in: body - description: "Parameters required for branch deletion: `branchName` – the name of the branch to be deleted + description: "Parameters required for branch deletion: `branchName` – the name of the branch to be deleted" required: true schema: type: object From 1552f985dbecaae300c61370eac3d7d50f3b4c96 Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Tue, 16 May 2023 02:20:27 +0000 Subject: [PATCH 027/114] Multiple fixes of swagger yaml to pass validation --- engine/api/swagger-spec/dblab_server_swagger.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engine/api/swagger-spec/dblab_server_swagger.yaml b/engine/api/swagger-spec/dblab_server_swagger.yaml index 4df5282e..2de3695e 100644 --- a/engine/api/swagger-spec/dblab_server_swagger.yaml +++ b/engine/api/swagger-spec/dblab_server_swagger.yaml @@ -394,7 +394,7 @@ paths: schema: $ref: "#/definitions/Error" - /observation/download: + /observation/download/{artifact_type}/{clone_id}/{session_id}: get: tags: - "observation" @@ -570,7 +570,7 @@ paths: - "config" summary: "Test source database" description: "" - operationId: "testDBConnection" + operationId: "testDBConnection1" consumes: - "application/json" parameters: @@ -598,7 +598,7 @@ paths: - "config" summary: "Test source database" description: "" - operationId: "testDBConnection" + operationId: "testDBConnection2" consumes: - "application/json" parameters: @@ -788,7 +788,7 @@ paths: required: true - name: body in: body - description: "Parameters required to access the log of the given branch: `branchName` – the name of the branch + description: "Parameters required to access the log of the given branch: `branchName` – the name of the branch" required: false schema: type: object From 6739bc1cb604c8b1e95fdd7610f6af97644d1fb9 Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Tue, 16 May 2023 02:47:00 +0000 Subject: [PATCH 028/114] Adjust tags in swagger yaml --- .../swagger-spec/dblab_server_swagger.yaml | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/engine/api/swagger-spec/dblab_server_swagger.yaml b/engine/api/swagger-spec/dblab_server_swagger.yaml index 2de3695e..5611bf56 100644 --- a/engine/api/swagger-spec/dblab_server_swagger.yaml +++ b/engine/api/swagger-spec/dblab_server_swagger.yaml @@ -2,7 +2,7 @@ swagger: "2.0" info: description: "This page provides the OpenAPI specification for the Database Lab (DBLab) API, previously recognized as the DLE API (Database Lab Engine API)." version: "4.0.0-alpha.5" - title: "DBLab API v4.0" + title: "DBLab API" contact: email: "team@postgres.ai" license: @@ -23,8 +23,8 @@ paths: /status: get: tags: - - "instance" - summary: "Get the status of the instance" + - "Instance" + summary: "Get instance status" description: "" operationId: "getInstanceStatus" consumes: @@ -41,15 +41,11 @@ paths: description: "Successful operation" schema: $ref: "#/definitions/Instance" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" /snapshots: get: tags: - - "instance" + - "Snapshots" summary: "Retrieve a list of snapshots" description: "" operationId: "getSnapshots" @@ -69,15 +65,11 @@ paths: type: "array" items: $ref: "#/definitions/Snapshot" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" /clones: get: tags: - - "clone" + - "Clones" description: Retrieve a list of clones parameters: - in: header @@ -95,7 +87,7 @@ paths: /clone: post: tags: - - "clone" + - "Clones" summary: "Create a clone" description: "" operationId: "createClone" @@ -131,7 +123,7 @@ paths: /clone/{id}: get: tags: - - "clone" + - "Clones" summary: "Get a clone status" description: "" operationId: "getClone" @@ -165,7 +157,7 @@ paths: patch: tags: - - "clone" + - "Clones" summary: "Update a clone" description: "" operationId: "patchClone" @@ -205,7 +197,7 @@ paths: delete: tags: - - "clone" + - "Clones" summary: "Delete a clone" description: "" operationId: "destroyClone" @@ -236,7 +228,7 @@ paths: /clone/{id}/reset: post: tags: - - "clone" + - "Clones" summary: "Reset a clone" description: "" operationId: "resetClone" @@ -273,7 +265,7 @@ paths: /observation/start: post: tags: - - "observation" + - "Observation" summary: "Start an observation session" description: "" operationId: "startObservation" @@ -313,7 +305,7 @@ paths: /observation/stop: post: tags: - - "observation" + - "Observation" summary: "Stop the observation session" description: "" operationId: "stopObservation" @@ -353,7 +345,7 @@ paths: /observation/summary/{clone_id}/{session_id}: get: tags: - - "observation" + - "Observation" summary: "Get the observation summary info" description: "" operationId: "summaryObservation" @@ -397,7 +389,7 @@ paths: /observation/download/{artifact_type}/{clone_id}/{session_id}: get: tags: - - "observation" + - "Observation" summary: "Download the observation artifact" description: "" operationId: "downloadObservationArtifact" @@ -440,7 +432,7 @@ paths: /instance/retrieval: get: tags: - - "instance" + - "Instance" summary: "Report state of retrieval subsystem" description: "" operationId: "instanceRetrieval" @@ -464,7 +456,7 @@ paths: /healthz: get: tags: - - "instance" + - "Instance" summary: "Get the state of the instance we are working with" description: "" operationId: "healthCheck" @@ -489,7 +481,7 @@ paths: /admin/config: post: tags: - - "config" + - "Config" summary: "Set instance configuration" description: "" operationId: "setConfig" @@ -519,7 +511,7 @@ paths: $ref: "#/definitions/Error" get: tags: - - "config" + - "Config" summary: "Get instance configuration" description: "" operationId: "getConfig" @@ -543,7 +535,7 @@ paths: /admin/config.yaml: get: tags: - - "config" + - "Config" summary: "Get the config of the instance" description: "" operationId: "getConfigYaml" @@ -567,7 +559,7 @@ paths: /admin/test-db-source: post: tags: - - "config" + - "Config" summary: "Test source database" description: "" operationId: "testDBConnection1" @@ -595,7 +587,7 @@ paths: /admin/ws-auth: post: tags: - - "config" + - "Config" summary: "Test source database" description: "" operationId: "testDBConnection2" @@ -619,7 +611,7 @@ paths: /branches: get: tags: - - "branch" + - "Branches" description: Retrieve a list of branches parameters: - in: header @@ -636,7 +628,9 @@ paths: /branch/snapshot/{id}: get: - description: Retrieves information about the specified branch + tags: + - "Branches" + description: Retrieves information about the specified snapshot parameters: - name: id in: path @@ -668,7 +662,7 @@ paths: /branch/create: post: tags: - - "branch" + - "Branches" description: Create a new branch parameters: - in: header @@ -708,7 +702,7 @@ paths: /branch/snapshot: post: tags: - - "branch" + - "Branches" description: Create a new snapshot for the specified clone parameters: - in: header @@ -746,7 +740,7 @@ paths: /branch/delete: post: tags: - - "branch" + - "Branches" description: Delete the specified branch parameters: - in: header @@ -779,7 +773,7 @@ paths: /branch/log: post: tags: - - "branch" + - "Branches" description: Retrieve a log of a given branch parameters: - in: header From 0df878be74e65f04cf08d8b4847ae42d1322b326 Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Tue, 16 May 2023 05:09:52 +0000 Subject: [PATCH 029/114] fix(ui): separate url for instance tabs, dynamic filtering for logs page --- .../ce/src/App/Instance/Logs/index.tsx | 10 ++ .../ce/src/App/Instance/Page/index.tsx | 5 - ui/packages/ce/src/App/Instance/index.tsx | 10 +- ui/packages/ce/src/App/Menu/Header/index.tsx | 23 +--- .../shared/pages/Configuration/index.tsx | 14 ++- .../Instance/Configuration/styles.module.scss | 9 +- .../shared/pages/Instance/Snapshots/index.tsx | 69 ++++++----- .../shared/pages/Instance/Tabs/index.tsx | 111 ++++++++++-------- ui/packages/shared/pages/Instance/index.tsx | 4 +- ui/packages/shared/pages/Logs/index.tsx | 54 +++++---- ui/packages/shared/pages/Logs/wsLogs.ts | 24 +++- 11 files changed, 196 insertions(+), 137 deletions(-) create mode 100644 ui/packages/ce/src/App/Instance/Logs/index.tsx diff --git a/ui/packages/ce/src/App/Instance/Logs/index.tsx b/ui/packages/ce/src/App/Instance/Logs/index.tsx new file mode 100644 index 00000000..584494b6 --- /dev/null +++ b/ui/packages/ce/src/App/Instance/Logs/index.tsx @@ -0,0 +1,10 @@ +import { TABS_INDEX } from '@postgres.ai/shared/pages/Instance/Tabs' +import { ROUTES } from 'config/routes' +import { Route } from 'react-router' +import { Page } from '../Page' + +export const Logs = () => ( + + + +) diff --git a/ui/packages/ce/src/App/Instance/Page/index.tsx b/ui/packages/ce/src/App/Instance/Page/index.tsx index ebc27c9d..2c78be28 100644 --- a/ui/packages/ce/src/App/Instance/Page/index.tsx +++ b/ui/packages/ce/src/App/Instance/Page/index.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react' import { Instance } from '@postgres.ai/shared/pages/Instance' @@ -54,10 +53,6 @@ export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { breadcrumbs: , } - useEffect(() => { - window.history.replaceState({}, document.title, ROUTES.INSTANCE.path) - }, []) - return ( { return ( @@ -22,6 +24,12 @@ export const Instance = () => { + + + + + + ) diff --git a/ui/packages/ce/src/App/Menu/Header/index.tsx b/ui/packages/ce/src/App/Menu/Header/index.tsx index 0b5924bc..11ec4a39 100644 --- a/ui/packages/ce/src/App/Menu/Header/index.tsx +++ b/ui/packages/ce/src/App/Menu/Header/index.tsx @@ -1,16 +1,12 @@ import cn from 'classnames' import { Link } from 'react-router-dom' -import { linksConfig } from '@postgres.ai/shared/config/links' -import { Button } from '@postgres.ai/shared/components/MenuButton' - import { ROUTES } from 'config/routes' import logoIconUrl from './icons/logo.svg' -import { ReactComponent as StarsIcon } from './icons/stars.svg' import styles from './styles.module.scss' -import { DLEEdition } from "helpers/edition"; +import { DLEEdition } from 'helpers/edition' type Props = { isCollapsed: boolean @@ -23,7 +19,11 @@ export const Header = (props: Props) => { to={ROUTES.path} className={cn(styles.header, props.isCollapsed && styles.collapsed)} > - Database Lab logo + Database Lab logo {!props.isCollapsed && (

@@ -33,17 +33,6 @@ export const Header = (props: Props) => {

)} - - {!props.isCollapsed && ( - - )} ) } diff --git a/ui/packages/shared/pages/Configuration/index.tsx b/ui/packages/shared/pages/Configuration/index.tsx index 273f4bc8..ef8a7342 100644 --- a/ui/packages/shared/pages/Configuration/index.tsx +++ b/ui/packages/shared/pages/Configuration/index.tsx @@ -28,13 +28,21 @@ import { MainStore } from '@postgres.ai/shared/pages/Instance/stores/Main' import { tooltipText } from '../Instance/Configuration/tooltipText' import { FormValues, useForm } from '../Instance/Configuration/useForm' import { ResponseMessage } from '../Instance/Configuration/ResponseMessage' -import { ConfigSectionTitle, Header, ModalTitle } from '../Instance/Configuration/Header' +import { + ConfigSectionTitle, + Header, + ModalTitle, +} from '../Instance/Configuration/Header' import { dockerImageOptions, defaultPgDumpOptions, defaultPgRestoreOptions, } from '../Instance/Configuration/configOptions' -import { formatDockerImageArray, FormValuesKey, uniqueChipValue } from '../Instance/Configuration/utils' +import { + formatDockerImageArray, + FormValuesKey, + uniqueChipValue, +} from '../Instance/Configuration/utils' import { SelectWithTooltip, InputWithChip, @@ -295,7 +303,7 @@ export const Configuration = observer( } className={styles.snackbar} /> - {!config && isConfigurationLoading ? ( + {!config || isConfigurationLoading ? (
diff --git a/ui/packages/shared/pages/Instance/Configuration/styles.module.scss b/ui/packages/shared/pages/Instance/Configuration/styles.module.scss index 7105e1e8..80041d27 100644 --- a/ui/packages/shared/pages/Instance/Configuration/styles.module.scss +++ b/ui/packages/shared/pages/Instance/Configuration/styles.module.scss @@ -1,5 +1,5 @@ .textField { - width: 350px; + width: 400px; max-width: 100%; input, @@ -16,12 +16,16 @@ cursor: not-allowed; color: rgba(0, 0, 0, 0.38); } + + @media (max-width: 600px) { + width: 100%; + } } .chipContainer { width: 350px; max-width: 100%; - margin-bottom: 1.25rem; + margin-bottom: 0; } .databasesContainer { @@ -46,7 +50,6 @@ .spinner { margin-left: 8px; - color: #fff; } .spinnerContainer { diff --git a/ui/packages/shared/pages/Instance/Snapshots/index.tsx b/ui/packages/shared/pages/Instance/Snapshots/index.tsx index 03bc0a18..90911958 100644 --- a/ui/packages/shared/pages/Instance/Snapshots/index.tsx +++ b/ui/packages/shared/pages/Instance/Snapshots/index.tsx @@ -13,7 +13,7 @@ import { useStores, useHost } from '@postgres.ai/shared/pages/Instance/context' import { SnapshotsTable } from '@postgres.ai/shared/pages/Instance/Snapshots/components/SnapshotsTable' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { isSameDayUTC } from '@postgres.ai/shared/utils/date' -import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' +import { Spinner } from '@postgres.ai/shared/components/Spinner' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' import { Button } from '@postgres.ai/shared/components/Button2' import { Tooltip } from '@postgres.ai/shared/components/Tooltip' @@ -30,6 +30,11 @@ const useStyles = makeStyles( marginLeft: '8px', color: '#808080', }, + spinner: { + position: 'absolute', + right: '50%', + transform: 'translate(-50%, -50%)', + }, }, { index: 1 }, ) @@ -71,38 +76,42 @@ export const Snapshots = observer(() => { /> ) - if (snapshots.isLoading) return - return (
- - - - {!hasClones && ( - - - - )} - - } - /> - {!isEmpty ? ( - + {snapshots.isLoading ? ( + ) : ( -

- This instance has no active snapshots -

+ <> + + + + {!hasClones && ( + + + + )} + + } + /> + {!isEmpty ? ( + + ) : ( +

+ This instance has no active snapshots +

+ )} + )}
) diff --git a/ui/packages/shared/pages/Instance/Tabs/index.tsx b/ui/packages/shared/pages/Instance/Tabs/index.tsx index 7cb34e28..40948ec2 100644 --- a/ui/packages/shared/pages/Instance/Tabs/index.tsx +++ b/ui/packages/shared/pages/Instance/Tabs/index.tsx @@ -6,6 +6,7 @@ */ import React from 'react' +import { Link } from 'react-router-dom' import { makeStyles, Tab as TabComponent, @@ -38,6 +39,10 @@ const useStyles = makeStyles( width: '18px', height: '18px', }, + + '& a': { + color: colors.black, + }, }, flexRow: { @@ -91,56 +96,68 @@ export const Tabs = (props: Props) => { onChange={handleChange} classes={{ root: classes.tabsRoot, indicator: classes.tabsIndicator }} > - - - - - Clones -
- } - classes={{ + + + + + + + + + + + + Clones +
+ } + classes={{ root: hideInstanceTabs ? classes.tabHidden : classes.tabRoot, - }} - value={TABS_INDEX.CLONES} - /> - + + + - + + + + }} + value={TABS_INDEX.CONFIGURATION} + /> + ) } diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index 426c38d4..db682c91 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -94,7 +94,9 @@ export const Instance = observer((props: Props) => { } }, [instance]) - const [activeTab, setActiveTab] = React.useState(0) + const [activeTab, setActiveTab] = React.useState( + props?.renderCurrentTab || TABS_INDEX.OVERVIEW, + ) const switchTab = (_: React.ChangeEvent<{}> | null, tabID: number) => { const contentElement = document.getElementById('content-container') diff --git a/ui/packages/shared/pages/Logs/index.tsx b/ui/packages/shared/pages/Logs/index.tsx index ce508566..bab9ebae 100644 --- a/ui/packages/shared/pages/Logs/index.tsx +++ b/ui/packages/shared/pages/Logs/index.tsx @@ -5,7 +5,10 @@ import React, { useEffect, useReducer } from 'react' import { Spinner } from '@postgres.ai/shared/components/Spinner' import { Api } from '@postgres.ai/shared/pages/Instance/stores/Main' -import { establishConnection } from '@postgres.ai/shared/pages/Logs/wsLogs' +import { + establishConnection, + restartConnection, +} from '@postgres.ai/shared/pages/Logs/wsLogs' import { useWsScroll } from '@postgres.ai/shared/pages/Logs/hooks/useWsScroll' import { LAPTOP_WIDTH_PX } from './constants' @@ -24,6 +27,7 @@ const useStyles = makeStyles( display: 'flex', flexDirection: 'row', gap: 10, + flexWrap: 'wrap', '& > span': { display: 'flex', @@ -118,27 +122,26 @@ export const Logs = ({ api }: { api: Api }) => { return true } - const initialState = { - '[DEBUG]': !isEmpty(logsFilterState) ? logsFilterState?.['[DEBUG]'] : true, - '[INFO]': !isEmpty(logsFilterState) ? logsFilterState?.['[INFO]'] : true, - '[ERROR]': !isEmpty(logsFilterState) ? logsFilterState?.['[ERROR]'] : true, - '[base.go]': !isEmpty(logsFilterState) - ? logsFilterState?.['[base.go]'] - : true, - '[runners.go]': !isEmpty(logsFilterState) - ? logsFilterState?.['[runners.go]'] - : true, - '[snapshots.go]': !isEmpty(logsFilterState) - ? logsFilterState?.['[snapshots.go]'] - : true, - '[util.go]': !isEmpty(logsFilterState) - ? logsFilterState?.['[util.go]'] - : true, - '[logging.go]': !isEmpty(logsFilterState) - ? logsFilterState?.['[logging.go]'] - : false, - '[ws.go]': !isEmpty(logsFilterState) ? logsFilterState?.['[ws.go]'] : false, - '[other]': !isEmpty(logsFilterState) ? logsFilterState?.['[other]'] : true, + const initialState = (obj: Record) => { + const filters = { + '[DEBUG]': true, + '[INFO]': true, + '[ERROR]': true, + '[base.go]': true, + '[runners.go]': true, + '[snapshots.go]': true, + '[util.go]': true, + '[logging.go]': false, + '[ws.go]': false, + '[other]': true, + } + + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + filters[key as keyof typeof filters] = obj[key] + } + } + return filters } const reducer = ( @@ -171,13 +174,16 @@ export const Logs = ({ api }: { api: Api }) => { } } - const [state, dispatch] = useReducer(reducer, initialState) + const [state, dispatch] = useReducer(reducer, initialState(logsFilterState)) const FormCheckbox = ({ type }: { type: string }) => { const filterType = (state as Record)[`[${type}]`] return ( dispatch({ type })} + onClick={() => { + dispatch({ type }) + restartConnection(api) + }} className={ filterType && type !== 'ERROR' ? classes.activeButton diff --git a/ui/packages/shared/pages/Logs/wsLogs.ts b/ui/packages/shared/pages/Logs/wsLogs.ts index 19b30c3f..17d8fbd2 100644 --- a/ui/packages/shared/pages/Logs/wsLogs.ts +++ b/ui/packages/shared/pages/Logs/wsLogs.ts @@ -12,7 +12,7 @@ export const establishConnection = async (api: Api) => { const logElement = document.getElementById('logs-container') if (logElement === null) { - console.log('Not found container element'); + console.log('Not found container element') return } @@ -51,9 +51,9 @@ export const establishConnection = async (api: Api) => { }) if (error || response == null) { - console.log('Not authorized:', error); + console.log('Not authorized:', error) appendLogElement('Not authorized') - return; + return } if (api.initWS == null) { @@ -65,17 +65,17 @@ export const establishConnection = async (api: Api) => { const socket = api.initWS(logsEndpoint, response.token) socket.onopen = () => { - console.log('Successfully Connected'); + console.log('Successfully Connected') } socket.onclose = (event) => { - console.log('Socket Closed Connection: ', event); + console.log('Socket Closed Connection: ', event) socket.send('Client Closed') appendLogElement('DLE Connection Closed') } socket.onerror = (error) => { - console.log('Socket Error: ', error); + console.log('Socket Error: ', error) appendLogElement('Connection Error') } @@ -85,3 +85,15 @@ export const establishConnection = async (api: Api) => { appendLogElement(logEntry, 'message') } } + +export const restartConnection = (api: Api) => { + const logElement = document.getElementById('logs-container') + + if (logElement && logElement.childElementCount > 1) { + while (logElement.firstChild) { + logElement.removeChild(logElement.firstChild) + } + } + + establishConnection(api) +} From e5d3d349985d2953530868f70bcb1ed53cbb32c3 Mon Sep 17 00:00:00 2001 From: LashaKakabadze Date: Tue, 16 May 2023 14:04:02 +0400 Subject: [PATCH 030/114] correct snapshot command --- ui/packages/shared/pages/CreateSnapshot/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/shared/pages/CreateSnapshot/utils/index.ts b/ui/packages/shared/pages/CreateSnapshot/utils/index.ts index c323b7db..233d46e1 100644 --- a/ui/packages/shared/pages/CreateSnapshot/utils/index.ts +++ b/ui/packages/shared/pages/CreateSnapshot/utils/index.ts @@ -1,3 +1,3 @@ export const getCliCreateSnapshotCommand = (cloneID: string) => { - return `dblab branch create ${cloneID ? cloneID : ``}` + return `dblab snapshot create ${cloneID ? cloneID : ``}` } From dd4da9a8eb82b12e7f65694d4dfb913c624e67c2 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 16 May 2023 15:49:30 -0300 Subject: [PATCH 031/114] chore: update openapi spec for DLE v4.0.0-alpha.5 --- engine/api/swagger-spec/dblab_openapi.yaml | 1803 +++++++++++++++++ .../swagger-spec/dblab_server_swagger.yaml | 1317 ------------ engine/api/swagger-ui/swagger-initializer.js | 2 +- 3 files changed, 1804 insertions(+), 1318 deletions(-) create mode 100644 engine/api/swagger-spec/dblab_openapi.yaml delete mode 100644 engine/api/swagger-spec/dblab_server_swagger.yaml diff --git a/engine/api/swagger-spec/dblab_openapi.yaml b/engine/api/swagger-spec/dblab_openapi.yaml new file mode 100644 index 00000000..96c689ba --- /dev/null +++ b/engine/api/swagger-spec/dblab_openapi.yaml @@ -0,0 +1,1803 @@ +# OpenAPI spec for DBLab API +# Useful links: +# - validate and test: https://editor.swagger.io/ +# - official reference location for this API: https://dblab.readme.io/ +# - GitHub (give us a ⭐️): https://github.com/postgres-ai/database-lab-engine + +openapi: 3.0.1 +info: + title: DBLab API + description: This page provides the OpenAPI specification for the Database Lab (DBLab) + API, previously recognized as the DLE API (Database Lab Engine API). + contact: + name: DBLab API Support + url: https://postgres.ai/contact + email: api@postgres.ai + license: + name: Apache 2.0 + url: https://github.com/postgres-ai/database-lab-engine/blob/dle-4-0/LICENSE + version: 4.0.0-alpha.5 +externalDocs: + description: DBLab Docs + url: https://gitlab.com/postgres-ai/docs/tree/master/docs/database-lab + +servers: + - url: "https://branching.aws.postgres.ai:446/api" + description: "DBLab 4.0 demo server (with DB branching support); token: 'demo-token'" + x-examples: + Verification-Token: "demo-token" + - url: "https://demo.aws.postgres.ai:446/api" + description: "DBLab 3.x demo server; token: 'demo-token'" + x-examples: + Verification-Token: "demo-token" + - url: "{scheme}://{host}:{port}/{basePath}" + description: "Any DBLab accessed locally / through SSH port forwarding" + variables: + scheme: + enum: + - "https" + - "http" + default: "http" + description: "'http' for local connections and SSH port forwarding; + 'https' for everything else." + host: + default: "localhost" + description: "where DBLab server is installed. Use 'localhost' to work locally + or when SSH port forwarding is used." + port: + default: "2346" + description: "Port to access DBLab UI or API. Originally, '2345' is used for + direct work with API and '2346' – with UI. However, with UI, API is also available, + at ':2346/api'." + basePath: + default: "api" + description: "basePath value to access API. Use empty when working with API port + (2345 by default), or '/api' when working with UI port ('2346' by default)." + x-examples: + Verification-Token: "custom_example_token" + +tags: +- name: DBLab + description: "DBLab API Reference – database branching, instant cloning, and more. + DBLab CLI and UI rely on DBLab API." + externalDocs: + description: "DBLab Docs - tutorials, howtos, references." + url: https://postgres.ai/docs/reference-guides/database-lab-engine-api-reference + +paths: + /status: + get: + tags: + - Instance + summary: DBLab instance status and detailed information + description: "Retrieves detailed information about the DBLab instance: status, version, + clones, snapshots, etc." + operationId: status + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Returned detailed information about the DBLab instance + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + status: + code: OK + message: Instance is ready + engine: + version: v4.0.0-alpha.5-20230516-0224 + edition: standard + billingActive: true + instanceID: chhfqfcnvrvc73d0lij0 + startedAt: '2023-05-16T03:50:19Z' + telemetry: true + disableConfigModification: false + pools: + - name: dblab_pool/dataset_1 + mode: zfs + dataStateAt: '' + status: empty + cloneList: [] + fileSystem: + mode: zfs + size: 30685528064 + free: 30685282816 + used: 245248 + dataSize: 12288 + usedBySnapshots: 0 + usedByClones: 219648 + compressRatio: 1 + - name: dblab_pool/dataset_2 + mode: zfs + dataStateAt: '' + status: empty + cloneList: [] + fileSystem: + mode: zfs + size: 30685528064 + free: 30685282816 + used: 245248 + dataSize: 12288 + usedBySnapshots: 0 + usedByClones: 219648 + compressRatio: 1 + - name: dblab_pool/dataset_3 + mode: zfs + dataStateAt: '' + status: empty + cloneList: [] + fileSystem: + mode: zfs + size: 30685528064 + free: 30685282816 + used: 245248 + dataSize: 12288 + usedBySnapshots: 0 + usedByClones: 219648 + compressRatio: 1 + cloning: + expectedCloningTime: 0 + numClones: 0 + clones: [] + retrieving: + mode: logical + status: pending + lastRefresh: + nextRefresh: + alerts: {} + activity: + provisioner: + dockerImage: postgresai/extended-postgres:15 + containerConfig: + shm-size: 1gb + synchronization: + status: + code: Not available + message: '' + lastReplayedLsn: '' + lastReplayedLsnAt: '' + replicationLag: 0 + replicationUptime: 0 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /snapshots: + get: + tags: + - Snapshots + summary: List all snapshots + description: Return a list of all available snapshots. + operationId: snapshots + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Returned a list of snapshots + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Snapshot' + example: + - id: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + createdAt: '2023-05-09T21:27:11Z' + dataStateAt: '2023-05-09T21:27:11Z' + physicalSize: 0 + logicalSize: 11518021632 + pool: dblab_pool/dataset_2 + numClones: 1 + - id: dblab_pool/dataset_2/nik-test-branch/20230307171959@20230307171959 + createdAt: '2023-03-07T17:19:59Z' + dataStateAt: '2023-03-07T17:19:59Z' + physicalSize: 151552 + logicalSize: 11518015488 + pool: dblab_pool/dataset_2 + numClones: 1 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /clones: + get: + tags: + - Clones + summary: List all clones + description: Return a list of all available clones (database endpoints). + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Returned a list of all available clones + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Clone' + example: + - id: test-clone-2 + snapshot: + id: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + createdAt: '2023-05-09T21:27:11Z' + dataStateAt: '2023-05-09T21:27:11Z' + physicalSize: 120832 + logicalSize: 11518021632 + pool: dblab_pool/dataset_2 + numClones: 3 + branch: '' + protected: false + deleteAt: + createdAt: '2023-05-16T06:12:52Z' + status: + code: OK + message: Clone is ready to accept Postgres connections. + db: + connStr: host=branching.aws.postgres.ai port=6005 user=tester dbname=postgres + host: branching.aws.postgres.ai + port: '6005' + username: tester + password: '' + dbName: postgres + metadata: + cloneDiffSize: 484352 + logicalSize: 11518029312 + cloningTime: 1.5250661829999999 + maxIdleMinutes: 120 + - id: test-clone + snapshot: + id: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + createdAt: '2023-05-09T21:27:11Z' + dataStateAt: '2023-05-09T21:27:11Z' + physicalSize: 120832 + logicalSize: 11518021632 + pool: dblab_pool/dataset_2 + numClones: 3 + branch: '' + protected: false + deleteAt: + createdAt: '2023-05-16T06:12:30Z' + status: + code: OK + message: Clone is ready to accept Postgres connections. + db: + connStr: host=branching.aws.postgres.ai port=6004 user=tester dbname=postgres + host: branching.aws.postgres.ai + port: '6004' + username: tester + password: '' + dbName: postgres + metadata: + cloneDiffSize: 486400 + logicalSize: 11518030336 + cloningTime: 1.57552338 + maxIdleMinutes: 120 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /clone: + post: + tags: + - Clones + summary: Create a clone + operationId: createClone + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + description: Clone object + content: + application/json: + schema: + $ref: '#/components/schemas/CreateClone' + required: true + responses: + 201: + description: Created a new clone + content: + application/json: + schema: + $ref: '#/components/schemas/Clone' + example: + id: test-clone-2 + snapshot: + id: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + createdAt: '2023-05-09T21:27:11Z' + dataStateAt: '2023-05-09T21:27:11Z' + physicalSize: 120832 + logicalSize: 11518021632 + pool: dblab_pool/dataset_2 + numClones: 3 + branch: '' + protected: false + deleteAt: + createdAt: '2023-05-16T06:12:52Z' + status: + code: CREATING + message: Clone is being created. + db: + connStr: '' + host: '' + port: '' + username: tester + password: '' + dbName: postgres + metadata: + cloneDiffSize: 0 + logicalSize: 0 + cloningTime: 0 + maxIdleMinutes: 0 + 400: + description: Returned an error caused by invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "BAD_REQUEST" + message: "clone with such ID already exists" + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + x-codegen-request-body-name: body + /clone/{id}: + get: + tags: + - Clones + summary: Retrieve a clone + description: Retrieves the information for the specified clone. + operationId: getClone + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + - name: id + in: path + description: Clone ID + required: true + schema: + type: string + responses: + 200: + description: Returned detailed information for the specified clone + content: + application/json: + schema: + $ref: '#/components/schemas/Clone' + example: + id: test-clone + snapshot: + id: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + createdAt: '2023-05-09T21:27:11Z' + dataStateAt: '2023-05-09T21:27:11Z' + physicalSize: 120832 + logicalSize: 11518021632 + pool: dblab_pool/dataset_2 + numClones: 3 + branch: '' + protected: false + deleteAt: + createdAt: '2023-05-16T06:12:30Z' + status: + code: OK + message: Clone is ready to accept Postgres connections. + db: + connStr: host=branching.aws.postgres.ai port=6004 user=tester dbname=postgres + host: branching.aws.postgres.ai + port: '6004' + username: tester + password: '' + dbName: postgres + metadata: + cloneDiffSize: 486400 + logicalSize: 11518030336 + cloningTime: 1.57552338 + maxIdleMinutes: 120 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + 404: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: NOT_FOUND + message: Requested object does not exist. Specify your request. + delete: + tags: + - Clones + summary: Delete a clone + description: Permanently delete the specified clone. It cannot be undone. + operationId: deleteClone + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + - name: id + in: path + description: Clone ID + required: true + schema: + type: string + responses: + 200: + description: Successfully deleted the specified clone + content: + application/json: + example: + "OK" + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + 404: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: NOT_FOUND + message: Requested object does not exist. Specify your request. + patch: + tags: + - Clones + summary: Update a clone + description: "Updates the specified clone by setting the values of the parameters passed. + Currently, only one paramater is supported: 'protected'." + operationId: updateClone + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + - name: id + in: path + description: Clone ID + required: true + schema: + type: string + requestBody: + description: Clone object + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateClone' + required: true + responses: + 200: + description: Successfully updated the specified clone + content: + application/json: + schema: + $ref: '#/components/schemas/Clone' + example: + id: test-clone-2 + snapshot: + id: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + createdAt: '2023-05-09T21:27:11Z' + dataStateAt: '2023-05-09T21:27:11Z' + physicalSize: 120832 + logicalSize: 11518021632 + pool: dblab_pool/dataset_2 + numClones: 2 + branch: '' + protected: true + deleteAt: + createdAt: '2023-05-16T06:12:52Z' + status: + code: OK + message: Clone is ready to accept Postgres connections. + db: + connStr: host=branching.aws.postgres.ai port=6005 user=tester dbname=postgres + host: branching.aws.postgres.ai + port: '6005' + username: tester + password: '' + dbName: postgres + metadata: + cloneDiffSize: 561664 + logicalSize: 11518030336 + cloningTime: 1.5250661829999999 + maxIdleMinutes: 120 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + #404: # TODO: fix it in engine (currently returns 500) + # description: Not found + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/Error' + # example: + # code: NOT_FOUND + # message: Requested object does not exist. Specify your request. + x-codegen-request-body-name: body + /clone/{id}/reset: + post: + tags: + - Clones + summary: Reset a clone + description: "Reset the specified clone to a previously stored state. + This can be done by specifying a particular snapshot ID or using the 'latest' flag. + All changes made after the snapshot are discarded during the reset, unless those + changes were preserved in a snapshot. All database connections will be reset, + requiring users and applications to reconnect. The duration of the reset operation + is comparable to the creation of a new clone. However, unlike creating a new clone, + the reset operation retains the database credentials and does not change the port. + Consequently, users and applications can continue to use the same database credentials + post-reset, though reconnection will be necessary. Please note that any unsaved changes + will be irretrievably lost during this operation, so ensure necessary data is backed up + in a snapshot prior to resetting the clone." + operationId: resetClone + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + - name: id + in: path + description: Clone ID + required: true + schema: + type: string + requestBody: + description: Reset object + content: + application/json: + schema: + $ref: '#/components/schemas/ResetClone' + required: false + responses: + 200: + description: Successfully reset the state of the specified clone + content: + application/json: + example: + "OK" + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + #404: # TODO: fix it in engine (currently returns 500) + # description: Not found + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/Error' + x-codegen-request-body-name: body + /branches: + get: + tags: + - Branches + summary: List all branches + description: Return a list of all available branches (named pointers to snapshots). + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Returned a list of all available branches + content: + '*/*': + schema: + type: array + items: + $ref: '#/components/schemas/Branch' + example: + - name: my-1 + parent: main + dataStateAt: '20230224202652' + snapshotID: dblab_pool/dataset_2/main/20230224202652@20230224202652 + - name: nik-test-branch + parent: "-" + dataStateAt: '20230509212711' + snapshotID: dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711 + - name: main + parent: "-" + dataStateAt: '20230224202652' + snapshotID: dblab_pool/dataset_2/main/20230224202652@20230224202652 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /branch/snapshot/{id}: + get: + tags: + - Snapshots + summary: Retrieve a snapshot + description: Retrieves the information for the specified snapshot. + parameters: + - name: id + in: path + description: ID of the branch snapshot + required: true + schema: + type: string + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: OK + content: + '*/*': + schema: + $ref: '#/components/schemas/SnapshotDetails' + 400: + description: Bad request + content: + '*/*': + schema: + $ref: '#/components/schemas/Error' + /branch/create: + post: + tags: + - Branches + summary: Create a branch + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + content: + '*/*': + schema: + type: object + properties: + branchName: + type: string + description: The name of the new branch. + baseBranch: + type: string + description: "The name of parent branch user to create a new branch. + Must not be specified if 'snapshotID' is specified." + snapshotID: + type: string + description: "The ID of the snapshot used to create a new branch. + Must not be specified if 'baseBranch' is specified." + required: true + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + properties: + name: + type: string + 400: + description: Bad request + content: + '*/*': + schema: + $ref: '#/components/schemas/Error' + x-codegen-request-body-name: body + /branch/snapshot: + post: + tags: + - Snapshots + summary: Create a snapshot + description: "Create a new snapshot using the specified clone. After a snapshot + has been created, the original clone can be deleted in order to free up compute resources, if necessary. + The snapshot created by this endpoint can be used later to create one or more new clones." + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + description: "Parameters necessary for snapshot creation: 'cloneID' – the + ID of the clone, 'message' – description of the snapshot" + content: + '*/*': + schema: + type: object + properties: + cloneID: + type: string + message: + type: string + required: true + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + properties: + snapshotID: + type: string + 400: + description: Bad request + content: + '*/*': + schema: + $ref: '#/components/schemas/Error' + x-codegen-request-body-name: body + /branch/delete: + post: + tags: + - Branches + summary: Delete a branch + description: "Permanently delete the specified branch. It cannot be undone." + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + content: + '*/*': + schema: + type: object + properties: + branchName: + type: string + description: "The name of the branch to be deleted." + required: true + responses: + 200: + description: OK + content: + '*/*': + schema: + $ref: '#/components/schemas/ResponseStatus' + 400: + description: Bad request + content: + '*/*': + schema: + $ref: '#/components/schemas/Error' + x-codegen-request-body-name: body + /branch/log: + post: + tags: + - Branches + summary: Retrieve a branch log + description: Retrieve a log of the specified branch (history of snapshots). + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + content: + '*/*': + schema: + type: object + properties: + branchName: + type: string + description: The name of the branch. + required: false + responses: + 200: + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: '#/components/schemas/SnapshotDetails' + x-codegen-request-body-name: body + /instance/retrieval: + get: + tags: + - Instance + summary: Data refresh status + description: 'Report a status of the data refresh subsystem (also known as + "data retrieval"): timestamps of the previous and next refresh runs, status, messages.' + operationId: instanceRetrieval + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Reported a status of the data retrieval subsystem + content: + application/json: + schema: + $ref: '#/components/schemas/Retrieving' + example: + mode: logical + status: pending + lastRefresh: + nextRefresh: + alerts: {} + activity: + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /healthz: + get: + tags: + - Instance + summary: Service health check + description: "Check the overall health and availability of the API system. + This endpoint does not require the 'Verification-Token' header." + operationId: healthz + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Engine' + example: + version: "v4.0.0-alpha.5-20230516-0224" + edition: "standard" + instanceID: "chhfqfcnvrvc73d0lij0" + /admin/config: + get: + tags: + - Admin + summary: Get config + description: "Retrieve the DBLab configuration. All sensitive values are masked. + Only limited set of configuration parameters is returned – only those that can be + changed via API (unless reconfiguration via API is disabled by admin). The result + is provided in JSON format." + operationId: getConfig + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Returned configuration + content: + application/json: + schema: + $ref: '#/components/schemas/Config' + example: + databaseConfigs: + configs: + shared_buffers: 1GB + shared_preload_libraries: pg_stat_statements, pg_stat_kcache, auto_explain, logerrors + databaseContainer: + dockerImage: registry.gitlab.com/postgres-ai/se-images/supabase:15 + global: + debug: true + retrieval: + refresh: + timetable: 0 1 * * 0 + spec: + logicalDump: + options: + customOptions: [] + databases: + test_small: {} + parallelJobs: 4 + source: + connection: + dbname: test_small + host: dev1.postgres.ai + port: 6666 + username: john + logicalRestore: + options: + customOptions: + - "--no-tablespaces" + - "--no-privileges" + - "--no-owner" + - "--exit-on-error" + parallelJobs: 4 + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + post: + tags: + - Admin + summary: Set config + description: "Set specific configurations for the DBLab instance using this endpoint. + The returned configuration parameters are limited to those that can be modified + via the API (unless the API-based reconfiguration has been disabled by an administrator). + The result will be provided in JSON format." + operationId: setConfig + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + description: Set configuration object + content: + application/json: + schema: + $ref: '#/components/schemas/Config' + required: true + responses: + 200: + description: Successfully saved configuration parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Config' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: BAD_REQUEST + message: configuration management via UI/API disabled by admin + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + x-codegen-request-body-name: body + /admin/config.yaml: + get: + tags: + - Admin + summary: Get full config (YAML) + description: "Retrieve the DBLab configuration in YAML format. All sensitive values are masked. + This method allows seeing the entire configuration file and can be helpful for + reviewing configuration and setting up workflows to automate DBLab provisioning + and configuration." + operationId: getConfigYaml + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: "Returned configuration (YAML)" + content: + application/yaml: + schema: + $ref: '#/components/schemas/Config' + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /admin/test-db-source: + post: + tags: + - Admin + summary: Test source database + operationId: testDBConnection1 + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + description: Connection DB object + content: + application/json: + schema: + $ref: '#/components/schemas/Connection' + required: true + responses: + 200: + description: Successful operation + content: {} + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: BAD_REQUEST + message: configuration management via UI/API disabled by admin + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + x-codegen-request-body-name: body + /admin/ws-auth: + post: + tags: + - Admin + summary: Test source database + operationId: testDBConnection2 + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + responses: + 200: + description: Successful operation + content: + '*/*': + schema: + $ref: '#/components/schemas/WSToken' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: BAD_REQUEST + message: configuration management via UI/API disabled by admin + 401: + description: Unauthorized access + content: + application/json: + schema: + $ref: '#/components/schemas/Instance' + example: + code: "UNAUTHORIZED" + message: "Check your verification token." + /observation/start: + post: + tags: + - Observation + summary: Start observing + description: "[EXPERIMENTAL] Start an observation session for the specified clone. + Observation sessions help detect dangerous (long-lasting, exclusive) locks in CI/CD pipelines. + One of common scenarios is using observation sessions to test schema changes (DB migrations)." + operationId: startObservation + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + description: Start observation object + content: + application/json: + schema: + $ref: '#/components/schemas/StartObservationRequest' + required: true + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationSession' + 404: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: NOT_FOUND + message: Requested object does not exist. Specify your request. + x-codegen-request-body-name: body + /observation/stop: + post: + tags: + - Observation + summary: Stop observing + description: "[EXPERIMENTAL] Stop the previously started observation session." + operationId: stopObservation + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + requestBody: + description: Stop observation object + content: + application/json: + schema: + $ref: '#/components/schemas/StopObservationRequest' + required: true + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationSession' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + x-codegen-request-body-name: body + /observation/summary/{clone_id}/{session_id}: + get: + tags: + - Observation + summary: Get observation summary + description: "[EXPERIMENTAL] Collect the observation summary info." + operationId: summaryObservation + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + - name: clone_id + in: path + description: Clone ID + required: true + schema: + type: string + - name: session_id + in: path + description: Session ID + required: true + schema: + type: string + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationSummaryArtifact' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /observation/download/{artifact_type}/{clone_id}/{session_id}: + get: + tags: + - Observation + summary: Download an observation artifact + description: "[EXPERIMENTAL] Download an artifact for the specified clone and observation session." + operationId: downloadObservationArtifact + parameters: + - name: Verification-Token + in: header + required: true + schema: + type: string + - name: artifact_type + in: path + description: Type of the requested artifact + required: true + schema: + type: string + - name: clone_id + in: path + description: Clone ID + required: true + schema: + type: string + - name: session_id + in: path + description: Session ID + required: true + schema: + type: string + responses: + 200: + description: Successful operation + content: {} + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + Instance: + type: object + properties: + status: + $ref: '#/components/schemas/Status' + engine: + $ref: '#/components/schemas/Engine' + pools: + type: array + items: + $ref: '#/components/schemas/PoolEntry' + cloning: + $ref: '#/components/schemas/Cloning' + retrieving: + $ref: '#/components/schemas/Retrieving' + provisioner: + $ref: '#/components/schemas/Provisioner' + synchronization: + $ref: '#/components/schemas/Synchronization' + Status: + required: + - code + - message + type: object + properties: + code: + type: string + description: Status code + message: + type: string + description: Status description + Engine: + type: object + properties: + version: + type: string + edition: + type: string + billingActive: + type: string + instanceID: + type: string + startedAt: + type: string + format: date-time + telemetry: + type: boolean + disableConfigModification: + type: boolean + PoolEntry: + type: object + properties: + name: + type: string + mode: + type: string + dataStateAt: + type: string + format: date-time + status: + type: string + cloneList: + type: array + items: + type: string + fileSystem: + $ref: '#/components/schemas/FileSystem' + FileSystem: + type: object + properties: + mode: + type: string + free: + type: integer + format: int64 + size: + type: integer + format: int64 + used: + type: integer + format: int64 + dataSize: + type: integer + format: int64 + usedBySnapshots: + type: integer + format: int64 + usedByClones: + type: integer + format: int64 + compressRatio: + type: integer + format: float64 + Cloning: + type: object + properties: + expectedCloningTime: + type: integer + format: float64 + numClones: + type: integer + format: int64 + clones: + type: array + items: + $ref: '#/components/schemas/Clone' + Retrieving: + type: object + properties: + mode: + type: string + status: + type: string + lastRefresh: + type: string + format: date-time + nextRefresh: + type: string + format: date-time + alerts: + type: array + items: + type: string + activity: + $ref: '#/components/schemas/Activity' + Activity: + type: object + properties: + source: + type: array + items: + $ref: '#/components/schemas/PGActivityEvent' + target: + type: array + items: + $ref: '#/components/schemas/PGActivityEvent' + PGActivityEvent: + type: object + properties: + user: + type: string + query: + type: string + duration: + type: number + waitEventType: + type: string + waitEvent: + type: string + Provisioner: + type: object + properties: + dockerImage: + type: string + containerConfig: + type: object + properties: {} + Synchronization: + type: object + properties: + status: + $ref: '#/components/schemas/Status' + startedAt: + type: string + format: date-time + lastReplayedLsn: + type: string + lastReplayedLsnAt: + type: string + format: date-time + replicationLag: + type: string + replicationUptime: + type: integer + Snapshot: + type: object + properties: + id: + type: string + createdAt: + type: string + format: date-time + dataStateAt: + type: string + format: date-time + physicalSize: + type: integer + format: int64 + logicalSize: + type: integer + format: int64 + pool: + type: string + numClones: + type: integer + format: int + Database: + type: object + properties: + connStr: + type: string + host: + type: string + port: + type: string + username: + type: string + password: + type: string + Clone: + type: object + properties: + id: + type: string + name: + type: string + snapshot: + $ref: '#/components/schemas/Snapshot' + protected: + type: boolean + default: false + deleteAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + status: + $ref: '#/components/schemas/Status' + db: + $ref: '#/components/schemas/Database' + metadata: + $ref: '#/components/schemas/CloneMetadata' + CloneMetadata: + type: object + properties: + cloneDiffSize: + type: integer + format: int64 + logicalSize: + type: integer + format: int64 + cloningTime: + type: integer + format: float64 + maxIdleMinutes: + type: integer + format: int64 + CreateClone: + type: object + properties: + id: + type: string + snapshot: + type: object + properties: + id: + type: string + branch: + type: string + protected: + type: boolean + default: + db: + type: object + properties: + username: + type: string + password: + type: string + restricted: + type: boolean + default: + db_name: + type: string + ResetClone: + type: object + properties: + snapshotID: + type: string + latest: + type: boolean + default: false + description: "Define what snapshot needs to be used when resseting the clone. + 'snapshotID' allows specifying the exact snapshot, while 'latest' allows using + the latest snapshot among all available snapshots. The latter method can be + helpful when the exact snapshot ID is now known." + UpdateClone: + type: object + properties: + protected: + type: boolean + default: false + StartObservationRequest: + type: object + properties: + clone_id: + type: string + config: + $ref: '#/components/schemas/ObservationConfig' + tags: + type: object + properties: {} + db_name: + type: string + ObservationConfig: + type: object + properties: + observation_interval: + type: integer + format: int64 + max_lock_duration: + type: integer + format: int64 + max_duration: + type: integer + format: int64 + ObservationSession: + type: object + properties: + session_id: + type: integer + format: int64 + started_at: + type: string + format: date-time + finished_at: + type: string + format: date-time + config: + $ref: '#/components/schemas/ObservationConfig' + tags: + type: object + properties: {} + artifacts: + type: array + items: + type: string + result: + $ref: '#/components/schemas/ObservationResult' + ObservationResult: + type: object + properties: + status: + type: string + intervals: + type: array + items: + $ref: '#/components/schemas/ObservationInterval' + summary: + $ref: '#/components/schemas/ObservationSummary' + ObservationInterval: + type: object + properties: + started_at: + type: string + format: date-time + duration: + type: integer + format: int64 + warning: + type: string + ObservationSummary: + type: object + properties: + total_duration: + type: integer + format: float64 + total_intervals: + type: integer + format: int + warning_intervals: + type: integer + format: int + checklist: + $ref: '#/components/schemas/ObservationChecklist' + ObservationChecklist: + type: object + properties: + overall_success: + type: boolean + session_duration_acceptable: + type: boolean + no_long_dangerous_locks: + type: boolean + StopObservationRequest: + type: object + properties: + clone_id: + type: string + overall_error: + type: boolean + SummaryObservationRequest: + type: object + properties: + clone_id: + type: string + session_id: + type: string + ObservationSummaryArtifact: + type: object + properties: + session_id: + type: integer + format: int64 + clone_id: + type: string + duration: + type: object + properties: {} + db_size: + type: object + properties: {} + locks: + type: object + properties: {} + log_errors: + type: object + properties: {} + artifact_types: + type: array + items: + type: string + Error: + type: object + properties: + code: + type: string + message: + type: string + detail: + type: string + hint: + type: string + ResponseStatus: + type: object + properties: + status: + type: string + message: + type: string + Config: + type: object + Connection: + type: object + properties: + host: + type: string + port: + type: string + dbname: + type: string + username: + type: string + password: + type: string + db_list: + type: array + items: + type: string + WSToken: + type: object + properties: + token: + type: string + description: WebSocket token + Branch: + type: object + properties: + name: + type: string + parent: + type: string + dataStateAt: + type: string + format: date-time + snapshotID: + type: string + SnapshotDetails: + type: object + properties: + id: + type: string + parent: + type: string + child: + type: string + branch: + type: array + items: + type: string + root: + type: string + dataStateAt: + type: string + format: date-time + message: + type: string \ No newline at end of file diff --git a/engine/api/swagger-spec/dblab_server_swagger.yaml b/engine/api/swagger-spec/dblab_server_swagger.yaml deleted file mode 100644 index 5611bf56..00000000 --- a/engine/api/swagger-spec/dblab_server_swagger.yaml +++ /dev/null @@ -1,1317 +0,0 @@ -swagger: "2.0" -info: - description: "This page provides the OpenAPI specification for the Database Lab (DBLab) API, previously recognized as the DLE API (Database Lab Engine API)." - version: "4.0.0-alpha.5" - title: "DBLab API" - contact: - email: "team@postgres.ai" - license: - name: "DBLab v4.0 uses Apache Licence Version 2.0" - url: "https://gitlab.com/postgres-ai/database-lab/blob/dle-4-0/LICENSE" -basePath: "/" -tags: - - name: "DBLab" - description: "DBLab API Reference" - externalDocs: - description: "DBLab Docs" - url: "https://postgres.ai/docs/database-lab" -schemes: - - "https" - - "http" - -paths: - /status: - get: - tags: - - "Instance" - summary: "Get instance status" - description: "" - operationId: "getInstanceStatus" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Instance" - - /snapshots: - get: - tags: - - "Snapshots" - summary: "Retrieve a list of snapshots" - description: "" - operationId: "getSnapshots" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - type: "array" - items: - $ref: "#/definitions/Snapshot" - - /clones: - get: - tags: - - "Clones" - description: Retrieve a list of clones - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: OK - schema: - type: array - items: - $ref: "#/definitions/Clone" - - /clone: - post: - tags: - - "Clones" - summary: "Create a clone" - description: "" - operationId: "createClone" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: body - name: body - description: "Clone object" - required: true - schema: - $ref: '#/definitions/CreateClone' - responses: - 201: - description: "Successful operation" - schema: - $ref: "#/definitions/Clone" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /clone/{id}: - get: - tags: - - "Clones" - summary: "Get a clone status" - description: "" - operationId: "getClone" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: path - required: true - name: "id" - type: "string" - description: "Clone ID" - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Clone" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - patch: - tags: - - "Clones" - summary: "Update a clone" - description: "" - operationId: "patchClone" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: path - required: true - name: "id" - type: "string" - description: "Clone ID" - - in: body - name: body - description: "Clone object" - required: true - schema: - $ref: '#/definitions/UpdateClone' - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Clone" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - delete: - tags: - - "Clones" - summary: "Delete a clone" - description: "" - operationId: "destroyClone" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: path - required: true - name: "id" - type: "string" - description: "Clone ID" - responses: - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /clone/{id}/reset: - post: - tags: - - "Clones" - summary: "Reset a clone" - description: "" - operationId: "resetClone" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: path - required: true - name: "id" - type: "string" - description: "Clone ID" - - in: body - name: body - description: "Reset object" - required: false - schema: - $ref: '#/definitions/ResetClone' - responses: - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /observation/start: - post: - tags: - - "Observation" - summary: "Start an observation session" - description: "" - operationId: "startObservation" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: body - name: body - description: "Start observation object" - required: true - schema: - $ref: '#/definitions/StartObservationRequest' - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/ObservationSession" - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /observation/stop: - post: - tags: - - "Observation" - summary: "Stop the observation session" - description: "" - operationId: "stopObservation" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: body - name: body - description: "Stop observation object" - required: true - schema: - $ref: '#/definitions/StopObservationRequest' - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/ObservationSession" - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /observation/summary/{clone_id}/{session_id}: - get: - tags: - - "Observation" - summary: "Get the observation summary info" - description: "" - operationId: "summaryObservation" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: path - required: true - name: "clone_id" - type: "string" - description: "Clone ID" - - in: path - required: true - name: "session_id" - type: "string" - description: "Session ID" - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/ObservationSummaryArtifact" - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /observation/download/{artifact_type}/{clone_id}/{session_id}: - get: - tags: - - "Observation" - summary: "Download the observation artifact" - description: "" - operationId: "downloadObservationArtifact" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: path - required: true - name: "artifact_type" - type: "string" - description: "Type of the requested artifact" - - in: path - required: true - name: "clone_id" - type: "string" - description: "Clone ID" - - in: path - required: true - name: "session_id" - type: "string" - description: "Session ID" - responses: - 200: - description: "Successful operation" - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - - /instance/retrieval: - get: - tags: - - "Instance" - summary: "Report state of retrieval subsystem" - description: "" - operationId: "instanceRetrieval" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Retrieving" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /healthz: - get: - tags: - - "Instance" - summary: "Get the state of the instance we are working with" - description: "" - operationId: "healthCheck" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Engine" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - - /admin/config: - post: - tags: - - "Config" - summary: "Set instance configuration" - description: "" - operationId: "setConfig" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: body - name: body - description: "Set configuration object" - required: true - schema: - $ref: '#/definitions/Config' - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Config" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - get: - tags: - - "Config" - summary: "Get instance configuration" - description: "" - operationId: "getConfig" - produces: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Config" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /admin/config.yaml: - get: - tags: - - "Config" - summary: "Get the config of the instance" - description: "" - operationId: "getConfigYaml" - produces: - - "application/yaml" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/Config" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /admin/test-db-source: - post: - tags: - - "Config" - summary: "Test source database" - description: "" - operationId: "testDBConnection1" - consumes: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - - in: body - name: body - description: "Connection DB object" - required: true - schema: - $ref: '#/definitions/Connection' - responses: - 200: - description: "Successful operation" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /admin/ws-auth: - post: - tags: - - "Config" - summary: "Test source database" - description: "" - operationId: "testDBConnection2" - consumes: - - "application/json" - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: "Successful operation" - schema: - $ref: "#/definitions/WSToken" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /branches: - get: - tags: - - "Branches" - description: Retrieve a list of branches - parameters: - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: OK - schema: - type: array - items: - $ref: "#/definitions/Branch" - - /branch/snapshot/{id}: - get: - tags: - - "Branches" - description: Retrieves information about the specified snapshot - parameters: - - name: id - in: path - description: ID of the branch snapshot - required: true - type: string - - in: header - name: Verification-Token - type: string - required: true - responses: - 200: - description: OK - schema: - $ref: "#/definitions/SnapshotDetails" - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 404: - description: "Not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /branch/create: - post: - tags: - - "Branches" - description: Create a new branch - parameters: - - in: header - name: Verification-Token - type: string - required: true - - name: body - in: body - description: "Parameters required for branch creation: `branchName` – the name of the new branch; `baseBranch` – the name of the parent branch used for branch creation, or `snapshotID` – the snapshot ID used for branch creation" - required: true - schema: - type: object - properties: - branchName: - type: string - baseBranch: - type: string - snapshotID: - type: string - responses: - 200: - description: OK - schema: - type: object - properties: - name: - type: string - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /branch/snapshot: - post: - tags: - - "Branches" - description: Create a new snapshot for the specified clone - parameters: - - in: header - name: Verification-Token - type: string - required: true - - name: body - in: body - description: "Parameters necessary for snapshot creation: `cloneID` – the ID of the clone, `message` – description of the snapshot" - required: true - schema: - type: object - properties: - cloneID: - type: string - message: - type: string - responses: - 200: - description: OK - schema: - type: object - properties: - snapshotID: - type: string - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /branch/delete: - post: - tags: - - "Branches" - description: Delete the specified branch - parameters: - - in: header - name: Verification-Token - type: string - required: true - - name: body - in: body - description: "Parameters required for branch deletion: `branchName` – the name of the branch to be deleted" - required: true - schema: - type: object - properties: - branchName: - type: string - responses: - 200: - description: OK - schema: - $ref: "#/definitions/ResponseStatus" - 400: - description: "Bad request" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error" - schema: - $ref: "#/definitions/Error" - - /branch/log: - post: - tags: - - "Branches" - description: Retrieve a log of a given branch - parameters: - - in: header - name: Verification-Token - type: string - required: true - - name: body - in: body - description: "Parameters required to access the log of the given branch: `branchName` – the name of the branch" - required: false - schema: - type: object - properties: - branchName: - type: string - responses: - 200: - description: OK - schema: - type: array - items: - $ref: "#/definitions/SnapshotDetails" - -definitions: - Instance: - type: "object" - properties: - status: - $ref: "#/definitions/Status" - engine: - $ref: "#/definitions/Engine" - pools: - type: "array" - items: - $ref: "#/definitions/PoolEntry" - cloning: - $ref: "#/definitions/Cloning" - retrieving: - $ref: "#/definitions/Retrieving" - provisioner: - $ref: "#/definitions/Provisioner" - synchronization: - $ref: "#/definitions/Synchronization" - - Status: - type: "object" - required: - - "code" - - "message" - properties: - code: - type: "string" - description: "Status code" - message: - type: "string" - description: "Status description" - - Engine: - type: "object" - properties: - version: - type: "string" - edition: - type: "string" - startedAt: - type: "string" - format: "date-time" - telemetry: - type: boolean - disableConfigModification: - type: boolean - - PoolEntry: - type: "object" - properties: - name: - type: "string" - mode: - type: "string" - dataStateAt: - type: "string" - format: "date-time" - status: - type: "string" - cloneList: - type: "array" - items: - type: "string" - fileSystem: - $ref: "#/definitions/FileSystem" - - FileSystem: - type: "object" - properties: - mode: - type: "string" - free: - type: "integer" - format: "int64" - size: - type: "integer" - format: "int64" - used: - type: "integer" - format: "int64" - dataSize: - type: "integer" - format: "int64" - usedBySnapshots: - type: "integer" - format: "int64" - usedByClones: - type: "integer" - format: "int64" - compressRatio: - type: "integer" - format: "float64" - - Cloning: - type: "object" - properties: - expectedCloningTime: - type: "integer" - format: "float64" - numClones: - type: "integer" - format: "int64" - clones: - type: "array" - items: - $ref: "#/definitions/Clone" - - Retrieving: - type: "object" - properties: - mode: - type: "string" - status: - type: "string" - lastRefresh: - type: "string" - format: "date-time" - nextRefresh: - type: "string" - format: "date-time" - activity: - $ref: "#/definitions/Activity" - - Activity: - type: "object" - properties: - source: - type: "array" - items: - $ref: "#/definitions/PGActivityEvent" - target: - type: "array" - items: - $ref: "#/definitions/PGActivityEvent" - - PGActivityEvent: - type: "object" - properties: - user: - type: "string" - query: - type: "string" - duration: - type: "number" - waitEventType: - type: "string" - waitEvent: - type: "string" - - Provisioner: - type: "object" - properties: - dockerImage: - type: "string" - containerConfig: - type: "object" - - Synchronization: - type: "object" - properties: - status: - $ref: "#/definitions/Status" - startedAt: - type: "string" - format: "date-time" - lastReplayedLsn: - type: "string" - lastReplayedLsnAt: - type: "string" - format: "date-time" - replicationLag: - type: "string" - replicationUptime: - type: "integer" - - Snapshot: - type: "object" - properties: - id: - type: "string" - createdAt: - type: "string" - format: "date-time" - dataStateAt: - type: "string" - format: "date-time" - physicalSize: - type: "integer" - format: "int64" - logicalSize: - type: "integer" - format: "int64" - pool: - type: "string" - numClones: - type: "integer" - format: "int" - - Database: - type: "object" - properties: - connStr: - type: "string" - host: - type: "string" - port: - type: "string" - username: - type: "string" - password: - type: "string" - - Clone: - type: "object" - properties: - id: - type: "string" - name: - type: "string" - snapshot: - $ref: "#/definitions/Snapshot" - protected: - type: "boolean" - default: false - deleteAt: - type: "string" - format: "date-time" - createdAt: - type: "string" - format: "date-time" - status: - $ref: "#/definitions/Status" - db: - $ref: "#/definitions/Database" - metadata: - $ref: "#/definitions/CloneMetadata" - - CloneMetadata: - type: "object" - properties: - cloneDiffSize: - type: "integer" - format: "int64" - logicalSize: - type: "integer" - format: "int64" - cloningTime: - type: "integer" - format: "float64" - maxIdleMinutes: - type: "integer" - format: "int64" - - CreateClone: - type: "object" - properties: - id: - type: "string" - snapshot: - type: "object" - properties: - id: - type: "string" - protected: - type: "boolean" - default: false - db: - type: "object" - properties: - username: - type: "string" - password: - type: "string" - restricted: - type: "boolean" - default: false - db_name: - type: "string" - - ResetClone: - type: "object" - description: "Object defining specific snapshot used when resetting clone. Optional parameters `latest` and `snapshotID` must not be specified together" - properties: - snapshotID: - type: "string" - latest: - type: "boolean" - default: false - - UpdateClone: - type: "object" - properties: - protected: - type: "boolean" - default: false - - StartObservationRequest: - type: "object" - properties: - clone_id: - type: "string" - config: - $ref: "#/definitions/ObservationConfig" - tags: - type: "object" - db_name: - type: "string" - - ObservationConfig: - type: "object" - properties: - observation_interval: - type: "integer" - format: "int64" - max_lock_duration: - type: "integer" - format: "int64" - max_duration: - type: "integer" - format: "int64" - - ObservationSession: - type: "object" - properties: - session_id: - type: "integer" - format: "int64" - started_at: - type: "string" - format: "date-time" - finished_at: - type: "string" - format: "date-time" - config: - $ref: "#/definitions/ObservationConfig" - tags: - type: "object" - artifacts: - type: array - items: - type: string - result: - $ref: "#/definitions/ObservationResult" - - ObservationResult: - type: "object" - properties: - status: - type: "string" - intervals: - type: array - items: - $ref: "#/definitions/ObservationInterval" - summary: - $ref: "#/definitions/ObservationSummary" - - ObservationInterval: - type: "object" - properties: - started_at: - type: "string" - format: "date-time" - duration: - type: "integer" - format: "int64" - warning: - type: string - - ObservationSummary: - type: "object" - properties: - total_duration: - type: "integer" - format: "float64" - total_intervals: - type: "integer" - format: "int" - warning_intervals: - type: "integer" - format: "int" - checklist: - $ref: "#/definitions/ObservationChecklist" - - ObservationChecklist: - type: "object" - properties: - overall_success: - type: boolean - session_duration_acceptable: - type: boolean - no_long_dangerous_locks: - type: boolean - - StopObservationRequest: - type: "object" - properties: - clone_id: - type: "string" - overall_error: - type: "boolean" - - SummaryObservationRequest: - type: "object" - properties: - clone_id: - type: "string" - session_id: - type: "string" - - ObservationSummaryArtifact: - type: "object" - properties: - session_id: - type: "integer" - format: "int64" - clone_id: - type: "string" - duration: - type: "object" - db_size: - type: "object" - locks: - type: "object" - log_errors: - type: "object" - artifact_types: - type: "array" - items: - type: "string" - - Error: - type: "object" - properties: - code: - type: "string" - message: - type: "string" - detail: - type: "string" - hint: - type: "string" - - ResponseStatus: - type: "object" - properties: - status: - type: "string" - message: - type: "string" - - Config: - type: object - - Connection: - type: "object" - properties: - host: - type: "string" - port: - type: "string" - dbname: - type: "string" - username: - type: "string" - password: - type: "string" - db_list: - type: "array" - items: - type: "string" - - WSToken: - type: "object" - properties: - token: - type: "string" - description: "WebSocket token" - - Branch: - type: object - properties: - name: - type: string - parent: - type: string - dataStateAt: - type: string - format: date-time - snapshotID: - type: string - - SnapshotDetails: - type: object - properties: - id: - type: string - parent: - type: string - child: - type: string - branch: - type: array - items: - type: string - root: - type: string - dataStateAt: - type: string - format: date-time - message: - type: string - - -externalDocs: - description: "DBLab Docs" - url: "https://gitlab.com/postgres-ai/docs/tree/master/docs/database-lab" diff --git a/engine/api/swagger-ui/swagger-initializer.js b/engine/api/swagger-ui/swagger-initializer.js index 03966101..c5e40fbe 100644 --- a/engine/api/swagger-ui/swagger-initializer.js +++ b/engine/api/swagger-ui/swagger-initializer.js @@ -3,7 +3,7 @@ window.onload = function() { // the following lines will be replaced by docker/configurator, when it runs in a docker-container window.ui = SwaggerUIBundle({ - url: "api/swagger-spec/dblab_server_swagger.yaml", + url: "api/swagger-spec/dblab_openapi.yaml", dom_id: '#swagger-ui', deepLinking: true, presets: [ From 7f79aebc840ad4623bf3d5b0ba85e7ca2417aaab Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Wed, 17 May 2023 21:03:52 -0700 Subject: [PATCH 032/114] (WIP) postman/newman testing of DBLab API --- .gitignore | 1 + ...g.aws.postgres.ai.postman_environment.json | 21 + .../api/postman/dblab.postman_collection.json | 431 -- .../postman/dblab.postman_environment.json | 22 - .../postman/dblab_api.postman_collection.json | 3930 +++++++++++++++++ engine/api/postman/portman-cli.json | 8 + 6 files changed, 3960 insertions(+), 453 deletions(-) create mode 100644 engine/api/postman/branching.aws.postgres.ai.postman_environment.json delete mode 100644 engine/api/postman/dblab.postman_collection.json delete mode 100644 engine/api/postman/dblab.postman_environment.json create mode 100644 engine/api/postman/dblab_api.postman_collection.json create mode 100644 engine/api/postman/portman-cli.json diff --git a/.gitignore b/.gitignore index d45de5ca..c5816b55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea/ +.env engine/bin/ /db-lab-run/ diff --git a/engine/api/postman/branching.aws.postgres.ai.postman_environment.json b/engine/api/postman/branching.aws.postgres.ai.postman_environment.json new file mode 100644 index 00000000..407d3d88 --- /dev/null +++ b/engine/api/postman/branching.aws.postgres.ai.postman_environment.json @@ -0,0 +1,21 @@ +{ + "id": "30035c51-5e48-4d31-8676-2aac8af456ee", + "name": "branching.aws.postgres.ai", + "values": [ + { + "key": "baseUrl", + "value": "https://branching.aws.postgres.ai:446/api", + "type": "default", + "enabled": true + }, + { + "key": "verificationToken", + "value": "demo-token", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2023-05-18T04:01:37.154Z", + "_postman_exported_using": "Postman/10.14.2-230517-0637" +} \ No newline at end of file diff --git a/engine/api/postman/dblab.postman_collection.json b/engine/api/postman/dblab.postman_collection.json deleted file mode 100644 index 2c57013d..00000000 --- a/engine/api/postman/dblab.postman_collection.json +++ /dev/null @@ -1,431 +0,0 @@ -{ - "variables": [], - "info": { - "name": "Database Lab", - "_postman_id": "d0182a6c-79d0-877f-df91-18dbca63b734", - "description": "", - "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" - }, - "item": [ - { - "name": "status", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check instance status\"] = responseCode.code === 200 && jsonData && jsonData.status && jsonData.status.code && jsonData.status.code === \"OK\";" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/status", - "method": "GET", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "", - "disabled": true - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"dblab_id\": 1\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "snapshots", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check snapshots list\"] = responseCode.code === 200 && jsonData && Array.isArray(jsonData) && jsonData.length === 1;", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/snapshots", - "method": "GET", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"dblab_id\": 1\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "clone not found", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check for clone status\"] = responseCode.code === 404 && jsonData && jsonData.detail && jsonData.detail === \"Requested object does not exist.\";", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/bopta26mq8oddsim86v0", - "method": "GET", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"dblab_id\": 1\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "create clone", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check for clone create\"] = responseCode.code === 201 && jsonData && jsonData.id && jsonData.status && ", - "(jsonData.status.code == 'OK' || jsonData.status.code == 'CREATING');", - "postman.setGlobalVariable(\"DBLAB_CLONE_ID\", jsonData.id);" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone", - "method": "POST", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n\t\"name\": \"test-demo-clone\",\r\n\t\"protected\": false,\r\n\t\"db\": {\r\n\t\t\"username\": \"username\",\r\n\t\t\"password\": \"password\"\r\n\t}\r\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "clone status", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check for clone status\"] = responseCode.code === 200 && jsonData && jsonData.id && jsonData.status && ", - "(jsonData.status.code == 'OK' || jsonData.status.code == 'CREATING');", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}", - "method": "GET", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"dblab_id\": 1\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "clone update (name, protected)", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "tests[\"Check for clone update\"] = responseCode.code === 200;", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}", - "method": "PATCH", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "", - "disabled": true - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"protected\": true,\n\t\"name\": \"UPDATE_CLONE_TEST\"\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "clone/reset", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "tests[\"Check for clone reset\"] = responseCode.code === 200;", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}/reset", - "method": "POST", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "", - "disabled": true - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"id\": \"xxx\"\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "delete protected clone", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check for delete protected clone\"] = responseCode.code === 500 && jsonData && jsonData.detail && jsonData.detail === \"clone is protected\";", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}", - "method": "DELETE", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "clone update (disable protection)", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "tests[\"Check for clone update\"] = responseCode.code === 200;", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}", - "method": "PATCH", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "", - "disabled": true - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"protected\": false\n}" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "delete clone", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "tests[\"Check for delete protected clone\"] = responseCode.code === 200;", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}", - "method": "DELETE", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "" - }, - "description": "Select users" - }, - "response": [] - }, - { - "name": "removed clone status", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "tests[\"Check for clone status\"] = (responseCode.code === 200 && jsonData && jsonData.id && jsonData.status && ", - "jsonData.status.code == 'DELETING') || responseCode.code == 404;", - "" - ] - } - } - ], - "request": { - "url": "{{DBLAB_URL}}/clone/{{DBLAB_CLONE_ID}}", - "method": "GET", - "header": [ - { - "key": "Verification-Token", - "value": "{{DBLAB_VERIFY_TOKEN}}", - "description": "" - }, - { - "key": "Content-Type", - "value": "application/json", - "description": "" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"dblab_id\": 1\n}" - }, - "description": "Select users" - }, - "response": [] - } - ] -} diff --git a/engine/api/postman/dblab.postman_environment.json b/engine/api/postman/dblab.postman_environment.json deleted file mode 100644 index 5f7244c9..00000000 --- a/engine/api/postman/dblab.postman_environment.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "id": "ff4200f0-7acd-eb4f-1dee-59da8c98c313", - "name": "Database Lab", - "values": [ - { - "enabled": true, - "key": "DBLAB_URL", - "value": "https://url", - "type": "text" - }, - { - "enabled": true, - "key": "DBLAB_VERIFY_TOKEN", - "value": "secret_token", - "type": "text" - } - ], - "timestamp": 1580454458304, - "_postman_variable_scope": "environment", - "_postman_exported_at": "2020-01-31T09:42:37.377Z", - "_postman_exported_using": "Postman/5.5.4" -} diff --git a/engine/api/postman/dblab_api.postman_collection.json b/engine/api/postman/dblab_api.postman_collection.json new file mode 100644 index 00000000..ce3a0baa --- /dev/null +++ b/engine/api/postman/dblab_api.postman_collection.json @@ -0,0 +1,3930 @@ +{ + "info": { + "_postman_id": "e064b320-9989-468d-8438-39e5a83bea1c", + "name": "DBLab API 4.0.0-alpha.5", + "description": "This page provides the OpenAPI specification for the Database Lab (DBLab) API, previously recognized as the DLE API (Database Lab Engine API).\n\nContact Support:\n Name: DBLab API Support\n Email: api@postgres.ai", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "23678518" + }, + "item": [ + { + "name": "Instance", + "item": [ + { + "name": "DBLab instance status and detailed information", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/status - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/status - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[GET]::/status - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\",\"properties\":{\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"engine\":{\"type\":\"object\",\"properties\":{\"version\":{\"type\":\"string\"},\"edition\":{\"type\":\"string\"},\"billingActive\":{\"type\":\"string\"},\"instanceID\":{\"type\":\"string\"},\"startedAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"telemetry\":{\"type\":\"boolean\"},\"disableConfigModification\":{\"type\":\"boolean\"}}},\"pools\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mode\":{\"type\":\"string\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"type\":\"string\"},\"cloneList\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"fileSystem\":{\"type\":\"object\",\"properties\":{\"mode\":{\"type\":\"string\"},\"free\":{\"type\":\"integer\",\"format\":\"int64\"},\"size\":{\"type\":\"integer\",\"format\":\"int64\"},\"used\":{\"type\":\"integer\",\"format\":\"int64\"},\"dataSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"usedBySnapshots\":{\"type\":\"integer\",\"format\":\"int64\"},\"usedByClones\":{\"type\":\"integer\",\"format\":\"int64\"},\"compressRatio\":{\"type\":\"integer\",\"format\":\"float64\"}}}}}},\"cloning\":{\"type\":\"object\",\"properties\":{\"expectedCloningTime\":{\"type\":\"integer\",\"format\":\"float64\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int64\"},\"clones\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"},\"snapshot\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"physicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"pool\":{\"type\":\"string\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int\"}}},\"protected\":{\"type\":\"boolean\",\"default\":false},\"deleteAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"db\":{\"type\":\"object\",\"properties\":{\"connStr\":{\"type\":\"string\"},\"host\":{\"type\":\"string\"},\"port\":{\"type\":\"string\"},\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}}},\"metadata\":{\"type\":\"object\",\"properties\":{\"cloneDiffSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"cloningTime\":{\"type\":\"integer\",\"format\":\"float64\"},\"maxIdleMinutes\":{\"type\":\"integer\",\"format\":\"int64\"}}}}}}}},\"retrieving\":{\"type\":\"object\",\"properties\":{\"mode\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"},\"lastRefresh\":{\"type\":\"string\",\"format\":\"date-time\"},\"nextRefresh\":{\"type\":\"string\",\"format\":\"date-time\"},\"alerts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"activity\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\"},\"query\":{\"type\":\"string\"},\"duration\":{\"type\":\"number\"},\"waitEventType\":{\"type\":\"string\"},\"waitEvent\":{\"type\":\"string\"}}}},\"target\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\"},\"query\":{\"type\":\"string\"},\"duration\":{\"type\":\"number\"},\"waitEventType\":{\"type\":\"string\"},\"waitEvent\":{\"type\":\"string\"}}}}}}}},\"provisioner\":{\"type\":\"object\",\"properties\":{\"dockerImage\":{\"type\":\"string\"},\"containerConfig\":{\"type\":\"object\",\"properties\":{}}}},\"synchronization\":{\"type\":\"object\",\"properties\":{\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"startedAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"lastReplayedLsn\":{\"type\":\"string\"},\"lastReplayedLsnAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"replicationLag\":{\"type\":\"string\"},\"replicationUptime\":{\"type\":\"integer\"}}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[GET]::/status - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "status" + ] + }, + "description": "Retrieves detailed information about the DBLab instance: status, version, clones, snapshots, etc." + }, + "response": [ + { + "name": "Returned detailed information about the DBLab instance", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "status" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"status\": {\n \"code\": \"OK\",\n \"message\": \"Instance is ready\"\n },\n \"engine\": {\n \"version\": \"v4.0.0-alpha.5-20230516-0224\",\n \"edition\": \"standard\",\n \"billingActive\": true,\n \"instanceID\": \"chhfqfcnvrvc73d0lij0\",\n \"startedAt\": \"2023-05-16T03:50:19Z\",\n \"telemetry\": true,\n \"disableConfigModification\": false\n },\n \"pools\": [\n {\n \"name\": \"dblab_pool/dataset_1\",\n \"mode\": \"zfs\",\n \"dataStateAt\": \"\",\n \"status\": \"empty\",\n \"cloneList\": [],\n \"fileSystem\": {\n \"mode\": \"zfs\",\n \"size\": 30685528064,\n \"free\": 30685282816,\n \"used\": 245248,\n \"dataSize\": 12288,\n \"usedBySnapshots\": 0,\n \"usedByClones\": 219648,\n \"compressRatio\": 1\n }\n },\n {\n \"name\": \"dblab_pool/dataset_2\",\n \"mode\": \"zfs\",\n \"dataStateAt\": \"\",\n \"status\": \"empty\",\n \"cloneList\": [],\n \"fileSystem\": {\n \"mode\": \"zfs\",\n \"size\": 30685528064,\n \"free\": 30685282816,\n \"used\": 245248,\n \"dataSize\": 12288,\n \"usedBySnapshots\": 0,\n \"usedByClones\": 219648,\n \"compressRatio\": 1\n }\n },\n {\n \"name\": \"dblab_pool/dataset_3\",\n \"mode\": \"zfs\",\n \"dataStateAt\": \"\",\n \"status\": \"empty\",\n \"cloneList\": [],\n \"fileSystem\": {\n \"mode\": \"zfs\",\n \"size\": 30685528064,\n \"free\": 30685282816,\n \"used\": 245248,\n \"dataSize\": 12288,\n \"usedBySnapshots\": 0,\n \"usedByClones\": 219648,\n \"compressRatio\": 1\n }\n }\n ],\n \"cloning\": {\n \"expectedCloningTime\": 0,\n \"numClones\": 0,\n \"clones\": []\n },\n \"retrieving\": {\n \"mode\": \"logical\",\n \"status\": \"pending\",\n \"lastRefresh\": null,\n \"nextRefresh\": null,\n \"alerts\": {},\n \"activity\": null\n },\n \"provisioner\": {\n \"dockerImage\": \"postgresai/extended-postgres:15\",\n \"containerConfig\": {\n \"shm-size\": \"1gb\"\n }\n },\n \"synchronization\": {\n \"status\": {\n \"code\": \"Not available\",\n \"message\": \"\"\n },\n \"lastReplayedLsn\": \"\",\n \"lastReplayedLsnAt\": \"\",\n \"replicationLag\": 0,\n \"replicationUptime\": 0\n }\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "status" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Data refresh status", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/instance/retrieval - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/instance/retrieval - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[GET]::/instance/retrieval - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\",\"properties\":{\"mode\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"},\"lastRefresh\":{\"type\":\"string\",\"format\":\"date-time\"},\"nextRefresh\":{\"type\":\"string\",\"format\":\"date-time\"},\"alerts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"activity\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\"},\"query\":{\"type\":\"string\"},\"duration\":{\"type\":\"number\"},\"waitEventType\":{\"type\":\"string\"},\"waitEvent\":{\"type\":\"string\"}}}},\"target\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\"},\"query\":{\"type\":\"string\"},\"duration\":{\"type\":\"number\"},\"waitEventType\":{\"type\":\"string\"},\"waitEvent\":{\"type\":\"string\"}}}}}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[GET]::/instance/retrieval - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/instance/retrieval", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "instance", + "retrieval" + ] + }, + "description": "Report a status of the data refresh subsystem (also known as \"data retrieval\"): timestamps of the previous and next refresh runs, status, messages." + }, + "response": [ + { + "name": "Reported a status of the data retrieval subsystem", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/instance/retrieval", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "instance", + "retrieval" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"mode\": \"logical\",\n \"status\": \"pending\",\n \"lastRefresh\": null,\n \"nextRefresh\": null,\n \"alerts\": {},\n \"activity\": null\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/instance/retrieval", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "instance", + "retrieval" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Service health check", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Validate status 2xx \npm.test(\"[GET]::/healthz - Status code is 2xx\", function () {\n pm.response.to.be.success;\n});\n", + "// Validate if response header has matching content-type\npm.test(\"[GET]::/healthz - Content-Type is application/json\", function () {\n pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");\n});\n", + "// Validate if response has JSON Body \npm.test(\"[GET]::/healthz - Response has JSON Body\", function () {\n pm.response.to.have.jsonBody();\n});\n", + "// Response Validation\nconst schema = {\"type\":\"object\",\"properties\":{\"version\":{\"type\":\"string\"},\"edition\":{\"type\":\"string\"},\"billingActive\":{\"type\":\"string\"},\"instanceID\":{\"type\":\"string\"},\"startedAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"telemetry\":{\"type\":\"boolean\"},\"disableConfigModification\":{\"type\":\"boolean\"}}}\n\n// Validate if response matches JSON schema \npm.test(\"[GET]::/healthz - Schema is valid\", function() {\n pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});\n});\n" + ] + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/healthz", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "healthz" + ] + }, + "description": "Check the overall health and availability of the API system. This endpoint does not require the 'Verification-Token' header." + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/healthz", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "healthz" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"version\": \"v4.0.0-alpha.5-20230516-0224\",\n \"edition\": \"standard\",\n \"instanceID\": \"chhfqfcnvrvc73d0lij0\"\n}" + } + ] + } + ] + }, + { + "name": "Snapshots", + "item": [ + { + "name": "List all snapshots", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/snapshots - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/snapshots - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[GET]::/snapshots - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"physicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"pool\":{\"type\":\"string\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int\"}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[GET]::/snapshots - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/snapshots", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "snapshots" + ] + }, + "description": "Return a list of all available snapshots." + }, + "response": [ + { + "name": "Returned a list of snapshots", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/snapshots", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "snapshots" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "[\n {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\",\n \"createdAt\": \"2023-05-09T21:27:11Z\",\n \"dataStateAt\": \"2023-05-09T21:27:11Z\",\n \"physicalSize\": 0,\n \"logicalSize\": 11518021632,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 1\n },\n {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230307171959@20230307171959\",\n \"createdAt\": \"2023-03-07T17:19:59Z\",\n \"dataStateAt\": \"2023-03-07T17:19:59Z\",\n \"physicalSize\": 151552,\n \"logicalSize\": 11518015488,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 1\n }\n]" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/snapshots", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "snapshots" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Create a snapshot", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/branch/snapshot - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/branch/snapshot - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "*/*" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cloneID\": \"aliquip sit nisi\",\n \"message\": \"do\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/snapshot", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "snapshot" + ] + }, + "description": "Create a new snapshot using the specified clone. After a snapshot has been created, the original clone can be deleted in order to free up compute resources, if necessary. The snapshot created by this endpoint can be used later to create one or more new clones." + }, + "response": [ + { + "name": "OK", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cloneID\": \"aliquip sit nisi\",\n \"message\": \"do\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/snapshot", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "snapshot" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"snapshotID\": \"voluptate\"\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cloneID\": \"aliquip sit nisi\",\n \"message\": \"do\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/snapshot", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "snapshot" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + }, + { + "name": "Retrieve a snapshot", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/branch/snapshot/:id - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/branch/snapshot/:id - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/branch/snapshot/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "snapshot", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) ID of the branch snapshot" + } + ] + }, + "description": "Retrieves the information for the specified snapshot." + }, + "response": [ + { + "name": "OK", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/branch/snapshot/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "snapshot", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) ID of the branch snapshot" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"id\": \"nostrud exercitation id velit\",\n \"parent\": \"exercitation sunt do anim\",\n \"child\": \"cillum incididunt voluptate veniam\",\n \"branch\": [\n \"cillum\",\n \"Excepteur ut ut occaecat eu\"\n ],\n \"root\": \"mollit culpa enim nostrud\",\n \"dataStateAt\": \"2008-01-19T00:42:22.510Z\",\n \"message\": \"irure qui \"\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/branch/snapshot/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "snapshot", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) ID of the branch snapshot" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + } + ] + }, + { + "name": "Clones", + "item": [ + { + "name": "List all clones", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/clones - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/clones - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[GET]::/clones - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"},\"snapshot\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"physicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"pool\":{\"type\":\"string\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int\"}}},\"protected\":{\"type\":\"boolean\",\"default\":false},\"deleteAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"db\":{\"type\":\"object\",\"properties\":{\"connStr\":{\"type\":\"string\"},\"host\":{\"type\":\"string\"},\"port\":{\"type\":\"string\"},\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}}},\"metadata\":{\"type\":\"object\",\"properties\":{\"cloneDiffSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"cloningTime\":{\"type\":\"integer\",\"format\":\"float64\"},\"maxIdleMinutes\":{\"type\":\"integer\",\"format\":\"int64\"}}}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[GET]::/clones - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clones", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clones" + ] + }, + "description": "Return a list of all available clones (database endpoints)." + }, + "response": [ + { + "name": "Returned a list of all available clones", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clones", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clones" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "[\n {\n \"id\": \"test-clone-2\",\n \"snapshot\": {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\",\n \"createdAt\": \"2023-05-09T21:27:11Z\",\n \"dataStateAt\": \"2023-05-09T21:27:11Z\",\n \"physicalSize\": 120832,\n \"logicalSize\": 11518021632,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 3\n },\n \"branch\": \"\",\n \"protected\": false,\n \"deleteAt\": null,\n \"createdAt\": \"2023-05-16T06:12:52Z\",\n \"status\": {\n \"code\": \"OK\",\n \"message\": \"Clone is ready to accept Postgres connections.\"\n },\n \"db\": {\n \"connStr\": \"host=branching.aws.postgres.ai port=6005 user=tester dbname=postgres\",\n \"host\": \"branching.aws.postgres.ai\",\n \"port\": \"6005\",\n \"username\": \"tester\",\n \"password\": \"\",\n \"dbName\": \"postgres\"\n },\n \"metadata\": {\n \"cloneDiffSize\": 484352,\n \"logicalSize\": 11518029312,\n \"cloningTime\": 1.5250661829999999,\n \"maxIdleMinutes\": 120\n }\n },\n {\n \"id\": \"test-clone\",\n \"snapshot\": {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\",\n \"createdAt\": \"2023-05-09T21:27:11Z\",\n \"dataStateAt\": \"2023-05-09T21:27:11Z\",\n \"physicalSize\": 120832,\n \"logicalSize\": 11518021632,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 3\n },\n \"branch\": \"\",\n \"protected\": false,\n \"deleteAt\": null,\n \"createdAt\": \"2023-05-16T06:12:30Z\",\n \"status\": {\n \"code\": \"OK\",\n \"message\": \"Clone is ready to accept Postgres connections.\"\n },\n \"db\": {\n \"connStr\": \"host=branching.aws.postgres.ai port=6004 user=tester dbname=postgres\",\n \"host\": \"branching.aws.postgres.ai\",\n \"port\": \"6004\",\n \"username\": \"tester\",\n \"password\": \"\",\n \"dbName\": \"postgres\"\n },\n \"metadata\": {\n \"cloneDiffSize\": 486400,\n \"logicalSize\": 11518030336,\n \"cloningTime\": 1.57552338,\n \"maxIdleMinutes\": 120\n }\n }\n]" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clones", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clones" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Create a clone", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/clone - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/clone - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[POST]::/clone - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"},\"snapshot\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"physicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"pool\":{\"type\":\"string\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int\"}}},\"protected\":{\"type\":\"boolean\",\"default\":false},\"deleteAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"db\":{\"type\":\"object\",\"properties\":{\"connStr\":{\"type\":\"string\"},\"host\":{\"type\":\"string\"},\"port\":{\"type\":\"string\"},\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}}},\"metadata\":{\"type\":\"object\",\"properties\":{\"cloneDiffSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"cloningTime\":{\"type\":\"integer\",\"format\":\"float64\"},\"maxIdleMinutes\":{\"type\":\"integer\",\"format\":\"int64\"}}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[POST]::/clone - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"magna cupidatat\",\n \"snapshot\": {\n \"id\": \"veniam\"\n },\n \"branch\": \"incididunt aliquip\",\n \"protected\": null,\n \"db\": {\n \"username\": \"Duis Lorem\",\n \"password\": \"culpa non velit ut\",\n \"restricted\": null,\n \"db_name\": \"dolore qui ut\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone" + ] + } + }, + "response": [ + { + "name": "Created a new clone", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"magna cupidatat\",\n \"snapshot\": {\n \"id\": \"veniam\"\n },\n \"branch\": \"incididunt aliquip\",\n \"protected\": null,\n \"db\": {\n \"username\": \"Duis Lorem\",\n \"password\": \"culpa non velit ut\",\n \"restricted\": null,\n \"db_name\": \"dolore qui ut\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone" + ] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"id\": \"test-clone-2\",\n \"snapshot\": {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\",\n \"createdAt\": \"2023-05-09T21:27:11Z\",\n \"dataStateAt\": \"2023-05-09T21:27:11Z\",\n \"physicalSize\": 120832,\n \"logicalSize\": 11518021632,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 3\n },\n \"branch\": \"\",\n \"protected\": false,\n \"deleteAt\": null,\n \"createdAt\": \"2023-05-16T06:12:52Z\",\n \"status\": {\n \"code\": \"CREATING\",\n \"message\": \"Clone is being created.\"\n },\n \"db\": {\n \"connStr\": \"\",\n \"host\": \"\",\n \"port\": \"\",\n \"username\": \"tester\",\n \"password\": \"\",\n \"dbName\": \"postgres\"\n },\n \"metadata\": {\n \"cloneDiffSize\": 0,\n \"logicalSize\": 0,\n \"cloningTime\": 0,\n \"maxIdleMinutes\": 0\n }\n}" + }, + { + "name": "Returned an error caused by invalid request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"magna cupidatat\",\n \"snapshot\": {\n \"id\": \"veniam\"\n },\n \"branch\": \"incididunt aliquip\",\n \"protected\": null,\n \"db\": {\n \"username\": \"Duis Lorem\",\n \"password\": \"culpa non velit ut\",\n \"restricted\": null,\n \"db_name\": \"dolore qui ut\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"BAD_REQUEST\",\n \"message\": \"clone with such ID already exists\"\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"magna cupidatat\",\n \"snapshot\": {\n \"id\": \"veniam\"\n },\n \"branch\": \"incididunt aliquip\",\n \"protected\": null,\n \"db\": {\n \"username\": \"Duis Lorem\",\n \"password\": \"culpa non velit ut\",\n \"restricted\": null,\n \"db_name\": \"dolore qui ut\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Retrieve a clone", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/clone/:id - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/clone/:id - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[GET]::/clone/:id - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"},\"snapshot\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"physicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"pool\":{\"type\":\"string\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int\"}}},\"protected\":{\"type\":\"boolean\",\"default\":false},\"deleteAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"db\":{\"type\":\"object\",\"properties\":{\"connStr\":{\"type\":\"string\"},\"host\":{\"type\":\"string\"},\"port\":{\"type\":\"string\"},\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}}},\"metadata\":{\"type\":\"object\",\"properties\":{\"cloneDiffSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"cloningTime\":{\"type\":\"integer\",\"format\":\"float64\"},\"maxIdleMinutes\":{\"type\":\"integer\",\"format\":\"int64\"}}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[GET]::/clone/:id - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + }, + "description": "Retrieves the information for the specified clone." + }, + "response": [ + { + "name": "Returned detailed information for the specified clone", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"id\": \"test-clone\",\n \"snapshot\": {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\",\n \"createdAt\": \"2023-05-09T21:27:11Z\",\n \"dataStateAt\": \"2023-05-09T21:27:11Z\",\n \"physicalSize\": 120832,\n \"logicalSize\": 11518021632,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 3\n },\n \"branch\": \"\",\n \"protected\": false,\n \"deleteAt\": null,\n \"createdAt\": \"2023-05-16T06:12:30Z\",\n \"status\": {\n \"code\": \"OK\",\n \"message\": \"Clone is ready to accept Postgres connections.\"\n },\n \"db\": {\n \"connStr\": \"host=branching.aws.postgres.ai port=6004 user=tester dbname=postgres\",\n \"host\": \"branching.aws.postgres.ai\",\n \"port\": \"6004\",\n \"username\": \"tester\",\n \"password\": \"\",\n \"dbName\": \"postgres\"\n },\n \"metadata\": {\n \"cloneDiffSize\": 486400,\n \"logicalSize\": 11518030336,\n \"cloningTime\": 1.57552338,\n \"maxIdleMinutes\": 120\n }\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"NOT_FOUND\",\n \"message\": \"Requested object does not exist. Specify your request.\"\n}" + } + ] + }, + { + "name": "Delete a clone", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[DELETE]::/clone/:id - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[DELETE]::/clone/:id - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[DELETE]::/clone/:id - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + }, + "description": "Permanently delete the specified clone. It cannot be undone." + }, + "response": [ + { + "name": "Successfully deleted the specified clone", + "originalRequest": { + "method": "DELETE", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "\"OK\"" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "DELETE", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "DELETE", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"NOT_FOUND\",\n \"message\": \"Requested object does not exist. Specify your request.\"\n}" + } + ] + }, + { + "name": "Update a clone", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[PATCH]::/clone/:id - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[PATCH]::/clone/:id - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[PATCH]::/clone/:id - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"},\"snapshot\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"dataStateAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"physicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"pool\":{\"type\":\"string\"},\"numClones\":{\"type\":\"integer\",\"format\":\"int\"}}},\"protected\":{\"type\":\"boolean\",\"default\":false},\"deleteAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"createdAt\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"required\":[\"code\",\"message\"],\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"description\":\"Status code\"},\"message\":{\"type\":\"string\",\"description\":\"Status description\"}}},\"db\":{\"type\":\"object\",\"properties\":{\"connStr\":{\"type\":\"string\"},\"host\":{\"type\":\"string\"},\"port\":{\"type\":\"string\"},\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}}},\"metadata\":{\"type\":\"object\",\"properties\":{\"cloneDiffSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"logicalSize\":{\"type\":\"integer\",\"format\":\"int64\"},\"cloningTime\":{\"type\":\"integer\",\"format\":\"float64\"},\"maxIdleMinutes\":{\"type\":\"integer\",\"format\":\"int64\"}}}}}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[PATCH]::/clone/:id - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"protected\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + }, + "description": "Updates the specified clone by setting the values of the parameters passed. Currently, only one paramater is supported: 'protected'." + }, + "response": [ + { + "name": "Successfully updated the specified clone", + "originalRequest": { + "method": "PATCH", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"protected\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"id\": \"test-clone-2\",\n \"snapshot\": {\n \"id\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\",\n \"createdAt\": \"2023-05-09T21:27:11Z\",\n \"dataStateAt\": \"2023-05-09T21:27:11Z\",\n \"physicalSize\": 120832,\n \"logicalSize\": 11518021632,\n \"pool\": \"dblab_pool/dataset_2\",\n \"numClones\": 2\n },\n \"branch\": \"\",\n \"protected\": true,\n \"deleteAt\": null,\n \"createdAt\": \"2023-05-16T06:12:52Z\",\n \"status\": {\n \"code\": \"OK\",\n \"message\": \"Clone is ready to accept Postgres connections.\"\n },\n \"db\": {\n \"connStr\": \"host=branching.aws.postgres.ai port=6005 user=tester dbname=postgres\",\n \"host\": \"branching.aws.postgres.ai\",\n \"port\": \"6005\",\n \"username\": \"tester\",\n \"password\": \"\",\n \"dbName\": \"postgres\"\n },\n \"metadata\": {\n \"cloneDiffSize\": 561664,\n \"logicalSize\": 11518030336,\n \"cloningTime\": 1.5250661829999999,\n \"maxIdleMinutes\": 120\n }\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "PATCH", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"protected\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Reset a clone", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/clone/:id/reset - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/clone/:id/reset - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[POST]::/clone/:id/reset - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"snapshotID\": \"ut nulla Duis in in\",\n \"latest\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone/:id/reset", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id", + "reset" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + }, + "description": "Reset the specified clone to a previously stored state. This can be done by specifying a particular snapshot ID or using the 'latest' flag. All changes made after the snapshot are discarded during the reset, unless those changes were preserved in a snapshot. All database connections will be reset, requiring users and applications to reconnect. The duration of the reset operation is comparable to the creation of a new clone. However, unlike creating a new clone, the reset operation retains the database credentials and does not change the port. Consequently, users and applications can continue to use the same database credentials post-reset, though reconnection will be necessary. Please note that any unsaved changes will be irretrievably lost during this operation, so ensure necessary data is backed up in a snapshot prior to resetting the clone." + }, + "response": [ + { + "name": "Successfully reset the state of the specified clone", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"snapshotID\": \"ut nulla Duis in in\",\n \"latest\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone/:id/reset", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id", + "reset" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "\"OK\"" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"snapshotID\": \"ut nulla Duis in in\",\n \"latest\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clone/:id/reset", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clone", + ":id", + "reset" + ], + "variable": [ + { + "key": "id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + } + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + } + ] + }, + { + "name": "Branches", + "item": [ + { + "name": "List all branches", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/branches - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/branches - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/branches", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branches" + ] + }, + "description": "Return a list of all available branches (named pointers to snapshots)." + }, + "response": [ + { + "name": "Returned a list of all available branches", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/branches", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branches" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "[\n {\n \"name\": \"my-1\",\n \"parent\": \"main\",\n \"dataStateAt\": \"20230224202652\",\n \"snapshotID\": \"dblab_pool/dataset_2/main/20230224202652@20230224202652\"\n },\n {\n \"name\": \"nik-test-branch\",\n \"parent\": \"-\",\n \"dataStateAt\": \"20230509212711\",\n \"snapshotID\": \"dblab_pool/dataset_2/nik-test-branch/20230509212711@20230509212711\"\n },\n {\n \"name\": \"main\",\n \"parent\": \"-\",\n \"dataStateAt\": \"20230224202652\",\n \"snapshotID\": \"dblab_pool/dataset_2/main/20230224202652@20230224202652\"\n }\n]" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/branches", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branches" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Create a branch", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/branch/create - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/branch/create - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "*/*" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"aute do laborum\",\n \"baseBranch\": \"tempor aliqua consectetur\",\n \"snapshotID\": \"mollit velit\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/create", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "create" + ] + } + }, + "response": [ + { + "name": "OK", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"aute do laborum\",\n \"baseBranch\": \"tempor aliqua consectetur\",\n \"snapshotID\": \"mollit velit\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/create", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "create" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"name\": \"cillum in laborum\"\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"aute do laborum\",\n \"baseBranch\": \"tempor aliqua consectetur\",\n \"snapshotID\": \"mollit velit\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/create", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "create" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + }, + { + "name": "Delete a branch", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/branch/delete - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/branch/delete - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "*/*" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"dolore aliqua laboris offi\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/delete", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "delete" + ] + }, + "description": "Permanently delete the specified branch. It cannot be undone." + }, + "response": [ + { + "name": "OK", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"dolore aliqua laboris offi\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/delete", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "delete" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"status\": \"irure pariatur Excepteur occaecat ullamco\",\n \"message\": \"in enim tempor\"\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"dolore aliqua laboris offi\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/delete", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "delete" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + }, + { + "name": "Retrieve a branch log", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/branch/log - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/branch/log - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "*/*" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"in exercitation eiusmod voluptate eu\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/log", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "log" + ] + }, + "description": "Retrieve a log of the specified branch (history of snapshots)." + }, + "response": [ + { + "name": "OK", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"branchName\": \"in exercitation eiusmod voluptate eu\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/branch/log", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "branch", + "log" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "[\n {\n \"id\": \"commodo enim\",\n \"parent\": \"laboris anim labore adipisi\",\n \"child\": \"consequat\",\n \"branch\": [\n \"ullamco ad cillum proident\",\n \"ea elit tempor nostrud\"\n ],\n \"root\": \"sunt\",\n \"dataStateAt\": \"2013-09-01T22:20:46.803Z\",\n \"message\": \"et sit\"\n },\n {\n \"id\": \"nisi cillum est deserunt\",\n \"parent\": \"pariatur Lorem\",\n \"child\": \"eu labore do deserunt\",\n \"branch\": [\n \"officia dolor\",\n \"dolor cillum eu culpa ut\"\n ],\n \"root\": \"exercitation aute\",\n \"dataStateAt\": \"1963-05-08T18:09:20.040Z\",\n \"message\": \"est Excepteur mollit nostrud\"\n }\n]" + } + ] + } + ] + }, + { + "name": "Admin", + "item": [ + { + "name": "Get config", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/admin/config - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/admin/config - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[GET]::/admin/config - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\"}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[GET]::/admin/config - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + }, + "description": "Retrieve the DBLab configuration. All sensitive values are masked. Only limited set of configuration parameters is returned – only those that can be changed via API (unless reconfiguration via API is disabled by admin). The result is provided in JSON format." + }, + "response": [ + { + "name": "Returned configuration", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"databaseConfigs\": {\n \"configs\": {\n \"shared_buffers\": \"1GB\",\n \"shared_preload_libraries\": \"pg_stat_statements, pg_stat_kcache, auto_explain, logerrors\"\n }\n },\n \"databaseContainer\": {\n \"dockerImage\": \"registry.gitlab.com/postgres-ai/se-images/supabase:15\"\n },\n \"global\": {\n \"debug\": true\n },\n \"retrieval\": {\n \"refresh\": {\n \"timetable\": \"0 1 * * 0\"\n },\n \"spec\": {\n \"logicalDump\": {\n \"options\": {\n \"customOptions\": [],\n \"databases\": {\n \"test_small\": {}\n },\n \"parallelJobs\": 4,\n \"source\": {\n \"connection\": {\n \"dbname\": \"test_small\",\n \"host\": \"dev1.postgres.ai\",\n \"port\": 6666,\n \"username\": \"john\"\n }\n }\n }\n },\n \"logicalRestore\": {\n \"options\": {\n \"customOptions\": [\n \"--no-tablespaces\",\n \"--no-privileges\",\n \"--no-owner\",\n \"--exit-on-error\"\n ],\n \"parallelJobs\": 4\n }\n }\n }\n }\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Set config", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/admin/config - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/admin/config - Content-Type is application/json\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");", + "});", + "", + "// Validate if response has JSON Body ", + "pm.test(\"[POST]::/admin/config - Response has JSON Body\", function () {", + " pm.response.to.have.jsonBody();", + "});", + "", + "// Response Validation", + "const schema = {\"type\":\"object\"}", + "", + "// Validate if response matches JSON schema ", + "pm.test(\"[POST]::/admin/config - Schema is valid\", function() {", + " pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + }, + "description": "Set specific configurations for the DBLab instance using this endpoint. The returned configuration parameters are limited to those that can be modified via the API (unless the API-based reconfiguration has been disabled by an administrator). The result will be provided in JSON format." + }, + "response": [ + { + "name": "Successfully saved configuration parameters", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"BAD_REQUEST\",\n \"message\": \"configuration management via UI/API disabled by admin\"\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/config", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Get full config (YAML)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[GET]::/admin/config.yaml - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[GET]::/admin/config.yaml - Content-Type is application/yaml\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/yaml\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "application/yaml" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/config.yaml", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config.yaml" + ] + }, + "description": "Retrieve the DBLab configuration in YAML format. All sensitive values are masked. This method allows seeing the entire configuration file and can be helpful for reviewing configuration and setting up workflows to automate DBLab provisioning and configuration." + }, + "response": [ + { + "name": "Returned configuration (YAML)", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/yaml" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/config.yaml", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config.yaml" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "text", + "header": [ + { + "key": "Content-Type", + "value": "application/yaml" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/config.yaml", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "config.yaml" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Test source database", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/admin/test-db-source - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"host\": \"veniam\",\n \"port\": \"tempor\",\n \"dbname\": \"et tempor in\",\n \"username\": \"minim ir\",\n \"password\": \"nisi ut incididunt in mollit\",\n \"db_list\": [\n \"veniam exercitation dolore\",\n \"do nisi in occaecat\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/test-db-source", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "test-db-source" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"host\": \"adipisicing dolor\",\n \"port\": \"elit\",\n \"dbname\": \"cupidatat in veniam laborum dolore\",\n \"username\": \"sint\",\n \"password\": \"cillum nisi consectetur\",\n \"db_list\": [\n \"ad quis\",\n \"aliqua nisi\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/test-db-source", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "test-db-source" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "text", + "header": [ + { + "key": "Content-Type", + "value": "text/plain" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"host\": \"adipisicing dolor\",\n \"port\": \"elit\",\n \"dbname\": \"cupidatat in veniam laborum dolore\",\n \"username\": \"sint\",\n \"password\": \"cillum nisi consectetur\",\n \"db_list\": [\n \"ad quis\",\n \"aliqua nisi\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/test-db-source", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "test-db-source" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"BAD_REQUEST\",\n \"message\": \"configuration management via UI/API disabled by admin\"\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"host\": \"adipisicing dolor\",\n \"port\": \"elit\",\n \"dbname\": \"cupidatat in veniam laborum dolore\",\n \"username\": \"sint\",\n \"password\": \"cillum nisi consectetur\",\n \"db_list\": [\n \"ad quis\",\n \"aliqua nisi\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/admin/test-db-source", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "test-db-source" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + }, + { + "name": "Test source database", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Validate status 2xx ", + "pm.test(\"[POST]::/admin/ws-auth - Status code is 2xx\", function () {", + " pm.response.to.be.success;", + "});", + "", + "// Validate if response header has matching content-type", + "pm.test(\"[POST]::/admin/ws-auth - Content-Type is */*\", function () {", + " pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"*/*\");", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "{{verificationToken}}" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/ws-auth", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "ws-auth" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "*/*" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/ws-auth", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "ws-auth" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "*/*" + } + ], + "cookie": [], + "body": "{\n \"token\": \"velit ut minim\"\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/ws-auth", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "ws-auth" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"BAD_REQUEST\",\n \"message\": \"configuration management via UI/API disabled by admin\"\n}" + }, + { + "name": "Unauthorized access", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/ws-auth", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "ws-auth" + ] + } + }, + "status": "Unauthorized", + "code": 401, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"UNAUTHORIZED\",\n \"message\": \"Check your verification token.\"\n}" + } + ] + } + ] + }, + { + "name": "Observation", + "item": [ + { + "name": "Start observing", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Validate status 2xx \npm.test(\"[POST]::/observation/start - Status code is 2xx\", function () {\n pm.response.to.be.success;\n});\n", + "// Validate if response header has matching content-type\npm.test(\"[POST]::/observation/start - Content-Type is application/json\", function () {\n pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");\n});\n", + "// Validate if response has JSON Body \npm.test(\"[POST]::/observation/start - Response has JSON Body\", function () {\n pm.response.to.have.jsonBody();\n});\n", + "// Response Validation\nconst schema = {\"type\":\"object\",\"properties\":{\"session_id\":{\"type\":\"integer\",\"format\":\"int64\"},\"started_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"finished_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"config\":{\"type\":\"object\",\"properties\":{\"observation_interval\":{\"type\":\"integer\",\"format\":\"int64\"},\"max_lock_duration\":{\"type\":\"integer\",\"format\":\"int64\"},\"max_duration\":{\"type\":\"integer\",\"format\":\"int64\"}}},\"tags\":{\"type\":\"object\",\"properties\":{}},\"artifacts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"result\":{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\"},\"intervals\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"duration\":{\"type\":\"integer\",\"format\":\"int64\"},\"warning\":{\"type\":\"string\"}}}},\"summary\":{\"type\":\"object\",\"properties\":{\"total_duration\":{\"type\":\"integer\",\"format\":\"float64\"},\"total_intervals\":{\"type\":\"integer\",\"format\":\"int\"},\"warning_intervals\":{\"type\":\"integer\",\"format\":\"int\"},\"checklist\":{\"type\":\"object\",\"properties\":{\"overall_success\":{\"type\":\"boolean\"},\"session_duration_acceptable\":{\"type\":\"boolean\"},\"no_long_dangerous_locks\":{\"type\":\"boolean\"}}}}}}}}}\n\n// Validate if response matches JSON schema \npm.test(\"[POST]::/observation/start - Schema is valid\", function() {\n pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});\n});\n" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clone_id\": \"ut sit irure\",\n \"config\": {\n \"observation_interval\": 33950905,\n \"max_lock_duration\": 82462220,\n \"max_duration\": 54143470\n },\n \"tags\": {},\n \"db_name\": \"magna esse dolore\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/observation/start", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "start" + ] + }, + "description": "[EXPERIMENTAL] Start an observation session for the specified clone. Observation sessions help detect dangerous (long-lasting, exclusive) locks in CI/CD pipelines. One of common scenarios is using observation sessions to test schema changes (DB migrations)." + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clone_id\": \"ut sit irure\",\n \"config\": {\n \"observation_interval\": 33950905,\n \"max_lock_duration\": 82462220,\n \"max_duration\": 54143470\n },\n \"tags\": {},\n \"db_name\": \"magna esse dolore\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/observation/start", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "start" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"session_id\": -41566390,\n \"started_at\": \"1991-02-14T03:01:06.417Z\",\n \"finished_at\": \"2018-05-30T06:18:09.119Z\",\n \"config\": {\n \"observation_interval\": 76803835,\n \"max_lock_duration\": -6633155,\n \"max_duration\": -968293\n },\n \"tags\": {},\n \"artifacts\": [\n \"aliqua do\",\n \"consectetur amet tempor eiusmod\"\n ],\n \"result\": {\n \"status\": \"qui adipisicing velit aute\",\n \"intervals\": [\n {\n \"started_at\": \"2008-06-20T07:35:49.463Z\",\n \"duration\": 34650553,\n \"warning\": \"velit nulla ex\"\n },\n {\n \"started_at\": \"1994-03-12T02:59:52.189Z\",\n \"duration\": 10020998,\n \"warning\": \"ipsum laborum\"\n }\n ],\n \"summary\": {\n \"total_duration\": -51894451,\n \"total_intervals\": -93757197,\n \"warning_intervals\": 95087393,\n \"checklist\": {\n \"overall_success\": false,\n \"session_duration_acceptable\": true,\n \"no_long_dangerous_locks\": false\n }\n }\n }\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clone_id\": \"ut sit irure\",\n \"config\": {\n \"observation_interval\": 33950905,\n \"max_lock_duration\": 82462220,\n \"max_duration\": 54143470\n },\n \"tags\": {},\n \"db_name\": \"magna esse dolore\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/observation/start", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "start" + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"NOT_FOUND\",\n \"message\": \"Requested object does not exist. Specify your request.\"\n}" + } + ] + }, + { + "name": "Stop observing", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Validate status 2xx \npm.test(\"[POST]::/observation/stop - Status code is 2xx\", function () {\n pm.response.to.be.success;\n});\n", + "// Validate if response header has matching content-type\npm.test(\"[POST]::/observation/stop - Content-Type is application/json\", function () {\n pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");\n});\n", + "// Validate if response has JSON Body \npm.test(\"[POST]::/observation/stop - Response has JSON Body\", function () {\n pm.response.to.have.jsonBody();\n});\n", + "// Response Validation\nconst schema = {\"type\":\"object\",\"properties\":{\"session_id\":{\"type\":\"integer\",\"format\":\"int64\"},\"started_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"finished_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"config\":{\"type\":\"object\",\"properties\":{\"observation_interval\":{\"type\":\"integer\",\"format\":\"int64\"},\"max_lock_duration\":{\"type\":\"integer\",\"format\":\"int64\"},\"max_duration\":{\"type\":\"integer\",\"format\":\"int64\"}}},\"tags\":{\"type\":\"object\",\"properties\":{}},\"artifacts\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"result\":{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\"},\"intervals\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"duration\":{\"type\":\"integer\",\"format\":\"int64\"},\"warning\":{\"type\":\"string\"}}}},\"summary\":{\"type\":\"object\",\"properties\":{\"total_duration\":{\"type\":\"integer\",\"format\":\"float64\"},\"total_intervals\":{\"type\":\"integer\",\"format\":\"int\"},\"warning_intervals\":{\"type\":\"integer\",\"format\":\"int\"},\"checklist\":{\"type\":\"object\",\"properties\":{\"overall_success\":{\"type\":\"boolean\"},\"session_duration_acceptable\":{\"type\":\"boolean\"},\"no_long_dangerous_locks\":{\"type\":\"boolean\"}}}}}}}}}\n\n// Validate if response matches JSON schema \npm.test(\"[POST]::/observation/stop - Schema is valid\", function() {\n pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});\n});\n" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clone_id\": \"proident cillum nostrud officia\",\n \"overall_error\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/observation/stop", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "stop" + ] + }, + "description": "[EXPERIMENTAL] Stop the previously started observation session." + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clone_id\": \"proident cillum nostrud officia\",\n \"overall_error\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/observation/stop", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "stop" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"session_id\": 9614128,\n \"started_at\": \"1993-11-12T01:24:57.933Z\",\n \"finished_at\": \"1953-01-01T04:06:59.652Z\",\n \"config\": {\n \"observation_interval\": -46635741,\n \"max_lock_duration\": -53938384,\n \"max_duration\": 85779944\n },\n \"tags\": {},\n \"artifacts\": [\n \"deseru\",\n \"in ullamco veniam\"\n ],\n \"result\": {\n \"status\": \"ut ea l\",\n \"intervals\": [\n {\n \"started_at\": \"1943-07-24T05:03:49.697Z\",\n \"duration\": -45788381,\n \"warning\": \"Ut qui occaecat\"\n },\n {\n \"started_at\": \"1973-02-08T19:49:36.906Z\",\n \"duration\": 78310177,\n \"warning\": \"dolore amet mollit velit\"\n }\n ],\n \"summary\": {\n \"total_duration\": 89098265,\n \"total_intervals\": -25796081,\n \"warning_intervals\": -74609996,\n \"checklist\": {\n \"overall_success\": false,\n \"session_duration_acceptable\": true,\n \"no_long_dangerous_locks\": false\n }\n }\n }\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "POST", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clone_id\": \"proident cillum nostrud officia\",\n \"overall_error\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/observation/stop", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "stop" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + }, + { + "name": "Get observation summary", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Validate status 2xx \npm.test(\"[GET]::/observation/summary/:clone_id/:session_id - Status code is 2xx\", function () {\n pm.response.to.be.success;\n});\n", + "// Validate if response header has matching content-type\npm.test(\"[GET]::/observation/summary/:clone_id/:session_id - Content-Type is application/json\", function () {\n pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\");\n});\n", + "// Validate if response has JSON Body \npm.test(\"[GET]::/observation/summary/:clone_id/:session_id - Response has JSON Body\", function () {\n pm.response.to.have.jsonBody();\n});\n", + "// Response Validation\nconst schema = {\"type\":\"object\",\"properties\":{\"session_id\":{\"type\":\"integer\",\"format\":\"int64\"},\"clone_id\":{\"type\":\"string\"},\"duration\":{\"type\":\"object\",\"properties\":{}},\"db_size\":{\"type\":\"object\",\"properties\":{}},\"locks\":{\"type\":\"object\",\"properties\":{}},\"log_errors\":{\"type\":\"object\",\"properties\":{}},\"artifact_types\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}}}\n\n// Validate if response matches JSON schema \npm.test(\"[GET]::/observation/summary/:clone_id/:session_id - Schema is valid\", function() {\n pm.response.to.have.jsonSchema(schema,{unknownFormats: [\"int32\", \"int64\", \"float\", \"double\"]});\n});\n" + ] + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/observation/summary/:clone_id/:session_id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "summary", + ":clone_id", + ":session_id" + ], + "variable": [ + { + "key": "clone_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + }, + { + "key": "session_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Session ID" + } + ] + }, + "description": "[EXPERIMENTAL] Collect the observation summary info." + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/observation/summary/:clone_id/:session_id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "summary", + ":clone_id", + ":session_id" + ], + "variable": [ + { + "key": "clone_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + }, + { + "key": "session_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Session ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"session_id\": 55155718,\n \"clone_id\": \"cupidatat laborum consequat Lorem officia\",\n \"duration\": {},\n \"db_size\": {},\n \"locks\": {},\n \"log_errors\": {},\n \"artifact_types\": [\n \"laboris anim Ut enim\",\n \"ullamco in esse nostrud Exc\"\n ]\n}" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/observation/summary/:clone_id/:session_id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "summary", + ":clone_id", + ":session_id" + ], + "variable": [ + { + "key": "clone_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + }, + { + "key": "session_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Session ID" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + }, + { + "name": "Download an observation artifact", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Validate status 2xx \npm.test(\"[GET]::/observation/download/:artifact_type/:clone_id/:session_id - Status code is 2xx\", function () {\n pm.response.to.be.success;\n});\n" + ] + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/observation/download/:artifact_type/:clone_id/:session_id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "download", + ":artifact_type", + ":clone_id", + ":session_id" + ], + "variable": [ + { + "key": "artifact_type", + "value": "Ut magna qui deserunt", + "description": "(Required) Type of the requested artifact" + }, + { + "key": "clone_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + }, + { + "key": "session_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Session ID" + } + ] + }, + "description": "[EXPERIMENTAL] Download an artifact for the specified clone and observation session." + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + } + ], + "url": { + "raw": "{{baseUrl}}/observation/download/:artifact_type/:clone_id/:session_id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "download", + ":artifact_type", + ":clone_id", + ":session_id" + ], + "variable": [ + { + "key": "artifact_type", + "value": "Ut magna qui deserunt", + "description": "(Required) Type of the requested artifact" + }, + { + "key": "clone_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + }, + { + "key": "session_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Session ID" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "text", + "header": [ + { + "key": "Content-Type", + "value": "text/plain" + } + ], + "cookie": [], + "body": "" + }, + { + "name": "Bad request", + "originalRequest": { + "method": "GET", + "header": [ + { + "description": "(Required) ", + "key": "Verification-Token", + "value": "Ut magna qui deserunt" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/observation/download/:artifact_type/:clone_id/:session_id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "observation", + "download", + ":artifact_type", + ":clone_id", + ":session_id" + ], + "variable": [ + { + "key": "artifact_type", + "value": "Ut magna qui deserunt", + "description": "(Required) Type of the requested artifact" + }, + { + "key": "clone_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Clone ID" + }, + { + "key": "session_id", + "value": "Ut magna qui deserunt", + "description": "(Required) Session ID" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "cookie": [], + "body": "{\n \"code\": \"incididunt minim nulla\",\n \"message\": \"qui fugiat\",\n \"detail\": \"occaecat\",\n \"hint\": \"anim\"\n}" + } + ] + } + ] + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "https://branching.aws.postgres.ai:446/api", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/engine/api/postman/portman-cli.json b/engine/api/postman/portman-cli.json new file mode 100644 index 00000000..aadb9ebf --- /dev/null +++ b/engine/api/postman/portman-cli.json @@ -0,0 +1,8 @@ +{ + "url": "https://gitlab.com/postgres-ai/database-lab/-/raw/dle-4-0/engine/api/swagger-spec/dblab_openapi.yaml", + "output": "engine/api/postman/output.json", + "envFile": "engine/api/postman/portman.env", + "includeTests": true, + "syncPostman": true, + "runNewman": false +} From a451a46d374741a04a6e0a227678d3e7877111ac Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Thu, 18 May 2023 04:15:23 +0000 Subject: [PATCH 033/114] Draft: API testing in CI/CD with postman, newman, and portman --- engine/api/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 engine/api/README.md diff --git a/engine/api/README.md b/engine/api/README.md new file mode 100644 index 00000000..7ff54a7b --- /dev/null +++ b/engine/api/README.md @@ -0,0 +1,22 @@ +## In this directory +- `swagger-spec` – OpenAPI 3.0 specification of DBLab API +- `swagger-ui` – Swagger UI to see the API specification (embedded in DBLab, available at :2345 or :2346/api) +- `postman` – [Postman](https://www.postman.com/) collection and environment files, used to test API in CI/CD pipelines (running [`newman`](https://github.com/postmanlabs/newman)) + +## Design principles +WIP: https://gitlab.com/postgres-ai/database-lab/-/merge_requests/744 + +## API docs +We use readme.io to host the API docs: https://dblab.readme.io/. Once a new API spec is ready, upload it there as a new documentation version, and publish. + +## Postman, newman, and CI/CD tests +Postman collection is to be generated based on the OpenAPI spec file, using [Portman](https://github.com/apideck-libraries/portman). +1. First, install and initialize `porman` +1. Next, generate a new version of the Postman collection file: + ``` + portman --cliOptionsFile engine/api/postman/portman-cli.json + ``` +1. Review it, edit, adjust: + - Object creation first, then deletion of this object, passing the ID of new object from one action to another (TODO: show how) + - Review and fix tests (TODO: details) +1. Commit, push, ensure `newman` testing works in CI/CD \ No newline at end of file From d4c0d88f5184def5641ca0dee51559808b1e16e3 Mon Sep 17 00:00:00 2001 From: Nikolay Samokhvalov Date: Thu, 18 May 2023 04:39:38 +0000 Subject: [PATCH 034/114] portman: switch to using local api spec --- engine/api/postman/portman-cli.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/engine/api/postman/portman-cli.json b/engine/api/postman/portman-cli.json index aadb9ebf..89b27ed2 100644 --- a/engine/api/postman/portman-cli.json +++ b/engine/api/postman/portman-cli.json @@ -1,5 +1,7 @@ { - "url": "https://gitlab.com/postgres-ai/database-lab/-/raw/dle-4-0/engine/api/swagger-spec/dblab_openapi.yaml", + "baseUrL": "http://branching.aws.postgres.ai:446/api", + "verificationToken": "demo-token", + "local": "engine/api/swagger-spec/dblab_openapi.yaml", "output": "engine/api/postman/output.json", "envFile": "engine/api/postman/portman.env", "includeTests": true, From dbc65fb85f7e0386c77dd70f59f8b41ddc31772b Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Thu, 29 Jun 2023 18:42:26 +0000 Subject: [PATCH 035/114] fix(ui): dle-4-0 UI bugs and improvements (https://gitlab.com/postgres-ai/database-lab/-/merge_requests/763#note_1422381456) --- .../ce/src/App/Instance/Page/index.tsx | 2 ++ .../components/BranchesTable/index.tsx | 27 +++++++++++++++++++ .../Modals/DeleteBranchModal/index.tsx | 2 +- ui/packages/shared/pages/Branches/index.tsx | 18 ++++++++++--- .../shared/pages/Instance/Tabs/index.tsx | 9 +++---- .../shared/pages/Instance/stores/Main.ts | 17 ++++++++++++ 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/ui/packages/ce/src/App/Instance/Page/index.tsx b/ui/packages/ce/src/App/Instance/Page/index.tsx index 2c78be28..0586058c 100644 --- a/ui/packages/ce/src/App/Instance/Page/index.tsx +++ b/ui/packages/ce/src/App/Instance/Page/index.tsx @@ -20,6 +20,7 @@ import { getEngine } from 'api/engine/getEngine' import { createBranch } from 'api/branches/createBranch' import { getBranches } from 'api/branches/getBranches' import { getSnapshotList } from 'api/branches/getSnapshotList' +import { deleteBranch } from 'api/branches/deleteBranch' export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { const routes = { @@ -47,6 +48,7 @@ export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { createBranch, getBranches, getSnapshotList, + deleteBranch } const elements = { diff --git a/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx b/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx index 25fded20..62c5f2c8 100644 --- a/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx +++ b/ui/packages/shared/pages/Branches/components/BranchesTable/index.tsx @@ -5,6 +5,7 @@ *-------------------------------------------------------------------------- */ +import { useState } from 'react' import copy from 'copy-to-clipboard' import { makeStyles } from '@material-ui/core' import { useHistory } from 'react-router-dom' @@ -21,6 +22,8 @@ import { TableBodyCellMenu, } from '@postgres.ai/shared/components/Table' +import { DeleteBranchModal } from '../Modals/DeleteBranchModal' + const useStyles = makeStyles( { cellContentCentered: { @@ -44,13 +47,20 @@ const useStyles = makeStyles( export const BranchesTable = ({ branchesData, emptyTableText, + deleteBranch, + deleteBranchError, }: { branchesData: GetBranchesResponseType[] emptyTableText: string + deleteBranch: (branchId: string) => void + deleteBranchError: { title?: string; message?: string } | null }) => { const history = useHistory() const classes = useStyles() + const [branchId, setBranchId] = useState('') + const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false) + if (!branchesData.length) { return

{emptyTableText}

} @@ -81,6 +91,13 @@ export const BranchesTable = ({ name: 'Copy snapshot ID', onClick: () => copy(branch.snapshotID), }, + { + name: 'Delete branch', + onClick: () => { + setBranchId(branch.name) + setIsOpenDestroyModal(true) + }, + }, ]} /> @@ -91,6 +108,16 @@ export const BranchesTable = ({ ))} + { + setIsOpenDestroyModal(false) + setBranchId('') + }} + deleteBranchError={deleteBranchError} + deleteBranch={deleteBranch} + branchName={branchId} + /> ) diff --git a/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx b/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx index d874696b..304fe66e 100644 --- a/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx +++ b/ui/packages/shared/pages/Branches/components/Modals/DeleteBranchModal/index.tsx @@ -14,7 +14,7 @@ import { SimpleModalControls } from '@postgres.ai/shared/components/SimpleModalC import { ImportantText } from '@postgres.ai/shared/components/ImportantText' import { Text } from '@postgres.ai/shared/components/Text' interface DeleteBranchModalProps extends ModalProps { - deleteBranchError: { title?: string; message: string } | null + deleteBranchError: { title?: string; message?: string } | null deleteBranch: (branchName: string) => void branchName: string } diff --git a/ui/packages/shared/pages/Branches/index.tsx b/ui/packages/shared/pages/Branches/index.tsx index e1983781..73d81269 100644 --- a/ui/packages/shared/pages/Branches/index.tsx +++ b/ui/packages/shared/pages/Branches/index.tsx @@ -48,12 +48,22 @@ export const Branches = observer((): React.ReactElement => { const [branchesList, setBranchesList] = useState( [], ) - - const { instance, getBranches, isBranchesLoading, getBranchesError } = - stores.main + const { + instance, + getBranches, + isBranchesLoading, + getBranchesError, + deleteBranchError, + deleteBranch, + } = stores.main const goToBranchAddPage = () => history.push(host.routes.createBranch()) + const handleDestroyBranch = async (branchId: string) => { + const isSuccess = await deleteBranch(branchId) + if (isSuccess) history.push('/instance/branches') + } + useEffect(() => { getBranches().then((response) => { response && setBranchesList(response) @@ -100,6 +110,8 @@ export const Branches = observer((): React.ReactElement => { /> diff --git a/ui/packages/shared/pages/Instance/Tabs/index.tsx b/ui/packages/shared/pages/Instance/Tabs/index.tsx index eafd8f29..f31cbca6 100644 --- a/ui/packages/shared/pages/Instance/Tabs/index.tsx +++ b/ui/packages/shared/pages/Instance/Tabs/index.tsx @@ -42,6 +42,7 @@ const useStyles = makeStyles( '& a': { color: colors.black, + textDecoration: 'none', }, }, @@ -86,8 +87,7 @@ type Props = { export const Tabs = (props: Props) => { const classes = useStyles() - const { value, handleChange, hasLogs, isConfigActive, hideInstanceTabs } = - props + const { value, handleChange, hasLogs, hideInstanceTabs } = props return ( { label="📓 Logs" disabled={!hasLogs} classes={{ - root: - props.hideInstanceTabs || !isConfigActive - ? classes.tabHidden - : classes.tabRoot, + root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, }} value={TABS_INDEX.LOGS} /> diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index 806d54eb..61c13ee9 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -28,6 +28,7 @@ import { InstanceRetrievalType } from '@postgres.ai/shared/types/api/entities/in import { GetEngine } from '@postgres.ai/shared/types/api/endpoints/getEngine' import { GetSnapshotList } from '@postgres.ai/shared/types/api/endpoints/getSnapshotList' import { GetBranches } from '@postgres.ai/shared/types/api/endpoints/getBranches' +import { DeleteBranch } from '@postgres.ai/shared/types/api/endpoints/deleteBranch' const POLLING_TIME = 2000 @@ -50,6 +51,7 @@ export type Api = { getInstanceRetrieval?: GetInstanceRetrieval getBranches?: GetBranches getSnapshotList?: GetSnapshotList + deleteBranch?: DeleteBranch } type Error = { @@ -70,6 +72,7 @@ export class MainStore { getFullConfigError: string | null = null getBranchesError: Error | null = null snapshotListError: string | null = null + deleteBranchError: Error | null = null unstableClones = new Set() private updateInstanceTimeoutId: number | null = null @@ -325,6 +328,20 @@ export class MainStore { return response } + deleteBranch = async (branchName: string) => { + if (!branchName || !this.api.deleteBranch) return + + this.deleteBranchError = null + + const { response, error } = await this.api.deleteBranch(branchName) + + if (error) { + this.deleteBranchError = await error.json().then((err) => err) + } + + return response + } + getSnapshotList = async (branchName: string) => { if (!this.api.getSnapshotList) return From a282936a80e9f42651450d15e2cc9e6f1c73acf9 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Tue, 15 Aug 2023 04:21:35 +0000 Subject: [PATCH 036/114] feat: add webhooks for major events for DLE 4.0 (#514) --- engine/.golangci.yml | 5 +- engine/Makefile | 2 +- engine/cmd/cli/commands/clone/actions.go | 6 +- engine/cmd/cli/commands/clone/command_list.go | 6 +- engine/cmd/cli/commands/config/file.go | 6 +- engine/cmd/cli/commands/global/actions.go | 6 +- engine/cmd/cli/main.go | 1 + engine/cmd/database-lab/main.go | 45 +- engine/cmd/runci/main.go | 1 + .../config.example.logical_generic.yml | 8 + .../config.example.logical_rds_iam.yml | 8 + .../config.example.physical_generic.yml | 8 + .../config.example.physical_pgbackrest.yml | 8 + .../configs/config.example.physical_walg.yml | 8 + engine/internal/billing/billing.go | 6 +- engine/internal/cloning/base.go | 57 +- engine/internal/cloning/storage_test.go | 4 +- engine/internal/observer/observer.go | 6 +- engine/internal/observer/observing_clone.go | 8 +- engine/internal/observer/stats.go | 6 +- .../postgres/pgconfig/configuration.go | 48 +- engine/internal/provision/docker/docker.go | 6 +- engine/internal/provision/mode_local_test.go | 12 +- .../provision/thinclones/lvm/lvmanager.go | 4 +- .../provision/thinclones/zfs/branching.go | 6 +- .../internal/retrieval/dbmarker/dbmarker.go | 8 +- .../engine/postgres/physical/custom.go | 2 +- .../engine/postgres/physical/pgbackrest.go | 2 +- .../engine/postgres/snapshot/physical.go | 6 +- engine/internal/srv/branch.go | 11 + engine/internal/srv/routes.go | 11 + engine/internal/srv/server.go | 13 +- engine/internal/srv/ws_test.go | 7 +- engine/internal/webhooks/events.go | 48 + engine/internal/webhooks/webhooks.go | 149 +++ engine/pkg/config/config.go | 2 + engine/pkg/log/filtering.go | 1 + engine/scripts/cli_install.sh | 6 +- ui/cspell.json | 39 +- .../ce/src/App/Menu/StickyTopBar/index.tsx | 8 +- ui/packages/ce/src/api/engine/getEngine.ts | 5 +- .../ce/src/api/instances/getInstance.ts | 4 +- ui/packages/ce/src/stores/app.ts | 4 +- .../ce/src/types/api/entities/engine.ts | 8 - ui/packages/platform/package.json | 3 + .../platform/public/images/ansible.svg | 2 + ui/packages/platform/public/images/docker.svg | 4 + ui/packages/platform/public/images/globe.svg | 2 + .../public/images/paymentMethods/amex.png | Bin 0 -> 6726 bytes .../public/images/paymentMethods/diners.png | Bin 0 -> 6921 bytes .../public/images/paymentMethods/discover.png | Bin 0 -> 5742 bytes .../public/images/paymentMethods/maestro.png | Bin 0 -> 6675 bytes .../images/paymentMethods/mastercard.png | Bin 0 -> 6281 bytes .../public/images/paymentMethods/unionpay.png | Bin 0 -> 32719 bytes .../public/images/paymentMethods/visa.png | Bin 0 -> 5167 bytes .../public/images/service-providers/aws.png | Bin 0 -> 4071 bytes .../images/service-providers/digitalocean.png | Bin 0 -> 6712 bytes .../public/images/service-providers/gcp.png | Bin 0 -> 4124 bytes .../images/service-providers/hetzner.png | Bin 0 -> 6938 bytes ui/packages/platform/src/App.jsx | 12 +- ui/packages/platform/src/api/api.js | 1 + .../src/api/billing/getPaymentMethods.ts | 18 + .../src/api/billing/getSubscription.ts | 18 + .../src/api/billing/startBillingSession.ts | 19 + .../platform/src/api/cloud/getCloudImages.ts | 38 + .../src/api/cloud/getCloudInstances.ts | 41 + .../src/api/cloud/getCloudProviders.ts | 22 + .../platform/src/api/cloud/getCloudRegions.ts | 25 + .../platform/src/api/cloud/getCloudVolumes.ts | 30 + .../platform/src/api/cloud/getOrgKeys.ts | 10 + .../platform/src/api/configs/getConfig.ts | 11 + .../platform/src/api/configs/getFullConfig.ts | 14 + .../platform/src/api/configs/testDbSource.ts | 21 + .../platform/src/api/configs/updateConfig.ts | 62 + .../platform/src/api/engine/getEngine.ts | 16 + .../platform/src/api/engine/getWSToken.ts | 14 + ui/packages/platform/src/api/engine/initWS.ts | 10 + .../components/AccessTokens/AccessTokens.tsx | 48 +- .../AccessTokens/AccessTokensWrapper.tsx | 1 + .../FilteredTableMessage.tsx | 39 + .../AddDbLabInstanceFormWrapper.tsx | 63 + .../AddDblabInstanceForm.tsx | 622 ++++++++++ .../AddMemberForm/AddMemberForm.tsx | 2 +- .../platform/src/components/Audit/Audit.tsx | 43 +- .../src/components/Billing/Billing.tsx | 305 +---- .../src/components/Billing/BillingWrapper.tsx | 92 +- .../src/components/ConsolePageTitle.tsx | 38 +- .../CreateDbLabCards/CreateDbLabCards.tsx | 170 +++ .../src/components/Dashboard/Dashboard.tsx | 176 +-- .../components/Dashboard/DashboardWrapper.tsx | 74 +- .../DbLabFormSteps/AnsibleInstance.tsx | 257 ++++ .../DbLabFormSteps/DockerInstance.tsx | 168 +++ .../DbLabFormSteps/InstanceFormCreation.tsx | 101 ++ .../DbLabInstanceForm/DbLabInstanceForm.tsx | 1082 +++++++++-------- .../DbLabInstanceFormSidebar.tsx | 209 ++++ .../DbLabInstanceFormSlider.tsx | 94 ++ .../DbLabInstanceFormWrapper.tsx | 255 +++- .../DbLabInstanceForm/reducer/index.tsx | 201 +++ .../DbLabInstanceForm/utils/index.ts | 121 ++ .../DbLabFormSteps/AnsibleInstance.tsx | 163 +++ .../DbLabFormSteps/DockerInstance.tsx | 133 ++ .../DbLabInstanceInstallForm.tsx | 222 ++++ .../DbLabInstanceInstallFormSidebar.tsx | 107 ++ .../DbLabInstanceInstallFormWrapper.tsx | 304 +++++ .../reducer/index.tsx | 60 + .../DbLabInstanceInstallForm/utils/index.ts | 52 + .../DbLabInstances/DbLabInstances.tsx | 167 ++- .../DbLabInstances/DbLabInstancesWrapper.tsx | 36 +- .../src/components/IndexPage/IndexPage.tsx | 44 +- .../components/IndexPage/IndexPageWrapper.tsx | 7 +- .../JoeInstanceForm/JoeInstanceForm.tsx | 4 +- .../components/JoeInstances/JoeInstances.tsx | 5 +- .../src/components/OrgMembers/OrgMembers.tsx | 49 +- .../ProductCard/ProductCardWrapper.tsx | 2 +- .../platform/src/components/StripeForm.tsx | 255 ---- .../src/components/StripeForm/index.tsx | 500 ++++++++ .../components/StripeForm/stripeStyles.tsx | 80 ++ .../platform/src/components/types/index.ts | 2 + ui/packages/platform/src/config/env.ts | 1 + .../platform/src/pages/Instance/index.tsx | 14 +- ui/packages/platform/src/stores/store.js | 15 +- ui/packages/platform/src/utils/settings.ts | 2 +- ui/packages/platform/src/utils/urls.ts | 6 +- .../components/SyntaxHighlight/index.tsx | 18 +- .../shared/pages/Instance/Clones/index.tsx | 2 +- .../pages/Instance/InactiveInstance/index.tsx | 165 +++ .../pages/Instance/InactiveInstance/utils.ts | 9 + .../Connection/ConnectModal/Content/utils.ts | 11 +- .../shared/pages/Instance/Info/index.tsx | 2 +- .../pages/Instance/Tabs/PlatformTabs.tsx | 91 ++ .../shared/pages/Instance/Tabs/index.tsx | 13 +- ui/packages/shared/pages/Instance/context.ts | 1 + ui/packages/shared/pages/Instance/index.tsx | 228 ++-- .../shared/pages/Instance/stores/Main.ts | 16 +- ui/packages/shared/styles/icons.tsx | 193 +++ .../shared/types/api/endpoints/getEngine.ts | 11 +- .../shared/types/api/entities/instance.ts | 45 +- .../types/api/entities/instanceState.ts | 4 +- ui/packages/shared/utils/date.ts | 6 +- ui/pnpm-lock.yaml | 84 ++ 140 files changed, 6598 insertions(+), 1700 deletions(-) create mode 100644 engine/internal/webhooks/events.go create mode 100644 engine/internal/webhooks/webhooks.go delete mode 100644 ui/packages/ce/src/types/api/entities/engine.ts create mode 100644 ui/packages/platform/public/images/ansible.svg create mode 100644 ui/packages/platform/public/images/docker.svg create mode 100644 ui/packages/platform/public/images/globe.svg create mode 100644 ui/packages/platform/public/images/paymentMethods/amex.png create mode 100644 ui/packages/platform/public/images/paymentMethods/diners.png create mode 100644 ui/packages/platform/public/images/paymentMethods/discover.png create mode 100644 ui/packages/platform/public/images/paymentMethods/maestro.png create mode 100644 ui/packages/platform/public/images/paymentMethods/mastercard.png create mode 100644 ui/packages/platform/public/images/paymentMethods/unionpay.png create mode 100644 ui/packages/platform/public/images/paymentMethods/visa.png create mode 100644 ui/packages/platform/public/images/service-providers/aws.png create mode 100644 ui/packages/platform/public/images/service-providers/digitalocean.png create mode 100644 ui/packages/platform/public/images/service-providers/gcp.png create mode 100644 ui/packages/platform/public/images/service-providers/hetzner.png create mode 100644 ui/packages/platform/src/api/billing/getPaymentMethods.ts create mode 100644 ui/packages/platform/src/api/billing/getSubscription.ts create mode 100644 ui/packages/platform/src/api/billing/startBillingSession.ts create mode 100644 ui/packages/platform/src/api/cloud/getCloudImages.ts create mode 100644 ui/packages/platform/src/api/cloud/getCloudInstances.ts create mode 100644 ui/packages/platform/src/api/cloud/getCloudProviders.ts create mode 100644 ui/packages/platform/src/api/cloud/getCloudRegions.ts create mode 100644 ui/packages/platform/src/api/cloud/getCloudVolumes.ts create mode 100644 ui/packages/platform/src/api/cloud/getOrgKeys.ts create mode 100644 ui/packages/platform/src/api/configs/getConfig.ts create mode 100644 ui/packages/platform/src/api/configs/getFullConfig.ts create mode 100644 ui/packages/platform/src/api/configs/testDbSource.ts create mode 100644 ui/packages/platform/src/api/configs/updateConfig.ts create mode 100644 ui/packages/platform/src/api/engine/getEngine.ts create mode 100644 ui/packages/platform/src/api/engine/getWSToken.ts create mode 100644 ui/packages/platform/src/api/engine/initWS.ts create mode 100644 ui/packages/platform/src/components/AccessTokens/FilteredTableMessage/FilteredTableMessage.tsx create mode 100644 ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDbLabInstanceFormWrapper.tsx create mode 100644 ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDblabInstanceForm.tsx create mode 100644 ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/DockerInstance.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSidebar.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSlider.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/reducer/index.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/utils/index.ts create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/AnsibleInstance.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/DockerInstance.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallForm.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormSidebar.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormWrapper.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/reducer/index.tsx create mode 100644 ui/packages/platform/src/components/DbLabInstanceInstallForm/utils/index.ts delete mode 100644 ui/packages/platform/src/components/StripeForm.tsx create mode 100644 ui/packages/platform/src/components/StripeForm/index.tsx create mode 100644 ui/packages/platform/src/components/StripeForm/stripeStyles.tsx create mode 100644 ui/packages/shared/pages/Instance/InactiveInstance/index.tsx create mode 100644 ui/packages/shared/pages/Instance/InactiveInstance/utils.ts create mode 100644 ui/packages/shared/pages/Instance/Tabs/PlatformTabs.tsx diff --git a/engine/.golangci.yml b/engine/.golangci.yml index e43f4a37..781d0243 100644 --- a/engine/.golangci.yml +++ b/engine/.golangci.yml @@ -66,14 +66,12 @@ linters-settings: linters: enable: - - deadcode - depguard - dupl - errcheck - gochecknoinits - goconst - gocritic - - goimports - gomnd - gosimple - govet @@ -83,10 +81,8 @@ linters: - misspell - prealloc - revive - - structcheck - stylecheck - unconvert - - varcheck - unused - unparam - wsl @@ -95,6 +91,7 @@ linters: - gosec - interfacer - gocyclo # currently unmaintained + - goimports # TODO: move it back to "enable" (Nik 2023-06-09) presets: fast: false diff --git a/engine/Makefile b/engine/Makefile index 84f6cd1a..bfb29109 100644 --- a/engine/Makefile +++ b/engine/Makefile @@ -34,7 +34,7 @@ help: ## Display the help message all: clean build ## Build all binary components of the project install-lint: ## Install the linter to $GOPATH/bin which is expected to be in $PATH - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.45.2 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.52.2 run-lint: ## Run linters golangci-lint run diff --git a/engine/cmd/cli/commands/clone/actions.go b/engine/cmd/cli/commands/clone/actions.go index 026b0cac..3eca7e3f 100644 --- a/engine/cmd/cli/commands/clone/actions.go +++ b/engine/cmd/cli/commands/clone/actions.go @@ -439,11 +439,7 @@ func forward(cliCtx *cli.Context) error { log.Msg(fmt.Sprintf("The clone is available by address: %s", tunnel.Endpoints.Local)) - if err := tunnel.Listen(cliCtx.Context); err != nil { - return err - } - - return nil + return tunnel.Listen(cliCtx.Context) } func retrieveClonePort(cliCtx *cli.Context, wg *sync.WaitGroup, remoteHost *url.URL) (string, error) { diff --git a/engine/cmd/cli/commands/clone/command_list.go b/engine/cmd/cli/commands/clone/command_list.go index 01b393d4..f6067205 100644 --- a/engine/cmd/cli/commands/clone/command_list.go +++ b/engine/cmd/cli/commands/clone/command_list.go @@ -229,11 +229,7 @@ func CommandList() []*cli.Command { return err } - if err := commands.CheckForwardingServerURL(ctxCli); err != nil { - return err - } - - return nil + return commands.CheckForwardingServerURL(ctxCli) }, Action: forward, }, diff --git a/engine/cmd/cli/commands/config/file.go b/engine/cmd/cli/commands/config/file.go index 24de4b96..67ffbc53 100644 --- a/engine/cmd/cli/commands/config/file.go +++ b/engine/cmd/cli/commands/config/file.go @@ -100,9 +100,5 @@ func SaveConfig(filename string, cfg *CLIConfig) error { return err } - if err := os.WriteFile(filename, configData, 0600); err != nil { - return err - } - - return nil + return os.WriteFile(filename, configData, 0600) } diff --git a/engine/cmd/cli/commands/global/actions.go b/engine/cmd/cli/commands/global/actions.go index 5c9b63fb..1de794fa 100644 --- a/engine/cmd/cli/commands/global/actions.go +++ b/engine/cmd/cli/commands/global/actions.go @@ -76,9 +76,5 @@ func forward(cliCtx *cli.Context) error { log.Msg(fmt.Sprintf("The connection is available by address: %s", tunnel.Endpoints.Local)) - if err := tunnel.Listen(cliCtx.Context); err != nil { - return err - } - - return nil + return tunnel.Listen(cliCtx.Context) } diff --git a/engine/cmd/cli/main.go b/engine/cmd/cli/main.go index c8b6fec5..80680431 100644 --- a/engine/cmd/cli/main.go +++ b/engine/cmd/cli/main.go @@ -1,3 +1,4 @@ +// Package main contains the starting point of the CLI tool. package main import ( diff --git a/engine/cmd/database-lab/main.go b/engine/cmd/database-lab/main.go index f5d4902e..ee6f2d2e 100644 --- a/engine/cmd/database-lab/main.go +++ b/engine/cmd/database-lab/main.go @@ -6,6 +6,7 @@ // - Validate configs in all components. // - Tests. +// Package main contains the starting point of the DLE server. package main import ( @@ -35,6 +36,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/srv" "gitlab.com/postgres-ai/database-lab/v3/internal/srv/ws" "gitlab.com/postgres-ai/database-lab/v3/internal/telemetry" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/config" "gitlab.com/postgres-ai/database-lab/v3/pkg/config/global" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" @@ -55,7 +57,7 @@ func main() { } logFilter := log.GetFilter() - logFilter.ReloadLogRegExp([]string{cfg.Server.VerificationToken, cfg.Platform.AccessToken, cfg.Platform.OrgKey}) + logFilter.ReloadLogRegExp(maskedSecrets(cfg)) config.ApplyGlobals(cfg) @@ -110,6 +112,11 @@ func main() { tm := telemetry.New(platformSvc, engProps.InstanceID) + webhookChan := make(chan webhooks.EventTyper, 1) + whs := webhooks.NewService(&cfg.Webhooks, webhookChan) + + go whs.Run(ctx) + pm := pool.NewPoolManager(&cfg.PoolManager, runner) if err = pm.ReloadPools(); err != nil { log.Err(err.Error()) @@ -143,7 +150,7 @@ func main() { shutdownDatabaseLabEngine(context.Background(), docker, &cfg.Global.Database, engProps.InstanceID, pm.First()) } - cloningSvc := cloning.NewBase(&cfg.Cloning, provisioner, tm, observingChan) + cloningSvc := cloning.NewBase(&cfg.Cloning, &cfg.Global, provisioner, tm, observingChan, webhookChan) if err = cloningSvc.Run(ctx); err != nil { log.Err(err) emergencyShutdown() @@ -192,17 +199,18 @@ func main() { server, logCleaner, logFilter, + whs, ) } server := srv.NewServer(&cfg.Server, &cfg.Global, &engProps, docker, cloningSvc, provisioner, retrievalSvc, platformSvc, - billingSvc, obs, pm, tm, tokenHolder, logFilter, embeddedUI, reloadConfigFn) + billingSvc, obs, pm, tm, tokenHolder, logFilter, embeddedUI, reloadConfigFn, webhookChan) shutdownCh := setShutdownListener() go setReloadListener(ctx, engProps, provisioner, billingSvc, retrievalSvc, pm, cloningSvc, platformSvc, embeddedUI, server, - logCleaner, logFilter) + logCleaner, logFilter, whs) server.InitHandlers() @@ -285,13 +293,14 @@ func getEngineProperties(ctx context.Context, docker *client.Client, cfg *config func reloadConfig(ctx context.Context, engProp global.EngineProps, provisionSvc *provision.Provisioner, billingSvc *billing.Billing, retrievalSvc *retrieval.Retrieval, pm *pool.Manager, cloningSvc *cloning.Base, platformSvc *platform.Service, - embeddedUI *embeddedui.UIManager, server *srv.Server, cleaner *diagnostic.Cleaner, filtering *log.Filtering) error { + embeddedUI *embeddedui.UIManager, server *srv.Server, cleaner *diagnostic.Cleaner, filtering *log.Filtering, + whs *webhooks.Service) error { cfg, err := config.LoadConfiguration() if err != nil { return err } - filtering.ReloadLogRegExp([]string{cfg.Server.VerificationToken, cfg.Platform.AccessToken, cfg.Platform.OrgKey}) + filtering.ReloadLogRegExp(maskedSecrets(cfg)) config.ApplyGlobals(cfg) if err := provision.IsValidConfig(cfg.Provision); err != nil { @@ -327,17 +336,19 @@ func reloadConfig(ctx context.Context, engProp global.EngineProps, provisionSvc provisionSvc.Reload(cfg.Provision, dbCfg) retrievalSvc.Reload(ctx, newRetrievalConfig) - cloningSvc.Reload(cfg.Cloning) + cloningSvc.Reload(cfg.Cloning, cfg.Global) platformSvc.Reload(newPlatformSvc) billingSvc.Reload(newPlatformSvc.Client) server.Reload(cfg.Server) + whs.Reload(&cfg.Webhooks) return nil } func setReloadListener(ctx context.Context, engProp global.EngineProps, provisionSvc *provision.Provisioner, billingSvc *billing.Billing, retrievalSvc *retrieval.Retrieval, pm *pool.Manager, cloningSvc *cloning.Base, platformSvc *platform.Service, - embeddedUI *embeddedui.UIManager, server *srv.Server, cleaner *diagnostic.Cleaner, logFilter *log.Filtering) { + embeddedUI *embeddedui.UIManager, server *srv.Server, cleaner *diagnostic.Cleaner, logFilter *log.Filtering, + whs *webhooks.Service) { reloadCh := make(chan os.Signal, 1) signal.Notify(reloadCh, syscall.SIGHUP) @@ -349,7 +360,7 @@ func setReloadListener(ctx context.Context, engProp global.EngineProps, provisio pm, cloningSvc, platformSvc, embeddedUI, server, - cleaner, logFilter); err != nil { + cleaner, logFilter, whs); err != nil { log.Err("Failed to reload configuration:", err) continue @@ -385,3 +396,19 @@ func removeObservingClones(obsCh chan string, obs *observer.Observer) { obs.RemoveObservingClone(cloneID) } } + +func maskedSecrets(cfg *config.Config) []string { + maskedSecrets := []string{ + cfg.Server.VerificationToken, + cfg.Platform.AccessToken, + cfg.Platform.OrgKey, + } + + for _, webhookCfg := range cfg.Webhooks.Hooks { + if webhookCfg.Secret != "" { + maskedSecrets = append(maskedSecrets, webhookCfg.Secret) + } + } + + return maskedSecrets +} diff --git a/engine/cmd/runci/main.go b/engine/cmd/runci/main.go index 7a9ae2e0..60af0beb 100644 --- a/engine/cmd/runci/main.go +++ b/engine/cmd/runci/main.go @@ -1,3 +1,4 @@ +// Package main contains the starting point of the CI Checker tool. package main import ( diff --git a/engine/configs/config.example.logical_generic.yml b/engine/configs/config.example.logical_generic.yml index e54aa3a2..f9f8f686 100644 --- a/engine/configs/config.example.logical_generic.yml +++ b/engine/configs/config.example.logical_generic.yml @@ -395,3 +395,11 @@ platform: # "select \\d+": "***" # "[a-z0-9._%+\\-]+(@[a-z0-9.\\-]+\\.[a-z]{2,4})": "***$1" # +# Webhooks configuration. +#webhooks: +# hooks: +# - url: "" +# secret: "" # (optional) Sent with the request in the `DBLab-Webhook-Token` HTTP header. +# trigger: +# - clone_create +# - clone_reset \ No newline at end of file diff --git a/engine/configs/config.example.logical_rds_iam.yml b/engine/configs/config.example.logical_rds_iam.yml index 43e98819..0ce5d0f4 100644 --- a/engine/configs/config.example.logical_rds_iam.yml +++ b/engine/configs/config.example.logical_rds_iam.yml @@ -394,3 +394,11 @@ platform: # "select \\d+": "***" # "[a-z0-9._%+\\-]+(@[a-z0-9.\\-]+\\.[a-z]{2,4})": "***$1" # +# Webhooks configuration. +#webhooks: +# hooks: +# - url: "" +# secret: "" # (optional) Sent with the request in the `DBLab-Webhook-Token` HTTP header. +# trigger: +# - clone_create +# - clone_reset \ No newline at end of file diff --git a/engine/configs/config.example.physical_generic.yml b/engine/configs/config.example.physical_generic.yml index 0919763d..416afa50 100644 --- a/engine/configs/config.example.physical_generic.yml +++ b/engine/configs/config.example.physical_generic.yml @@ -348,3 +348,11 @@ platform: # "select \\d+": "***" # "[a-z0-9._%+\\-]+(@[a-z0-9.\\-]+\\.[a-z]{2,4})": "***$1" # +# Webhooks configuration. +#webhooks: +# hooks: +# - url: "" +# secret: "" # (optional) Sent with the request in the `DBLab-Webhook-Token` HTTP header. +# trigger: +# - clone_create +# - clone_reset \ No newline at end of file diff --git a/engine/configs/config.example.physical_pgbackrest.yml b/engine/configs/config.example.physical_pgbackrest.yml index 56fe8659..2d54b589 100644 --- a/engine/configs/config.example.physical_pgbackrest.yml +++ b/engine/configs/config.example.physical_pgbackrest.yml @@ -366,3 +366,11 @@ platform: # "regexp": "replace" # "select \\d+": "***" # "[a-z0-9._%+\\-]+(@[a-z0-9.\\-]+\\.[a-z]{2,4})": "***$1" +# Webhooks configuration. +#webhooks: +# hooks: +# - url: "" +# secret: "" # (optional) Sent with the request in the `DBLab-Webhook-Token` HTTP header. +# trigger: +# - clone_create +# - clone_reset \ No newline at end of file diff --git a/engine/configs/config.example.physical_walg.yml b/engine/configs/config.example.physical_walg.yml index df398375..d7f226a0 100644 --- a/engine/configs/config.example.physical_walg.yml +++ b/engine/configs/config.example.physical_walg.yml @@ -339,3 +339,11 @@ platform: # "regexp": "replace" # "select \\d+": "***" # "[a-z0-9._%+\\-]+(@[a-z0-9.\\-]+\\.[a-z]{2,4})": "***$1" +# Webhooks configuration. +#webhooks: +# hooks: +# - url: "" +# secret: "" # (optional) Sent with the request in the `DBLab-Webhook-Token` HTTP header. +# trigger: +# - clone_create +# - clone_reset \ No newline at end of file diff --git a/engine/internal/billing/billing.go b/engine/internal/billing/billing.go index 7ff35151..5dd7c073 100644 --- a/engine/internal/billing/billing.go +++ b/engine/internal/billing/billing.go @@ -86,11 +86,7 @@ func (b *Billing) RegisterInstance(ctx context.Context, systemMetrics models.Sys } // To check billing state immediately. - if err := b.SendUsage(ctx, systemMetrics); err != nil { - return err - } - - return nil + return b.SendUsage(ctx, systemMetrics) } // CollectUsage periodically collects usage statistics of the instance. diff --git a/engine/internal/cloning/base.go b/engine/internal/cloning/base.go index 9ad173b2..a4a32cdd 100644 --- a/engine/internal/cloning/base.go +++ b/engine/internal/cloning/base.go @@ -23,7 +23,9 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/provision" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/resources" "gitlab.com/postgres-ai/database-lab/v3/internal/telemetry" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" + "gitlab.com/postgres-ai/database-lab/v3/pkg/config/global" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" @@ -32,8 +34,6 @@ import ( const ( idleCheckDuration = 5 * time.Minute - - defaultDatabaseName = "postgres" ) // Config contains a cloning configuration. @@ -45,22 +45,27 @@ type Config struct { // Base provides cloning service. type Base struct { config *Config + global *global.Config cloneMutex sync.RWMutex clones map[string]*CloneWrapper snapshotBox SnapshotBox provision *provision.Provisioner tm *telemetry.Agent observingCh chan string + webhookCh chan webhooks.EventTyper } // NewBase instances a new Base service. -func NewBase(cfg *Config, provision *provision.Provisioner, tm *telemetry.Agent, observingCh chan string) *Base { +func NewBase(cfg *Config, global *global.Config, provision *provision.Provisioner, tm *telemetry.Agent, + observingCh chan string, whCh chan webhooks.EventTyper) *Base { return &Base{ config: cfg, + global: global, clones: make(map[string]*CloneWrapper), provision: provision, tm: tm, observingCh: observingCh, + webhookCh: whCh, snapshotBox: SnapshotBox{ items: make(map[string]*models.Snapshot), }, @@ -68,8 +73,9 @@ func NewBase(cfg *Config, provision *provision.Provisioner, tm *telemetry.Agent, } // Reload reloads base cloning configuration. -func (c *Base) Reload(cfg Config) { +func (c *Base) Reload(cfg Config, global global.Config) { *c.config = cfg + *c.global = global } // Run initializes and runs cloning component. @@ -212,6 +218,18 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon c.fillCloneSession(cloneID, session) c.SaveClonesState() + + c.webhookCh <- webhooks.CloneEvent{ + BasicEvent: webhooks.BasicEvent{ + EventType: webhooks.CloneCreatedEvent, + EntityID: cloneID, + }, + Host: c.config.AccessHost, + Port: session.Port, + Username: clone.DB.Username, + DBName: clone.DB.DBName, + ContainerName: util.GetCloneName(session.Port), + } }() return clone, nil @@ -236,15 +254,14 @@ func (c *Base) fillCloneSession(cloneID string, session *resources.Session) { Message: models.CloneMessageOK, } - dbName := clone.DB.DBName - if dbName == "" { - dbName = defaultDatabaseName + if dbName := clone.DB.DBName; dbName == "" { + clone.DB.DBName = c.global.Database.Name() } clone.DB.Port = strconv.FormatUint(uint64(session.Port), 10) clone.DB.Host = c.config.AccessHost clone.DB.ConnStr = fmt.Sprintf("host=%s port=%s user=%s dbname=%s", - clone.DB.Host, clone.DB.Port, clone.DB.Username, dbName) + clone.DB.Host, clone.DB.Port, clone.DB.Username, clone.DB.DBName) clone.Metadata = models.CloneMetadata{ CloningTime: w.TimeStartedAt.Sub(w.TimeCreatedAt).Seconds(), @@ -329,6 +346,18 @@ func (c *Base) DestroyClone(cloneID string) error { c.observingCh <- cloneID c.SaveClonesState() + + c.webhookCh <- webhooks.CloneEvent{ + BasicEvent: webhooks.BasicEvent{ + EventType: webhooks.CloneDeleteEvent, + EntityID: cloneID, + }, + Host: c.config.AccessHost, + Port: w.Session.Port, + Username: w.Clone.DB.Username, + DBName: w.Clone.DB.DBName, + ContainerName: util.GetCloneName(w.Session.Port), + } }() return nil @@ -484,6 +513,18 @@ func (c *Base) ResetClone(cloneID string, resetOptions types.ResetCloneRequest) c.SaveClonesState() + c.webhookCh <- webhooks.CloneEvent{ + BasicEvent: webhooks.BasicEvent{ + EventType: webhooks.CloneResetEvent, + EntityID: cloneID, + }, + Host: c.config.AccessHost, + Port: w.Session.Port, + Username: w.Clone.DB.Username, + DBName: w.Clone.DB.DBName, + ContainerName: util.GetCloneName(w.Session.Port), + } + c.tm.SendEvent(context.Background(), telemetry.CloneResetEvent, telemetry.CloneCreated{ ID: util.HashID(w.Clone.ID), CloningTime: w.Clone.Metadata.CloningTime, diff --git a/engine/internal/cloning/storage_test.go b/engine/internal/cloning/storage_test.go index e2a458d8..16c3b3ca 100644 --- a/engine/internal/cloning/storage_test.go +++ b/engine/internal/cloning/storage_test.go @@ -122,7 +122,7 @@ func TestSavingSessionState(t *testing.T) { prov, err := newProvisioner() assert.NoError(t, err) - s := NewBase(nil, prov, &telemetry.Agent{}, nil) + s := NewBase(nil, nil, prov, &telemetry.Agent{}, nil, nil) err = s.saveClonesState(f.Name()) assert.NoError(t, err) @@ -166,7 +166,7 @@ func TestFilter(t *testing.T) { assert.NoError(t, err) defer func() { _ = os.Remove(filepath) }() - s := NewBase(nil, prov, &telemetry.Agent{}, nil) + s := NewBase(nil, nil, prov, &telemetry.Agent{}, nil, nil) s.filterRunningClones(context.Background()) assert.Equal(t, 0, len(s.clones)) diff --git a/engine/internal/observer/observer.go b/engine/internal/observer/observer.go index f319b274..25bdf0ef 100644 --- a/engine/internal/observer/observer.go +++ b/engine/internal/observer/observer.go @@ -131,11 +131,7 @@ func (o *Observer) processCSVLogFile(ctx context.Context, buf io.Writer, filenam } }() - if err := o.scanCSVLogFile(ctx, logFile, buf, obsClone); err != nil { - return err - } - - return nil + return o.scanCSVLogFile(ctx, logFile, buf, obsClone) } func (o *Observer) scanCSVLogFile(ctx context.Context, reader io.Reader, writer io.Writer, obsClone *ObservingClone) error { diff --git a/engine/internal/observer/observing_clone.go b/engine/internal/observer/observing_clone.go index 4bc9a202..dc85387e 100644 --- a/engine/internal/observer/observing_clone.go +++ b/engine/internal/observer/observing_clone.go @@ -393,7 +393,7 @@ func (c *ObservingClone) storeArtifacts() error { } if err := c.collectCurrentState(ctx); err != nil { - return err + return errors.Wrap(err, "failed to collect current state") } return nil @@ -412,11 +412,7 @@ func (c *ObservingClone) collectCurrentState(ctx context.Context) error { return err } - if err := c.countLogErrors(ctx, &c.session.state.LogErrors); err != nil { - return err - } - - return nil + return c.countLogErrors(ctx, &c.session.state.LogErrors) } func (c *ObservingClone) discoverLogFields(ctx context.Context) error { diff --git a/engine/internal/observer/stats.go b/engine/internal/observer/stats.go index aacbef55..a51c52ea 100644 --- a/engine/internal/observer/stats.go +++ b/engine/internal/observer/stats.go @@ -381,11 +381,7 @@ func initStatFile(filename string) error { return err } - if err := os.Chmod(filename, 0666); err != nil { - return err - } - - return nil + return os.Chmod(filename, 0666) } // IsAvailableArtifactType checks if artifact type is available. diff --git a/engine/internal/provision/databases/postgres/pgconfig/configuration.go b/engine/internal/provision/databases/postgres/pgconfig/configuration.go index 485a3a36..6cb5769b 100644 --- a/engine/internal/provision/databases/postgres/pgconfig/configuration.go +++ b/engine/internal/provision/databases/postgres/pgconfig/configuration.go @@ -295,11 +295,7 @@ func (m *Manager) adjustGeneralConfigs() error { // AppendGeneralConfig appends configuration parameters to a general configuration file. func (m *Manager) AppendGeneralConfig(cfg map[string]string) error { - if err := appendExtraConf(m.getConfigPath(PgConfName), cfg); err != nil { - return err - } - - return nil + return appendExtraConf(m.getConfigPath(PgConfName), cfg) } // AdjustRecoveryFiles adjusts a recovery files. @@ -332,11 +328,7 @@ func (m *Manager) ApplyRecovery(cfg map[string]string) error { return err } - if err := appendExtraConf(m.recoveryPath(), cfg); err != nil { - return err - } - - return nil + return appendExtraConf(m.recoveryPath(), cfg) } // ReadRecoveryConfig reads a recovery configuration file. @@ -363,11 +355,7 @@ func (m *Manager) RemoveRecoveryConfig() error { return err } - if err := m.removeOptionally(m.recoverySignalPath()); err != nil { - return err - } - - return nil + return m.removeOptionally(m.recoverySignalPath()) } func (m *Manager) removeOptionally(filepath string) error { @@ -384,20 +372,12 @@ func (m *Manager) removeOptionally(filepath string) error { // ApplyPgControl applies significant configuration parameters extracted by the pg_control tool. func (m *Manager) ApplyPgControl(pgControl map[string]string) error { // TODO (akartasov): add a label check to skip an already initialized pg_control config. - if err := m.rewriteConfig(m.getConfigPath(pgControlName), pgControl); err != nil { - return err - } - - return nil + return m.rewriteConfig(m.getConfigPath(pgControlName), pgControl) } // ApplySync applies configuration parameters for sync instance. func (m *Manager) ApplySync(cfg map[string]string) error { - if err := m.rewriteConfig(m.getConfigPath(syncConfigName), cfg); err != nil { - return err - } - - return nil + return m.rewriteConfig(m.getConfigPath(syncConfigName), cfg) } // TruncateSyncConfig truncates a sync configuration file. @@ -407,11 +387,7 @@ func (m *Manager) TruncateSyncConfig() error { // ApplyPromotion applies promotion configuration parameters. func (m *Manager) ApplyPromotion(cfg map[string]string) error { - if err := m.rewriteConfig(m.getConfigPath(promotionConfigName), cfg); err != nil { - return err - } - - return nil + return m.rewriteConfig(m.getConfigPath(promotionConfigName), cfg) } // TruncatePromotionConfig truncates a promotion configuration file. @@ -421,20 +397,12 @@ func (m *Manager) TruncatePromotionConfig() error { // ApplySnapshot applies snapshot configuration parameters. func (m *Manager) ApplySnapshot(cfg map[string]string) error { - if err := m.rewriteConfig(m.getConfigPath(snapshotConfigName), cfg); err != nil { - return err - } - - return nil + return m.rewriteConfig(m.getConfigPath(snapshotConfigName), cfg) } // ApplyUserConfig applies user-defined configuration. func (m *Manager) ApplyUserConfig(cfg map[string]string) error { - if err := m.rewriteConfig(m.getConfigPath(userConfigName), cfg); err != nil { - return err - } - - return nil + return m.rewriteConfig(m.getConfigPath(userConfigName), cfg) } // getConfigPath builds a path of the Database Lab config file. diff --git a/engine/internal/provision/docker/docker.go b/engine/internal/provision/docker/docker.go index 36a7de98..e8c5bf5f 100644 --- a/engine/internal/provision/docker/docker.go +++ b/engine/internal/provision/docker/docker.go @@ -186,11 +186,7 @@ func createSocketCloneDir(socketCloneDir string) error { return err } - if err := os.Chmod(socketCloneDir, 0777); err != nil { - return err - } - - return nil + return os.Chmod(socketCloneDir, 0777) } // StopContainer stops specified container. diff --git a/engine/internal/provision/mode_local_test.go b/engine/internal/provision/mode_local_test.go index 6adb87c5..2f86cb46 100644 --- a/engine/internal/provision/mode_local_test.go +++ b/engine/internal/provision/mode_local_test.go @@ -63,11 +63,11 @@ type mockFSManager struct { cloneList []string } -func (m mockFSManager) CreateClone(name, snapshotID string) error { +func (m mockFSManager) CreateClone(_, _ string) error { return nil } -func (m mockFSManager) DestroyClone(name string) error { +func (m mockFSManager) DestroyClone(_ string) error { return nil } @@ -75,15 +75,15 @@ func (m mockFSManager) ListClonesNames() ([]string, error) { return m.cloneList, nil } -func (m mockFSManager) CreateSnapshot(poolSuffix, dataStateAt string) (snapshotName string, err error) { +func (m mockFSManager) CreateSnapshot(_, _ string) (snapshotName string, err error) { return "", nil } -func (m mockFSManager) DestroySnapshot(snapshotName string) (err error) { +func (m mockFSManager) DestroySnapshot(_ string) (err error) { return nil } -func (m mockFSManager) CleanupSnapshots(retentionLimit int) ([]string, error) { +func (m mockFSManager) CleanupSnapshots(_ int) ([]string, error) { return nil, nil } @@ -94,7 +94,7 @@ func (m mockFSManager) SnapshotList() []resources.Snapshot { func (m mockFSManager) RefreshSnapshotList() { } -func (m mockFSManager) GetSessionState(name string) (*resources.SessionState, error) { +func (m mockFSManager) GetSessionState(_ string) (*resources.SessionState, error) { return nil, nil } diff --git a/engine/internal/provision/thinclones/lvm/lvmanager.go b/engine/internal/provision/thinclones/lvm/lvmanager.go index 06eebe35..4579190f 100644 --- a/engine/internal/provision/thinclones/lvm/lvmanager.go +++ b/engine/internal/provision/thinclones/lvm/lvmanager.go @@ -234,14 +234,14 @@ func (m *LVManager) GetRepo() (*models.Repo, error) { } // SetDSA sets value of DataStateAt to snapshot. -func (m *LVManager) SetDSA(dsa, snapshotName string) error { +func (m *LVManager) SetDSA(_, _ string) error { log.Msg("SetDSA is not supported for LVM. Skip the operation") return nil } // SetMessage sets commit message to snapshot. -func (m *LVManager) SetMessage(message, snapshotName string) error { +func (m *LVManager) SetMessage(_, _ string) error { log.Msg("SetMessage is not supported for LVM. Skip the operation") return nil diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go index 38cdf797..286e2100 100644 --- a/engine/internal/provision/thinclones/zfs/branching.go +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -342,11 +342,7 @@ func (m *Manager) SetRelation(parent, snapshotName string) error { return err } - if err := m.addChild(parent, snapshotName); err != nil { - return err - } - - return nil + return m.addChild(parent, snapshotName); } // DeleteChildProp deletes child from snapshot property. diff --git a/engine/internal/retrieval/dbmarker/dbmarker.go b/engine/internal/retrieval/dbmarker/dbmarker.go index b567b58e..4d6e3b97 100644 --- a/engine/internal/retrieval/dbmarker/dbmarker.go +++ b/engine/internal/retrieval/dbmarker/dbmarker.go @@ -118,11 +118,7 @@ func (m *Marker) SaveConfig(cfg *Config) error { return err } - if err := os.WriteFile(m.buildFileName(configFilename), configData, 0600); err != nil { - return err - } - - return nil + return os.WriteFile(m.buildFileName(configFilename), configData, 0600) } // buildFileName builds a DBMarker filename. @@ -291,7 +287,7 @@ func (m *Marker) SaveSnapshotRef(branch, snapshotID string) error { h.Ref = buildSnapshotRef(snapshotID) if err := m.writeBranchHead(h, branch); err != nil { - return err + return fmt.Errorf("cannot write branch head: %w", err) } return nil diff --git a/engine/internal/retrieval/engine/postgres/physical/custom.go b/engine/internal/retrieval/engine/postgres/physical/custom.go index edc940b0..ea600d90 100644 --- a/engine/internal/retrieval/engine/postgres/physical/custom.go +++ b/engine/internal/retrieval/engine/postgres/physical/custom.go @@ -50,6 +50,6 @@ func (c *custom) GetRecoveryConfig(pgVersion float64) map[string]string { } // Init initialize custom recovery tool to work in provided container. -func (c *custom) Init(ctx context.Context, containerID string) error { +func (c *custom) Init(_ context.Context, _ string) error { return nil } diff --git a/engine/internal/retrieval/engine/postgres/physical/pgbackrest.go b/engine/internal/retrieval/engine/postgres/physical/pgbackrest.go index a47db505..5e18a5b9 100644 --- a/engine/internal/retrieval/engine/postgres/physical/pgbackrest.go +++ b/engine/internal/retrieval/engine/postgres/physical/pgbackrest.go @@ -57,6 +57,6 @@ func (p *pgbackrest) GetRecoveryConfig(pgVersion float64) map[string]string { } // Init initialize pgbackrest tool. -func (p *pgbackrest) Init(ctx context.Context, containerID string) error { +func (p *pgbackrest) Init(_ context.Context, _ string) error { return nil } diff --git a/engine/internal/retrieval/engine/postgres/snapshot/physical.go b/engine/internal/retrieval/engine/postgres/snapshot/physical.go index 32bb2061..be744fbe 100644 --- a/engine/internal/retrieval/engine/postgres/snapshot/physical.go +++ b/engine/internal/retrieval/engine/postgres/snapshot/physical.go @@ -210,11 +210,7 @@ func (p *PhysicalInitial) validateConfig() error { strings.Join(notSupportedSysctls, ", ")) } - if err := p.validateScheduler(); err != nil { - return err - } - - return nil + return p.validateScheduler() } func (p *PhysicalInitial) hasSchedulingOptions() bool { diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 076ae59b..39779d21 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -9,6 +9,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/provision/pool" "gitlab.com/postgres-ai/database-lab/v3/internal/srv/api" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" @@ -154,6 +155,11 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { branch := models.Branch{Name: createRequest.BranchName} + s.webhookCh <- webhooks.BasicEvent{ + EventType: webhooks.BranchCreateEvent, + EntityID: branch.Name, + } + if err := api.WriteJSON(w, http.StatusOK, branch); err != nil { api.SendError(w, r, err) return @@ -415,6 +421,11 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } + s.webhookCh <- webhooks.BasicEvent{ + EventType: webhooks.BranchDeleteEvent, + EntityID: deleteRequest.BranchName, + } + if err := api.WriteJSON(w, http.StatusOK, models.Response{ Status: models.ResponseOK, Message: "Deleted branch", diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index d85c34a5..1d5e28ed 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -18,6 +18,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/activity" "gitlab.com/postgres-ai/database-lab/v3/internal/srv/api" "gitlab.com/postgres-ai/database-lab/v3/internal/telemetry" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" "gitlab.com/postgres-ai/database-lab/v3/pkg/client/platform" "gitlab.com/postgres-ai/database-lab/v3/pkg/config/global" @@ -162,6 +163,11 @@ func (s *Server) createSnapshot(w http.ResponseWriter, r *http.Request) { latestSnapshot := snapshotList[0] + s.webhookCh <- webhooks.BasicEvent{ + EventType: webhooks.SnapshotCreateEvent, + EntityID: latestSnapshot.ID, + } + if err := api.WriteJSON(w, http.StatusOK, latestSnapshot); err != nil { api.SendError(w, r, err) return @@ -216,6 +222,11 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { if err := s.Cloning.ReloadSnapshots(); err != nil { log.Dbg("Failed to reload snapshots", err.Error()) } + + s.webhookCh <- webhooks.BasicEvent{ + EventType: webhooks.SnapshotDeleteEvent, + EntityID: destroyRequest.SnapshotID, + } } func (s *Server) createSnapshotClone(w http.ResponseWriter, r *http.Request) { diff --git a/engine/internal/srv/server.go b/engine/internal/srv/server.go index 5a047fb7..a85c4cb8 100644 --- a/engine/internal/srv/server.go +++ b/engine/internal/srv/server.go @@ -32,6 +32,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/srv/ws" "gitlab.com/postgres-ai/database-lab/v3/internal/telemetry" "gitlab.com/postgres-ai/database-lab/v3/internal/validator" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/config/global" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" @@ -59,6 +60,7 @@ type Server struct { startedAt *models.LocalTime filtering *log.Filtering reloadFn func(server *Server) error + webhookCh chan webhooks.EventTyper } // WSService defines a service to manage web-sockets. @@ -73,7 +75,8 @@ func NewServer(cfg *srvCfg.Config, globalCfg *global.Config, engineProps *global dockerClient *client.Client, cloning *cloning.Base, provisioner *provision.Provisioner, retrievalSvc *retrieval.Retrieval, platform *platform.Service, billingSvc *billing.Billing, observer *observer.Observer, pm *pool.Manager, tm *telemetry.Agent, tokenKeeper *ws.TokenKeeper, - filtering *log.Filtering, uiManager *embeddedui.UIManager, reloadConfigFn func(server *Server) error) *Server { + filtering *log.Filtering, uiManager *embeddedui.UIManager, reloadConfigFn func(server *Server) error, + webhookCh chan webhooks.EventTyper) *Server { server := &Server{ Config: cfg, Global: globalCfg, @@ -95,6 +98,7 @@ func NewServer(cfg *srvCfg.Config, globalCfg *global.Config, engineProps *global filtering: filtering, startedAt: &models.LocalTime{Time: time.Now().Truncate(time.Second)}, reloadFn: reloadConfigFn, + webhookCh: webhookCh, } return server @@ -183,7 +187,6 @@ func attachAPI(r *mux.Router) error { // Reload reloads server configuration. func (s *Server) Reload(cfg srvCfg.Config) { *s.Config = cfg - s.initLogRegExp() } // InitHandlers initializes handler functions of the HTTP server. @@ -231,7 +234,7 @@ func (s *Server) InitHandlers() { r.HandleFunc("/instance/logs", authMW.WebSocketsMW(s.wsService.tokenKeeper, s.instanceLogs)) // Health check. - r.HandleFunc("/healthz", s.healthCheck).Methods(http.MethodGet) + r.HandleFunc("/healthz", s.healthCheck).Methods(http.MethodGet, http.MethodPost) // Show Swagger UI on index page. if err := attachAPI(r); err != nil { @@ -275,7 +278,3 @@ func (s *Server) Uptime() float64 { func reportLaunching(cfg *srvCfg.Config) { log.Msg(fmt.Sprintf("API server started listening on %s:%d.", cfg.Host, cfg.Port)) } - -func (s *Server) initLogRegExp() { - s.filtering.ReloadLogRegExp([]string{s.Config.VerificationToken, s.Platform.AccessToken(), s.Platform.OrgKey()}) -} diff --git a/engine/internal/srv/ws_test.go b/engine/internal/srv/ws_test.go index a6fd1132..77e078a8 100644 --- a/engine/internal/srv/ws_test.go +++ b/engine/internal/srv/ws_test.go @@ -21,7 +21,8 @@ func TestLogLineFiltering(t *testing.T) { Platform: pl, filtering: log.GetFilter(), } - s.initLogRegExp() + + s.filtering.ReloadLogRegExp([]string{"secretToken"}) testCases := []struct { input []byte @@ -75,6 +76,10 @@ func TestLogLineFiltering(t *testing.T) { input: []byte(`AWS_ACCESS_KEY_ID:password`), output: []byte(`AWS_********`), }, + { + input: []byte(`secret: "secret_token"`), + output: []byte(`********`), + }, } for _, tc := range testCases { diff --git a/engine/internal/webhooks/events.go b/engine/internal/webhooks/events.go new file mode 100644 index 00000000..bf5e8f1e --- /dev/null +++ b/engine/internal/webhooks/events.go @@ -0,0 +1,48 @@ +package webhooks + +const ( + // CloneCreatedEvent defines the clone create event type. + CloneCreatedEvent = "clone_create" + // CloneResetEvent defines the clone reset event type. + CloneResetEvent = "clone_reset" + // CloneDeleteEvent defines the clone delete event type. + CloneDeleteEvent = "clone_delete" + + // SnapshotCreateEvent defines the snapshot create event type. + SnapshotCreateEvent = "snapshot_create" + + // SnapshotDeleteEvent defines the snapshot delete event type. + SnapshotDeleteEvent = "snapshot_delete" + + // BranchCreateEvent defines the branch create event type. + BranchCreateEvent = "branch_create" + + // BranchDeleteEvent defines the branch delete event type. + BranchDeleteEvent = "branch_delete" +) + +// EventTyper unifies webhook events. +type EventTyper interface { + GetType() string +} + +// BasicEvent defines payload of basic webhook event. +type BasicEvent struct { + EventType string `json:"event_type"` + EntityID string `json:"entity_id"` +} + +// GetType returns type of the event. +func (e BasicEvent) GetType() string { + return e.EventType +} + +// CloneEvent defines clone webhook events payload. +type CloneEvent struct { + BasicEvent + Host string `json:"host,omitempty"` + Port uint `json:"port,omitempty"` + Username string `json:"username,omitempty"` + DBName string `json:"dbname,omitempty"` + ContainerName string `json:"container_name,omitempty"` +} diff --git a/engine/internal/webhooks/webhooks.go b/engine/internal/webhooks/webhooks.go new file mode 100644 index 00000000..308d8fe3 --- /dev/null +++ b/engine/internal/webhooks/webhooks.go @@ -0,0 +1,149 @@ +// Package webhooks configures the webhooks that will be called by the DBLab Engine when an event occurs. +package webhooks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" +) + +const ( + // DLEWebhookTokenHeader defines the HTTP header name to send secret with the webhook request. + DLEWebhookTokenHeader = "DBLab-Webhook-Token" +) + +// Config defines webhooks configuration. +type Config struct { + Hooks []Hook `yaml:"hooks"` +} + +// Hook defines structure of the webhook configuration. +type Hook struct { + URL string `yaml:"url"` + Secret string `yaml:"secret"` + Trigger []string `yaml:"trigger"` +} + +// Service listens events and performs webhooks requests. +type Service struct { + client *http.Client + hooksRegistry map[string][]Hook + eventCh <-chan EventTyper +} + +// NewService creates a new Webhook Service. +func NewService(cfg *Config, eventCh <-chan EventTyper) *Service { + whs := &Service{ + client: &http.Client{ + Transport: &http.Transport{}, + }, + hooksRegistry: make(map[string][]Hook), + eventCh: eventCh, + } + + whs.Reload(cfg) + + return whs +} + +// Reload reloads Webhook Service configuration. +func (s *Service) Reload(cfg *Config) { + s.hooksRegistry = make(map[string][]Hook) + + for _, hook := range cfg.Hooks { + if err := validateURL(hook.URL); err != nil { + log.Msg("Skip webhook processing:", err) + continue + } + + for _, event := range hook.Trigger { + s.hooksRegistry[event] = append(s.hooksRegistry[event], hook) + } + } + + log.Dbg("Registered webhooks", s.hooksRegistry) +} + +func validateURL(hookURL string) error { + parsedURL, err := url.ParseRequestURI(hookURL) + if err != nil { + return fmt.Errorf("URL %q is invalid: %w", hookURL, err) + } + + if parsedURL.Scheme == "" { + return fmt.Errorf("no scheme found in %q", hookURL) + } + + if parsedURL.Host == "" { + return fmt.Errorf("no host found in %q", hookURL) + } + + return nil +} + +// Run starts webhook listener. +func (s *Service) Run(ctx context.Context) { + for whEvent := range s.eventCh { + hooks, ok := s.hooksRegistry[whEvent.GetType()] + if !ok { + log.Dbg("Skipped unknown hook: ", whEvent.GetType()) + + continue + } + + log.Dbg("Trigger event:", whEvent) + + for _, hook := range hooks { + go s.triggerWebhook(ctx, hook, whEvent) + } + } +} + +func (s *Service) triggerWebhook(ctx context.Context, hook Hook, whEvent EventTyper) { + log.Msg("Webhook request: ", hook.URL) + + resp, err := s.makeRequest(ctx, hook, whEvent) + + if err != nil { + log.Err("Webhook error:", err) + return + } + + log.Dbg("Webhook status code: ", resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Err("Webhook error:", err) + return + } + + log.Dbg("Webhook response: ", string(body)) +} + +func (s *Service) makeRequest(ctx context.Context, hook Hook, whEvent EventTyper) (*http.Response, error) { + payload, err := json.Marshal(whEvent) + if err != nil { + return nil, err + } + + log.Dbg("Webhook payload: ", string(payload)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, hook.URL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + if hook.Secret != "" { + req.Header.Add(DLEWebhookTokenHeader, hook.Secret) + } + + req.Header.Set("Content-Type", "application/json") + + return s.client.Do(req) +} diff --git a/engine/pkg/config/config.go b/engine/pkg/config/config.go index 747873f3..92be33fc 100644 --- a/engine/pkg/config/config.go +++ b/engine/pkg/config/config.go @@ -15,6 +15,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/provision/pool" retConfig "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/config" srvCfg "gitlab.com/postgres-ai/database-lab/v3/internal/srv/config" + "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/config/global" ) @@ -35,4 +36,5 @@ type Config struct { PoolManager pool.Config `yaml:"poolManager"` EmbeddedUI embeddedui.Config `yaml:"embeddedUI"` Diagnostic diagnostic.Config `yaml:"diagnostic"` + Webhooks webhooks.Config `yaml:"webhooks"` } diff --git a/engine/pkg/log/filtering.go b/engine/pkg/log/filtering.go index c5fef4eb..c294aefb 100644 --- a/engine/pkg/log/filtering.go +++ b/engine/pkg/log/filtering.go @@ -39,6 +39,7 @@ func (f *Filtering) ReloadLogRegExp(secretStings []string) { "accessToken:\\s?(\\S+)", "orgKey:\\s?(\\S+)", "ACCESS_KEY(_ID)?:\\s?(\\S+)", + "secret:\\s?(\\S+)", } for _, secret := range secretStings { diff --git a/engine/scripts/cli_install.sh b/engine/scripts/cli_install.sh index 3a00644e..ee46abfb 100644 --- a/engine/scripts/cli_install.sh +++ b/engine/scripts/cli_install.sh @@ -2,7 +2,11 @@ ################################################ # Welcome to DBLab 🖖 # This script downloads DBLab CLI (`dblab`). -# 🌠 Contribute to DBLab: https://dblab.dev +# +# To install it on macOS/Linux/Windows: +# curl -sSL dblab.sh | bash +# +# ⭐️ Contribute to DBLab: https://dblab.dev # 📚 DBLab Docs: https://docs.dblab.dev # 💻 CLI reference: https://cli-docs.dblab.dev/ # 👨‍💻 API reference: https://api.dblab.dev diff --git a/ui/cspell.json b/ui/cspell.json index 7e1f73e4..129bcff7 100644 --- a/ui/cspell.json +++ b/ui/cspell.json @@ -138,6 +138,43 @@ "Formik", "healthz", "SAST", - "rehype" + "rehype", + "selfassigned_instance_id", + "Paulo", + "Aviv", + "northamerica", + "Montréal", + "southamerica", + "eastasia", + "japaneast", + "southeastasia", + "australiaeast", + "australiasoutheast", + "westcentral", + "northeurope", + "uksouth", + "ukwest", + "westeurope", + "canadacentral", + "canadaeast", + "centralus", + "eastus", + "northcentralus", + "southcentralus", + "westcentralus", + "westus", + "brazilsouth", + "Hetzner", + "hetzner", + "japanwest", + "vcpu", + "Createdb", + "BYOM", + "vcpus", + "postgresai", + "nvme", + "HCLOUD", + "gserviceaccount", + "pgrst" ] } diff --git a/ui/packages/ce/src/App/Menu/StickyTopBar/index.tsx b/ui/packages/ce/src/App/Menu/StickyTopBar/index.tsx index 22dee556..bb41b055 100644 --- a/ui/packages/ce/src/App/Menu/StickyTopBar/index.tsx +++ b/ui/packages/ce/src/App/Menu/StickyTopBar/index.tsx @@ -93,14 +93,14 @@ export const StickyTopBar = () => { activateBilling() .then((res) => { setIsLoading(false) - if (res.response?.billing_active) { + if (res.response?.billing_active || res.response?.billingActive) { handleReset() setSnackbarState({ isOpen: true, message: 'All DLE SE features are now active.', type: 'success', }) - } else { + } else if (res.error?.message) { setSnackbarState({ isOpen: true, message: capitalizeFirstLetter(res?.error?.message), @@ -123,10 +123,10 @@ export const StickyTopBar = () => { message: 'No active payment methods are found for your organization on the Postgres.ai Platform; please, visit the', }) - } else if (!res.response?.recognized_org) { + } else if (!res.error?.message && !res.response?.recognized_org) { setState({ type: 'missingOrgKey', - message: capitalizeFirstLetter(res.error.message), + message: capitalizeFirstLetter(res?.error?.message), }) } }) diff --git a/ui/packages/ce/src/api/engine/getEngine.ts b/ui/packages/ce/src/api/engine/getEngine.ts index 070a3b3b..267c46c1 100644 --- a/ui/packages/ce/src/api/engine/getEngine.ts +++ b/ui/packages/ce/src/api/engine/getEngine.ts @@ -1,5 +1,8 @@ +import { + EngineDto, + formatEngineDto, +} from '@postgres.ai/shared/types/api/endpoints/getEngine' import { request } from 'helpers/request' -import { EngineDto, formatEngineDto } from 'types/api/entities/engine' export const getEngine = async () => { const response = await request('/healthz') diff --git a/ui/packages/ce/src/api/instances/getInstance.ts b/ui/packages/ce/src/api/instances/getInstance.ts index 453151ac..f1b92fd0 100644 --- a/ui/packages/ce/src/api/instances/getInstance.ts +++ b/ui/packages/ce/src/api/instances/getInstance.ts @@ -6,7 +6,7 @@ */ import { GetInstance } from '@postgres.ai/shared/types/api/endpoints/getInstance' -import { formatInstanceDto } from '@postgres.ai/shared/types/api/entities/instance' +import { formatInstanceDto, InstanceDto } from '@postgres.ai/shared/types/api/entities/instance' import { InstanceStateDto } from '@postgres.ai/shared/types/api/entities/instanceState' import { request } from 'helpers/request' @@ -24,7 +24,7 @@ export const getInstance: GetInstance = async () => { : null return { - response: responseDto ? formatInstanceDto(responseDto) : null, + response: responseDto ? formatInstanceDto(responseDto as InstanceDto) : null, error: response.ok ? null : response, } } diff --git a/ui/packages/ce/src/stores/app.ts b/ui/packages/ce/src/stores/app.ts index 8d85d857..32e21277 100644 --- a/ui/packages/ce/src/stores/app.ts +++ b/ui/packages/ce/src/stores/app.ts @@ -1,10 +1,10 @@ import { makeAutoObservable } from 'mobx' import { getEngine } from 'api/engine/getEngine' -import { Engine } from 'types/api/entities/engine' +import { EngineType } from '@postgres.ai/shared/types/api/endpoints/getEngine' type EngineProp = { - data: Engine | null | undefined + data: EngineType | null | undefined isLoading: boolean } diff --git a/ui/packages/ce/src/types/api/entities/engine.ts b/ui/packages/ce/src/types/api/entities/engine.ts deleted file mode 100644 index d7710a6e..00000000 --- a/ui/packages/ce/src/types/api/entities/engine.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type EngineDto = { - version: string - edition?: string -} - -export const formatEngineDto = (dto: EngineDto) => dto - -export type Engine = ReturnType diff --git a/ui/packages/platform/package.json b/ui/packages/platform/package.json index f2c170d6..1f820e6b 100644 --- a/ui/packages/platform/package.json +++ b/ui/packages/platform/package.json @@ -19,6 +19,8 @@ "@postgres.ai/ce": "link:../ce", "@postgres.ai/platform": "link:./", "@postgres.ai/shared": "link:../shared", + "@sentry/react": "^6.11.0", + "@sentry/tracing": "^6.11.0", "@stripe/react-stripe-js": "^1.1.2", "@stripe/stripe-js": "^1.9.0", "@types/d3": "^7.4.0", @@ -29,6 +31,7 @@ "@types/react-dom": "^17.0.3", "@types/react-router": "^5.1.17", "@types/react-router-dom": "^5.1.7", + "@types/react-syntax-highlighter": "^15.5.6", "bootstrap": "^4.3.1", "byte-size": "^7.0.1", "classnames": "^2.3.1", diff --git a/ui/packages/platform/public/images/ansible.svg b/ui/packages/platform/public/images/ansible.svg new file mode 100644 index 00000000..7f0480fb --- /dev/null +++ b/ui/packages/platform/public/images/ansible.svg @@ -0,0 +1,2 @@ + +Ansible icon \ No newline at end of file diff --git a/ui/packages/platform/public/images/docker.svg b/ui/packages/platform/public/images/docker.svg new file mode 100644 index 00000000..2dd944c7 --- /dev/null +++ b/ui/packages/platform/public/images/docker.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/packages/platform/public/images/globe.svg b/ui/packages/platform/public/images/globe.svg new file mode 100644 index 00000000..f2f0671c --- /dev/null +++ b/ui/packages/platform/public/images/globe.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/ui/packages/platform/public/images/paymentMethods/amex.png b/ui/packages/platform/public/images/paymentMethods/amex.png new file mode 100644 index 0000000000000000000000000000000000000000..366f6e2bb944bde077c23510861fa6089bad6ba9 GIT binary patch literal 6726 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 literal 0 HcmV?d00001 diff --git a/ui/packages/platform/public/images/paymentMethods/unionpay.png b/ui/packages/platform/public/images/paymentMethods/unionpay.png new file mode 100644 index 0000000000000000000000000000000000000000..e9205908e16bbf2ac093972f6bfe0b10f027e37e GIT binary patch literal 32719 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

cUB%z8`mLl`x@0@+Max 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

OVH@&n(Dek@%@)=+xW>v7_O@j%z9ZjH-4Z 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

K{UseFn?P54(h){MtvFua87r3@X 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 literal 0 HcmV?d00001 diff --git a/ui/packages/platform/public/images/paymentMethods/visa.png b/ui/packages/platform/public/images/paymentMethods/visa.png new file mode 100644 index 0000000000000000000000000000000000000000..7d21c22cd32cf522c50aae92686353e2f6150656 GIT binary patch literal 5167 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 literal 0 HcmV?d00001 diff --git a/ui/packages/platform/public/images/service-providers/digitalocean.png b/ui/packages/platform/public/images/service-providers/digitalocean.png new file mode 100644 index 0000000000000000000000000000000000000000..3df53ee4e7c2b53f00fd7a264d5d63128857c6ff GIT binary patch literal 6712 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 O0000@&=w00009a7bBm000XU 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 - + + + diff --git a/ui/packages/platform/src/api/api.js b/ui/packages/platform/src/api/api.js index d75ae7b9..71b99e2b 100644 --- a/ui/packages/platform/src/api/api.js +++ b/ui/packages/platform/src/api/api.js @@ -519,6 +519,7 @@ class Api { Authorization: 'Bearer ' + token, } let params = { + url: instanceData.url, instance_id: Number(instanceData.instanceId), project_name: instanceData.project, project_label: instanceData.projectLabel, diff --git a/ui/packages/platform/src/api/billing/getPaymentMethods.ts b/ui/packages/platform/src/api/billing/getPaymentMethods.ts new file mode 100644 index 00000000..51191867 --- /dev/null +++ b/ui/packages/platform/src/api/billing/getPaymentMethods.ts @@ -0,0 +1,18 @@ +import { request } from 'helpers/request' + +export const getPaymentMethods = async (orgId: number) => { + const response = await request(`/rpc/billing_payment_methods`, { + headers: { + Accept: 'application/vnd.pgrst.object+json', + }, + method: 'POST', + body: JSON.stringify({ + org_id: orgId, + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/billing/getSubscription.ts b/ui/packages/platform/src/api/billing/getSubscription.ts new file mode 100644 index 00000000..2dc4cbf8 --- /dev/null +++ b/ui/packages/platform/src/api/billing/getSubscription.ts @@ -0,0 +1,18 @@ +import { request } from 'helpers/request' + +export const getSubscription = async (orgId: number) => { + const response = await request(`/rpc/billing_subscriptions`, { + headers: { + Accept: 'application/vnd.pgrst.object+json', + }, + method: 'POST', + body: JSON.stringify({ + org_id: orgId, + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/billing/startBillingSession.ts b/ui/packages/platform/src/api/billing/startBillingSession.ts new file mode 100644 index 00000000..fcfcd81e --- /dev/null +++ b/ui/packages/platform/src/api/billing/startBillingSession.ts @@ -0,0 +1,19 @@ +import { request } from 'helpers/request' + +export const startBillingSession = async (orgId: number, returnUrl: string) => { + const response = await request(`/rpc/billing_portal_start_session`, { + headers: { + Accept: 'application/vnd.pgrst.object+json', + }, + method: 'POST', + body: JSON.stringify({ + org_id: orgId, + return_url: returnUrl, + }), + }) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : await response.json(), + } +} diff --git a/ui/packages/platform/src/api/cloud/getCloudImages.ts b/ui/packages/platform/src/api/cloud/getCloudImages.ts new file mode 100644 index 00000000..c105cfc5 --- /dev/null +++ b/ui/packages/platform/src/api/cloud/getCloudImages.ts @@ -0,0 +1,38 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export interface CloudImage { + api_name: string + os_name: string + os_version: string + arch: string + cloud_provider: string + region: string + native_os_image: string + release: string +} + +export interface CloudImagesRequest { + os_name: string + os_version: string + arch: string + cloud_provider: string + region: string +} + +export const getCloudImages = async (req: CloudImagesRequest) => { + const response = await request( + `/cloud_os_images?os_name=eq.${req.os_name}&os_version=eq.${req.os_version}&arch=eq.${req.arch}&cloud_provider=eq.${req.cloud_provider}®ion=eq.${req.region}`, + ) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/cloud/getCloudInstances.ts b/ui/packages/platform/src/api/cloud/getCloudInstances.ts new file mode 100644 index 00000000..7467dbea --- /dev/null +++ b/ui/packages/platform/src/api/cloud/getCloudInstances.ts @@ -0,0 +1,41 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export interface CloudInstance { + api_name: string + arch: string + vcpus: number + ram_gib: number + dle_se_price_hourly: number + cloud_provider: string + only_in_regions: boolean | null + native_name: string + native_vcpus: number + native_ram_gib: number + native_reference_price_hourly: number + native_reference_price_currency: string + native_reference_price_region: string + native_reference_price_revision_date: string +} + +export interface CloudInstancesRequest { + provider: string + region: string +} + +export const getCloudInstances = async (req: CloudInstancesRequest) => { + const response = await request( + `/cloud_instances?cloud_provider=eq.${req.provider}&only_in_regions&only_in_regions=ov.{all,${req.region}}&order=vcpus.asc,ram_gib.asc`, + ) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/cloud/getCloudProviders.ts b/ui/packages/platform/src/api/cloud/getCloudProviders.ts new file mode 100644 index 00000000..a46983dd --- /dev/null +++ b/ui/packages/platform/src/api/cloud/getCloudProviders.ts @@ -0,0 +1,22 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export interface CloudProvider { + api_name: string + label: string +} + +export const getCloudProviders = async () => { + const response = await request('/cloud_providers') + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/cloud/getCloudRegions.ts b/ui/packages/platform/src/api/cloud/getCloudRegions.ts new file mode 100644 index 00000000..80b0ccfc --- /dev/null +++ b/ui/packages/platform/src/api/cloud/getCloudRegions.ts @@ -0,0 +1,25 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export interface CloudRegion { + api_name: string + cloud_provider: string + label: string + native_code: string + world_part: string +} + +export const getCloudRegions = async (req: string) => { + const response = await request(`/cloud_regions?cloud_provider=eq.${req}`) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/cloud/getCloudVolumes.ts b/ui/packages/platform/src/api/cloud/getCloudVolumes.ts new file mode 100644 index 00000000..68c2d4c3 --- /dev/null +++ b/ui/packages/platform/src/api/cloud/getCloudVolumes.ts @@ -0,0 +1,30 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { request } from 'helpers/request' + +export interface CloudVolumes { + api_name: string + type: string + cloud_provider: string + native_name: string + native_reference_price_per_1000gib_per_hour: number + native_reference_price_currency: string + native_reference_price_region: string + native_reference_price_revision_date: string +} + +export const getCloudVolumes = async (cloud_provider: string) => { + const response = await request( + `/cloud_volumes?cloud_provider=eq.${cloud_provider}`, + ) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/cloud/getOrgKeys.ts b/ui/packages/platform/src/api/cloud/getOrgKeys.ts new file mode 100644 index 00000000..804befc5 --- /dev/null +++ b/ui/packages/platform/src/api/cloud/getOrgKeys.ts @@ -0,0 +1,10 @@ +import { request } from 'helpers/request' + +export const getOrgKeys = async (org_id: number) => { + const response = await request(`/org_keys?org_id=eq.${org_id}`) + + return { + response: response.ok ? await response.json() : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/configs/getConfig.ts b/ui/packages/platform/src/api/configs/getConfig.ts new file mode 100644 index 00000000..22ed8fb4 --- /dev/null +++ b/ui/packages/platform/src/api/configs/getConfig.ts @@ -0,0 +1,11 @@ +import { formatConfig } from '@postgres.ai/shared/types/api/entities/config' +import { request } from 'helpers/request' + +export const getConfig = async () => { + const response = await request('/admin/config') + + return { + response: response.ok ? formatConfig(await response.json()) : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/configs/getFullConfig.ts b/ui/packages/platform/src/api/configs/getFullConfig.ts new file mode 100644 index 00000000..abf0338d --- /dev/null +++ b/ui/packages/platform/src/api/configs/getFullConfig.ts @@ -0,0 +1,14 @@ +import { request } from 'helpers/request' +export const getFullConfig = async () => { + const response = await request('/admin/config.yaml') + .then((res) => res.blob()) + .then((blob) => blob.text()) + .then((yamlAsString) => { + return yamlAsString + }) + + return { + response: response ? response : null, + error: response && null, + } +} diff --git a/ui/packages/platform/src/api/configs/testDbSource.ts b/ui/packages/platform/src/api/configs/testDbSource.ts new file mode 100644 index 00000000..c79925bc --- /dev/null +++ b/ui/packages/platform/src/api/configs/testDbSource.ts @@ -0,0 +1,21 @@ +import { dbSource } from '@postgres.ai/shared/types/api/entities/dbSource' +import { request } from 'helpers/request' + +export const testDbSource = async (req: dbSource) => { + const response = await request('/admin/test-db-source', { + method: 'POST', + body: JSON.stringify({ + host: req.host, + port: req.port.toString(), + dbname: req.dbname, + username: req.username, + password: req.password, + db_list: req.db_list + }), + }) + + return { + response: response.ok ? await response.json(): null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/configs/updateConfig.ts b/ui/packages/platform/src/api/configs/updateConfig.ts new file mode 100644 index 00000000..f5cf267d --- /dev/null +++ b/ui/packages/platform/src/api/configs/updateConfig.ts @@ -0,0 +1,62 @@ +import { + postUniqueCustomOptions, + postUniqueDatabases, +} from '@postgres.ai/shared/pages/Instance/Configuration/utils' +import { Config } from '@postgres.ai/shared/types/api/entities/config' +import { request } from 'helpers/request' + +export const updateConfig = async (req: Config) => { + const response = await request('/admin/config', { + method: 'POST', + body: JSON.stringify({ + global: { + debug: req.debug, + }, + databaseContainer: { + dockerImage: req.dockerImage, + }, + databaseConfigs: { + configs: { + shared_buffers: req.sharedBuffers, + shared_preload_libraries: req.sharedPreloadLibraries, + }, + }, + retrieval: { + refresh: { + timetable: req.timetable, + }, + spec: { + logicalDump: { + options: { + databases: postUniqueDatabases(req.databases), + customOptions: postUniqueCustomOptions(req.pgDumpCustomOptions), + parallelJobs: req.dumpParallelJobs, + source: { + connection: { + dbname: req.dbname, + host: req.host, + port: req.port, + username: req.username, + password: req.password, + }, + }, + }, + }, + logicalRestore: { + options: { + customOptions: postUniqueCustomOptions( + req.pgRestoreCustomOptions, + ), + parallelJobs: req.restoreParallelJobs, + }, + }, + }, + }, + }), + }) + + return { + response: response.ok ? response : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/engine/getEngine.ts b/ui/packages/platform/src/api/engine/getEngine.ts new file mode 100644 index 00000000..59680981 --- /dev/null +++ b/ui/packages/platform/src/api/engine/getEngine.ts @@ -0,0 +1,16 @@ +import { request } from 'helpers/request' +import { + EngineDto, + formatEngineDto, +} from '@postgres.ai/shared/types/api/endpoints/getEngine' + +export const getEngine = async () => { + const response = await request('/healthz') + + return { + response: response.ok + ? formatEngineDto((await response.json()) as EngineDto) + : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/engine/getWSToken.ts b/ui/packages/platform/src/api/engine/getWSToken.ts new file mode 100644 index 00000000..3a7872ce --- /dev/null +++ b/ui/packages/platform/src/api/engine/getWSToken.ts @@ -0,0 +1,14 @@ +import { request } from 'helpers/request' +import { formatWSTokenDto, WSTokenDTO } from '@postgres.ai/shared/types/api/entities/wsToken' +import { GetWSToken } from "@postgres.ai/shared/types/api/endpoints/getWSToken"; + +export const getWSToken: GetWSToken = async (req ) => { + const response = await request('/admin/ws-auth') + + return { + response: response.ok + ? formatWSTokenDto((await response.json()) as WSTokenDTO) + : null, + error: response.ok ? null : response, + } +} diff --git a/ui/packages/platform/src/api/engine/initWS.ts b/ui/packages/platform/src/api/engine/initWS.ts new file mode 100644 index 00000000..74fd0164 --- /dev/null +++ b/ui/packages/platform/src/api/engine/initWS.ts @@ -0,0 +1,10 @@ +import { InitWS } from "@postgres.ai/shared/types/api/endpoints/initWS"; +import { WS_URL_PREFIX } from 'config/env' + +export const initWS: InitWS = (path: string, token: string): WebSocket => { + let url = new URL(WS_URL_PREFIX + path, window.location.href); + url.protocol = url.protocol.replace('http', 'ws'); + const wsAddr = url.href + '?token=' + token; + + return new WebSocket(wsAddr) +} diff --git a/ui/packages/platform/src/components/AccessTokens/AccessTokens.tsx b/ui/packages/platform/src/components/AccessTokens/AccessTokens.tsx index 1d15cb0a..b8cca319 100644 --- a/ui/packages/platform/src/components/AccessTokens/AccessTokens.tsx +++ b/ui/packages/platform/src/components/AccessTokens/AccessTokens.tsx @@ -33,6 +33,7 @@ import ConsolePageTitle from '../ConsolePageTitle' import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' import { DisplayTokenWrapper } from 'components/DisplayToken/DisplayTokenWrapper' import { AccessTokensProps } from 'components/AccessTokens/AccessTokensWrapper' +import { FilteredTableMessage } from 'components/AccessTokens/FilteredTableMessage/FilteredTableMessage' interface AccessTokensWithStylesProps extends AccessTokensProps { classes: ClassesType @@ -49,6 +50,7 @@ interface UserTokenData { } interface AccessTokensState { + filterValue: string data: { auth: { token: string @@ -75,6 +77,7 @@ class AccessTokens extends Component< AccessTokensState > { state = { + filterValue: '', data: { auth: { token: '', @@ -247,6 +250,10 @@ class AccessTokens extends Component< } } + filterTokensInputHandler = (event: React.ChangeEvent) => { + this.setState({ filterValue: event.target.value }) + } + render() { const { classes, orgPermissions, orgId } = this.props const data = @@ -255,7 +262,27 @@ class AccessTokens extends Component< this.state && this.state.data && this.state.data.tokenRequest ? this.state.data.tokenRequest : null - const pageTitle = + const filteredTokens = data?.data?.filter( + (token: UserTokenData) => + token.name + ?.toLowerCase() + .indexOf((this.state.filterValue || '')?.toLowerCase()) !== -1, + ) + + const pageTitle = ( + 0 + ? { + filterValue: this.state.filterValue, + filterHandler: this.filterTokensInputHandler, + placeholder: 'Search access tokens by name', + } + : null + } + /> + ) let tokenDisplay = null if ( @@ -437,7 +464,7 @@ class AccessTokens extends Component<

Active access tokens

- {data.data.length > 0 ? ( + {filteredTokens && filteredTokens.length > 0 ? ( @@ -452,9 +479,9 @@ class AccessTokens extends Component< - {data.data && - data.data.length > 0 && - data.data.map((t: UserTokenData) => { + {filteredTokens && + filteredTokens.length > 0 && + filteredTokens.map((t: UserTokenData) => { return ( @@ -496,7 +523,16 @@ class AccessTokens extends Component<
) : ( - 'This user has no active access tokens' + + this.setState({ + filterValue: '', + }) + } + /> )}
diff --git a/ui/packages/platform/src/components/AccessTokens/AccessTokensWrapper.tsx b/ui/packages/platform/src/components/AccessTokens/AccessTokensWrapper.tsx index bb8eb027..237e8e47 100644 --- a/ui/packages/platform/src/components/AccessTokens/AccessTokensWrapper.tsx +++ b/ui/packages/platform/src/components/AccessTokens/AccessTokensWrapper.tsx @@ -41,6 +41,7 @@ export const AccessTokensWrapper = (props: AccessTokensProps) => { marginTop: 15, height: '33px', marginBottom: 10, + maxWidth: 'max-content', }, revokeButton: { paddingRight: 5, diff --git a/ui/packages/platform/src/components/AccessTokens/FilteredTableMessage/FilteredTableMessage.tsx b/ui/packages/platform/src/components/AccessTokens/FilteredTableMessage/FilteredTableMessage.tsx new file mode 100644 index 00000000..c10af14b --- /dev/null +++ b/ui/packages/platform/src/components/AccessTokens/FilteredTableMessage/FilteredTableMessage.tsx @@ -0,0 +1,39 @@ +import { Button } from '@material-ui/core' +import { AuditLogData } from 'components/Audit/Audit' + +export const FilteredTableMessage = ({ + filterValue, + filteredItems, + clearFilter, + emptyState, +}: { + filterValue: string + filteredItems: string[] | never[] | AuditLogData[] | undefined | null + clearFilter: () => void + emptyState: string | JSX.Element +}) => { + if (filterValue && filteredItems?.length === 0) { + return ( + <> +
+ No results found for {filterValue} +
+ + + ) + } + + return <>{emptyState} +} diff --git a/ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDbLabInstanceFormWrapper.tsx b/ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDbLabInstanceFormWrapper.tsx new file mode 100644 index 00000000..b38ac7fe --- /dev/null +++ b/ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDbLabInstanceFormWrapper.tsx @@ -0,0 +1,63 @@ +import { makeStyles } from '@material-ui/core' +import { styles } from '@postgres.ai/shared/styles/styles' +import AddDbLabInstanceForm from 'components/AddDbLabInstanceFormWrapper/AddDblabInstanceForm' +import { RouteComponentProps } from 'react-router' + +export interface DbLabInstanceFormProps { + edit?: boolean + orgId: number + project: string | undefined + history: RouteComponentProps['history'] + orgPermissions: { + dblabInstanceCreate?: boolean + } +} + +export const AddDbLabInstanceFormWrapper = (props: DbLabInstanceFormProps) => { + const useStyles = makeStyles( + { + textField: { + ...styles.inputField, + maxWidth: 400, + }, + errorMessage: { + marginTop: 10, + color: 'red', + }, + fieldBlock: { + width: '100%', + }, + urlOkIcon: { + color: 'green', + }, + urlOk: { display: 'flex', gap: 5, alignItems: 'center', color: 'green' }, + urlTextMargin: { + marginTop: 10, + }, + urlFailIcon: { + color: 'red', + }, + urlFail: { + display: 'flex', + gap: 5, + alignItems: 'center', + color: 'red', + }, + warning: { + color: '#801200', + fontSize: '0.9em', + }, + warningIcon: { + color: '#801200', + fontSize: '1.2em', + position: 'relative', + marginBottom: -3, + }, + }, + { index: 1 }, + ) + + const classes = useStyles() + + return +} diff --git a/ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDblabInstanceForm.tsx b/ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDblabInstanceForm.tsx new file mode 100644 index 00000000..1a436cbd --- /dev/null +++ b/ui/packages/platform/src/components/AddDbLabInstanceFormWrapper/AddDblabInstanceForm.tsx @@ -0,0 +1,622 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { Component } from 'react' +import { + Checkbox, + Grid, + Button, + TextField, + FormControlLabel, +} from '@material-ui/core' +import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline' +import BlockIcon from '@material-ui/icons/Block' +import WarningIcon from '@material-ui/icons/Warning' + +import { styles } from '@postgres.ai/shared/styles/styles' +import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' +import { + ClassesType, + ProjectProps, +} from '@postgres.ai/platform/src/components/types' + +import Actions from '../../actions/actions' +import ConsolePageTitle from './../ConsolePageTitle' +import Store from '../../stores/store' +import Urls from '../../utils/urls' +import { generateToken, isHttps } from '../../utils/utils' +import { WarningWrapper } from 'components/Warning/WarningWrapper' +import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' +import { DbLabInstanceFormProps } from 'components/DbLabInstanceForm/DbLabInstanceFormWrapper' + +interface DbLabInstanceFormWithStylesProps extends DbLabInstanceFormProps { + classes: ClassesType +} + +interface DbLabInstanceFormState { + data: { + auth: { + token: string | null + } | null + projects: ProjectProps + newDbLabInstance: { + isUpdating: boolean + isChecking: boolean + isChecked: boolean + isCheckProcessed: boolean + errorMessage: string + error: boolean + isProcessed: boolean + data: { + id: string + } + } | null + dbLabInstances: { + isProcessing: boolean + error: boolean + isProcessed: boolean + data: unknown + } | null + } | null + url: string + token: string | null + useTunnel: boolean + instanceID: string + project: string + project_label: string + errorFields: string[] + sshServerUrl: string +} + +class DbLabInstanceForm extends Component< + DbLabInstanceFormWithStylesProps, + DbLabInstanceFormState +> { + state = { + url: 'https://', + token: null, + useTunnel: false, + instanceID: '', + project: this.props.project ? this.props.project : '', + project_label: '', + errorFields: [''], + sshServerUrl: '', + data: { + auth: { + token: null, + }, + projects: { + data: [], + error: false, + isProcessing: false, + isProcessed: false, + }, + newDbLabInstance: { + isUpdating: false, + isChecked: false, + isChecking: false, + isCheckProcessed: false, + isProcessed: false, + error: false, + errorMessage: '', + data: { + id: '', + }, + }, + dbLabInstances: { + isProcessing: false, + error: false, + isProcessed: false, + data: '', + }, + }, + } + + unsubscribe: () => void + componentDidMount() { + const that = this + const { orgId } = this.props + const url = window.location.href.split('/') + const instanceID = url[url.length - 1] + + this.unsubscribe = Store.listen(function () { + that.setState({ data: this.data, instanceID: instanceID }) + + const auth = this.data && this.data.auth ? this.data.auth : null + const projects = + this.data && this.data.projects ? this.data.projects : null + const dbLabInstances = + this.data && this.data.dbLabInstances ? this.data.dbLabInstances : null + + if (dbLabInstances.data) { + that.setState({ + project_label: + that.state.project_label || + dbLabInstances.data[instanceID]?.project_label_or_name, + token: dbLabInstances.data[instanceID]?.verify_token, + useTunnel: + that.state.useTunnel || dbLabInstances.data[instanceID]?.use_tunnel, + url: that.state.url || dbLabInstances.data[instanceID]?.url, + sshServerUrl: + that.state.sshServerUrl || + dbLabInstances.data[instanceID]?.ssh_server_url, + }) + } + + if ( + auth && + auth.token && + !projects.isProcessing && + !projects.error && + !projects.isProcessed + ) { + Actions.getProjects(auth.token, orgId) + } + + if ( + auth && + auth.token && + !dbLabInstances?.isProcessing && + !dbLabInstances?.error && + !dbLabInstances?.isProcessed + ) { + Actions.getDbLabInstances(auth.token, orgId, 0) + } + }) + + Actions.refresh() + } + + componentWillUnmount() { + this.unsubscribe() + Actions.resetNewDbLabInstance() + } + + buttonHandler = () => { + const orgId = this.props.orgId ? this.props.orgId : null + const auth = + this.state.data && this.state.data.auth ? this.state.data.auth : null + const data = this.state.data ? this.state.data.newDbLabInstance : null + const errorFields = [] + + if (!this.state.url) { + errorFields.push('url') + } + + if (!this.state.project) { + errorFields.push('project') + } + + if (!this.state.token) { + errorFields.push('token') + } + + if (errorFields.length > 0) { + this.setState({ errorFields: errorFields }) + return + } + + this.setState({ errorFields: [] }) + + if ( + auth && + data && + !data.isUpdating && + this.state.url && + this.state.token && + this.state.project + ) { + Actions[`${this.props.edit ? 'edit' : 'add'}DbLabInstance`](auth.token, { + orgId: orgId, + project: this.state.project, + instanceId: this.props.edit ? this.state.instanceID : null, + projectLabel: this.state.project_label, + url: this.state.url, + instanceToken: this.state.token, + useTunnel: this.state.useTunnel, + sshServerUrl: this.state.sshServerUrl, + }) + } + } + + checkUrlHandler = () => { + const auth = + this.state.data && this.state.data.auth ? this.state.data.auth : null + const data = this.state.data ? this.state.data.newDbLabInstance : null + const errorFields = [] + + if (!this.state.url) { + errorFields.push('url') + return + } + + if (auth && data && !data.isChecking && this.state.url) { + Actions.checkDbLabInstanceUrl( + auth.token, + this.state.url, + this.state.token, + this.state.useTunnel, + ) + } + } + + returnHandler = () => { + this.props.history.push(Urls.linkDbLabInstances(this.props)) + } + + processedHandler = () => { + const data = this.state.data ? this.state.data.newDbLabInstance : null + + this.props.history.push( + Urls.linkDbLabInstance(this.props, data?.data?.id as string), + ) + } + + generateTokenHandler = () => { + this.setState({ token: generateToken() }) + } + + render() { + const { classes, orgPermissions } = this.props + const data = + this.state && this.state.data ? this.state.data.newDbLabInstance : null + const projects = + this.state && this.state.data && this.state.data.projects + ? this.state.data.projects + : null + const projectsList = [] + const dbLabInstances = + this.state && this.state.data && this.state.data.dbLabInstances + ? this.state.data.dbLabInstances + : null + + if (data && data.isProcessed && !data.error) { + this.processedHandler() + Actions.resetNewDbLabInstance() + } + + const breadcrumbs = ( + + ) + + const pageTitle = ( + + ) + + const permitted = !orgPermissions || orgPermissions.dblabInstanceCreate + const disabledOnEdit = this.props.edit + const instancesLoaded = dbLabInstances && dbLabInstances.data + + if (!projects || !projects.data || !instancesLoaded) { + return ( +
+ {breadcrumbs} + + {pageTitle} + + +
+ ) + } + + if (projects.data && projects.data?.length > 0) { + projects.data.map((p: { name: string; id: number }) => { + return projectsList.push({ title: p.name, value: p.id }) + }) + } + + const isDataUpdating = data && (data.isUpdating || data.isChecking) + + return ( +
+ {breadcrumbs} + + {pageTitle} + + {!permitted && ( + + You do not have permission to {this.props.edit ? 'edit' : 'add'}{' '} + Database Lab instances. + + )} + + {!disabledOnEdit && ( + + Database Lab provisioning is currently semi-automated. +
+ First, you need to prepare a Database Lab instance on a + separate  machine. Once the instance is ready, register it + here. +
+ )} + + +
+ { + this.setState({ + project: e.target.value, + }) + Actions.resetNewDbLabInstance() + }} + margin="normal" + error={this.state.errorFields.indexOf('project') !== -1} + fullWidth + inputProps={{ + name: 'project', + id: 'project', + shrink: true, + }} + InputLabelProps={{ + shrink: true, + style: styles.inputFieldLabel, + }} + FormHelperTextProps={{ + style: styles.inputFieldHelper, + }} + /> +
+ +
+ { + this.setState({ + project_label: e.target.value, + }) + Actions.resetNewDbLabInstance() + }} + margin="normal" + error={this.state.errorFields.indexOf('project_label') !== -1} + fullWidth + inputProps={{ + name: 'project_label', + id: 'project_label', + shrink: true, + }} + InputLabelProps={{ + shrink: true, + style: styles.inputFieldLabel, + }} + FormHelperTextProps={{ + style: styles.inputFieldHelper, + }} + /> +
+ + {!disabledOnEdit && ( +
+ { + this.setState({ + token: e.target.value, + }) + Actions.resetNewDbLabInstance() + }} + margin="normal" + error={this.state.errorFields.indexOf('token') !== -1} + fullWidth + inputProps={{ + name: 'token', + id: 'token', + shrink: true, + }} + InputLabelProps={{ + shrink: true, + style: styles.inputFieldLabel, + }} + FormHelperTextProps={{ + style: styles.inputFieldHelper, + }} + /> +
+ +
+
+ )} + +
+ { + this.setState({ + url: e.target.value, + }) + Actions.resetNewDbLabInstance() + }} + margin="normal" + helperText={ + this.state.url && + !isHttps(this.state.url) && + !this.state.useTunnel ? ( + + + + The connection to the Database Lab API is not secure. Use + HTTPS. + + + ) : null + } + error={this.state.errorFields.indexOf('url') !== -1} + fullWidth + inputProps={{ + name: 'url', + id: 'url', + shrink: true, + }} + InputLabelProps={{ + shrink: true, + style: styles.inputFieldLabel, + }} + FormHelperTextProps={{ + style: styles.inputFieldHelper, + }} + /> +
+ +
+ { + this.setState({ + useTunnel: e.target.checked, + }) + Actions.resetNewDbLabInstance() + }} + id="useTunnel" + name="useTunnel" + /> + } + label="Use tunnel" + labelPlacement="end" + /> +
+ { + this.setState({ + sshServerUrl: e.target.value, + }) + Actions.resetNewDbLabInstance() + }} + margin="normal" + error={this.state.errorFields.indexOf('sshServerUrl') !== -1} + fullWidth + inputProps={{ + name: 'sshServerUrl', + id: 'sshServerUrl', + shrink: true, + }} + InputLabelProps={{ + shrink: true, + style: styles.inputFieldLabel, + }} + FormHelperTextProps={{ + style: styles.inputFieldHelper, + }} + /> +
+
+
+ + +
+ {data?.isCheckProcessed && + data?.isChecked && + (isHttps(this.state.url) || this.state.useTunnel) ? ( + + {' '} + Verified + + ) : null} + + {data?.isCheckProcessed && + data?.isChecked && + !isHttps(this.state.url) && + !this.state.useTunnel ? ( + + Verified but is + not secure + + ) : null} + + {data?.isCheckProcessed && !data?.isChecked ? ( + + Not available + + ) : null} +
+
+ +
+ +    + +
+
+ {data?.errorMessage ? data.errorMessage : null} +
+
+
+ ) + } +} + +export default DbLabInstanceForm diff --git a/ui/packages/platform/src/components/AddMemberForm/AddMemberForm.tsx b/ui/packages/platform/src/components/AddMemberForm/AddMemberForm.tsx index 914ef1ef..fbb9b953 100644 --- a/ui/packages/platform/src/components/AddMemberForm/AddMemberForm.tsx +++ b/ui/packages/platform/src/components/AddMemberForm/AddMemberForm.tsx @@ -58,7 +58,7 @@ class InviteForm extends Component { that.setState({ data: this.data }) if (this.data.inviteUser.isProcessed && !this.data.inviteUser.error) { - that.props.history.push('/' + org + '/members') + window.location.href = '/' + org + '/members' } const auth: InviteFormState['data']['auth'] = diff --git a/ui/packages/platform/src/components/Audit/Audit.tsx b/ui/packages/platform/src/components/Audit/Audit.tsx index 17c88c1f..882ba102 100644 --- a/ui/packages/platform/src/components/Audit/Audit.tsx +++ b/ui/packages/platform/src/components/Audit/Audit.tsx @@ -34,6 +34,7 @@ import { messages } from '../../assets/messages' import format from '../../utils/format' import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' import { AuditProps } from 'components/Audit/AuditWrapper' +import { FilteredTableMessage } from 'components/AccessTokens/FilteredTableMessage/FilteredTableMessage' const PAGE_SIZE = 20 const auditTitle = 'Audit log' @@ -42,7 +43,7 @@ interface AuditWithStylesProps extends AuditProps { classes: ClassesType } -interface AuditLogData { +export interface AuditLogData { id: number data_before: string data_after: string @@ -56,6 +57,7 @@ interface AuditLogData { } interface AuditState { + filterValue: string data: { auth: { token: string @@ -212,6 +214,10 @@ class Audit extends Component { return 'Changes' } + filterInputHandler = (event: React.ChangeEvent) => { + this.setState({ filterValue: event.target.value }) + } + render() { const { classes, orgPermissions, orgId } = this.props const data = this.state && this.state.data ? this.state.data.auditLog : null @@ -227,7 +233,27 @@ class Audit extends Component { /> ) - const pageTitle = + const filteredLogs = logs.filter( + (log) => + log.actor + ?.toLowerCase() + .indexOf((this.state.filterValue || '')?.toLowerCase()) !== -1, + ) + + const pageTitle = ( + 0 + ? { + filterValue: this.state.filterValue, + filterHandler: this.filterInputHandler, + placeholder: 'Search audit log', + } + : null + } + /> + ) if (orgPermissions && !orgPermissions.auditLogView) { return ( @@ -268,7 +294,7 @@ class Audit extends Component {
{breadcrumbs} {pageTitle} - {logs && logs.length > 0 ? ( + {filteredLogs && filteredLogs.length > 0 ? (
@@ -374,7 +400,16 @@ class Audit extends Component { ) : ( - 'Audit log records not found' + + this.setState({ + filterValue: '', + }) + } + /> )}
diff --git a/ui/packages/platform/src/components/Billing/Billing.tsx b/ui/packages/platform/src/components/Billing/Billing.tsx index 24854cfa..d0229bc9 100644 --- a/ui/packages/platform/src/components/Billing/Billing.tsx +++ b/ui/packages/platform/src/components/Billing/Billing.tsx @@ -5,35 +5,14 @@ *-------------------------------------------------------------------------- */ -import React, { Component } from 'react' -import { NavLink } from 'react-router-dom' +import { Component } from 'react' import { loadStripe } from '@stripe/stripe-js' import { Elements } from '@stripe/react-stripe-js' -import { - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - Paper, - ExpansionPanel, - ExpansionPanelSummary, - ExpansionPanelDetails, -} from '@material-ui/core' -import { GatewayLink } from '@postgres.ai/shared/components/GatewayLink' -import ExpandMoreIcon from '@material-ui/icons/ExpandMore' - -import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { icons } from '@postgres.ai/shared/styles/icons' import { ClassesType } from '@postgres.ai/platform/src/components/types' import ConsolePageTitle from '../ConsolePageTitle' import StripeForm from '../StripeForm' import settings from '../../utils/settings' -import format from '../../utils/format' -import Urls from '../../utils/urls' import Store from '../../stores/store' import Actions from '../../actions/actions' import Permissions from '../../utils/permissions' @@ -57,6 +36,7 @@ interface BillingState { subscriptionError: boolean subscriptionErrorMessage: string isSubscriptionProcessing: boolean + primaryPaymentMethod: string data: { unit_amount: string data_usage_estimate: string @@ -124,210 +104,6 @@ class Billing extends Component { return '0.0' } - getDataUsageTable(isPriveleged: boolean) { - const billing = - this.state && this.state.data && this.state.data.billing - ? this.state.data.billing - : null - const { orgId, classes } = this.props - let unitAmount = 0 - let tibAmount = 0 - let periodAmount = 0 - let estAmount = 0 - let startDate - let endDate - let period - - if ( - !billing || - (billing && billing.isProcessing) || - (billing && billing.orgId !== orgId) - ) { - return - } - - if (!billing) { - return null - } - - if (billing.data && billing.data.unit_amount) { - // Anatoly: Logic behind `/100` is unknown, but currently we store 0.26 in the DB - // So unitAmount will have the right value of 0.0026 per GiB*hour. - unitAmount = parseFloat(billing.data.unit_amount) / 100 - tibAmount = unitAmount * 1024 - } - - if (billing && billing.data) { - const periodDataUsage = parseFloat(billing.data.data_usage_sum) - const periodEstDataUsage = - parseFloat(billing.data.data_usage_estimate) + periodDataUsage - if (!isPriveleged && periodDataUsage) { - periodAmount = periodDataUsage * unitAmount - } - if (!isPriveleged && periodEstDataUsage) { - estAmount = periodEstDataUsage * unitAmount - } - if (billing.data.period_start) { - startDate = format.formatDate(billing.data.period_start) - } - if (billing.data.period_now) { - endDate = format.formatDate(billing.data.period_now) - } - } - - if (!startDate && !endDate) { - period = '-' - } else { - period = startDate + ' – ' + endDate - } - - return ( - <> - <> - -
-
- Current month -
- {period} -
-
-
- Month-to-date total cost   - - Total cost for the {period} interval. - - } - classes={{ tooltip: classes.toolTip }} - > - {icons.infoIcon} - -
- - ${this.toFixed(periodAmount)} - -
- -
- End-of-month total cost (forecast) -    - - The forecast for this period is a sum of the actual cost - to the date and the projected cost based on average - usage from {period}. - - } - classes={{ tooltip: classes.toolTip }} - > - {icons.infoIcon} - -
- - ${this.toFixed(estAmount)} - -
-
- This is not an invoice -
-
- - - } - aria-controls="panel1a-content" - id="panel1a-header" - className={classes.expansionPaperHeader} - > - How is billing calculated? - - -

- Billing is based on the total size of the databases running - within Database Lab. -

-

- The base cost per TiB per hour:  - ${tibAmount && this.toFixed(tibAmount)}.
- Discounts are not shown here and will be applied when the - invoice is issued. -

-

- We account only for the actual physical disk space used and - monitor this hourly with 1 GiB precision. Free disk space is - always ignored. The logical size of the database also does not - factor into our calculation. -

- - Learn more - -
-
- - -

Data usage

- {billing.data && - billing.data.data_usage && - billing.data.data_usage.length ? ( - -
- - - Database Lab instance ID - - Date  - - {icons.sortArrowUp} - - - Consumption, GiB·h - Amount, $ - Billable - - - - {billing.data.data_usage.map((d) => { - return ( - - - - {d.instance_id} - - - - {format.formatDate(d.day_date)} - - - {d.data_size_gib} - - - {!isPriveleged && d.to_invoice - ? this.toFixed(d.data_size_gib * unitAmount) - : 0} - - - {d.to_invoice ? 'Yes' : 'No'} - - - ) - })} - -
-
- ) : ( - 'Data usage metrics are not gathered yet.' - )} - - ) - } - render() { const { classes, orgId, orgData } = this.props const auth = @@ -338,7 +114,6 @@ class Billing extends Component { this.state && this.state.data && this.state.data.billing ? this.state.data.billing : null - const dataUsage = this.getDataUsageTable(orgData.is_priveleged) const breadcrumbs = ( { ) } - let subscription = null - let mode = 'new' if (orgData.is_blocked && orgData.stripe_subscription_id) { mode = 'resume' @@ -372,42 +145,11 @@ class Billing extends Component { mode = 'update' } - if (!orgData.is_priveleged) { - subscription = ( + return ( +
+ {breadcrumbs} +
- {orgData.stripe_subscription_id && ( -
- {!orgData.is_blocked ? ( - Subscription is active - ) : ( -
- {icons.warningIcon} Subscription is NOT active.  - {orgData.new_subscription - ? 'Payment processing.' - : 'Payment processing error.'} -
- )} -
- )} - - {!orgData.stripe_subscription_id && ( -
- {!orgData.is_blocked_on_creation ? ( -
- {icons.warningIcon}  Trial period is expired. Enter - payment details to activate the organization. -
- ) : ( -
- {icons.warningIcon} Enter payment details to activate the - organization. -
- )} -
- )} - -
-
{Permissions.isAdmin(orgData) && (
@@ -418,6 +160,7 @@ class Billing extends Component { )} { )}
- ) - } - - return ( -
- {breadcrumbs} - - {} - - {orgData.is_blocked && !orgData.is_priveleged && ( - - Organization is suspended. - - )} - - {!orgData.is_blocked && orgData.is_priveleged && ( - - Subscription is active till{' '} - {format.formatTimestampUtc(orgData.priveleged_until)}. - - )} - - {!orgData.is_blocked && - !orgData.is_priveleged && - orgData.stripe_subscription_id && ( - - Subscription is active. Payment details are set. - - )} - - {mode !== 'update' && subscription} - - {!this.props.short && dataUsage} -
) diff --git a/ui/packages/platform/src/components/Billing/BillingWrapper.tsx b/ui/packages/platform/src/components/Billing/BillingWrapper.tsx index 09173d1f..6c4497fe 100644 --- a/ui/packages/platform/src/components/Billing/BillingWrapper.tsx +++ b/ui/packages/platform/src/components/Billing/BillingWrapper.tsx @@ -1,7 +1,6 @@ import { makeStyles } from '@material-ui/core' import Billing from 'components/Billing/Billing' import { colors } from '@postgres.ai/shared/styles/colors' -import { styles } from '@postgres.ai/shared/styles/styles' export interface BillingProps { org: string | number @@ -9,7 +8,9 @@ export interface BillingProps { short: boolean projectId: number | string | undefined orgData: { + alias: string is_priveleged: boolean + stripe_payment_method_primary: string is_blocked: boolean new_subscription: boolean is_blocked_on_creation: boolean @@ -59,9 +60,6 @@ export const BillingWrapper = (props: BillingProps) => { flexDirection: 'column', paddingBottom: '20px', }, - billingError: { - color: colors.state.warning, - }, errorMessage: { color: colors.state.error, marginBottom: 10, @@ -69,92 +67,6 @@ export const BillingWrapper = (props: BillingProps) => { subscriptionForm: { marginBottom: 20, }, - orgStatusActive: { - color: colors.state.ok, - display: 'block', - marginBottom: 20, - }, - orgStatusBlocked: { - color: colors.state.error, - display: 'block', - marginBottom: 20, - }, - navLink: { - color: colors.secondary2.main, - '&:visited': { - color: colors.secondary2.main, - }, - }, - sortArrow: { - '& svg': { - marginBottom: -8, - }, - }, - paperSection: { - display: 'block', - width: '100%', - marginBottom: 20, - overflow: 'auto', - }, - monthColumn: { - width: 255, - float: 'left', - }, - monthInfo: { - '& strong': { - display: 'inline-block', - marginBottom: 10, - }, - }, - monthValue: { - marginBottom: '0px!important', - }, - - toolTip: { - fontSize: '12px!important', - maxWidth: '300px!important', - }, - paper: { - maxWidth: 510, - padding: 15, - marginBottom: 20, - display: 'block', - borderWidth: 1, - borderColor: colors.consoleStroke, - borderStyle: 'solid', - }, - expansionPaper: { - maxWidth: 540, - borderWidth: 1, - borderColor: colors.consoleStroke, - borderStyle: 'solid', - borderRadius: 4, - marginBottom: 30, - }, - expansionPaperHeader: { - padding: 15, - minHeight: 0, - 'justify-content': 'left', - '& div.MuiExpansionPanelSummary-content': { - margin: 0, - }, - '&.Mui-expanded': { - minHeight: '0px!important', - }, - '& .MuiExpansionPanelSummary-expandIcon': { - padding: 0, - marginRight: 0, - }, - }, - expansionPaperBody: { - padding: 15, - paddingTop: 0, - display: 'block', - marginTop: -15, - }, - bottomSpace: { - ...styles.bottomSpace, - }, }), { index: 1 }, ) diff --git a/ui/packages/platform/src/components/ConsolePageTitle.tsx b/ui/packages/platform/src/components/ConsolePageTitle.tsx index 58a32906..8ab19126 100644 --- a/ui/packages/platform/src/components/ConsolePageTitle.tsx +++ b/ui/packages/platform/src/components/ConsolePageTitle.tsx @@ -5,8 +5,13 @@ *-------------------------------------------------------------------------- */ -import { makeStyles } from '@material-ui/core' -import Tooltip from '@material-ui/core/Tooltip' +import { + makeStyles, + Tooltip, + TextField, + InputAdornment, +} from '@material-ui/core' +import SearchIcon from '@material-ui/icons/Search' import { colors } from '@postgres.ai/shared/styles/colors' import { icons } from '@postgres.ai/shared/styles/icons' @@ -17,6 +22,11 @@ interface ConsolePageTitleProps { label?: string actions?: JSX.Element[] | string[] top?: boolean + filterProps?: { + filterValue: string + filterHandler: (event: React.ChangeEvent) => void + placeholder: string + } | null } const useStyles = makeStyles( @@ -87,6 +97,7 @@ const ConsolePageTitle = ({ label, actions, top, + filterProps, }: ConsolePageTitleProps) => { const classes = useStyles() @@ -107,9 +118,28 @@ const ConsolePageTitle = ({ ) : null} {label ? {label} : null} - {actions && actions?.length > 0 ? ( + {(actions && actions?.length > 0) || filterProps ? ( - {actions.map((a, index) => { + {filterProps ? ( + + + + ), + }} + /> + ) : null} + {actions?.map((a, index) => { return ( {a} diff --git a/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx b/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx new file mode 100644 index 00000000..ad7ed330 --- /dev/null +++ b/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx @@ -0,0 +1,170 @@ +import { makeStyles } from '@material-ui/core' +import { StubContainer } from '@postgres.ai/shared/components/StubContainer' +import { icons } from '@postgres.ai/shared/styles/icons' +import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' +import { ProductCardWrapper } from 'components/ProductCard/ProductCardWrapper' +import { DashboardProps } from 'components/Dashboard/DashboardWrapper' + +import Urls from '../../utils/urls' +import { messages } from '../../assets/messages' + +const useStyles = makeStyles((theme) => ({ + stubContainerProjects: { + marginRight: '-20px', + padding: '0 40px', + + '& > div:nth-child(1), & > div:nth-child(2)': { + minHeight: '350px', + }, + + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + }, + }, + productCardProjects: { + flex: '1 1 0', + marginRight: '20px', + height: 'maxContent', + gap: 20, + maxHeight: '100%', + '& ul': { + marginBlockStart: '-10px', + paddingInlineStart: '30px', + }, + + '& svg': { + width: '206px', + height: '130px', + }, + + '&:nth-child(1) svg': { + marginBottom: '-5px', + }, + + '&:nth-child(2) svg': { + marginBottom: '-20px', + }, + + '& li': { + listStyleType: 'none', + position: 'relative', + + '&::before': { + content: '"-"', + position: 'absolute', + left: '-10px', + top: '0', + }, + }, + + [theme.breakpoints.down('sm')]: { + flex: '100%', + marginTop: '20px', + minHeight: 'auto !important', + + '&:nth-child(1) svg': { + marginBottom: 0, + }, + + '&:nth-child(2) svg': { + marginBottom: 0, + }, + }, + }, +})) + +export const CreatedDbLabCards = ({ + props, + dblabPermitted, +}: { + props: DashboardProps + dblabPermitted: boolean | undefined +}) => { + const classes = useStyles() + + const createDblabInstanceButtonHandler = (provider: string) => { + props.history.push(Urls.linkDbLabInstanceAdd(props, provider)) + } + + const CreateButton = ({ type, title }: { type: string; title: string }) => ( + createDblabInstanceButtonHandler(type)} + title={dblabPermitted ? title : messages.noPermission} + > + {type === 'create' ? 'Create' : 'Install'} + + ) + + const productData = [ + { + title: 'Create DLE in your cloud', + renderDescription: () => ( + <> +

+ Supported cloud platforms include AWS, GCP, Digital Ocean, and + Hetzner Cloud. +

+

All components are installed within your cloud account.

+

Your data remains secure and never leaves your infrastructure.

+ + ), + icon: icons.createDLEIcon, + actions: [ + { + id: 'createDblabInstanceButton', + content: ( + + ), + }, + ], + }, + { + title: 'BYOM (Bring Your Own Machine)', + renderDescription: () => ( + <> +

+ Install on your existing resources, regardless of the machine or + location. Compatible with both cloud and bare metal infrastructures. + Your data remains secure and never leaves your infrastructure. +

+

Requirements:

+
    +
  • Ubuntu 20.04 or newer
  • +
  • Internet connectivity
  • +
+ + ), + icon: icons.installDLEIcon, + actions: [ + { + id: 'createDblabInstanceButton', + content: ( + + ), + }, + ], + }, + ] + + return ( + + {productData.map((product) => ( + +
{product.renderDescription()}
+
+ ))} +
+ ) +} diff --git a/ui/packages/platform/src/components/Dashboard/Dashboard.tsx b/ui/packages/platform/src/components/Dashboard/Dashboard.tsx index 75a4e7be..46ed1899 100644 --- a/ui/packages/platform/src/components/Dashboard/Dashboard.tsx +++ b/ui/packages/platform/src/components/Dashboard/Dashboard.tsx @@ -7,6 +7,7 @@ import { Component, MouseEvent } from 'react' import { NavLink } from 'react-router-dom' +import Brightness1Icon from '@material-ui/icons/Brightness1' import { Table, TableBody, @@ -23,7 +24,6 @@ import remarkGfm from 'remark-gfm' import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' import { StubContainer } from '@postgres.ai/shared/components/StubContainer' -import { icons } from '@postgres.ai/shared/styles/icons' import { ClassesType } from '@postgres.ai/platform/src/components/types' import { ROUTES } from 'config/routes' @@ -32,21 +32,24 @@ import Actions from '../../actions/actions' import ConsolePageTitle from '../ConsolePageTitle' import { ErrorWrapper } from 'components/Error/ErrorWrapper' import { GatewayLink } from '@postgres.ai/shared/components/GatewayLink' -import { messages } from '../../assets/messages' import Store from '../../stores/store' import Urls from '../../utils/urls' import settings from '../../utils/settings' +import format from '../../utils/format' import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' import { ProductCardWrapper } from 'components/ProductCard/ProductCardWrapper' import { DashboardProps } from 'components/Dashboard/DashboardWrapper' +import { FilteredTableMessage } from 'components/AccessTokens/FilteredTableMessage/FilteredTableMessage' +import { CreatedDbLabCards } from 'components/CreateDbLabCards/CreateDbLabCards' interface DashboardWithStylesProps extends DashboardProps { classes: ClassesType } interface DashboardState { + filterValue: string data: { auth: { token: string @@ -69,6 +72,8 @@ interface DashboardState { platform_onboarding_text: string orgs: { [org: string]: { + is_blocked: boolean + created_at: string id: number alias: string name: string @@ -178,10 +183,6 @@ class Dashboard extends Component { this.props.history.push(ROUTES.CREATE_ORG.path) } - addDblabInstanceButtonHandler = () => { - this.props.history.push(Urls.linkDbLabInstanceAdd(this.props)) - } - addCheckupAgentButtonHandler = () => { this.props.history.push(Urls.linkCheckupAgentAdd(this.props)) } @@ -204,6 +205,10 @@ class Dashboard extends Component { } } + filterOrgsInputHandler = (event: React.ChangeEvent) => { + this.setState({ filterValue: event.target.value }) + } + render() { const renderProjects = this.props.onlyProjects @@ -264,90 +269,9 @@ class Dashboard extends Component { const projects = projectsData.data const dblabPermitted = this.props.orgPermissions?.dblabInstanceCreate - const checkupPermitted = this.props.orgPermissions?.checkupReportConfigure - - const addDblabInstanceButton = ( - - Add instance - - ) - - const addCheckupAgentButton = ( - - Add agent - - ) let table = ( - - -

- Clone multi-terabyte databases in seconds and use them to test your - database migrations, optimize SQL, or deploy full-size staging apps. - Start here to work with all Database Lab tools. - - Learn more - - . -

-
- -

- Automated routine checkup for your PostgreSQL databases. Configure - Checkup agent to start collecting reports ( - - Learn more - - ). -

-
-
+ ) if (projects.length > 0) { @@ -411,7 +335,7 @@ class Dashboard extends Component { onboarding = (
- +

Getting started

{ />
- -
-

Useful links

- { - const { href, target, children } = props - return ( - - {String(children)} - - ) - }, - }} - /> -
-
) @@ -486,6 +387,15 @@ class Dashboard extends Component { ? this.state.data.dashboard.profileUpdateInitAfterDemo : null + const filteredItems = + profile?.data?.orgs && + Object.keys(profile?.data?.orgs)?.filter( + (org) => + org + ?.toLowerCase() + .indexOf((this.state.filterValue || '')?.toLowerCase()) !== -1, + ) + // Show organizations. if (this.state && this.state.data.projects?.error) { return ( @@ -528,6 +438,7 @@ class Dashboard extends Component { color="primary" onClick={this.addOrgButtonHandler} id="createOrgButton" + className={classes.createOrgButton} title="" > Create new organization @@ -576,9 +487,17 @@ class Dashboard extends Component { title="Your organizations" information="Your own organizations and organizations of which you are a member" actions={pageActions} + filterProps={ + profile?.data?.orgs + ? { + filterValue: this.state.filterValue, + filterHandler: this.filterOrgsInputHandler, + placeholder: 'Search organizations by name', + } + : null + } /> - - {profile.data?.orgs && Object.keys(profile.data?.orgs).length > 0 ? ( + {profile.data?.orgs && filteredItems && filteredItems.length > 0 ? ( @@ -587,10 +506,12 @@ class Dashboard extends Component { Organization Projects count + Status + Created at - {Object.keys(profile.data?.orgs).map((index) => { + {filteredItems.map((index) => { return ( { : '0'} + + + + + {format.formatDate( + profile.data?.orgs[index].created_at, + ) || '-'} + ) })} @@ -627,7 +562,16 @@ class Dashboard extends Component {
) : ( - orgsPlaceholder + + this.setState({ + filterValue: '', + }) + } + /> )}
) diff --git a/ui/packages/platform/src/components/Dashboard/DashboardWrapper.tsx b/ui/packages/platform/src/components/Dashboard/DashboardWrapper.tsx index 224fcf17..64196490 100644 --- a/ui/packages/platform/src/components/Dashboard/DashboardWrapper.tsx +++ b/ui/packages/platform/src/components/Dashboard/DashboardWrapper.tsx @@ -6,7 +6,7 @@ import Dashboard from 'components/Dashboard/Dashboard' export interface DashboardProps { org?: string | number orgId?: number - onlyProjects: boolean + onlyProjects?: boolean history: RouteComponentProps['history'] project?: string | undefined orgPermissions?: { @@ -18,24 +18,6 @@ export interface DashboardProps { export const DashboardWrapper = (props: DashboardProps) => { const useStyles = makeStyles( (theme) => ({ - stubContainerProjects: { - marginRight: '-20px', - paddingBottom: 0, - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - marginRight: 0, - marginTop: '-20px', - }, - }, - productCardProjects: { - flex: '1 1 100%', - marginRight: '20px', - [theme.breakpoints.down('sm')]: { - flex: '0 0 auto', - marginRight: 0, - marginTop: '20px', - }, - }, orgsHeader: { position: 'relative', }, @@ -93,6 +75,60 @@ export const DashboardWrapper = (props: DashboardProps) => { paddingInlineStart: '20px', }, }, + filterOrgsInput: { + width: '100%', + + '& .MuiOutlinedInput-input': { + width: '200px', + }, + }, + createOrgButton: { + height: '37px', + }, + blockedStatus: { + color: colors.state.error, + fontSize: '1.1em', + verticalAlign: 'middle', + '& svg': { + marginTop: '-3px', + }, + }, + validStatus: { + color: colors.state.ok, + fontSize: '1.1em', + verticalAlign: 'middle', + '& svg': { + marginTop: '-3px', + }, + }, + flexContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + gap: 40, + marginTop: '20px', + + '& > div': { + maxWidth: '300px', + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: '1px solid #e0e0e0', + padding: '20px', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '15px', + transition: 'border 0.3s ease-in-out', + + '&:hover': { + border: '1px solid #FF6212', + }, + }, + }, }), { index: 1 }, ) diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance.tsx new file mode 100644 index 00000000..b2170c4b --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance.tsx @@ -0,0 +1,257 @@ +import { Box } from '@mui/material' +import { useEffect, useState } from 'react' +import { makeStyles, Button } from '@material-ui/core' + +import { Spinner } from '@postgres.ai/shared/components/Spinner' +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' + +import { getOrgKeys } from 'api/cloud/getOrgKeys' +import { getCloudImages } from 'api/cloud/getCloudImages' + +import { + getGcpAccountContents, + getPlaybookCommandWithoutDocker, +} from 'components/DbLabInstanceForm/utils' +import { + cloneRepositoryCommand, + getAnsibleInstallationCommand, +} from 'components/DbLabInstanceInstallForm/utils' +import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' + +import { initialState } from '../reducer' + +export const formStyles = makeStyles({ + marginTop: { + marginTop: '20px', + }, + marginBottom: { + marginBottom: '20px', + display: 'block', + }, + spinner: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + }, + title: { + fontWeight: 600, + fontSize: '15px', + margin: '10px 0', + }, + code: { + backgroundColor: '#eee', + borderRadius: '3px', + padding: '0 3px', + marginLeft: '0.25em', + }, + ul: { + paddingInlineStart: '30px', + }, + important: { + fontWeight: 600, + margin: 0, + }, +}) + +export const InstanceDocumentation = ({ + fistStep, + firsStepDescription, + documentation, + secondStep, + snippetContent, + classes, +}: { + fistStep: string + firsStepDescription?: React.ReactNode + documentation: string + secondStep: React.ReactNode + snippetContent: string + classes: ReturnType +}) => ( + <> +

1. {fistStep}

+ {firsStepDescription &&

{firsStepDescription}

} +

+ Documentation:{' '} + + {documentation} + +

+

2. Export {secondStep}

+ + +) + +export const AnsibleInstance = ({ + state, + orgId, + goBack, + goBackToForm, + formStep, + setFormStep, +}: { + state: typeof initialState + orgId: number + goBack: () => void + goBackToForm: () => void + formStep: string + setFormStep: (step: string) => void +}) => { + const classes = formStyles() + const [orgKey, setOrgKey] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [cloudImages, setCloudImages] = useState([]) + const [orgKeyError, setOrgKeyError] = useState(false) + + useEffect(() => { + setIsLoading(true) + getOrgKeys(orgId).then((data) => { + if (data.error !== null || !Array.isArray(data.response)) { + setIsLoading(false) + setOrgKeyError(true) + } else { + setOrgKeyError(false) + setOrgKey(data.response[0].value) + } + }) + getCloudImages({ + os_name: 'Ubuntu', + os_version: '22.04%20LTS', + arch: state.instanceType.arch, + cloud_provider: state.provider, + region: state.provider === 'aws' ? state.location.native_code : 'all', + }).then((data) => { + setIsLoading(false) + setOrgKeyError(false) + setCloudImages(data.response) + }) + }, [ + orgId, + state.instanceType.arch, + state.location.native_code, + state.provider, + ]) + + const AnsibleInstallation = () => ( + <> +

+ 3. Install Ansible on your working machine (which could easily be a + laptop) +

+

example of installing the latest version:

+ + + for more instructions on installing ansible, see{' '} + + here + + . + +

4. Clone the dle-se-ansible repository

+ +

5. Install requirements

+ + + ) + + return ( + + {isLoading ? ( + + + + ) : ( + <> + {orgKeyError ? ( + + ) : state.provider === 'digitalocean' ? ( + + DO_API_TOKEN + + } + snippetContent="export DO_API_TOKEN=XXXXXX" + classes={classes} + /> + ) : state.provider === 'hetzner' ? ( + HCLOUD_API_TOKEN + } + snippetContent="export HCLOUD_API_TOKEN=XXXXXX" + classes={classes} + /> + ) : state.provider === 'aws' ? ( + + AWS_ACCESS_KEY_ID and + AWS_SECRET_ACCESS_KEY + + } + snippetContent={`export AWS_ACCESS_KEY_ID=XXXXXX\nexport AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXX`} + classes={classes} + /> + ) : state.provider === 'gcp' ? ( + + GCP_SERVICE_ACCOUNT_CONTENTS + + } + snippetContent={getGcpAccountContents()} + classes={classes} + /> + ) : null} + +

+ 6. Run ansible playbook to create server and install DLE SE +

+ +

+ 7. After the code snippet runs successfully, follow the directions + displayed in the resulting output to start using DLE AUI/API/CLI. +

+ + + + + + )} +
+ ) +} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/DockerInstance.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/DockerInstance.tsx new file mode 100644 index 00000000..b21b30b3 --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/DockerInstance.tsx @@ -0,0 +1,168 @@ +import { Box } from '@mui/material' +import { useEffect, useState } from 'react' +import { Button } from '@material-ui/core' + +import { Spinner } from '@postgres.ai/shared/components/Spinner' +import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' +import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' + +import { getOrgKeys } from 'api/cloud/getOrgKeys' +import { getCloudImages } from 'api/cloud/getCloudImages' + +import { + getGcpAccountContents, + getPlaybookCommand, +} from 'components/DbLabInstanceForm/utils' +import { + InstanceDocumentation, + formStyles, +} from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' +import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' + +import { initialState } from '../reducer' + +export const DockerInstance = ({ + state, + orgId, + goBack, + goBackToForm, + formStep, + setFormStep, +}: { + state: typeof initialState + orgId: number + goBack: () => void + goBackToForm: () => void + formStep: string + setFormStep: (step: string) => void +}) => { + const classes = formStyles() + const [orgKey, setOrgKey] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [cloudImages, setCloudImages] = useState([]) + const [orgKeyError, setOrgKeyError] = useState(false) + + useEffect(() => { + setIsLoading(true) + getOrgKeys(orgId).then((data) => { + if (data.error !== null || !Array.isArray(data.response)) { + setIsLoading(false) + setOrgKeyError(true) + } else { + setOrgKeyError(false) + setOrgKey(data.response[0].value) + } + }) + getCloudImages({ + os_name: 'Ubuntu', + os_version: '22.04%20LTS', + arch: state.instanceType.arch, + cloud_provider: state.provider, + region: state.provider === 'aws' ? state.location.native_code : 'all', + }).then((data) => { + setIsLoading(false) + setOrgKeyError(false) + setCloudImages(data.response) + }) + }, [ + orgId, + state.instanceType.arch, + state.location.native_code, + state.provider, + ]) + + return ( + + {isLoading ? ( + + + + ) : ( + <> + {orgKeyError ? ( + + ) : state.provider === 'digitalocean' ? ( + DO_API_TOKEN} + snippetContent="export DO_API_TOKEN=XXXXXX" + classes={classes} + /> + ) : state.provider === 'hetzner' ? ( + HCLOUD_API_TOKEN + } + snippetContent="export HCLOUD_API_TOKEN=XXXXXX" + classes={classes} + /> + ) : state.provider === 'aws' ? ( + + AWS_ACCESS_KEY_ID and + AWS_SECRET_ACCESS_KEY + + } + snippetContent={`export AWS_ACCESS_KEY_ID=XXXXXX\nexport AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXX`} + classes={classes} + /> + ) : state.provider === 'gcp' ? ( + <> + + Create and save the JSON key for the service account and + point to them using{' '} + + GCP_SERVICE_ACCOUNT_CONTENTS + {' '} + variable. + + } + documentation="https://developers.google.com/identity/protocols/oauth2/service-account#creatinganaccount" + secondStep={ + + GCP_SERVICE_ACCOUNT_CONTENTS + + } + snippetContent={getGcpAccountContents()} + classes={classes} + /> + + ) : null} +

+ 3. Run ansible playbook to create server and install DLE SE +

+ +

+ 4. After the code snippet runs successfully, follow the directions + displayed in the resulting output to start using DLE UI/API/CLI. +

+ + + + + + )} +
+ ) +} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation.tsx new file mode 100644 index 00000000..b38521d3 --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation.tsx @@ -0,0 +1,101 @@ +import { makeStyles } from '@material-ui/core' + +const useStyles = makeStyles((theme) => ({ + snippetContainer: { + width: '100%', + height: '100%', + maxWidth: '800px', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + gap: 40, + + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + }, + + '& p:first-child': { + marginTop: '0', + }, + }, + navigation: { + display: 'flex', + flexDirection: 'column', + marginLeft: '-20px', + flex: '0 0 220px', + + [theme.breakpoints.down('sm')]: { + flex: 'auto', + }, + + '& span': { + display: 'flex', + alignItems: 'center', + gap: 10, + cursor: 'pointer', + padding: '8px 14px 8px 20px', + borderBottom: '1px solid #CCD7DA', + transition: 'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', + + '&:hover': { + backgroundColor: '#F5F8FA', + }, + }, + }, + form: { + flex: '1 1 0', + overflow: 'auto', + + [theme.breakpoints.down('sm')]: { + flex: 'auto', + }, + }, + active: { + backgroundColor: '#F5F8FA', + borderRight: '4px solid #FF6212', + }, +})) + +export const InstanceFormCreation = ({ + formStep, + setFormStep, + children, +}: { + formStep: string + setFormStep: (step: string) => void + children: React.ReactNode +}) => { + const classes = useStyles() + + return ( +
+
+ setFormStep('docker')} + > + {'docker + Docker + + setFormStep('ansible')} + > + {'ansible + Ansible + +
+
{children}
+
+ ) +} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceForm.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceForm.tsx index 687fe3c4..3f442fc0 100644 --- a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceForm.tsx +++ b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceForm.tsx @@ -5,549 +5,621 @@ *-------------------------------------------------------------------------- */ -import { Component } from 'react' +import cn from 'classnames' +import { useEffect, useReducer } from 'react' +import { Box } from '@mui/material' import { - Checkbox, - Grid, - Button, + Tab, + Tabs, TextField, - FormControlLabel, + Button, + MenuItem, + InputAdornment, } from '@material-ui/core' -import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline' -import BlockIcon from '@material-ui/icons/Block' -import WarningIcon from '@material-ui/icons/Warning' - -import { styles } from '@postgres.ai/shared/styles/styles' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { - ClassesType, - ProjectProps, -} from '@postgres.ai/platform/src/components/types' -import Actions from '../../actions/actions' import ConsolePageTitle from './../ConsolePageTitle' -import Store from '../../stores/store' -import Urls from '../../utils/urls' -import { generateToken, isHttps } from '../../utils/utils' +import { TabPanel } from 'pages/JoeSessionCommand/TabPanel' import { WarningWrapper } from 'components/Warning/WarningWrapper' +import { ClassesType } from '@postgres.ai/platform/src/components/types' import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' import { DbLabInstanceFormProps } from 'components/DbLabInstanceForm/DbLabInstanceFormWrapper' +import { StorageSlider } from 'components/DbLabInstanceForm/DbLabInstanceFormSlider' +import { CloudProvider, getCloudProviders } from 'api/cloud/getCloudProviders' +import { CloudVolumes, getCloudVolumes } from 'api/cloud/getCloudVolumes' +import { initialState, reducer } from 'components/DbLabInstanceForm/reducer' +import { DbLabInstanceFormSidebar } from 'components/DbLabInstanceForm/DbLabInstanceFormSidebar' +import { Spinner } from '@postgres.ai/shared/components/Spinner' +import { StubSpinner } from '@postgres.ai/shared/components/StubSpinnerFlex' +import { Select } from '@postgres.ai/shared/components/Select' + +import { generateToken } from 'utils/utils' +import urls from 'utils/urls' + +import { AnsibleInstance } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' +import { CloudRegion, getCloudRegions } from 'api/cloud/getCloudRegions' +import { CloudInstance, getCloudInstances } from 'api/cloud/getCloudInstances' +import { DockerInstance } from './DbLabFormSteps/DockerInstance' +import { availableTags } from 'components/DbLabInstanceForm/utils' interface DbLabInstanceFormWithStylesProps extends DbLabInstanceFormProps { classes: ClassesType } -interface DbLabInstanceFormState { - data: { - auth: { - token: string | null - } | null - projects: ProjectProps - newDbLabInstance: { - isUpdating: boolean - isChecking: boolean - isChecked: boolean - isCheckProcessed: boolean - errorMessage: string - error: boolean - isProcessed: boolean - data: { - id: string +const DbLabInstanceForm = (props: DbLabInstanceFormWithStylesProps) => { + const { classes, orgPermissions } = props + const [state, dispatch] = useReducer(reducer, initialState) + + const permitted = !orgPermissions || orgPermissions.dblabInstanceCreate + + useEffect(() => { + const fetchCloudDetails = async () => { + dispatch({ type: 'set_is_loading', isLoading: true }) + try { + const cloudRegions = await getCloudRegions(initialState.provider) + const cloudVolumes = await getCloudVolumes(initialState.provider) + const serviceProviders = await getCloudProviders() + const ssdCloudVolumes = cloudVolumes.response.filter( + (volume: CloudVolumes) => volume.api_name === initialState?.api_name, + )[0] + + dispatch({ + type: 'set_initial_state', + cloudRegions: cloudRegions.response, + volumes: cloudVolumes.response, + volumeType: `${ssdCloudVolumes.api_name} (${ssdCloudVolumes.cloud_provider}: ${ssdCloudVolumes.native_name})`, + volumeCurrency: ssdCloudVolumes.native_reference_price_currency, + volumePricePerHour: + ssdCloudVolumes.native_reference_price_per_1000gib_per_hour, + volumePrice: + (initialState.storage * + ssdCloudVolumes.native_reference_price_per_1000gib_per_hour) / + 1000, + serviceProviders: serviceProviders.response, + isLoading: false, + }) + } catch (error) { + console.log(error) } - } | null - dbLabInstances: { - isProcessing: boolean - error: boolean - isProcessed: boolean - data: unknown - } | null - } | null - url: string - token: string | null - useTunnel: boolean - project: string - errorFields: string[] - sshServerUrl: string -} - -class DbLabInstanceForm extends Component< - DbLabInstanceFormWithStylesProps, - DbLabInstanceFormState -> { - state = { - url: 'https://', - token: null, - useTunnel: false, - project: this.props.project ? this.props.project : '', - errorFields: [''], - sshServerUrl: '', - data: { - auth: { - token: null, - }, - projects: { - data: [], - error: false, - isProcessing: false, - isProcessed: false, - }, - newDbLabInstance: { - isUpdating: false, - isChecked: false, - isChecking: false, - isCheckProcessed: false, - isProcessed: false, - error: false, - errorMessage: '', - data: { - id: '', - }, - }, - dbLabInstances: { - isProcessing: false, - error: false, - isProcessed: false, - data: '', - }, - }, - } - - unsubscribe: () => void - componentDidMount() { - const that = this - const { orgId } = this.props - - this.unsubscribe = Store.listen(function () { - that.setState({ data: this.data }) - const auth = this.data && this.data.auth ? this.data.auth : null - const projects = - this.data && this.data.projects ? this.data.projects : null - const dbLabInstances = - this.data && this.data.dbLabInstances ? this.data.dbLabInstances : null - - if ( - auth && - auth.token && - !projects.isProcessing && - !projects.error && - !projects.isProcessed - ) { - Actions.getProjects(auth.token, orgId) - } - - if ( - auth && - auth.token && - !dbLabInstances?.isProcessing && - !dbLabInstances?.error && - !dbLabInstances?.isProcessed - ) { - Actions.getDbLabInstances(auth.token, orgId, 0) - } - }) - - Actions.refresh() - } - - componentWillUnmount() { - this.unsubscribe() - Actions.resetNewDbLabInstance() - } - - buttonHandler = () => { - const orgId = this.props.orgId ? this.props.orgId : null - const auth = - this.state.data && this.state.data.auth ? this.state.data.auth : null - const data = this.state.data ? this.state.data.newDbLabInstance : null - const errorFields = [] - - if (!this.state.url) { - errorFields.push('url') } - - if (!this.state.project) { - errorFields.push('project') - } - - if (!this.state.token) { - errorFields.push('token') + fetchCloudDetails() + }, []) + + useEffect(() => { + const fetchUpdatedDetails = async () => { + try { + const cloudRegions = await getCloudRegions(state.provider) + const cloudVolumes = await getCloudVolumes(state.provider) + const ssdCloudVolumes = cloudVolumes.response.filter( + (volume: CloudVolumes) => volume.api_name === initialState?.api_name, + )[0] + dispatch({ + type: 'update_initial_state', + volumes: cloudVolumes.response, + volumeType: `${ssdCloudVolumes.api_name} (${ssdCloudVolumes.cloud_provider}: ${ssdCloudVolumes.native_name})`, + volumeCurrency: ssdCloudVolumes.native_reference_price_currency, + volumePricePerHour: + ssdCloudVolumes.native_reference_price_per_1000gib_per_hour, + volumePrice: + (initialState.storage * + ssdCloudVolumes.native_reference_price_per_1000gib_per_hour) / + 1000, + cloudRegions: cloudRegions.response, + }) + } catch (error) { + console.log(error) + } } - - if (errorFields.length > 0) { - this.setState({ errorFields: errorFields }) - return + fetchUpdatedDetails() + }, [state.api_name, state.provider]) + + useEffect(() => { + const fetchUpdatedDetails = async () => { + dispatch({ type: 'set_is_reloading', isReloading: true }) + try { + const cloudInstances = await getCloudInstances({ + provider: state.provider, + region: state.location.native_code, + }) + + dispatch({ + type: 'update_instance_type', + cloudInstances: cloudInstances.response, + instanceType: cloudInstances.response[0], + isReloading: false, + }) + } catch (error) { + console.log(error) + } } + fetchUpdatedDetails() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.location.native_code]) + + const uniqueRegionsByProvider = state.cloudRegions + .map((region: CloudRegion) => region.world_part) + .filter( + (value: string, index: number, self: string) => + self.indexOf(value) === index, + ) - this.setState({ errorFields: [] }) - - if ( - auth && - data && - !data.isUpdating && - this.state.url && - this.state.token && - this.state.project - ) { - Actions.addDbLabInstance(auth.token, { - orgId: orgId, - project: this.state.project, - url: this.state.url, - instanceToken: this.state.token, - useTunnel: this.state.useTunnel, - sshServerUrl: this.state.sshServerUrl, - }) - } + const filteredRegions = state.cloudRegions.filter( + (region: CloudRegion) => region.world_part === state.region, + ) + + const pageTitle = + const breadcrumbs = ( + + ) + + const handleGenerateToken = () => { + dispatch({ + type: 'change_verification_token', + verificationToken: generateToken(), + }) } - checkUrlHandler = () => { - const auth = - this.state.data && this.state.data.auth ? this.state.data.auth : null - const data = this.state.data ? this.state.data.newDbLabInstance : null - const errorFields = [] - - if (!this.state.url) { - errorFields.push('url') - return - } - - if (auth && data && !data.isChecking && this.state.url) { - Actions.checkDbLabInstanceUrl( - auth.token, - this.state.url, - this.state.token, - this.state.useTunnel, - ) - } + const handleChangeVolume = ( + event: React.ChangeEvent, + ) => { + const volumeApiName = event.target.value.split(' ')[0] + const selectedVolume = state.volumes.filter( + (volume: CloudVolumes) => volume.api_name === volumeApiName, + )[0] + + dispatch({ + type: 'change_volume_type', + volumeType: event.target.value, + volumePricePerHour: + selectedVolume.native_reference_price_per_1000gib_per_hour, + volumePrice: + (state.storage * + selectedVolume.native_reference_price_per_1000gib_per_hour) / + 1000, + }) } - returnHandler = () => { - this.props.history.push(Urls.linkDbLabInstances(this.props)) + const handleSetFormStep = (step: string) => { + dispatch({ type: 'set_form_step', formStep: step }) } - processedHandler = () => { - const data = this.state.data ? this.state.data.newDbLabInstance : null - - this.props.history.push( - Urls.linkDbLabInstance(this.props, data?.data?.id as string), - ) + const handleReturnToList = () => { + props.history.push(urls.linkDbLabInstances(props)) } - generateTokenHandler = () => { - this.setState({ token: generateToken() }) + const handleReturnToForm = () => { + dispatch({ type: 'set_form_step', formStep: initialState.formStep }) } - render() { - const { classes, orgPermissions } = this.props - const data = - this.state && this.state.data ? this.state.data.newDbLabInstance : null - const projects = - this.state && this.state.data && this.state.data.projects - ? this.state.data.projects - : null - const projectsList = [] - const dbLabInstances = - this.state && this.state.data && this.state.data.dbLabInstances - ? this.state.data.dbLabInstances - : null - - if (data && data.isProcessed && !data.error) { - this.processedHandler() - Actions.resetNewDbLabInstance() - } - - const breadcrumbs = ( - - ) - - const pageTitle = + const calculateVolumePrice = (databaseSize: number, snapshots: number) => { + let storage = databaseSize * snapshots + if (storage > 2000) storage = 2000 - const permitted = !orgPermissions || orgPermissions.dblabInstanceCreate - - const instancesLoaded = dbLabInstances && dbLabInstances.data - - if (!projects || !projects.data || !instancesLoaded) { - return ( -
- {breadcrumbs} - - {pageTitle} - - -
- ) - } + return (storage * state.volumePricePerHour) / 1000 + } - if (projects.data && projects.data?.length > 0) { - projects.data.map((p: { name: string; id: number }) => { - return projectsList.push({ title: p.name, value: p.id }) - }) - } + if (state.isLoading) return - const isDataUpdating = data && (data.isUpdating || data.isChecking) + return ( +
+ {breadcrumbs} - return ( -
- {breadcrumbs} + {pageTitle} - {pageTitle} + {!permitted && ( + + You do not have permission to add Database Lab instances. + + )} - {!permitted && ( - - You do not have permission to add Database Lab instances. - +
- Database Lab provisioning is currently semi-automated. -
- First, you need to prepare a Database Lab instance on a separate  - machine. Once the instance is ready, register it here. - - -
- {data?.errorMessage ? data.errorMessage : null} -
- - -
- { - this.setState({ - project: e.target.value, - }) - Actions.resetNewDbLabInstance() - }} - margin="normal" - error={this.state.errorFields.indexOf('project') !== -1} - fullWidth - inputProps={{ - name: 'project', - id: 'project', - shrink: true, - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - /> -
- -
- { - this.setState({ - token: e.target.value, - }) - Actions.resetNewDbLabInstance() - }} - margin="normal" - error={this.state.errorFields.indexOf('token') !== -1} - fullWidth - inputProps={{ - name: 'token', - id: 'token', - shrink: true, - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - /> -
- -
-
- -
- { - this.setState({ - url: e.target.value, - }) - Actions.resetNewDbLabInstance() - }} - margin="normal" - helperText={ - !isHttps(this.state.url) && !this.state.useTunnel ? ( - - - - The connection to the Database Lab API is not secure. Use - HTTPS. - - - ) : null - } - error={this.state.errorFields.indexOf('url') !== -1} - fullWidth - inputProps={{ - name: 'url', - id: 'url', - shrink: true, - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - /> -
- -
- { - this.setState({ - useTunnel: e.target.checked, + > + {state.formStep === initialState.formStep && permitted ? ( + <> + {state.isReloading && ( + + )} +
+

+ 1. Select your cloud provider +

+
+ {state.serviceProviders.map( + (provider: CloudProvider, index: number) => ( +
+ dispatch({ + type: 'change_provider', + provider: provider.api_name, + }) + } + > + {provider.label} +
+ ), + )} +
+

+ 2. Select your cloud region +

+
+ | null, value: string) => + dispatch({ + type: 'change_region', + region: value, + location: state.cloudRegions.find( + (region: CloudRegion) => + region.world_part === value && + region.cloud_provider === state.provider, + ), }) - Actions.resetNewDbLabInstance() - }} - id="useTunnel" - name="useTunnel" - /> - } - label="Use tunnel" - labelPlacement="end" - /> -
- { - this.setState({ - sshServerUrl: e.target.value, - }) - Actions.resetNewDbLabInstance() - }} - margin="normal" - error={this.state.errorFields.indexOf('sshServerUrl') !== -1} - fullWidth - inputProps={{ - name: 'sshServerUrl', - id: 'sshServerUrl', - shrink: true, - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - /> -
-
-
- - - {data?.isCheckProcessed && - data?.isChecked && - (isHttps(this.state.url) || this.state.useTunnel) ? ( - - {' '} - Verified - - ) : null} - - {data?.isCheckProcessed && - data?.isChecked && - !isHttps(this.state.url) && - !this.state.useTunnel ? ( - - Verified but is - not secure - - ) : null} - - {data?.isCheckProcessed && !data?.isChecked ? ( - - Not available - - ) : null} -
- -
-
+ + {filteredRegions.map((region: CloudRegion, index: number) => ( +
+ dispatch({ + type: 'change_location', + location: region, + }) + } + > +

{region.api_name}

+

🏴 {region.label}

+
+ ))} +
+ {state.instanceType ? ( + <> +

+ 3. Choose the instance type +

+

+ A larger instance can accommodate more dev/test activities. + For example, a team of 5 engineers requiring 5-10 clones + during peak times should consider a minimum instance size of + 8 vCPUs and 32 GiB. +

+ + {state.cloudInstances.map( + (instance: CloudInstance, index: number) => ( +
+ dispatch({ + type: 'change_instance_type', + instanceType: instance, + }) + } + > +

+ {instance.api_name} ( + {state.instanceType.cloud_provider}:{' '} + {instance.native_name}) +

+
+ 🔳 {instance.native_vcpus} CPU + 🧠 {instance.native_ram_gib} GiB RAM +
+
+ ), + )} +
+

4. Database volume

+ + + + + {(state.volumes as CloudVolumes[]).map((p, id) => { + const volumeName = `${p.api_name} (${p.cloud_provider}: ${p.native_name})` + return ( + + {volumeName} + + ) + })} + + + + + GiB + + ), + }} + value={Number(state.databaseSize)?.toFixed(2)} + className={classes.filterSelect} + onChange={( + event: React.ChangeEvent< + HTMLTextAreaElement | HTMLInputElement + >, + ) => { + dispatch({ + type: 'change_volume_price', + storage: Math.min( + Number(event.target.value) * state.snapshots, + 2000, + ), + databaseSize: event.target.value, + volumePrice: calculateVolumePrice( + Number(event.target.value), + state.snapshots, + ), + }) + }} + /> + × + + {Number(state.snapshots) === 1 + ? 'snapshot' + : 'snapshots'} + + ), + }} + value={state.snapshots} + className={classes.filterSelect} + onChange={( + event: React.ChangeEvent< + HTMLTextAreaElement | HTMLInputElement + >, + ) => { + dispatch({ + type: 'change_snapshots', + snapshots: Number(event.target.value), + storage: Math.min( + Number(event.target.value) * state.databaseSize, + 2000, + ), + volumePrice: calculateVolumePrice( + state.databaseSize, + Number(event.target.value), + ), + }) + }} + /> + + + , value: unknown) => { + dispatch({ + type: 'change_volume_price', + storage: value, + databaseSize: Number(value) / state.snapshots, + volumePrice: + (Number(value) * state.volumePricePerHour) / 1000, + }) + }} + /> + +

5. Provide DLE name

+ , + ) => + dispatch({ + type: 'change_name', + name: event.target.value, + }) + } + /> +

+ 6. Define DLE verification token (keep it secret!) +

+
+ , + ) => + dispatch({ + type: 'change_verification_token', + verificationToken: event.target.value, + }) + } + /> + +
+

7. Choose DLE version

+ { + const defaultTag = availableTags[0] + + return { + value: tag, + children: defaultTag === tag ? `${tag} (default)` : tag, + } + }) ?? [] + } + value={state.tag} + onChange={( + e: React.ChangeEvent, + ) => + dispatch({ + type: 'set_tag', + tag: e.target.value, + }) + } + /> +

+ 4. Provide SSH public keys (one per line) +

{' '} +

+ The specified ssh public keys will be added to authorized_keys + on the DLE server. Add your public key here to have access to + the server after deployment. +

+ , + ) => + dispatch({ + type: 'change_public_keys', + publicKeys: event.target.value, + }) + } + /> +
+ handleSetFormStep('docker')} + /> + + ) : state.formStep === 'ansible' && permitted ? ( + + ) : state.formStep === 'docker' && permitted ? ( + + ) : null} +
+
+ ) +} + +export default DbLabInstanceInstallForm diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormSidebar.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormSidebar.tsx new file mode 100644 index 00000000..9d662cb1 --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormSidebar.tsx @@ -0,0 +1,107 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { Button, makeStyles } from '@material-ui/core' + +import { initialState } from 'components/DbLabInstanceForm/reducer' + +const useStyles = makeStyles({ + boxShadow: { + padding: '24px', + boxShadow: '0 8px 16px #3a3a441f, 0 16px 32px #5a5b6a1f', + }, + aside: { + width: '100%', + height: 'fit-content', + borderRadius: '4px', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + flex: '1 1 0', + position: 'sticky', + top: 10, + + '& h2': { + fontSize: '14px', + fontWeight: 500, + margin: '0 0 10px 0', + height: 'fit-content', + }, + + '& span': { + fontSize: '13px', + }, + + '& button': { + padding: '10px 20px', + marginTop: '20px', + }, + + '@media (max-width: 1200px)': { + position: 'relative', + boxShadow: 'none', + borderRadius: '0', + padding: '0', + flex: 'auto', + marginBottom: '30px', + + '& button': { + width: 'max-content', + }, + }, + }, + asideSection: { + padding: '12px 0', + borderBottom: '1px solid #e0e0e0', + + '& span': { + color: '#808080', + }, + + '& p': { + margin: '5px 0 0 0', + fontSize: '13px', + }, + }, +}) + +export const DbLabInstanceFormInstallSidebar = ({ + state, + handleCreate, +}: { + state: typeof initialState + handleCreate: () => void +}) => { + const classes = useStyles() + + return ( +
+
+ {state.name && ( +
+ Name +

{state.name}

+
+ )} + {state.tag && ( +
+ Tag +

{state.tag}

+
+ )} + +
+
+ ) +} diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormWrapper.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormWrapper.tsx new file mode 100644 index 00000000..5a31cc1b --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallFormWrapper.tsx @@ -0,0 +1,304 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + +import { RouteComponentProps } from 'react-router' +import { makeStyles } from '@material-ui/core' + +import DbLabInstanceInstallForm from 'components/DbLabInstanceInstallForm/DbLabInstanceInstallForm' + +import { styles } from '@postgres.ai/shared/styles/styles' + +export interface DbLabInstanceFormProps { + edit?: boolean + orgId: number + project: string | undefined + history: RouteComponentProps['history'] + orgPermissions: { + dblabInstanceCreate?: boolean + } +} + +export const DbLabInstanceFormInstallWrapper = ( + props: DbLabInstanceFormProps, +) => { + const useStyles = makeStyles( + { + textField: { + ...styles.inputField, + maxWidth: 400, + }, + errorMessage: { + color: 'red', + }, + fieldBlock: { + width: '100%', + }, + urlOkIcon: { + marginBottom: -5, + marginLeft: 10, + color: 'green', + }, + urlOk: { + color: 'green', + }, + urlFailIcon: { + marginBottom: -5, + marginLeft: 10, + color: 'red', + }, + urlFail: { + color: 'red', + }, + warning: { + color: '#801200', + fontSize: '0.9em', + }, + warningIcon: { + color: '#801200', + fontSize: '1.2em', + position: 'relative', + marginBottom: -3, + }, + container: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 30, + gap: 60, + width: '100%', + height: '95%', + position: 'relative', + '& input': { + padding: '13.5px 14px', + }, + + '@media (max-width: 1200px)': { + flexDirection: 'column', + height: 'auto', + gap: 30, + }, + }, + form: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + flex: '3 1 0', + + '& > [role="tabpanel"] .MuiBox-root': { + padding: 0, + + '& > div:first-child': { + marginTop: '10px', + }, + }, + }, + activeBorder: { + border: '1px solid #FF6212 !important', + }, + providerFlex: { + display: 'flex', + gap: '10px', + marginBottom: '20px', + overflow: 'auto', + flexShrink: 0, + + '& > div': { + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: '1px solid #e0e0e0', + padding: '15px', + borderRadius: '4px', + cursor: 'pointer', + transition: 'border 0.3s ease-in-out', + + '&:hover': { + border: '1px solid #FF6212', + }, + + '& > img': { + margin: 'auto', + }, + }, + }, + sectionTitle: { + fontSize: '14px', + fontWeight: 500, + marginTop: '20px', + + '&:first-child': { + marginTop: 0, + }, + }, + sectionContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + + '& > .MuiTabs-root, .MuiTabs-fixed': { + overflow: 'auto !important', + }, + + '& span': { + top: '40px', + height: '2px', + }, + }, + tab: { + minWidth: 'auto', + padding: '0 12px', + }, + tabPanel: { + padding: '10px 0 0 0', + }, + instanceSize: { + marginBottom: '10px', + border: '1px solid #e0e0e0', + borderRadius: '4px', + cursor: 'pointer', + padding: '15px', + transition: 'border 0.3s ease-in-out', + display: 'flex', + gap: 10, + flexDirection: 'column', + + '&:hover': { + border: '1px solid #FF6212', + }, + + '& > p': { + margin: 0, + }, + + '& > div': { + display: 'flex', + gap: 10, + alignItems: 'center', + flexWrap: 'wrap', + }, + }, + serviceLocation: { + display: 'flex', + flexDirection: 'column', + gap: '5px', + marginBottom: '10px', + border: '1px solid #e0e0e0', + borderRadius: '4px', + cursor: 'pointer', + padding: '15px', + transition: 'border 0.3s ease-in-out', + + '&:hover': { + border: '1px solid #FF6212', + }, + + '& > p': { + margin: 0, + }, + }, + instanceParagraph: { + margin: '0 0 10px 0', + }, + filterSelect: { + flex: '2 1 0', + + '& .MuiSelect-select': { + padding: '10px', + }, + + '& .MuiInputBase-input': { + padding: '10px', + }, + + '& .MuiSelect-icon': { + top: 'calc(50% - 9px)', + }, + }, + generateContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '10px', + + '& > button': { + width: 'max-content', + marginTop: '10px', + flexShrink: 0, + height: 'calc(100% - 10px)', + }, + + '@media (max-width: 640px)': { + flexDirection: 'column', + alignItems: 'flex-start', + gap: 0, + + '& > button': { + height: 'auto', + }, + }, + }, + backgroundOverlay: { + '&::before': { + content: '""', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + background: 'rgba(255, 255, 255, 0.8)', + zIndex: 1, + }, + }, + absoluteSpinner: { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + zIndex: 1, + width: '32px !important', + height: '32px !important', + }, + marginTop: { + marginTop: '10px', + }, + sliderContainer: { + width: '100%', + padding: '30px 35px', + borderRadius: '4px', + border: '1px solid #e0e0e0', + }, + sliderInputContainer: { + display: 'flex', + flexDirection: 'column', + marginBottom: '20px', + gap: '20px', + maxWidth: '350px', + width: '100%', + }, + sliderVolume: { + display: 'flex', + flexDirection: 'row', + gap: '10px', + alignItems: 'center', + }, + databaseSize: { + display: 'flex', + flexDirection: 'row', + gap: '10px', + alignItems: 'center', + }, + }, + { index: 1 }, + ) + + const classes = useStyles() + + return +} diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/reducer/index.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/reducer/index.tsx new file mode 100644 index 00000000..83a3fe76 --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceInstallForm/reducer/index.tsx @@ -0,0 +1,60 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ +import { ReducerAction } from 'react' + +import { availableTags } from 'components/DbLabInstanceForm/utils' + +export const initialState = { + isLoading: false, + formStep: 'create', + api_name: 'ssd', + name: '', + publicKeys: '', + tag: availableTags[0], + verificationToken: '', +} + +export const reducer = ( + state: typeof initialState, + // @ts-ignore + action: ReducerAction, +) => { + switch (action.type) { + case 'change_name': { + return { + ...state, + name: action.name, + } + } + case 'change_verification_token': { + return { + ...state, + verificationToken: action.verificationToken, + } + } + case 'change_public_keys': { + return { + ...state, + publicKeys: action.publicKeys, + } + } + case 'set_form_step': { + return { + ...state, + formStep: action.formStep, + } + } + case 'set_tag': { + return { + ...state, + tag: action.tag, + } + } + default: + throw new Error() + } +} diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/utils/index.ts b/ui/packages/platform/src/components/DbLabInstanceInstallForm/utils/index.ts new file mode 100644 index 00000000..82297cd3 --- /dev/null +++ b/ui/packages/platform/src/components/DbLabInstanceInstallForm/utils/index.ts @@ -0,0 +1,52 @@ +import { initialState } from '../reducer' +import { DEBUG_API_SERVER } from 'components/DbLabInstanceForm/utils' + +const API_SERVER = process.env.REACT_APP_API_SERVER + +export const getPlaybookCommand = ( + state: typeof initialState, + orgKey: string, +) => + `docker run --rm -it postgresai/dle-se-ansible:v1.0-rc.1 \\\r + ansible-playbook deploy_dle.yml --extra-vars \\\r + "dle_host='user@server-ip-address' \\\r + dle_platform_project_name='${state.name}' \\\r + dle_version='${state.tag}' \\\r + ${orgKey ? `dle_platform_org_key='${orgKey}' \\\r` : ``} + ${ + API_SERVER === DEBUG_API_SERVER + ? `dle_platform_url='${DEBUG_API_SERVER}' \\\r` + : `` + } + ${state.publicKeys ? `ssh_public_keys='${state.publicKeys}' \\\r` : ``} + dle_verification_token='${state.verificationToken}'" +` + +export const getAnsiblePlaybookCommand = ( + state: typeof initialState, + orgKey: string, +) => + `ansible-playbook deploy_dle.yml --extra-vars \\\r + "dle_host='user@server-ip-address' \\\r + dle_platform_project_name='${state.name}' \\\r + dle_version='${state.tag}' \\\r + ${orgKey ? `dle_platform_org_key='${orgKey}' \\\r` : ``} + ${ + API_SERVER === DEBUG_API_SERVER + ? `dle_platform_url='${DEBUG_API_SERVER}' \\\r` + : `` + } + ${state.publicKeys ? `ssh_public_keys='${state.publicKeys}' \\\r` : ``} + dle_verification_token='${state.verificationToken}'" +` + +export const getAnsibleInstallationCommand = () => + `sudo apt update +sudo apt install -y python3-pip +pip3 install ansible` + +export const cloneRepositoryCommand = () => + `git clone https://gitlab.com/postgres-ai/dle-se-ansible.git +# Go to the playbook directory +cd dle-se-ansible/ +` diff --git a/ui/packages/platform/src/components/DbLabInstances/DbLabInstances.tsx b/ui/packages/platform/src/components/DbLabInstances/DbLabInstances.tsx index 4501ec84..f05ba17d 100644 --- a/ui/packages/platform/src/components/DbLabInstances/DbLabInstances.tsx +++ b/ui/packages/platform/src/components/DbLabInstances/DbLabInstances.tsx @@ -6,6 +6,7 @@ */ import { Component, MouseEvent } from 'react' +import { formatDistanceToNowStrict } from 'date-fns' import { Table, TableBody, @@ -22,11 +23,10 @@ import MoreVertIcon from '@material-ui/icons/MoreVert' import WarningIcon from '@material-ui/icons/Warning' import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' -import { StubContainer } from '@postgres.ai/shared/components/StubContainer' import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' import { Spinner } from '@postgres.ai/shared/components/Spinner' +import { Modal } from '@postgres.ai/shared/components/Modal' import { styles } from '@postgres.ai/shared/styles/styles' -import { icons } from '@postgres.ai/shared/styles/icons' import { ClassesType, ProjectProps, @@ -35,26 +35,27 @@ import { import Actions from '../../actions/actions' import ConsolePageTitle from './../ConsolePageTitle' import { ErrorWrapper } from 'components/Error/ErrorWrapper' -import { GatewayLink } from '@postgres.ai/shared/components/GatewayLink' import { messages } from '../../assets/messages' import Store from '../../stores/store' import Urls from '../../utils/urls' +import format from '../../utils/format' import { isHttps } from '../../utils/utils' import { WarningWrapper } from 'components/Warning/WarningWrapper' import { ProjectDataType, getProjectAliasById } from 'utils/aliases' import { InstanceStateDto } from '@postgres.ai/shared/types/api/entities/instanceState' import { InstanceDto } from '@postgres.ai/shared/types/api/entities/instance' import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' -import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' -import { ProductCardWrapper } from 'components/ProductCard/ProductCardWrapper' import { DbLabStatusWrapper } from 'components/DbLabStatus/DbLabStatusWrapper' import { DbLabInstancesProps } from 'components/DbLabInstances/DbLabInstancesWrapper' +import { CreatedDbLabCards } from 'components/CreateDbLabCards/CreateDbLabCards' +import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' interface DbLabInstancesWithStylesProps extends DbLabInstancesProps { classes: ClassesType } interface DbLabInstancesState { + modalOpen: boolean data: { auth: { token: string @@ -68,7 +69,9 @@ interface DbLabInstancesState { orgId: number data: { [org: string]: { + created_at: string project_label_or_name: string + plan: string project_name: string project_label: string url: string @@ -182,8 +185,8 @@ class DbLabInstances extends Component< this.props.history.push(Urls.linkDbLabInstances(props)) } - registerButtonHandler = () => { - this.props.history.push(Urls.linkDbLabInstanceAdd(this.props)) + registerButtonHandler = (provider: string) => { + this.props.history.push(Urls.linkDbLabInstanceAdd(this.props, provider)) } openMenu = (event: MouseEvent) => { @@ -285,24 +288,40 @@ class DbLabInstances extends Component< const deletePermitted = !orgPermissions || orgPermissions.dblabInstanceDelete + const getVersionDigits = (str: string) => { + if (!str) { + return 'N/A' + } + + const digits = str.match(/\d+/g) + + if (digits && digits.length > 0) { + return `${digits[0]}.${digits[1]}.${digits[2]}` + } + return '' + } + const addInstanceButton = ( this.setState({ modalOpen: true })} + title={addPermitted ? 'Create new DLE' : messages.noPermission} > - Add instance + New DLE ) const pageTitle = ( - + 0 + ? [addInstanceButton] + : [] + } + /> ) let projectFilter = null @@ -391,37 +410,21 @@ class DbLabInstances extends Component< ) } - const emptyListTitle = projectId - ? 'There are no Database Lab instances in this project yet' - : 'There are no Database Lab instances yet' + const CardsModal = () => ( + this.setState({ modalOpen: false })} + aria-labelledby="simple-modal-title" + aria-describedby="simple-modal-description" + > + + + ) let table = ( - - -

- Clone multi-terabyte databases in seconds and use them to test your - database migrations, optimize SQL, or deploy full-size staging apps. - Start here to work with all Database Lab tools. Setup - - documentation here - - . -

-
-
+ ) let menu = null @@ -432,9 +435,13 @@ class DbLabInstances extends Component< Project + Instance ID URL - Status Clones + Plan + Version + State + Created at   @@ -456,11 +463,15 @@ class DbLabInstances extends Component< data.data[index].project_name} + + {data.data[index].id} + {data.data[index].state && data.data[index].url ? data.data[index].url - : ''} + : 'N/A'} {!isHttps(data.data[index].url) && + data.data[index].url && !data.data[index].use_tunnel ? ( ) : null} - - - - - {data.data[index]?.state?.cloning?.numClones ?? data.data[index]?.state?.clones?.length ?? - ''} + 'N/A'} + + + {data.data[index] && + (data.data[index]?.plan === 'EE' + ? 'Enterprise' + : data.data[index]?.plan === 'SE' + ? 'Standard' + : data.data[index]?.plan)} + + + {getVersionDigits( + data.data[index] && + (data.data[index].state?.engine?.version as string), + )} + + + {data.data[index].state && data.data[index].url ? ( + + ) : ( + 'N/A' + )} + + + + + {format.formatTimestampUtc( + data.data[index].created_at, + ) ?? ''} + + - {data.data[index].isProcessing || (this.state.data?.dbLabInstanceStatus.instanceId === @@ -509,6 +552,12 @@ class DbLabInstances extends Component< ) + const selectedInstance = Object.values(data.data).filter((item) => { + const anchorElLabel = this.state.anchorEl?.getAttribute('aria-label') + // eslint-disable-next-line eqeqeq + return anchorElLabel && item.id == anchorElLabel + })[0] + menu = ( this.menuHandler(event, 'editProject')} - disabled={!addPermitted} + disabled={!addPermitted || selectedInstance?.plan === 'SE'} > Edit this.menuHandler(event, 'addclone')} + disabled={selectedInstance?.plan === 'SE'} > Create clone this.menuHandler(event, 'refresh')} + disabled={selectedInstance?.plan === 'SE'} > Refresh @@ -563,6 +614,8 @@ class DbLabInstances extends Component< {table} {menu} + +
) } diff --git a/ui/packages/platform/src/components/DbLabInstances/DbLabInstancesWrapper.tsx b/ui/packages/platform/src/components/DbLabInstances/DbLabInstancesWrapper.tsx index 518ebf87..605a45f7 100644 --- a/ui/packages/platform/src/components/DbLabInstances/DbLabInstancesWrapper.tsx +++ b/ui/packages/platform/src/components/DbLabInstances/DbLabInstancesWrapper.tsx @@ -30,9 +30,6 @@ export const DbLabInstancesWrapper = (props: DbLabInstancesProps) => { display: 'flex', flexDirection: 'column', }, - stubContainer: { - marginTop: '10px', - }, filterSelect: { ...styles.inputField, width: 150, @@ -60,6 +57,39 @@ export const DbLabInstancesWrapper = (props: DbLabInstancesProps) => { tooltip: { fontSize: '10px!important', }, + timeLabel: { + lineHeight: '16px', + fontSize: 12, + cursor: 'pointer', + }, + flexContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + gap: 40, + marginTop: '20px', + + '& > div': { + maxWidth: '300px', + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: '1px solid #e0e0e0', + padding: '20px', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '15px', + transition: 'border 0.3s ease-in-out', + + '&:hover': { + border: '1px solid #FF6212', + }, + }, + }, }, { index: 1 }, ) diff --git a/ui/packages/platform/src/components/IndexPage/IndexPage.tsx b/ui/packages/platform/src/components/IndexPage/IndexPage.tsx index abdaed9b..2b5022d1 100644 --- a/ui/packages/platform/src/components/IndexPage/IndexPage.tsx +++ b/ui/packages/platform/src/components/IndexPage/IndexPage.tsx @@ -43,7 +43,9 @@ import { SideNav } from 'components/SideNav' import { ContentLayout } from 'components/ContentLayout' import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' import { DbLabInstanceFormWrapper } from 'components/DbLabInstanceForm/DbLabInstanceFormWrapper' +import { AddDbLabInstanceFormWrapper } from 'components/AddDbLabInstanceFormWrapper/AddDbLabInstanceFormWrapper' import { DbLabInstancesWrapper } from 'components/DbLabInstances/DbLabInstancesWrapper' +import { DbLabInstanceFormInstallWrapper } from 'components/DbLabInstanceInstallForm/DbLabInstanceInstallFormWrapper' import { DbLabSessionWrapper } from 'components/DbLabSession/DbLabSessionWrapper' import { DbLabSessionsWrapper } from 'components/DbLabSessions/DbLabSessionsWrapper' import { JoeInstanceFormWrapper } from 'components/JoeInstanceForm/JoeInstanceFormWrapper' @@ -145,14 +147,26 @@ function ProjectWrapper(parentProps: Omit) { ( + + )} + /> + ( )} /> + ( + + )} + /> ( - + )} /> +
Organization @@ -659,6 +673,16 @@ function OrganizationWrapper(parentProps: OrganizationWrapperProps) { ( + + )} + /> + ( )} /> + ( + + )} + /> @@ -887,14 +921,14 @@ class IndexPage extends Component { env.data && env.data.orgs[orgProfile.data.alias] ) { - that.props.history.push('/' + orgProfile.data.alias + '/settings') + window.location.href = '/' + orgProfile.data.alias + '/settings' } if ( (env.isConfirmProcessed || (env.data && env.data.info.is_active)) && Urls.isRequestedPath('confirm') ) { - that.props.history.push(ROUTES.ROOT.path) + window.location.href = ROUTES.ROOT.path } }) @@ -1173,7 +1207,7 @@ class IndexPage extends Component { } const drawer = ( -
+
diff --git a/ui/packages/platform/src/components/IndexPage/IndexPageWrapper.tsx b/ui/packages/platform/src/components/IndexPage/IndexPageWrapper.tsx index e2c6668d..c306a2cb 100644 --- a/ui/packages/platform/src/components/IndexPage/IndexPageWrapper.tsx +++ b/ui/packages/platform/src/components/IndexPage/IndexPageWrapper.tsx @@ -30,6 +30,7 @@ export const IndexPageWrapper = (props: IndexPageProps) => { paddingTop: '40px', }, position: 'absolute', + overflow: 'hidden', width: drawerWidth, 'background-color': colors.consoleMenuBackground, 'border-right-color': colors.consoleStroke, @@ -229,9 +230,13 @@ export const IndexPageWrapper = (props: IndexPageProps) => { paddingLeft: '15px', color: '#000000', }, + menuPointer: { + height: '100%', + }, navMenu: { padding: '0px', - marginBottom: '85px', + height: 'calc(100% - 160px)', + overflowY: 'auto', }, menuSectionHeaderIcon: { marginRight: '13px', diff --git a/ui/packages/platform/src/components/JoeInstanceForm/JoeInstanceForm.tsx b/ui/packages/platform/src/components/JoeInstanceForm/JoeInstanceForm.tsx index 326f1168..e8bbb35d 100644 --- a/ui/packages/platform/src/components/JoeInstanceForm/JoeInstanceForm.tsx +++ b/ui/packages/platform/src/components/JoeInstanceForm/JoeInstanceForm.tsx @@ -355,7 +355,9 @@ class JoeInstanceForm extends Component< }} margin="normal" helperText={ - !isHttps(this.state.url) && !this.state.useTunnel ? ( + this.state.url && + !isHttps(this.state.url) && + !this.state.useTunnel ? ( diff --git a/ui/packages/platform/src/components/JoeInstances/JoeInstances.tsx b/ui/packages/platform/src/components/JoeInstances/JoeInstances.tsx index e3a4a1a0..992cb901 100644 --- a/ui/packages/platform/src/components/JoeInstances/JoeInstances.tsx +++ b/ui/packages/platform/src/components/JoeInstances/JoeInstances.tsx @@ -403,8 +403,9 @@ class JoeInstances extends Component< - {data.data[i].url ? data.data[i].url : ''} - {!isHttps(data.data[i].url) && + {data.data[i].url ? data.data[i].url : 'N/A'} + {data.data[i].url && + !isHttps(data.data[i].url) && !data.data[i].use_tunnel ? ( ) => { + this.setState({ filterValue: event.target.value }) + } + render() { const { classes, orgPermissions, orgId, env } = this.props const data = this.state && this.state.data ? this.state.data.orgUsers : null @@ -278,8 +283,38 @@ class OrgSettings extends Component< , ] + let users: UsersType[] = [] + if (hasListMembersPermission) { + if (data && data.data && data.data.users && data.data.users.length > 0) { + users = data.data.users + } + } else if (userProfile && userProfile.data && userProfile.data.info) { + users = [userProfile.data.info] + } + + const filteredUsers = users?.filter((user) => { + const fullName = (user.first_name || '') + ' ' + (user.last_name || '') + return ( + fullName + ?.toLowerCase() + .indexOf((this.state.filterValue || '')?.toLowerCase()) !== -1 + ) + }) + const pageTitle = ( - + 0 + ? { + filterValue: this.state.filterValue, + filterHandler: this.filterInputHandler, + placeholder: 'Search users by name', + } + : null + } + /> ) if ( @@ -308,14 +343,6 @@ class OrgSettings extends Component< // If user does not have "ListMembersPermission" we will fill the list only // with his data without making getOrgUsers request. - let users: UsersType[] = [] - if (hasListMembersPermission) { - if (data && data.data && data.data.users && data.data.users.length > 0) { - users = data.data.users - } - } else if (userProfile && userProfile.data && userProfile.data.info) { - users = [userProfile.data.info] - } return (
@@ -329,7 +356,7 @@ class OrgSettings extends Component< )} - {users.length > 0 ? ( + {filteredUsers && filteredUsers.length > 0 ? ( @@ -342,7 +369,7 @@ class OrgSettings extends Component< - {users.map((u: UsersType) => { + {filteredUsers.map((u: UsersType) => { return ( {u.email} diff --git a/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx b/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx index b6522f62..eb5f0647 100644 --- a/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx +++ b/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx @@ -26,7 +26,7 @@ export const ProductCardWrapper = (props: ProductCardProps) => { margin: '0', }, [muiTheme.breakpoints.down('xs')]: { - height: '350px', + height: '100%', }, fontFamily: theme.typography.fontFamily, fontSize: '14px', diff --git a/ui/packages/platform/src/components/StripeForm.tsx b/ui/packages/platform/src/components/StripeForm.tsx deleted file mode 100644 index eec4b77e..00000000 --- a/ui/packages/platform/src/components/StripeForm.tsx +++ /dev/null @@ -1,255 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useMemo } from 'react' -import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js' -import { StripeCardElement } from '@stripe/stripe-js' -import { Button, makeStyles, Paper } from '@material-ui/core' - -import { colors } from '@postgres.ai/shared/styles/colors' -import { icons } from '@postgres.ai/shared/styles/icons' - -import Actions from '../actions/actions' - -const useStyles = makeStyles( - (theme) => ({ - yellowContainer: { - background: 'yellow', - color: 'red', - }, - redContainer: { - background: 'red', - color: 'blue', - }, - toolbar: theme.mixins.toolbar, - cardContainer: { - padding: 20, - width: 500, - display: 'inline-block', - borderWidth: 1, - borderColor: colors.consoleStroke, - borderStyle: 'solid', - }, - cardContainerTitle: { - fontSize: 16, - fontWeight: 'bold', - marginTop: 0, - }, - secureNotice: { - fontSize: 10, - float: 'right', - height: 40, - marginTop: -3, - '& img': { - float: 'right', - }, - }, - }), - { index: 1 }, -) - -const stripeStyles = ( - -) - -const useOptions = () => { - const fontSize = '14px' - const options = useMemo( - () => ({ - hidePostalCode: true, - style: { - base: { - fontSize, - color: '#424770', - letterSpacing: '0.025em', - fontFamily: 'Source Code Pro, monospace', - '::placeholder': { - color: '#aab7c4', - }, - }, - invalid: { - color: '#9e2146', - }, - }, - }), - [fontSize], - ) - - return options -} - -function StripeForm(props: { - mode: string - token: string | null - orgId: number - disabled: boolean -}) { - const classes = useStyles() - const stripe = useStripe() - const elements = useElements() - const options = useOptions() - const subscriptionMode = props.mode - - const handleSubmit = async (event: { preventDefault: () => void }) => { - event.preventDefault() - - if (!stripe || !elements) { - // Stripe.js has not loaded yet. Make sure to disable - // form submission until Stripe.js has loaded. - return - } - - const payload = await stripe.createPaymentMethod({ - type: 'card', - card: elements.getElement(CardElement) as StripeCardElement, - }) - console.log('[PaymentMethod]', payload) - - if (payload.error && payload.error.message) { - Actions.setSubscriptionError(payload.error.message) - return - } - - Actions.subscribeBilling( - props.token, - props.orgId, - payload.paymentMethod?.id, - ) - } - - let buttonTitle = 'Subscribe' - let messages = ( -
-

Enter payment details

-

Your subscription will start now.

-
- ) - - if (subscriptionMode === 'resume') { - buttonTitle = 'Resume subscription' - messages = ( -
-

Enter payment details

-

Your subscription will be resumed now.

-
- ) - } - - if (subscriptionMode === 'update') { - buttonTitle = 'Update subscription' - messages = ( -
-

Update payment details

-

Your payment details will be updated now.

-
- ) - } - - return ( -
- - {messages} - - - -
{icons.poweredByStripe}
-
-
- ) -} - -export default StripeForm diff --git a/ui/packages/platform/src/components/StripeForm/index.tsx b/ui/packages/platform/src/components/StripeForm/index.tsx new file mode 100644 index 00000000..f6f3dbe2 --- /dev/null +++ b/ui/packages/platform/src/components/StripeForm/index.tsx @@ -0,0 +1,500 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ +import { Box } from '@mui/material' +import { formatDistanceToNowStrict } from 'date-fns' +import { useReducer, useEffect, useCallback, ReducerAction } from 'react' +import { Button, makeStyles, Paper, Tooltip } from '@material-ui/core' + +import { colors } from '@postgres.ai/shared/styles/colors' +import { Spinner } from '@postgres.ai/shared/components/Spinner' +import { stripeStyles } from 'components/StripeForm/stripeStyles' +import { Link } from '@postgres.ai/shared/components/Link2' +import { ROUTES } from 'config/routes' + +import { getPaymentMethods } from 'api/billing/getPaymentMethods' +import { startBillingSession } from 'api/billing/startBillingSession' +import { getSubscription } from 'api/billing/getSubscription' + +import format from '../../utils/format' + +interface BillingSubscription { + status: string + created_at: number + description: string + plan_description: string + recognized_dblab_instance_id: number + selfassigned_instance_id: string + subscription_id: string + telemetry_last_reported: number + telemetry_usage_total_hours_last_3_months: string +} + +interface HourEntry { + [key: string]: number +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const useStyles = makeStyles( + (theme) => ({ + paperContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + gap: 20, + width: '100%', + height: '100%', + + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + }, + }, + cardContainer: { + padding: 20, + display: 'inline-block', + borderWidth: 1, + borderColor: colors.consoleStroke, + borderStyle: 'solid', + flex: '1 1 0', + + [theme.breakpoints.down('sm')]: { + width: '100%', + flex: 'auto', + }, + }, + subscriptionContainer: { + display: 'flex', + flexDirection: 'column', + flex: '1 1 0', + gap: 20, + + [theme.breakpoints.down('sm')]: { + width: '100%', + flex: 'auto', + }, + }, + cardContainerTitle: { + fontSize: 14, + fontWeight: 'bold', + margin: 0, + }, + label: { + fontSize: 14, + }, + saveButton: { + marginTop: 15, + display: 'flex', + }, + error: { + color: '#E42548', + fontSize: 12, + marginTop: '-10px', + }, + checkboxRoot: { + fontSize: 14, + marginTop: 10, + }, + spinner: { + marginLeft: '8px', + }, + emptyMethod: { + fontSize: 13, + flex: '1 1 0', + }, + columnContainer: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + padding: '10px 0', + borderBottom: '1px solid rgba(224, 224, 224, 1)', + + '& p:first-child': { + flex: '1 1 0', + fontWeight: '500', + }, + + '& p': { + flex: '2 1 0', + margin: '0', + fontSize: 13, + }, + + '&:last-child': { + borderBottom: 'none', + }, + }, + toolTip: { + fontSize: '10px !important', + maxWidth: '100%', + }, + timeLabel: { + lineHeight: '16px', + fontSize: 13, + cursor: 'pointer', + flex: '2 1 0', + }, + card: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 20, + position: 'relative', + justifyContent: 'space-between', + marginTop: 20, + minHeight: 45, + + '& img': { + objectFit: 'contain', + }, + }, + }), + { index: 1 }, +) + +function StripeForm(props: { + alias: string + mode: string + token: string | null + orgId: number + disabled: boolean +}) { + const classes = useStyles() + + const initialState = { + isLoading: false, + isFetching: false, + cards: [], + billingInfo: [], + } + + const reducer = ( + state: typeof initialState, + // @ts-ignore + action: ReducerAction, + ) => { + switch (action.type) { + case 'setIsLoading': + return { ...state, isLoading: action.isLoading } + case 'setIsFetching': + return { ...state, isFetching: action.isFetching } + case 'setBillingInfo': + return { ...state, billingInfo: action.billingInfo, isFetching: false } + case 'setCards': + const updatedCards = state.cards.concat(action.cards) + const uniqueCards = updatedCards.filter( + (card: { id: string }, index: number) => + updatedCards.findIndex((c: { id: string }) => c.id === card.id) === + index, + ) + + return { + ...state, + cards: uniqueCards, + isLoading: false, + } + default: + throw new Error() + } + } + + const [state, dispatch] = useReducer(reducer, initialState) + + const handleSubmit = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + dispatch({ + type: 'setIsLoading', + isLoading: true, + }) + + startBillingSession(props.orgId, window.location.href).then((res) => { + dispatch({ + type: 'setIsLoading', + isLoading: false, + }) + + if (res.response) { + window.open(res.response.details.content.url, '_blank') + } + }) + } + + const fetchPaymentMethods = useCallback(() => { + dispatch({ + type: 'setIsFetching', + isFetching: true, + }) + + getPaymentMethods(props.orgId).then((res) => { + dispatch({ + type: 'setCards', + cards: res.response.details.content.data, + }) + + getSubscription(props.orgId).then((res) => { + dispatch({ + type: 'setBillingInfo', + billingInfo: res.response.summary, + }) + }) + }) + }, [props.orgId]) + + useEffect(() => { + fetchPaymentMethods() + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + fetchPaymentMethods() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange, false) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [props.orgId, fetchPaymentMethods]) + + if (state.isFetching) { + return ( +
+ +
+ ) + } + + const BillingDetail = ({ + label, + value, + isDateValue, + isLink, + instanceId, + }: { + label: string + value: string | number + isDateValue?: boolean + isLink?: boolean + instanceId?: string | number + }) => ( + +

{label}

+ {isDateValue && value ? ( + + + + {formatDistanceToNowStrict(new Date(value), { + addSuffix: true, + })} + + + + ) : isLink && value ? ( +

+ + {instanceId} + +   + {`(self-assigned: ${value})` || '---'} +

+ ) : ( +

+ {value || '---'} +

+ )} +
+ ) + + const formatHoursUsed = (hours: HourEntry[] | string) => { + if (typeof hours === 'string' || !hours) { + return 'N/A' + } + + const formattedHours = hours.map((entry: HourEntry) => { + const key = Object.keys(entry)[0] + const value = entry[key] + return `${key}: ${value}` + }) + + return formattedHours.join('\n') + } + + return ( +
+ {stripeStyles} +
+ +

Subscriptions

+ {state.billingInfo.length > 0 ? ( + state.billingInfo.map( + (billing: BillingSubscription, index: number) => ( + + + + + + + + + + + ), + ) + ) : ( + + + + + + + + + + + )} +
+ +

Payment methods

+ + + + + {state.cards.length === 0 && !state.isFetching ? ( +

No payment method available

+ ) : ( + <> + {state.cards.map( + ( + card: { + id: string + card: { + exp_year: string + exp_month: string + brand: string + last4: string + } + }, + index: number, + ) => ( + + + {card.card.brand} + p': { + margin: 0, + }, + }} + > +

+ **** {card.card.last4} +

+

+ Expires {card.card.exp_month}/{card.card.exp_year} +

+
+
+
+ ), + )} + + )} +
+
+
+
+ ) +} + +export default StripeForm diff --git a/ui/packages/platform/src/components/StripeForm/stripeStyles.tsx b/ui/packages/platform/src/components/StripeForm/stripeStyles.tsx new file mode 100644 index 00000000..7b37c057 --- /dev/null +++ b/ui/packages/platform/src/components/StripeForm/stripeStyles.tsx @@ -0,0 +1,80 @@ +export const stripeStyles = ( + +) diff --git a/ui/packages/platform/src/components/types/index.ts b/ui/packages/platform/src/components/types/index.ts index 807c9ac1..13cf2368 100644 --- a/ui/packages/platform/src/components/types/index.ts +++ b/ui/packages/platform/src/components/types/index.ts @@ -26,10 +26,12 @@ export interface MatchParams { export interface Orgs { [project: string]: { + alias: string is_blocked: boolean is_priveleged: boolean new_subscription: boolean is_blocked_on_creation: boolean + stripe_payment_method_primary: string stripe_subscription_id: number priveleged_until: Date role: { id: number; permissions: string[] } diff --git a/ui/packages/platform/src/config/env.ts b/ui/packages/platform/src/config/env.ts index f55767a5..3b802ffe 100644 --- a/ui/packages/platform/src/config/env.ts +++ b/ui/packages/platform/src/config/env.ts @@ -8,4 +8,5 @@ export const NODE_ENV = process.env.NODE_ENV export const SENTRY_DSN = process.env.REACT_APP_SENTRY_DSN export const API_URL_PREFIX = process.env.REACT_APP_API_SERVER ?? '' +export const WS_URL_PREFIX = process.env.REACT_APP_WS_URL_PREFIX ?? '' export const BUILD_TIMESTAMP = process.env.BUILD_TIMESTAMP diff --git a/ui/packages/platform/src/pages/Instance/index.tsx b/ui/packages/platform/src/pages/Instance/index.tsx index 3233318b..d8c90373 100644 --- a/ui/packages/platform/src/pages/Instance/index.tsx +++ b/ui/packages/platform/src/pages/Instance/index.tsx @@ -11,6 +11,12 @@ import { destroyClone } from 'api/clones/destroyClone' import { resetClone } from 'api/clones/resetClone' import { bannersStore } from 'stores/banners' import { getWSToken } from 'api/instances/getWSToken' +import { getConfig } from 'api/configs/getConfig' +import { getFullConfig } from 'api/configs/getFullConfig' +import { testDbSource } from 'api/configs/testDbSource' +import { updateConfig } from 'api/configs/updateConfig' +import { getEngine } from 'api/engine/getEngine' +import { initWS } from 'api/engine/initWS' type Params = { org: string @@ -54,6 +60,12 @@ export const Instance = () => { refreshInstance, resetClone, getWSToken, + getConfig, + getFullConfig, + updateConfig, + testDbSource, + getEngine, + initWS, } const callbacks = { @@ -82,11 +94,11 @@ export const Instance = () => { return ( 0) { - this.data.orgProfile.prevAlias = this.data.orgProfile.data.alias; + this.data.orgProfile.prevAlias = this.data.orgProfile.data?.alias; this.data.orgProfile.data = data[0]; Actions.getUserProfile(this.data.auth.token); Actions.getOrgs(this.data.auth.token, this.data.orgProfile.orgId); @@ -632,7 +632,7 @@ const Store = Reflux.createStore({ Actions.getUserProfile(this.data.auth.token); Actions.getOrgs(this.data.auth.token, this.data.orgProfile.orgId); this.data.orgProfile.updateErrorFields = null; - window.location.pathname = process.env.PUBLIC_URL; + window.location.pathname = this.data.orgProfile.data.alias } else { this.data.orgProfile.updateErrorFields = []; @@ -2384,20 +2384,11 @@ const Store = Reflux.createStore({ this.data.billing.subscriptionError = !!this.data.billing.subscriptionErrorMessage; if (!this.data.billing.subscriptionError && data.result && data.result !== 'fail') { - if (data.result === 'created') { - Actions.showNotification('Subscription successfully created.', 'success'); - } else { - Actions.showNotification('Subscription successfully created.', 'success'); - } - Actions.getUserProfile(this.data.auth.token); setTimeout(() => { Actions.getUserProfile(this.data.auth.token); }, 5000); - } else { - Actions.showNotification('Error happened on change subscription.', 'error'); - } - + } this.trigger(this.data); }, diff --git a/ui/packages/platform/src/utils/settings.ts b/ui/packages/platform/src/utils/settings.ts index 39ae08c4..77d6e2b1 100644 --- a/ui/packages/platform/src/utils/settings.ts +++ b/ui/packages/platform/src/utils/settings.ts @@ -12,7 +12,7 @@ const AUTH_URL = process.env.REACT_APP_AUTH_URL; const ROOT_URL = process.env.REACT_APP_ROOT_URL; const EXPLAIN_DEPESZ_SERVER = process.env.REACT_APP_EXPLAIN_DEPESZ_SERVER; const EXPLAIN_PEV2_SERVER = process.env.REACT_APP_EXPLAIN_PEV2_SERVER; -const STRIPE_API_KEY = process.env.REACT_APP_STRIPE_API_KEY; +const STRIPE_API_KEY = process.env.REACT_APP_STRIPE_API_KEY const settings = { server: window.location.protocol + '//' + window.location.host, diff --git a/ui/packages/platform/src/utils/urls.ts b/ui/packages/platform/src/utils/urls.ts index 60d5be8b..18359dfb 100644 --- a/ui/packages/platform/src/utils/urls.ts +++ b/ui/packages/platform/src/utils/urls.ts @@ -72,10 +72,12 @@ export default { return basePath + '/instances/' + instanceId }, - linkDbLabInstanceAdd: function (props: PropsType) { + linkDbLabInstanceAdd: function (props: PropsType, creationType?: string) { const basePath = this.getBasePath(props) - return basePath + '/instances/add' + return ( + basePath + '/instances' + (creationType ? '/' + creationType : '') + ) }, linkDbLabClone: function ( diff --git a/ui/packages/shared/components/SyntaxHighlight/index.tsx b/ui/packages/shared/components/SyntaxHighlight/index.tsx index 1c3846cc..b18829d4 100644 --- a/ui/packages/shared/components/SyntaxHighlight/index.tsx +++ b/ui/packages/shared/components/SyntaxHighlight/index.tsx @@ -40,16 +40,26 @@ const useStyles = makeStyles( export const SyntaxHighlight = ({ content }: { content: string }) => { const classes = useStyles() + const copyContent = () => { + copyToClipboard(content.replace(/^\s*[\r\n]/gm, '')) + } + return (
@@ -58,7 +68,7 @@ export const SyntaxHighlight = ({ content }: { content: string }) => { copyToClipboard(content)} + onClick={copyContent} > {icons.copyIcon} diff --git a/ui/packages/shared/pages/Instance/Clones/index.tsx b/ui/packages/shared/pages/Instance/Clones/index.tsx index 382cf2f8..82c30a8a 100644 --- a/ui/packages/shared/pages/Instance/Clones/index.tsx +++ b/ui/packages/shared/pages/Instance/Clones/index.tsx @@ -72,7 +72,7 @@ export const Clones = observer((props: ClonesProps) => { const goToCloneAddPage = () => history.push(host.routes.createClone()) const showListSizeButton = - instance.state.cloning.clones.length > SHORT_LIST_SIZE && isMobile + instance.state.cloning.clones?.length > SHORT_LIST_SIZE && isMobile const isLoadingSnapshots = stores.main.snapshots.isLoading const hasSnapshots = Boolean(stores.main.snapshots.data?.length) diff --git a/ui/packages/shared/pages/Instance/InactiveInstance/index.tsx b/ui/packages/shared/pages/Instance/InactiveInstance/index.tsx new file mode 100644 index 00000000..aebc2a4c --- /dev/null +++ b/ui/packages/shared/pages/Instance/InactiveInstance/index.tsx @@ -0,0 +1,165 @@ +import { Tooltip } from '@material-ui/core' +import { makeStyles } from '@material-ui/core' +import { formatDistanceToNowStrict } from 'date-fns' + +import { Link } from '@postgres.ai/shared/components/Link2' +import { Instance } from '@postgres.ai/shared/types/api/entities/instance' + +import { formatTimestampUtc } from './utils' + +const useStyles = makeStyles( + { + container: { + maxWidth: '650px', + width: '100%', + }, + timeLabel: { + lineHeight: '16px', + fontSize: 14, + cursor: 'pointer', + flex: '2 1 0', + + '& span': { + fontWeight: 'normal !important', + }, + }, + toolTip: { + fontSize: '10px !important', + maxWidth: '100%', + }, + content: { + margin: '25px 0', + }, + flexContainer: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + padding: '10px 0', + borderBottom: '1px solid rgba(224, 224, 224, 1)', + + '& span:first-child': { + flex: '1 1 0', + fontWeight: '500', + }, + + '& span': { + flex: '2 1 0', + margin: '0', + }, + }, + }, + { index: 1 }, +) + +export const InactiveInstance = ({ + org, + instance, +}: { + org: string + instance: Instance | null +}) => { + const classes = useStyles() + + const getVersionDigits = (str: string | undefined) => { + if (!str) { + return 'N/A' + } + + const digits = str.match(/\d+/g) + + if (digits && digits.length > 0) { + return `${digits[0]}.${digits[1]}.${digits[2]}` + } + return '' + } + + return ( +
+
+ Plan + {instance?.dto.plan || '---'} +
+
+ Version + + {getVersionDigits( + instance?.state && (instance?.state?.engine?.version as string), + )} + +
+
+ Registered + + + + {instance?.createdAt && + formatDistanceToNowStrict(new Date(instance?.createdAt), { + addSuffix: true, + })} + + + +
+ {instance?.telemetryLastReportedAt && ( +
+ Telemetry last reported + + + + {formatDistanceToNowStrict( + new Date(instance?.telemetryLastReportedAt), + { + addSuffix: true, + }, + )} + + + +
+ )} +
+ Instance ID + + + {instance?.id} +   (self-assigned:{' '} + {instance?.dto?.selfassigned_instance_id || 'N/A'}) + + +
+ {instance?.dto.plan !== 'CE' && ( +
+ Billing data + + Billing data + +
+ )} +

+ To work with this instance, access its UI or API directly. Full + integration of UI to the Platform is currently available only to the EE + users. If you have any questions, reach out to{' '} + + the Postgres.ai team + + . +

+
+ ) +} diff --git a/ui/packages/shared/pages/Instance/InactiveInstance/utils.ts b/ui/packages/shared/pages/Instance/InactiveInstance/utils.ts new file mode 100644 index 00000000..a64e338f --- /dev/null +++ b/ui/packages/shared/pages/Instance/InactiveInstance/utils.ts @@ -0,0 +1,9 @@ +import moment from 'moment' + +export const formatTimestampUtc = (timestamp: moment.MomentInput) => { + if (!timestamp) { + return null + } + + return moment(timestamp).utc().format('YYYY-MM-DD HH:mm:ss UTC') +} diff --git a/ui/packages/shared/pages/Instance/Info/Connection/ConnectModal/Content/utils.ts b/ui/packages/shared/pages/Instance/Info/Connection/ConnectModal/Content/utils.ts index 8553b07d..88ae3c9e 100644 --- a/ui/packages/shared/pages/Instance/Info/Connection/ConnectModal/Content/utils.ts +++ b/ui/packages/shared/pages/Instance/Info/Connection/ConnectModal/Content/utils.ts @@ -5,13 +5,18 @@ *-------------------------------------------------------------------------- */ - import { Instance } from '@postgres.ai/shared/types/api/entities/instance' export const getCliInitCommand = (instance: Instance) => `dblab init --url ${instance.url} --token TOKEN --environment-id ${instance.projectName}` export const getSshPortForwardingCommand = (instance: Instance) => { - if (!instance.sshServerUrl) return null - return `ssh -NTML 2348:localhost:2345 ${instance.sshServerUrl} -i ~/.ssh/id_rsa` + if (instance.sshServerUrl) { + // Parse the URL to get the port + const url = new URL(instance.url) + const port = url.port || '2345' + return `ssh -NTML ${port}:localhost:${port} ${instance.sshServerUrl} -i ~/.ssh/id_rsa` + } else { + return null + } } diff --git a/ui/packages/shared/pages/Instance/Info/index.tsx b/ui/packages/shared/pages/Instance/Info/index.tsx index 8d4220f0..61580a14 100644 --- a/ui/packages/shared/pages/Instance/Info/index.tsx +++ b/ui/packages/shared/pages/Instance/Info/index.tsx @@ -53,7 +53,7 @@ const useStyles = makeStyles( width: '64px', }, collapseBtn: { - background: '#f9f9f9', + background: '#f9f9f9 !important', margin: '20px 0 10px 0', fontWeight: 500, diff --git a/ui/packages/shared/pages/Instance/Tabs/PlatformTabs.tsx b/ui/packages/shared/pages/Instance/Tabs/PlatformTabs.tsx new file mode 100644 index 00000000..5a238fb7 --- /dev/null +++ b/ui/packages/shared/pages/Instance/Tabs/PlatformTabs.tsx @@ -0,0 +1,91 @@ +/*-------------------------------------------------------------------------- + * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai + * All Rights Reserved. Proprietary and confidential. + * Unauthorized copying of this file, via any medium is strictly prohibited + *-------------------------------------------------------------------------- + */ + + import React from 'react' + import { + makeStyles, + Tab as TabComponent, + Tabs as TabsComponent, + } from '@material-ui/core' + import { colors } from '@postgres.ai/shared/styles/colors' + + const useStyles = makeStyles( + { + tabsRoot: { + minHeight: 0, + marginTop: '-8px', + }, + tabsIndicator: { + height: '3px', + }, + tabRoot: { + fontWeight: 400, + minWidth: 0, + minHeight: 0, + padding: '6px 16px', + borderBottom: `3px solid ${colors.consoleStroke}`, + + '& + $tabRoot': { + marginLeft: '10px', + }, + + '&.Mui-disabled': { + opacity: 1, + color: colors.pgaiDarkGray, + }, + }, + tabHidden: { + display: 'none', + }, + }, + { index: 1 }, + ) + + type Props = { + value: number + handleChange: (event: React.ChangeEvent<{}>, newValue: number) => void + hasLogs: boolean + isPlatform?: boolean + } + + export const PlatformTabs = (props: Props) => { + const classes = useStyles() + + const { value, handleChange, hasLogs } = props + + return ( + + + + + + ) + } + \ No newline at end of file diff --git a/ui/packages/shared/pages/Instance/Tabs/index.tsx b/ui/packages/shared/pages/Instance/Tabs/index.tsx index eafd8f29..d743bc08 100644 --- a/ui/packages/shared/pages/Instance/Tabs/index.tsx +++ b/ui/packages/shared/pages/Instance/Tabs/index.tsx @@ -76,18 +76,18 @@ const useStyles = makeStyles( { index: 1 }, ) -type Props = { +export interface TabsProps { value: number handleChange: (event: React.ChangeEvent<{}>, newValue: number) => void hasLogs: boolean + isPlatform?: boolean hideInstanceTabs?: boolean } -export const Tabs = (props: Props) => { +export const Tabs = (props: TabsProps) => { const classes = useStyles() - const { value, handleChange, hasLogs, isConfigActive, hideInstanceTabs } = - props + const { value, handleChange, hasLogs, hideInstanceTabs } = props return ( { label="📓 Logs" disabled={!hasLogs} classes={{ - root: - props.hideInstanceTabs || !isConfigActive - ? classes.tabHidden - : classes.tabRoot, + root: props.hideInstanceTabs ? classes.tabHidden : classes.tabRoot, }} value={TABS_INDEX.LOGS} /> diff --git a/ui/packages/shared/pages/Instance/context.ts b/ui/packages/shared/pages/Instance/context.ts index d8b1fb17..2416a028 100644 --- a/ui/packages/shared/pages/Instance/context.ts +++ b/ui/packages/shared/pages/Instance/context.ts @@ -30,6 +30,7 @@ export type Host = { wsHost?: string hideInstanceTabs?: boolean renderCurrentTab?: number + isPlatform?: boolean } // Host context. diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index 7e976891..fda74b0f 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -14,7 +14,8 @@ import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { TABS_INDEX, Tabs } from './Tabs' +import { TABS_INDEX, Tabs, TabsProps } from './Tabs' +import { PlatformTabs } from './Tabs/PlatformTabs' import { Logs } from '../Logs' import { Clones } from './Clones' import { Info } from './Info' @@ -23,6 +24,7 @@ import { Branches } from '../Branches' import { Configuration } from '../Configuration' import { ClonesModal } from './Clones/ClonesModal' import { SnapshotsModal } from './Snapshots/components/SnapshotsModal' +import { InactiveInstance } from './InactiveInstance' import { Host, HostProvider, StoresProvider } from './context' import Typography from '@material-ui/core/Typography' @@ -65,134 +67,174 @@ export const Instance = observer((props: Props) => { const classes = useStyles() const { instanceId, api } = props + const [activeTab, setActiveTab] = React.useState( + props?.renderCurrentTab || TABS_INDEX.OVERVIEW, + ) const stores = useCreatedStores(props) - const { instance, instanceError, instanceRetrieval, load } = stores.main + const { + instance, + instanceError, + instanceRetrieval, + isLoadingInstance, + load, + } = stores.main + + const switchTab = (_: React.ChangeEvent<{}> | null, tabID: number) => { + const contentElement = document.getElementById('content-container') + setActiveTab(tabID) + + if (tabID === 0) { + load(props.instanceId) + } + contentElement?.scroll(0, 0) + } + + const isInstanceIntegrated = + instanceRetrieval || + (!isLoadingInstance && instance && instance?.url && !instanceError) + + const isConfigurationActive = instanceRetrieval?.mode !== 'physical' + + const InstanceTab = (props: TabsProps) => + !props.isPlatform ? : useEffect(() => { load(instanceId) }, [instanceId]) - const isConfigurationActive = instanceRetrieval?.mode !== 'physical' - useEffect(() => { if ( instance && instance?.state.retrieving?.status === 'pending' && - isConfigurationActive + isConfigurationActive && + !props.isPlatform ) { setActiveTab(TABS_INDEX.CONFIGURATION) } if (instance && !instance?.state?.pools) { if (!props.callbacks) return - - props.callbacks.showDeprecatedApiBanner() - return props.callbacks?.hideDeprecatedApiBanner } }, [instance]) - const [activeTab, setActiveTab] = React.useState( - props?.renderCurrentTab || TABS_INDEX.OVERVIEW, - ) - - const switchTab = (_: React.ChangeEvent<{}> | null, tabID: number) => { - const contentElement = document.getElementById('content-container') - setActiveTab(tabID) - contentElement?.scroll(0, 0) - } - return ( - <> - {props.elements.breadcrumbs} - load(props.instanceId)} - isDisabled={!instance && !instanceError} - className={classes.reloadButton} - > - Reload info - - } - > - load(props.instanceId)} + isDisabled={!instance && !instanceError} + className={classes.reloadButton} + > + Reload info + + } + > + {isInstanceIntegrated && ( + - - - {instanceError && ( - )} - - - {!instanceError && ( -
- {!instance || - (!instance?.state.retrieving?.status && )} - - {instance ? ( - <> - - - - ) : ( - - )} -
+ + + {instanceError && ( + + )} + + {isInstanceIntegrated ? ( + <> + + {!instanceError && ( +
+ {instance && instance?.state.retrieving?.status ? ( + <> + + + + ) : ( + + )} +
+ )} + + + + +
+ + {!props.isPlatform && ( + <> + + {activeTab === TABS_INDEX.CLONES && ( +
+ {!instanceError && + (instance ? ( + + ) : ( + + ))} +
+ )} +
+ + {activeTab === TABS_INDEX.LOGS && } + + + {activeTab === TABS_INDEX.CONFIGURATION && ( + load(props.instanceId)} + disableConfigModification={ + instance?.state.engine.disableConfigModification + } + /> + )} + + + {activeTab === TABS_INDEX.SNAPSHOTS && } + + + {activeTab === TABS_INDEX.BRANCHES && } + + )} - - - - + + ) : !isLoadingInstance && !instanceError ? ( + + - - - {activeTab === TABS_INDEX.CLONES && ( + ) : ( + !instanceError && ( +
- {!instanceError && - (instance ? : )} +
- )} -
- - - {activeTab === TABS_INDEX.LOGS && } - - - - - {activeTab === TABS_INDEX.CONFIGURATION && ( - load(props.instanceId)} - disableConfigModification={ - instance?.state.engine.disableConfigModification - } - /> - )} - - - {activeTab === TABS_INDEX.SNAPSHOTS && } - - - {activeTab === TABS_INDEX.BRANCHES && } - +
+ ) + )}
) }) -function TabPanel(props: any) { +function TabPanel(props: { + children?: React.ReactNode + index: number + value: number +}) { const { children, value, index, ...other } = props return ( diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index 806d54eb..f52d7015 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -81,6 +81,7 @@ export class MainStore { isReloadingInstanceRetrieval = false isBranchesLoading = false isConfigLoading = false + isLoadingInstance = false private readonly api: Api @@ -101,11 +102,13 @@ export class MainStore { this.loadInstance(instanceId) this.getBranches() this.loadInstanceRetrieval(instanceId).then(() => { - this.getConfig().then((res) => { - if (res) { - this.getEngine() - } - }) + if (this.instanceRetrieval) { + this.getConfig().then((res) => { + if (res) { + this.getEngine() + } + }) + } }) this.snapshots.load(instanceId) } @@ -142,6 +145,7 @@ export class MainStore { updateUnstableClones = true, ) => { this.instanceError = null + this.isLoadingInstance = true if (this.api.refreshInstance) await this.api.refreshInstance({ instanceId: instanceId }) @@ -150,6 +154,8 @@ export class MainStore { instanceId: instanceId, }) + this.isLoadingInstance = false + if (response === null) { this.instanceError = { title: 'Error 404', diff --git a/ui/packages/shared/styles/icons.tsx b/ui/packages/shared/styles/icons.tsx index e353f5d7..ec760f63 100644 --- a/ui/packages/shared/styles/icons.tsx +++ b/ui/packages/shared/styles/icons.tsx @@ -1641,4 +1641,197 @@ export const icons = { ), + createDLEIcon: ( + + + + + + + + + + + + + + + + + + + + + + + + + + ), + installDLEIcon: ( + + + + + + + + + + + + + + + + + ), } diff --git a/ui/packages/shared/types/api/endpoints/getEngine.ts b/ui/packages/shared/types/api/endpoints/getEngine.ts index 25aaf9c4..22672da2 100644 --- a/ui/packages/shared/types/api/endpoints/getEngine.ts +++ b/ui/packages/shared/types/api/endpoints/getEngine.ts @@ -1,6 +1,13 @@ -import { Engine } from '@postgres.ai/ce/src/types/api/entities/engine' +export type EngineDto = { + version: string + edition?: string +} export type GetEngine = () => Promise<{ - response: Engine | null + response: EngineType | null error: Response | null }> + +export const formatEngineDto = (dto: EngineDto) => dto + +export type EngineType = ReturnType diff --git a/ui/packages/shared/types/api/entities/instance.ts b/ui/packages/shared/types/api/entities/instance.ts index 1c52ce4a..5587b803 100644 --- a/ui/packages/shared/types/api/entities/instance.ts +++ b/ui/packages/shared/types/api/entities/instance.ts @@ -6,12 +6,15 @@ import { type CoreInstanceDto = { id: number state: InstanceStateDto + plan: string + selfassigned_instance_id: string } type CeInstanceDto = CoreInstanceDto & {} type PlatformInstanceDto = CoreInstanceDto & { created_at: string + telemetry_last_reported_at?: string created_formatted: string iid: number | null is_active: true @@ -28,22 +31,32 @@ type PlatformInstanceDto = CoreInstanceDto & { export type InstanceDto = CeInstanceDto | PlatformInstanceDto export const formatInstanceDto = (dto: InstanceDto) => { - const coreMapped = { id: dto.id.toString(), state: formatInstanceStateDto(dto.state), dto } - - const platformMapped = 'created_at' in dto ? { - createdAt: new Date(dto.created_at), - createdFormatted: `${dto.created_formatted} UTC`, - iid: dto.iid, - isActive: dto.is_active, - orgId: dto.org_id.toString(), - projectAlias: dto.project_alias, - projectId: dto.project_id.toString(), - projectName: dto.project_name, - sshServerUrl: dto.ssh_server_url, - useTunnel: dto.use_tunnel, - verifyToken: dto.verify_token, - url: dto.url, - } : null + const coreMapped = { + id: dto.id.toString(), + state: formatInstanceStateDto(dto.state), + dto, + } + + const platformMapped = + 'created_at' in dto + ? { + createdAt: new Date(dto.created_at), + ...(dto.telemetry_last_reported_at && { + telemetryLastReportedAt: new Date(dto.telemetry_last_reported_at), + }), + createdFormatted: `${dto.created_formatted} UTC`, + iid: dto.iid, + isActive: dto.is_active, + orgId: dto.org_id.toString(), + projectAlias: dto.project_alias, + projectId: dto.project_id.toString(), + projectName: dto.project_name, + sshServerUrl: dto.ssh_server_url, + useTunnel: dto.use_tunnel, + verifyToken: dto.verify_token, + url: dto.url, + } + : null return { ...coreMapped, diff --git a/ui/packages/shared/types/api/entities/instanceState.ts b/ui/packages/shared/types/api/entities/instanceState.ts index ac7a12bb..86b88e59 100644 --- a/ui/packages/shared/types/api/entities/instanceState.ts +++ b/ui/packages/shared/types/api/entities/instanceState.ts @@ -55,9 +55,9 @@ export type InstanceStateDto = { export const formatInstanceStateDto = (dto: InstanceStateDto) => { const pools = dto.pools?.map(formatPoolDto) ?? null const clones = - dto.clones?.map(formatCloneDto) ?? dto.cloning.clones.map(formatCloneDto) + dto?.clones?.map(formatCloneDto) ?? dto.cloning?.clones.map(formatCloneDto) const expectedCloningTime = - dto?.expectedCloningTime ?? dto.cloning.expectedCloningTime + dto?.expectedCloningTime ?? dto.cloning?.expectedCloningTime return { ...dto, diff --git a/ui/packages/shared/utils/date.ts b/ui/packages/shared/utils/date.ts index 21393ba8..4b6c6b92 100644 --- a/ui/packages/shared/utils/date.ts +++ b/ui/packages/shared/utils/date.ts @@ -23,7 +23,11 @@ import { // parseDate parses date of both format: '2006-01-02 15:04:05 UTC' and `2006-01-02T15:04:05Z` (RFC3339). export const parseDate = (dateStr: string) => - parse(dateStr.replace(' UTC', 'Z').replace('T', ' '), 'yyyy-MM-dd HH:mm:ssX', new Date()) + parse( + dateStr.replace(' UTC', 'Z').replace('T', ' '), + 'yyyy-MM-dd HH:mm:ssX', + new Date(), + ) // UTCf - UTC formatted, but not actually UTC. // date-fns using this approach because browser don't have an opportunity to switch single date diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 926cb17f..f473cb3d 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -144,6 +144,8 @@ importers: '@postgres.ai/ce': link:../ce '@postgres.ai/platform': link:./ '@postgres.ai/shared': link:../shared + '@sentry/react': ^6.11.0 + '@sentry/tracing': ^6.11.0 '@stripe/react-stripe-js': ^1.1.2 '@stripe/stripe-js': ^1.9.0 '@tsconfig/recommended': ^1.0.1 @@ -155,6 +157,7 @@ importers: '@types/react-dom': ^17.0.3 '@types/react-router': ^5.1.17 '@types/react-router-dom': ^5.1.7 + '@types/react-syntax-highlighter': ^15.5.6 '@typescript-eslint/eslint-plugin': ^5.6.0 '@typescript-eslint/parser': ^5.6.0 bootstrap: ^4.3.1 @@ -225,6 +228,8 @@ importers: '@postgres.ai/ce': link:../ce '@postgres.ai/platform': 'link:' '@postgres.ai/shared': link:../shared + '@sentry/react': 6.19.7_react@17.0.2 + '@sentry/tracing': 6.19.7 '@stripe/react-stripe-js': 1.6.0_hkj6yydrkt2pne2rq73lc3qjom '@stripe/stripe-js': 1.20.3 '@types/d3': 7.4.0 @@ -235,6 +240,7 @@ importers: '@types/react-dom': 17.0.11 '@types/react-router': 5.1.17 '@types/react-router-dom': 5.3.1 + '@types/react-syntax-highlighter': 15.5.6 bootstrap: 4.6.0_47tpum6wtjtozdoo6t4hr5iro4 byte-size: 7.0.1 classnames: 2.3.1 @@ -4207,6 +4213,84 @@ packages: resolution: {integrity: sha512-LwzQKA4vzIct1zNZzBmRKI9QuNpLgTQMEjsQLf3BXuGYb3QPTP4Yjf6mkdX+X1mYttZ808QpOwAzZjv28kq7DA==} dev: false + /@sentry/browser/6.19.7: + resolution: {integrity: sha512-oDbklp4O3MtAM4mtuwyZLrgO1qDVYIujzNJQzXmi9YzymJCuzMLSRDvhY83NNDCRxf0pds4DShgYeZdbSyKraA==} + engines: {node: '>=6'} + dependencies: + '@sentry/core': 6.19.7 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + tslib: 1.14.1 + dev: false + + /@sentry/core/6.19.7: + resolution: {integrity: sha512-tOfZ/umqB2AcHPGbIrsFLcvApdTm9ggpi/kQZFkej7kMphjT+SGBiQfYtjyg9jcRW+ilAR4JXC9BGKsdEQ+8Vw==} + engines: {node: '>=6'} + dependencies: + '@sentry/hub': 6.19.7 + '@sentry/minimal': 6.19.7 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + tslib: 1.14.1 + dev: false + + /@sentry/hub/6.19.7: + resolution: {integrity: sha512-y3OtbYFAqKHCWezF0EGGr5lcyI2KbaXW2Ik7Xp8Mu9TxbSTuwTe4rTntwg8ngPjUQU3SUHzgjqVB8qjiGqFXCA==} + engines: {node: '>=6'} + dependencies: + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + tslib: 1.14.1 + dev: false + + /@sentry/minimal/6.19.7: + resolution: {integrity: sha512-wcYmSJOdvk6VAPx8IcmZgN08XTXRwRtB1aOLZm+MVHjIZIhHoBGZJYTVQS/BWjldsamj2cX3YGbGXNunaCfYJQ==} + engines: {node: '>=6'} + dependencies: + '@sentry/hub': 6.19.7 + '@sentry/types': 6.19.7 + tslib: 1.14.1 + dev: false + + /@sentry/react/6.19.7_react@17.0.2: + resolution: {integrity: sha512-VzJeBg/v41jfxUYPkH2WYrKjWc4YiMLzDX0f4Zf6WkJ4v3IlDDSkX6DfmWekjTKBho6wiMkSNy2hJ1dHfGZ9jA==} + engines: {node: '>=6'} + peerDependencies: + react: 15.x || 16.x || 17.x || 18.x + dependencies: + '@sentry/browser': 6.19.7 + '@sentry/minimal': 6.19.7 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + hoist-non-react-statics: 3.3.2 + react: 17.0.2 + tslib: 1.14.1 + dev: false + + /@sentry/tracing/6.19.7: + resolution: {integrity: sha512-ol4TupNnv9Zd+bZei7B6Ygnr9N3Gp1PUrNI761QSlHtPC25xXC5ssSD3GMhBgyQrcvpuRcCFHVNNM97tN5cZiA==} + engines: {node: '>=6'} + dependencies: + '@sentry/hub': 6.19.7 + '@sentry/minimal': 6.19.7 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + tslib: 1.14.1 + dev: false + + /@sentry/types/6.19.7: + resolution: {integrity: sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==} + engines: {node: '>=6'} + dev: false + + /@sentry/utils/6.19.7: + resolution: {integrity: sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA==} + engines: {node: '>=6'} + dependencies: + '@sentry/types': 6.19.7 + tslib: 1.14.1 + dev: false + /@sinclair/typebox/0.24.39: resolution: {integrity: sha512-GqtkxoAjhTzoMwFg/JYRl+1+miOoyvp6mkLpbMSd2fIQak2KvY00ndlXxxkDBpuCPYkorZeEZf0LEQn9V9NRVQ==} dev: false From 623c8a584c65e18858a42af5f7ec997984e99b1a Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Tue, 22 Aug 2023 00:32:41 +0400 Subject: [PATCH 037/114] fix the config tab redirect issue --- ui/packages/shared/pages/Instance/index.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/packages/shared/pages/Instance/index.tsx b/ui/packages/shared/pages/Instance/index.tsx index fda74b0f..e5548083 100644 --- a/ui/packages/shared/pages/Instance/index.tsx +++ b/ui/packages/shared/pages/Instance/index.tsx @@ -70,6 +70,7 @@ export const Instance = observer((props: Props) => { const [activeTab, setActiveTab] = React.useState( props?.renderCurrentTab || TABS_INDEX.OVERVIEW, ) + const [hasBeenRedirected, setHasBeenRedirected] = React.useState(false); const stores = useCreatedStores(props) const { @@ -108,14 +109,12 @@ export const Instance = observer((props: Props) => { instance && instance?.state.retrieving?.status === 'pending' && isConfigurationActive && - !props.isPlatform + !props.isPlatform && !hasBeenRedirected ) { setActiveTab(TABS_INDEX.CONFIGURATION) + setHasBeenRedirected(true) } - if (instance && !instance?.state?.pools) { - if (!props.callbacks) return - } - }, [instance]) + }, [instance, hasBeenRedirected]) return ( From c802aaee65a95eb937c6024a36fd5a2b6cf82837 Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Sat, 2 Sep 2023 01:32:10 +0400 Subject: [PATCH 038/114] remove empty div if instace object is empty --- ui/packages/shared/pages/Instance/Configuration/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/packages/shared/pages/Instance/Configuration/index.tsx b/ui/packages/shared/pages/Instance/Configuration/index.tsx index 920fc64e..497beda4 100644 --- a/ui/packages/shared/pages/Instance/Configuration/index.tsx +++ b/ui/packages/shared/pages/Instance/Configuration/index.tsx @@ -252,8 +252,6 @@ export const Configuration = observer( if (!instance && isConfigLoading) return - if (!instance && !isConfigLoading) return <> - return (
Date: Mon, 11 Sep 2023 16:14:40 +0400 Subject: [PATCH 039/114] show snapshots message correctly --- ui/packages/shared/pages/CreateSnapshot/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/shared/pages/CreateSnapshot/index.tsx b/ui/packages/shared/pages/CreateSnapshot/index.tsx index 97202008..f2e074a7 100644 --- a/ui/packages/shared/pages/CreateSnapshot/index.tsx +++ b/ui/packages/shared/pages/CreateSnapshot/index.tsx @@ -193,7 +193,7 @@ export const CreateSnapshotPage = observer( {snapshotError && ( )}
From e0d4e7e56ccfa1f26e4c5c58d1662f11adc8c7b4 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 12 Sep 2023 17:18:43 +0700 Subject: [PATCH 040/114] fix DLE after merging --- engine/cmd/database-lab/main.go | 3 +-- engine/internal/retrieval/engine/postgres/logical/restore.go | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/cmd/database-lab/main.go b/engine/cmd/database-lab/main.go index 17585c10..452bbb5a 100644 --- a/engine/cmd/database-lab/main.go +++ b/engine/cmd/database-lab/main.go @@ -190,7 +190,6 @@ func main() { server := srv.NewServer(&cfg.Server, &cfg.Global, &engProps, docker, cloningSvc, provisioner, retrievalSvc, platformSvc, billingSvc, obs, pm, tm, tokenHolder, logFilter, embeddedUI, reloadConfigFn, webhookChan) - shutdownCh := setShutdownListener() go setReloadListener(ctx, engProps, provisioner, billingSvc, retrievalSvc, pm, cloningSvc, platformSvc, @@ -243,7 +242,7 @@ func main() { go setReloadListener(ctx, engProps, provisioner, billingSvc, retrievalSvc, pm, cloningSvc, platformSvc, embeddedUI, server, - logCleaner, logFilter) + logCleaner, logFilter, whs) go billingSvc.CollectUsage(ctx, systemMetrics) diff --git a/engine/internal/retrieval/engine/postgres/logical/restore.go b/engine/internal/retrieval/engine/postgres/logical/restore.go index a56dc5ce..c3855792 100644 --- a/engine/internal/retrieval/engine/postgres/logical/restore.go +++ b/engine/internal/retrieval/engine/postgres/logical/restore.go @@ -102,6 +102,7 @@ type RestoreOptions struct { DockerImage string `yaml:"dockerImage"` ContainerConfig map[string]interface{} `yaml:"containerConfig"` Databases map[string]DumpDefinition `yaml:"databases"` + ForceInit bool `yaml:"forceInit"` IgnoreErrors bool `yaml:"ignoreErrors"` ParallelJobs int `yaml:"parallelJobs"` Configs map[string]string `yaml:"configs"` From 3b4630ffad6f690629d18403e360d695439c9da6 Mon Sep 17 00:00:00 2001 From: Vitaliy Kukharik Date: Tue, 12 Sep 2023 18:27:48 +0000 Subject: [PATCH 041/114] DLE: PostgreSQL 16 support --- engine/.gitlab-ci.yml | 5 + .../standard/postgres/default/16/pg_hba.conf | 133 +++ .../16/postgresql.dblab.postgresql.conf | 822 ++++++++++++++++++ engine/test/_cleanup.sh | 3 +- 4 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 engine/configs/standard/postgres/default/16/pg_hba.conf create mode 100644 engine/configs/standard/postgres/default/16/postgresql.dblab.postgresql.conf diff --git a/engine/.gitlab-ci.yml b/engine/.gitlab-ci.yml index 75cdcead..0be9891e 100644 --- a/engine/.gitlab-ci.yml +++ b/engine/.gitlab-ci.yml @@ -464,6 +464,11 @@ bash-test-15: variables: POSTGRES_VERSION: 15 +bash-test-16: + <<: *bash_test + variables: + POSTGRES_VERSION: 16rc1 + integration-test: services: - name: docker:dind diff --git a/engine/configs/standard/postgres/default/16/pg_hba.conf b/engine/configs/standard/postgres/default/16/pg_hba.conf new file mode 100644 index 00000000..59dfe5d3 --- /dev/null +++ b/engine/configs/standard/postgres/default/16/pg_hba.conf @@ -0,0 +1,133 @@ +# PostgreSQL Client Authentication Configuration File +# =================================================== +# +# Refer to the "Client Authentication" section in the PostgreSQL +# documentation for a complete description of this file. A short +# synopsis follows. +# +# ---------------------- +# Authentication Records +# ---------------------- +# +# This file controls: which hosts are allowed to connect, how clients +# are authenticated, which PostgreSQL user names they can use, which +# databases they can access. Records take one of these forms: +# +# local DATABASE USER METHOD [OPTIONS] +# host DATABASE USER ADDRESS METHOD [OPTIONS] +# hostssl DATABASE USER ADDRESS METHOD [OPTIONS] +# hostnossl DATABASE USER ADDRESS METHOD [OPTIONS] +# hostgssenc DATABASE USER ADDRESS METHOD [OPTIONS] +# hostnogssenc DATABASE USER ADDRESS METHOD [OPTIONS] +# +# (The uppercase items must be replaced by actual values.) +# +# The first field is the connection type: +# - "local" is a Unix-domain socket +# - "host" is a TCP/IP socket (encrypted or not) +# - "hostssl" is a TCP/IP socket that is SSL-encrypted +# - "hostnossl" is a TCP/IP socket that is not SSL-encrypted +# - "hostgssenc" is a TCP/IP socket that is GSSAPI-encrypted +# - "hostnogssenc" is a TCP/IP socket that is not GSSAPI-encrypted +# +# DATABASE can be "all", "sameuser", "samerole", "replication", a +# database name, a regular expression (if it starts with a slash (/)) +# or a comma-separated list thereof. The "all" keyword does not match +# "replication". Access to replication must be enabled in a separate +# record (see example below). +# +# USER can be "all", a user name, a group name prefixed with "+", a +# regular expression (if it starts with a slash (/)) or a comma-separated +# list thereof. In both the DATABASE and USER fields you can also write +# a file name prefixed with "@" to include names from a separate file. +# +# ADDRESS specifies the set of hosts the record matches. It can be a +# host name, or it is made up of an IP address and a CIDR mask that is +# an integer (between 0 and 32 (IPv4) or 128 (IPv6) inclusive) that +# specifies the number of significant bits in the mask. A host name +# that starts with a dot (.) matches a suffix of the actual host name. +# Alternatively, you can write an IP address and netmask in separate +# columns to specify the set of hosts. Instead of a CIDR-address, you +# can write "samehost" to match any of the server's own IP addresses, +# or "samenet" to match any address in any subnet that the server is +# directly connected to. +# +# METHOD can be "trust", "reject", "md5", "password", "scram-sha-256", +# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert". +# Note that "password" sends passwords in clear text; "md5" or +# "scram-sha-256" are preferred since they send encrypted passwords. +# +# OPTIONS are a set of options for the authentication in the format +# NAME=VALUE. The available options depend on the different +# authentication methods -- refer to the "Client Authentication" +# section in the documentation for a list of which options are +# available for which authentication methods. +# +# Database and user names containing spaces, commas, quotes and other +# special characters must be quoted. Quoting one of the keywords +# "all", "sameuser", "samerole" or "replication" makes the name lose +# its special character, and just match a database or username with +# that name. +# +# --------------- +# Include Records +# --------------- +# +# This file allows the inclusion of external files or directories holding +# more records, using the following keywords: +# +# include FILE +# include_if_exists FILE +# include_dir DIRECTORY +# +# FILE is the file name to include, and DIR is the directory name containing +# the file(s) to include. Any file in a directory will be loaded if suffixed +# with ".conf". The files of a directory are ordered by name. +# include_if_exists ignores missing files. FILE and DIRECTORY can be +# specified as a relative or an absolute path, and can be double-quoted if +# they contain spaces. +# +# ------------- +# Miscellaneous +# ------------- +# +# This file is read on server startup and when the server receives a +# SIGHUP signal. If you edit the file on a running system, you have to +# SIGHUP the server for the changes to take effect, run "pg_ctl reload", +# or execute "SELECT pg_reload_conf()". +# +# ---------------------------------- +# Put your actual configuration here +# ---------------------------------- +# +# If you want to allow non-local connections, you need to add more +# "host" records. In that case you will also need to make PostgreSQL +# listen on a non-local interface via the listen_addresses +# configuration parameter, or via the -i or -h command line switches. + + + + +# DO NOT DISABLE! +# If you change this first entry you will need to make sure that the +# database superuser can access the database using some other method. +# Noninteractive access to all databases is required during automatic +# maintenance (custom daily cronjobs, replication, and similar tasks). +# +# Database administrative login by Unix domain socket + +# TYPE DATABASE USER ADDRESS METHOD + +# "local" is for Unix domain socket connections only +local all all trust +# IPv4 local connections: +host all all 127.0.0.1/32 trust +# IPv6 local connections: +host all all ::1/128 trust +# Allow replication connections from localhost, by a user with the +# replication privilege. +local replication all trust +host replication all 127.0.0.1/32 trust +host replication all ::1/128 trust + +host all all all scram-sha-256 diff --git a/engine/configs/standard/postgres/default/16/postgresql.dblab.postgresql.conf b/engine/configs/standard/postgres/default/16/postgresql.dblab.postgresql.conf new file mode 100644 index 00000000..3f07a2f0 --- /dev/null +++ b/engine/configs/standard/postgres/default/16/postgresql.dblab.postgresql.conf @@ -0,0 +1,822 @@ +# ----------------------------- +# PostgreSQL configuration file +# ----------------------------- +# +# This file consists of lines of the form: +# +# name = value +# +# (The "=" is optional.) Whitespace may be used. Comments are introduced with +# "#" anywhere on a line. The complete list of parameter names and allowed +# values can be found in the PostgreSQL documentation. +# +# The commented-out settings shown in this file represent the default values. +# Re-commenting a setting is NOT sufficient to revert it to the default value; +# you need to reload the server. +# +# This file is read on server startup and when the server receives a SIGHUP +# signal. If you edit the file on a running system, you have to SIGHUP the +# server for the changes to take effect, run "pg_ctl reload", or execute +# "SELECT pg_reload_conf()". Some parameters, which are marked below, +# require a server shutdown and restart to take effect. +# +# Any parameter can also be given as a command-line option to the server, e.g., +# "postgres -c log_connections=on". Some parameters can be changed at run time +# with the "SET" SQL command. +# +# Memory units: B = bytes Time units: us = microseconds +# kB = kilobytes ms = milliseconds +# MB = megabytes s = seconds +# GB = gigabytes min = minutes +# TB = terabytes h = hours +# d = days + + +#------------------------------------------------------------------------------ +# FILE LOCATIONS +#------------------------------------------------------------------------------ + +# The default values of these variables are driven from the -D command-line +# option or PGDATA environment variable, represented here as ConfigDir. + +#data_directory = 'ConfigDir' # use data in another directory + # (change requires restart) +#hba_file = 'ConfigDir/pg_hba.conf' # host-based authentication file + # (change requires restart) +#ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file + # (change requires restart) + +# If external_pid_file is not explicitly set, no extra PID file is written. +#external_pid_file = '' # write an extra PID file + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONNECTIONS AND AUTHENTICATION +#------------------------------------------------------------------------------ + +# - Connection Settings - + +listen_addresses = '*' # what IP address(es) to listen on; + # comma-separated list of addresses; + # defaults to 'localhost'; use '*' for all + # (change requires restart) +#port = 5432 # (change requires restart) +max_connections = 100 # (change requires restart) +#reserved_connections = 0 # (change requires restart) +#superuser_reserved_connections = 3 # (change requires restart) +#unix_socket_directories = '/var/run/postgresql' # comma-separated list of directories + # (change requires restart) +#unix_socket_group = '' # (change requires restart) +#unix_socket_permissions = 0777 # begin with 0 to use octal notation + # (change requires restart) +#bonjour = off # advertise server via Bonjour + # (change requires restart) +#bonjour_name = '' # defaults to the computer name + # (change requires restart) + +# - TCP settings - +# see "man tcp" for details + +#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; + # 0 selects the system default +#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; + # 0 selects the system default +#tcp_keepalives_count = 0 # TCP_KEEPCNT; + # 0 selects the system default +#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds; + # 0 selects the system default + +#client_connection_check_interval = 0 # time between checks for client + # disconnection while running queries; + # 0 for never + +# - Authentication - + +#authentication_timeout = 1min # 1s-600s +#password_encryption = scram-sha-256 # scram-sha-256 or md5 +#scram_iterations = 4096 +#db_user_namespace = off + +# GSSAPI using Kerberos +#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab' +#krb_caseins_users = off +#gss_accept_delegation = off + +# - SSL - + +#ssl = off +#ssl_ca_file = '' +#ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem' +#ssl_crl_file = '' +#ssl_crl_dir = '' +#ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key' +#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers +#ssl_prefer_server_ciphers = on +#ssl_ecdh_curve = 'prime256v1' +#ssl_min_protocol_version = 'TLSv1.2' +#ssl_max_protocol_version = '' +#ssl_dh_params_file = '' +#ssl_passphrase_command = '' +#ssl_passphrase_command_supports_reload = off + + +#------------------------------------------------------------------------------ +# RESOURCE USAGE (except WAL) +#------------------------------------------------------------------------------ + +# - Memory - + +shared_buffers = 128MB # min 128kB + # (change requires restart) +#huge_pages = try # on, off, or try + # (change requires restart) +#huge_page_size = 0 # zero for system default + # (change requires restart) +#temp_buffers = 8MB # min 800kB +#max_prepared_transactions = 0 # zero disables the feature + # (change requires restart) +# Caution: it is not advisable to set max_prepared_transactions nonzero unless +# you actively intend to use prepared transactions. +#work_mem = 4MB # min 64kB +#hash_mem_multiplier = 2.0 # 1-1000.0 multiplier on hash table work_mem +#maintenance_work_mem = 64MB # min 1MB +#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem +#logical_decoding_work_mem = 64MB # min 64kB +#max_stack_depth = 2MB # min 100kB +#shared_memory_type = mmap # the default is the first option + # supported by the operating system: + # mmap + # sysv + # windows + # (change requires restart) +dynamic_shared_memory_type = posix # the default is usually the first option + # supported by the operating system: + # posix + # sysv + # windows + # mmap + # (change requires restart) +#min_dynamic_shared_memory = 0MB # (change requires restart) +#vacuum_buffer_usage_limit = 256kB # size of vacuum and analyze buffer access strategy ring; + # 0 to disable vacuum buffer access strategy; + # range 128kB to 16GB + +# - Disk - + +#temp_file_limit = -1 # limits per-process temp file space + # in kilobytes, or -1 for no limit + +# - Kernel Resources - + +#max_files_per_process = 1000 # min 64 + # (change requires restart) + +# - Cost-Based Vacuum Delay - + +#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables) +#vacuum_cost_page_hit = 1 # 0-10000 credits +#vacuum_cost_page_miss = 2 # 0-10000 credits +#vacuum_cost_page_dirty = 20 # 0-10000 credits +#vacuum_cost_limit = 200 # 1-10000 credits + +# - Background Writer - + +#bgwriter_delay = 200ms # 10-10000ms between rounds +#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables +#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round +#bgwriter_flush_after = 512kB # measured in pages, 0 disables + +# - Asynchronous Behavior - + +#backend_flush_after = 0 # measured in pages, 0 disables +#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching +#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching +#max_worker_processes = 8 # (change requires restart) +#max_parallel_workers_per_gather = 2 # taken from max_parallel_workers +#max_parallel_maintenance_workers = 2 # taken from max_parallel_workers +#max_parallel_workers = 8 # maximum number of max_worker_processes that + # can be used in parallel operations +#parallel_leader_participation = on +#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate + # (change requires restart) + + +#------------------------------------------------------------------------------ +# WRITE-AHEAD LOG +#------------------------------------------------------------------------------ + +# - Settings - + +#wal_level = replica # minimal, replica, or logical + # (change requires restart) +#fsync = on # flush data to disk for crash safety + # (turning this off can cause + # unrecoverable data corruption) +#synchronous_commit = on # synchronization level; + # off, local, remote_write, remote_apply, or on +#wal_sync_method = fsync # the default is the first option + # supported by the operating system: + # open_datasync + # fdatasync (default on Linux and FreeBSD) + # fsync + # fsync_writethrough + # open_sync +#full_page_writes = on # recover from partial page writes +#wal_log_hints = off # also do full page writes of non-critical updates + # (change requires restart) +#wal_compression = off # enables compression of full-page writes; + # off, pglz, lz4, zstd, or on +#wal_init_zero = on # zero-fill new WAL files +#wal_recycle = on # recycle WAL files +#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers + # (change requires restart) +#wal_writer_delay = 200ms # 1-10000 milliseconds +#wal_writer_flush_after = 1MB # measured in pages, 0 disables +#wal_skip_threshold = 2MB + +#commit_delay = 0 # range 0-100000, in microseconds +#commit_siblings = 5 # range 1-1000 + +# - Checkpoints - + +#checkpoint_timeout = 5min # range 30s-1d +#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0 +#checkpoint_flush_after = 256kB # measured in pages, 0 disables +#checkpoint_warning = 30s # 0 disables +max_wal_size = 1GB +min_wal_size = 80MB + +# - Prefetching during recovery - + +#recovery_prefetch = try # prefetch pages referenced in the WAL? +#wal_decode_buffer_size = 512kB # lookahead window used for prefetching + # (change requires restart) + +# - Archiving - + +#archive_mode = off # enables archiving; off, on, or always + # (change requires restart) +#archive_library = '' # library to use to archive a WAL file + # (empty string indicates archive_command should + # be used) +#archive_command = '' # command to use to archive a WAL file + # placeholders: %p = path of file to archive + # %f = file name only + # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' +#archive_timeout = 0 # force a WAL file switch after this + # number of seconds; 0 disables + +# - Archive Recovery - + +# These are only used in recovery mode. + +#restore_command = '' # command to use to restore an archived WAL file + # placeholders: %p = path of file to restore + # %f = file name only + # e.g. 'cp /mnt/server/archivedir/%f %p' +#archive_cleanup_command = '' # command to execute at every restartpoint +#recovery_end_command = '' # command to execute at completion of recovery + +# - Recovery Target - + +# Set these only when performing a targeted recovery. + +#recovery_target = '' # 'immediate' to end recovery as soon as a + # consistent state is reached + # (change requires restart) +#recovery_target_name = '' # the named restore point to which recovery will proceed + # (change requires restart) +#recovery_target_time = '' # the time stamp up to which recovery will proceed + # (change requires restart) +#recovery_target_xid = '' # the transaction ID up to which recovery will proceed + # (change requires restart) +#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed + # (change requires restart) +#recovery_target_inclusive = on # Specifies whether to stop: + # just after the specified recovery target (on) + # just before the recovery target (off) + # (change requires restart) +#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID + # (change requires restart) +#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown' + # (change requires restart) + + +#------------------------------------------------------------------------------ +# REPLICATION +#------------------------------------------------------------------------------ + +# - Sending Servers - + +# Set these on the primary and on any standby that will send replication data. + +#max_wal_senders = 10 # max number of walsender processes + # (change requires restart) +#max_replication_slots = 10 # max number of replication slots + # (change requires restart) +#wal_keep_size = 0 # in megabytes; 0 disables +#max_slot_wal_keep_size = -1 # in megabytes; -1 disables +#wal_sender_timeout = 60s # in milliseconds; 0 disables +#track_commit_timestamp = off # collect timestamp of transaction commit + # (change requires restart) + +# - Primary Server - + +# These settings are ignored on a standby server. + +#synchronous_standby_names = '' # standby servers that provide sync rep + # method to choose sync standbys, number of sync standbys, + # and comma-separated list of application_name + # from standby(s); '*' = all + +# - Standby Servers - + +# These settings are ignored on a primary server. + +#primary_conninfo = '' # connection string to sending server +#primary_slot_name = '' # replication slot on sending server +#hot_standby = on # "off" disallows queries during recovery + # (change requires restart) +#max_standby_archive_delay = 30s # max delay before canceling queries + # when reading WAL from archive; + # -1 allows indefinite delay +#max_standby_streaming_delay = 30s # max delay before canceling queries + # when reading streaming WAL; + # -1 allows indefinite delay +#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name + # is not set +#wal_receiver_status_interval = 10s # send replies at least this often + # 0 disables +#hot_standby_feedback = off # send info from standby to prevent + # query conflicts +#wal_receiver_timeout = 60s # time that receiver waits for + # communication from primary + # in milliseconds; 0 disables +#wal_retrieve_retry_interval = 5s # time to wait before retrying to + # retrieve WAL after a failed attempt +#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery + +# - Subscribers - + +# These settings are ignored on a publisher. + +#max_logical_replication_workers = 4 # taken from max_worker_processes + # (change requires restart) +#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers +#max_parallel_apply_workers_per_subscription = 2 # taken from max_logical_replication_workers + + +#------------------------------------------------------------------------------ +# QUERY TUNING +#------------------------------------------------------------------------------ + +# - Planner Method Configuration - + +#enable_async_append = on +#enable_bitmapscan = on +#enable_gathermerge = on +#enable_hashagg = on +#enable_hashjoin = on +#enable_incremental_sort = on +#enable_indexscan = on +#enable_indexonlyscan = on +#enable_material = on +#enable_memoize = on +#enable_mergejoin = on +#enable_nestloop = on +#enable_parallel_append = on +#enable_parallel_hash = on +#enable_partition_pruning = on +#enable_partitionwise_join = off +#enable_partitionwise_aggregate = off +#enable_presorted_aggregate = on +#enable_seqscan = on +#enable_sort = on +#enable_tidscan = on + +# - Planner Cost Constants - + +#seq_page_cost = 1.0 # measured on an arbitrary scale +#random_page_cost = 4.0 # same scale as above +#cpu_tuple_cost = 0.01 # same scale as above +#cpu_index_tuple_cost = 0.005 # same scale as above +#cpu_operator_cost = 0.0025 # same scale as above +#parallel_setup_cost = 1000.0 # same scale as above +#parallel_tuple_cost = 0.1 # same scale as above +#min_parallel_table_scan_size = 8MB +#min_parallel_index_scan_size = 512kB +#effective_cache_size = 4GB + +#jit_above_cost = 100000 # perform JIT compilation if available + # and query more expensive than this; + # -1 disables +#jit_inline_above_cost = 500000 # inline small functions if query is + # more expensive than this; -1 disables +#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if + # query is more expensive than this; + # -1 disables + +# - Genetic Query Optimizer - + +#geqo = on +#geqo_threshold = 12 +#geqo_effort = 5 # range 1-10 +#geqo_pool_size = 0 # selects default based on effort +#geqo_generations = 0 # selects default based on effort +#geqo_selection_bias = 2.0 # range 1.5-2.0 +#geqo_seed = 0.0 # range 0.0-1.0 + +# - Other Planner Options - + +#default_statistics_target = 100 # range 1-10000 +#constraint_exclusion = partition # on, off, or partition +#cursor_tuple_fraction = 0.1 # range 0.0-1.0 +#from_collapse_limit = 8 +#jit = on # allow JIT compilation +#join_collapse_limit = 8 # 1 disables collapsing of explicit + # JOIN clauses +#plan_cache_mode = auto # auto, force_generic_plan or + # force_custom_plan +#recursive_worktable_factor = 10.0 # range 0.001-1000000 + + +#------------------------------------------------------------------------------ +# REPORTING AND LOGGING +#------------------------------------------------------------------------------ + +# - Where to Log - + +#log_destination = 'stderr' # Valid values are combinations of + # stderr, csvlog, jsonlog, syslog, and + # eventlog, depending on platform. + # csvlog and jsonlog require + # logging_collector to be on. + +# This is used when logging to stderr: +#logging_collector = off # Enable capturing of stderr, jsonlog, + # and csvlog into log files. Required + # to be on for csvlogs and jsonlogs. + # (change requires restart) + +# These are only used if logging_collector is on: +#log_directory = 'log' # directory where log files are written, + # can be absolute or relative to PGDATA +#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, + # can include strftime() escapes +#log_file_mode = 0600 # creation mode for log files, + # begin with 0 to use octal notation +#log_rotation_age = 1d # Automatic rotation of logfiles will + # happen after that time. 0 disables. +#log_rotation_size = 10MB # Automatic rotation of logfiles will + # happen after that much log output. + # 0 disables. +#log_truncate_on_rotation = off # If on, an existing log file with the + # same name as the new log file will be + # truncated rather than appended to. + # But such truncation only occurs on + # time-driven rotation, not on restarts + # or size-driven rotation. Default is + # off, meaning append to existing files + # in all cases. + +# These are relevant when logging to syslog: +#syslog_facility = 'LOCAL0' +#syslog_ident = 'postgres' +#syslog_sequence_numbers = on +#syslog_split_messages = on + +# This is only relevant when logging to eventlog (Windows): +# (change requires restart) +#event_source = 'PostgreSQL' + +# - When to Log - + +#log_min_messages = warning # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic + +#log_min_error_statement = error # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic (effectively off) + +#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements + # and their durations, > 0 logs only + # statements running at least this number + # of milliseconds + +#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements + # and their durations, > 0 logs only a sample of + # statements running at least this number + # of milliseconds; + # sample fraction is determined by log_statement_sample_rate + +#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding + # log_min_duration_sample to be logged; + # 1.0 logs all such statements, 0.0 never logs + + +#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements + # are logged regardless of their duration; 1.0 logs all + # statements from all transactions, 0.0 never logs + +#log_startup_progress_interval = 10s # Time between progress updates for + # long-running startup operations. + # 0 disables the feature, > 0 indicates + # the interval in milliseconds. + +# - What to Log - + +#debug_print_parse = off +#debug_print_rewritten = off +#debug_print_plan = off +#debug_pretty_print = on +#log_autovacuum_min_duration = 10min # log autovacuum activity; + # -1 disables, 0 logs all actions and + # their durations, > 0 logs only + # actions running at least this number + # of milliseconds. +#log_checkpoints = on +#log_connections = off +#log_disconnections = off +#log_duration = off +#log_error_verbosity = default # terse, default, or verbose messages +#log_hostname = off +#log_line_prefix = '%m [%p] %q%u@%d ' # special values: + # %a = application name + # %u = user name + # %d = database name + # %r = remote host and port + # %h = remote host + # %b = backend type + # %p = process ID + # %P = process ID of parallel group leader + # %t = timestamp without milliseconds + # %m = timestamp with milliseconds + # %n = timestamp with milliseconds (as a Unix epoch) + # %Q = query ID (0 if none or not computed) + # %i = command tag + # %e = SQL state + # %c = session ID + # %l = session line number + # %s = session start timestamp + # %v = virtual transaction ID + # %x = transaction ID (0 if none) + # %q = stop here in non-session + # processes + # %% = '%' + # e.g. '<%u%%%d> ' +#log_lock_waits = off # log lock waits >= deadlock_timeout +#log_recovery_conflict_waits = off # log standby recovery conflict waits + # >= deadlock_timeout +#log_parameter_max_length = -1 # when logging statements, limit logged + # bind-parameter values to N bytes; + # -1 means print in full, 0 disables +#log_parameter_max_length_on_error = 0 # when logging an error, limit logged + # bind-parameter values to N bytes; + # -1 means print in full, 0 disables +#log_statement = 'none' # none, ddl, mod, all +#log_replication_commands = off +#log_temp_files = -1 # log temporary files equal or larger + # than the specified size in kilobytes; + # -1 disables, 0 logs all temp files +log_timezone = 'Etc/UTC' + +# - Process Title - + +cluster_name = '16/main' # added to process titles if nonempty + # (change requires restart) +#update_process_title = on + + +#------------------------------------------------------------------------------ +# STATISTICS +#------------------------------------------------------------------------------ + +# - Cumulative Query and Index Statistics - + +#track_activities = on +#track_activity_query_size = 1024 # (change requires restart) +#track_counts = on +#track_io_timing = off +#track_wal_io_timing = off +#track_functions = none # none, pl, all +#stats_fetch_consistency = cache # cache, none, snapshot + + +# - Monitoring - + +#compute_query_id = auto +#log_statement_stats = off +#log_parser_stats = off +#log_planner_stats = off +#log_executor_stats = off + + +#------------------------------------------------------------------------------ +# AUTOVACUUM +#------------------------------------------------------------------------------ + +#autovacuum = on # Enable autovacuum subprocess? 'on' + # requires track_counts to also be on. +#autovacuum_max_workers = 3 # max number of autovacuum subprocesses + # (change requires restart) +#autovacuum_naptime = 1min # time between autovacuum runs +#autovacuum_vacuum_threshold = 50 # min number of row updates before + # vacuum +#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts + # before vacuum; -1 disables insert + # vacuums +#autovacuum_analyze_threshold = 50 # min number of row updates before + # analyze +#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum +#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table + # size before insert vacuum +#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze +#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum + # (change requires restart) +#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age + # before forced vacuum + # (change requires restart) +#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for + # autovacuum, in milliseconds; + # -1 means use vacuum_cost_delay +#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for + # autovacuum, -1 means use + # vacuum_cost_limit + + +#------------------------------------------------------------------------------ +# CLIENT CONNECTION DEFAULTS +#------------------------------------------------------------------------------ + +# - Statement Behavior - + +#client_min_messages = notice # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # log + # notice + # warning + # error +#search_path = '"$user", public' # schema names +#row_security = on +#default_table_access_method = 'heap' +#default_tablespace = '' # a tablespace name, '' uses the default +#default_toast_compression = 'pglz' # 'pglz' or 'lz4' +#temp_tablespaces = '' # a list of tablespace names, '' uses + # only default tablespace +#check_function_bodies = on +#default_transaction_isolation = 'read committed' +#default_transaction_read_only = off +#default_transaction_deferrable = off +#session_replication_role = 'origin' +#statement_timeout = 0 # in milliseconds, 0 is disabled +#lock_timeout = 0 # in milliseconds, 0 is disabled +#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled +#idle_session_timeout = 0 # in milliseconds, 0 is disabled +#vacuum_freeze_table_age = 150000000 +#vacuum_freeze_min_age = 50000000 +#vacuum_failsafe_age = 1600000000 +#vacuum_multixact_freeze_table_age = 150000000 +#vacuum_multixact_freeze_min_age = 5000000 +#vacuum_multixact_failsafe_age = 1600000000 +#bytea_output = 'hex' # hex, escape +#xmlbinary = 'base64' +#xmloption = 'content' +#gin_pending_list_limit = 4MB +#createrole_self_grant = '' # set and/or inherit + +# - Locale and Formatting - + +datestyle = 'iso, mdy' +#intervalstyle = 'postgres' +timezone = 'Etc/UTC' +#timezone_abbreviations = 'Default' # Select the set of available time zone + # abbreviations. Currently, there are + # Default + # Australia (historical usage) + # India + # You can create your own file in + # share/timezonesets/. +#extra_float_digits = 1 # min -15, max 3; any value >0 actually + # selects precise output mode +#client_encoding = sql_ascii # actually, defaults to database + # encoding + +# These settings are initialized by initdb, but they can be changed. +lc_messages = 'en_US.UTF-8' # locale for system error message + # strings +lc_monetary = 'en_US.UTF-8' # locale for monetary formatting +lc_numeric = 'en_US.UTF-8' # locale for number formatting +lc_time = 'en_US.UTF-8' # locale for time formatting + +#icu_validation_level = warning # report ICU locale validation + # errors at the given level + +# default configuration for text search +default_text_search_config = 'pg_catalog.english' + +# - Shared Library Preloading - + +#local_preload_libraries = '' +#session_preload_libraries = '' +#shared_preload_libraries = '' # (change requires restart) +#jit_provider = 'llvmjit' # JIT library to use + +# - Other Defaults - + +#dynamic_library_path = '$libdir' +#extension_destdir = '' # prepend path when loading extensions + # and shared objects (added by Debian) +#gin_fuzzy_search_limit = 0 + + +#------------------------------------------------------------------------------ +# LOCK MANAGEMENT +#------------------------------------------------------------------------------ + +#deadlock_timeout = 1s +#max_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_relation = -2 # negative values mean + # (max_pred_locks_per_transaction + # / -max_pred_locks_per_relation) - 1 +#max_pred_locks_per_page = 2 # min 0 + + +#------------------------------------------------------------------------------ +# VERSION AND PLATFORM COMPATIBILITY +#------------------------------------------------------------------------------ + +# - Previous PostgreSQL Versions - + +#array_nulls = on +#backslash_quote = safe_encoding # on, off, or safe_encoding +#escape_string_warning = on +#lo_compat_privileges = off +#quote_all_identifiers = off +#standard_conforming_strings = on +#synchronize_seqscans = on + +# - Other Platforms and Clients - + +#transform_null_equals = off + + +#------------------------------------------------------------------------------ +# ERROR HANDLING +#------------------------------------------------------------------------------ + +#exit_on_error = off # terminate session on any error? +#restart_after_crash = on # reinitialize after backend crash? +#data_sync_retry = off # retry or panic on failure to fsync + # data? + # (change requires restart) +#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+) + + +#------------------------------------------------------------------------------ +# CONFIG FILE INCLUDES +#------------------------------------------------------------------------------ + +# These options allow settings to be loaded from files other than the +# default postgresql.conf. Note that these are directives, not variable +# assignments, so they can usefully be given more than once. + +#include_dir = 'conf.d' # include files ending in '.conf' from + # a directory, e.g., 'conf.d' +#include_if_exists = '...' # include file only if it exists +#include = '...' # include file + + +#------------------------------------------------------------------------------ +# CUSTOMIZED OPTIONS +#------------------------------------------------------------------------------ + +# Add settings for extensions here diff --git a/engine/test/_cleanup.sh b/engine/test/_cleanup.sh index 4d92cd5d..b9c234a1 100644 --- a/engine/test/_cleanup.sh +++ b/engine/test/_cleanup.sh @@ -33,5 +33,4 @@ sudo zpool destroy test_dblab_pool \ sudo rm -f "${ZFS_FILE}" # Remove CLI configuration -dblab config remove test \ - || echo "Cannot remove CLI configuration but this was optional (ignore the error)." +dblab config remove test || { echo "Cannot remove CLI configuration but this was optional (ignore the error)."; } From 9e63ca49cd6f1e78fc0754da95fcebe668aba591 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 14 Nov 2023 11:59:36 +0700 Subject: [PATCH 042/114] update pnpm lock file --- ui/pnpm-lock.yaml | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 09d746ad..a7f63c04 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: '@types/react-router-dom': specifier: ^5.3.1 version: 5.3.1 + '@types/react-syntax-highlighter': + specifier: ^15.5.6 + version: 15.5.6 byte-size: specifier: ^8.1.0 version: 8.1.0 @@ -118,6 +121,9 @@ importers: react-scripts: specifier: ^5.0.0 version: 5.0.1(@babel/plugin-syntax-flow@7.18.6)(@babel/plugin-transform-react-jsx@7.19.0)(acorn@8.8.0)(eslint@8.9.0)(react@17.0.2)(sass@1.43.2)(typescript@4.5.5) + react-syntax-highlighter: + specifier: ^15.5.0 + version: 15.5.0(react@17.0.2) stream-browserify: specifier: ^3.0.0 version: 3.0.0 @@ -5020,7 +5026,7 @@ packages: /@types/react-is@17.0.3: resolution: {integrity: sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==} dependencies: - '@types/react': 18.0.18 + '@types/react': 17.0.39 dev: false /@types/react-router-dom@5.3.1: @@ -5041,19 +5047,19 @@ packages: /@types/react-syntax-highlighter@15.5.6: resolution: {integrity: sha512-i7wFuLbIAFlabTeD2I1cLjEOrG/xdMa/rpx2zwzAoGHuXJDhSqp9BSfDlMHSh9JSuNfxHk9eEmMX6D55GiyjGg==} dependencies: - '@types/react': 18.0.18 + '@types/react': 17.0.39 dev: false /@types/react-transition-group@4.4.3: resolution: {integrity: sha512-fUx5muOWSYP8Bw2BUQ9M9RK9+W1XBK/7FLJ8PTQpnpTEkn0ccyMffyEQvan4C3h53gHdx7KE5Qrxi/LnUGQtdg==} dependencies: - '@types/react': 18.0.18 + '@types/react': 17.0.39 dev: false /@types/react-transition-group@4.4.5: resolution: {integrity: sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==} dependencies: - '@types/react': 18.0.18 + '@types/react': 17.0.39 dev: false /@types/react@17.0.39: @@ -15195,19 +15201,6 @@ packages: refractor: 3.6.0 dev: false - /react-syntax-highlighter/15.5.0_react@17.0.2: - resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==} - peerDependencies: - react: '>= 0.14.0' - dependencies: - '@babel/runtime': 7.19.0 - highlight.js: 10.7.3 - lowlight: 1.20.0 - prismjs: 1.29.0 - react: 17.0.2 - refractor: 3.6.0 - dev: false - /react-transition-group@2.9.0(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} peerDependencies: From 818dae6eb35242d0729b9b6f921470c65345b87b Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Thu, 30 Nov 2023 07:13:11 +0000 Subject: [PATCH 043/114] Branching datasets --- engine/cmd/cli/commands/branch/actions.go | 33 +++++ engine/cmd/database-lab/main.go | 5 - engine/internal/cloning/base.go | 31 +++-- engine/internal/cloning/snapshots.go | 19 ++- engine/internal/cloning/snapshots_test.go | 2 +- engine/internal/cloning/storage.go | 6 +- engine/internal/observer/observer.go | 10 +- engine/internal/observer/observing_clone.go | 2 +- engine/internal/observer/sql.go | 17 +-- engine/internal/provision/mode_local.go | 30 ++--- engine/internal/provision/pool/manager.go | 1 - .../internal/provision/resources/appconfig.go | 1 + engine/internal/provision/resources/pool.go | 10 +- .../provision/thinclones/lvm/lvmanager.go | 7 - .../provision/thinclones/zfs/branching.go | 46 +++---- .../internal/provision/thinclones/zfs/zfs.go | 2 +- engine/internal/runci/handlers.go | 3 +- engine/internal/srv/branch.go | 98 ++++++++------ engine/internal/srv/routes.go | 42 ++++-- engine/pkg/client/dblabapi/branch.go | 2 +- engine/pkg/util/branching/branching.go | 9 ++ engine/pkg/util/clones.go | 14 -- engine/test/1.synthetic.sh | 17 ++- engine/test/2.logical_generic.sh | 6 +- engine/test/3.physical_walg.sh | 6 +- engine/test/4.physical_basebackup.sh | 6 +- engine/test/5.logical_rds.sh | 6 +- ui/.gitlab-ci.yml | 2 +- .../CreateDbLabCards/CreateDbLabCards.tsx | 1 - .../DbLabFormSteps/SimpleInstance.tsx | 2 +- .../DbLabInstanceForm/utils/index.ts | 121 ------------------ 31 files changed, 247 insertions(+), 310 deletions(-) create mode 100644 engine/pkg/util/branching/branching.go delete mode 100644 ui/packages/platform/src/components/DbLabInstanceForm/utils/index.ts diff --git a/engine/cmd/cli/commands/branch/actions.go b/engine/cmd/cli/commands/branch/actions.go index ec7585ee..0286af26 100644 --- a/engine/cmd/cli/commands/branch/actions.go +++ b/engine/cmd/cli/commands/branch/actions.go @@ -218,6 +218,15 @@ func deleteBranch(cliCtx *cli.Context) error { branchName := cliCtx.String("delete") + branching, err := getBranchingFromEnv() + if err != nil { + return err + } + + if branching.CurrentBranch == branchName { + return fmt.Errorf("cannot delete branch %q because it is the current one", branchName) + } + if err = dblabClient.DeleteBranch(cliCtx.Context, types.BranchDeleteRequest{ BranchName: branchName, }); err != nil { @@ -286,6 +295,30 @@ func history(cliCtx *cli.Context) error { return err } +func getBranchingFromEnv() (config.Branching, error) { + branching := config.Branching{} + + dirname, err := config.GetDirname() + if err != nil { + return branching, err + } + + filename := config.BuildFileName(dirname) + + cfg, err := config.Load(filename) + if err != nil && !os.IsNotExist(err) { + return branching, err + } + + if len(cfg.Environments) == 0 { + return branching, errors.New("no environments found. Use `dblab init` to create a new environment before branching") + } + + branching = cfg.Environments[cfg.CurrentEnvironment].Branching + + return branching, nil +} + func formatSnapshotLog(snapshots []models.SnapshotDetails) (string, error) { sb := &strings.Builder{} diff --git a/engine/cmd/database-lab/main.go b/engine/cmd/database-lab/main.go index 452bbb5a..b0140275 100644 --- a/engine/cmd/database-lab/main.go +++ b/engine/cmd/database-lab/main.go @@ -191,11 +191,6 @@ func main() { server := srv.NewServer(&cfg.Server, &cfg.Global, &engProps, docker, cloningSvc, provisioner, retrievalSvc, platformSvc, billingSvc, obs, pm, tm, tokenHolder, logFilter, embeddedUI, reloadConfigFn, webhookChan) - go setReloadListener(ctx, engProps, provisioner, billingSvc, - retrievalSvc, pm, cloningSvc, platformSvc, - embeddedUI, server, - logCleaner, logFilter, whs) - server.InitHandlers() go func() { diff --git a/engine/internal/cloning/base.go b/engine/internal/cloning/base.go index a01d1cff..c86267c9 100644 --- a/engine/internal/cloning/base.go +++ b/engine/internal/cloning/base.go @@ -29,6 +29,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util/branching" "gitlab.com/postgres-ai/database-lab/v3/pkg/util/pglog" ) @@ -115,7 +116,7 @@ func (c *Base) cleanupInvalidClones() error { c.cloneMutex.Lock() for _, clone := range c.clones { - keepClones[util.GetCloneName(clone.Session.Port)] = struct{}{} + keepClones[clone.Clone.ID] = struct{}{} } c.cloneMutex.Unlock() @@ -186,6 +187,10 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon }, } + if clone.Branch == "" { + clone.Branch = branching.DefaultBranch + } + w := NewCloneWrapper(clone, createdAt) cloneID := clone.ID @@ -198,10 +203,10 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon AvailableDB: cloneRequest.DB.DBName, } - c.incrementCloneNumber(clone.Snapshot.ID) + c.IncrementCloneNumber(clone.Snapshot.ID) go func() { - session, err := c.provision.StartSession(clone.Snapshot.ID, ephemeralUser, cloneRequest.ExtraConf) + session, err := c.provision.StartSession(clone, ephemeralUser, cloneRequest.ExtraConf) if err != nil { // TODO(anatoly): Empty room case. log.Errf("Failed to start session: %v.", err) @@ -228,7 +233,7 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon Port: session.Port, Username: clone.DB.Username, DBName: clone.DB.DBName, - ContainerName: util.GetCloneName(session.Port), + ContainerName: cloneID, } }() @@ -304,7 +309,7 @@ func (c *Base) DestroyClone(cloneID string) error { } if c.hasDependentSnapshots(w) { - return models.New(models.ErrCodeBadRequest, "clone has dependent snapshots") + log.Warn("clone has dependent snapshots", cloneID) } if err := c.UpdateCloneStatus(cloneID, models.Status{ @@ -325,7 +330,7 @@ func (c *Base) DestroyClone(cloneID string) error { } go func() { - if err := c.provision.StopSession(w.Session); err != nil { + if err := c.provision.StopSession(w.Session, w.Clone); err != nil { log.Errf("Failed to delete a clone: %v.", err) if updateErr := c.UpdateCloneStatus(cloneID, models.Status{ @@ -356,7 +361,7 @@ func (c *Base) DestroyClone(cloneID string) error { Port: w.Session.Port, Username: w.Clone.DB.Username, DBName: w.Clone.DB.DBName, - ContainerName: util.GetCloneName(w.Session.Port), + ContainerName: cloneID, } }() @@ -381,7 +386,7 @@ func (c *Base) refreshCloneMetadata(w *CloneWrapper) { return } - sessionState, err := c.provision.GetSessionState(w.Session) + sessionState, err := c.provision.GetSessionState(w.Session, w.Clone.ID) if err != nil { // Session not ready yet. log.Err(fmt.Errorf("failed to get a session state: %w", err)) @@ -484,7 +489,7 @@ func (c *Base) ResetClone(cloneID string, resetOptions types.ResetCloneRequest) originalSnapshotID = w.Clone.Snapshot.ID } - snapshot, err := c.provision.ResetSession(w.Session, snapshotID) + snapshot, err := c.provision.ResetSession(w.Session, w.Clone, snapshotID) if err != nil { log.Errf("Failed to reset clone: %v", err) @@ -502,7 +507,7 @@ func (c *Base) ResetClone(cloneID string, resetOptions types.ResetCloneRequest) w.Clone.Snapshot = snapshot c.cloneMutex.Unlock() c.decrementCloneNumber(originalSnapshotID) - c.incrementCloneNumber(snapshot.ID) + c.IncrementCloneNumber(snapshot.ID) if err := c.UpdateCloneStatus(cloneID, models.Status{ Code: models.StatusOK, @@ -522,7 +527,7 @@ func (c *Base) ResetClone(cloneID string, resetOptions types.ResetCloneRequest) Port: w.Session.Port, Username: w.Clone.DB.Username, DBName: w.Clone.DB.DBName, - ContainerName: util.GetCloneName(w.Session.Port), + ContainerName: cloneID, } c.tm.SendEvent(context.Background(), telemetry.CloneResetEvent, telemetry.CloneCreated{ @@ -714,10 +719,10 @@ func (c *Base) isIdleClone(wrapper *CloneWrapper) (bool, error) { return false, errors.New("failed to get clone session") } - if _, err := c.provision.LastSessionActivity(session, minimumTime); err != nil { + if _, err := c.provision.LastSessionActivity(session, wrapper.Clone.ID, minimumTime); err != nil { if err == pglog.ErrNotFound { log.Dbg(fmt.Sprintf("Not found recent activity for the session: %q. Clone name: %q", - session.ID, util.GetCloneName(session.Port))) + session.ID, wrapper.Clone.ID)) return hasNotQueryActivity(session) } diff --git a/engine/internal/cloning/snapshots.go b/engine/internal/cloning/snapshots.go index 7e0fe2bd..8d120c04 100644 --- a/engine/internal/cloning/snapshots.go +++ b/engine/internal/cloning/snapshots.go @@ -130,7 +130,8 @@ func (c *Base) getSnapshotByID(snapshotID string) (*models.Snapshot, error) { return snapshot, nil } -func (c *Base) incrementCloneNumber(snapshotID string) { +// IncrementCloneNumber increases clone counter by 1. +func (c *Base) IncrementCloneNumber(snapshotID string) { c.snapshotBox.snapshotMutex.Lock() defer c.snapshotBox.snapshotMutex.Unlock() @@ -161,6 +162,20 @@ func (c *Base) decrementCloneNumber(snapshotID string) { snapshot.NumClones-- } +// GetCloneNumber counts snapshot clones. +func (c *Base) GetCloneNumber(snapshotID string) int { + c.snapshotBox.snapshotMutex.Lock() + defer c.snapshotBox.snapshotMutex.Unlock() + + snapshot, ok := c.snapshotBox.items[snapshotID] + if !ok { + log.Err("Snapshot not found:", snapshotID) + return 0 + } + + return snapshot.NumClones +} + func (c *Base) getSnapshotList() []models.Snapshot { c.snapshotBox.snapshotMutex.RLock() defer c.snapshotBox.snapshotMutex.RUnlock() @@ -188,7 +203,7 @@ func (c *Base) hasDependentSnapshots(w *CloneWrapper) bool { c.snapshotBox.snapshotMutex.RLock() defer c.snapshotBox.snapshotMutex.RUnlock() - poolName := util.GetPoolName(w.Clone.Snapshot.Pool, util.GetCloneNameStr(w.Clone.DB.Port)) + poolName := util.GetPoolName(w.Clone.Snapshot.Pool, w.Clone.ID) for name := range c.snapshotBox.items { if strings.HasPrefix(name, poolName) { diff --git a/engine/internal/cloning/snapshots_test.go b/engine/internal/cloning/snapshots_test.go index 7e4ac8c0..c068c1e2 100644 --- a/engine/internal/cloning/snapshots_test.go +++ b/engine/internal/cloning/snapshots_test.go @@ -110,7 +110,7 @@ func TestCloneCounter(t *testing.T) { require.Nil(t, err) require.Equal(t, 0, snapshot.NumClones) - c.incrementCloneNumber("testSnapshotID") + c.IncrementCloneNumber("testSnapshotID") snapshot, err = c.getSnapshotByID("testSnapshotID") require.Nil(t, err) require.Equal(t, 1, snapshot.NumClones) diff --git a/engine/internal/cloning/storage.go b/engine/internal/cloning/storage.go index 558b111d..e627556e 100644 --- a/engine/internal/cloning/storage.go +++ b/engine/internal/cloning/storage.go @@ -55,7 +55,7 @@ func (c *Base) restartCloneContainers(ctx context.Context) { continue } - cloneName := util.GetCloneName(wrapper.Session.Port) + cloneName := wrapper.Clone.ID if c.provision.IsCloneRunning(ctx, cloneName) { continue } @@ -102,11 +102,11 @@ func (c *Base) filterRunningClones(ctx context.Context) { snapshotCache[snapshot.ID] = struct{}{} } - if !c.provision.IsCloneRunning(ctx, util.GetCloneName(wrapper.Session.Port)) { + if !c.provision.IsCloneRunning(ctx, wrapper.Clone.ID) { delete(c.clones, cloneID) } - c.incrementCloneNumber(wrapper.Clone.Snapshot.ID) + c.IncrementCloneNumber(wrapper.Clone.Snapshot.ID) } } diff --git a/engine/internal/observer/observer.go b/engine/internal/observer/observer.go index 25bdf0ef..dd4b52b7 100644 --- a/engine/internal/observer/observer.go +++ b/engine/internal/observer/observer.go @@ -12,7 +12,6 @@ import ( "io" "os" "regexp" - "strconv" "sync" "time" @@ -80,13 +79,8 @@ func NewObserver(dockerClient *client.Client, cfg *Config, pm *pool.Manager) *Ob // GetCloneLog gets clone logs. // TODO (akartasov): Split log to chunks. -func (o *Observer) GetCloneLog(ctx context.Context, port string, obsClone *ObservingClone) ([]byte, error) { - clonePort, err := strconv.Atoi(port) - if err != nil { - return nil, errors.Wrap(err, "failed to parse clone port") - } - - fileSelector := pglog.NewSelector(obsClone.pool.ClonePath(uint(clonePort))) +func (o *Observer) GetCloneLog(ctx context.Context, obsClone *ObservingClone) ([]byte, error) { + fileSelector := pglog.NewSelector(obsClone.pool.ClonePath(obsClone.cloneID)) fileSelector.SetMinimumTime(obsClone.session.StartedAt) if err := fileSelector.DiscoverLogDir(); err != nil { diff --git a/engine/internal/observer/observing_clone.go b/engine/internal/observer/observing_clone.go index dc85387e..d9c80774 100644 --- a/engine/internal/observer/observing_clone.go +++ b/engine/internal/observer/observing_clone.go @@ -479,7 +479,7 @@ func (c *ObservingClone) currentArtifactsSessionPath() string { } func (c *ObservingClone) artifactsSessionPath(sessionID uint64) string { - return path.Join(c.pool.ObserverDir(c.port), c.cloneID, strconv.FormatUint(sessionID, 10)) + return path.Join(c.pool.ObserverDir(c.cloneID), c.cloneID, strconv.FormatUint(sessionID, 10)) } // CheckPerformanceRequirements checks monitoring data and returns an error if any of performance requires was not satisfied. diff --git a/engine/internal/observer/sql.go b/engine/internal/observer/sql.go index 8db4d99c..88fc4623 100644 --- a/engine/internal/observer/sql.go +++ b/engine/internal/observer/sql.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "path" - "strconv" "strings" "github.com/jackc/pgx/v4" @@ -17,16 +16,11 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/defaults" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" - "gitlab.com/postgres-ai/database-lab/v3/pkg/util" ) // InitConnection creates a new connection to the clone database. func InitConnection(clone *models.Clone, socketDir string) (*pgx.Conn, error) { - host, err := unixSocketDir(socketDir, clone.DB.Port) - if err != nil { - return nil, errors.Wrap(err, "failed to parse clone port") - } - + host := unixSocketDir(socketDir, clone.ID) connectionStr := buildConnectionString(clone, host) conn, err := pgx.Connect(context.Background(), connectionStr) @@ -73,13 +67,8 @@ func runQuery(ctx context.Context, db *pgx.Conn, query string, args ...interface return result.String(), nil } -func unixSocketDir(socketDir, portStr string) (string, error) { - port, err := strconv.ParseUint(portStr, 10, 64) - if err != nil { - return "", err - } - - return path.Join(socketDir, util.GetCloneName(uint(port))), nil +func unixSocketDir(socketDir, cloneID string) string { + return path.Join(socketDir, cloneID) } func buildConnectionString(clone *models.Clone, socketDir string) string { diff --git a/engine/internal/provision/mode_local.go b/engine/internal/provision/mode_local.go index 54f4b3d8..5609643f 100644 --- a/engine/internal/provision/mode_local.go +++ b/engine/internal/provision/mode_local.go @@ -34,7 +34,6 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/fs" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" - "gitlab.com/postgres-ai/database-lab/v3/pkg/util" "gitlab.com/postgres-ai/database-lab/v3/pkg/util/networks" "gitlab.com/postgres-ai/database-lab/v3/pkg/util/pglog" ) @@ -151,9 +150,9 @@ func (p *Provisioner) ContainerOptions() models.ContainerOptions { } // StartSession starts a new session. -func (p *Provisioner) StartSession(snapshotID string, user resources.EphemeralUser, +func (p *Provisioner) StartSession(clone *models.Clone, user resources.EphemeralUser, extraConfig map[string]string) (*resources.Session, error) { - snapshot, err := p.getSnapshot(snapshotID) + snapshot, err := p.getSnapshot(clone.Snapshot.ID) if err != nil { return nil, errors.Wrap(err, "failed to get snapshots") } @@ -163,7 +162,7 @@ func (p *Provisioner) StartSession(snapshotID string, user resources.EphemeralUs return nil, errors.New("failed to get a free port") } - name := util.GetCloneName(port) + name := clone.ID fsm, err := p.pm.GetFSManager(snapshot.Pool) if err != nil { @@ -186,7 +185,7 @@ func (p *Provisioner) StartSession(snapshotID string, user resources.EphemeralUs return nil, errors.Wrap(err, "failed to create clone") } - appConfig := p.getAppConfig(fsm.Pool(), name, port) + appConfig := p.getAppConfig(fsm.Pool(), clone.Branch, name, port) appConfig.SetExtraConf(extraConfig) if err := fs.CleanupLogsDir(appConfig.DataDir()); err != nil { @@ -217,13 +216,13 @@ func (p *Provisioner) StartSession(snapshotID string, user resources.EphemeralUs } // StopSession stops an existing session. -func (p *Provisioner) StopSession(session *resources.Session) error { +func (p *Provisioner) StopSession(session *resources.Session, clone *models.Clone) error { fsm, err := p.pm.GetFSManager(session.Pool) if err != nil { return errors.Wrap(err, "failed to find a filesystem manager of this session") } - name := util.GetCloneName(session.Port) + name := clone.ID if err := postgres.Stop(p.runner, fsm.Pool(), name); err != nil { return errors.Wrap(err, "failed to stop a container") @@ -241,13 +240,13 @@ func (p *Provisioner) StopSession(session *resources.Session) error { } // ResetSession resets an existing session. -func (p *Provisioner) ResetSession(session *resources.Session, snapshotID string) (*models.Snapshot, error) { +func (p *Provisioner) ResetSession(session *resources.Session, clone *models.Clone, snapshotID string) (*models.Snapshot, error) { fsm, err := p.pm.GetFSManager(session.Pool) if err != nil { return nil, errors.Wrap(err, "failed to find filesystem manager of this session") } - name := util.GetCloneName(session.Port) + name := clone.ID snapshot, err := p.getSnapshot(snapshotID) if err != nil { @@ -286,7 +285,7 @@ func (p *Provisioner) ResetSession(session *resources.Session, snapshotID string return nil, errors.Wrap(err, "failed to create clone") } - appConfig := p.getAppConfig(newFSManager.Pool(), name, session.Port) + appConfig := p.getAppConfig(newFSManager.Pool(), clone.Branch, name, session.Port) appConfig.SetExtraConf(session.ExtraConfig) if err := fs.CleanupLogsDir(appConfig.DataDir()); err != nil { @@ -328,13 +327,13 @@ func (p *Provisioner) GetSnapshots() ([]resources.Snapshot, error) { } // GetSessionState describes the state of the session. -func (p *Provisioner) GetSessionState(s *resources.Session) (*resources.SessionState, error) { +func (p *Provisioner) GetSessionState(s *resources.Session, cloneID string) (*resources.SessionState, error) { fsm, err := p.pm.GetFSManager(s.Pool) if err != nil { return nil, errors.Wrap(err, "failed to find a filesystem manager of this session") } - return fsm.GetSessionState(util.GetCloneName(s.Port)) + return fsm.GetSessionState(cloneID) } // GetPoolEntryList provides an ordered list of available pools. @@ -614,11 +613,12 @@ func (p *Provisioner) stopPoolSessions(fsm pool.FSManager, exceptClones map[stri return nil } -func (p *Provisioner) getAppConfig(pool *resources.Pool, name string, port uint) *resources.AppConfig { +func (p *Provisioner) getAppConfig(pool *resources.Pool, branch, name string, port uint) *resources.AppConfig { provisionHosts := p.getProvisionHosts() appConfig := &resources.AppConfig{ CloneName: name, + Branch: branch, DockerImage: p.config.DockerImage, Host: pool.SocketCloneDir(name), Port: port, @@ -654,7 +654,7 @@ func (p *Provisioner) getProvisionHosts() string { } // LastSessionActivity returns the time of the last session activity. -func (p *Provisioner) LastSessionActivity(session *resources.Session, minimumTime time.Time) (*time.Time, error) { +func (p *Provisioner) LastSessionActivity(session *resources.Session, cloneID string, minimumTime time.Time) (*time.Time, error) { fsm, err := p.pm.GetFSManager(session.Pool) if err != nil { return nil, errors.Wrap(err, "failed to find a filesystem manager") @@ -663,7 +663,7 @@ func (p *Provisioner) LastSessionActivity(session *resources.Session, minimumTim ctx, cancel := context.WithCancel(p.ctx) defer cancel() - clonePath := fsm.Pool().ClonePath(session.Port) + clonePath := fsm.Pool().ClonePath(cloneID) fileSelector := pglog.NewSelector(clonePath) if err := fileSelector.DiscoverLogDir(); err != nil { diff --git a/engine/internal/provision/pool/manager.go b/engine/internal/provision/pool/manager.go index cda760da..87472686 100644 --- a/engine/internal/provision/pool/manager.go +++ b/engine/internal/provision/pool/manager.go @@ -66,7 +66,6 @@ type Branching interface { DeleteBranchProp(branch, snapshotName string) error DeleteChildProp(childSnapshot, snapshotName string) error DeleteRootProp(branch, snapshotName string) error - DeleteBranch(branch string) error SetRoot(branch, snapshotName string) error SetDSA(dsa, snapshotName string) error SetMessage(message, snapshotName string) error diff --git a/engine/internal/provision/resources/appconfig.go b/engine/internal/provision/resources/appconfig.go index 94a37c40..958b2911 100644 --- a/engine/internal/provision/resources/appconfig.go +++ b/engine/internal/provision/resources/appconfig.go @@ -11,6 +11,7 @@ import ( // AppConfig currently stores Postgres configuration (other application in the future too). type AppConfig struct { CloneName string + Branch string DockerImage string Pool *Pool Host string diff --git a/engine/internal/provision/resources/pool.go b/engine/internal/provision/resources/pool.go index 78664a7e..606351ca 100644 --- a/engine/internal/provision/resources/pool.go +++ b/engine/internal/provision/resources/pool.go @@ -8,8 +8,6 @@ import ( "path" "sync" "time" - - "gitlab.com/postgres-ai/database-lab/v3/pkg/util" ) // PoolStatus represents a pool status. @@ -68,8 +66,8 @@ func (p *Pool) SocketDir() string { } // ObserverDir returns a path to the observer directory of the storage pool. -func (p *Pool) ObserverDir(port uint) string { - return path.Join(p.ClonePath(port), p.ObserverSubDir) +func (p *Pool) ObserverDir(name string) string { + return path.Join(p.ClonePath(name), p.ObserverSubDir) } // ClonesDir returns a path to the clones directory of the storage pool. @@ -78,8 +76,8 @@ func (p *Pool) ClonesDir() string { } // ClonePath returns a path to the initialized clone directory. -func (p *Pool) ClonePath(port uint) string { - return path.Join(p.MountDir, p.PoolDirName, p.CloneSubDir, util.GetCloneName(port), p.DataSubDir) +func (p *Pool) ClonePath(name string) string { + return path.Join(p.MountDir, p.PoolDirName, p.CloneSubDir, name, p.DataSubDir) } // SocketCloneDir returns a path to the socket clone directory. diff --git a/engine/internal/provision/thinclones/lvm/lvmanager.go b/engine/internal/provision/thinclones/lvm/lvmanager.go index 4579190f..948b3445 100644 --- a/engine/internal/provision/thinclones/lvm/lvmanager.go +++ b/engine/internal/provision/thinclones/lvm/lvmanager.go @@ -260,10 +260,3 @@ func (m *LVManager) Rename(_, _ string) error { return nil } - -// DeleteBranch deletes branch. -func (m *LVManager) DeleteBranch(_ string) error { - log.Msg("DeleteBranch is not supported for LVM. Skip the operation") - - return nil -} diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go index 286e2100..9562572b 100644 --- a/engine/internal/provision/thinclones/zfs/branching.go +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -8,23 +8,22 @@ import ( "bytes" "encoding/base64" "fmt" - "path/filepath" "strings" "gitlab.com/postgres-ai/database-lab/v3/internal/provision/thinclones" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util/branching" ) const ( - branchProp = "dle:branch" - parentProp = "dle:parent" - childProp = "dle:child" - rootProp = "dle:root" - messageProp = "dle:message" - branchSep = "," - empty = "-" - defaultBranch = "main" + branchProp = "dle:branch" + parentProp = "dle:parent" + childProp = "dle:child" + rootProp = "dle:root" + messageProp = "dle:message" + branchSep = "," + empty = "-" ) // InitBranching inits data branching. @@ -60,7 +59,7 @@ func (m *Manager) InitBranching() error { return nil } - if err := m.AddBranchProp(defaultBranch, latest.ID); err != nil { + if err := m.AddBranchProp(branching.DefaultBranch, latest.ID); err != nil { return fmt.Errorf("failed to add branch property: %w", err) } @@ -82,8 +81,8 @@ func (m *Manager) InitBranching() error { return fmt.Errorf("failed to read branch property: %w", err) } - if brProperty == defaultBranch { - if err := m.DeleteBranchProp(defaultBranch, follower.ID); err != nil { + if brProperty == branching.DefaultBranch { + if err := m.DeleteBranchProp(branching.DefaultBranch, follower.ID); err != nil { return fmt.Errorf("failed to delete default branch property: %w", err) } @@ -134,7 +133,7 @@ func (m *Manager) VerifyBranchMetadata() error { } if brName == "" { - brName = defaultBranch + brName = branching.DefaultBranch } if err := m.AddBranchProp(brName, latest.ID); err != nil { @@ -208,7 +207,9 @@ func (m *Manager) SetMountpoint(path, name string) error { // ListBranches lists data pool branches. func (m *Manager) ListBranches() (map[string]string, error) { cmd := fmt.Sprintf( - `zfs list -H -t snapshot -o %s,name | grep -v "^-" | cat`, branchProp, + // Get ZFS snapshots (-t) with options (-o) without output headers (-H) filtered by pool (-r). + // Excluding snapshots without "dle:branch" property ("grep -v"). + `zfs list -H -t snapshot -o %s,name -r %s | grep -v "^-" | cat`, branchProp, m.config.Pool.Name, ) out, err := m.runner.Run(cmd) @@ -248,7 +249,8 @@ func (m *Manager) GetRepo() (*models.Repo, error) { strFields := bytes.TrimRight(bytes.Repeat([]byte(`%s,`), len(repoFields)), ",") cmd := fmt.Sprintf( - `zfs list -H -t snapshot -o `+string(strFields), repoFields..., + // Get ZFS snapshots (-t) with options (-o) without output headers (-H) filtered by pool (-r). + `zfs list -H -t snapshot -o `+string(strFields)+" -r %s", append(repoFields, m.config.Pool.Name)..., ) out, err := m.runner.Run(cmd) @@ -342,7 +344,7 @@ func (m *Manager) SetRelation(parent, snapshotName string) error { return err } - return m.addChild(parent, snapshotName); + return m.addChild(parent, snapshotName) } // DeleteChildProp deletes child from snapshot property. @@ -474,15 +476,3 @@ func (m *Manager) Reset(snapshotID string, _ thinclones.ResetOptions) error { return nil } - -// DeleteBranch deletes branch. -func (m *Manager) DeleteBranch(branch string) error { - branchName := filepath.Join(m.Pool().Name, branch) - cmd := fmt.Sprintf("zfs destroy -R %s", branchName) - - if out, err := m.runner.Run(cmd, true); err != nil { - return fmt.Errorf("failed to destroy branch: %w. Out: %v", err, out) - } - - return nil -} diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index 10583d9d..765e4e90 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -191,7 +191,7 @@ func (m *Manager) CreateClone(cloneName, snapshotID string) error { clonesMountDir := m.config.Pool.ClonesDir() - cmd := "zfs clone " + + cmd := "zfs clone -p " + "-o mountpoint=" + clonesMountDir + "/" + cloneName + " " + snapshotID + " " + m.config.Pool.Name + "/" + cloneName + " && " + diff --git a/engine/internal/runci/handlers.go b/engine/internal/runci/handlers.go index 8d12dc61..40dd200a 100644 --- a/engine/internal/runci/handlers.go +++ b/engine/internal/runci/handlers.go @@ -30,7 +30,6 @@ import ( dblab_types "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" - "gitlab.com/postgres-ai/database-lab/v3/pkg/util" "gitlab.com/postgres-ai/database-lab/v3/version" ) @@ -266,7 +265,7 @@ func (s *Server) runCommands(ctx context.Context, clone *models.Clone, runID str func (s *Server) buildContainerConfig(clone *models.Clone, migrationEnvs []string) *container.Config { host := clone.DB.Host if host == s.dle.URL("").Hostname() || host == "127.0.0.1" || host == "localhost" { - host = util.GetCloneNameStr(clone.DB.Port) + host = clone.ID } return &container.Config{ diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 39779d21..9e5f9ed6 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -3,6 +3,7 @@ package srv import ( "fmt" "net/http" + "strings" "time" "github.com/gorilla/mux" @@ -11,6 +12,7 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/srv/api" "gitlab.com/postgres-ai/database-lab/v3/internal/webhooks" "gitlab.com/postgres-ai/database-lab/v3/pkg/client/dblabapi/types" + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" ) @@ -227,13 +229,6 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } - fsm := s.pm.First() - - if fsm == nil { - api.SendBadRequestError(w, r, "no available pools") - return - } - clone, err := s.Cloning.GetClone(snapshotRequest.CloneID) if err != nil { api.SendBadRequestError(w, r, "clone not found") @@ -245,6 +240,13 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } + fsm, err := s.pm.GetFSManager(clone.Snapshot.Pool) + + if err != nil { + api.SendBadRequestError(w, r, fmt.Sprintf("pool %q not found", clone.Snapshot.Pool)) + return + } + branches, err := fsm.ListBranches() if err != nil { api.SendBadRequestError(w, r, err.Error()) @@ -257,9 +259,11 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } + log.Dbg("Current snapshot ID", currentSnapshotID) + dataStateAt := time.Now().Format(util.DataStateAtFormat) - snapshotBase := fmt.Sprintf("%s/%s", clone.Snapshot.Pool, util.GetCloneNameStr(clone.DB.Port)) + snapshotBase := fmt.Sprintf("%s/%s", clone.Snapshot.Pool, clone.ID) snapshotName := fmt.Sprintf("%s@%s", snapshotBase, dataStateAt) if err := fsm.Snapshot(snapshotName); err != nil { @@ -267,20 +271,7 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } - newSnapshotName := fmt.Sprintf("%s/%s/%s", fsm.Pool().Name, clone.Branch, dataStateAt) - - if err := fsm.Rename(snapshotBase, newSnapshotName); err != nil { - api.SendBadRequestError(w, r, err.Error()) - return - } - - snapshotPath := fmt.Sprintf("%s/%s@%s", fsm.Pool().ClonesDir(), clone.Branch, dataStateAt) - if err := fsm.SetMountpoint(snapshotPath, newSnapshotName); err != nil { - api.SendBadRequestError(w, r, err.Error()) - return - } - - if err := fsm.AddBranchProp(clone.Branch, newSnapshotName); err != nil { + if err := fsm.AddBranchProp(clone.Branch, snapshotName); err != nil { api.SendBadRequestError(w, r, err.Error()) return } @@ -290,22 +281,24 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } - childID := newSnapshotName + "@" + dataStateAt - if err := fsm.SetRelation(currentSnapshotID, childID); err != nil { + if err := fsm.SetRelation(currentSnapshotID, snapshotName); err != nil { api.SendBadRequestError(w, r, err.Error()) return } - if err := fsm.SetDSA(dataStateAt, childID); err != nil { + if err := fsm.SetDSA(dataStateAt, snapshotName); err != nil { api.SendBadRequestError(w, r, err.Error()) return } - if err := fsm.SetMessage(snapshotRequest.Message, childID); err != nil { + if err := fsm.SetMessage(snapshotRequest.Message, snapshotName); err != nil { api.SendBadRequestError(w, r, err.Error()) return } + // Since the snapshot is created from a clone, it already has one associated clone. + s.Cloning.IncrementCloneNumber(snapshotName) + fsm.RefreshSnapshotList() if err := s.Cloning.ReloadSnapshots(); err != nil { @@ -313,18 +306,18 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } - newSnapshot, err := s.Cloning.GetSnapshotByID(childID) + snapshot, err := s.Cloning.GetSnapshotByID(snapshotName) if err != nil { api.SendBadRequestError(w, r, err.Error()) return } - if err := s.Cloning.UpdateCloneSnapshot(clone.ID, newSnapshot); err != nil { + if err := s.Cloning.UpdateCloneSnapshot(clone.ID, snapshot); err != nil { api.SendBadRequestError(w, r, err.Error()) return } - if err := api.WriteJSON(w, http.StatusOK, types.SnapshotResponse{SnapshotID: childID}); err != nil { + if err := api.WriteJSON(w, http.StatusOK, types.SnapshotResponse{SnapshotID: snapshotName}); err != nil { api.SendError(w, r, err) return } @@ -402,10 +395,28 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } - if hasSnapshots(repo, snapshotID, deleteRequest.BranchName) { - if err := fsm.DeleteBranch(deleteRequest.BranchName); err != nil { - api.SendBadRequestError(w, r, err.Error()) - return + toRemove := snapshotsToRemove(repo, snapshotID, deleteRequest.BranchName) + + // Pre-check. + for _, snapshotID := range toRemove { + if cloneNum := s.Cloning.GetCloneNumber(snapshotID); cloneNum > 0 { + log.Warn(fmt.Sprintf("cannot delete branch %q because snapshot %q contains %d clone(s)", + deleteRequest.BranchName, snapshotID, cloneNum)) + } + } + + for _, snapshotID := range toRemove { + if err := fsm.DestroySnapshot(snapshotID); err != nil { + log.Warn(fmt.Sprintf("failed to remove snapshot %q:", snapshotID), err.Error()) + } + } + + if len(toRemove) > 0 { + datasetFull := strings.Split(toRemove[0], "@") + datasetName, _ := strings.CutPrefix(datasetFull[0], fsm.Pool().Name+"/") + + if err := fsm.DestroyClone(datasetName); err != nil { + log.Warn("cannot destroy the underlying branch dataset", err) } } @@ -463,14 +474,25 @@ func cleanupSnapshotProperties(repo *models.Repo, fsm pool.FSManager, branchName return nil } -func hasSnapshots(repo *models.Repo, snapshotID, branchName string) bool { +func snapshotsToRemove(repo *models.Repo, snapshotID, branchName string) []string { snapshotPointer := repo.Snapshots[snapshotID] - for _, rootBranch := range snapshotPointer.Root { - if rootBranch == branchName { - return false + removingList := []string{} + + for snapshotPointer.Parent != "-" { + if len(snapshotPointer.Root) > 0 { + break + } + + for _, snapshotRoot := range snapshotPointer.Root { + if snapshotRoot == branchName { + break + } } + + removingList = append(removingList, snapshotPointer.ID) + snapshotPointer = repo.Snapshots[snapshotPointer.Parent] } - return true + return removingList } diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 1d5e28ed..dd51e0a0 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -181,22 +181,17 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { return } - const snapshotParts = 2 - - parts := strings.Split(destroyRequest.SnapshotID, "@") - if len(parts) != snapshotParts { - api.SendBadRequestError(w, r, fmt.Sprintf("invalid snapshot name given: %s", destroyRequest.SnapshotID)) + poolName, err := s.detectPoolName(destroyRequest.SnapshotID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) return } - rootParts := strings.Split(parts[0], "/") - if len(rootParts) < 1 { - api.SendBadRequestError(w, r, fmt.Sprintf("invalid root part of snapshot name given: %s", destroyRequest.SnapshotID)) + if poolName == "" { + api.SendBadRequestError(w, r, fmt.Sprintf("pool for the requested snapshot (%s) not found", destroyRequest.SnapshotID)) return } - poolName := rootParts[0] - fsm, err := s.pm.GetFSManager(poolName) if err != nil { api.SendBadRequestError(w, r, err.Error()) @@ -229,6 +224,26 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) detectPoolName(snapshotID string) (string, error) { + const snapshotParts = 2 + + parts := strings.Split(snapshotID, "@") + if len(parts) != snapshotParts { + return "", fmt.Errorf("invalid snapshot name given: %s. Should contain `dataset@snapname`", snapshotID) + } + + poolName := "" + + for _, fsm := range s.pm.GetFSManagerList() { + if strings.HasPrefix(parts[0], fsm.Pool().Name) { + poolName = fsm.Pool().Name + break + } + } + + return poolName, nil +} + func (s *Server) createSnapshotClone(w http.ResponseWriter, r *http.Request) { if r.Body == http.NoBody { api.SendBadRequestError(w, r, "request body cannot be empty") @@ -258,7 +273,7 @@ func (s *Server) createSnapshotClone(w http.ResponseWriter, r *http.Request) { return } - cloneName := util.GetCloneNameStr(clone.DB.Port) + cloneName := clone.ID snapshotID, err := fsm.CreateSnapshot(cloneName, time.Now().Format(util.DataStateAtFormat)) if err != nil { @@ -550,8 +565,7 @@ func (s *Server) stopObservation(w http.ResponseWriter, r *http.Request) { return } - clone, err := s.Cloning.GetClone(observationRequest.CloneID) - if err != nil { + if _, err := s.Cloning.GetClone(observationRequest.CloneID); err != nil { api.SendNotFoundError(w, r) return } @@ -596,7 +610,7 @@ func (s *Server) stopObservation(w http.ResponseWriter, r *http.Request) { sessionID := strconv.FormatUint(session.SessionID, 10) - logs, err := s.Observer.GetCloneLog(context.TODO(), clone.DB.Port, observingClone) + logs, err := s.Observer.GetCloneLog(context.TODO(), observingClone) if err != nil { log.Err("Failed to get observation logs", err) } diff --git a/engine/pkg/client/dblabapi/branch.go b/engine/pkg/client/dblabapi/branch.go index 4a76f8f7..220f22ee 100644 --- a/engine/pkg/client/dblabapi/branch.go +++ b/engine/pkg/client/dblabapi/branch.go @@ -163,7 +163,7 @@ func (c *Client) DeleteBranch(ctx context.Context, r types.BranchDeleteRequest) response, err := c.Do(ctx, request) if err != nil { - return fmt.Errorf("failed to get response: %w", err) + return err } defer func() { _ = response.Body.Close() }() diff --git a/engine/pkg/util/branching/branching.go b/engine/pkg/util/branching/branching.go new file mode 100644 index 00000000..f0cb388d --- /dev/null +++ b/engine/pkg/util/branching/branching.go @@ -0,0 +1,9 @@ +/* +2023 © Postgres.ai +*/ + +// Package branching contains branching tools and types. +package branching + +// DefaultBranch defines the name of the default branch. +const DefaultBranch = "main" diff --git a/engine/pkg/util/clones.go b/engine/pkg/util/clones.go index 18048b77..0a798c51 100644 --- a/engine/pkg/util/clones.go +++ b/engine/pkg/util/clones.go @@ -4,25 +4,11 @@ package util -import ( - "strconv" -) - const ( // ClonePrefix defines a Database Lab clone prefix. ClonePrefix = "dblab_clone_" ) -// GetCloneName returns clone name. -func GetCloneName(port uint) string { - return ClonePrefix + strconv.FormatUint(uint64(port), 10) -} - -// GetCloneNameStr returns clone name. -func GetCloneNameStr(port string) string { - return ClonePrefix + port -} - // GetPoolName returns pool name. func GetPoolName(basePool, snapshotSuffix string) string { return basePool + "/" + snapshotSuffix diff --git a/engine/test/1.synthetic.sh b/engine/test/1.synthetic.sh index 81092815..4849a863 100644 --- a/engine/test/1.synthetic.sh +++ b/engine/test/1.synthetic.sh @@ -181,13 +181,15 @@ if [[ $(dblab snapshot list | jq length) -eq 0 ]] ; then fi ## Create a clone +CLONE_ID="testclone" + dblab clone create \ --username dblab_user_1 \ --password secret_password \ - --id testclone + --id ${CLONE_ID} ### Check that database system was properly shut down (clone data dir) -CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/dblab_clone_"${DLE_PORT_POOL_FROM}"/data/log +CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/"${CLONE_ID}"/data/log LOG_FILE_CSV=$(sudo ls -t "$CLONE_LOG_DIR" | grep .csv | head -n 1) if sudo test -d "$CLONE_LOG_DIR" then @@ -258,7 +260,7 @@ dblab branch dblab clone create \ --username john \ - --password test \ + --password secret_test_123 \ --branch 001-branch \ --id branchclone001 || (echo "Failed to create a clone on branch" && exit 1) @@ -266,7 +268,7 @@ dblab commit --clone-id branchclone001 --message branchclone001 || (echo "Failed dblab clone create \ --username alice \ - --password password \ + --password secret_password_123 \ --branch 001-branch \ --id branchclone002 || (echo "Failed to create a clone on branch" && exit 1) @@ -277,6 +279,13 @@ dblab log 001-branch || (echo "Failed to show branch history" && exit 1) dblab clone destroy branchclone001 || (echo "Failed to destroy clone" && exit 1) dblab clone destroy branchclone002 || (echo "Failed to destroy clone" && exit 1) +sudo docker wait branchclone001 branchclone002 || echo "Clones have been removed" + +dblab clone list +dblab snapshot list + +dblab switch main + dblab branch --delete 001-branch || (echo "Failed to delete data branch" && exit 1) dblab branch diff --git a/engine/test/2.logical_generic.sh b/engine/test/2.logical_generic.sh index a2f382bf..b68d86b7 100644 --- a/engine/test/2.logical_generic.sh +++ b/engine/test/2.logical_generic.sh @@ -289,13 +289,15 @@ dblab instance status ## Create a clone +CLONE_ID="testclone" + dblab clone create \ --username dblab_user_1 \ --password secret_password \ - --id testclone + --id ${CLONE_ID} ### Check that database system was properly shut down (clone data dir) -CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/dblab_clone_"${DLE_PORT_POOL_FROM}"/data/log +CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/"${CLONE_ID}"/data/log LOG_FILE_CSV=$(sudo ls -t "$CLONE_LOG_DIR" | grep .csv | head -n 1) if sudo test -d "$CLONE_LOG_DIR" then diff --git a/engine/test/3.physical_walg.sh b/engine/test/3.physical_walg.sh index a311367d..32462eef 100644 --- a/engine/test/3.physical_walg.sh +++ b/engine/test/3.physical_walg.sh @@ -174,13 +174,15 @@ dblab instance status ## Create a clone +CLONE_ID="testclone" + dblab clone create \ --username dblab_user_1 \ --password secret_password \ - --id testclone + --id ${CLONE_ID} ### Check that database system was properly shut down (clone data dir) -CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/dblab_clone_"${DLE_PORT_POOL_FROM}"/data/log +CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/"${CLONE_ID}"/data/log LOG_FILE_CSV=$(sudo ls -t "$CLONE_LOG_DIR" | grep .csv | head -n 1) if sudo test -d "$CLONE_LOG_DIR" then diff --git a/engine/test/4.physical_basebackup.sh b/engine/test/4.physical_basebackup.sh index ca52b70b..604a7384 100644 --- a/engine/test/4.physical_basebackup.sh +++ b/engine/test/4.physical_basebackup.sh @@ -196,13 +196,15 @@ dblab instance status ## Create a clone +CLONE_ID="testclone" + dblab clone create \ --username dblab_user_1 \ --password secret_password \ - --id testclone + --id ${CLONE_ID} ### Check that database system was properly shut down (clone data dir) -CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/dblab_clone_"${DLE_PORT_POOL_FROM}"/data/log +CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/"${CLONE_ID}"/data/log LOG_FILE_CSV=$(sudo ls -t "$CLONE_LOG_DIR" | grep .csv | head -n 1) if sudo test -d "$CLONE_LOG_DIR" then diff --git a/engine/test/5.logical_rds.sh b/engine/test/5.logical_rds.sh index a05e325d..4b9938c4 100644 --- a/engine/test/5.logical_rds.sh +++ b/engine/test/5.logical_rds.sh @@ -125,13 +125,15 @@ dblab instance status ## Create a clone +CLONE_ID="testclone" + dblab clone create \ --username dblab_user_1 \ --password secret_password \ - --id testclone + --id ${CLONE_ID} ### Check that database system was properly shut down (clone data dir) -CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/dblab_clone_"${DLE_PORT_POOL_FROM}"/data/log +CLONE_LOG_DIR="${DLE_TEST_MOUNT_DIR}"/"${DLE_TEST_POOL_NAME}"/clones/"${CLONE_ID}"/data/log LOG_FILE_CSV=$(sudo ls -t "$CLONE_LOG_DIR" | grep .csv | head -n 1) if sudo test -d "$CLONE_LOG_DIR" then diff --git a/ui/.gitlab-ci.yml b/ui/.gitlab-ci.yml index 06560ad5..0b8c4d0a 100644 --- a/ui/.gitlab-ci.yml +++ b/ui/.gitlab-ci.yml @@ -65,7 +65,7 @@ e2e-ce-ui-test: variables: CYPRESS_CACHE_FOLDER: '$CI_PROJECT_DIR/cache/Cypress' before_script: - - apt update && apt install curl + - apt update && apt install -y curl - apt install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb - npm install -g wait-on - npm install -g pnpm diff --git a/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx b/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx index b308f470..79a46ab7 100644 --- a/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx +++ b/ui/packages/platform/src/components/CreateDbLabCards/CreateDbLabCards.tsx @@ -145,7 +145,6 @@ export const CreatedDbLabCards = ({ content: ( ), diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/SimpleInstance.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/SimpleInstance.tsx index b288387a..9a472734 100644 --- a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/SimpleInstance.tsx +++ b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabFormSteps/SimpleInstance.tsx @@ -6,7 +6,7 @@ import { Spinner } from '@postgres.ai/shared/components/Spinner' import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' import { formStyles } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' -import { ResponseMessage } from '@postgres.ai/shared/pages/Configuration/ResponseMessage' +import { ResponseMessage } from '@postgres.ai/shared/pages/Instance/Configuration/ResponseMessage' import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' import { initialState } from '../reducer' diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/utils/index.ts b/ui/packages/platform/src/components/DbLabInstanceForm/utils/index.ts deleted file mode 100644 index 0d3c305b..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceForm/utils/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { CloudImage } from 'api/cloud/getCloudImages' -import { initialState } from '../reducer' - -const API_SERVER = process.env.REACT_APP_API_SERVER -export const DEBUG_API_SERVER = 'https://v2.postgres.ai/api/general' - -export const availableTags = ['3.4.0-rc.5', '4.0.0-alpha.5'] - -export const dockerRunCommand = (provider: string) => { - /* eslint-disable no-template-curly-in-string */ - switch (provider) { - case 'aws': - return 'docker run --rm -it --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}' - case 'gcp': - return 'docker run --rm -it --env GCP_SERVICE_ACCOUNT_CONTENTS=${GCP_SERVICE_ACCOUNT_CONTENTS}' - case 'hetzner': - return 'docker run --rm -it --env HCLOUD_API_TOKEN=${HCLOUD_API_TOKEN}' - case 'digitalocean': - return 'docker run --rm -it --env DO_API_TOKEN=${DO_API_TOKEN}' - default: - throw new Error('Provider is not supported') - } -} - -export const getPlaybookCommand = ( - state: typeof initialState, - cloudImages: CloudImage, - orgKey: string, -) => - `${dockerRunCommand(state.provider)} \\\r - postgresai/dle-se-ansible:v1.0-rc.1 \\\r - ansible-playbook deploy_dle.yml --extra-vars \\\r - "provision='${state.provider}' \\\r - server_name='${state.name}' \\\r - server_type='${state.instanceType.native_name}' \\\r - server_image='${cloudImages?.native_os_image}' \\\r - server_location='${state.location.native_code}' \\\r - volume_size='${state.storage}' \\\r - dle_verification_token='${state.verificationToken}' \\\r - dle_version='${state.tag}' \\\r - ${ - state.snapshots > 1 - ? `zpool_datasets_number='${state.snapshots}' \\\r` - : `` - } - ${orgKey ? `dle_platform_org_key='${orgKey}' \\\r` : ``} - ${ - API_SERVER === DEBUG_API_SERVER - ? `dle_platform_url='${DEBUG_API_SERVER}' \\\r` - : `` - } - ${state.publicKeys ? `ssh_public_keys='${state.publicKeys}' \\\r` : ``} - dle_platform_project_name='${state.name}'"` - -export const getPlaybookCommandWithoutDocker = ( - state: typeof initialState, - cloudImages: CloudImage, - orgKey: string, -) => - `ansible-playbook deploy_dle.yml --extra-vars \\\r - "provision='${state.provider}' \\\r - server_name='${state.name}' \\\r - server_type='${state.instanceType.native_name}' \\\r - server_image='${cloudImages?.native_os_image}' \\\r - server_location='${state.location.native_code}' \\\r - volume_size='${state.storage}' \\\r - dle_verification_token='${state.verificationToken}' \\\r - dle_version='${state.tag}' \\\r - ${ - state.snapshots > 1 ? `zpool_datasets_number='${state.snapshots}' \\\r` : `` - } - ${orgKey ? `dle_platform_org_key='${orgKey}' \\\r` : ``} - ${ - API_SERVER === DEBUG_API_SERVER - ? `dle_platform_url='${DEBUG_API_SERVER}' \\\r` - : `` - } - ${state.publicKeys ? `ssh_public_keys='${state.publicKeys}' \\\r` : ``} - dle_platform_project_name='${state.name}'"` - -export const getGcpAccountContents = () => - `export GCP_SERVICE_ACCOUNT_CONTENTS='{ - "type": "service_account", - "project_id": "my-project", - "private_key_id": "c764349XXXXXXXXXX72f", - "private_key": "XXXXXXXXXX", - "client_email": "my-sa@my-project.iam.gserviceaccount.com", - "client_id": "111111112222222", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-sat%40my-project.iam.gserviceaccount.com" -}'` - -export const cloudProviderName = (provider: string) => { - switch (provider) { - case 'aws': - return 'AWS' - case 'gcp': - return 'GCP' - case 'digitalocean': - return 'DigitalOcean' - case 'hetzner': - return 'Hetzner' - default: - return provider - } -} - -export const pricingPageForProvider = (provider: string) => { - switch (provider) { - case 'aws': - return 'https://instances.vantage.sh/' - case 'gcp': - return 'https://cloud.google.com/compute/docs/general-purpose-machines' - case 'digitalocean': - return 'https://www.digitalocean.com/pricing/droplets' - case 'hetzner': - return 'https://www.hetzner.com/cloud' - } -} From 9d45ce39d54776c48bd6912cedd602e57ee99850 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 12 Dec 2023 17:35:47 +0700 Subject: [PATCH 044/114] fix: clone counting and restoration cleanup --- .../internal/provision/thinclones/zfs/zfs.go | 8 ++-- .../provision/thinclones/zfs/zfs_test.go | 39 ++++++++++--------- .../engine/postgres/logical/restore.go | 14 +++++-- .../retrieval/engine/postgres/tools/tools.go | 19 +++++++++ 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index 765e4e90..22d2dffd 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -259,11 +259,10 @@ func (m *Manager) ListClonesNames() ([]string, error) { cloneNames := []string{} poolPrefix := m.config.Pool.Name + "/" - clonePoolPrefix := m.config.Pool.Name + "/" + util.ClonePrefix lines := strings.Split(strings.TrimSpace(cmdOutput), "\n") for _, line := range lines { - if strings.HasPrefix(line, clonePoolPrefix) { + if strings.HasPrefix(line, poolPrefix) && !strings.Contains(line, m.config.PreSnapshotSuffix) { cloneNames = append(cloneNames, strings.TrimPrefix(line, poolPrefix)) } } @@ -447,7 +446,7 @@ func (m *Manager) CleanupSnapshots(retentionLimit int) ([]string, error) { func (m *Manager) getBusySnapshotList(clonesOutput string) []string { systemClones, userClones := make(map[string]string), make(map[string]struct{}) - userClonePrefix := m.config.Pool.Name + "/" + util.ClonePrefix + userClonePrefix := m.config.Pool.Name + "/" for _, line := range strings.Split(clonesOutput, "\n") { cloneLine := strings.FieldsFunc(line, unicode.IsSpace) @@ -456,7 +455,8 @@ func (m *Manager) getBusySnapshotList(clonesOutput string) []string { continue } - if strings.HasPrefix(cloneLine[0], userClonePrefix) { + if cloneName, _ := strings.CutPrefix(cloneLine[0], userClonePrefix); + strings.HasPrefix(cloneLine[0], userClonePrefix) && !strings.Contains(cloneName, m.config.PreSnapshotSuffix) { origin := cloneLine[1] if idx := strings.Index(origin, "@"); idx != -1 { diff --git a/engine/internal/provision/thinclones/zfs/zfs_test.go b/engine/internal/provision/thinclones/zfs/zfs_test.go index db2acecd..6b71dce1 100644 --- a/engine/internal/provision/thinclones/zfs/zfs_test.go +++ b/engine/internal/provision/thinclones/zfs/zfs_test.go @@ -21,8 +21,8 @@ func (r runnerMock) Run(string, ...bool) (string, error) { func TestListClones(t *testing.T) { const ( - poolName = "datastore" - clonePrefix = "dblab_clone_" + poolName = "datastore" + preSnapshotSuffix = "_pre" ) testCases := []struct { @@ -37,47 +37,47 @@ func TestListClones(t *testing.T) { { caseName: "single clone", cmdOutput: `datastore/clone_pre_20200831030000 -datastore/dblab_clone_6000 +datastore/cls19p20l4rc73bc2v9g `, cloneNames: []string{ - "dblab_clone_6000", + "cls19p20l4rc73bc2v9g", }, }, { caseName: "multiple clones", cmdOutput: `datastore/clone_pre_20200831030000 -datastore/dblab_clone_6000 -datastore/dblab_clone_6001 +datastore/cls19p20l4rc73bc2v9g +datastore/cls184a0l4rc73bc2v90 `, cloneNames: []string{ - "dblab_clone_6000", - "dblab_clone_6001", + "cls19p20l4rc73bc2v9g", + "cls184a0l4rc73bc2v90", }, }, { caseName: "clone duplicate", cmdOutput: `datastore/clone_pre_20200831030000 -datastore/dblab_clone_6000 -datastore/dblab_clone_6000 +datastore/cls19p20l4rc73bc2v9g +datastore/cls19p20l4rc73bc2v9g `, cloneNames: []string{ - "dblab_clone_6000", + "cls19p20l4rc73bc2v9g", }, }, { caseName: "different pool", cmdOutput: `datastore/clone_pre_20200831030000 -dblab_pool/dblab_clone_6001 -datastore/dblab_clone_6000 +dblab_pool/cls19p20l4rc73bc2v9g +datastore/cls184a0l4rc73bc2v90 `, cloneNames: []string{ - "dblab_clone_6000", + "cls184a0l4rc73bc2v90", }, }, { caseName: "no matched clone", cmdOutput: `datastore/clone_pre_20200831030000 -dblab_pool/dblab_clone_6001 +dblab_pool/cls19p20l4rc73bc2v9g `, cloneNames: []string{}, }, @@ -90,7 +90,7 @@ dblab_pool/dblab_clone_6001 }, config: Config{ Pool: resources.NewPool(poolName), - PreSnapshotSuffix: clonePrefix, + PreSnapshotSuffix: preSnapshotSuffix, }, } @@ -115,7 +115,8 @@ func TestFailedListClones(t *testing.T) { } func TestBusySnapshotList(t *testing.T) { - m := Manager{config: Config{Pool: &resources.Pool{Name: "dblab_pool"}}} + const preSnapshotSuffix = "_pre" + m := Manager{config: Config{Pool: &resources.Pool{Name: "dblab_pool"}, PreSnapshotSuffix: preSnapshotSuffix}} out := `dblab_pool - dblab_pool/clone_pre_20210127105215 dblab_pool@snapshot_20210127105215_pre @@ -125,8 +126,8 @@ dblab_pool/clone_pre_20210127123000 dblab_pool@snapshot_20210127123000_pre dblab_pool/clone_pre_20210127130000 dblab_pool@snapshot_20210127130000_pre dblab_pool/clone_pre_20210127133000 dblab_pool@snapshot_20210127133000_pre dblab_pool/clone_pre_20210127140000 dblab_pool@snapshot_20210127140000_pre -dblab_pool/dblab_clone_6000 dblab_pool/clone_pre_20210127133000@snapshot_20210127133008 -dblab_pool/dblab_clone_6001 dblab_pool/clone_pre_20210127123000@snapshot_20210127133008 +dblab_pool/cls19p20l4rc73bc2v9g dblab_pool/clone_pre_20210127133000@snapshot_20210127133008 +dblab_pool/cls19p20l4rc73bc2v9g dblab_pool/clone_pre_20210127123000@snapshot_20210127133008 ` expected := []string{"dblab_pool@snapshot_20210127133000_pre", "dblab_pool@snapshot_20210127123000_pre"} diff --git a/engine/internal/retrieval/engine/postgres/logical/restore.go b/engine/internal/retrieval/engine/postgres/logical/restore.go index c3855792..b925602a 100644 --- a/engine/internal/retrieval/engine/postgres/logical/restore.go +++ b/engine/internal/retrieval/engine/postgres/logical/restore.go @@ -211,10 +211,6 @@ func (r *RestoreJob) Run(ctx context.Context) (err error) { return fmt.Errorf("failed to explore the data directory %q: %w", dataDir, err) } - if !isEmpty { - log.Warn(fmt.Sprintf("The data directory %q is not empty. Existing data will be overwritten.", dataDir)) - } - if err := tools.PullImage(ctx, r.dockerClient, r.RestoreOptions.DockerImage); err != nil { return errors.Wrap(err, "failed to scan image pulling response") } @@ -244,6 +240,16 @@ func (r *RestoreJob) Run(ctx context.Context) (err error) { } }() + if !isEmpty { + log.Warn(fmt.Sprintf("The data directory %q is not empty. Existing data will be overwritten.", dataDir)) + + log.Msg("Clean up data directory:", dataDir) + + if err := tools.CleanupDir(dataDir); err != nil { + return fmt.Errorf("failed to clean up data directory before restore: %w", err) + } + } + log.Msg(fmt.Sprintf("Running container: %s. ID: %v", r.restoreContainerName(), containerID)) if err := r.dockerClient.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { diff --git a/engine/internal/retrieval/engine/postgres/tools/tools.go b/engine/internal/retrieval/engine/postgres/tools/tools.go index 6b196b62..0b9a81af 100644 --- a/engine/internal/retrieval/engine/postgres/tools/tools.go +++ b/engine/internal/retrieval/engine/postgres/tools/tools.go @@ -15,6 +15,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "strconv" "strings" "time" @@ -95,6 +96,24 @@ func IsEmptyDirectory(dir string) (bool, error) { return len(names) == 0, nil } +// CleanupDir removes content of the directory. +func CleanupDir(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read directory %s: %w", dir, err) + } + + for _, entry := range entries { + entryName := filepath.Join(dir, entry.Name()) + + if err := os.RemoveAll(entryName); err != nil { + return fmt.Errorf("failed to remove %s: %w", entryName, err) + } + } + + return nil +} + // TouchFile creates an empty file. func TouchFile(filename string) error { file, err := os.Create(filename) From c609155ec51faa37a0469447dfc24c41916e71a2 Mon Sep 17 00:00:00 2001 From: akartasov Date: Mon, 25 Dec 2023 13:15:29 +0700 Subject: [PATCH 045/114] feat: check dependent entities(branches, clones) before snapshot delete --- engine/internal/provision/mode_local_test.go | 4 +++ engine/internal/provision/pool/manager.go | 1 + .../provision/thinclones/lvm/lvmanager.go | 7 ++++ .../provision/thinclones/zfs/branching.go | 32 +++++++++++++++++++ .../internal/provision/thinclones/zfs/zfs.go | 12 +++++++ engine/internal/srv/routes.go | 5 +++ 6 files changed, 61 insertions(+) diff --git a/engine/internal/provision/mode_local_test.go b/engine/internal/provision/mode_local_test.go index 3d9eba8c..a59b2192 100644 --- a/engine/internal/provision/mode_local_test.go +++ b/engine/internal/provision/mode_local_test.go @@ -182,6 +182,10 @@ func (m mockFSManager) DeleteRootProp(_, _ string) error { return nil } +func (m mockFSManager) HasDependentEntity(_ string) error { + return nil +} + func TestBuildPoolEntry(t *testing.T) { testCases := []struct { pool *resources.Pool diff --git a/engine/internal/provision/pool/manager.go b/engine/internal/provision/pool/manager.go index 87472686..e070a8ff 100644 --- a/engine/internal/provision/pool/manager.go +++ b/engine/internal/provision/pool/manager.go @@ -70,6 +70,7 @@ type Branching interface { SetDSA(dsa, snapshotName string) error SetMessage(message, snapshotName string) error Reset(snapshotID string, options thinclones.ResetOptions) error + HasDependentEntity(snapshotName string) error } // Pooler describes methods for Pool providing. diff --git a/engine/internal/provision/thinclones/lvm/lvmanager.go b/engine/internal/provision/thinclones/lvm/lvmanager.go index 948b3445..b2b4b959 100644 --- a/engine/internal/provision/thinclones/lvm/lvmanager.go +++ b/engine/internal/provision/thinclones/lvm/lvmanager.go @@ -260,3 +260,10 @@ func (m *LVManager) Rename(_, _ string) error { return nil } + +// HasDependentEntity checks if snapshot has dependent entities. +func (m *LVManager) HasDependentEntity(_ string) error { + log.Msg("HasDependentEntity is not supported for LVM. Skip the operation") + + return nil +} diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go index 9562572b..a96ef05f 100644 --- a/engine/internal/provision/thinclones/zfs/branching.go +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -381,6 +381,38 @@ func (m *Manager) SetMessage(message, snapshotName string) error { return m.setProperty(messageProp, encodedMessage, snapshotName) } +// HasDependentEntity gets the root property of the snapshot. +func (m *Manager) HasDependentEntity(snapshotName string) error { + root, err := m.getProperty(rootProp, snapshotName) + if err != nil { + return fmt.Errorf("failed to check root property: %w", err) + } + + if root != "" { + return fmt.Errorf("snapshot has dependent branches: %s", root) + } + + child, err := m.getProperty(childProp, snapshotName) + if err != nil { + return fmt.Errorf("failed to check snapshot child property: %w", err) + } + + if child != "" { + return fmt.Errorf("snapshot has dependent snapshots: %s", child) + } + + clones, err := m.checkDependentClones(snapshotName) + if err != nil { + return fmt.Errorf("failed to check dependent clones: %w", err) + } + + if len(clones) != 0 { + return fmt.Errorf("snapshot has dependent clones: %s", clones) + } + + return nil +} + func (m *Manager) addToSet(property, snapshot, value string) error { original, err := m.getProperty(property, snapshot) if err != nil { diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index 22d2dffd..4f02f884 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -415,6 +415,18 @@ func (m *Manager) moveBranchPointer(rel *snapshotRelation, snapshotName string) return nil } +func (m *Manager) checkDependentClones(snapshotName string) (string, error) { + clonesCmd := fmt.Sprintf("zfs list -t snapshot -H -o clones -r %s %s", m.config.Pool.Name, snapshotName) + + clonesOutput, err := m.runner.Run(clonesCmd) + if err != nil { + log.Dbg(clonesOutput) + return "", fmt.Errorf("failed to list dependent clones: %w", err) + } + + return strings.TrimSpace(clonesOutput), nil +} + // CleanupSnapshots destroys old snapshots considering retention limit and related clones. func (m *Manager) CleanupSnapshots(retentionLimit int) ([]string, error) { clonesCmd := fmt.Sprintf("zfs list -S clones -o name,origin -H -r %s", m.config.Pool.Name) diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index dd51e0a0..e1bf9d01 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -198,6 +198,11 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { return } + if err := fsm.HasDependentEntity(destroyRequest.SnapshotID); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + if err = fsm.DestroySnapshot(destroyRequest.SnapshotID); err != nil { api.SendBadRequestError(w, r, err.Error()) return From a43fd3031746dedca3cd6302e9c4f0b9324e6d9f Mon Sep 17 00:00:00 2001 From: akartasov Date: Mon, 25 Dec 2023 19:37:42 +0700 Subject: [PATCH 046/114] fix: cut hyphen --- engine/internal/provision/thinclones/zfs/zfs.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index 4f02f884..4aa228b1 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -424,7 +424,7 @@ func (m *Manager) checkDependentClones(snapshotName string) (string, error) { return "", fmt.Errorf("failed to list dependent clones: %w", err) } - return strings.TrimSpace(clonesOutput), nil + return strings.Trim(strings.TrimSpace(clonesOutput), "-"), nil } // CleanupSnapshots destroys old snapshots considering retention limit and related clones. @@ -467,8 +467,7 @@ func (m *Manager) getBusySnapshotList(clonesOutput string) []string { continue } - if cloneName, _ := strings.CutPrefix(cloneLine[0], userClonePrefix); - strings.HasPrefix(cloneLine[0], userClonePrefix) && !strings.Contains(cloneName, m.config.PreSnapshotSuffix) { + if cloneName, _ := strings.CutPrefix(cloneLine[0], userClonePrefix); strings.HasPrefix(cloneLine[0], userClonePrefix) && !strings.Contains(cloneName, m.config.PreSnapshotSuffix) { origin := cloneLine[1] if idx := strings.Index(origin, "@"); idx != -1 { From 63ebfd0a1a4dbac10bd341830d7470c3a74a0b53 Mon Sep 17 00:00:00 2001 From: akartasov Date: Mon, 25 Dec 2023 20:12:49 +0700 Subject: [PATCH 047/114] fix lint --- engine/internal/provision/thinclones/zfs/zfs.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/engine/internal/provision/thinclones/zfs/zfs.go b/engine/internal/provision/thinclones/zfs/zfs.go index 4aa228b1..9faf1d3f 100644 --- a/engine/internal/provision/thinclones/zfs/zfs.go +++ b/engine/internal/provision/thinclones/zfs/zfs.go @@ -467,7 +467,8 @@ func (m *Manager) getBusySnapshotList(clonesOutput string) []string { continue } - if cloneName, _ := strings.CutPrefix(cloneLine[0], userClonePrefix); strings.HasPrefix(cloneLine[0], userClonePrefix) && !strings.Contains(cloneName, m.config.PreSnapshotSuffix) { + if cloneName, _ := strings.CutPrefix(cloneLine[0], userClonePrefix); + strings.HasPrefix(cloneLine[0], userClonePrefix) && !strings.Contains(cloneName, m.config.PreSnapshotSuffix) { origin := cloneLine[1] if idx := strings.Index(origin, "@"); idx != -1 { From e4c96c9c8774ea5202260eeb8e41fe25f2dc1d86 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 26 Dec 2023 18:05:51 +0700 Subject: [PATCH 048/114] fix: return all available branches regardless their datasets --- engine/internal/provision/mode_local_test.go | 8 +++ engine/internal/provision/pool/manager.go | 2 + .../provision/thinclones/lvm/lvmanager.go | 14 +++++ .../provision/thinclones/zfs/branching.go | 61 ++++++++++++++++--- engine/internal/srv/branch.go | 15 +++-- engine/pkg/models/branch.go | 7 +++ 6 files changed, 91 insertions(+), 16 deletions(-) diff --git a/engine/internal/provision/mode_local_test.go b/engine/internal/provision/mode_local_test.go index a59b2192..40f522d1 100644 --- a/engine/internal/provision/mode_local_test.go +++ b/engine/internal/provision/mode_local_test.go @@ -134,6 +134,10 @@ func (m mockFSManager) ListBranches() (map[string]string, error) { return nil, nil } +func (m mockFSManager) ListAllBranches() (map[string]string, error) { + return nil, nil +} + func (m mockFSManager) AddBranchProp(_, _ string) error { return nil } @@ -154,6 +158,10 @@ func (m mockFSManager) GetRepo() (*models.Repo, error) { return nil, nil } +func (m mockFSManager) GetAllRepo() (*models.Repo, error) { + return nil, nil +} + func (m mockFSManager) SetDSA(_, _ string) error { return nil } diff --git a/engine/internal/provision/pool/manager.go b/engine/internal/provision/pool/manager.go index e070a8ff..d53df1c3 100644 --- a/engine/internal/provision/pool/manager.go +++ b/engine/internal/provision/pool/manager.go @@ -57,7 +57,9 @@ type Branching interface { VerifyBranchMetadata() error CreateBranch(branchName, snapshotID string) error ListBranches() (map[string]string, error) + ListAllBranches() (map[string]string, error) GetRepo() (*models.Repo, error) + GetAllRepo() (*models.Repo, error) SetRelation(parent, snapshotName string) error Snapshot(snapshotName string) error SetMountpoint(path, branch string) error diff --git a/engine/internal/provision/thinclones/lvm/lvmanager.go b/engine/internal/provision/thinclones/lvm/lvmanager.go index b2b4b959..929740b3 100644 --- a/engine/internal/provision/thinclones/lvm/lvmanager.go +++ b/engine/internal/provision/thinclones/lvm/lvmanager.go @@ -184,6 +184,13 @@ func (m *LVManager) ListBranches() (map[string]string, error) { return nil, nil } +// ListAllBranches lists all branches. +func (m *LVManager) ListAllBranches() (map[string]string, error) { + log.Msg("ListAllBranches is not supported for LVM. Skip the operation") + + return nil, nil +} + // AddBranchProp adds branch to snapshot property. func (m *LVManager) AddBranchProp(_, _ string) error { log.Msg("AddBranchProp is not supported for LVM. Skip the operation") @@ -233,6 +240,13 @@ func (m *LVManager) GetRepo() (*models.Repo, error) { return nil, nil } +// GetAllRepo provides data repository details. +func (m *LVManager) GetAllRepo() (*models.Repo, error) { + log.Msg("GetAllRepo is not supported for LVM. Skip the operation") + + return nil, nil +} + // SetDSA sets value of DataStateAt to snapshot. func (m *LVManager) SetDSA(_, _ string) error { log.Msg("SetDSA is not supported for LVM. Skip the operation") diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go index a96ef05f..fe848671 100644 --- a/engine/internal/provision/thinclones/zfs/branching.go +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -26,6 +26,10 @@ const ( empty = "-" ) +type cmdCfg struct { + pool string +} + // InitBranching inits data branching. func (m *Manager) InitBranching() error { snapshots := m.SnapshotList() @@ -206,10 +210,28 @@ func (m *Manager) SetMountpoint(path, name string) error { // ListBranches lists data pool branches. func (m *Manager) ListBranches() (map[string]string, error) { + return m.listBranches(cmdCfg{pool: m.config.Pool.Name}) +} + +// ListAllBranches lists all branches. +func (m *Manager) ListAllBranches() (map[string]string, error) { + return m.listBranches(cmdCfg{}) +} + +func (m *Manager) listBranches(cfg cmdCfg) (map[string]string, error) { + filter := "" + args := []any{branchProp} + + if cfg.pool != "" { + filter = "-r %s" + + args = append(args, cfg.pool) + } + cmd := fmt.Sprintf( // Get ZFS snapshots (-t) with options (-o) without output headers (-H) filtered by pool (-r). // Excluding snapshots without "dle:branch" property ("grep -v"). - `zfs list -H -t snapshot -o %s,name -r %s | grep -v "^-" | cat`, branchProp, m.config.Pool.Name, + `zfs list -H -t snapshot -o %s,name `+filter+` | grep -v "^-" | cat`, args..., ) out, err := m.runner.Run(cmd) @@ -229,13 +251,15 @@ func (m *Manager) ListBranches() (map[string]string, error) { continue } + dataset, _, _ := strings.Cut(fields[1], "@") + if !strings.Contains(fields[0], branchSep) { - branches[fields[0]] = fields[1] + branches[models.BranchName(dataset, fields[0])] = fields[1] continue } for _, branchName := range strings.Split(fields[0], branchSep) { - branches[branchName] = fields[1] + branches[models.BranchName(dataset, branchName)] = fields[1] } } @@ -244,16 +268,30 @@ func (m *Manager) ListBranches() (map[string]string, error) { var repoFields = []any{"name", parentProp, childProp, branchProp, rootProp, dataStateAtLabel, messageProp} -// GetRepo provides repository details about snapshots and branches. +// GetRepo provides repository details about snapshots and branches filtered by data pool. func (m *Manager) GetRepo() (*models.Repo, error) { + return m.getRepo(cmdCfg{pool: m.config.Pool.Name}) +} + +// GetAllRepo provides all repository details about snapshots and branches. +func (m *Manager) GetAllRepo() (*models.Repo, error) { + return m.getRepo(cmdCfg{}) +} + +func (m *Manager) getRepo(cmdCfg cmdCfg) (*models.Repo, error) { strFields := bytes.TrimRight(bytes.Repeat([]byte(`%s,`), len(repoFields)), ",") - cmd := fmt.Sprintf( - // Get ZFS snapshots (-t) with options (-o) without output headers (-H) filtered by pool (-r). - `zfs list -H -t snapshot -o `+string(strFields)+" -r %s", append(repoFields, m.config.Pool.Name)..., - ) + // Get ZFS snapshots (-t) with options (-o) without output headers (-H) filtered by pool (-r). + format := `zfs list -H -t snapshot -o ` + string(strFields) + args := repoFields - out, err := m.runner.Run(cmd) + if cmdCfg.pool != "" { + format += " -r %s" + + args = append(args, cmdCfg.pool) + } + + out, err := m.runner.Run(fmt.Sprintf(format, args...)) if err != nil { return nil, fmt.Errorf("failed to list branches: %w. Out: %v", err, out) } @@ -271,6 +309,8 @@ func (m *Manager) GetRepo() (*models.Repo, error) { continue } + dataset, _, _ := strings.Cut(fields[0], "@") + snDetail := models.SnapshotDetails{ ID: fields[0], Parent: fields[1], @@ -279,6 +319,7 @@ func (m *Manager) GetRepo() (*models.Repo, error) { Root: unwindField(fields[4]), DataStateAt: strings.Trim(fields[5], empty), Message: decodeCommitMessage(fields[6]), + Dataset: dataset, } repo.Snapshots[fields[0]] = snDetail @@ -288,7 +329,7 @@ func (m *Manager) GetRepo() (*models.Repo, error) { continue } - repo.Branches[sn] = fields[0] + repo.Branches[models.BranchName(dataset, sn)] = fields[0] } } diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 9e5f9ed6..c1483a95 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -26,13 +26,13 @@ func (s *Server) listBranches(w http.ResponseWriter, r *http.Request) { return } - branches, err := fsm.ListBranches() + branches, err := fsm.ListAllBranches() if err != nil { api.SendBadRequestError(w, r, err.Error()) return } - repo, err := fsm.GetRepo() + repo, err := fsm.GetAllRepo() if err != nil { api.SendBadRequestError(w, r, err.Error()) return @@ -46,12 +46,15 @@ func (s *Server) listBranches(w http.ResponseWriter, r *http.Request) { continue } + _, branchNam, _ := strings.Cut(branchName, "_") + branchDetails = append(branchDetails, models.BranchView{ - Name: branchName, - Parent: findBranchParent(repo.Snapshots, snapshotDetails.ID, branchName), + Name: branchNam, + Parent: findBranchParent(repo.Snapshots, snapshotDetails.ID, branchNam), DataStateAt: snapshotDetails.DataStateAt, SnapshotID: snapshotDetails.ID, + Dataset: snapshotDetails.Dataset, }) } @@ -343,7 +346,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { return } - snapshotID, ok := repo.Branches[logRequest.BranchName] + snapshotID, ok := repo.Branches[models.BranchName(fsm.Pool().Name, logRequest.BranchName)] if !ok { api.SendBadRequestError(w, r, "branch not found: "+logRequest.BranchName) return @@ -389,7 +392,7 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } - snapshotID, ok := repo.Branches[deleteRequest.BranchName] + snapshotID, ok := repo.Branches[models.BranchName(fsm.Pool().Name, deleteRequest.BranchName)] if !ok { api.SendBadRequestError(w, r, "branch not found: "+deleteRequest.BranchName) return diff --git a/engine/pkg/models/branch.go b/engine/pkg/models/branch.go index 1a223c4a..25a1bdc8 100644 --- a/engine/pkg/models/branch.go +++ b/engine/pkg/models/branch.go @@ -28,6 +28,7 @@ type SnapshotDetails struct { Root []string `json:"root"` DataStateAt string `json:"dataStateAt"` Message string `json:"message"` + Dataset string `json:"dataset"` } // BranchView describes branch view. @@ -36,4 +37,10 @@ type BranchView struct { Parent string `json:"parent"` DataStateAt string `json:"dataStateAt"` SnapshotID string `json:"snapshotID"` + Dataset string `json:"dataset"` +} + +// BranchName returns full branch name. +func BranchName(pool, branch string) string { + return pool + "_" + branch } From 84d3c183a5b507cd92c7ace250fa83cd899007d1 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 26 Dec 2023 18:21:15 +0700 Subject: [PATCH 049/114] fix branch naming --- engine/internal/srv/branch.go | 6 +++--- engine/internal/srv/routes.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index c1483a95..c33a95a0 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -126,7 +126,7 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } - if _, ok := branches[createRequest.BranchName]; ok { + if _, ok := branches[models.BranchName(fsm.Pool().Name, createRequest.BranchName)]; ok { api.SendBadRequestError(w, r, fmt.Sprintf("branch '%s' already exists", createRequest.BranchName)) return } @@ -139,7 +139,7 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } - branchPointer, ok := branches[createRequest.BaseBranch] + branchPointer, ok := branches[createRequest.BranchName] if !ok { api.SendBadRequestError(w, r, "base branch not found") return @@ -256,7 +256,7 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } - currentSnapshotID, ok := branches[clone.Branch] + currentSnapshotID, ok := branches[models.BranchName(fsm.Pool().Name, clone.Branch)] if !ok { api.SendBadRequestError(w, r, "branch not found: "+clone.Branch) return diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index e1bf9d01..827d4ede 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -344,7 +344,7 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { return } - snapshotID, ok := branches[cloneRequest.Branch] + snapshotID, ok := branches[models.BranchName(fsm.Pool().Name, cloneRequest.Branch)] if !ok { api.SendBadRequestError(w, r, "branch not found") return From ed113c5ad7843865ae91dd7c5f8a188e1771f922 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 26 Dec 2023 18:33:05 +0700 Subject: [PATCH 050/114] fix branch naming --- engine/internal/srv/branch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index c33a95a0..f5fd445f 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -139,7 +139,7 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } - branchPointer, ok := branches[createRequest.BranchName] + branchPointer, ok := branches[models.BranchName(fsm.Pool().Name, createRequest.BranchName)] if !ok { api.SendBadRequestError(w, r, "base branch not found") return From 081895070c503108b4fe6e50b8402e6bc89742b9 Mon Sep 17 00:00:00 2001 From: akartasov Date: Tue, 26 Dec 2023 21:29:42 +0700 Subject: [PATCH 051/114] fix branch naming --- engine/internal/srv/branch.go | 2 +- engine/pkg/models/branch.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index f5fd445f..fe44dc9b 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -139,7 +139,7 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } - branchPointer, ok := branches[models.BranchName(fsm.Pool().Name, createRequest.BranchName)] + branchPointer, ok := branches[models.BranchName(fsm.Pool().Name, createRequest.BaseBranch)] if !ok { api.SendBadRequestError(w, r, "base branch not found") return diff --git a/engine/pkg/models/branch.go b/engine/pkg/models/branch.go index 25a1bdc8..6338ea50 100644 --- a/engine/pkg/models/branch.go +++ b/engine/pkg/models/branch.go @@ -42,5 +42,5 @@ type BranchView struct { // BranchName returns full branch name. func BranchName(pool, branch string) string { - return pool + "_" + branch + return pool + "|" + branch } From cd14db11dd0baa18174e21f0e35ce27c2a658320 Mon Sep 17 00:00:00 2001 From: akartasov Date: Wed, 27 Dec 2023 12:37:46 +0700 Subject: [PATCH 052/114] revert branch changes: remove prefixes --- engine/internal/provision/mode_local_test.go | 2 +- engine/internal/provision/pool/manager.go | 2 +- .../provision/thinclones/lvm/lvmanager.go | 2 +- .../provision/thinclones/zfs/branching.go | 56 +++++++++++++------ engine/internal/srv/branch.go | 20 +++---- engine/internal/srv/routes.go | 2 +- engine/pkg/models/branch.go | 7 ++- 7 files changed, 56 insertions(+), 35 deletions(-) diff --git a/engine/internal/provision/mode_local_test.go b/engine/internal/provision/mode_local_test.go index 40f522d1..e8ecb3e9 100644 --- a/engine/internal/provision/mode_local_test.go +++ b/engine/internal/provision/mode_local_test.go @@ -134,7 +134,7 @@ func (m mockFSManager) ListBranches() (map[string]string, error) { return nil, nil } -func (m mockFSManager) ListAllBranches() (map[string]string, error) { +func (m mockFSManager) ListAllBranches() ([]models.BranchEntity, error) { return nil, nil } diff --git a/engine/internal/provision/pool/manager.go b/engine/internal/provision/pool/manager.go index d53df1c3..ad326e8a 100644 --- a/engine/internal/provision/pool/manager.go +++ b/engine/internal/provision/pool/manager.go @@ -57,7 +57,7 @@ type Branching interface { VerifyBranchMetadata() error CreateBranch(branchName, snapshotID string) error ListBranches() (map[string]string, error) - ListAllBranches() (map[string]string, error) + ListAllBranches() ([]models.BranchEntity, error) GetRepo() (*models.Repo, error) GetAllRepo() (*models.Repo, error) SetRelation(parent, snapshotName string) error diff --git a/engine/internal/provision/thinclones/lvm/lvmanager.go b/engine/internal/provision/thinclones/lvm/lvmanager.go index 929740b3..7be3d9d1 100644 --- a/engine/internal/provision/thinclones/lvm/lvmanager.go +++ b/engine/internal/provision/thinclones/lvm/lvmanager.go @@ -185,7 +185,7 @@ func (m *LVManager) ListBranches() (map[string]string, error) { } // ListAllBranches lists all branches. -func (m *LVManager) ListAllBranches() (map[string]string, error) { +func (m *LVManager) ListAllBranches() ([]models.BranchEntity, error) { log.Msg("ListAllBranches is not supported for LVM. Skip the operation") return nil, nil diff --git a/engine/internal/provision/thinclones/zfs/branching.go b/engine/internal/provision/thinclones/zfs/branching.go index fe848671..a38743bb 100644 --- a/engine/internal/provision/thinclones/zfs/branching.go +++ b/engine/internal/provision/thinclones/zfs/branching.go @@ -210,28 +210,52 @@ func (m *Manager) SetMountpoint(path, name string) error { // ListBranches lists data pool branches. func (m *Manager) ListBranches() (map[string]string, error) { - return m.listBranches(cmdCfg{pool: m.config.Pool.Name}) + return m.listBranches() } // ListAllBranches lists all branches. -func (m *Manager) ListAllBranches() (map[string]string, error) { - return m.listBranches(cmdCfg{}) -} +func (m *Manager) ListAllBranches() ([]models.BranchEntity, error) { + cmd := fmt.Sprintf( + // Get all ZFS snapshots (-t) with options (-o) without output headers (-H). + // Excluding snapshots without "dle:branch" property ("grep -v"). + `zfs list -H -t snapshot -o %s,name | grep -v "^-" | cat`, branchProp, + ) -func (m *Manager) listBranches(cfg cmdCfg) (map[string]string, error) { - filter := "" - args := []any{branchProp} + out, err := m.runner.Run(cmd) + if err != nil { + return nil, fmt.Errorf("failed to list branches: %w. Out: %v", err, out) + } + + branches := make([]models.BranchEntity, 0) + lines := strings.Split(strings.TrimSpace(out), "\n") + + const expectedColumns = 2 + + for _, line := range lines { + fields := strings.Fields(line) - if cfg.pool != "" { - filter = "-r %s" - - args = append(args, cfg.pool) + if len(fields) != expectedColumns { + continue + } + + if !strings.Contains(fields[0], branchSep) { + branches = append(branches, models.BranchEntity{Name: fields[0], SnapshotID: fields[1]}) + continue + } + + for _, branchName := range strings.Split(fields[0], branchSep) { + branches = append(branches, models.BranchEntity{Name: branchName, SnapshotID: fields[1]}) + } } + return branches, nil +} + +func (m *Manager) listBranches() (map[string]string, error) { cmd := fmt.Sprintf( // Get ZFS snapshots (-t) with options (-o) without output headers (-H) filtered by pool (-r). // Excluding snapshots without "dle:branch" property ("grep -v"). - `zfs list -H -t snapshot -o %s,name `+filter+` | grep -v "^-" | cat`, args..., + `zfs list -H -t snapshot -o %s,name -r %s | grep -v "^-" | cat`, branchProp, m.config.Pool.Name, ) out, err := m.runner.Run(cmd) @@ -251,15 +275,13 @@ func (m *Manager) listBranches(cfg cmdCfg) (map[string]string, error) { continue } - dataset, _, _ := strings.Cut(fields[1], "@") - if !strings.Contains(fields[0], branchSep) { - branches[models.BranchName(dataset, fields[0])] = fields[1] + branches[fields[0]] = fields[1] continue } for _, branchName := range strings.Split(fields[0], branchSep) { - branches[models.BranchName(dataset, branchName)] = fields[1] + branches[branchName] = fields[1] } } @@ -329,7 +351,7 @@ func (m *Manager) getRepo(cmdCfg cmdCfg) (*models.Repo, error) { continue } - repo.Branches[models.BranchName(dataset, sn)] = fields[0] + repo.Branches[sn] = fields[0] } } diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index fe44dc9b..9a79a627 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -40,18 +40,16 @@ func (s *Server) listBranches(w http.ResponseWriter, r *http.Request) { branchDetails := make([]models.BranchView, 0, len(branches)) - for branchName, snapshotID := range branches { - snapshotDetails, ok := repo.Snapshots[snapshotID] + for _, branchEntity := range branches { + snapshotDetails, ok := repo.Snapshots[branchEntity.SnapshotID] if !ok { continue } - _, branchNam, _ := strings.Cut(branchName, "_") - branchDetails = append(branchDetails, models.BranchView{ - Name: branchNam, - Parent: findBranchParent(repo.Snapshots, snapshotDetails.ID, branchNam), + Name: branchEntity.Name, + Parent: findBranchParent(repo.Snapshots, snapshotDetails.ID, branchEntity.Name), DataStateAt: snapshotDetails.DataStateAt, SnapshotID: snapshotDetails.ID, Dataset: snapshotDetails.Dataset, @@ -126,7 +124,7 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } - if _, ok := branches[models.BranchName(fsm.Pool().Name, createRequest.BranchName)]; ok { + if _, ok := branches[createRequest.BranchName]; ok { api.SendBadRequestError(w, r, fmt.Sprintf("branch '%s' already exists", createRequest.BranchName)) return } @@ -139,7 +137,7 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } - branchPointer, ok := branches[models.BranchName(fsm.Pool().Name, createRequest.BaseBranch)] + branchPointer, ok := branches[createRequest.BaseBranch] if !ok { api.SendBadRequestError(w, r, "base branch not found") return @@ -256,7 +254,7 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { return } - currentSnapshotID, ok := branches[models.BranchName(fsm.Pool().Name, clone.Branch)] + currentSnapshotID, ok := branches[clone.Branch] if !ok { api.SendBadRequestError(w, r, "branch not found: "+clone.Branch) return @@ -346,7 +344,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { return } - snapshotID, ok := repo.Branches[models.BranchName(fsm.Pool().Name, logRequest.BranchName)] + snapshotID, ok := repo.Branches[logRequest.BranchName] if !ok { api.SendBadRequestError(w, r, "branch not found: "+logRequest.BranchName) return @@ -392,7 +390,7 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } - snapshotID, ok := repo.Branches[models.BranchName(fsm.Pool().Name, deleteRequest.BranchName)] + snapshotID, ok := repo.Branches[deleteRequest.BranchName] if !ok { api.SendBadRequestError(w, r, "branch not found: "+deleteRequest.BranchName) return diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 827d4ede..e1bf9d01 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -344,7 +344,7 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { return } - snapshotID, ok := branches[models.BranchName(fsm.Pool().Name, cloneRequest.Branch)] + snapshotID, ok := branches[cloneRequest.Branch] if !ok { api.SendBadRequestError(w, r, "branch not found") return diff --git a/engine/pkg/models/branch.go b/engine/pkg/models/branch.go index 6338ea50..47d8358f 100644 --- a/engine/pkg/models/branch.go +++ b/engine/pkg/models/branch.go @@ -40,7 +40,8 @@ type BranchView struct { Dataset string `json:"dataset"` } -// BranchName returns full branch name. -func BranchName(pool, branch string) string { - return pool + "|" + branch +// BranchEntity defines a branch-snapshot pair. +type BranchEntity struct { + Name string + SnapshotID string } From 8e65d3336963b62674c845fdda7355a62cee6702 Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Thu, 4 Jan 2024 09:10:39 +0000 Subject: [PATCH 053/114] fix(ui): resolve UI issues for dle-4-0 --- .../components/ResetCloneModal/index.tsx | 3 +- .../shared/icons/ArrowDropDown/index.tsx | 2 + .../components/BranchesTable/index.tsx | 52 +- .../Modals/DeleteBranchModal/index.tsx | 1 + ui/packages/shared/pages/Branches/index.tsx | 4 +- ui/packages/shared/pages/Clone/index.tsx | 88 +- .../shared/pages/Configuration/index.tsx | 1184 ----------------- .../shared/pages/CreateClone/index.tsx | 122 +- .../pages/CreateClone/styles.module.scss | 5 + .../Instance/Clones/ClonesList/index.tsx | 84 +- .../Clones/ClonesList/styles.module.scss | 10 + .../shared/pages/Instance/Clones/index.tsx | 4 +- .../Configuration/InputWithTooltip/index.tsx | 17 +- .../pages/Instance/Configuration/index.tsx | 69 +- .../Instance/Configuration/tooltipText.tsx | 35 +- .../pages/Instance/Info/Retrieval/index.tsx | 9 +- .../pages/Instance/Info/Status/index.tsx | 8 +- .../components/SnapshotsTable/index.tsx | 369 +++-- .../shared/pages/Instance/Snapshots/index.tsx | 4 +- .../shared/pages/Instance/stores/Main.ts | 2 + .../Snapshot/DestorySnapshotModal/index.tsx | 35 +- .../shared/pages/Snapshots/Snapshot/index.tsx | 22 +- .../pages/Snapshots/Snapshot/stores/Main.ts | 12 +- .../Snapshots/Snapshot/useCreatedStores.ts | 4 +- ui/packages/shared/stores/Snapshots.ts | 10 +- .../shared/types/api/entities/clone.ts | 1 + .../shared/types/api/entities/snapshot.ts | 1 + 27 files changed, 614 insertions(+), 1543 deletions(-) delete mode 100644 ui/packages/shared/pages/Configuration/index.tsx diff --git a/ui/packages/shared/components/ResetCloneModal/index.tsx b/ui/packages/shared/components/ResetCloneModal/index.tsx index 77ff77e8..4b278137 100644 --- a/ui/packages/shared/components/ResetCloneModal/index.tsx +++ b/ui/packages/shared/components/ResetCloneModal/index.tsx @@ -112,10 +112,11 @@ export const ResetCloneModal = (props: Props) => { children: ( <> {snapshot.dataStateAt} ( - {isValidDate(snapshot.dataStateAtDate) && + {isValidDate(snapshot.dataStateAtDate) && formatDistanceToNowStrict(snapshot.dataStateAtDate, { addSuffix: true, })} + ) {isLatest && ( Latest )} diff --git a/ui/packages/shared/icons/ArrowDropDown/index.tsx b/ui/packages/shared/icons/ArrowDropDown/index.tsx index 8a03cd9f..b601b520 100644 --- a/ui/packages/shared/icons/ArrowDropDown/index.tsx +++ b/ui/packages/shared/icons/ArrowDropDown/index.tsx @@ -9,6 +9,7 @@ import React from 'react' type Props = { className?: string + onClick?: () => void } export const ArrowDropDownIcon = (props: Props) => { @@ -18,6 +19,7 @@ export const ArrowDropDownIcon = (props: Props) => { viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg" + onClick={props.onClick} > { + const sortByParent = state.sortByParent === 'desc' ? 'asc' : 'desc' + + const sortedBranches = [...state.branches].sort((a, b) => { + if (sortByParent === 'asc') { + return a.parent.localeCompare(b.parent) + } else { + return b.parent.localeCompare(a.parent) + } + }) + + setState({ + sortByParent, + branches: sortedBranches, + }) + } + + if (!state.branches.length) { return

{emptyTableText}

} @@ -72,13 +105,26 @@ export const BranchesTable = ({ Branch - Parent + +
+ Parent + +
+
Data state time Snapshot ID
- {branchesData?.map((branch) => ( + {state.branches?.map((branch) => ( diff --git a/ui/packages/shared/pages/Branches/index.tsx b/ui/packages/shared/pages/Branches/index.tsx index 73d81269..5dc42d99 100644 --- a/ui/packages/shared/pages/Branches/index.tsx +++ b/ui/packages/shared/pages/Branches/index.tsx @@ -102,7 +102,9 @@ export const Branches = observer((): React.ReactElement => { {!branchesList.length && ( - +
+ +
)} diff --git a/ui/packages/shared/pages/Clone/index.tsx b/ui/packages/shared/pages/Clone/index.tsx index 63e1dbe4..6fb6313c 100644 --- a/ui/packages/shared/pages/Clone/index.tsx +++ b/ui/packages/shared/pages/Clone/index.tsx @@ -36,6 +36,7 @@ import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle' import { icons } from '@postgres.ai/shared/styles/icons' import { styles } from '@postgres.ai/shared/styles/styles' import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' +import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' import { Status } from './Status' import { useCreatedStores } from './useCreatedStores' @@ -53,7 +54,7 @@ const useStyles = makeStyles( (theme) => ({ wrapper: { display: 'flex', - gap: '60px', + gap: '40px', maxWidth: '1200px', fontSize: '14px !important', marginTop: '20px', @@ -77,13 +78,13 @@ const useStyles = makeStyles( marginTop: '4px', }, errorStub: { - marginTop: '24px', + margin: '24px 0', }, spinner: { marginLeft: '8px', }, summary: { - flex: '1 1 0', + flex: '1.5 1 0', minWidth: 0, }, snippetContainer: { @@ -124,7 +125,7 @@ const useStyles = makeStyles( marginBottom: '20px', }, actionButton: { - marginRight: '16px', + marginRight: '10px', }, remark: { fontSize: '12px', @@ -230,26 +231,17 @@ export const Clone = observer((props: Props) => { ) - // Getting instance error. - if (stores.main.instanceError) - return ( - <> - {headRendered} - - - - ) + const cloneErrorMessage = + stores.main.instanceError?.message || stores.main.cloneError?.message + const cloneErrorTitle = + stores.main.instanceError?.title || stores.main.cloneError?.title - // Getting clone error. - if (stores.main.cloneError) + if (cloneErrorMessage && cloneErrorTitle) return ( <> {headRendered} - + ) @@ -283,6 +275,16 @@ export const Clone = observer((props: Props) => { if (isSuccess) history.push(props.routes.instance()) } + const createSnapshot = async () => { + await snapshots.createSnapshot(props.cloneId).then((snapshot) => { + if (snapshot && generateSnapshotPageId(snapshot.snapshotID)) { + history.push( + `/instance/snapshots/${generateSnapshotPageId(snapshot.snapshotID)}`, + ) + } + }) + } + // Clone reload. const reloadClone = () => stores.main.reload() @@ -309,22 +311,6 @@ export const Clone = observer((props: Props) => { <> {headRendered}
- {stores.main.resetCloneError && ( - - )} - - {stores.main.destroyCloneError && ( - - )} -
+
+ {stores.main.destroyCloneError || + (stores.main.resetCloneError && ( + + ))}
+

+ Branch +

+

{clone.branch}

+
+ +

Created

@@ -651,8 +665,8 @@ export const Clone = observer((props: Props) => { text={'Toggle deletion protection using CLI'} />

- You can toggle deletion protection using CLI for this clone - using the following command: + You can toggle deletion protection using CLI for this clone using + the following command:

diff --git a/ui/packages/shared/pages/Configuration/index.tsx b/ui/packages/shared/pages/Configuration/index.tsx deleted file mode 100644 index dc704ec7..00000000 --- a/ui/packages/shared/pages/Configuration/index.tsx +++ /dev/null @@ -1,1184 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any small is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useState, useEffect } from 'react' -import { observer } from 'mobx-react-lite' -import Editor from '@monaco-editor/react' -import { - Checkbox, - FormControlLabel, - Typography, - Snackbar, - makeStyles, - Button, -} from '@material-ui/core' -import Box from '@mui/material/Box' - -import { Modal } from '@postgres.ai/shared/components/Modal' -import { StubSpinner } from '@postgres.ai/shared/components/StubSpinner' -import { ExternalIcon } from '@postgres.ai/shared/icons/External' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { useStores } from '@postgres.ai/shared/pages/Instance/context' -import { MainStore } from '@postgres.ai/shared/pages/Instance/stores/Main' - -import { tooltipText } from './tooltipText' -import { FormValues, useForm } from './useForm' -import { ResponseMessage } from './ResponseMessage' -import { ConfigSectionTitle, Header, ModalTitle } from './Header' -import { dockerImageOptions, imagePgOptions } from './configOptions' -import { - FormValuesKey, - uniqueChipValue, - customOrGenericImage, - genericDockerImages, -} from './utils' -import { - SelectWithTooltip, - InputWithChip, - InputWithTooltip, -} from './InputWithTooltip' - -import styles from './styles.module.scss' -import { SeImages } from '@postgres.ai/shared/types/api/endpoints/getSeImages' -import { - formatTuningParams, - formatTuningParamsToObj, -} from '@postgres.ai/shared/types/api/endpoints/testDbSource' - -type PgOptionsType = { - optionType: string - pgDumpOptions: string[] - pgRestoreOptions: string[] -} - -const NON_LOGICAL_RETRIEVAL_MESSAGE = - 'Configuration editing is only available in logical mode' -const PREVENT_MODIFYING_MESSAGE = 'Editing is disabled by admin' - -const useStyles = makeStyles( - { - checkboxRoot: { - padding: '9px 10px', - }, - grayText: { - color: '#8a8a8a', - fontSize: '12px', - }, - }, - { index: 1 }, -) - -export const Configuration = observer( - ({ - switchActiveTab, - reload, - isConfigurationActive, - disableConfigModification, - }: { - switchActiveTab: (_: null, activeTab: number) => void - reload: () => void - isConfigurationActive: boolean - disableConfigModification?: boolean - }) => { - const classes = useStyles() - const stores = useStores() - const { - config, - isConfigurationLoading, - updateConfig, - getSeImages, - fullConfig, - testDbSource, - configError, - getFullConfig, - getFullConfigError, - getEngine, - } = stores.main - - const configData: MainStore['config'] = - config && JSON.parse(JSON.stringify(config)) - const isConfigurationDisabled = - !isConfigurationActive || disableConfigModification - - const [dleEdition, setDledition] = useState('') - const isCeEdition = dleEdition === 'community' - const filteredDockerImageOptions = isCeEdition - ? dockerImageOptions.filter( - (option) => - option.type === 'custom' || option.type === 'Generic Postgres', - ) - : dockerImageOptions - - const [isModalOpen, setIsModalOpen] = useState(false) - const [submitState, setSubmitState] = useState({ - status: '', - response: '' as string | React.ReactNode, - }) - const [dockerState, setDockerState] = useState({ - loading: false, - error: '', - tags: [] as string[], - locations: [] as string[], - images: [] as string[], - preloadLibraries: '' as string | undefined, - data: [] as SeImages[], - }) - const [testConnectionState, setTestConnectionState] = useState({ - default: { - loading: false, - error: '', - message: { - status: '', - message: '', - }, - }, - dockerImage: { - loading: false, - error: '', - message: { - status: '', - message: '', - }, - }, - fetchTuning: { - loading: false, - error: '', - message: { - status: '', - message: '', - }, - }, - }) - - const switchTab = async () => { - reload() - switchActiveTab(null, 0) - } - - const onSubmit = async (values: FormValues) => { - setSubmitState({ - ...submitState, - response: '', - }) - await updateConfig({ - ...values, - tuningParams: formatTuningParamsToObj( - values.tuningParams, - ) as unknown as string, - }).then((response) => { - if (response?.ok) { - setSubmitState({ - status: 'success', - response: ( -

- Changes applied.{' '} - - Switch to Overview - {' '} - to see details and to work with clones -

- ), - }) - } - }) - } - const [{ formik, connectionData, isConnectionDataValid }] = - useForm(onSubmit) - - const scrollToField = () => { - const errorElement = document.querySelector('.Mui-error') - if (errorElement) { - errorElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) - const inputElement = errorElement.querySelector('input') - if (inputElement) { - setTimeout(() => { - inputElement.focus() - }, 1000) - } - } - } - - const onTestConnectionClick = async ({ - type, - }: { - type: 'default' | 'dockerImage' | 'fetchTuning' - }) => { - Object.keys(connectionData).map(function (key: string) { - if (key !== 'password' && key !== 'db_list') { - formik.validateField(key).then(() => { - scrollToField() - }) - } - }) - if (isConnectionDataValid) { - setTestConnectionState({ - ...testConnectionState, - [type]: { - ...testConnectionState[type as keyof typeof testConnectionState], - loading: true, - error: '', - message: { - status: '', - message: '', - }, - }, - }) - testDbSource(connectionData) - .then((res) => { - if (res?.response) { - setTestConnectionState({ - ...testConnectionState, - [type]: { - ...testConnectionState[ - type as keyof typeof testConnectionState - ], - message: { - status: res.response.status, - message: res.response.message, - }, - }, - }) - - if (type === 'fetchTuning') { - formik.setFieldValue( - 'tuningParams', - formatTuningParams(res.response.tuningParams), - ) - } - - if (type === 'dockerImage' && res.response?.dbVersion) { - const currentDockerImage = dockerState.data.find( - (image) => - Number(image.pg_major_version) === res.response?.dbVersion, - ) - - if (currentDockerImage) { - formik.setValues({ - ...formik.values, - dockerImage: currentDockerImage.pg_major_version, - dockerPath: currentDockerImage.location, - dockerTag: currentDockerImage.tag, - }) - - setDockerState({ - ...dockerState, - tags: dockerState.data - .map((image) => image.tag) - .filter((tag) => - tag.startsWith(currentDockerImage.pg_major_version), - ), - }) - } - } - } else if (res?.error) { - setTestConnectionState({ - ...testConnectionState, - [type]: { - ...testConnectionState[ - type as keyof typeof testConnectionState - ], - message: { - status: 'error', - message: res.error.message, - }, - }, - }) - } - }) - .catch((err) => { - setTestConnectionState({ - ...testConnectionState, - [type]: { - ...testConnectionState[ - type as keyof typeof testConnectionState - ], - error: err.message, - loading: false, - }, - }) - }) - } - } - - const handleModalClick = async () => { - await getFullConfig() - setIsModalOpen(true) - } - - const handleDeleteChip = ( - _: React.FormEvent, - uniqueValue: string, - id: string, - ) => { - if (formik.values[id as FormValuesKey]) { - let newValues = '' - const currentValues = uniqueChipValue( - String(formik.values[id as FormValuesKey]), - ) - const splitValues = currentValues.split(' ') - const curDividers = String(formik.values[id as FormValuesKey]).match( - /[,(\s)(\n)(\r)(\t)(\r\n)]/gm, - ) - for (let i in splitValues) { - if (curDividers && splitValues[i] !== uniqueValue) { - newValues = - newValues + - splitValues[i] + - (curDividers[i] ? curDividers[i] : '') - } - } - formik.setFieldValue(id, newValues) - } - } - - const handleSelectPgOptions = ( - e: React.ChangeEvent, - formikName: string, - ) => { - let pgValue = formik.values[formikName as FormValuesKey] - formik.setFieldValue( - formikName, - configData && configData[formikName as FormValuesKey], - ) - const selectedPgOptions = imagePgOptions.filter( - (pg) => e.target.value === pg.optionType, - ) - - const setFormikPgValue = (name: string) => { - if (selectedPgOptions.length === 0) { - formik.setFieldValue(formikName, '') - } - - selectedPgOptions.forEach((pg: PgOptionsType) => { - return (pg[name as keyof PgOptionsType] as string[]).forEach( - (addOption) => { - if (!String(pgValue)?.includes(addOption)) { - const addOptionWithSpace = addOption + ' ' - formik.setFieldValue( - formikName, - (pgValue += addOptionWithSpace), - ) - } - }, - ) - }) - } - - if (formikName === 'pgRestoreCustomOptions') { - setFormikPgValue('pgRestoreOptions') - } else { - setFormikPgValue('pgDumpOptions') - } - } - - const fetchSeImages = async ({ - dockerTag, - packageGroup, - initialRender, - }: { - dockerTag?: string - packageGroup: string - initialRender?: boolean - }) => { - setDockerState({ - ...dockerState, - loading: true, - }) - await getSeImages({ - packageGroup, - }).then((data) => { - if (data) { - const seImagesMajorVersions = data - .map((image) => image.pg_major_version) - .filter((value, index, self) => self.indexOf(value) === index) - .sort((a, b) => Number(a) - Number(b)) - const currentDockerImage = initialRender - ? formik.values.dockerImage - : seImagesMajorVersions.slice(-1)[0] - - const currentPreloadLibraries = - data.find((image) => image.tag === dockerTag)?.pg_config_presets - ?.shared_preload_libraries || - data[0]?.pg_config_presets?.shared_preload_libraries - - setDockerState({ - ...(initialRender - ? { images: seImagesMajorVersions } - : { - ...dockerState, - }), - error: '', - tags: data - .map((image) => image.tag) - .filter((tag) => tag.startsWith(currentDockerImage)), - locations: data - .map((image) => image.location) - .filter((location) => location?.includes(currentDockerImage)), - loading: false, - preloadLibraries: currentPreloadLibraries, - images: seImagesMajorVersions, - data, - }) - - formik.setValues({ - ...formik.values, - dockerImage: currentDockerImage, - dockerImageType: packageGroup, - dockerTag: dockerTag - ? dockerTag - : data.map((image) => image.tag)[0], - dockerPath: initialRender - ? formik.values.dockerPath - : data.map((image) => image.location)[0], - sharedPreloadLibraries: currentPreloadLibraries || '', - }) - } else { - setDockerState({ - ...dockerState, - loading: false, - }) - } - }) - } - - const handleDockerImageSelect = ( - e: React.ChangeEvent, - ) => { - if (e.target.value === 'Generic Postgres') { - const genericImageVersions = genericDockerImages - .map((image) => image.pg_major_version) - .filter((value, index, self) => self.indexOf(value) === index) - .sort((a, b) => Number(a) - Number(b)) - const currentDockerImage = genericImageVersions.slice(-1)[0] - - setDockerState({ - ...dockerState, - tags: genericDockerImages - .map((image) => image.tag) - .filter((tag) => tag.startsWith(currentDockerImage)), - locations: genericDockerImages - .map((image) => image.location) - .filter((location) => location?.includes(currentDockerImage)), - images: genericImageVersions, - data: genericDockerImages, - }) - - formik.setValues({ - ...formik.values, - dockerImage: currentDockerImage, - dockerImageType: e.target.value, - dockerTag: genericDockerImages.map((image) => image.tag)[0], - dockerPath: genericDockerImages.map((image) => image.location)[0], - sharedPreloadLibraries: - 'pg_stat_statements,pg_stat_kcache,pg_cron,pgaudit,anon', - }) - } else if (e.target.value === 'custom') { - formik.setValues({ - ...formik.values, - dockerImage: '', - dockerPath: '', - dockerTag: '', - sharedPreloadLibraries: '', - dockerImageType: e.target.value, - }) - } else { - formik.setValues({ - ...formik.values, - dockerImageType: e.target.value, - }) - fetchSeImages({ - packageGroup: e.target.value, - }) - } - - handleSelectPgOptions(e, 'pgDumpCustomOptions') - handleSelectPgOptions(e, 'pgRestoreCustomOptions') - } - - const handleDockerVersionSelect = ( - e: React.ChangeEvent, - ) => { - if (formik.values.dockerImageType !== 'custom') { - const updatedDockerTags = dockerState.data - .map((image) => image.tag) - .filter((tag) => tag.startsWith(e.target.value)) - - setDockerState({ - ...dockerState, - tags: updatedDockerTags, - }) - - const currentLocation = dockerState.data.find( - (image) => image.tag === updatedDockerTags[0], - )?.location as string - - formik.setValues({ - ...formik.values, - dockerTag: updatedDockerTags[0], - dockerImage: e.target.value, - dockerPath: currentLocation, - }) - } else { - formik.setValues({ - ...formik.values, - dockerImage: e.target.value, - dockerPath: e.target.value, - }) - } - } - - // Set initial data, empty string for password - useEffect(() => { - if (configData) { - for (const [key, value] of Object.entries(configData)) { - if (key !== 'password') { - formik.setFieldValue(key, value) - } - - if (key === 'tuningParams') { - formik.setFieldValue(key, value) - } - - if (customOrGenericImage(configData?.dockerImageType)) { - if (configData?.dockerImageType === 'Generic Postgres') { - const genericImageVersions = genericDockerImages - .map((image) => image.pg_major_version) - .filter((value, index, self) => self.indexOf(value) === index) - .sort((a, b) => Number(a) - Number(b)) - const currentDockerImage = - genericDockerImages.filter( - (image) => image.location === configData?.dockerPath, - )[0] || - genericDockerImages.filter((image) => - configData?.dockerPath?.includes(image.pg_major_version), - )[0] - - setDockerState({ - ...dockerState, - tags: genericDockerImages - .map((image) => image.tag) - .filter((tag) => - tag.startsWith(currentDockerImage.pg_major_version), - ), - images: genericImageVersions, - data: genericDockerImages, - }) - - formik.setFieldValue('dockerTag', currentDockerImage?.tag) - formik.setFieldValue( - 'dockerImage', - currentDockerImage.pg_major_version, - ) - } else { - formik.setFieldValue('dockerImage', configData?.dockerPath) - } - } - } - } - }, [config]) - - useEffect(() => { - getEngine().then((res) => { - setDledition(String(res?.edition)) - }) - }, []) - - useEffect(() => { - const initialFetch = async () => { - if ( - formik.dirty && - !isCeEdition && - !customOrGenericImage(configData?.dockerImageType) - ) { - await getFullConfig().then(async (data) => { - if (data) { - await fetchSeImages({ - packageGroup: configData?.dockerImageType as string, - dockerTag: configData?.dockerTag, - initialRender: true, - }) - } - }) - } - } - initialFetch() - }, [ - formik.dirty, - configData?.dockerImageType, - configData?.dockerTag, - isCeEdition, - ]) - - return ( -
- { - Boolean(dockerState.error) - ? setDockerState({ - ...dockerState, - error: '', - }) - : undefined - }} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - open={ - (isConfigurationDisabled || Boolean(dockerState.error)) && - !isModalOpen - } - message={ - Boolean(dockerState.error) - ? dockerState.error - : disableConfigModification - ? PREVENT_MODIFYING_MESSAGE - : NON_LOGICAL_RETRIEVAL_MESSAGE - } - className={styles.snackbar} - /> - {!config && isConfigurationLoading ? ( -
- -
- ) : ( - -
- - - - formik.setFieldValue('debug', e.target.checked) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Debug mode'} - /> - - - - - - Subsection "retrieval.spec.logicalDump" - - - Source database credentials and dumping options. - - - formik.setFieldValue('host', e.target.value) - } - /> - - formik.setFieldValue('port', e.target.value) - } - /> - - formik.setFieldValue('username', e.target.value) - } - /> - - formik.setFieldValue('password', e.target.value) - } - /> - - formik.setFieldValue('dbname', e.target.value) - } - /> - - formik.setFieldValue('databases', e.target.value) - } - /> - - - {testConnectionState.default.message.status || - testConnectionState.default.error ? ( - - ) : null} - - - formik.setFieldValue('dumpParallelJobs', e.target.value) - } - /> - - formik.setFieldValue( - 'pgDumpCustomOptions', - e.target.value, - ) - } - /> - - formik.setFieldValue( - 'dumpIgnoreErrors', - e.target.checked, - ) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Ignore errors during logical data dump'} - /> - - - - - - DBLab manages various database containers, such as clones. - This section defines default container settings. - -
- { - return { - value: image.type, - children: image.name, - } - })} - onChange={handleDockerImageSelect} - /> - {formik.values.dockerImageType === 'custom' ? ( - { - formik.setValues({ - ...formik.values, - dockerImage: e.target.value, - dockerPath: e.target.value, - }) - }} - /> - ) : ( - <> - { - return { - value: image, - children: image, - } - })} - onChange={handleDockerVersionSelect} - /> - - - {testConnectionState.dockerImage.message.status === - 'error' || testConnectionState.dockerImage.error ? ( - - ) : null} - - { - const currentLocation = dockerState.data.find( - (image) => image.tag === e.target.value, - )?.location as string - - formik.setValues({ - ...formik.values, - dockerTag: e.target.value, - dockerPath: currentLocation, - }) - }} - items={dockerState.tags.map((image) => { - return { - value: image, - children: image, - } - })} - /> - - )} - - Cannot find your image? Reach out to support:{' '} - - https://postgres.ai/contact - - - -
-
- - - - Default Postgres configuration used for all Postgres instances - running in containers managed by DBLab. - - - formik.setFieldValue('sharedBuffers', e.target.value) - } - /> - - formik.setFieldValue( - 'sharedPreloadLibraries', - e.target.value, - ) - } - /> - , - ) - .map(([key, value]) => `${key}=${value}`) - .join('\n') - : formik.values.tuningParams - } - tooltipText={tooltipText.tuningParams} - disabled={isConfigurationDisabled} - onChange={(e) => - formik.setFieldValue('tuningParams', e.target.value) - } - /> - - {testConnectionState.fetchTuning.message.status === 'error' || - testConnectionState.fetchTuning.error ? ( - - ) : null} - - - - - Subsection "retrieval.spec.logicalRestore" - - Restoring options. - - - formik.setFieldValue('restoreParallelJobs', e.target.value) - } - /> - - formik.setFieldValue( - 'pgRestoreCustomOptions', - e.target.value, - ) - } - /> - - formik.setFieldValue( - 'restoreIgnoreErrors', - e.target.checked, - ) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Ignore errors during logical data restore'} - /> - - - - Subsection "retrieval.refresh" - - - - Define full data refresh on schedule. The process requires at - least one additional filesystem mount point. The schedule is to - be specified using{' '} - - crontab format - - - . - - - formik.setFieldValue('timetable', e.target.value) - } - /> -
- - - - - - - {(submitState.status && submitState.response) || configError ? ( - - ) : null} - - )} - } - onClose={() => setIsModalOpen(false)} - isOpen={isModalOpen} - size="xl" - > - } - theme="vs-light" - options={{ domReadOnly: true, readOnly: true }} - /> - -
- ) - }, -) diff --git a/ui/packages/shared/pages/CreateClone/index.tsx b/ui/packages/shared/pages/CreateClone/index.tsx index f7e131de..849476fe 100644 --- a/ui/packages/shared/pages/CreateClone/index.tsx +++ b/ui/packages/shared/pages/CreateClone/index.tsx @@ -1,3 +1,4 @@ +import cn from 'classnames' import { useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import { observer } from 'mobx-react-lite' @@ -135,12 +136,6 @@ export const CreateClone = observer((props: Props) => {
- {stores.main.cloneError && ( -
- -
- )} -
{branchesList && branchesList.length > 0 && (
- - - - Data state time - -
- Created - -
-
- Pool - Number of clones - Logical Size - Physical Size - Comment -
-
- - {filteredSnapshots.map((snapshot) => { - const snapshotPageId = generateSnapshotPageId(snapshot.id) - return ( - - snapshotPageId && - history.push(`/instance/snapshots/${snapshotPageId}`) - } - className={classes.pointerCursor} - > - copy(snapshot.id), - }, - { - name: 'Show related clones', - onClick: () => - stores.clonesModal.openModal({ - snapshotId: snapshot.id, - }), - }, - ]} - /> - - {snapshot.dataStateAt} - {isValidDate(snapshot.dataStateAtDate) - ? formatDistanceToNowStrict(snapshot.dataStateAtDate, { - addSuffix: true, - }) - : '-'} - - - {snapshot.createdAt} ( - {isValidDate(snapshot.createdAtDate) - ? formatDistanceToNowStrict(snapshot.createdAtDate, { - addSuffix: true, - }) - : '-'} - ) - - {snapshot.pool ?? '-'} - {snapshot.numClones ?? '-'} - - {snapshot.physicalSize - ? formatBytesIEC(snapshot.logicalSize) - : '-'} - - - {snapshot.physicalSize - ? formatBytesIEC(snapshot.physicalSize) - : '-'} - - {snapshot.comment ?? '-'} - - ) - })} - -
-
- ) -}) + import React from 'react' + import cn from 'classnames' + import { observer } from 'mobx-react-lite' + import { makeStyles } from '@material-ui/core' + import { formatDistanceToNowStrict } from 'date-fns' + import copy from 'copy-to-clipboard' + import { useHistory } from 'react-router-dom' + + import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' + import { generateSnapshotPageId } from '@postgres.ai/shared/pages/Instance/Snapshots/utils' + import { DestroySnapshotModal } from '@postgres.ai/shared/pages/Snapshots/Snapshot/DestorySnapshotModal' + import { useStores } from '@postgres.ai/shared/pages/Instance/context' + import { ArrowDropDownIcon } from '@postgres.ai/shared/icons/ArrowDropDown' + import { formatBytesIEC } from '@postgres.ai/shared/utils/units' + import { isSameDayUTC, isValidDate } from '@postgres.ai/shared/utils/date' + import { + Table, + TableHead, + TableRow, + TableBody, + TableHeaderCell, + TableBodyCell, + TableBodyCellMenu, + } from '@postgres.ai/shared/components/Table' + + const useStyles = makeStyles( + { + cellContentCentered: { + display: 'flex', + alignItems: 'center', + }, + pointerCursor: { + cursor: 'pointer', + }, + sortIcon: { + marginLeft: '8px', + width: '10px', + cursor: 'pointer', + transition: 'transform 0.15s ease-in-out', + }, + + sortIconUp: { + transform: 'rotate(180deg)', + }, + + hideSortIcon: { + opacity: 0, + }, + + verticalCentered: { + display: 'flex', + alignItems: 'center', + }, + }, + { index: 1 }, + ) + + export const SnapshotsTable = observer(() => { + const history = useHistory() + const classes = useStyles() + const stores = useStores() + const { snapshots } = stores.main + + const [snapshotModal, setSnapshotModal] = React.useState({ + isOpen: false, + snapshotId: '', + }) + + const filteredSnapshots = snapshots?.data?.filter((snapshot) => { + const isMatchedByDate = + !stores.snapshotsModal.date || + isSameDayUTC(snapshot.dataStateAtDate, stores.snapshotsModal.date) + + const isMatchedByPool = + !stores.snapshotsModal.pool || + snapshot.pool === stores.snapshotsModal.pool + + return isMatchedByDate && isMatchedByPool + }) + + const [state, setState] = React.useState({ + sortByCreatedDate: 'desc', + snapshots: filteredSnapshots ?? [], + }) + + const handleSortByCreatedDate = () => { + const sortByCreatedDate = + state.sortByCreatedDate === 'desc' ? 'asc' : 'desc' + + const sortedSnapshots = [...state.snapshots].sort((a, b) => { + if (sortByCreatedDate === 'asc') { + return ( + new Date(a.createdAtDate).getTime() - + new Date(b.createdAtDate).getTime() + ) + } else { + return ( + new Date(b.createdAtDate).getTime() - + new Date(a.createdAtDate).getTime() + ) + } + }) + + setState({ + ...state, + sortByCreatedDate, + snapshots: sortedSnapshots, + }) + } + + if (!snapshots.data) return null + + return ( + + + + + + Data state time + +
+ Created + +
+
+ Pool + Number of clones + Logical Size + Physical Size +
+
+ + {state.snapshots?.map((snapshot) => { + const snapshotPageId = generateSnapshotPageId(snapshot.id) + return ( + + snapshotPageId && + history.push(`/instance/snapshots/${snapshotPageId}`) + } + className={classes.pointerCursor} + > + copy(snapshot.id), + }, + { + name: 'Show related clones', + onClick: () => + stores.clonesModal.openModal({ + snapshotId: snapshot.id, + }), + }, + { + name: 'Delete snapshot', + onClick: () => + setSnapshotModal({ + isOpen: true, + snapshotId: snapshot.id, + }), + }, + ]} + /> + + {snapshot.dataStateAt} ( + {isValidDate(snapshot.dataStateAtDate) + ? formatDistanceToNowStrict(snapshot.dataStateAtDate, { + addSuffix: true, + }) + : '-'} + ) + + + {snapshot.createdAt} ( + {isValidDate(snapshot.createdAtDate) + ? formatDistanceToNowStrict(snapshot.createdAtDate, { + addSuffix: true, + }) + : '-'} + ) + + {snapshot.pool ?? '-'} + {snapshot.numClones ?? '-'} + + {snapshot.logicalSize + ? formatBytesIEC(snapshot.logicalSize) + : '-'} + + + {snapshot.physicalSize + ? formatBytesIEC(snapshot.physicalSize) + : '-'} + + + ) + })} + + {snapshotModal.isOpen && snapshotModal.snapshotId && ( + setSnapshotModal({ isOpen: false, snapshotId: '' })} + snapshotId={snapshotModal.snapshotId} + afterSubmitClick={() => + stores.main?.reload(stores.main.instance?.id ?? '') + } + /> + )} +
+
+ ) + }) + \ No newline at end of file diff --git a/ui/packages/shared/pages/Instance/Snapshots/index.tsx b/ui/packages/shared/pages/Instance/Snapshots/index.tsx index 90911958..b6c2016e 100644 --- a/ui/packages/shared/pages/Instance/Snapshots/index.tsx +++ b/ui/packages/shared/pages/Instance/Snapshots/index.tsx @@ -98,7 +98,9 @@ export const Snapshots = observer(() => { {!hasClones && ( - +
+ +
)} diff --git a/ui/packages/shared/pages/Instance/stores/Main.ts b/ui/packages/shared/pages/Instance/stores/Main.ts index 94804c4d..3f710448 100644 --- a/ui/packages/shared/pages/Instance/stores/Main.ts +++ b/ui/packages/shared/pages/Instance/stores/Main.ts @@ -66,6 +66,7 @@ export class MainStore { fullConfig?: string dleEdition?: string platformUrl?: string + uiVersion?: string instanceError: Error | null = null configError: string | null = null getFullConfigError: string | null = null @@ -248,6 +249,7 @@ export class MainStore { const splitYML = this.fullConfig.split('---') this.platformUrl = splitYML[0]?.split('url: ')[1]?.split('\n')[0] + this.uiVersion = splitYML[0]?.split('dockerImage: "postgresai/ce-ui:')[2]?.split('\n')[0]?.replace(/['"]+/g, '') } if (error) diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx b/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx index b07cb251..3a259e40 100644 --- a/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx +++ b/ui/packages/shared/pages/Snapshots/Snapshot/DestorySnapshotModal/index.tsx @@ -5,20 +5,21 @@ *-------------------------------------------------------------------------- */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { makeStyles } from '@material-ui/core' import { Modal } from '@postgres.ai/shared/components/Modal' import { ImportantText } from '@postgres.ai/shared/components/ImportantText' import { Text } from '@postgres.ai/shared/components/Text' +import { destroySnapshot as destroySnapshotAPI } from '@postgres.ai/ce/src/api/snapshots/destroySnapshot' import { SimpleModalControls } from '@postgres.ai/shared/components/SimpleModalControls' +import { useCreatedStores } from '../useCreatedStores' type Props = { snapshotId: string isOpen: boolean onClose: () => void - onDestroySnapshot: () => void - destroySnapshotError: { title?: string; message: string } | null + afterSubmitClick: () => void } const useStyles = makeStyles( @@ -35,24 +36,30 @@ export const DestroySnapshotModal = ({ snapshotId, isOpen, onClose, - onDestroySnapshot, - destroySnapshotError, + afterSubmitClick, }: Props) => { const classes = useStyles() - const [deleteError, setDeleteError] = useState(destroySnapshotError?.message) - - const handleClickDestroy = () => { - onDestroySnapshot() - } + const props = { api: { destroySnapshot: destroySnapshotAPI } } + const stores = useCreatedStores(props.api) + const { destroySnapshot } = stores.main + const [deleteError, setDeleteError] = useState(null) const handleClose = () => { - setDeleteError('') + setDeleteError(null) onClose() } - useEffect(() => { - setDeleteError(destroySnapshotError?.message) - }, [destroySnapshotError]) + const handleClickDestroy = () => { + destroySnapshot(snapshotId).then((res) => { + if (res?.error?.message) { + setDeleteError(res.error.message) + } else { + afterSubmitClick() + handleClose() + } + }) + } + return ( diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx b/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx index 36ec6c23..75ba17ae 100644 --- a/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx +++ b/ui/packages/shared/pages/Snapshots/Snapshot/index.tsx @@ -136,7 +136,7 @@ const useStyles = makeStyles( export const SnapshotPage = observer((props: Props) => { const classes = useStyles() const history = useHistory() - const stores = useCreatedStores(props) + const stores = useCreatedStores(props.api) const [isOpenDestroyModal, setIsOpenDestroyModal] = useState(false) @@ -146,13 +146,11 @@ export const SnapshotPage = observer((props: Props) => { isSnapshotsLoading, snapshotError, branchSnapshotError, - destroySnapshotError, load, } = stores.main - const destroySnapshot = async () => { - const isSuccess = await stores.main.destroySnapshot(String(snapshot?.id)) - if (isSuccess) history.push(props.routes.snapshot()) + const redirectToSnapshot = () => { + history.push(props.routes.snapshot()) } const BranchHeader = () => { @@ -376,15 +374,14 @@ export const SnapshotPage = observer((props: Props) => { text={'Delete snapshot using CLI'} />

- You can delete this snapshot using CLI. To do this, run the - command below: + You can delete this snapshot using CLI. To do this, run the command + below:

- { text={'Get snapshots using CLI'} />

- You can get a list of all snapshots using CLI. To do this, run - the command below: + You can get a list of all snapshots using CLI. To do this, run the + command below:

@@ -401,8 +398,7 @@ export const SnapshotPage = observer((props: Props) => { isOpen={isOpenDestroyModal} onClose={() => setIsOpenDestroyModal(false)} snapshotId={props.snapshotId} - onDestroySnapshot={destroySnapshot} - destroySnapshotError={destroySnapshotError} + afterSubmitClick={redirectToSnapshot} />
diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts b/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts index 7e919526..9b918524 100644 --- a/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts +++ b/ui/packages/shared/pages/Snapshots/Snapshot/stores/Main.ts @@ -24,7 +24,7 @@ type Error = { export type Api = SnapshotsApi & { destroySnapshot: DestroySnapshot - getBranchSnapshot: GetBranchSnapshot + getBranchSnapshot?: GetBranchSnapshot } export class MainStore { @@ -33,7 +33,6 @@ export class MainStore { snapshotError: Error | null = null branchSnapshotError: Error | null = null - destroySnapshotError: Error | null = null isSnapshotsLoading = false @@ -78,7 +77,7 @@ export class MainStore { } getBranchSnapshot = async (snapshotId: string) => { - if (!snapshotId) return + if (!snapshotId || !this.api.getBranchSnapshot) return const { response, error } = await this.api.getBranchSnapshot(snapshotId) @@ -100,10 +99,9 @@ export class MainStore { const { response, error } = await this.api.destroySnapshot(snapshotId) - if (error) { - this.destroySnapshotError = await error.json().then((err) => err) + return { + response, + error: error ? await error.json().then((err) => err) : null, } - - return response } } diff --git a/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts b/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts index 7164757e..331b6cb0 100644 --- a/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts +++ b/ui/packages/shared/pages/Snapshots/Snapshot/useCreatedStores.ts @@ -3,8 +3,8 @@ import { useMemo } from 'react' import { MainStore } from './stores/Main' import { Host } from './context' -export const useCreatedStores = (host: Host) => ({ - main: useMemo(() => new MainStore(host.api), []), +export const useCreatedStores = (api: Host["api"]) => ({ + main: useMemo(() => new MainStore(api), []), }) export type Stores = ReturnType diff --git a/ui/packages/shared/stores/Snapshots.ts b/ui/packages/shared/stores/Snapshots.ts index a20a1f6c..bb0e4fe3 100644 --- a/ui/packages/shared/stores/Snapshots.ts +++ b/ui/packages/shared/stores/Snapshots.ts @@ -19,8 +19,12 @@ export class SnapshotsStore { data: Snapshot[] | null = null error: string | null = null isLoading = false + snapshotDataLoading = false snapshotData: boolean | null = null - snapshotDataError: Error | null = null + snapshotDataError: { + title?: string + message?: string + } | null = null private readonly api: SnapshotsApi @@ -40,11 +44,13 @@ export class SnapshotsStore { createSnapshot = async (cloneId: string) => { if (!this.api.createSnapshot || !cloneId) return - + this.snapshotDataLoading = true this.snapshotDataError = null const { response, error } = await this.api.createSnapshot(cloneId) + this.snapshotDataLoading = false + if (response) { this.snapshotData = !!response this.reload('') diff --git a/ui/packages/shared/types/api/entities/clone.ts b/ui/packages/shared/types/api/entities/clone.ts index 7ac24393..bf0dc169 100644 --- a/ui/packages/shared/types/api/entities/clone.ts +++ b/ui/packages/shared/types/api/entities/clone.ts @@ -14,6 +14,7 @@ import { export type CloneDto = { createdAt: string id: string + branch: string status: { code: 'OK' | 'CREATING' | 'DELETING' | 'RESETTING' | 'FATAL' message: string diff --git a/ui/packages/shared/types/api/entities/snapshot.ts b/ui/packages/shared/types/api/entities/snapshot.ts index d6aac3f4..97c8e41e 100644 --- a/ui/packages/shared/types/api/entities/snapshot.ts +++ b/ui/packages/shared/types/api/entities/snapshot.ts @@ -9,6 +9,7 @@ export type SnapshotDto = { physicalSize: number logicalSize: number comment?: string + message?: string } export const formatSnapshotDto = (dto: SnapshotDto) => ({ From cc931a839df81ee98c7b2afc827da3eda9f2e4f4 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Wed, 24 Apr 2024 05:57:14 +0000 Subject: [PATCH 054/114] fix: get the relevant FSManager for the requested snapshot or branch --- .../embedded_ui_integration_test.go | 2 +- engine/internal/srv/branch.go | 73 +++++++++++++++---- engine/internal/srv/routes.go | 8 +- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/engine/internal/embeddedui/embedded_ui_integration_test.go b/engine/internal/embeddedui/embedded_ui_integration_test.go index 2df49cb4..f11a24d1 100644 --- a/engine/internal/embeddedui/embedded_ui_integration_test.go +++ b/engine/internal/embeddedui/embedded_ui_integration_test.go @@ -35,7 +35,7 @@ func TestStartExistingContainer(t *testing.T) { embeddedUI := New( Config{ // "mock" UI image - DockerImage: "gcr.io/google_containers/pause-amd64:3.0", + DockerImage: "alpine:3.19", }, engProps, runners.NewLocalRunner(false), diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 9a79a627..5f95f6fe 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -94,6 +94,22 @@ func containsString(slice []string, s string) bool { return false } +//nolint:unused +func (s *Server) getFSManagerForBranch(branchName string) (pool.FSManager, error) { + allBranches, err := s.pm.First().ListAllBranches() + if err != nil { + return nil, fmt.Errorf("failed to get branch list: %w", err) + } + + for _, branchEntity := range allBranches { + if branchEntity.Name == branchName { + return s.getFSManagerForSnapshot(branchEntity.SnapshotID) + } + } + + return nil, fmt.Errorf("failed to found dataset of the branch: %s", branchName) +} + func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { var createRequest types.BranchCreateRequest if err := api.ReadJSON(r, &createRequest); err != nil { @@ -111,10 +127,20 @@ func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { return } + var err error + fsm := s.pm.First() + if createRequest.BaseBranch != "" { + fsm, err = s.getFSManagerForBranch(createRequest.BaseBranch) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + } + if fsm == nil { - api.SendBadRequestError(w, r, "no available pools") + api.SendBadRequestError(w, r, "no pool manager found") return } @@ -197,10 +223,9 @@ func (s *Server) getCommit(w http.ResponseWriter, r *http.Request) { return } - fsm := s.pm.First() - - if fsm == nil { - api.SendBadRequestError(w, r, "no available pools") + fsm, err := s.getFSManagerForSnapshot(snapshotID) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) return } @@ -223,6 +248,20 @@ func (s *Server) getCommit(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) getFSManagerForSnapshot(snapshotID string) (pool.FSManager, error) { + poolName, err := s.detectPoolName(snapshotID) + if err != nil { + return nil, fmt.Errorf("failed to detect pool name for the snapshot %s: %w", snapshotID, err) + } + + fsm, err := s.pm.GetFSManager(poolName) + if err != nil { + return nil, fmt.Errorf("pool manager not available %s: %w", poolName, err) + } + + return fsm, nil +} + func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { var snapshotRequest types.SnapshotCloneCreateRequest if err := api.ReadJSON(r, &snapshotRequest); err != nil { @@ -325,19 +364,23 @@ func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { } func (s *Server) log(w http.ResponseWriter, r *http.Request) { - fsm := s.pm.First() - - if fsm == nil { - api.SendBadRequestError(w, r, "no available pools") + var logRequest types.LogRequest + if err := api.ReadJSON(r, &logRequest); err != nil { + api.SendBadRequestError(w, r, err.Error()) return } - var logRequest types.LogRequest - if err := api.ReadJSON(r, &logRequest); err != nil { + fsm, err := s.getFSManagerForBranch(logRequest.BranchName) + if err != nil { api.SendBadRequestError(w, r, err.Error()) return } + if fsm == nil { + api.SendBadRequestError(w, r, "no pool manager found") + return + } + repo, err := fsm.GetRepo() if err != nil { api.SendBadRequestError(w, r, err.Error()) @@ -377,10 +420,14 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } - fsm := s.pm.First() + fsm, err := s.getFSManagerForBranch(deleteRequest.BranchName) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } if fsm == nil { - api.SendBadRequestError(w, r, "no available pools") + api.SendBadRequestError(w, r, "no pool manager found") return } diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index e1bf9d01..bfbd1474 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -331,10 +331,14 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { } if cloneRequest.Branch != "" { - fsm := s.pm.First() + fsm, err := s.getFSManagerForBranch(cloneRequest.Branch) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } if fsm == nil { - api.SendBadRequestError(w, r, "no available pools") + api.SendBadRequestError(w, r, "no pool manager found") return } From e3c278d6308c4dc5dd78e4c9df08b3aa2de089da Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Fri, 12 Jul 2024 15:52:03 +0000 Subject: [PATCH 055/114] fix(ui): branch page polishes --- ui/packages/ce/src/App/Menu/Header/styles.module.scss | 1 + ui/packages/ce/src/components/NavPath/index.tsx | 3 ++- .../pages/Branches/components/BranchesTable/index.tsx | 11 +++++++++-- ui/packages/shared/pages/Branches/index.tsx | 2 +- ui/packages/shared/pages/CreateBranch/index.tsx | 2 +- ui/packages/shared/pages/CreateBranch/utils/index.ts | 9 +++++++-- ui/packages/shared/pages/Instance/Clones/index.tsx | 2 +- .../shared/pages/Instance/Info/Status/index.tsx | 6 +++--- ui/packages/shared/pages/Instance/Snapshots/index.tsx | 2 +- 9 files changed, 26 insertions(+), 12 deletions(-) diff --git a/ui/packages/ce/src/App/Menu/Header/styles.module.scss b/ui/packages/ce/src/App/Menu/Header/styles.module.scss index f08de9c0..c60279aa 100644 --- a/ui/packages/ce/src/App/Menu/Header/styles.module.scss +++ b/ui/packages/ce/src/App/Menu/Header/styles.module.scss @@ -20,6 +20,7 @@ height: 32px; color: inherit; text-decoration: none; + align-items: center; &.collapsed { justify-content: center; diff --git a/ui/packages/ce/src/components/NavPath/index.tsx b/ui/packages/ce/src/components/NavPath/index.tsx index 1b69baaa..c999e62d 100644 --- a/ui/packages/ce/src/components/NavPath/index.tsx +++ b/ui/packages/ce/src/components/NavPath/index.tsx @@ -19,6 +19,7 @@ export const NavPath = (props: Props) => {

@@ -428,10 +428,10 @@ export const SnapshotPage = observer((props: Props) => { className={classes.marginTop} tag="h2" level={2} - text={'Destroy snapshot using CLI'} + text={'Delete snapshot using CLI'} />

- You can destroy this snapshot using CLI. To do this, run the command + You can delete this snapshot using CLI. To do this, run the command below:

@@ -453,7 +453,7 @@ export const SnapshotPage = observer((props: Props) => { onClose={() => setIsOpenDestroyModal(false)} snapshotId={snapshot.id} instanceId={props.instanceId} - afterSubmitClick={redirectToSnapshot} + afterSubmitClick={afterSubmitClick} destroySnapshot={stores.main.destroySnapshot as DestroySnapshot} /> )} From 8a4015904b8e5c75e011c72b26fd109c8c27b0f7 Mon Sep 17 00:00:00 2001 From: Lasha Kakabadze Date: Fri, 28 Mar 2025 13:49:45 +0400 Subject: [PATCH 081/114] fix(ui): Minor svg icon improvements --- ui/packages/shared/pages/Logs/index.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/ui/packages/shared/pages/Logs/index.tsx b/ui/packages/shared/pages/Logs/index.tsx index 9d3cedad..a41509cd 100644 --- a/ui/packages/shared/pages/Logs/index.tsx +++ b/ui/packages/shared/pages/Logs/index.tsx @@ -32,7 +32,6 @@ const useStyles = makeStyles( '& > span': { display: 'flex', flexDirection: 'row', - gap: '5px', alignItems: 'center', border: '1px solid #898E9A', padding: '3px 8px', @@ -46,9 +45,13 @@ const useStyles = makeStyles( background: 'none', outline: 'none', border: 0, - width: '18px', - height: '18px', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', cursor: 'pointer', + paddingBottom: 0, + paddingRight: 0, }, }, // we need important since id has higher priority than class @@ -82,6 +85,12 @@ const useStyles = makeStyles( transform: 'rotate(45deg) scale(0.75)', }, }, + buttonClassName: { + '& svg': { + width: '14px', + height: '14px', + }, + }, activeError: { border: '1px solid #F44336 !important', color: '#F44336 !important', @@ -193,7 +202,11 @@ export const Logs = ({ api, instanceId }: { api: Api; instanceId: string }) => { } > {type.toLowerCase()} - From 3eef87bc2d23dea7919aa0671ee60e97972ccf02 Mon Sep 17 00:00:00 2001 From: Bogdan Tsechoev Date: Mon, 31 Mar 2025 10:46:11 +0000 Subject: [PATCH 082/114] fix(ui): Fix snapshot delete issues (UI side) --- ui/packages/ce/src/App/Instance/Page/index.tsx | 2 ++ ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/ui/packages/ce/src/App/Instance/Page/index.tsx b/ui/packages/ce/src/App/Instance/Page/index.tsx index faff2845..344e5bd5 100644 --- a/ui/packages/ce/src/App/Instance/Page/index.tsx +++ b/ui/packages/ce/src/App/Instance/Page/index.tsx @@ -21,6 +21,7 @@ import { createBranch } from 'api/branches/createBranch' import { getBranches } from 'api/branches/getBranches' import { getSnapshotList } from 'api/branches/getSnapshotList' import { deleteBranch } from 'api/branches/deleteBranch' +import { destroySnapshot } from 'api/snapshots/destroySnapshot' export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { const routes = { @@ -56,6 +57,7 @@ export const Page = ({ renderCurrentTab }: { renderCurrentTab?: number }) => { getBranches, getSnapshotList, deleteBranch, + destroySnapshot } const elements = { diff --git a/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx b/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx index 120f1201..af600bf0 100644 --- a/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx +++ b/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx @@ -44,6 +44,7 @@ export const Snapshot = () => { instanceId={''} snapshotId={snapshotId} routes={{ + snapshots: () => ROUTES.INSTANCE.SNAPSHOTS.SNAPSHOTS.path, snapshot: () => ROUTES.INSTANCE.SNAPSHOTS.SNAPSHOTS.path, branch: (branchName: string) => ROUTES.INSTANCE.BRANCHES.BRANCH.createPath(branchName), From ab5d44a2f119c03c0405b1c49c2845bd53d74995 Mon Sep 17 00:00:00 2001 From: Artyom Kartasov Date: Mon, 31 Mar 2025 12:08:16 +0000 Subject: [PATCH 083/114] fix: prevent deletion of dataset pool and main branch (#605) --- engine/internal/cloning/base.go | 6 +-- engine/internal/provision/mode_local.go | 12 ----- engine/internal/provision/resources/pool.go | 5 ++ engine/internal/srv/branch.go | 5 ++ engine/internal/srv/routes.go | 56 ++++++++++++++++++++- engine/pkg/client/dblabapi/types/clone.go | 1 + 6 files changed, 67 insertions(+), 18 deletions(-) diff --git a/engine/internal/cloning/base.go b/engine/internal/cloning/base.go index 4db74672..e9e51318 100644 --- a/engine/internal/cloning/base.go +++ b/engine/internal/cloning/base.go @@ -30,7 +30,6 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/pkg/log" "gitlab.com/postgres-ai/database-lab/v3/pkg/models" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" - "gitlab.com/postgres-ai/database-lab/v3/pkg/util/branching" "gitlab.com/postgres-ai/database-lab/v3/pkg/util/pglog" ) @@ -190,10 +189,7 @@ func (c *Base) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clon Username: cloneRequest.DB.Username, DBName: cloneRequest.DB.DBName, }, - } - - if clone.Branch == "" { - clone.Branch = branching.DefaultBranch + Revision: cloneRequest.Revision, } w := NewCloneWrapper(clone, createdAt) diff --git a/engine/internal/provision/mode_local.go b/engine/internal/provision/mode_local.go index 487a0552..32ceeb63 100644 --- a/engine/internal/provision/mode_local.go +++ b/engine/internal/provision/mode_local.go @@ -229,18 +229,6 @@ func (p *Provisioner) StopSession(session *resources.Session, clone *models.Clon return errors.Wrap(err, "failed to stop container") } - if clone.Revision == branching.DefaultRevision { - // Destroy clone revision - if err := fsm.DestroyClone(clone.Branch, name, clone.Revision); err != nil { - return errors.Wrap(err, "failed to destroy clone") - } - - // Destroy clone dataset - if err := fsm.DestroyDataset(fsm.Pool().CloneDataset(clone.Branch, name)); err != nil { - return errors.Wrap(err, "failed to destroy clone dataset") - } - } - if err := p.FreePort(session.Port); err != nil { return errors.Wrap(err, "failed to unbind a port") } diff --git a/engine/internal/provision/resources/pool.go b/engine/internal/provision/resources/pool.go index ff33acce..fac79160 100644 --- a/engine/internal/provision/resources/pool.go +++ b/engine/internal/provision/resources/pool.go @@ -84,6 +84,11 @@ func (p *Pool) CloneLocation(branchName, name string, revision int) string { return path.Join(p.MountDir, p.PoolDirName, branching.BranchDir, branchName, name, branching.RevisionSegment(revision)) } +// CloneRevisionLocation returns a path to the clone revisions. +func (p *Pool) CloneRevisionLocation(branchName, name string) string { + return path.Join(p.MountDir, p.PoolDirName, branching.BranchDir, branchName, name) +} + // SocketCloneDir returns a path to the socket clone directory. func (p *Pool) SocketCloneDir(name string) string { return path.Join(p.SocketDir(), name) diff --git a/engine/internal/srv/branch.go b/engine/internal/srv/branch.go index 6edf602d..8a2e12de 100644 --- a/engine/internal/srv/branch.go +++ b/engine/internal/srv/branch.go @@ -523,6 +523,11 @@ func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { return } + if branchName == branching.DefaultBranch { + api.SendBadRequestError(w, r, fmt.Sprintf("cannot delete default branch: %s", branching.DefaultBranch)) + return + } + snapshotID, ok := repo.Branches[branchName] if !ok { api.SendBadRequestError(w, r, "branch not found: "+branchName) diff --git a/engine/internal/srv/routes.go b/engine/internal/srv/routes.go index 8998e8b8..762afaa0 100644 --- a/engine/internal/srv/routes.go +++ b/engine/internal/srv/routes.go @@ -229,6 +229,14 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { return } + // Prevent deletion of the last snapshot in the pool. + snapshotCnt := len(fsm.SnapshotList()) + + if fullDataset, _, found := strings.Cut(snapshotID, "@"); found && fullDataset == poolName && snapshotCnt == 1 { + api.SendBadRequestError(w, r, "cannot destroy the last snapshot in the pool") + return + } + // Check if snapshot exists. if _, err := fsm.GetSnapshotProperties(snapshotID); err != nil { if runnerError, ok := err.(runners.RunnerError); ok { @@ -322,7 +330,7 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) { if snapshotProperties.Clones == "" && snapshot.NumClones == 0 { // Destroy dataset if there are no related objects - if fullDataset, _, found := strings.Cut(snapshotID, "@"); found { + if fullDataset, _, found := strings.Cut(snapshotID, "@"); found && fullDataset != poolName { if err = fsm.DestroyDataset(fullDataset); err != nil { api.SendBadRequestError(w, r, err.Error()) return @@ -504,6 +512,19 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { } cloneRequest.Snapshot = &types.SnapshotCloneFieldRequest{ID: snapshotID} + } else { + cloneRequest.Branch = branching.DefaultBranch + } + + if cloneRequest.ID != "" { + fsm, err := s.getFSManagerForBranch(cloneRequest.Branch) + if err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + // Check if there is any clone revision under the dataset. + cloneRequest.Revision = findMaxCloneRevision(fsm.Pool().CloneRevisionLocation(cloneRequest.Branch, cloneRequest.ID)) } newClone, err := s.Cloning.CreateClone(cloneRequest) @@ -533,6 +554,39 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) { log.Dbg(fmt.Sprintf("Clone ID=%s is being created", newClone.ID)) } +func findMaxCloneRevision(path string) int { + files, err := os.ReadDir(path) + if err != nil { + log.Err(err) + return 0 + } + + maxIndex := -1 + + for _, file := range files { + if !file.IsDir() { + continue + } + + revisionIndex, ok := strings.CutPrefix(file.Name(), "r") + if !ok { + continue + } + + index, err := strconv.Atoi(revisionIndex) + if err != nil { + log.Err(err) + continue + } + + if index > maxIndex { + maxIndex = index + } + } + + return maxIndex + 1 +} + func (s *Server) destroyClone(w http.ResponseWriter, r *http.Request) { cloneID := mux.Vars(r)["id"] diff --git a/engine/pkg/client/dblabapi/types/clone.go b/engine/pkg/client/dblabapi/types/clone.go index 24778373..442d5e22 100644 --- a/engine/pkg/client/dblabapi/types/clone.go +++ b/engine/pkg/client/dblabapi/types/clone.go @@ -13,6 +13,7 @@ type CloneCreateRequest struct { Snapshot *SnapshotCloneFieldRequest `json:"snapshot"` ExtraConf map[string]string `json:"extra_conf"` Branch string `json:"branch"` + Revision int `json:"-"` } // CloneUpdateRequest represents params of an update request. From e4329498638cea5455a3ed41459582eb7b8efa16 Mon Sep 17 00:00:00 2001 From: Bogdan Tsechoev Date: Mon, 31 Mar 2025 19:04:31 +0000 Subject: [PATCH 084/114] feat (UI): Create clone button added to snapshots and branches pages, "data state at" changed to "Snapshot" on create clone page, platform EE UI bug fix --- .../ce/src/App/Instance/Branches/Branch/index.tsx | 1 + .../src/App/Instance/Snapshots/Snapshot/index.tsx | 1 + ui/packages/platform/src/api/clones/createClone.ts | 2 ++ ui/packages/platform/src/pages/Branch/index.tsx | 11 +++++++++++ .../platform/src/pages/CreateClone/index.tsx | 2 ++ ui/packages/platform/src/pages/Snapshot/index.tsx | 11 +++++++++++ .../shared/pages/Branches/Branch/context.ts | 1 + ui/packages/shared/pages/Branches/Branch/index.tsx | 10 ++++++++++ ui/packages/shared/pages/CreateClone/index.tsx | 14 +++++++------- .../shared/pages/CreateClone/styles.module.scss | 6 ++++++ .../Snapshots/components/SnapshotsList/index.tsx | 11 +++++++++-- .../shared/pages/Snapshots/Snapshot/context.ts | 1 + .../shared/pages/Snapshots/Snapshot/index.tsx | 9 +++++++++ 13 files changed, 71 insertions(+), 9 deletions(-) diff --git a/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx b/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx index 26c56200..718c81aa 100644 --- a/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx +++ b/ui/packages/ce/src/App/Instance/Branches/Branch/index.tsx @@ -49,6 +49,7 @@ export const Branch = () => { branches: () => ROUTES.INSTANCE.BRANCHES.BRANCHES.path, snapshot: (snapshotId: string) => ROUTES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath(snapshotId), + createClone: () => ROUTES.INSTANCE.CLONES.CREATE.path, }} /> diff --git a/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx b/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx index af600bf0..9cd21f51 100644 --- a/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx +++ b/ui/packages/ce/src/App/Instance/Snapshots/Snapshot/index.tsx @@ -50,6 +50,7 @@ export const Snapshot = () => { ROUTES.INSTANCE.BRANCHES.BRANCH.createPath(branchName), clone: (cloneId: string) => ROUTES.INSTANCE.CLONES.CLONE.createPath(cloneId), + createClone: () => ROUTES.INSTANCE.CLONES.CREATE.path, }} api={api} elements={elements} diff --git a/ui/packages/platform/src/api/clones/createClone.ts b/ui/packages/platform/src/api/clones/createClone.ts index 6fbc7666..50cf7157 100644 --- a/ui/packages/platform/src/api/clones/createClone.ts +++ b/ui/packages/platform/src/api/clones/createClone.ts @@ -9,6 +9,7 @@ type Req = { dbUser: string dbPassword: string isProtected: boolean + branch?: string } export const createClone = async (req: Req) => { @@ -19,6 +20,7 @@ export const createClone = async (req: Req) => { action: '/clone', method: 'post', data: { + branch: req.branch, id: req.cloneId, snapshot: { id: req.snapshotId, diff --git a/ui/packages/platform/src/pages/Branch/index.tsx b/ui/packages/platform/src/pages/Branch/index.tsx index 919a50bc..93e4673f 100644 --- a/ui/packages/platform/src/pages/Branch/index.tsx +++ b/ui/packages/platform/src/pages/Branch/index.tsx @@ -52,6 +52,17 @@ export const Branch = () => { instanceId: params.instanceId, snapshotId, }), + createClone: () => + params.project + ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.CLONES.ADD.createPath({ + org: params.org, + project: params.project, + instanceId: params.instanceId, + }) + : ROUTES.ORG.INSTANCES.INSTANCE.CLONES.ADD.createPath({ + org: params.org, + instanceId: params.instanceId, + }), } const api = { diff --git a/ui/packages/platform/src/pages/CreateClone/index.tsx b/ui/packages/platform/src/pages/CreateClone/index.tsx index e1641543..73fe958a 100644 --- a/ui/packages/platform/src/pages/CreateClone/index.tsx +++ b/ui/packages/platform/src/pages/CreateClone/index.tsx @@ -7,6 +7,7 @@ import { getInstance } from 'api/instances/getInstance' import { getSnapshots } from 'api/snapshots/getSnapshots' import { createClone } from 'api/clones/createClone' import { getClone } from 'api/clones/getClone' +import { getBranches } from 'api/branches/getBranches' import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' type Params = { @@ -38,6 +39,7 @@ export const CreateClone = () => { getInstance, createClone, getClone, + getBranches } const elements = { diff --git a/ui/packages/platform/src/pages/Snapshot/index.tsx b/ui/packages/platform/src/pages/Snapshot/index.tsx index 52853b38..631bf404 100644 --- a/ui/packages/platform/src/pages/Snapshot/index.tsx +++ b/ui/packages/platform/src/pages/Snapshot/index.tsx @@ -62,6 +62,17 @@ export const Snapshot = () => { cloneId: cloneId, instanceId: params.instanceId, }), + createClone: () => + params.project + ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.CLONES.ADD.createPath({ + org: params.org, + project: params.project, + instanceId: params.instanceId, + }) + : ROUTES.ORG.INSTANCES.INSTANCE.CLONES.ADD.createPath({ + org: params.org, + instanceId: params.instanceId, + }), } const api = { diff --git a/ui/packages/shared/pages/Branches/Branch/context.ts b/ui/packages/shared/pages/Branches/Branch/context.ts index 23a04366..7569e29f 100644 --- a/ui/packages/shared/pages/Branches/Branch/context.ts +++ b/ui/packages/shared/pages/Branches/Branch/context.ts @@ -10,6 +10,7 @@ export type Host = { branch: () => string branches: () => string snapshot: (snapshotId: string) => string + createClone: () => string } api: Api elements: { diff --git a/ui/packages/shared/pages/Branches/Branch/index.tsx b/ui/packages/shared/pages/Branches/Branch/index.tsx index 2ba7781b..62baef2c 100644 --- a/ui/packages/shared/pages/Branches/Branch/index.tsx +++ b/ui/packages/shared/pages/Branches/Branch/index.tsx @@ -214,6 +214,16 @@ export const BranchesPage = observer((props: Props) => {
+
-
- ) -} - -export default DbLabInstanceForm diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSidebar.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSidebar.tsx deleted file mode 100644 index cf066e12..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSidebar.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Button, makeStyles } from '@material-ui/core' - -import { useCloudProviderProps } from 'hooks/useCloudProvider' -import { cloudProviderName, pricingPageForProvider } from './utils' - -const MONTHLY_HOURS = 730 - -const useStyles = makeStyles({ - aside: { - width: '100%', - height: 'fit-content', - minHeight: '300px', - padding: '24px', - borderRadius: '4px', - boxShadow: '0 8px 16px #3a3a441f, 0 16px 32px #5a5b6a1f', - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-start', - flex: '1 1 0', - position: 'sticky', - top: 10, - - '& > h2': { - fontSize: '14px', - fontWeight: 500, - margin: '0 0 10px 0', - height: 'fit-content', - }, - - '& > span': { - fontSize: '13px', - }, - - '& > button': { - padding: '10px 20px', - marginTop: '20px', - }, - - '@media (max-width: 1200px)': { - position: 'relative', - boxShadow: 'none', - borderRadius: '0', - padding: '0', - flex: 'auto', - marginBottom: '30px', - - '& > button': { - width: 'max-content', - }, - }, - }, - asideSection: { - padding: '12px 0', - borderBottom: '1px solid #e0e0e0', - - '& > span': { - color: '#808080', - }, - - '& > p': { - margin: '5px 0 0 0', - fontSize: '13px', - }, - }, - flexWrap: { - display: 'flex', - flexWrap: 'wrap', - gap: '5px', - }, - capitalize: { - textTransform: 'capitalize', - }, - remark: { - fontSize: '11px', - marginTop: '5px', - display: 'block', - lineHeight: '1.2', - }, -}) - -export const DbLabInstanceFormSidebar = ({ - cluster, - state, - handleCreate, - disabled, -}: { - cluster?: boolean - state: useCloudProviderProps['initialState'] - handleCreate: () => void - disabled: boolean -}) => { - const classes = useStyles() - - return ( -
-

Preview

- - Review the specifications of the virtual machine, storage, and software - that will be provisioned. - - {state.name && ( -
- Name -

{state.name}

-
- )} - {state.tag && ( -
- Tag -

{state.tag}

-
- )} -
- Cloud provider -

- {cloudProviderName(state.provider)} -

-
-
- Cloud region -

- {state.location?.native_code}: {state.location?.label} -

-
-
- Instance type -

- {state.instanceType ? ( - <> - {cluster && ( - - Instances count: {state.numberOfInstances} - - )} - {state.instanceType.native_name}: - 🔳 {state.instanceType.native_vcpus} CPU - 🧠 {state.instanceType.native_ram_gib} GiB RAM - - Price: {state.instanceType.native_reference_price_currency} - {cluster - ? ( - state.numberOfInstances * - state.instanceType.native_reference_price_hourly - )?.toFixed(4) - : state.instanceType.native_reference_price_hourly?.toFixed( - 4, - )}{' '} - hourly (~{state.instanceType.native_reference_price_currency} - {cluster - ? ( - state.numberOfInstances * - state.instanceType.native_reference_price_hourly * - MONTHLY_HOURS - ).toFixed(2) - : ( - state.instanceType.native_reference_price_hourly * - MONTHLY_HOURS - ).toFixed(2)}{' '} - per month)* - - - ) : ( - No instance type available for this region. - )} -

-
-
- Database volume -

- Type: {state.volumeType} - Size: {Number(state.storage)?.toFixed(2)} GiB -

-

- Price: {state.volumeCurrency} - {cluster - ? (state.volumePrice * state.numberOfInstances).toFixed(4) - : state.volumePrice.toFixed(4)}{' '} - hourly (~{state.volumeCurrency} - {cluster - ? ( - state.volumePrice * - state.numberOfInstances * - MONTHLY_HOURS - ).toFixed(2) - : (state.volumePrice * MONTHLY_HOURS).toFixed(2)}{' '} - per month) - * -

- - *Payment is made directly to the cloud provider. The - estimated cost is calculated for the " - {state.instanceType?.native_reference_price_region}" region and is not - guaranteed. Please refer to{' '} - - the official pricing page - -   to confirm the actual costs. . - -
-
- - Software: {cluster ? 'Postgres cluster' : 'DBLab SE'} (pay as you go) - -

- {state.instanceType && ( - <> - Size: {state.instanceType.api_name} - - Price: $ - {cluster - ? ( - state.instanceType.dle_se_price_hourly * - state.numberOfInstances - ).toFixed(4) - : state.instanceType.dle_se_price_hourly?.toFixed(4)}{' '} - hourly (~$ - {cluster - ? ( - state.numberOfInstances * - state.instanceType.dle_se_price_hourly * - MONTHLY_HOURS - ).toFixed(2) - : ( - state.instanceType.dle_se_price_hourly * MONTHLY_HOURS - ).toFixed(2)}{' '} - per month) - - - )} -

-
- -
- ) -} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSlider.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSlider.tsx deleted file mode 100644 index 0c2f7e4c..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormSlider.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React from 'react' -import Slider from '@material-ui/core/Slider' -import { makeStyles } from '@material-ui/core' - -const useStyles = makeStyles({ - root: { - width: '100%', - marginBottom: 0, - }, - valueLabel: { - '& > span': { - backgroundColor: 'transparent', - }, - '& > span > span': { - color: '#000', - fontWeight: 'bold', - }, - }, -}) - -export const StorageSlider = ({ - value, - onChange, - customMarks, - sliderOptions, -}: { - value: number - customMarks: { value: number; scaledValue: number; label: string | number }[] - sliderOptions: { [key: string]: number } - onChange: (event: React.ChangeEvent<{}>, value: unknown) => void -}) => { - const classes = useStyles() - - const scale = (value: number) => { - if (customMarks) { - const previousMarkIndex = Math.floor(value / 25) - const previousMark = customMarks[previousMarkIndex] - const remainder = value % 25 - - if (remainder === 0) { - return previousMark?.scaledValue - } - - const nextMark = customMarks[previousMarkIndex + 1] - const increment = (nextMark?.scaledValue - previousMark?.scaledValue) / 25 - return remainder * increment + previousMark?.scaledValue - } else { - return value - } - } - - return ( - value} - aria-labelledby="non-linear-slider" - classes={{ root: classes.root, valueLabel: classes.valueLabel }} - /> - ) -} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormWrapper.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormWrapper.tsx deleted file mode 100644 index c405ece2..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceForm/DbLabInstanceFormWrapper.tsx +++ /dev/null @@ -1,311 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { RouteComponentProps } from 'react-router' -import { makeStyles } from '@material-ui/core' - -import DbLabInstanceForm from 'components/DbLabInstanceForm/DbLabInstanceForm' - -import { styles } from '@postgres.ai/shared/styles/styles' -import { OrgPermissions } from 'components/types' - -export interface DbLabInstanceFormProps { - userID?: number - edit?: boolean - orgId: number - project: string | undefined - history: RouteComponentProps['history'] - orgPermissions: OrgPermissions -} - -export const useInstanceFormStyles = makeStyles( - { - textField: { - ...styles.inputField, - maxWidth: 400, - }, - errorMessage: { - color: 'red', - }, - fieldBlock: { - width: '100%', - }, - urlOkIcon: { - marginBottom: -5, - marginLeft: 10, - color: 'green', - }, - urlOk: { - color: 'green', - }, - urlFailIcon: { - marginBottom: -5, - marginLeft: 10, - color: 'red', - }, - urlFail: { - color: 'red', - }, - warning: { - color: '#801200', - fontSize: '0.9em', - }, - warningIcon: { - color: '#801200', - fontSize: '1.2em', - position: 'relative', - marginBottom: -3, - }, - container: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 30, - gap: 60, - width: '100%', - height: '95%', - position: 'relative', - '& input': { - padding: '13.5px 14px', - }, - - '@media (max-width: 1200px)': { - flexDirection: 'column', - height: 'auto', - gap: 30, - }, - }, - form: { - width: '100%', - height: '100%', - display: 'flex', - flexDirection: 'column', - flex: '3 1 0', - - '& > [role="tabpanel"] .MuiBox-root': { - padding: 0, - - '& > div:first-child': { - marginTop: '10px', - }, - }, - }, - activeBorder: { - border: '1px solid #FF6212 !important', - }, - providerFlex: { - display: 'flex', - gap: '10px', - marginBottom: '20px', - overflow: 'auto', - flexShrink: 0, - - '& > div': { - width: '100%', - height: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - border: '1px solid #e0e0e0', - padding: '15px', - borderRadius: '4px', - cursor: 'pointer', - transition: 'border 0.3s ease-in-out', - - '@media (max-width: 600px)': { - width: '122.5px', - height: '96px', - }, - - '&:hover': { - border: '1px solid #FF6212', - }, - - '& > img': { - margin: 'auto', - }, - }, - }, - sectionTitle: { - fontSize: '14px', - fontWeight: 500, - marginTop: '20px', - - '&:first-child': { - marginTop: 0, - }, - }, - sectionContainer: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - - '& > .MuiTabs-root, .MuiTabs-fixed': { - overflow: 'auto !important', - }, - - '& span': { - top: '40px', - height: '2px', - }, - }, - tab: { - minWidth: 'auto', - padding: '0 12px', - }, - tabPanel: { - padding: '10px 0 0 0', - }, - instanceSize: { - marginBottom: '10px', - border: '1px solid #e0e0e0', - borderRadius: '4px', - cursor: 'pointer', - padding: '15px', - transition: 'border 0.3s ease-in-out', - display: 'flex', - gap: 10, - flexDirection: 'column', - - '&:hover': { - border: '1px solid #FF6212', - }, - - '& > p': { - margin: 0, - }, - - '& > div': { - display: 'flex', - gap: 10, - alignItems: 'center', - flexWrap: 'wrap', - }, - }, - serviceLocation: { - display: 'flex', - flexDirection: 'column', - gap: '5px', - marginBottom: '10px', - border: '1px solid #e0e0e0', - borderRadius: '4px', - cursor: 'pointer', - padding: '15px', - transition: 'border 0.3s ease-in-out', - - '&:hover': { - border: '1px solid #FF6212', - }, - - '& > p': { - margin: 0, - }, - }, - instanceParagraph: { - margin: '0 0 10px 0', - }, - filterSelect: { - flex: '2 1 0', - - '& .MuiSelect-select': { - padding: '10px', - }, - - '& .MuiInputBase-input': { - padding: '10px', - }, - - '& .MuiSelect-icon': { - top: 'calc(50% - 9px)', - }, - }, - generateContainer: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: '10px', - - '& > button': { - width: 'max-content', - marginTop: '10px', - flexShrink: 0, - height: 'calc(100% - 10px)', - }, - - '@media (max-width: 640px)': { - flexDirection: 'column', - alignItems: 'flex-start', - gap: 0, - - '& > button': { - height: 'auto', - }, - }, - }, - backgroundOverlay: { - '&::before': { - content: '""', - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - background: 'rgba(255, 255, 255, 0.8)', - zIndex: 1, - }, - }, - absoluteSpinner: { - position: 'fixed', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - zIndex: 1, - width: '32px !important', - height: '32px !important', - }, - marginTop: { - marginTop: '10px', - }, - sliderContainer: { - width: '100%', - padding: '30px 35px', - borderRadius: '4px', - border: '1px solid #e0e0e0', - }, - sliderInputContainer: { - display: 'flex', - flexDirection: 'column', - marginBottom: '20px', - gap: '20px', - maxWidth: '350px', - width: '100%', - }, - sliderVolume: { - display: 'flex', - flexDirection: 'row', - gap: '10px', - alignItems: 'center', - }, - databaseSize: { - display: 'flex', - flexDirection: 'row', - gap: '10px', - alignItems: 'center', - spinner: { - marginLeft: 8, - color: 'inherit', - }, - }, - }, - { index: 1 }, -) - -export const DbLabInstanceFormWrapper = (props: DbLabInstanceFormProps) => { - const classes = useInstanceFormStyles() - - return -} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/reducer/index.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/reducer/index.tsx deleted file mode 100644 index 9a103227..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceForm/reducer/index.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { ReducerAction } from 'react' - -import { CloudInstance } from 'api/cloud/getCloudInstances' -import { CloudRegion } from 'api/cloud/getCloudRegions' -import { CloudVolumes } from 'api/cloud/getCloudVolumes' - -import { availableTags } from 'components/DbLabInstanceForm/utils' -import { clusterExtensionsState } from 'components/PostgresClusterInstallForm/reducer' - -export const initialState = { - isLoading: false, - isReloading: false, - formStep: 'create', - provider: 'aws', - storage: 30, - region: 'North America', - tag: availableTags[0], - serviceProviders: [] as string[], - cloudRegions: [] as CloudRegion[], - cloudInstances: [] as CloudInstance[], - volumes: [] as CloudVolumes[], - api_name: 'ssd', - databaseSize: 10, - snapshots: 3, - volumeType: '', - volumePrice: 0, - volumePricePerHour: 0, - volumeCurrency: '', - location: {} as CloudRegion, - instanceType: {} as CloudInstance, - name: '', - publicKeys: '', - verificationToken: '', - numberOfInstances: 3, - version: 16, - database_public_access: false, - with_haproxy_load_balancing: false, - pgbouncer_install: true, - synchronous_mode: false, - synchronous_node_count: 1, - netdata_install: true, - taskID: '', - fileSystem: 'zfs', - ...clusterExtensionsState, -} - -export const reducer = ( - state: typeof initialState, - // @ts-ignore - action: ReducerAction, -) => { - switch (action.type) { - case 'set_initial_state': { - return { - ...state, - isLoading: action.isLoading, - serviceProviders: action.serviceProviders, - volumes: action.volumes, - volumeType: action.volumeType, - volumePrice: action.volumePrice, - volumePricePerHour: action.volumePricePerHour, - volumeCurrency: action.volumeCurrency, - region: initialState.region, - databaseSize: initialState.databaseSize, - snapshots: initialState.snapshots, - location: action.cloudRegions.find( - (region: CloudRegion) => - region.world_part === initialState.region && - region.cloud_provider === initialState.provider, - ), - } - } - case 'update_initial_state': { - return { - ...state, - volumes: action.volumes, - volumeType: action.volumeType, - volumePricePerHour: action.volumePricePerHour, - volumeCurrency: action.volumeCurrency, - cloudRegions: action.cloudRegions, - location: action.cloudRegions.find( - (region: CloudRegion) => region.world_part === initialState.region, - ), - } - } - case 'update_instance_type': { - return { - ...state, - cloudInstances: action.cloudInstances, - instanceType: action.instanceType, - isReloading: action.isReloading, - } - } - case 'change_provider': { - return { - ...state, - provider: action.provider, - region: initialState.region, - isReloading: action.isReloading, - databaseSize: initialState.databaseSize, - snapshots: initialState.snapshots, - storage: initialState.storage, - } - } - case 'change_region': { - return { - ...state, - region: action.region, - location: action.location, - } - } - case 'change_location': { - return { - ...state, - location: action.location, - } - } - case 'change_name': { - return { - ...state, - name: action.name, - } - } - case 'change_instance_type': { - return { - ...state, - instanceType: action.instanceType, - } - } - case 'change_verification_token': { - return { - ...state, - verificationToken: action.verificationToken, - } - } - case 'change_public_keys': { - return { - ...state, - publicKeys: action.publicKeys, - } - } - case 'change_volume_type': { - return { - ...state, - volumeType: action.volumeType, - volumePrice: action.volumePrice, - volumePricePerHour: action.volumePricePerHour, - } - } - case 'change_snapshots': { - return { - ...state, - snapshots: action.snapshots, - storage: action.storage, - volumePrice: action.volumePrice, - } - } - - case 'change_volume_price': { - return { - ...state, - volumePrice: action.volumePrice, - databaseSize: action.databaseSize, - storage: action.storage, - } - } - - case 'set_form_step': { - return { - ...state, - formStep: action.formStep, - ...(action.taskID ? { taskID: action.taskID } : {}), - ...(action.provider ? { provider: action.provider } : {}), - } - } - case 'set_tag': { - return { - ...state, - tag: action.tag, - } - } - default: - throw new Error() - } -} diff --git a/ui/packages/platform/src/components/DbLabInstanceForm/utils/index.tsx b/ui/packages/platform/src/components/DbLabInstanceForm/utils/index.tsx deleted file mode 100644 index 2e612fe0..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceForm/utils/index.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { CloudImage } from 'api/cloud/getCloudImages' -import { CloudRegion } from 'api/cloud/getCloudRegions' -import { CloudVolumes } from 'api/cloud/getCloudVolumes' -import { addIndentForDocker } from 'components/PostgresClusterInstallForm/utils' -import { ClassesType } from 'components/types' -import { useCloudProviderProps } from 'hooks/useCloudProvider' - -const API_SERVER = process.env.REACT_APP_API_SERVER -export const DEBUG_API_SERVER = 'https://v2.postgres.ai/api/general' - -export const availableTags = ['3.5.0', '3.4.0', '4.0.0-beta.7'] - -export const sePackageTag = 'v1.4' - -export const dockerRunCommand = (provider: string) => { - switch (provider) { - case 'aws': - return `docker run --rm -it \\\r - --env AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID} \\\r - --env AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY}` - - case 'gcp': - return `docker run --rm -it \\\r - --env GCP_SERVICE_ACCOUNT_CONTENTS=\${GCP_SERVICE_ACCOUNT_CONTENTS}` - - case 'hetzner': - return `docker run --rm -it \\\r - --env HCLOUD_API_TOKEN=\${HCLOUD_API_TOKEN}` - - case 'digitalocean': - return `docker run --rm -it \\\r - --env DO_API_TOKEN=\${DO_API_TOKEN}` - - default: - throw new Error('Provider is not supported') - } -} - -export const getPlaybookCommand = ( - state: useCloudProviderProps['initialState'], - cloudImages: CloudImage, - orgKey: string, - isDocker?: boolean, -) =>{ - const playbookCommand = `ansible-playbook deploy_dle.yml --extra-vars \\\r - "provision='${state.provider}' \\\r - server_name='${state.name}' \\\r - server_type='${state.instanceType.native_name}' \\\r - server_image='${cloudImages?.native_os_image}' \\\r - server_location='${state.location.native_code}' \\\r - volume_size='${state.storage}' \\\r - dblab_engine_verification_token='${state.verificationToken}' \\\r - dblab_engine_version='${state.tag}' \\\r - ${ state.snapshots > 1 ? `zpool_datasets_number='${state.snapshots}' \\\r` : `` } - ${ orgKey ? `platform_org_key='${orgKey}' \\\r` : `` } - ${ API_SERVER === DEBUG_API_SERVER ? `platform_url='${DEBUG_API_SERVER}' \\\r` : `` } - ${state.publicKeys ? `ssh_public_keys='${state.publicKeys}' \\\r` : ``} - platform_project_name='${state.name}'"` - - if (isDocker) { - return `${dockerRunCommand(state.provider)} \\\r - postgresai/dle-se-ansible:${sePackageTag} \\\r - ${addIndentForDocker(playbookCommand)}` - } else { - return playbookCommand - } -} - -export const getGcpAccountContents = () => - `export GCP_SERVICE_ACCOUNT_CONTENTS='{ - "type": "service_account", - "project_id": "my-project", - "private_key_id": "c764349XXXXXXXXXX72f", - "private_key": "XXXXXXXXXX", - "client_email": "my-sa@my-project.iam.gserviceaccount.com", - "client_id": "111111112222222", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-sat%40my-project.iam.gserviceaccount.com" -}'` - -export const cloudProviderName = (provider: string) => { - switch (provider) { - case 'aws': - return 'AWS' - case 'gcp': - return 'GCP' - case 'digitalocean': - return 'DigitalOcean' - case 'hetzner': - return 'Hetzner' - default: - return provider - } -} - -export const pricingPageForProvider = (provider: string) => { - switch (provider) { - case 'aws': - return 'https://instances.vantage.sh/' - case 'gcp': - return 'https://cloud.google.com/compute/docs/general-purpose-machines' - case 'digitalocean': - return 'https://www.digitalocean.com/pricing/droplets' - case 'hetzner': - return 'https://www.hetzner.com/cloud' - } -} - -export const getNetworkSubnet = (provider: string, classNames: ClassesType) => { - const AnchorElement = ({ - href, - text = 'created', - }: { - href: string - text?: string - }) => ( - - {text} - - ) - - const NetworkSubnet = ({ - note, - code, - optionalText, - }: { - note: React.ReactNode - code: string - optionalText: React.ReactNode - }) => ( -
-

Additional parameters:

-
    -
  • - {code} {optionalText} {note}. -
  • -
-
- ) - - switch (provider) { - case 'aws': - return ( - - {' '} - - - } - code="server_network='subnet-xxx'" - optionalText={ - <> - (optional) Subnet ID. The VM will use this subnet ({' '} - {' '} - in the selected region). If not specified, default VPC and subnet - will be used. - - } - /> - ) - case 'hetzner': - return ( - - Public network is always attached; this is needed to access the - server the installation process. The variable 'server_network' is - used to define an additional network will be attached - - } - code="server_network='network-xx'" - optionalText={ - <> - (optional) Subnet ID. The VM will use this network ({' '} - {' '} - in the selected region). If not specified, no additional networks - will be used. - - } - /> - ) - case 'digitalocean': - return ( - - {' '} - - - } - code="server_network='vpc-name'" - optionalText={ - <> - (optional) VPC name. The droplet will use this VPC ({' '} - {' '} - in the selected region). If not specified, default VPC will be - used. - - } - /> - ) - case 'gcp': - return ( - - {' '} - - - } - code="server_network='vpc-network-name'" - optionalText={ - <> - optional) VPC network name. The VM will use this network ({' '} - {' '} - in the selected region). If not specified, default VPC and network - will be used. - - } - /> - ) - } -} - -export const uniqueRegionsByProvider = (cloudRegions: CloudRegion[]) => { - return cloudRegions - .map((region) => region.world_part) - .filter((value, index, self) => self.indexOf(value) === index) -} - -export const filteredRegions = ( - cloudRegions: CloudRegion[], - selectedRegion: string, -) => { - return cloudRegions.filter((region) => region.world_part === selectedRegion) -} - -export const formatVolumeDetails = ( - ssdCloudVolume: CloudVolumes, - storage: number, -) => ({ - volumeType: `${ssdCloudVolume.api_name} (${ssdCloudVolume.cloud_provider}: ${ssdCloudVolume.native_name})`, - volumeCurrency: ssdCloudVolume.native_reference_price_currency, - volumePricePerHour: - ssdCloudVolume.native_reference_price_per_1000gib_per_hour, - volumePrice: - (storage * ssdCloudVolume.native_reference_price_per_1000gib_per_hour) / - 1000, -}) diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/AnsibleInstance.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/AnsibleInstance.tsx deleted file mode 100644 index 6d1cb770..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/AnsibleInstance.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Button } from '@material-ui/core' -import { Box } from '@mui/material' -import { useEffect, useState } from 'react' - -import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' - -import { getOrgKeys } from 'api/cloud/getOrgKeys' - -import { formStyles } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' -import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' -import { SetupStep } from 'components/DbLabInstanceInstallForm/DbLabFormSteps/SetupStep' -import { - cloneRepositoryCommand, - getAnsibleInstallationCommand, - getPlaybookCommand, -} from 'components/DbLabInstanceInstallForm/utils' - -import { useCloudProviderProps } from 'hooks/useCloudProvider' - -export const AnsibleInstance = ({ - state, - orgId, - goBack, - goBackToForm, - formStep, - setFormStep, -}: { - state: useCloudProviderProps['initialState'] - orgId: number - goBack: () => void - goBackToForm: () => void - formStep: string - setFormStep: (step: string) => void -}) => { - const classes = formStyles() - const [orgKey, setOrgKey] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [orgKeyError, setOrgKeyError] = useState(false) - - useEffect(() => { - setIsLoading(true) - getOrgKeys(orgId).then((data) => { - console.log('data', data) - if (data.error !== null || !Array.isArray(data.response)) { - setIsLoading(false) - setOrgKeyError(true) - } else { - setIsLoading(false) - setOrgKeyError(false) - setOrgKey(data.response[0].value) - } - }) - }, [orgId]) - - return ( - - {isLoading ? ( - - - - ) : ( - <> - {orgKeyError ? ( - - ) : ( - <> - -

- 2. Install Ansible on your local machine (such as a laptop) -

- - - For additional instructions on installing Ansible, please refer - to{' '} - - this guide - - . - -

- 3. Clone the "dle-se-ansible" repository to your local machine -

- -

4. Install necessary dependencies

- {' '} -

- 5. Execute the Ansible playbook to install DBLab SE on the remote - server -

-

- Replace{' '} - 'user@server-ip-address' - with the specific username and IP address of the server where - you will be installing DBLab. -

- -

Please be aware:

-

- The script will attempt to automatically detect an empty volume - by default. If needed, you can manually specify the path to your - desired empty disk using the{' '} - zpool_disk  variable - (e.g.,{' '} - zpool_disk=/dev/nvme1n1). -

-

- 7. After the code snippet runs successfully, follow the - directions displayed in the resulting output to start using DBLab - UI/API/CLI. -

{' '} - - - - - - )} - - )} -
- ) -} diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/DockerInstance.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/DockerInstance.tsx deleted file mode 100644 index d8403707..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/DockerInstance.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Button } from '@material-ui/core' -import { Box } from '@mui/material' -import { useEffect, useState } from 'react' - -import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' - -import { getOrgKeys } from 'api/cloud/getOrgKeys' - -import { formStyles } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' -import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' -import { SetupStep } from 'components/DbLabInstanceInstallForm/DbLabFormSteps/SetupStep' -import { getPlaybookCommand } from 'components/DbLabInstanceInstallForm/utils' -import { useCloudProviderProps } from 'hooks/useCloudProvider' - - -export const DockerInstance = ({ - state, - orgId, - goBack, - goBackToForm, - formStep, - setFormStep, -}: { - state: useCloudProviderProps['initialState'] - orgId: number - goBack: () => void - goBackToForm: () => void - formStep: string - setFormStep: (step: string) => void -}) => { - const classes = formStyles() - const [orgKey, setOrgKey] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [orgKeyError, setOrgKeyError] = useState(false) - - useEffect(() => { - setIsLoading(true) - getOrgKeys(orgId).then((data) => { - console.log('data', data) - if (data.error !== null || !Array.isArray(data.response)) { - setIsLoading(false) - setOrgKeyError(true) - } else { - setIsLoading(false) - setOrgKeyError(false) - setOrgKey(data.response[0].value) - } - }) - }, [orgId]) - - return ( - - {isLoading ? ( - - - - ) : ( - <> - {orgKeyError ? ( - - ) : ( - <> - -

- 2. Execute the Ansible playbook to install DBLab SE on the remote - server -

-

- Replace{' '} - 'user@server-ip-address' - with the specific username and IP address of the server where - you will be installing DBLab. -

- -

Please be aware:

-

- The script will attempt to automatically detect an empty volume - by default. If needed, you can manually specify the path to your - desired empty disk using the{' '} - zpool_disk  variable - (e.g.,{' '} - zpool_disk=/dev/nvme1n1). -

-

- 3. After the code snippet runs successfully, follow the - directions displayed in the resulting output to start using DBLab - AUI/API/CLI. -

{' '} - - - - - - )} - - )} -
- ) -} diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/SetupStep.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/SetupStep.tsx deleted file mode 100644 index abd1112a..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabFormSteps/SetupStep.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ClassesType } from 'components/types' - -export const SetupStep = ({ classes }: { classes: ClassesType }) => ( - <> -

1. Set up your machine

-
    -
  • - Obtain a machine running Ubuntu 22.04 (although other versions may work, - we recommend using an LTS version for optimal compatibility). -
  • -
  • - Attach an empty disk that is at least twice the size of the database you - plan to use with DBLab. -
  • -
  • - Ensure that your SSH public key is added to the machine (in - ~/.ssh/authorized_keys), allowing - for secure SSH access. -
  • -
- -) diff --git a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallForm.tsx b/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallForm.tsx deleted file mode 100644 index 44a9608b..00000000 --- a/ui/packages/platform/src/components/DbLabInstanceInstallForm/DbLabInstanceInstallForm.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useReducer } from 'react' -import { TextField, Button } from '@material-ui/core' - -import ConsolePageTitle from '../ConsolePageTitle' -import { WarningWrapper } from 'components/Warning/WarningWrapper' -import { ClassesType } from '@postgres.ai/platform/src/components/types' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' -import { DbLabInstanceFormProps } from 'components/DbLabInstanceForm/DbLabInstanceFormWrapper' -import { initialState, reducer } from 'components/DbLabInstanceForm/reducer' -import { DbLabInstanceFormInstallSidebar } from 'components/DbLabInstanceInstallForm/DbLabInstanceInstallFormSidebar' -import { StubSpinner } from '@postgres.ai/shared/components/StubSpinnerFlex' -import { AnsibleInstance } from 'components/DbLabInstanceInstallForm/DbLabFormSteps/AnsibleInstance' -import { DockerInstance } from 'components/DbLabInstanceInstallForm/DbLabFormSteps/DockerInstance' -import { availableTags } from 'components/DbLabInstanceForm/utils' -import { Select } from '@postgres.ai/shared/components/Select' - -import { generateToken, validateDLEName } from 'utils/utils' -import urls from 'utils/urls' - -interface DbLabInstanceFormWithStylesProps extends DbLabInstanceFormProps { - classes: ClassesType -} - -const DbLabInstanceInstallForm = (props: DbLabInstanceFormWithStylesProps) => { - const { classes, orgPermissions } = props - const [state, dispatch] = useReducer(reducer, initialState) - - const permitted = !orgPermissions || orgPermissions.dblabInstanceCreate - - const pageTitle = - const breadcrumbs = ( - - ) - - const handleGenerateToken = () => { - dispatch({ - type: 'change_verification_token', - verificationToken: generateToken(), - }) - } - - const handleReturnToList = () => { - props.history.push(urls.linkDbLabInstances(props)) - } - - const handleSetFormStep = (step: string) => { - dispatch({ type: 'set_form_step', formStep: step }) - } - - const handleReturnToForm = () => { - dispatch({ type: 'set_form_step', formStep: initialState.formStep }) - } - - if (state.isLoading) return - - return ( -
- {breadcrumbs} - - {pageTitle} - - {!permitted && ( - - You do not have permission to add Database Lab instances. - - )} - -
- {state.formStep === initialState.formStep && permitted ? ( - <> -
-

1. Provide DBLab name

- , - ) => - dispatch({ type: 'change_name', name: event.target.value }) - } - /> -

- 2. Define DBLab verification token (keep it secret!) -

-
- , - ) => - dispatch({ - type: 'change_verification_token', - verificationToken: event.target.value, - }) - } - /> - -
-

- 3. Choose DBLab server version -

- { - if (u.role_id !== event.target.value) { - that.changeRoleHandler(u.id, event.target.value as number) - } - }} - className={classes.roleSelector} - variant="outlined" - > - {roles.map((r) => { - return ( - - {r.name} - - ) - })} - - - ) - } - - // Just output role name. - let role = '-' - for (let i in roles) { - if (roles[i].id === u.role_id) { - role = roles[i].name - } - } - - return ( - - {role} - {u.is_owner ? ' (Owner)' : ''} - - ) - } - - filterInputHandler = (event: React.ChangeEvent) => { - this.setState({ filterValue: event.target.value }) - } - - render() { - const { classes, orgPermissions, orgId, env } = this.props - const data = this.state && this.state.data ? this.state.data.orgUsers : null - const userProfile = - this.state && this.state.data ? this.state.data.userProfile : null - - const breadcrumbs = ( - - ) - - const hasAddMemberPermission = - !orgPermissions || orgPermissions.settingsMemberAdd - const hasListMembersPermission = - !orgPermissions || orgPermissions.settingsMemberList - - const actions = [ - - Add - , - ] - - let users: UsersType[] = [] - if (hasListMembersPermission) { - if (data && data.data && data.data.users && data.data.users.length > 0) { - users = data.data.users - } - } else if (userProfile && userProfile.data && userProfile.data.info) { - users = [userProfile.data.info] - } - - const filteredUsers = users?.filter((user) => { - const fullName = (user.first_name || '') + ' ' + (user.last_name || '') - return ( - fullName - ?.toLowerCase() - .indexOf((this.state.filterValue || '')?.toLowerCase()) !== -1 - ) - }) - - const pageTitle = ( - 0 - ? { - filterValue: this.state.filterValue, - filterHandler: this.filterInputHandler, - placeholder: 'Search users by name', - } - : null - } - /> - ) - - if ( - !data || - (hasListMembersPermission && data.orgId !== orgId) || - (data.isProcessing && data.isRefresh === false) - ) { - return ( -
- {breadcrumbs} - - {pageTitle} - - -
- ) - } - - if (data.error) { - return ( -
- -
- ) - } - - // If user does not have "ListMembersPermission" we will fill the list only - // with his data without making getOrgUsers request. - - return ( -
- {breadcrumbs} - - {pageTitle} - - {!hasListMembersPermission && ( - - You do not have permission to view the full list of members - - )} - - {filteredUsers && filteredUsers.length > 0 ? ( - - - - - ID - Email - Role - First name - Last name - - - - - {filteredUsers.map((u: UsersType) => { - return ( - - {u.id} - {u.email} - - {this.roleSelector(u)} - {u.id === data.updateUserId && data.isUpdating && ( - - )} - - - {u.first_name} - - - {u.last_name} - - - {!u.is_owner && - data.isDeleting && - data.deleteUserId === u.id && ( - - )} - {!u.is_owner && u.id !== env.data.info.id && ( - this.deleteHandler(event, u)} - > - - - )} - {u.id === env.data.info.id && ( - this.deleteHandler(event, u)} - > - - - )} - - - ) - })} - -
-
- ) : ( - 'Members not found' - )} - -
-
- ) - } -} - -export default OrgSettings diff --git a/ui/packages/platform/src/components/OrgMembers/OrgMembersWrapper.tsx b/ui/packages/platform/src/components/OrgMembers/OrgMembersWrapper.tsx deleted file mode 100644 index 780fe321..00000000 --- a/ui/packages/platform/src/components/OrgMembers/OrgMembersWrapper.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { styles } from '@postgres.ai/shared/styles/styles' -import { RouteComponentProps } from 'react-router' -import OrgSettings from 'components/OrgMembers/OrgMembers' -import { OrgPermissions } from 'components/types' - -export interface OrgSettingsProps { - project: string | undefined - history: RouteComponentProps['history'] - org: string | number - orgId: number - orgPermissions: OrgPermissions - env: { - data: { - info: { - id: number | null - } - } - } -} - -export const OrgMembersWrapper = (props: OrgSettingsProps) => { - const useStyles = makeStyles( - (theme) => ({ - root: { - ...(styles.root as Object), - display: 'flex', - flexDirection: 'column', - paddingBottom: '20px', - }, - container: { - display: 'flex', - flexWrap: 'wrap', - }, - textField: { - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), - width: '80%', - }, - actionCell: { - textAlign: 'right', - padding: 0, - paddingRight: 16, - }, - iconButton: { - margin: '-12px', - marginLeft: 5, - }, - inTableProgress: { - width: '15px!important', - height: '15px!important', - marginLeft: 5, - verticalAlign: 'middle', - }, - roleSelector: { - height: 24, - width: 190, - '& svg': { - top: 5, - right: 3, - }, - '& .MuiSelect-select': { - padding: 8, - paddingRight: 20, - }, - }, - roleSelectorItem: { - fontSize: 14, - }, - bottomSpace: { - ...styles.bottomSpace, - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/PostgresClusterForm/PostgresCluster.tsx b/ui/packages/platform/src/components/PostgresClusterForm/PostgresCluster.tsx deleted file mode 100644 index 7eb0647a..00000000 --- a/ui/packages/platform/src/components/PostgresClusterForm/PostgresCluster.tsx +++ /dev/null @@ -1,809 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { - Accordion, - AccordionDetails, - AccordionSummary, - Checkbox, - FormControlLabel, - InputAdornment, - MenuItem, - Tab, - Tabs, - TextField, -} from '@material-ui/core' -import { Box } from '@mui/material' -import cn from 'classnames' - -import { ClassesType } from '@postgres.ai/platform/src/components/types' -import { Select } from '@postgres.ai/shared/components/Select' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { StubSpinner } from '@postgres.ai/shared/components/StubSpinnerFlex' -import { CloudProvider } from 'api/cloud/getCloudProviders' -import { CloudVolumes } from 'api/cloud/getCloudVolumes' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' -import { DbLabInstanceFormSidebar } from 'components/DbLabInstanceForm/DbLabInstanceFormSidebar' -import { StorageSlider } from 'components/DbLabInstanceForm/DbLabInstanceFormSlider' -import { DbLabInstanceFormProps } from 'components/DbLabInstanceForm/DbLabInstanceFormWrapper' -import { initialState, reducer } from 'components/PostgresClusterForm/reducer' -import { WarningWrapper } from 'components/Warning/WarningWrapper' -import { TabPanel } from 'pages/JoeSessionCommand/TabPanel' -import ConsolePageTitle from '../ConsolePageTitle' - -import urls from 'utils/urls' -import { validateDLEName } from 'utils/utils' - -import { icons } from '@postgres.ai/shared/styles/icons' -import { CloudInstance } from 'api/cloud/getCloudInstances' -import { CloudRegion } from 'api/cloud/getCloudRegions' -import { AnsibleInstance } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' -import { DockerInstance } from 'components/DbLabInstanceForm/DbLabFormSteps/DockerInstance' -import { SimpleInstance } from 'components/DbLabInstanceForm/DbLabFormSteps/SimpleInstance' -import { - filteredRegions, - uniqueRegionsByProvider, -} from 'components/DbLabInstanceForm/utils' -import { ClusterExtensionAccordion } from 'components/PostgresClusterForm/PostgresClusterSteps' -import { useCloudProvider } from 'hooks/useCloudProvider' - -interface PostgresClusterProps extends DbLabInstanceFormProps { - classes: ClassesType - auth?: { - userId: number - } -} - -const PostgresCluster = (props: PostgresClusterProps) => { - const { classes, orgPermissions } = props - const { - state, - dispatch, - handleChangeVolume, - handleSetFormStep, - handleReturnToForm, - } = useCloudProvider({ initialState, reducer }) - - const permitted = !orgPermissions || orgPermissions.dblabInstanceCreate - const requirePublicKeys = - !state.publicKeys && (state.provider === 'aws' || state.provider === 'gcp') - - const pageTitle = - const breadcrumbs = ( - - ) - - const handleReturnToList = () => { - props.history.push(urls.linkClusters(props)) - } - - const checkSyncStandbyCount = () => { - if (state.synchronous_mode) { - if (Number(state.numberOfInstances) === 1) { - return state.synchronous_node_count > state.numberOfInstances - } else { - return state.synchronous_node_count > state.numberOfInstances - 1 - } - } - } - - const disableSubmitButton = - validateDLEName(state.name) || - requirePublicKeys || - state.numberOfInstances > 32 || - checkSyncStandbyCount() || - (state.publicKeys && state.publicKeys.length < 30) - - if (state.isLoading) return - - return ( -
- {breadcrumbs} - - {pageTitle} - - {!permitted && ( - - You do not have permission to add Database Lab instances. - - )} - -
- {state.formStep === initialState.formStep && permitted ? ( - <> - {state.isReloading && ( - - )} -
-

- 1. Select your cloud provider -

-
- {state.serviceProviders.map( - (provider: CloudProvider, index: number) => ( -
- dispatch({ - type: 'change_provider', - provider: provider.api_name, - isReloading: true, - }) - } - > - {provider.label} -
- ), - )} -
-

- 2. Select your cloud region -

-
- | null, value: string) => - dispatch({ - type: 'change_region', - region: value, - location: state.cloudRegions.find( - (region: CloudRegion) => - region.world_part === value && - region.cloud_provider === state.provider, - ), - }) - } - > - {uniqueRegionsByProvider(state.cloudRegions).map( - (region: string, index: number) => ( - - ), - )} - -
- - {filteredRegions(state.cloudRegions, state.region).map( - (region: CloudRegion, index: number) => ( -
- dispatch({ - type: 'change_location', - location: region, - }) - } - > -

{region.api_name}

-

🏴 {region.label}

-
- ), - )} -
- {state.instanceType ? ( - <> -

- 3. Choose instance type -

- - {state.cloudInstances.map( - (instance: CloudInstance, index: number) => ( -
- dispatch({ - type: 'change_instance_type', - instanceType: instance, - }) - } - > -

- {instance.api_name} ( - {state.instanceType.cloud_provider}:{' '} - {instance.native_name}) -

-
- 🔳 {instance.native_vcpus} CPU - 🧠 {instance.native_ram_gib} GiB RAM -
-
- ), - )} -
-

4. Number of instances

-

- Number of servers in the Postgres cluster -

- - - - 32 && - 'Maximum 32 instances' - } - error={state.numberOfInstances > 32} - value={state.numberOfInstances} - className={classes.filterSelect} - onChange={( - event: React.ChangeEvent< - HTMLTextAreaElement | HTMLInputElement - >, - ) => { - dispatch({ - type: 'change_number_of_instances', - number: event.target.value, - }) - }} - /> - - - , value: unknown) => { - dispatch({ - type: 'change_number_of_instances', - number: value, - }) - }} - /> - -

5. Database volume

- - - - - dispatch({ - type: 'change_file_system', - fileSystem: e.target.value, - }) - } - select - label="Filesystem" - InputLabelProps={{ - shrink: true, - }} - variant="outlined" - className={classes.filterSelect} - > - {state.fileSystemArray.map( - (p: string, id: number) => { - return ( - - {p} - - ) - }, - )} - - - - - {(state.volumes as CloudVolumes[]).map((p, id) => { - const volumeName = `${p.api_name} (${p.cloud_provider}: ${p.native_name})` - return ( - - {volumeName} - - ) - })} - - - - - GiB - - ), - }} - value={state.storage} - className={classes.filterSelect} - onChange={( - event: React.ChangeEvent< - HTMLTextAreaElement | HTMLInputElement - >, - ) => { - dispatch({ - type: 'change_volume_price', - volumeSize: event.target.value, - volumePrice: event.target.value, - }) - }} - /> - - - , value: unknown) => { - dispatch({ - type: 'change_volume_price', - volumeSize: value, - volumePrice: - (Number(value) * state.volumePricePerHour) / 1000, - }) - }} - /> - -

- 6. Provide cluster name -

- , - ) => - dispatch({ - type: 'change_name', - name: event.target.value, - }) - } - /> -

- 7. Choose Postgres version -

- i + 10).map( - (version) => { - return { - value: version, - children: version, - } - }, - )} - value={state.version} - onChange={( - e: React.ChangeEvent, - ) => - dispatch({ - type: 'change_version', - version: e.target.value, - }) - } - /> -

3. Data directory

-

- If you want to place the data on a separate disk, you can - specify an alternative path to the data directory. -

- , - ) => - dispatch({ - type: 'change_postgresql_data_dir', - postgresql_data_dir: event.target.value, - }) - } - /> - key !== 'postgresql_data_dir', - ), - )} - classes={classes} - dispatch={dispatch} - /> - - - 5. Advanced options - - - -

- Optional. Specify here the IP address that will be used as - a single entry point for client access to databases in the - cluster (not for cloud environments). -

- , - ) => - dispatch({ - type: 'change_cluster_vip', - cluster_vip: event.target.value, - }) - } - /> - - dispatch({ - type: 'change_with_haproxy_load_balancing', - with_haproxy_load_balancing: e.target.checked, - }) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Haproxy load balancing'} - /> - - dispatch({ - type: 'change_pgbouncer_install', - pgbouncer_install: e.target.checked, - }) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'PgBouncer connection pooler'} - /> - - dispatch({ - type: 'change_synchronous_mode', - synchronous_mode: e.target.checked, - }) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Enable synchronous replication'} - /> - , - ) => - dispatch({ - type: 'change_synchronous_node_count', - synchronous_node_count: e.target.value, - }) - } - /> - - dispatch({ - type: 'change_netdata_install', - netdata_install: e.target.checked, - }) - } - classes={{ - root: classes.checkboxRoot, - }} - /> - } - label={'Netdata monitoring'} - /> -
-
-
-
- - !validateDLEName(state.patroni_cluster_name) && - handleSetFormStep('docker') - } - /> - - ) : state.formStep === 'ansible' && permitted ? ( - - ) : state.formStep === 'docker' && permitted ? ( - - ) : null} -
-
- ) -} - -export default PostgresClusterInstallForm diff --git a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterInstallFormSidebar.tsx b/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterInstallFormSidebar.tsx deleted file mode 100644 index bbeb60b0..00000000 --- a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterInstallFormSidebar.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Button, makeStyles } from '@material-ui/core' - -import { useCloudProviderProps } from 'hooks/useCloudProvider' - -const useStyles = makeStyles({ - boxShadow: { - padding: '24px', - boxShadow: '0 8px 16px #3a3a441f, 0 16px 32px #5a5b6a1f', - }, - aside: { - width: '100%', - height: 'fit-content', - borderRadius: '4px', - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-start', - flex: '1 1 0', - position: 'sticky', - top: 10, - - '& h2': { - fontSize: '14px', - fontWeight: 500, - margin: '0 0 10px 0', - height: 'fit-content', - }, - - '& span': { - fontSize: '13px', - }, - - '& button': { - padding: '10px 20px', - marginTop: '20px', - }, - - '@media (max-width: 1200px)': { - position: 'relative', - boxShadow: 'none', - borderRadius: '0', - padding: '0', - flex: 'auto', - marginBottom: '30px', - - '& button': { - width: 'max-content', - }, - }, - }, - asideSection: { - padding: '12px 0', - borderBottom: '1px solid #e0e0e0', - - '& span': { - color: '#808080', - }, - - '& p': { - margin: '5px 0 0 0', - fontSize: '13px', - }, - }, -}) - -export const PostgresClusterInstallFormSidebar = ({ - state, - handleCreate, - disabled, -}: { - state: useCloudProviderProps['initialState'] - handleCreate: () => void - disabled: boolean -}) => { - const classes = useStyles() - - return ( -
-
- {state.patroni_cluster_name && ( -
- Cluster Name -

{state.patroni_cluster_name}

-
- )} -
- Postgres version -

{state.version}

-
-
- Data directory -

{state.postgresql_data_dir}

-
- -
-
- ) -} diff --git a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterInstallWrapper.tsx b/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterInstallWrapper.tsx deleted file mode 100644 index 5e6bea25..00000000 --- a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterInstallWrapper.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { RouteComponentProps } from 'react-router' - -import PostgresClusterInstallForm from './PostgresClusterInstallForm' -import { useInstanceFormStyles } from 'components/DbLabInstanceForm/DbLabInstanceFormWrapper' -import { OrgPermissions } from 'components/types' - -export interface PostgresClusterInstallFormWrapperProps { - edit?: boolean - orgId: number - project: string | undefined - history: RouteComponentProps['history'] - orgPermissions: OrgPermissions -} - -export const PostgresClusterInstallWrapper = ( - props: PostgresClusterInstallFormWrapperProps, -) => { - const classes = useInstanceFormStyles() - - return -} diff --git a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterSteps/AnsibleInstance.tsx b/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterSteps/AnsibleInstance.tsx deleted file mode 100644 index b02250c1..00000000 --- a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterSteps/AnsibleInstance.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Button, -} from '@material-ui/core' -import { Box } from '@mui/material' - -import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' -import { icons } from '@postgres.ai/shared/styles/icons' -import { formStyles } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' -import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' -import { getAnsibleInstallationCommand } from 'components/DbLabInstanceInstallForm/utils' -import { useCloudProviderProps } from 'hooks/useCloudProvider' -import { - getClusterCommand, - getClusterExampleCommand, - getPostgresClusterInstallationCommand, -} from '../utils' - -export const AnsibleInstance = ({ - state, - goBack, - goBackToForm, - formStep, - setFormStep, -}: { - state: useCloudProviderProps['initialState'] - goBack: () => void - goBackToForm: () => void - formStep: string - setFormStep: (step: string) => void -}) => { - const classes = formStyles() - - return ( - - <> -

1. Set up your machine

-
    -
  • - Obtain a machine running on one of the supported Linux - distributions: Ubuntu 20.04/22.04, Debian 11/12, CentOS Stream 8/9, - Rocky Linux 8/9, AlmaLinux 8/9, or Red Hat Enterprise Linux 8/9. -
  • -
  • - (Recommended) Attach and mount the disk for the data directory. -
  • -
  • - Ensure that your SSH public key is added to the machine (in - ~/.ssh/authorized_keys), - allowing for secure SSH access. -
  • -
-

- 2. Install Ansible on your local machine (such as a laptop) -

- - - For additional instructions on installing Ansible, please refer to{' '} - - this guide - - . - -

- 3. Clone the postgresql_cluster repository -

- -

4. Prepare the inventory file

-
    -
  • - Specify private IP addresses (non-public) and connection settings ({' '} - ansible_user, - ansible_ssh_private_key_file{' '} - or ansible_ssh_pass for your - environment. -
  • -
  • - For deploying via public IPs, add '{' '} - ansible_host=public_ip_address - ' variable for each node. -
  • -
- - - - - Example - - - - - - -

- 5. Execute the Ansible playbook to Postgres Cluster -

- -

- 6. After the code snippet runs successfully, follow the directions - displayed in the resulting output to start using the database. -

- - - - - -
- ) -} diff --git a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterSteps/DockerInstance.tsx b/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterSteps/DockerInstance.tsx deleted file mode 100644 index 97c1c0ae..00000000 --- a/ui/packages/platform/src/components/PostgresClusterInstallForm/PostgresClusterSteps/DockerInstance.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Button, -} from '@material-ui/core' -import { Box } from '@mui/material' - -import { SyntaxHighlight } from '@postgres.ai/shared/components/SyntaxHighlight' -import { icons } from '@postgres.ai/shared/styles/icons' -import { formStyles } from 'components/DbLabInstanceForm/DbLabFormSteps/AnsibleInstance' -import { InstanceFormCreation } from 'components/DbLabInstanceForm/DbLabFormSteps/InstanceFormCreation' -import { useCloudProviderProps } from 'hooks/useCloudProvider' -import { - getClusterCommand, - getClusterExampleCommand, - getInventoryPreparationCommand, -} from '../utils' - -export const DockerInstance = ({ - state, - goBack, - goBackToForm, - formStep, - setFormStep, -}: { - state: useCloudProviderProps['initialState'] - goBack: () => void - goBackToForm: () => void - formStep: string - setFormStep: (step: string) => void -}) => { - const classes = formStyles() - - return ( - - <> -

1. Set up your machine

-
    -
  • - Obtain a machine running on one of the supported Linux - distributions: Ubuntu 20.04/22.04, Debian 11/12, CentOS Stream 8/9, - Rocky Linux 8/9, AlmaLinux 8/9, or Red Hat Enterprise Linux 8/9. -
  • -
  • - (Recommended) Attach and mount the disk for the data directory. -
  • -
  • - Ensure that your SSH public key is added to the machine (in - ~/.ssh/authorized_keys), - allowing for secure SSH access. -
  • -
-

2. Prepare the inventory file

-
    -
  • - Specify private IP addresses (non-public) and connection settings ({' '} - ansible_user, - ansible_ssh_private_key_file{' '} - or ansible_ssh_pass for your - environment. -
  • -
  • - For deploying via public IPs, add '{' '} - ansible_host=public_ip_address - ' variable for each node. -
  • -
- - - - - Example - - - - - - -

- 3. Run ansible playbook to deploy Postgres Cluster -

- -

- 4. After the code snippet runs successfully, follow the directions - displayed in the resulting output to start using the database. -

- - - - - -
- ) -} diff --git a/ui/packages/platform/src/components/PostgresClusterInstallForm/reducer/index.tsx b/ui/packages/platform/src/components/PostgresClusterInstallForm/reducer/index.tsx deleted file mode 100644 index 79a82c8e..00000000 --- a/ui/packages/platform/src/components/PostgresClusterInstallForm/reducer/index.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { ReducerAction } from 'react' - -export const clusterExtensionsState = { - pg_repack: true, - pg_cron: true, - pgaudit: true, - pgvector: true, - postgis: false, - pgrouting: false, - timescaledb: false, - citus: false, - pg_partman: false, - pg_stat_kcache: false, - pg_wait_sampling: false, -} - -export const initialState = { - isLoading: false, - isReloading: false, - formStep: 'create', - patroni_cluster_name: '', - version: 16, - postgresql_data_dir: '/var/lib/postgresql/16/', - cluster_vip: '', - with_haproxy_load_balancing: false, - pgbouncer_install: true, - synchronous_mode: false, - synchronous_node_count: 1, - netdata_install: true, - taskID: '', - ...clusterExtensionsState, -} - -export const reducer = ( - state: typeof initialState, - // @ts-ignore - action: ReducerAction, -) => { - switch (action.type) { - case 'change_patroni_cluster_name': { - return { - ...state, - patroni_cluster_name: action.patroni_cluster_name, - postgresql_data_dir: `/var/lib/postgresql/${state.version}/${ - action.patroni_cluster_name || '' - }`, - } - } - - case 'change_version': { - return { - ...state, - version: action.version, - postgresql_data_dir: `/var/lib/postgresql/${action.version}/${ - state.patroni_cluster_name || '' - }`, - } - } - - case 'change_postgresql_data_dir': { - return { - ...state, - postgresql_data_dir: action.postgresql_data_dir, - } - } - - case 'change_cluster_vip': { - return { - ...state, - cluster_vip: action.cluster_vip, - } - } - - case 'change_with_haproxy_load_balancing': { - return { - ...state, - with_haproxy_load_balancing: action.with_haproxy_load_balancing, - } - } - - case 'change_pgbouncer_install': { - return { - ...state, - pgbouncer_install: action.pgbouncer_install, - } - } - - case 'change_synchronous_mode': { - return { - ...state, - synchronous_mode: action.synchronous_mode, - } - } - - case 'change_synchronous_node_count': { - return { - ...state, - synchronous_node_count: action.synchronous_node_count, - } - } - - case 'change_netdata_install': { - return { - ...state, - netdata_install: action.netdata_install, - } - } - - case 'set_form_step': { - return { - ...state, - formStep: action.formStep, - } - } - case 'change_pg_repack': { - return { - ...state, - pg_repack: action.pg_repack, - } - } - case 'change_pg_cron': { - return { - ...state, - pg_cron: action.pg_cron, - } - } - case 'change_pgaudit': { - return { - ...state, - pgaudit: action.pgaudit, - } - } - case 'change_pgvector': { - return { - ...state, - pgvector: action.pgvector, - } - } - case 'change_postgis': { - return { - ...state, - postgis: action.postgis, - } - } - case 'change_pgrouting': { - return { - ...state, - pgrouting: action.pgrouting, - } - } - case 'change_timescaledb': { - return { - ...state, - timescaledb: action.timescaledb, - } - } - case 'change_citus': { - return { - ...state, - citus: action.citus, - } - } - case 'change_pg_partman': { - return { - ...state, - pg_partman: action.pg_partman, - } - } - case 'change_pg_stat_kcache': { - return { - ...state, - pg_stat_kcache: action.pg_stat_kcache, - } - } - case 'change_pg_wait_sampling': { - return { - ...state, - pg_wait_sampling: action.pg_wait_sampling, - } - } - default: - throw new Error() - } -} diff --git a/ui/packages/platform/src/components/PostgresClusterInstallForm/utils/index.tsx b/ui/packages/platform/src/components/PostgresClusterInstallForm/utils/index.tsx deleted file mode 100644 index 1235f11c..00000000 --- a/ui/packages/platform/src/components/PostgresClusterInstallForm/utils/index.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useCloudProviderProps } from "hooks/useCloudProvider" - -export const getInventoryPreparationCommand = () => - `curl -fsSL https://raw.githubusercontent.com/vitabaks/postgresql_cluster/master/inventory \\\r - --output ~/inventory -nano ~/inventory` - -export const addIndentForDocker = (command: string) => - command - .split('\r\n') - .map((line, index) => { - if (index === 0) { - return `${line}` - } else { - return `${' '.repeat(8)}${line.replace(/^\s+/, '')}` - } - }) - .join('\r\n') - -export const addIndentForAnsible = (command: string) => -command - .split('\r\n') - .map((line, index) => { - if (index === 0) { - return `${line.replace(/^\s+/, '')}` - } else { - return `${' '.repeat(2)}${line.replace(/^\s+/, '')}` - } - }) - .join('\r\n') - -export const getClusterExampleCommand = () => -`[etcd_cluster] -10.0.1.1 ansible_host=5.161.228.76 -10.0.1.2 ansible_host=5.161.224.229 -10.0.1.3 ansible_host=5.161.63.15 - -[consul_instances] - -[balancers] -10.0.1.1 ansible_host=5.161.228.76 -10.0.1.2 ansible_host=5.161.224.229 -10.0.1.3 ansible_host=5.161.63.15 - -[master] -10.0.1.1 ansible_host=5.161.228.76 hostname=pgnode01 - -[replica] -10.0.1.2 ansible_host=5.161.224.229 hostname=pgnode02 -10.0.1.3 ansible_host=5.161.63.15 hostname=pgnode03 - -[postgres_cluster:children] -master -replica - -[pgbackrest] - -[all:vars] -ansible_connection='ssh' -ansible_ssh_port='22' -ansible_user='root' -ansible_ssh_private_key_file=/root/.ssh/id_rsa - -[pgbackrest:vars] -` - -export const clusterExtensions = (state: {[key: string]: boolean | string | number}) => `${state.pg_repack ? `enable_pg_repack='${state.pg_repack}' \\\r` : ''} -${state.pg_cron ? `enable_pg_cron='${state.pg_cron}' \\\r` : ''} -${state.pgaudit ? `enable_pgaudit='${state.pgaudit}' \\\r` : ''} -${state.version !== 10 && state.pgvector ? `enable_pgvector='${state.pgvector}' \\\r` : ''} -${state.postgis ? `enable_postgis='${state.postgis}' \\\r` : ''} -${state.pgrouting ? `enable_pgrouting='${state.pgrouting}' \\\r` : ''} -${state.version !== 10 && state.version !== 11 && state.timescaledb ? `enable_timescaledb='${state.timescaledb}' \\\r` : ''} -${state.version !== 10 && state.citus ? `enable_citus='${state.citus}' \\\r` : ''} -${state.pg_partman ? `enable_pg_partman='${state.pg_partman}' \\\r` : ''} -${state.pg_stat_kcache ? `enable_pg_stat_kcache='${state.pg_stat_kcache}' \\\r` : ''} -${state.pg_wait_sampling ? `enable_pg_wait_sampling='${state.pg_wait_sampling}' \\\r` : ''}` - -export const getClusterCommand = ( - state: useCloudProviderProps['initialState'], - isDocker?: boolean, -) => { - const playbookVariables = `ansible-playbook deploy_pgcluster.yml --extra-vars \\\r - "postgresql_version='${state.version}' \\\r - patroni_cluster_name='${state.patroni_cluster_name}' \\\r - ${addIndentForAnsible(clusterExtensions(state))} - ${state.cluster_vip ? `cluster_vip='${state.cluster_vip} \\\r'` : ''} - with_haproxy_load_balancing='${state.with_haproxy_load_balancing}' \\\r - pgbouncer_install='${state.pgbouncer_install}' \\\r - synchronous_mode='${state.synchronous_mode}' \\\r - ${state.synchronous_mode ? `synchronous_node_count='${state.synchronous_node_count}' \\\r` : ''} - netdata_install='${state.netdata_install}' \\\r - postgresql_data_dir='${state.postgresql_data_dir}'"` - - if (isDocker) { - return `docker run --rm -it \\\r - -v ~/inventory:/postgresql_cluster/inventory:ro \\\r - -v $HOME/.ssh:/root/.ssh:ro -e ANSIBLE_SSH_ARGS="-F none" \\\r - vitabaks/postgresql_cluster:cloud \\\r - ${addIndentForDocker(playbookVariables)}` - } - - return playbookVariables -} - -export const getPostgresClusterInstallationCommand = () => -`git clone --depth 1 --branch cloud \\\r - https://github.com/vitabaks/postgresql_cluster.git \\\r - && cd postgresql_cluster/ -` - -export function isIPAddress(input: string) { - if (input === '') { - return true - } - - const ipPattern = - /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ - return ipPattern.test(input) -} \ No newline at end of file diff --git a/ui/packages/platform/src/components/PostgresClusters/PostgresClusters.tsx b/ui/packages/platform/src/components/PostgresClusters/PostgresClusters.tsx deleted file mode 100644 index fb361911..00000000 --- a/ui/packages/platform/src/components/PostgresClusters/PostgresClusters.tsx +++ /dev/null @@ -1,606 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { - Menu, - MenuItem, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, -} from '@material-ui/core' -import { Component, MouseEvent } from 'react' - -import { - ClassesType, - ProjectProps, - RefluxTypes, -} from '@postgres.ai/platform/src/components/types' -import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' -import { Modal } from '@postgres.ai/shared/components/Modal' -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { styles } from '@postgres.ai/shared/styles/styles' - -import { InstanceDto } from '@postgres.ai/shared/types/api/entities/instance' -import { InstanceStateDto } from '@postgres.ai/shared/types/api/entities/instanceState' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' -import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' -import { CreateClusterCards } from 'components/CreateClusterCards/CreateClusterCards' -import { DbLabInstancesProps } from 'components/DbLabInstances/DbLabInstancesWrapper' -import { ErrorWrapper } from 'components/Error/ErrorWrapper' -import { WarningWrapper } from 'components/Warning/WarningWrapper' -import { ProjectDataType, getProjectAliasById } from 'utils/aliases' -import Actions from '../../actions/actions' -import { messages } from '../../assets/messages' -import Store from '../../stores/store' -import Urls from '../../utils/urls' -import ConsolePageTitle from './../ConsolePageTitle' - -interface PostgresClustersProps extends DbLabInstancesProps { - classes: ClassesType -} - -interface DbLabInstancesState { - modalState: { - open: boolean - type: string - } - data: { - auth: { - token: string - } | null - userProfile: { - data: { - orgs: ProjectDataType - } - } - dbLabInstances: { - orgId: number - data: { - [org: string]: { - created_at: string - project_label_or_name: string - plan: string - project_name: string - project_label: string - url: string - use_tunnel: boolean - isProcessing: boolean - id: string - project_alias: string - state: InstanceStateDto - dto: InstanceDto - } - } - isProcessing: boolean - projectId: string | number | undefined - error: boolean - } | null - dbLabInstanceStatus: { - instanceId: string - isProcessing: boolean - } - projects: Omit - } - anchorEl: (EventTarget & HTMLButtonElement) | null -} - -class PostgresClusters extends Component< - PostgresClustersProps, - DbLabInstancesState -> { - componentDidMount() { - const that = this - const orgId = this.props.orgId ? this.props.orgId : null - let projectId = this.props.projectId ? this.props.projectId : null - - if (!projectId) { - projectId = - this.props.match && - this.props.match.params && - this.props.match.params.projectId - ? this.props.match.params.projectId - : null - } - - if (projectId) { - Actions.setDbLabInstancesProject(orgId, projectId) - } else { - Actions.setDbLabInstancesProject(orgId, 0) - } - - this.unsubscribe = (Store.listen as RefluxTypes['listen'])(function () { - const auth: DbLabInstancesState['data']['auth'] = - this.data && this.data.auth ? this.data.auth : null - const dbLabInstances: DbLabInstancesState['data']['dbLabInstances'] = - this.data && this.data.dbLabInstances ? this.data.dbLabInstances : null - const projects: Omit = - this.data && this.data.projects ? this.data.projects : null - - if ( - auth && - auth.token && - !dbLabInstances?.isProcessing && - !dbLabInstances?.error && - !that.state - ) { - Actions.getDbLabInstances(auth.token, orgId, projectId) - } - - if ( - auth && - auth.token && - !projects.isProcessing && - !projects.error && - !that.state - ) { - Actions.getProjects(auth.token, orgId) - } - - that.setState({ data: this.data }) - }) - - Actions.refresh() - } - - unsubscribe: Function - componentWillUnmount() { - this.unsubscribe() - } - - handleClick = ( - _: MouseEvent, - id: string, - ) => { - const url = Urls.linkDbLabInstance(this.props, id) - - if (url) { - this.props.history.push(url) - } - } - - handleChangeProject = ( - event: React.ChangeEvent, - ) => { - const org = this.props.org ? this.props.org : null - const orgId = this.props.orgId ? this.props.orgId : null - const projectId = event.target.value - const project = this.state.data - ? getProjectAliasById(this.state.data?.userProfile?.data?.orgs, projectId) - : '' - const props = { org, orgId, projectId, project } - - Actions.setDbLabInstancesProject(orgId, event.target.value) - this.props.history.push(Urls.linkDbLabInstances(props)) - } - - registerButtonHandler = (provider: string) => { - this.props.history.push(Urls.linkDbLabInstanceAdd(this.props, provider)) - } - - openMenu = (event: MouseEvent) => { - event.stopPropagation() - this.setState({ anchorEl: event.currentTarget }) - } - - closeMenu = () => { - this.setState({ anchorEl: null }) - } - - menuHandler = (_: MouseEvent, action: string) => { - const anchorEl = this.state.anchorEl - - this.closeMenu() - - setTimeout(() => { - const auth = - this.state.data && this.state.data.auth ? this.state.data.auth : null - const data = - this.state.data && this.state.data.dbLabInstances - ? this.state.data.dbLabInstances - : null - - if (anchorEl) { - const instanceId = anchorEl.getAttribute('aria-label') - if (!instanceId) { - return - } - - let project = '' - if (data?.data) { - for (const i in data.data) { - if (parseInt(data.data[i].id, 10) === parseInt(instanceId, 10)) { - project = data.data[i].project_alias - } - } - } - - switch (action) { - case 'addclone': - this.props.history.push( - Urls.linkDbLabCloneAdd( - { org: this.props.org, project: project }, - instanceId, - ), - ) - - break - - case 'destroy': - /* eslint no-alert: 0 */ - if ( - window.confirm( - 'Are you sure you want to remove this Database Lab instance?', - ) === true - ) { - Actions.destroyDbLabInstance(auth?.token, instanceId) - } - - break - - case 'refresh': - Actions.getDbLabInstanceStatus(auth?.token, instanceId) - - break - - case 'editProject': - this.props.history.push( - Urls.linkDbLabInstanceEditProject( - { org: this.props.org, project: project }, - instanceId, - ), - ) - - break - - default: - break - } - } - }, 100) - } - - render() { - const { classes, orgPermissions, orgId } = this.props - const data = - this.state && this.state.data && this.state.data.dbLabInstances - ? this.state.data.dbLabInstances - : null - const projects = - this.state && this.state.data && this.state.data?.projects - ? this.state.data.projects - : null - const projectId = this.props.projectId ? this.props.projectId : null - const menuOpen = Boolean(this.state && this.state.anchorEl) - const title = 'Postgres Clusters' - const addPermitted = !orgPermissions || orgPermissions.dblabInstanceCreate - const deletePermitted = - !orgPermissions || orgPermissions.dblabInstanceDelete - - const addInstanceButton = ( -
- - this.setState({ modalState: { open: true, type: 'cluster' } }) - } - title={addPermitted ? 'Create new cluster' : messages.noPermission} - > - New Postgres cluster - -
- ) - const pageTitle = ( - - ) - - let projectFilter = null - if (projects && projects.data && data) { - projectFilter = ( -
- this.handleChangeProject(event)} - select - label="Name" - inputProps={{ - name: 'project', - id: 'project-filter', - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - variant="outlined" - className={classes.filterSelect} - disabled - > - All - - {projects.data.map((p) => { - return ( - - {p?.project_label_or_name || p.name} - - ) - })} - -
- ) - } - - const breadcrumbs = ( - - ) - - if (orgPermissions && !orgPermissions.dblabInstanceList) { - return ( -
- {breadcrumbs} - - {pageTitle} - - {messages.noPermissionPage} -
- ) - } - - if (this.state?.data && this.state.data?.dbLabInstances?.error) { - return ( -
- {breadcrumbs} - - - - -
- ) - } - - if ( - !data || - (data && data.isProcessing) || - data.orgId !== orgId || - data.projectId !== (projectId ? projectId : 0) - ) { - return ( -
- {breadcrumbs} - - - - -
- ) - } - - const CardsModal = () => ( - this.setState({ modalState: { open: false, type: '' } })} - aria-labelledby="simple-modal-title" - aria-describedby="simple-modal-description" - > - - - ) - - let table = ( - - ) - - let menu = null - if (true) { - table = ( - - - - - Cluster name - ID - Plan - Version - State - Created at -   - - - - - {/* {Object.keys(data.data).map((index) => { - return ( - - this.handleClick(event, data.data[index].id) - } - style={{ cursor: 'pointer' }} - > - - {data.data[index].project_label_or_name || - data.data[index].project_name} - - - - {data.data[index].id} - - - {data.data[index].state && data.data[index].url - ? data.data[index].url - : 'N/A'} - {!isHttps(data.data[index].url) && - data.data[index].url && - !data.data[index].use_tunnel ? ( - - - - ) : null} - - - {data.data[index]?.state?.cloning?.numClones ?? - data.data[index]?.state?.clones?.length ?? - 'N/A'} - - - {data.data[index] && - (data.data[index]?.plan === 'EE' - ? 'Enterprise' - : data.data[index]?.plan === 'SE' - ? 'Standard' - : data.data[index]?.plan)} - - - {getVersionDigits( - data.data[index] && - (data.data[index].state?.engine?.version as string), - )} - - - {data.data[index].state && data.data[index].url ? ( - - ) : ( - 'N/A' - )} - - - - - {format.formatTimestampUtc( - data.data[index].created_at, - ) ?? ''} - - - - - {data.data[index].isProcessing || - (this.state.data?.dbLabInstanceStatus.instanceId === - index && - this.state.data.dbLabInstanceStatus.isProcessing) ? ( - - ) : null} - - - - - - ) - })} */} - -
-
- ) - - const selectedInstance = Object.values(data.data).filter((item) => { - const anchorElLabel = this.state.anchorEl?.getAttribute('aria-label') - // eslint-disable-next-line eqeqeq - return anchorElLabel && item.id == anchorElLabel - })[0] - - menu = ( - - this.menuHandler(event, 'editProject')} - disabled={!addPermitted || selectedInstance?.plan === 'SE'} - > - Edit - - this.menuHandler(event, 'addclone')} - disabled={selectedInstance?.plan === 'SE'} - > - Create clone - - this.menuHandler(event, 'refresh')} - disabled={selectedInstance?.plan === 'SE'} - > - Refresh - - this.menuHandler(event, 'destroy')} - > - Remove from List - - - ) - } - - return ( -
- {breadcrumbs} - - {pageTitle} - - {projectFilter} - - {table} - - {menu} - - {this.state.modalState && } -
- ) - } -} - -export default PostgresClusters diff --git a/ui/packages/platform/src/components/PostgresClusters/PostgresClustersWrapper.tsx b/ui/packages/platform/src/components/PostgresClusters/PostgresClustersWrapper.tsx deleted file mode 100644 index 510e4ee5..00000000 --- a/ui/packages/platform/src/components/PostgresClusters/PostgresClustersWrapper.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { styles } from '@postgres.ai/shared/styles/styles' -import { RouteComponentProps } from 'react-router' -import { colors } from '@postgres.ai/shared/styles/colors' -import PostgresClusters from './PostgresClusters' -import { OrgPermissions } from 'components/types' - -export interface DbLabInstancesProps { - orgId: number - org: string | number - project: string | undefined - projectId: string | number | undefined - history: RouteComponentProps['history'] - match: { - params: { - project?: string - projectId?: string | number | undefined - org?: string - } - } - orgPermissions: OrgPermissions -} - -export const PostgresClustersWrapper = (props: DbLabInstancesProps) => { - const useStyles = makeStyles( - { - root: { - ...(styles.root as Object), - display: 'flex', - flexDirection: 'column', - }, - filterSelect: { - ...styles.inputField, - width: 150, - }, - cell: { - '& > a': { - color: 'black', - textDecoration: 'none', - }, - '& > a:hover': { - color: 'black', - textDecoration: 'none', - }, - }, - inTableProgress: { - width: '30px!important', - height: '30px!important', - }, - warningIcon: { - color: colors.state.warning, - fontSize: '1.2em', - position: 'absolute', - marginLeft: 5, - }, - tooltip: { - fontSize: '10px!important', - }, - timeLabel: { - lineHeight: '16px', - fontSize: 12, - cursor: 'pointer', - }, - buttonContainer: { - display: 'flex', - gap: 10, - }, - flexContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - height: '100%', - gap: 40, - marginTop: '20px', - - '& > div': { - maxWidth: '300px', - width: '100%', - height: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - border: '1px solid #e0e0e0', - padding: '20px', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '15px', - transition: 'border 0.3s ease-in-out', - - '&:hover': { - border: '1px solid #FF6212', - }, - }, - }, - }, - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/ProductCard/ProductCard.tsx b/ui/packages/platform/src/components/ProductCard/ProductCard.tsx deleted file mode 100644 index 668b76a4..00000000 --- a/ui/packages/platform/src/components/ProductCard/ProductCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Component } from 'react' -import clsx from 'clsx' - -import { ClassesType } from '@postgres.ai/platform/src/components/types' - -import { ProductCardProps } from 'components/ProductCard/ProductCardWrapper' - -interface ProductCardWithStylesProps extends ProductCardProps { - classes: ClassesType -} - -class ProductCard extends Component { - render() { - const { - classes, - children, - actions, - inline, - title, - icon, - style, - className, - } = this.props - - return ( -
-
-

{title}

-
{children}
-
- -
- {icon} -
- {actions?.map((a) => { - return ( - - {a.content} - - ) - })} -
-
-
- ) - } -} - -export default ProductCard diff --git a/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx b/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx deleted file mode 100644 index eb5f0647..00000000 --- a/ui/packages/platform/src/components/ProductCard/ProductCardWrapper.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { theme } from '@postgres.ai/shared/styles/theme' -import { colors } from '@postgres.ai/shared/styles/colors' -import { CSSProperties } from '@material-ui/styles' -import ProductCard from 'components/ProductCard/ProductCard' - -export interface ProductCardProps { - children?: React.ReactNode - inline: boolean - title: string - icon?: JSX.Element | string - style?: CSSProperties | undefined - className?: string - actions?: { - id: string - content: JSX.Element | string - }[] -} - -export const ProductCardWrapper = (props: ProductCardProps) => { - const useStyles = makeStyles( - (muiTheme) => ({ - root: { - '& h1': { - fontSize: '16px', - margin: '0', - }, - [muiTheme.breakpoints.down('xs')]: { - height: '100%', - }, - fontFamily: theme.typography.fontFamily, - fontSize: '14px', - border: '1px solid ' + colors.consoleStroke, - maxWidth: '450px', - minHeight: '260px', - paddingTop: '15px', - paddingBottom: '15px', - paddingLeft: '20px', - paddingRight: '20px', - alignItems: 'center', - borderRadius: '3px', - // Flexbox. - display: 'flex', - '-webkit-flex-direction': 'column', - '-ms-flex-direction': 'column', - 'flex-direction': 'column', - '-webkit-flex-wrap': 'nowrap', - '-ms-flex-wrap': 'nowrap', - 'flex-wrap': 'nowrap', - '-webkit-justify-content': 'space-between', - '-ms-flex-pack': 'justify', - 'justify-content': 'space-between', - '-webkit-align-content': 'flex-start', - '-ms-flex-line-pack': 'start', - 'align-content': 'flex-start', - '-webkit-align-items': 'flex-start', - '-ms-flex-align': 'start', - 'align-items': 'flex-start', - }, - block: { - marginBottom: '20px', - }, - icon: { - padding: '5px', - }, - actionsContainer: { - marginTop: '15px', - - [muiTheme.breakpoints.down('xs')]: { - marginTop: '5px', - }, - }, - contentContainer: { - marginTop: '15px', - }, - bottomContainer: { - width: '100%', - display: 'flex', - '-webkit-flex-direction': 'row', - '-ms-flex-direction': 'row', - 'flex-direction': 'row', - '-webkit-flex-wrap': 'wrap', - '-ms-flex-wrap': 'wrap', - 'flex-wrap': 'wrap', - '-webkit-justify-content': 'space-between', - '-ms-flex-pack': 'justify', - 'justify-content': 'space-between', - '-webkit-align-content': 'stretch', - '-ms-flex-line-pack': 'stretch', - 'align-content': 'stretch', - '-webkit-align-items': 'flex-end', - '-ms-flex-align': 'end', - 'align-items': 'flex-end', - - [muiTheme.breakpoints.down('xs')]: { - flexDirection: 'column', - alignItems: 'center', - }, - }, - buttonSpan: { - '&:not(:first-child)': { - marginLeft: '15px', - }, - - [muiTheme.breakpoints.down('xs')]: { - '&:not(:first-child)': { - marginLeft: 0, - }, - - '& button': { - marginTop: '15px', - width: '100%', - }, - }, - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/Report/Report.tsx b/ui/packages/platform/src/components/Report/Report.tsx deleted file mode 100644 index 6309b9c7..00000000 --- a/ui/packages/platform/src/components/Report/Report.tsx +++ /dev/null @@ -1,345 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Component, MouseEvent } from 'react' -import { NavLink } from 'react-router-dom' -import { - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Typography, - Button, -} from '@material-ui/core' - -import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { ClassesType, RefluxTypes } from '@postgres.ai/platform/src/components/types' - -import Store from '../../stores/store' -import Actions from '../../actions/actions' -import { ErrorWrapper } from 'components/Error/ErrorWrapper' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import Urls from '../../utils/urls' -import { ReportProps } from 'components/Report/ReportWrapper' - -interface ReportWithStylesProps extends ReportProps { - classes: ClassesType -} - -interface ReportState { - data: { - auth: { - token: string - } | null - report: { - reportId: string - type: string - isProcessing: boolean - isDownloading: boolean - error: boolean - data: { - id: number - type: string - filename: string - created_formatted: string - }[] - } | null - reports: { - error: boolean - errorMessage: string - errorCode: number - isProcessing: boolean - data: { - id: number - }[] - } | null - } | null -} - -class Report extends Component { - unsubscribe: Function - componentDidMount() { - const that = this - const reportId = this.props.match.params.reportId - const type = this.props.match.params.type - ? this.props.match.params.type - : 'md' - const orgId = this.props.orgId - - this.unsubscribe = (Store.listen as RefluxTypes["listen"]) (function () { - const auth = this.data && this.data.auth ? this.data.auth : null - const reports = this.data && this.data.reports ? this.data.reports : null - const report = this.data && this.data.report ? this.data.report : null - - if (auth && auth.token && !reports?.isProcessing && !that.state) { - Actions.getCheckupReports(auth.token, orgId, 0, reportId) - } - - if ( - auth && - auth.token && - report?.reportId !== reportId && - !report?.isProcessing && - !report?.error && - !reports?.error && - reports?.data && - reports.data[0].id - ) { - Actions.getCheckupReportFiles( - auth.token, - reportId, - type, - 'filename', - 'asc', - ) - } - - that.setState({ data: this.data }) - }) - - Actions.refresh() - } - - componentWillUnmount() { - this.unsubscribe() - } - - componentDidUpdate(prevProps: ReportProps) { - const prevType = prevProps.match.params.type - ? prevProps.match.params.type - : 'md' - const curType = this.props.match.params.type - ? this.props.match.params.type - : 'md' - const reportId = this.props.match.params.reportId - const auth = - this.state.data && this.state.data.auth ? this.state.data.auth : null - - if (prevType !== curType) { - const report = - this.state.data && this.state.data.report - ? this.state.data.report - : null - - if ( - auth && - report !== null && - auth.token && - (report?.reportId !== reportId || report.type !== curType) && - !report.isProcessing && - !report.error - ) { - Actions.getCheckupReportFiles( - auth.token, - reportId, - curType, - 'filename', - 'asc', - ) - } - } - } - - handleClick = ( - _: MouseEvent, - id: number, - type: string, - ) => { - this.props.history.push(this.getReportFileLink(id, type)) - } - - getReportFileLink(id: number, type: string) { - const reportId = this.props.match.params.reportId - - return Urls.linkReportFile(this.props, reportId, id, type) - } - - downloadJsonFiles = () => { - const auth = - this.state && this.state.data && this.state.data.auth - ? this.state.data.auth - : null - const reportId = this.props.match.params.reportId - - Actions.downloadReportJsonFiles(auth?.token, reportId) - } - - render() { - const { classes } = this.props - const data = - this.state && this.state.data && this.state.data.report - ? this.state.data.report - : null - let reportId = this.props.match.params.reportId - const type = this.props.match.params.type - ? this.props.match.params.type - : 'md' - - let breadcrumbs = ( - - ) - - let menu = null - if (type === 'md') { - menu = ( -
-
- - Markdown files - - - JSON files - -
-
- ) - } - - if (type === 'json') { - menu = ( -
-
- - Markdown files - - - JSON files - -
- - -
- ) - } - - let errorWidget = null - if (this.state && this.state.data?.reports?.error) { - errorWidget = ( - - ) - } - - if (this.state && this.state.data && this.state.data?.report?.error) { - errorWidget = - } - - if (errorWidget) { - return ( -
- {breadcrumbs} - - {errorWidget} -
- ) - } - - if ( - !data || - (data && data.isProcessing) || - (data && data.reportId !== reportId) - ) { - return ( -
- {breadcrumbs} - - -
- ) - } - - return ( -
- {breadcrumbs} - - {menu} - - {data.data && data.data.length > 0 ? ( - - - - - Filename - Type - Created - - - - {data.data.map((f) => { - return ( - this.handleClick(event, f.id, f.type)} - style={{ cursor: 'pointer' }} - > - - - {f.filename} - - - {f.type} - - {f.created_formatted} - - - ) - })} - -
-
- ) : ( - 'This report does not contain any files.' - )} - -
-
- ) - } -} - -export default Report diff --git a/ui/packages/platform/src/components/Report/ReportWrapper.tsx b/ui/packages/platform/src/components/Report/ReportWrapper.tsx deleted file mode 100644 index f3153398..00000000 --- a/ui/packages/platform/src/components/Report/ReportWrapper.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { styles } from '@postgres.ai/shared/styles/styles' -import { RouteComponentProps } from 'react-router' -import Report from 'components/Report/Report' - -interface MatchParams { - reportId: string - type?: string -} - -export interface ReportProps extends RouteComponentProps { - orgId: number -} -export const ReportWrapper = (props: ReportProps) => { - const useStyles = makeStyles( - { - root: { - ...(styles.root as Object), - flex: '1 1 100%', - display: 'flex', - flexDirection: 'column', - }, - cell: { - '& > a': { - color: 'black', - textDecoration: 'none', - }, - '& > a:hover': { - color: 'black', - textDecoration: 'none', - }, - }, - horizontalMenuItem: { - display: 'inline-block', - marginRight: 10, - 'font-weight': 'normal', - color: 'black', - '&:visited': { - color: 'black', - }, - }, - activeHorizontalMenuItem: { - display: 'inline-block', - marginRight: 10, - 'font-weight': 'bold', - color: 'black', - }, - fileTypeMenu: { - marginBottom: 10, - }, - bottomSpace: { - ...styles.bottomSpace, - }, - }, - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/ReportFile/ReportFile.tsx b/ui/packages/platform/src/components/ReportFile/ReportFile.tsx deleted file mode 100644 index 6614b0b9..00000000 --- a/ui/packages/platform/src/components/ReportFile/ReportFile.tsx +++ /dev/null @@ -1,428 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Component, HTMLAttributeAnchorTarget } from 'react' -import { Button, TextField } from '@material-ui/core' -import ReactMarkdown from 'react-markdown' -import rehypeRaw from 'rehype-raw' -import remarkGfm from 'remark-gfm' - -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { ClassesType, RefluxTypes } from '@postgres.ai/platform/src/components/types' - -import Store from '../../stores/store' -import Actions from '../../actions/actions' -import { ErrorWrapper } from 'components/Error/ErrorWrapper' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import Urls from '../../utils/urls' -import { ReportFileProps } from 'components/ReportFile/ReportFileWrapper' - -interface LinkProps { - href: string | HTMLAttributeAnchorTarget - target: string - children: React.ReactNode -} - -interface ReportFileWithStylesProps extends ReportFileProps { - classes: ClassesType -} - -interface ReportFileState { - data: { - auth: { - token: string - } | null - reportFile: { - isProcessing: boolean - error: boolean - errorCode: number - errorMessage: string - files: { - [file: string]: { - filename: string - text: string - data: string - } - } - } | null - report: { - isProcessing: boolean - data: { - id: number - }[] - } | null - } | null -} - -const mdViewerStyles = ( - -) - -const textAreaStyles = ( - -) - -class ReportFile extends Component { - getFileId() { - let fileID = this.props.match.params.fileId - let parseID = parseInt(fileID, 10) - - // To distinct different fileIDs. For example, "72215" and "1_1.sql". - // {ORG_URL}/reports/268/files/72215/md - // {ORG_URL}/reports/268/files/1_1.sql/sql?raw - return (fileID.toString() == parseID.toString()) ? parseID : fileID - } - - componentDidMount() { - const that = this - const projectId = this.props.projectId - const reportId = parseInt(this.props.match.params.reportId, 10) - const type = this.props.match.params.fileType - const id = this.getFileId() - const fileId = type.toLowerCase() + '_' + id - - this.unsubscribe = (Store.listen as RefluxTypes["listen"]) (function () { - const auth = this.data && this.data.auth ? this.data.auth : null - const reportFile = - this.data && this.data.reportFile ? this.data.reportFile : null - const report = this.data && this.data.report ? this.data.report : null - - that.setState({ data: this.data }) - - if ( - auth && - auth.token && - !reportFile?.files[fileId] && - !reportFile?.isProcessing && - !reportFile?.error - ) { - Actions.getCheckupReportFile(auth.token, projectId, reportId, id, type) - } - - if (auth && auth.token && !report?.isProcessing && !report?.data) { - Actions.getCheckupReportFiles( - auth.token, - reportId, - 'md', - 'filename', - 'asc', - ) - } - }) - - Actions.refresh() - } - - unsubscribe: Function - componentWillUnmount() { - this.unsubscribe() - } - - downloadFile(fileId: string) { - let type = this.props.match.params.fileType - const data = - this.state.data && this.state.data.reportFile - ? this.state.data.reportFile - : null - const fileName = data?.files[fileId].filename - let content = data?.files[fileId].text - ? data.files[fileId].text - : (data?.files[fileId].data as string) - - if (type === 'json') { - let jsonData = null - try { - jsonData = JSON.parse(content) - content = JSON.stringify(jsonData, null, '\t') - } catch (err) { - content = '' - console.log(err) - } - } - - if (content) { - let a: HTMLAnchorElement = document.createElement('a') - let file = new Blob([content], { type: 'application/json' }) - a.href = URL.createObjectURL(file) - a.download = fileName as string - a.click() - } - - return false - } - - markdownLink(linkProps: LinkProps) { - const { href, target, children } = linkProps - const reportId = this.props.match.params.reportId - let fileName = null - - let match = href.match(/..\/..\/json_reports\/(.*)\/K_query_groups\/(.*)/) - if (match && match.length > 2) { - fileName = match[2] - } - - if (fileName) { - return ( - - {children} - - ) - } - - if (!href.startsWith('#')) { - // Show external link in new tab - return ( - - {children} - - ) - } - - return ( - - {children} - - ) - } - - copyFile() { - let copyText = document.getElementById('fileContent') as HTMLInputElement - if (copyText) { - copyText.select() - copyText.setSelectionRange(0, 99999) - document.execCommand('copy') - copyText.setSelectionRange(0, 0) - } - } - - render() { - const that = this - const { classes } = this.props - const data = - this.state && this.state.data && this.state.data.reportFile - ? this.state.data.reportFile - : null - const reportId = this.props.match.params.reportId - const id = this.getFileId() - const type = this.props.match.params.fileType - const fileId: string = type.toLowerCase() + '_' + id - const raw = this.props.raw - - const breadcrumbs = ( - - ) - - if (this.state && this.state.data && this.state.data?.reportFile?.error) { - return ( -
- {!raw && breadcrumbs} - - -
- ) - } - - if (!data || (data && data.isProcessing) || (data && !data.files[fileId])) { - return ( - <> - {!raw && breadcrumbs} - - - - ) - } - - let fileContent = null - let fileLink = null - let copyBtn = null - if (type === 'md') { - fileContent = ( -
- {mdViewerStyles} - - { - return that.markdownLink(props as LinkProps) - }, - }} - /> -
- ) - } else { - let content = data.files[fileId].text - ? data.files[fileId].text - : data.files[fileId].data - - if (type === 'json') { - let jsonData = null - - try { - jsonData = JSON.parse(content) - content = JSON.stringify(jsonData, null, '\t') - } catch (err) { - console.log(err) - } - } - - fileContent = ( -
- {textAreaStyles} - - -
- ) - - fileLink = ( - - ) - - copyBtn = ( - - ) - } - - return ( -
-
- {!raw && breadcrumbs} - - {fileLink} - {raw && copyBtn} - - {fileContent} -
- -
-
- ) - } -} - -export default ReportFile diff --git a/ui/packages/platform/src/components/ReportFile/ReportFileWrapper.tsx b/ui/packages/platform/src/components/ReportFile/ReportFileWrapper.tsx deleted file mode 100644 index e221104d..00000000 --- a/ui/packages/platform/src/components/ReportFile/ReportFileWrapper.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { styles } from '@postgres.ai/shared/styles/styles' -import { RouteComponentProps } from 'react-router' -import ReportFile from 'components/ReportFile/ReportFile' - -interface MatchParams { - fileId: string - fileType: string - reportId: string -} - -export interface ReportFileProps extends RouteComponentProps { - projectId: string | number | undefined - reportId: string - fileType: string - raw?: boolean -} - -export const ReportFileWrapper = (props: ReportFileProps) => { - const useStyles = makeStyles( - (theme) => ({ - root: { - width: '100%', - [theme.breakpoints.down('sm')]: { - maxWidth: '100vw', - }, - [theme.breakpoints.up('md')]: { - maxWidth: 'calc(100vw - 240px)', - }, - [theme.breakpoints.up('lg')]: { - maxWidth: 'calc(100vw - 240px)', - }, - minHeight: '100%', - zIndex: 1, - position: 'relative', - }, - reportFileContent: { - border: '1px solid silver', - margin: 5, - padding: 5, - }, - code: { - width: '100%', - 'background-color': 'rgb(246, 248, 250)', - '& > div > textarea': { - fontFamily: - '"Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",' + - ' "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace', - color: 'black', - fontSize: 14, - }, - }, - rawCode: { - width: '100%', - 'background-color': 'none', - 'margin-top': '10px', - padding: '0px', - '& > .MuiOutlinedInput-multiline': { - padding: '0px!important', - }, - '& > div > textarea': { - fontFamily: - '"Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",' + - ' "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace', - color: 'black', - fontSize: 14, - }, - '& > .MuiInputBase-fullWidth > fieldset': { - borderWidth: 'none!important', - borderStyle: 'none!important', - borderRadius: '0px!important', - }, - }, - bottomSpace: { - ...styles.bottomSpace, - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/Reports/Reports.tsx b/ui/packages/platform/src/components/Reports/Reports.tsx deleted file mode 100644 index 1bf26489..00000000 --- a/ui/packages/platform/src/components/Reports/Reports.tsx +++ /dev/null @@ -1,586 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { Component, MouseEvent } from 'react' -import { NavLink } from 'react-router-dom' -import { - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, - MenuItem, - Button, - Checkbox, -} from '@material-ui/core' - -import { HorizontalScrollContainer } from '@postgres.ai/shared/components/HorizontalScrollContainer' -import { StubContainer } from '@postgres.ai/shared/components/StubContainer' -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { styles } from '@postgres.ai/shared/styles/styles' -import { icons } from '@postgres.ai/shared/styles/icons' -import { GatewayLink } from '@postgres.ai/shared/components/GatewayLink' -import { - ClassesType, - ProjectProps, - RefluxTypes, -} from '@postgres.ai/platform/src/components/types' - -import Store from '../../stores/store' -import Actions from '../../actions/actions' -import { ErrorWrapper } from 'components/Error/ErrorWrapper' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import ConsolePageTitle from './../ConsolePageTitle' -import { messages } from '../../assets/messages' -import { getProjectAliasById, ProjectDataType } from 'utils/aliases' -import { ConsoleButtonWrapper } from 'components/ConsoleButton/ConsoleButtonWrapper' -import { ProductCardWrapper } from 'components/ProductCard/ProductCardWrapper' -import { ReportsProps } from 'components/Reports/ReportsWrapper' - -interface ReportsWithStylesProps extends ReportsProps { - classes: ClassesType -} - -interface ReportsType { - error: boolean - errorMessage: string - errorCode: number | null - orgId: number | null - projectId: string | number | undefined - isDeleting: boolean - isProcessing: boolean - data: { - id: number - project_id: string - created_formatted: string - project_name: string - project_label_or_name: string - project_label: string - epoch: string - }[] -} - -interface ReportsState { - data: { - auth: { - token: string - } | null - userProfile: { - data: { - orgs: ProjectDataType - } - } | null - reports: ReportsType | null - projects: Omit - } | null - selectedRows: { - [rows: number]: number | boolean - } -} - -class Reports extends Component { - state = { - data: { - auth: { - token: '', - }, - userProfile: { - data: { - orgs: {}, - }, - }, - reports: { - error: false, - errorMessage: '', - errorCode: null, - orgId: null, - projectId: undefined, - isDeleting: false, - isProcessing: false, - data: [], - }, - projects: { - error: false, - isProcessing: false, - isProcessed: false, - data: [], - }, - }, - selectedRows: {}, - } - - onSelectRow(event: React.ChangeEvent, rowId: number) { - let selectedRows: ReportsState['selectedRows'] = this.state.selectedRows - - if (selectedRows[rowId] && !event.target.checked) { - delete selectedRows[rowId] - } else { - selectedRows[rowId] = event.target.checked - } - - this.setState({ selectedRows: selectedRows }) - } - - onCheckBoxClick( - event: React.ChangeEvent | MouseEvent, - ) { - event.stopPropagation() - } - - onSelectAllClick( - event: React.ChangeEvent, - reports: ReportsType['data'], - ) { - if (!event.target.checked) { - this.setState({ selectedRows: {} }) - return - } - - let selectedRows: ReportsState['selectedRows'] = {} - if (selectedRows) - for (let i in reports) { - if (reports.hasOwnProperty(i)) { - selectedRows[reports[i].id] = true - } - } - - this.setState({ selectedRows: selectedRows }) - } - - deleteReports() { - const count = Object.keys(this.state.selectedRows).length - const auth = - this.state.data && this.state.data.auth ? this.state.data.auth : null - - /* eslint no-alert: 0 */ - if ( - window.confirm( - 'Are you sure you want to delete ' + count + ' report(s)?', - ) === true - ) { - let reports = [] - for (let i in this.state.selectedRows) { - if (this.state.selectedRows.hasOwnProperty(i)) { - reports.push(parseInt(i, 10)) - } - } - - Actions.deleteCheckupReports(auth?.token, reports) - this.setState({ selectedRows: {} }) - } - } - - unsubscribe: Function - componentDidMount() { - const that = this - const orgId = this.props.orgId ? this.props.orgId : null - let projectId = this.props.projectId ? this.props.projectId : null - - if (projectId) { - Actions.setReportsProject(orgId, projectId) - } else { - Actions.setReportsProject(orgId, 0) - } - - this.unsubscribe = (Store.listen as RefluxTypes["listen"]) (function () { - const auth = this.data && this.data.auth ? this.data.auth : null - const reports: ReportsType = - this.data && this.data.reports ? this.data.reports : null - const projects: Omit = - this.data && this.data.projects ? this.data.projects : null - - if ( - auth && - auth.token && - !reports.isProcessing && - !reports.error && - !that.state - ) { - Actions.getCheckupReports(auth.token, orgId, projectId) - } - - if ( - auth && - auth.token && - !projects.isProcessing && - !projects.error && - !that?.state && - !that?.state?.data - ) { - Actions.getProjects(auth.token, orgId) - } - - that.setState({ data: this.data }) - }) - Actions.refresh() - } - - componentWillUnmount() { - this.unsubscribe() - } - - handleClick = ( - _: MouseEvent, - id: number, - projectId: string | number, - ) => { - const url = this.getReportLink(id, projectId) - - if (url) { - this.props.history.push(url) - } - } - - getReportLink(id: number, projectId: string | number) { - const org = this.props.org ? this.props.org : null - const project = this.props.project ? this.props.project : null - let projectAlias = project - - if ( - !projectAlias && - org && - this.state.data && - this.state.data.userProfile?.data.orgs - ) { - projectAlias = getProjectAliasById( - this.state.data.userProfile.data.orgs, - projectId, - ) - } - - if (org && projectAlias) { - return '/' + org + '/' + projectAlias + '/reports/' + id - } - - return null - } - - handleChangeProject = ( - event: React.ChangeEvent, - ) => { - const org = this.props.org ? this.props.org : null - const orgId = this.props.orgId ? this.props.orgId : null - const projectId = event.target.value - let projectAlias = null - - if (org && this.state.data && this.state.data.userProfile?.data.orgs) { - projectAlias = getProjectAliasById( - this.state.data.userProfile.data.orgs, - projectId, - ) - } - - Actions.setReportsProject(orgId, event.target.value) - if (org && this.props.history) { - if (Number(event.target.value) !== 0 && projectId && projectAlias) { - this.props.history.push('/' + org + '/' + projectAlias + '/reports/') - } else { - this.props.history.push('/' + org + '/reports/') - } - } - } - - render() { - const org = this.props.org ? this.props.org : null - const { classes, orgId } = this.props - const data = - this.state && this.state.data && this.state.data.reports - ? this.state.data.reports - : null - const projects = - this.state && this.state.data && this.state.data.projects - ? this.state.data.projects - : null - let projectId = this.props.projectId ? this.props.projectId : null - let projectFilter = null - - const addAgentButton = ( - this.props.history.push('/' + org + '/checkup-config')} - title={ - this.props.orgPermissions?.checkupReportConfigure - ? 'Start generating new reports for your Postgres servers' - : messages.noPermission - } - > - Generate report - - ) - - const pageTitle = ( - - ) - - if (projects && projects.data && data) { - projectFilter = ( -
- this.handleChangeProject(event)} - select - label="Project" - inputProps={{ - name: 'project', - id: 'project-filter', - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - variant="outlined" - className={classes.filterSelect} - > - All - - {projects.data.map( - (p: { - id: number - name: string - label: string - project_label_or_name: string - }) => { - return ( - - {p.project_label_or_name || p.label || p.name} - - ) - }, - )} - -
- ) - } - - let breadcrumbs = ( - - ) - - if (this.state && this.state.data && this.state.data.reports?.error) { - return ( -
- {breadcrumbs} - - {pageTitle} - - -
- ) - } - - if ( - !data || - (data && data.isProcessing) || - data.orgId !== orgId || - data.projectId !== (projectId ? projectId : 0) - ) { - return ( -
- {breadcrumbs} - - {pageTitle} - - -
- ) - } - - const emptyListTitle = projectId - ? 'There are no uploaded checkup reports in this project yet' - : 'There are no uploaded checkup reports' - - let reports = ( - - -

- Automated routine checkup for your PostgreSQL databases. Configure - Checkup agent to start collecting reports ( - - Learn more - - ). -

-
-
- ) - - if (data && data.data && data.data.length > 0) { - reports = ( -
- {this.props.orgPermissions?.checkupReportDelete ? ( -
- {data.isDeleting ? ( - Processing... - ) : ( -
- {Object.keys(this.state.selectedRows).length > 0 ? ( - - Selected: {Object.keys(this.state.selectedRows).length}{' '} - rows - - ) : ( - 'Select table rows to process them' - )} -
- )} -
- -
-
- ) : null} - - - - - {this.props.orgPermissions?.checkupReportDelete ? ( - - 0 - } - checked={ - Object.keys(this.state.selectedRows).length === - data.data.length - } - onChange={(event) => - this.onSelectAllClick(event, data.data) - } - onClick={(event) => this.onCheckBoxClick(event)} - /> - - ) : null} - Report # - Project - Created - Epoch - - - - {data.data.map( - (r: { - id: number - project_id: string - created_formatted: string - project_name: string - epoch: string - project_label_or_name: string - project_label: string - }) => { - return ( - - this.handleClick(event, r.id, r.project_id) - } - style={{ cursor: 'pointer' }} - > - {this.props.orgPermissions?.checkupReportDelete ? ( - - - this.onSelectRow(event, r.id) - } - onClick={(event) => this.onCheckBoxClick(event)} - /> - - ) : null} - - - {r.id} - - - - {r.project_label_or_name || - r.project_label || - r.project_name} - - - {r.created_formatted} - - - {r.epoch} - - - ) - }, - )} - -
-
-
- ) - } - - return ( -
- {breadcrumbs} - - {pageTitle} - - {projectFilter} - - {reports} -
- ) - } -} - -export default Reports diff --git a/ui/packages/platform/src/components/Reports/ReportsWrapper.tsx b/ui/packages/platform/src/components/Reports/ReportsWrapper.tsx deleted file mode 100644 index 84af32ec..00000000 --- a/ui/packages/platform/src/components/Reports/ReportsWrapper.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { styles } from '@postgres.ai/shared/styles/styles' -import { RouteComponentProps } from 'react-router' -import Reports from 'components/Reports/Reports' -import { OrgPermissions } from 'components/types' - -export interface ReportsProps extends RouteComponentProps { - projectId: string | undefined | number - project: string | undefined - orgId: number - org: string | number - orgPermissions: OrgPermissions -} - -export const ReportsWrapper = (props: ReportsProps) => { - const useStyles = makeStyles( - { - root: { - ...(styles.root as Object), - paddingBottom: '20px', - display: 'flex', - flexDirection: 'column', - }, - stubContainer: { - marginTop: '10px', - }, - filterSelect: { - ...styles.inputField, - width: 150, - }, - cell: { - '& > a': { - color: 'black', - textDecoration: 'none', - }, - '& > a:hover': { - color: 'black', - textDecoration: 'none', - }, - }, - tableHead: { - ...(styles.tableHead as Object), - }, - tableHeadActions: { - ...(styles.tableHeadActions as Object), - }, - checkboxTableCell: { - width: '30px', - }, - }, - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/SIEMIntegrationForm/SIEMIntegrationForm.tsx b/ui/packages/platform/src/components/SIEMIntegrationForm/SIEMIntegrationForm.tsx deleted file mode 100644 index 8f27fdc3..00000000 --- a/ui/packages/platform/src/components/SIEMIntegrationForm/SIEMIntegrationForm.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { TextField, Grid, IconButton } from '@mui/material'; -import { Button, makeStyles } from "@material-ui/core"; -import { styles } from "@postgres.ai/shared/styles/styles"; -import RemoveCircleOutlineIcon from '@material-ui/icons/RemoveCircleOutline'; -import { FormikErrors, useFormik } from "formik"; -import { FormValues } from "../AuditSettingsForm/AuditSettingsForm"; - -const useStyles = makeStyles({ - textField: { - ...styles.inputField, - maxWidth: 450, - }, - requestHeadersContainer: { - paddingTop: '8px!important' - }, - label: { - color: '#000!important', - margin: 0 - }, - requestHeadersTextFieldContainer: { - flexBasis: 'calc(100% / 2 - 20px)!important', - width: 'calc(100% / 2 - 20px)!important', - }, - requestHeadersIconButtonContainer: { - width: '32px!important', - height: '32px!important', - padding: '0!important', - marginLeft: 'auto!important', - marginTop: '12px!important', - '& button': { - width: 'inherit', - height: 'inherit' - } - } -}) - -interface SIEMIntegrationFormProps { - formik: ReturnType>; - disabled: boolean -} - -export const SIEMIntegrationForm: React.FC = ({ formik, disabled }) => { - const classes = useStyles(); - const [isFocused, setIsFocused] = useState(false); - const [focusedHeaderIndex, setFocusedHeaderIndex] = useState(null); - - const getTruncatedUrl = (url: string) => { - const parts = url.split('/'); - return parts.length > 3 ? parts.slice(0, 3).join('/') + '/*****/' : url; - }; - - const handleHeaderValueDisplay = (index: number, value: string) => { - if (focusedHeaderIndex === index) { - return value; - } - if (value.length) { - return "*****"; - } else { - return '' - } - }; - - const handleFocusHeaderValue = (index: number) => setFocusedHeaderIndex(index); - const handleBlurHeaderValue = () => setFocusedHeaderIndex(null); - - const handleFocus = () => setIsFocused(true); - const handleBlur = () => setIsFocused(false); - - const handleHeaderChange = (index: number, field: 'key' | 'value', value: string) => { - const headers = formik.values.siemSettings.headers || []; - const updatedHeaders = [...headers]; - updatedHeaders[index] = { - ...updatedHeaders[index], - [field]: value, - }; - formik.setFieldValue('siemSettings.headers', updatedHeaders); - }; - - const addHeader = () => { - const headers = formik.values.siemSettings.headers || []; - const updatedHeaders = [...headers, { key: '', value: '' }]; - formik.setFieldValue('siemSettings.headers', updatedHeaders); - }; - - const removeHeader = (index: number) => { - const updatedHeaders = formik.values.siemSettings?.headers?.filter((_, i) => i !== index); - formik.setFieldValue('siemSettings.headers', updatedHeaders); - }; - - return ( - - - formik.setFieldValue('siemSettings.urlSchema', e.target.value)} - onFocus={handleFocus} - onBlur={(e) => { - formik.handleBlur(e); - handleBlur(); - }} - margin="normal" - fullWidth - placeholder="https://{siem-host}/{path}" - inputProps={{ - name: 'siemSettings.urlSchema', - id: 'urlSchemaTextField', - shrink: 'true', - }} - InputLabelProps={{ - shrink: true, - }} - disabled={disabled} - error={formik.touched.siemSettings?.urlSchema && !!formik.errors.siemSettings?.urlSchema} - helperText={formik.touched.siemSettings?.urlSchema && formik.errors.siemSettings?.urlSchema} - /> - - -

Request headers

- {formik.values.siemSettings.headers.map((header, index) => ( - - - handleHeaderChange(index, 'key', e.target.value)} - placeholder="Authorization" - inputProps={{ - name: `siemSettings.headers[${index}].key`, - id: `requestHeaderKeyField${index}`, - shrink: 'true', - }} - InputLabelProps={{ - shrink: true, - }} - margin="normal" - disabled={disabled} - /> - - - handleHeaderChange(index, 'value', e.target.value)} - onFocus={() => handleFocusHeaderValue(index)} - onBlur={handleBlurHeaderValue} - placeholder="token" - inputProps={{ - name: `siemSettings.headers[${index}].value`, - id: `requestHeaderValueField${index}`, - shrink: 'true', - }} - InputLabelProps={{ - shrink: true, - }} - margin="normal" - disabled={disabled} - /> - - - removeHeader(index)} disabled={disabled}> - - - - - ))} - -
-
- ); -}; \ No newline at end of file diff --git a/ui/packages/platform/src/components/ShareUrlDialog/ShareUrlDialog.tsx b/ui/packages/platform/src/components/ShareUrlDialog/ShareUrlDialog.tsx deleted file mode 100644 index 109de175..00000000 --- a/ui/packages/platform/src/components/ShareUrlDialog/ShareUrlDialog.tsx +++ /dev/null @@ -1,330 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { Component } from 'react' -import { - IconButton, - TextField, - Dialog, - Typography, - Radio, - RadioGroup, - FormControlLabel, - Button, - makeStyles, -} from '@material-ui/core' -import MuiDialogTitle from '@material-ui/core/DialogTitle' -import MuiDialogContent from '@material-ui/core/DialogContent' -import MuiDialogActions from '@material-ui/core/DialogActions' - -import { styles } from '@postgres.ai/shared/styles/styles' -import { icons } from '@postgres.ai/shared/styles/icons' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { ClassesType, RefluxTypes } from '@postgres.ai/platform/src/components/types' - -import Store from '../../stores/store' -import Actions from '../../actions/actions' -import Urls from '../../utils/urls' - -interface ShareUrlDialogProps { - classes: ClassesType -} - -interface DialogTitleProps { - id: string - children: React.ReactNode - onClose: () => void -} - -interface ShareUrlDialogState { - data: { - shareUrl: { - shared: string | null - uuid: string | null - remark: string | null - isProcessed: boolean - open: boolean - isRemoving: boolean - isAdding: boolean - data: { - uuid: string | null - } | null - } - } | null - shared: string | null - uuid: string | null -} - -const DialogTitle = (props: DialogTitleProps) => { - const useStyles = makeStyles( - (theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, - dialogTitle: { - fontSize: 16, - lineHeight: '19px', - fontWeight: 600, - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - const { children, onClose, ...other } = props - return ( - - {children} - {onClose ? ( - - {icons.closeIcon} - - ) : null} - - ) -} - -const DialogContent = (props: { children: React.ReactNode }) => { - const useStyles = makeStyles( - (theme) => ({ - dialogContent: { - paddingTop: 10, - padding: theme.spacing(2), - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - return ( - - {props.children} - - ) -} - -const DialogActions = (props: { children: React.ReactNode }) => { - const useStyles = makeStyles( - (theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - return ( - - {props.children} - - ) -} - -class ShareUrlDialog extends Component< - ShareUrlDialogProps, - ShareUrlDialogState -> { - state: ShareUrlDialogState = { - shared: null, - uuid: null, - data: { - shareUrl: { - shared: null, - uuid: null, - remark: null, - isProcessed: false, - isRemoving: false, - isAdding: false, - open: false, - data: null, - }, - }, - } - - unsubscribe: Function - componentDidMount() { - const that = this - // const { url_type, url_id } = this.props; - - this.unsubscribe = (Store.listen as RefluxTypes["listen"]) (function () { - let stateData = { data: this.data, shared: '', uuid: '' } - - if (this.data.shareUrl.isAdding) { - return - } - - if (this.data.shareUrl.data && this.data.shareUrl.data.uuid) { - stateData.shared = 'shared' - stateData.uuid = this.data.shareUrl.data.uuid - } else { - stateData.shared = 'default' - stateData.uuid = this.data.shareUrl.uuid - } - - that.setState(stateData) - }) - - Actions.refresh() - } - - componentWillUnmount() { - this.unsubscribe() - } - - copyUrl = () => { - let copyText = document.getElementById('sharedUrl') as HTMLInputElement - - if (copyText) { - copyText.select() - copyText.setSelectionRange(0, 99999) - document.execCommand('copy') - } - } - - closeShareDialog = (close: boolean, save: boolean) => { - Actions.closeShareUrlDialog(close, save, this.state.shared === 'shared') - if (close) { - this.setState({ data: null, shared: null }) - } - } - - render() { - const { classes } = this.props - const shareUrl = - this.state && this.state.data && this.state.data.shareUrl - ? this.state.data.shareUrl - : null - - if ( - !shareUrl || - (shareUrl && !shareUrl.isProcessed) || - (shareUrl && !shareUrl.open && !shareUrl.data) || - this.state.shared === null - ) { - return null - } - - const urlField = ( -
- - - - {icons.copyIcon} - -
- ) - - return ( - this.closeShareDialog(true, false)} - aria-labelledby="customized-dialog-title" - open={shareUrl.open} - className={classes.dialog} - > - this.closeShareDialog(true, false)} - > - Share - - - { - this.setState({ shared: event.target.value }) - }} - className={classes.radioLabel} - > - } - label="Only members of the organization can view" - /> - - } - label="Anyone with a special link and members of thr organization can view" - /> - - {shareUrl.remark && ( - - {icons.warningIcon} - {shareUrl.remark} - - )} - {this.state.shared === 'shared' && ( -
{urlField}
- )} -
- - - - -
- ) - } -} - -export default ShareUrlDialog diff --git a/ui/packages/platform/src/components/ShareUrlDialog/ShareUrlDialogWrapper.tsx b/ui/packages/platform/src/components/ShareUrlDialog/ShareUrlDialogWrapper.tsx deleted file mode 100644 index a1dbaefc..00000000 --- a/ui/packages/platform/src/components/ShareUrlDialog/ShareUrlDialogWrapper.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { styles } from '@postgres.ai/shared/styles/styles' -import { colors } from '@postgres.ai/shared/styles/colors' -import ShareUrlDialog from 'components/ShareUrlDialog/ShareUrlDialog' - -export const ShareUrlDialogWrapper = () => { - const useStyles = makeStyles( - () => ({ - textField: { - ...styles.inputField, - marginTop: '0px', - width: 480, - }, - copyButton: { - marginTop: '-3px', - fontSize: '20px', - }, - dialog: {}, - remark: { - fontSize: 12, - lineHeight: '12px', - color: colors.state.warning, - paddingLeft: 20, - }, - remarkIcon: { - display: 'block', - height: '20px', - width: '22px', - float: 'left', - paddingTop: '5px', - }, - urlContainer: { - marginTop: 10, - paddingLeft: 22, - }, - radioLabel: { - fontSize: 12, - }, - dialogContent: { - paddingTop: 10, - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/SharedUrl/SharedUrl.tsx b/ui/packages/platform/src/components/SharedUrl/SharedUrl.tsx deleted file mode 100644 index 4352090b..00000000 --- a/ui/packages/platform/src/components/SharedUrl/SharedUrl.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Component } from 'react' -import { Button } from '@material-ui/core' - -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner' -import { icons } from '@postgres.ai/shared/styles/icons' -import { ClassesType, RefluxTypes } from '@postgres.ai/platform/src/components/types' - -import { JoeSessionCommandWrapper } from 'pages/JoeSessionCommand/JoeSessionCommandWrapper' -import Actions from '../../actions/actions' -import { ErrorWrapper } from 'components/Error/ErrorWrapper' -import Store from '../../stores/store' -import settings from '../../utils/settings' -import { SharedUrlProps } from 'components/SharedUrl/SharedUrlWrapper' - -interface SharedUrlWithStylesProps extends SharedUrlProps { - classes: ClassesType -} - -interface SharedUrlState { - signUpBannerClosed: boolean - data: { - sharedUrlData: { - isProcessing: boolean - isProcessed: boolean - error: boolean - data: { - url: { - object_type: string | null - object_id: number | null - } - url_data: { - joe_session_id: number | null - } - } - } | null - userProfile: { data: Object | null } | null - } | null -} - -const SIGN_UP_BANNER_PARAM = 'signUpBannerClosed' - -class SharedUrl extends Component { - state = { - signUpBannerClosed: localStorage.getItem(SIGN_UP_BANNER_PARAM) === '1', - data: { - sharedUrlData: { - isProcessing: false, - isProcessed: false, - error: false, - data: { - url: { - object_type: null, - object_id: null, - }, - url_data: { joe_session_id: null }, - }, - }, - userProfile: { - data: null, - }, - }, - } - - unsubscribe: Function - componentDidMount() { - const that = this - const uuid = this.props.match.params.url_uuid - - this.unsubscribe = (Store.listen as RefluxTypes["listen"]) (function () { - const sharedUrlData = - this.data && this.data.sharedUrlData ? this.data.sharedUrlData : null - - that.setState({ data: this.data }) - - if ( - !sharedUrlData?.isProcessing && - !sharedUrlData?.error && - !sharedUrlData?.isProcessed - ) { - Actions.getSharedUrlData(uuid) - } - }) - - Actions.refresh() - } - - componentWillUnmount() { - this.unsubscribe() - } - - closeBanner = () => { - localStorage.setItem(SIGN_UP_BANNER_PARAM, '1') - this.setState({ signUpBannerClosed: true }) - } - - signUp = () => { - window.open(settings.signinUrl, '_blank') - } - - render() { - const { classes } = this.props - const data = - this.state && this.state.data && this.state.data.sharedUrlData - ? this.state.data.sharedUrlData - : null - const env = - this.state && this.state.data ? this.state.data.userProfile : null - const showBanner = !this.state.signUpBannerClosed - - if (!data || (data && (data.isProcessing || !data.isProcessed))) { - return ( - <> - - - ) - } - - if (data && data.isProcessed && data.error) { - return ( - <> - - - ) - } - - let page = null - if (data?.data?.url && data.data.url?.object_type === 'command') { - const customProps = { - commandId: data.data.url.object_id, - sessionId: data.data.url_data.joe_session_id, - } - page = - } - - let banner = null - if (!env || (env && !env.data)) { - banner = ( -
- Boost your development process with  - - Postgres.ai Platform - -   - - - {icons.bannerCloseIcon} - -
- ) - } - - return ( - <> - {page} - {showBanner && banner} - - ) - } -} - -export default SharedUrl diff --git a/ui/packages/platform/src/components/SharedUrl/SharedUrlWrapper.tsx b/ui/packages/platform/src/components/SharedUrl/SharedUrlWrapper.tsx deleted file mode 100644 index 7028d241..00000000 --- a/ui/packages/platform/src/components/SharedUrl/SharedUrlWrapper.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import SharedUrl from 'components/SharedUrl/SharedUrl' -import { RouteComponentProps } from 'react-router' - -interface MatchProps { - url_uuid: string -} -export interface SharedUrlProps extends RouteComponentProps {} - -export const SharedUrlWrapper = (props: SharedUrlProps) => { - const useStyles = makeStyles( - (theme) => ({ - container: { - display: 'flex', - flexWrap: 'wrap', - }, - textField: { - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), - width: '80%', - }, - dense: { - marginTop: 16, - }, - menu: { - width: 200, - }, - updateButtonContainer: { - marginTop: 20, - textAlign: 'right', - }, - errorMessage: { - color: 'red', - }, - orgsHeader: { - position: 'relative', - }, - newOrgBtn: { - position: 'absolute', - top: 0, - right: 10, - }, - banner: { - height: 50, - position: 'absolute', - left: 0, - bottom: 0, - right: 0, - backgroundColor: 'rgba(1, 58, 68, 0.8)', - color: 'white', - zIndex: 100, - fontSize: 18, - lineHeight: '50px', - paddingLeft: 20, - '& a': { - color: 'white', - fontWeight: '600', - }, - '& svg': { - position: 'absolute', - right: 18, - top: 18, - cursor: 'pointer', - }, - }, - signUpButton: { - backgroundColor: 'white', - fontWeight: 600, - marginLeft: 10, - '&:hover': { - backgroundColor: '#ecf6f7', - }, - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/SideNav/index.tsx b/ui/packages/platform/src/components/SideNav/index.tsx deleted file mode 100644 index bf17ac4e..00000000 --- a/ui/packages/platform/src/components/SideNav/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { NavLink } from 'react-router-dom' -import { ListItem, List } from '@material-ui/core' -import { makeStyles } from '@material-ui/styles' - -import { colors } from '@postgres.ai/shared/styles/colors' - -import { ROUTES } from 'config/routes' - -const useStyles = makeStyles( - { - listItem: { - padding: 0, - }, - navLink: { - textDecoration: 'none', - color: '#000000', - fontWeight: 'bold', - fontSize: '14px', - width: '100%', - padding: '12px 14px', - }, - activeNavLink: { - backgroundColor: colors.consoleStroke, - }, - }, - { index: 1 }, -) - -export const SideNav = () => { - const classes = useStyles() - - return ( - - - - Organizations - - - - - Profile - - - - ) -} diff --git a/ui/packages/platform/src/components/StripeForm/index.tsx b/ui/packages/platform/src/components/StripeForm/index.tsx deleted file mode 100644 index 90b4c1f8..00000000 --- a/ui/packages/platform/src/components/StripeForm/index.tsx +++ /dev/null @@ -1,530 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ -import { Button, Paper, Tooltip, makeStyles } from '@material-ui/core' -import { Box } from '@mui/material' -import { formatDistanceToNowStrict } from 'date-fns' -import { ReducerAction, useCallback, useEffect, useReducer } from 'react' - -import { Link } from '@postgres.ai/shared/components/Link2' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { colors } from '@postgres.ai/shared/styles/colors' -import { stripeStyles } from 'components/StripeForm/stripeStyles' -import { ROUTES } from 'config/routes' - -import { getPaymentMethods } from 'api/billing/getPaymentMethods' -import { getSubscription } from 'api/billing/getSubscription' -import { startBillingSession } from 'api/billing/startBillingSession' - -import format from '../../utils/format' - -interface BillingSubscription { - status: string - created_at: number - description: string - plan_description: string - recognized_dblab_instance_id: number - selfassigned_instance_id: string - subscription_id: string - telemetry_last_reported: number - telemetry_usage_total_hours_last_3_months: string -} - -interface HourEntry { - [key: string]: number -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const useStyles = makeStyles( - (theme) => ({ - paperContainer: { - display: 'flex', - flexDirection: 'row', - alignItems: 'flex-start', - gap: 20, - width: '100%', - height: '100%', - - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - }, - }, - cardContainer: { - padding: 20, - display: 'inline-block', - borderWidth: 1, - borderColor: colors.consoleStroke, - borderStyle: 'solid', - flex: '1 1 0', - - [theme.breakpoints.down('sm')]: { - width: '100%', - flex: 'auto', - }, - }, - subscriptionContainer: { - display: 'flex', - flexDirection: 'column', - flex: '1 1 0', - gap: 20, - - [theme.breakpoints.down('sm')]: { - width: '100%', - flex: 'auto', - }, - }, - flexColumnContainer: { - display: 'flex', - flexDirection: 'column', - flex: '1.75 1 0', - gap: 20, - - [theme.breakpoints.down('sm')]: { - width: '100%', - flex: 'auto', - }, - }, - cardContainerTitle: { - fontSize: 14, - fontWeight: 'bold', - margin: 0, - }, - label: { - fontSize: 14, - }, - saveButton: { - marginTop: 15, - display: 'flex', - }, - error: { - color: '#E42548', - fontSize: 12, - marginTop: '-10px', - }, - checkboxRoot: { - fontSize: 14, - marginTop: 10, - }, - spinner: { - marginLeft: '8px', - }, - emptyMethod: { - fontSize: 13, - flex: '1 1 0', - }, - button: { - '&:hover': { - color: '#0F879D', - }, - }, - columnContainer: { - display: 'flex', - alignItems: 'center', - flexDirection: 'row', - padding: '10px 0', - borderBottom: '1px solid rgba(224, 224, 224, 1)', - - '& p:first-child': { - flex: '1 1 0', - fontWeight: '500', - }, - - '& p': { - flex: '2 1 0', - margin: '0', - fontSize: 13, - }, - - '&:last-child': { - borderBottom: 'none', - }, - }, - toolTip: { - fontSize: '10px !important', - maxWidth: '100%', - }, - timeLabel: { - lineHeight: '16px', - fontSize: 13, - cursor: 'pointer', - flex: '2 1 0', - }, - card: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: 20, - position: 'relative', - justifyContent: 'space-between', - marginTop: 20, - minHeight: 45, - - '& img': { - objectFit: 'contain', - }, - }, - }), - { index: 1 }, -) - -function StripeForm(props: { - alias: string - mode: string - token: string | null - orgId: number - disabled: boolean -}) { - const classes = useStyles() - - const initialState = { - isLoading: false, - isFetching: false, - cards: [] as { id: string; card: { exp_year: string; exp_month: string; brand: string; last4: string } }[], - billingInfo: [] as BillingSubscription[], - } - - const reducer = ( - state: typeof initialState, - // @ts-ignore - action: ReducerAction, - ) => { - switch (action.type) { - case 'setIsLoading': - return { ...state, isLoading: action.isLoading } - case 'setIsFetching': - return { ...state, isFetching: action.isFetching } - case 'setBillingInfo': - return { ...state, billingInfo: action.billingInfo, isFetching: false } - case 'setCards': - const updatedCards = state.cards.concat(action.cards) - const uniqueCards = updatedCards.filter( - (card: { id: string }, index: number) => - updatedCards.findIndex((c: { id: string }) => c.id === card.id) === - index, - ) - - return { - ...state, - cards: uniqueCards, - isLoading: false, - } - default: - throw new Error() - } - } - - const [state, dispatch] = useReducer(reducer, initialState) - - const handleSubmit = async (event: { preventDefault: () => void }) => { - event.preventDefault() - - dispatch({ - type: 'setIsLoading', - isLoading: true, - }) - - startBillingSession(props.orgId, window.location.href).then((res) => { - dispatch({ - type: 'setIsLoading', - isLoading: false, - }) - - if (res.response) { - window.open(res.response.details.content.url, '_blank') - } - }) - } - - const fetchPaymentMethods = useCallback(() => { - dispatch({ - type: 'setIsFetching', - isFetching: true, - }) - - getPaymentMethods(props.orgId).then((res) => { - dispatch({ - type: 'setCards', - cards: res.response.details.content.data, - }) - - getSubscription(props.orgId).then((res) => { - dispatch({ - type: 'setBillingInfo', - billingInfo: res.response.summary, - }) - }) - }) - }, [props.orgId]) - - useEffect(() => { - fetchPaymentMethods() - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - fetchPaymentMethods() - } - } - - document.addEventListener('visibilitychange', handleVisibilityChange, false) - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, [props.orgId, fetchPaymentMethods]) - - if (state.isFetching) { - return ( -
- -
- ) - } - - const BillingDetail = ({ - label, - value, - isDateValue, - isLink, - instanceId, - }: { - label: string - value: string | number - isDateValue?: boolean - isLink?: boolean - instanceId?: string | number - }) => ( - -

{label}

- {isDateValue && value ? ( - - - - {formatDistanceToNowStrict(new Date(value), { - addSuffix: true, - })} - - - - ) : isLink && value ? ( -

- - {instanceId} - -   - {`(self-assigned: ${value})` || '---'} -

- ) : ( -

- {value || '---'} -

- )} -
- ) - - const formatHoursUsed = (hours: HourEntry[] | string) => { - if (typeof hours === 'string' || !hours) { - return 'N/A' - } - - const formattedHours = hours.map((entry: HourEntry) => { - const key = Object.keys(entry)[0] - const value = entry[key] - return `${key}: ${value}` - }) - - return formattedHours.join('\n') - } - - return ( -
- {stripeStyles} -
- -

Subscriptions

- {state.billingInfo.length > 0 ? ( - state.billingInfo.map( - (billing: BillingSubscription, index: number) => ( - - - - - - - - - - - ), - ) - ) : ( - - - - - - - - - - - )} -
- -

Payment methods

- - - - {state.cards.length > 0 && ( - - )} - - {state.cards.length === 0 && !state.isFetching ? ( -

No payment methods available

- ) : ( - <> - {state.cards.map( - ( - card: { - id: string - card: { - exp_year: string - exp_month: string - brand: string - last4: string - } - }, - index: number, - ) => ( - - - {card.card.brand} - p': { - margin: 0, - }, - }} - > -

- **** {card.card.last4} -

-

- Expires {card.card.exp_month}/{card.card.exp_year} -

-
-
-
- ), - )} - - )} -
-
-
-
- ) -} - -export default StripeForm diff --git a/ui/packages/platform/src/components/StripeForm/stripeStyles.tsx b/ui/packages/platform/src/components/StripeForm/stripeStyles.tsx deleted file mode 100644 index 7b37c057..00000000 --- a/ui/packages/platform/src/components/StripeForm/stripeStyles.tsx +++ /dev/null @@ -1,80 +0,0 @@ -export const stripeStyles = ( - -) diff --git a/ui/packages/platform/src/components/Warning/Warning.tsx b/ui/packages/platform/src/components/Warning/Warning.tsx deleted file mode 100644 index f2d312f0..00000000 --- a/ui/packages/platform/src/components/Warning/Warning.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Component } from 'react' - -import { icons } from '@postgres.ai/shared/styles/icons' -import { WarningProps } from 'components/Warning/WarningWrapper' -import { ClassesType } from '@postgres.ai/platform/src/components/types' - -interface WarningWithStylesProps extends WarningProps { - classes: ClassesType -} - -class Warning extends Component { - render() { - const { classes, children, actions, inline } = this.props - - return ( -
- {icons.warningIcon} - -
{children}
- - {actions ? ( - - {actions.map((a, key) => { - return ( - - {a} - - ) - })} - - ) : null} -
- ) - } -} -export default Warning diff --git a/ui/packages/platform/src/components/Warning/WarningWrapper.tsx b/ui/packages/platform/src/components/Warning/WarningWrapper.tsx deleted file mode 100644 index d67b3440..00000000 --- a/ui/packages/platform/src/components/Warning/WarningWrapper.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import Warning from 'components/Warning/Warning' -import { colors } from '@postgres.ai/shared/styles/colors' - -export interface WarningProps { - children: React.ReactNode - inline?: boolean - actions?: { - id: number - content: string - }[] -} - -export const WarningWrapper = (props: WarningProps) => { - const linkColor = colors.secondary2.main - - const useStyles = makeStyles( - { - root: { - backgroundColor: colors.secondary1.lightLight, - color: colors.secondary1.darkDark, - fontSize: '14px', - paddingTop: '5px', - paddingBottom: '5px', - paddingLeft: '10px', - paddingRight: '10px', - display: 'flex', - alignItems: 'center', - borderRadius: '3px', - }, - block: { - marginBottom: '20px', - }, - icon: { - padding: '5px', - }, - actions: { - '& a': { - color: linkColor, - '&:visited': { - color: linkColor, - }, - '&:hover': { - color: linkColor, - }, - '&:active': { - color: linkColor, - }, - }, - marginRight: '15px', - }, - container: { - marginLeft: '5px', - marginRight: '5px', - lineHeight: '24px', - }, - }, - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/components/types/index.ts b/ui/packages/platform/src/components/types/index.ts deleted file mode 100644 index 61ff39b3..00000000 --- a/ui/packages/platform/src/components/types/index.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { RouteComponentProps } from 'react-router' -export interface ClassesType { - [classes: string]: string -} -export interface QueryParamsType { - session: string | undefined - command: string | undefined - author: string | undefined - fingerprint: string | undefined - project: string | undefined - search: string | undefined - is_favorite: string | undefined -} - -export type OrgPermissions = { [permission: string]: boolean } - -export interface MatchParams { - project: string | undefined - projectId: string | undefined - org: string | undefined - mode: string | undefined - type: string | undefined - fileType: string | undefined - reportId: string | undefined -} - -export interface Orgs { - [project: string]: { - alias: string - is_blocked: boolean - is_priveleged: boolean - new_subscription: boolean - is_blocked_on_creation: boolean - stripe_payment_method_primary: string - stripe_subscription_id: number - priveleged_until: Date - role: { id: number; permissions: string[] } - name: string - id: number - owner_user_id: number - is_chat_public_by_default: boolean - chats_private_allowed: boolean - consulting_type: string | null - dblab_old_clones_notifications_threshold_hours: number | null - dblab_low_disk_space_notifications_threshold_percent: number | null - data: { - plan: string - } | null - projects: { - [project: string]: { - alias: string - name: string - id: number - org_id: string - } - } - } -} - -export interface ProjectWrapperProps { - classes: ClassesType - location: RouteComponentProps['location'] - match: { - params: { - org?: string - project?: string - projectId?: string - } - } - raw?: boolean - org: string | number - orgId: number - userIsOwner: boolean - orgPermissions: OrgPermissions - auth: { - isProcessed: boolean - userId: number - token: string - } | null - orgData: { - projects: { - [project: string]: { - id: number - } - } - } - env: { - data: { - orgs?: Orgs - } - } - envData: { - orgs?: Orgs - } -} - -export interface OrganizationWrapperProps { - classes: ClassesType - match: { - params: { - org?: string | undefined - projectId?: string | undefined - project?: string | undefined - } - } - location: RouteComponentProps['location'] - env: { - data: { - orgs?: Orgs - info: { - first_name: string - user_name: string - email: string - is_tos_confirmed: boolean - is_active: boolean - id: number | null - } - } - } - auth: { - isProcessed: boolean - userId: number - token: string - } | null - raw?: boolean -} - -export interface OrganizationMenuProps { - classes: { [classes: string]: string } - location: RouteComponentProps['location'] - match: { - params: { - org?: string - project?: string - projectId?: string - } - } - env: { - data: { - orgs?: Orgs - info: { - first_name: string - user_name: string - email: string - is_tos_confirmed: boolean - is_active: boolean - id: number | null - } - } - } -} - -export interface UserProfile { - data: { - info: { - first_name: string - user_name: string - email: string - is_tos_confirmed: boolean - is_active: boolean - } - } - isConfirmProcessing: boolean - isConfirmProcessed: boolean - isTosAgreementConfirmProcessing: boolean -} - -export interface TabPanelProps { - children: React.ReactNode - value: number - index: number -} - -export interface ProjectProps { - error: boolean - isProcessing: boolean - isProcessed: boolean - data: { - name: string - id: number - project_label_or_name: string - }[] -} - -export interface TokenRequestProps { - isProcessing: boolean - isProcessed: boolean - data: { - name: string - is_personal: boolean - expires_at: string - token: string - } - errorMessage: string - error: boolean | null -} - -export interface FlameGraphPlanType { - [plan: string]: string | string[] -} - -export interface RefluxTypes { - listen: (callback: Function) => Function -} diff --git a/ui/packages/platform/src/config/emoji.ts b/ui/packages/platform/src/config/emoji.ts deleted file mode 100644 index b092b87a..00000000 --- a/ui/packages/platform/src/config/emoji.ts +++ /dev/null @@ -1,909 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -export const emoji = { - '100': '💯', - '1234': '🔢', - '+1': '👍', - '8ball': '🎱', - 'a': '🅰️', - 'ab': '🆎', - 'abc': '🔤', - 'abcd': '🔡', - 'accept': '🉑', - 'aerial_tramway': '🚡', - 'airplane': '✈️', - 'alarm_clock': '⏰', - 'alien': '👽', - 'ambulance': '🚑', - 'anchor': '⚓️', - 'angel': '👼', - 'anger': '💢', - 'angry': '😠', - 'anguished': '😧', - 'ant': '🐜', - 'apple': '🍎', - 'aquarius': '♒️', - 'aries': '♈️', - 'arrow_backward': '◀️', - 'arrow_double_down': '⏬', - 'arrow_double_up': '⏫', - 'arrow_down': '⬇️', - 'arrow_down_small': '🔽', - 'arrow_forward': '▶️', - 'arrow_heading_down': '⤵️', - 'arrow_heading_up': '⤴️', - 'arrow_left': '⬅️', - 'arrow_lower_left': '↙️', - 'arrow_lower_right': '↘️', - 'arrow_right': '➡️', - 'arrow_right_hook': '↪️', - 'arrow_up': '⬆️', - 'arrow_up_down': '↕️', - 'arrow_up_small': '🔼', - 'arrow_upper_left': '↖️', - 'arrow_upper_right': '↗️', - 'arrows_clockwise': '🔃', - 'arrows_counterclockwise': '🔄', - 'art': '🎨', - 'articulated_lorry': '🚛', - 'astonished': '😲', - 'athletic_shoe': '👟', - 'atm': '🏧', - 'b': '🅱️', - 'baby': '👶', - 'baby_bottle': '🍼', - 'baby_chick': '🐤', - 'baby_symbol': '🚼', - 'back': '🔙', - 'baggage_claim': '🛄', - 'balloon': '🎈', - 'ballot_box_with_check': '☑️', - 'bamboo': '🎍', - 'banana': '🍌', - 'bangbang': '‼️', - 'bank': '🏦', - 'bar_chart': '📊', - 'barber': '💈', - 'baseball': '⚾️', - 'basketball': '🏀', - 'bath': '🛀', - 'bathtub': '🛁', - 'battery': '🔋', - 'bear': '🐻', - 'bee': '🐝', - 'beer': '🍺', - 'beers': '🍻', - 'beetle': '🐞', - 'beginner': '🔰', - 'bell': '🔔', - 'bento': '🍱', - 'bicyclist': '🚴', - 'bike': '🚲', - 'bikini': '👙', - 'bird': '🐦', - 'birthday': '🎂', - 'black_circle': '⚫️', - 'black_joker': '🃏', - 'black_large_square': '⬛️', - 'black_medium_small_square': '◾️', - 'black_medium_square': '◼️', - 'black_nib': '✒️', - 'black_small_square': '▪️', - 'black_square_button': '🔲', - 'blossom': '🌼', - 'blowfish': '🐡', - 'blue_book': '📘', - 'blue_car': '🚙', - 'blue_heart': '💙', - 'blush': '😊', - 'boar': '🐗', - 'boat': '⛵️', - 'bomb': '💣', - 'book': '📖', - 'bookmark': '🔖', - 'bookmark_tabs': '📑', - 'books': '📚', - 'boom': '💥', - 'boot': '👢', - 'bouquet': '💐', - 'bow': '🙇', - 'bow_and_arrow': '🏹', - 'bowing_man': '🙇', - 'bowing_woman': '🙇‍♀️', - 'bowling': '🎳', - 'bowtie': 'https://assets-cdn.github.com/images/icons/emoji/bowtie.png', - 'boy': '👦', - 'bread': '🍞', - 'bride_with_veil': '👰', - 'bridge_at_night': '🌉', - 'briefcase': '💼', - 'broken_heart': '💔', - 'bug': '🐛', - 'bulb': '💡', - 'bullettrain_front': '🚅', - 'bullettrain_side': '🚄', - 'bus': '🚌', - 'busstop': '🚏', - 'bust_in_silhouette': '👤', - 'busts_in_silhouette': '👥', - 'cactus': '🌵', - 'cake': '🍰', - 'calendar': '📆', - 'calling': '📲', - 'camel': '🐫', - 'camera': '📷', - 'canada': '🇨🇦', - 'canary_islands': '🇮🇨', - 'cancer': '♋️', - 'candle': '🕯', - 'candy': '🍬', - 'capital_abcd': '🔠', - 'capricorn': '♑️', - 'car': '🚗', - 'card_index': '📇', - 'carousel_horse': '🎠', - 'cat': '🐱', - 'cat2': '🐈', - 'cd': '💿', - 'chart': '💹', - 'chart_with_downwards_trend': '📉', - 'chart_with_upwards_trend': '📈', - 'checkered_flag': '🏁', - 'cherries': '🍒', - 'cherry_blossom': '🌸', - 'chestnut': '🌰', - 'chicken': '🐔', - 'children_crossing': '🚸', - 'chocolate_bar': '🍫', - 'christmas_tree': '🎄', - 'church': '⛪️', - 'cinema': '🎦', - 'circus_tent': '🎪', - 'city_sunrise': '🌇', - 'city_sunset': '🌆', - 'cl': '🆑', - 'clap': '👏', - 'clapper': '🎬', - 'clipboard': '📋', - 'clock1': '🕐', - 'clock10': '🕙', - 'clock1030': '🕥', - 'clock11': '🕚', - 'clock1130': '🕦', - 'clock12': '🕛', - 'clock1230': '🕧', - 'clock130': '🕜', - 'clock2': '🕑', - 'clock230': '🕝', - 'clock3': '🕒', - 'clock330': '🕞', - 'clock4': '🕓', - 'clock430': '🕟', - 'clock5': '🕔', - 'clock530': '🕠', - 'clock6': '🕕', - 'clock630': '🕡', - 'clock7': '🕖', - 'clock730': '🕢', - 'clock8': '🕗', - 'clock830': '🕣', - 'clock9': '🕘', - 'clock930': '🕤', - 'closed_book': '📕', - 'closed_lock_with_key': '🔐', - 'closed_umbrella': '🌂', - 'cloud': '☁️', - 'clubs': '♣️', - 'cn': '🇨🇳', - 'cocktail': '🍸', - 'coffee': '☕️', - 'cold_sweat': '😰', - 'collision': '💥', - 'computer': '💻', - 'confetti_ball': '🎊', - 'confounded': '😖', - 'confused': '😕', - 'congratulations': '㊗️', - 'construction': '🚧', - 'construction_worker': '👷', - 'convenience_store': '🏪', - 'cookie': '🍪', - 'cool': '🆒', - 'cop': '👮', - 'copyright': '©️', - 'corn': '🌽', - 'couple': '👫', - 'couple_with_heart': '💑', - 'cow': '🐮', - 'cow2': '🐄', - 'credit_card': '💳', - 'crescent_moon': '🌙', - 'crocodile': '🐊', - 'crossed_flags': '🎌', - 'crown': '👑', - 'cry': '😢', - 'crying_cat_face': '😿', - 'crystal_ball': '🔮', - 'cupid': '💘', - 'curly_loop': '➰', - 'currency_exchange': '💱', - 'curry': '🍛', - 'custard': '🍮', - 'customs': '🛃', - 'cyclone': '🌀', - 'dancer': '💃', - 'dancers': '👯', - 'dango': '🍡', - 'dart': '🎯', - 'dash': '💨', - 'date': '📅', - 'de': '🇩🇪', - 'deciduous_tree': '🌳', - 'department_store': '🏬', - 'diamond_shape_with_a_dot_inside': '💠', - 'diamonds': '♦️', - 'disappointed': '😞', - 'disappointed_relieved': '😥', - 'dizzy': '💫', - 'dizzy_face': '😵', - 'do_not_litter': '🚯', - 'dog': '🐶', - 'dog2': '🐕', - 'dollar': '💵', - 'dolls': '🎎', - 'dolphin': '🐬', - 'door': '🚪', - 'doughnut': '🍩', - 'dragon': '🐉', - 'dragon_face': '🐲', - 'dress': '👗', - 'dromedary_camel': '🐪', - 'droplet': '💧', - 'dvd': '📀', - 'e-mail': '📧', - 'ear': '👂', - 'ear_of_rice': '🌾', - 'earth_africa': '🌍', - 'earth_americas': '🌎', - 'earth_asia': '🌏', - 'egg': '🥚', - 'eggplant': '🍆', - 'eight': '8️⃣', - 'eight_pointed_black_star': '✴️', - 'eight_spoked_asterisk': '✳️', - 'electric_plug': '🔌', - 'elephant': '🐘', - 'email': '✉️', - 'end': '🔚', - 'envelope': '✉️', - 'envelope_with_arrow': '📩', - 'es': '🇪🇸', - 'euro': '💶', - 'european_castle': '🏰', - 'european_post_office': '🏤', - 'evergreen_tree': '🌲', - 'exclamation': '❗️', - 'expressionless': '😑', - 'eyeglasses': '👓', - 'eyes': '👀', - 'facepunch': '👊', - 'factory': '🏭', - 'fallen_leaf': '🍂', - 'family': '👪', - 'fast_forward': '⏩', - 'fax': '📠', - 'fearful': '😨', - 'feelsgood': 'https://assets-cdn.github.com/images/icons/emoji/feelsgood.png', - 'feet': '🐾', - 'ferris_wheel': '🎡', - 'file_folder': '📁', - 'finnadie': 'https://assets-cdn.github.com/images/icons/emoji/finnadie.png', - 'fire': '🔥', - 'fire_engine': '🚒', - 'fireworks': '🎆', - 'first_quarter_moon': '🌓', - 'first_quarter_moon_with_face': '🌛', - 'fish': '🐟', - 'fish_cake': '🍥', - 'fishing_pole_and_fish': '🎣', - 'fist': '✊', - 'five': '5️⃣', - 'flags': '🎏', - 'flashlight': '🔦', - 'flipper': '🐬', - 'floppy_disk': '💾', - 'flower_playing_cards': '🎴', - 'flushed': '😳', - 'foggy': '🌁', - 'football': '🏈', - 'footprints': '👣', - 'fork_and_knife': '🍴', - 'fountain': '⛲️', - 'four': '4️⃣', - 'four_leaf_clover': '🍀', - 'fr': '🇫🇷', - 'free': '🆓', - 'fried_shrimp': '🍤', - 'fries': '🍟', - 'frog': '🐸', - 'frowning': '😦', - 'fu': '🖕', - 'fuelpump': '⛽️', - 'full_moon': '🌕', - 'full_moon_with_face': '🌝', - 'game_die': '🎲', - 'gb': '🇬🇧', - 'gem': '💎', - 'gemini': '♊️', - 'ghost': '👻', - 'gift': '🎁', - 'gift_heart': '💝', - 'girl': '👧', - 'globe_with_meridians': '🌐', - 'goat': '🐐', - 'goberserk': 'https://assets-cdn.github.com/images/icons/emoji/goberserk.png', - 'godmode': 'https://assets-cdn.github.com/images/icons/emoji/godmode.png', - 'golf': '⛳️', - 'grapes': '🍇', - 'green_apple': '🍏', - 'green_book': '📗', - 'green_heart': '💚', - 'grey_exclamation': '❕', - 'grey_question': '❔', - 'grimacing': '😬', - 'grin': '😁', - 'grinning': '😀', - 'guardsman': '💂', - 'guitar': '🎸', - 'gun': '🔫', - 'haircut': '💇', - 'hamburger': '🍔', - 'hammer': '🔨', - 'hamster': '🐹', - 'hand': '✋', - 'handbag': '👜', - 'hankey': '💩', - 'hash': '#️⃣', - 'hatched_chick': '🐥', - 'hatching_chick': '🐣', - 'headphones': '🎧', - 'hear_no_evil': '🙉', - 'heart': '❤️', - 'heart_decoration': '💟', - 'heart_eyes': '😍', - 'heart_eyes_cat': '😻', - 'heartbeat': '💓', - 'heartpulse': '💗', - 'hearts': '♥️', - 'heavy_check_mark': '✔️', - 'heavy_division_sign': '➗', - 'heavy_dollar_sign': '💲', - 'heavy_exclamation_mark': '❗️', - 'heavy_minus_sign': '➖', - 'heavy_multiplication_x': '✖️', - 'heavy_plus_sign': '➕', - 'helicopter': '🚁', - 'herb': '🌿', - 'hibiscus': '🌺', - 'high_brightness': '🔆', - 'high_heel': '👠', - 'hocho': '🔪', - 'honey_pot': '🍯', - 'honeybee': '🐝', - 'horse': '🐴', - 'horse_racing': '🏇', - 'hospital': '🏥', - 'hotel': '🏨', - 'hotsprings': '♨️', - 'hourglass': '⌛️', - 'hourglass_flowing_sand': '⏳', - 'house': '🏠', - 'house_with_garden': '🏡', - 'hurtrealbad': 'https://assets-cdn.github.com/images/icons/emoji/hurtrealbad.png', - 'hushed': '😯', - 'ice_cream': '🍨', - 'icecream': '🍦', - 'id': '🆔', - 'ideograph_advantage': '🉐', - 'imp': '👿', - 'inbox_tray': '📥', - 'incoming_envelope': '📨', - 'information_desk_person': '💁', - 'information_source': 'ℹ️', - 'innocent': '😇', - 'interrobang': '⁉️', - 'iphone': '📱', - 'it': '🇮🇹', - 'izakaya_lantern': '🏮', - 'jack_o_lantern': '🎃', - 'japan': '🗾', - 'japanese_castle': '🏯', - 'japanese_goblin': '👺', - 'japanese_ogre': '👹', - 'jeans': '👖', - 'joy': '😂', - 'joy_cat': '😹', - 'jp': '🇯🇵', - 'key': '🔑', - 'keycap_ten': '🔟', - 'kimono': '👘', - 'kiss': '💋', - 'kissing': '😗', - 'kissing_cat': '😽', - 'kissing_closed_eyes': '😚', - 'kissing_heart': '😘', - 'kissing_smiling_eyes': '😙', - 'koala': '🐨', - 'koko': '🈁', - 'kr': '🇰🇷', - 'lantern': '🏮', - 'large_blue_circle': '🔵', - 'large_blue_diamond': '🔷', - 'large_orange_diamond': '🔶', - 'last_quarter_moon': '🌗', - 'last_quarter_moon_with_face': '🌜', - 'laughing': '😆', - 'leaves': '🍃', - 'ledger': '📒', - 'left_luggage': '🛅', - 'left_right_arrow': '↔️', - 'leftwards_arrow_with_hook': '↩️', - 'lemon': '🍋', - 'leo': '♌️', - 'leopard': '🐆', - 'libra': '♎️', - 'light_rail': '🚈', - 'link': '🔗', - 'lips': '👄', - 'lipstick': '💄', - 'lock': '🔒', - 'lock_with_ink_pen': '🔏', - 'lollipop': '🍭', - 'loop': '➿', - 'loud_sound': '🔊', - 'loudspeaker': '📢', - 'love_hotel': '🏩', - 'love_letter': '💌', - 'low_brightness': '🔅', - 'm': 'Ⓜ️', - 'mag': '🔍', - 'mag_right': '🔎', - 'mahjong': '🀄️', - 'mailbox': '📫', - 'mailbox_closed': '📪', - 'mailbox_with_mail': '📬', - 'mailbox_with_no_mail': '📭', - 'man': '👨', - 'man_with_gua_pi_mao': '👲', - 'man_with_turban': '👳', - 'mans_shoe': '👞', - 'maple_leaf': '🍁', - 'mask': '😷', - 'massage': '💆', - 'meat_on_bone': '🍖', - 'mega': '📣', - 'melon': '🍈', - 'memo': '📝', - 'mens': '🚹', - 'metal': '🤘', - 'metro': '🚇', - 'microphone': '🎤', - 'microscope': '🔬', - 'milky_way': '🌌', - 'minibus': '🚐', - 'minidisc': '💽', - 'mobile_phone_off': '📴', - 'money_with_wings': '💸', - 'moneybag': '💰', - 'monkey': '🐒', - 'monkey_face': '🐵', - 'monorail': '🚝', - 'moon': '🌔', - 'mortar_board': '🎓', - 'mount_fuji': '🗻', - 'mountain_bicyclist': '🚵', - 'mountain_cableway': '🚠', - 'mountain_railway': '🚞', - 'mouse': '🐭', - 'mouse2': '🐁', - 'movie_camera': '🎥', - 'moyai': '🗿', - 'muscle': '💪', - 'mushroom': '🍄', - 'musical_keyboard': '🎹', - 'musical_note': '🎵', - 'musical_score': '🎼', - 'mute': '🔇', - 'nail_care': '💅', - 'name_badge': '📛', - 'neckbeard': 'https://assets-cdn.github.com/images/icons/emoji/neckbeard.png', - 'necktie': '👔', - 'negative_squared_cross_mark': '❎', - 'neutral_face': '😐', - 'new': '🆕', - 'new_moon': '🌑', - 'new_moon_with_face': '🌚', - 'newspaper': '📰', - 'ng': '🆖', - 'night_with_stars': '🌃', - 'nine': '9️⃣', - 'no_bell': '🔕', - 'no_bicycles': '🚳', - 'no_entry': '⛔️', - 'no_entry_sign': '🚫', - 'no_good': '🙅', - 'no_mobile_phones': '📵', - 'no_mouth': '😶', - 'no_pedestrians': '🚷', - 'no_smoking': '🚭', - 'non-potable_water': '🚱', - 'nose': '👃', - 'notebook': '📓', - 'notebook_with_decorative_cover': '📔', - 'notes': '🎶', - 'nut_and_bolt': '🔩', - 'o': '⭕️', - 'o2': '🅾️', - 'ocean': '🌊', - 'octocat': 'https://assets-cdn.github.com/images/icons/emoji/octocat.png', - 'octopus': '🐙', - 'oden': '🍢', - 'office': '🏢', - 'ok': '🆗', - 'ok_hand': '👌', - 'ok_woman': '🙆', - 'older_man': '👴', - 'older_woman': '👵', - 'on': '🔛', - 'oncoming_automobile': '🚘', - 'oncoming_bus': '🚍', - 'oncoming_police_car': '🚔', - 'oncoming_taxi': '🚖', - 'one': '1️⃣', - 'open_book': '📖', - 'open_file_folder': '📂', - 'open_hands': '👐', - 'open_mouth': '😮', - 'ophiuchus': '⛎', - 'orange_book': '📙', - 'outbox_tray': '📤', - 'ox': '🐂', - 'package': '📦', - 'page_facing_up': '📄', - 'page_with_curl': '📃', - 'pager': '📟', - 'palm_tree': '🌴', - 'panda_face': '🐼', - 'paperclip': '📎', - 'parking': '🅿️', - 'part_alternation_mark': '〽️', - 'partly_sunny': '⛅️', - 'passport_control': '🛂', - 'paw_prints': '🐾', - 'peach': '🍑', - 'pear': '🍐', - 'pencil': '📝', - 'pencil2': '✏️', - 'penguin': '🐧', - 'pensive': '😔', - 'performing_arts': '🎭', - 'persevere': '😣', - 'person_frowning': '🙍', - 'person_with_blond_hair': '👱', - 'person_with_pouting_face': '🙎', - 'phone': '☎️', - 'pig': '🐷', - 'pig2': '🐖', - 'pig_nose': '🐽', - 'pill': '💊', - 'pineapple': '🍍', - 'pisces': '♓️', - 'pizza': '🍕', - 'point_down': '👇', - 'point_left': '👈', - 'point_right': '👉', - 'point_up': '☝️', - 'point_up_2': '👆', - 'police_car': '🚓', - 'poodle': '🐩', - 'poop': '💩', - 'post_office': '🏣', - 'postal_horn': '📯', - 'postbox': '📮', - 'potable_water': '🚰', - 'pouch': '👝', - 'poultry_leg': '🍗', - 'pound': '💷', - 'pouting_cat': '😾', - 'pray': '🙏', - 'princess': '👸', - 'punch': '👊', - 'purple_heart': '💜', - 'purse': '👛', - 'pushpin': '📌', - 'put_litter_in_its_place': '🚮', - 'question': '❓', - 'rabbit': '🐰', - 'rabbit2': '🐇', - 'racehorse': '🐎', - 'radio': '📻', - 'radio_button': '🔘', - 'rage': '😡', - 'rage1': 'https://assets-cdn.github.com/images/icons/emoji/rage1.png', - 'rage2': 'https://assets-cdn.github.com/images/icons/emoji/rage2.png', - 'rage3': 'https://assets-cdn.github.com/images/icons/emoji/rage3.png', - 'rage4': 'https://assets-cdn.github.com/images/icons/emoji/rage4.png', - 'railway_car': '🚃', - 'rainbow': '🌈', - 'raised_hand': '✋', - 'raised_hands': '🙌', - 'raising_hand': '🙋', - 'ram': '🐏', - 'ramen': '🍜', - 'rat': '🐀', - 'recycle': '♻️', - 'red_car': '🚗', - 'red_circle': '🔴', - 'registered': '®️', - 'relaxed': '☺️', - 'relieved': '😌', - 'repeat': '🔁', - 'repeat_one': '🔂', - 'restroom': '🚻', - 'revolving_hearts': '💞', - 'rewind': '⏪', - 'ribbon': '🎀', - 'rice': '🍚', - 'rice_ball': '🍙', - 'rice_cracker': '🍘', - 'rice_scene': '🎑', - 'ring': '💍', - 'rocket': '🚀', - 'roller_coaster': '🎢', - 'rooster': '🐓', - 'rose': '🌹', - 'rotating_light': '🚨', - 'round_pushpin': '📍', - 'rowboat': '🚣', - 'ru': '🇷🇺', - 'rugby_football': '🏉', - 'runner': '🏃', - 'running': '🏃', - 'running_shirt_with_sash': '🎽', - 'sa': '🈂️', - 'sagittarius': '♐️', - 'sailboat': '⛵️', - 'sake': '🍶', - 'sandal': '👡', - 'santa': '🎅', - 'satellite': '📡', - 'satisfied': '😆', - 'saxophone': '🎷', - 'school': '🏫', - 'school_satchel': '🎒', - 'scissors': '✂️', - 'scorpius': '♏️', - 'scream': '😱', - 'scream_cat': '🙀', - 'scroll': '📜', - 'seat': '💺', - 'secret': '㊙️', - 'see_no_evil': '🙈', - 'seedling': '🌱', - 'seven': '7️⃣', - 'shaved_ice': '🍧', - 'sheep': '🐑', - 'shell': '🐚', - 'ship': '🚢', - 'shipit': 'https://assets-cdn.github.com/images/icons/emoji/shipit.png', - 'shirt': '👕', - 'shit': '💩', - 'shoe': '👞', - 'shower': '🚿', - 'signal_strength': '📶', - 'six': '6️⃣', - 'six_pointed_star': '🔯', - 'ski': '🎿', - 'skull': '💀', - 'sleeping': '😴', - 'sleepy': '😪', - 'slot_machine': '🎰', - 'small_blue_diamond': '🔹', - 'small_orange_diamond': '🔸', - 'small_red_triangle': '🔺', - 'small_red_triangle_down': '🔻', - 'smile': '😄', - 'smile_cat': '😸', - 'smiley': '😃', - 'smiley_cat': '😺', - 'smiling_imp': '😈', - 'smirk': '😏', - 'smirk_cat': '😼', - 'smoking': '🚬', - 'snail': '🐌', - 'snake': '🐍', - 'snowboarder': '🏂', - 'snowflake': '❄️', - 'snowman': '⛄️', - 'sob': '😭', - 'soccer': '⚽️', - 'soon': '🔜', - 'sos': '🆘', - 'sound': '🔉', - 'space_invader': '👾', - 'spades': '♠️', - 'spaghetti': '🍝', - 'sparkle': '❇️', - 'sparkler': '🎇', - 'sparkles': '✨', - 'sparkling_heart': '💖', - 'speak_no_evil': '🙊', - 'speaker': '🔈', - 'speech_balloon': '💬', - 'speedboat': '🚤', - 'squirrel': 'https://assets-cdn.github.com/images/icons/emoji/shipit.png', - 'star': '⭐️', - 'star2': '🌟', - 'stars': '🌠', - 'station': '🚉', - 'statue_of_liberty': '🗽', - 'steam_locomotive': '🚂', - 'stew': '🍲', - 'straight_ruler': '📏', - 'strawberry': '🍓', - 'stuck_out_tongue': '😛', - 'stuck_out_tongue_closed_eyes': '😝', - 'stuck_out_tongue_winking_eye': '😜', - 'sun_with_face': '🌞', - 'sunflower': '🌻', - 'sunglasses': '😎', - 'sunny': '☀️', - 'sunrise': '🌅', - 'sunrise_over_mountains': '🌄', - 'surfer': '🏄', - 'sushi': '🍣', - 'suspect': 'https://assets-cdn.github.com/images/icons/emoji/suspect.png', - 'suspension_railway': '🚟', - 'sweat': '😓', - 'sweat_drops': '💦', - 'sweat_smile': '😅', - 'sweet_potato': '🍠', - 'swimmer': '🏊', - 'symbols': '🔣', - 'syringe': '💉', - 'tada': '🎉', - 'tanabata_tree': '🎋', - 'tangerine': '🍊', - 'taurus': '♉️', - 'taxi': '🚕', - 'tea': '🍵', - 'telephone': '☎️', - 'telephone_receiver': '📞', - 'telescope': '🔭', - 'tennis': '🎾', - 'tent': '⛺️', - 'thought_balloon': '💭', - 'three': '3️⃣', - 'thumbsdown': '👎', - 'thumbsup': '👍', - 'ticket': '🎫', - 'tiger': '🐯', - 'tiger2': '🐅', - 'tired_face': '😫', - 'tm': '™️', - 'toilet': '🚽', - 'tokyo_tower': '🗼', - 'tomato': '🍅', - 'tongue': '👅', - 'top': '🔝', - 'tophat': '🎩', - 'tractor': '🚜', - 'traffic_light': '🚥', - 'train': '🚋', - 'train2': '🚆', - 'tram': '🚊', - 'triangular_flag_on_post': '🚩', - 'triangular_ruler': '📐', - 'trident': '🔱', - 'triumph': '😤', - 'trolleybus': '🚎', - 'trollface': 'https://assets-cdn.github.com/images/icons/emoji/trollface.png', - 'trophy': '🏆', - 'tropical_drink': '🍹', - 'tropical_fish': '🐠', - 'truck': '🚚', - 'trumpet': '🎺', - 'tshirt': '👕', - 'tulip': '🌷', - 'turtle': '🐢', - 'tv': '📺', - 'twisted_rightwards_arrows': '🔀', - 'two': '2️⃣', - 'two_hearts': '💕', - 'two_men_holding_hands': '👬', - 'two_women_holding_hands': '👭', - 'u5272': '🈹', - 'u5408': '🈴', - 'u55b6': '🈺', - 'u6307': '🈯️', - 'u6708': '🈷️', - 'u6709': '🈶', - 'u6e80': '🈵', - 'u7121': '🈚️', - 'u7533': '🈸', - 'u7981': '🈲', - 'u7a7a': '🈳', - 'uk': '🇬🇧', - 'ukraine': '🇺🇦', - 'umbrella': '☔️', - 'unamused': '😒', - 'underage': '🔞', - 'unlock': '🔓', - 'up': '🆙', - 'us': '🇺🇸', - 'us_virgin_islands': '🇻🇮', - 'v': '✌️', - 'vertical_traffic_light': '🚦', - 'vhs': '📼', - 'vibration_mode': '📳', - 'video_camera': '📹', - 'video_game': '🎮', - 'violin': '🎻', - 'virgo': '♍️', - 'volcano': '🌋', - 'vs': '🆚', - 'walking': '🚶', - 'walking_man': '🚶', - 'walking_woman': '🚶‍♀️', - 'wallis_futuna': '🇼🇫', - 'waning_crescent_moon': '🌘', - 'waning_gibbous_moon': '🌖', - 'warning': '⚠️', - 'watch': '⌚️', - 'water_buffalo': '🐃', - 'watermelon': '🍉', - 'wave': '👋', - 'wavy_dash': '〰️', - 'waxing_crescent_moon': '🌒', - 'waxing_gibbous_moon': '🌔', - 'wc': '🚾', - 'weary': '😩', - 'wedding': '💒', - 'whale': '🐳', - 'whale2': '🐋', - 'wheel_of_dharma': '☸️', - 'wheelchair': '♿️', - 'white_check_mark': '✅', - 'white_circle': '⚪️', - 'white_flower': '💮', - 'white_large_square': '⬜️', - 'white_medium_small_square': '◽️', - 'white_medium_square': '◻️', - 'white_small_square': '▫️', - 'white_square_button': '🔳', - 'wilted_flower': '🥀', - 'wind_chime': '🎐', - 'wind_face': '🌬', - 'wine_glass': '🍷', - 'wink': '😉', - 'wolf': '🐺', - 'woman': '👩', - 'womans_clothes': '👚', - 'womans_hat': '👒', - 'womens': '🚺', - 'world_map': '🗺', - 'worried': '😟', - 'wrench': '🔧', - 'x': '❌', - 'yellow_heart': '💛', - 'yen': '💴', - 'yum': '😋', - 'zap': '⚡️', - 'zero': '0️⃣', - 'zzz': '💤', -} diff --git a/ui/packages/platform/src/config/env.ts b/ui/packages/platform/src/config/env.ts deleted file mode 100644 index 43359e9b..00000000 --- a/ui/packages/platform/src/config/env.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -export const NODE_ENV = process.env.NODE_ENV -export const SENTRY_DSN = process.env.REACT_APP_SENTRY_DSN -export const API_URL_PREFIX = process.env.REACT_APP_API_SERVER ?? '' -export const WS_URL_PREFIX = process.env.REACT_APP_WS_URL_PREFIX ?? '' -export const BUILD_TIMESTAMP = process.env.BUILD_TIMESTAMP - -// For debug purposes or during development, allow to pre-set the JWT token. -const token = process.env.REACT_APP_TOKEN_DEBUG -if (token) { - localStorage.setItem('token', token) - console.warn( - 'WARNING: JWT token is being set from the environment variable. This appears to be a debugging or development setup.', - ) -} diff --git a/ui/packages/platform/src/config/routes/branches.ts b/ui/packages/platform/src/config/routes/branches.ts deleted file mode 100644 index b5368613..00000000 --- a/ui/packages/platform/src/config/routes/branches.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const ORG_BRANCHES = { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - - return `/${org}/instances/${instanceId}/branches` - }, - ADD: { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - - return `/${org}/instances/${instanceId}/branches/add` - }, - }, - - BRANCH: { - createPath: (args?: { - org: string - instanceId: string - branchId: string - }) => { - const { - org = ':org', - instanceId = ':instanceId', - branchId = ':branchId', - } = args ?? {} - - return `/${org}/instances/${instanceId}/branches/${branchId}` - }, - }, -} - -export const PROJECT_BRANCHES = { - createPath: (args?: { org: string; project: string; instanceId: string }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/branches` - }, - ADD: { - createPath: (args?: { - org: string - project: string - instanceId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/branches/add` - }, - }, - - BRANCH: { - createPath: (args?: { - org: string - project: string - instanceId: string - branchId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - branchId = ':branchId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/branches/${branchId}` - }, - }, -} diff --git a/ui/packages/platform/src/config/routes/clones.ts b/ui/packages/platform/src/config/routes/clones.ts deleted file mode 100644 index 4b187ab3..00000000 --- a/ui/packages/platform/src/config/routes/clones.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const ORG_CLONES = { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - - return `/${org}/instances/${instanceId}/clones` - }, - ADD: { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - - return `/${org}/instances/${instanceId}/clones/add` - }, - }, - - CLONE: { - createPath: (args?: { - org: string - instanceId: string - cloneId: string - }) => { - const { - org = ':org', - instanceId = ':instanceId', - cloneId = ':cloneId', - } = args ?? {} - - return `/${org}/instances/${instanceId}/clones/${cloneId}` - }, - }, -} - -export const PROJECT_CLONES = { - createPath: (args?: { org: string; project: string; instanceId: string }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/clones` - }, - ADD: { - createPath: (args?: { - org: string - project: string - instanceId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/clones/add` - }, - }, - - CLONE: { - createPath: (args?: { - org: string - project: string - instanceId: string - cloneId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - cloneId = ':cloneId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/clones/${cloneId}` - }, - }, -} diff --git a/ui/packages/platform/src/config/routes/index.ts b/ui/packages/platform/src/config/routes/index.ts deleted file mode 100644 index 98b3af4b..00000000 --- a/ui/packages/platform/src/config/routes/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { ORG_INSTANCES, PROJECT_INSTANCES } from './instances' - -export const ROUTES = { - ROOT: { - name: 'Organizations', - path: '/', - }, - - PROFILE: { - path: '/profile', - }, - - CREATE_ORG: { - path: '/addorg', - }, - - ORG: { - TOKENS: { - createPath: (args?: { org: string }) => { - const { org = ':org' } = args ?? {} - return `/${org}/tokens` - } - }, - - JOE_INSTANCES: { - JOE_INSTANCE: { - createPath: ({ - org = ':org', - id = ':id', - }: { org?: string; id?: string } = {}) => `/${org}/joe-instances/${id}`, - }, - }, - - INSTANCES: ORG_INSTANCES, - - PROJECT: { - JOE_INSTANCES: { - JOE_INSTANCE: { - createPath: ({ - org = ':org', - project = ':project', - id = ':id', - }: { org?: string; project?: string; id?: string } = {}) => - `/${org}/${project}/joe-instances/${id}`, - }, - }, - - INSTANCES: PROJECT_INSTANCES, - - ASSISTANT: { - createPath: ({ - org = ':org', - id, - }: { org?: string; id?: string } = {}) => - id ? `/${org}/assistant/${id}` : `/${org}/assistant`, - } - }, - }, -} diff --git a/ui/packages/platform/src/config/routes/instances.ts b/ui/packages/platform/src/config/routes/instances.ts deleted file mode 100644 index 7eac3abd..00000000 --- a/ui/packages/platform/src/config/routes/instances.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ORG_CLONES, PROJECT_CLONES } from './clones' -import { ORG_BRANCHES, PROJECT_BRANCHES } from './branches' -import { ORG_SNAPSHOTS, PROJECT_SNAPSHOTS } from './snapshots' - -export const ORG_INSTANCES = { - createPath: (args?: { org: string }) => { - const { org = ':org' } = args ?? {} - return `/${org}/instances` - }, - - INSTANCE: { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - return `/${org}/instances/${instanceId}` - }, - LOGS: { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - return `/${org}/instances/${instanceId}/logs` - }, - }, - CONFIGURATION: { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - return `/${org}/instances/${instanceId}/configuration` - }, - }, - CLONES: ORG_CLONES, - BRANCHES: ORG_BRANCHES, - SNAPSHOTS: ORG_SNAPSHOTS, - }, -} - -export const PROJECT_INSTANCES = { - createPath: (args?: { org: string; project: string }) => { - const { org = ':org', project = ':project' } = args ?? {} - return `/${org}/${project}/instances` - }, - - INSTANCE: { - createPath: (args?: { - org: string - project: string - instanceId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - return `/${org}/${project}/instances/${instanceId}` - }, - - CLONES: PROJECT_CLONES, - BRANCHES: PROJECT_BRANCHES, - SNAPSHOTS: PROJECT_SNAPSHOTS, - LOGS: { - createPath: (args?: { - org: string - project: string - instanceId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - return `/${org}/${project}/instances/${instanceId}/logs` - }, - }, - CONFIGURATION: { - createPath: (args?: { - org: string - project: string - instanceId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - return `/${org}/${project}/instances/${instanceId}/configuration` - }, - }, - }, -} diff --git a/ui/packages/platform/src/config/routes/snapshots.ts b/ui/packages/platform/src/config/routes/snapshots.ts deleted file mode 100644 index af1c1972..00000000 --- a/ui/packages/platform/src/config/routes/snapshots.ts +++ /dev/null @@ -1,85 +0,0 @@ -export const ORG_SNAPSHOTS = { - createPath: (args?: { org: string; instanceId: string }) => { - const { org = ':org', instanceId = ':instanceId' } = args ?? {} - - return `/${org}/instances/${instanceId}/snapshots` - }, - ADD: { - createPath: (args?: { org: string; instanceId: string, cloneId?: string }) => { - const { org = ':org', instanceId = ':instanceId', cloneId = undefined } = args ?? {} - - if (cloneId) { - return `/${org}/instances/${instanceId}/snapshots/add?clone_id=${cloneId}` - } - - return `/${org}/instances/${instanceId}/snapshots/add` - }, - }, - - SNAPSHOT: { - createPath: (args?: { - org: string - instanceId: string - snapshotId: string - }) => { - const { - org = ':org', - instanceId = ':instanceId', - snapshotId = ':snapshotId', - } = args ?? {} - - return `/${org}/instances/${instanceId}/snapshots/${snapshotId}` - }, - }, -} - -export const PROJECT_SNAPSHOTS = { - createPath: (args?: { org: string; project: string; instanceId: string }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/snapshots` - }, - ADD: { - createPath: (args?: { - org: string - project: string - instanceId: string - cloneId?: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - cloneId = undefined, - } = args ?? {} - - if (cloneId) { - return `/${org}/${project}/instances/${instanceId}/snapshots/add?clone_id=${cloneId}` - } - - return `/${org}/${project}/instances/${instanceId}/snapshots/add` - }, - }, - - SNAPSHOT: { - createPath: (args?: { - org: string - project: string - instanceId: string - snapshotId: string - }) => { - const { - org = ':org', - project = ':project', - instanceId = ':instanceId', - snapshotId = ':snapshotId', - } = args ?? {} - - return `/${org}/${project}/instances/${instanceId}/snapshots/${snapshotId}` - }, - }, -} diff --git a/ui/packages/platform/src/helpers/localStorage.ts b/ui/packages/platform/src/helpers/localStorage.ts deleted file mode 100644 index 4bbc8b0d..00000000 --- a/ui/packages/platform/src/helpers/localStorage.ts +++ /dev/null @@ -1,12 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { LocalStorage as LocalStorageShared } from '@postgres.ai/shared/helpers/localStorage' - -class LocalStorage extends LocalStorageShared {} - -export const localStorage = new LocalStorage() diff --git a/ui/packages/platform/src/helpers/request.ts b/ui/packages/platform/src/helpers/request.ts deleted file mode 100644 index e372ec8e..00000000 --- a/ui/packages/platform/src/helpers/request.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - request as requestCore, - RequestOptions, -} from '@postgres.ai/shared/helpers/request' - -import { localStorage } from 'helpers/localStorage' -import { API_URL_PREFIX } from 'config/env' - -export const request = async ( - path: string, - options?: RequestOptions, - customPrefix?: string, -) => { - const authToken = localStorage.getAuthToken() - - const response = await requestCore( - `${customPrefix ? customPrefix?.replace(/"/g, '') : API_URL_PREFIX}${path}`, - { - ...options, - headers: { - ...(authToken && { Authorization: `Bearer ${authToken}` }), - ...options?.headers, - }, - }, - ) - - return response -} diff --git a/ui/packages/platform/src/helpers/simpleInstallRequest.ts b/ui/packages/platform/src/helpers/simpleInstallRequest.ts deleted file mode 100644 index 5b4a7e90..00000000 --- a/ui/packages/platform/src/helpers/simpleInstallRequest.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - RequestOptions, - request as requestCore, -} from '@postgres.ai/shared/helpers/request' - -const sign = require('jwt-encode') - -export const SI_API_SERVER = 'https://si.dblab.dev' - -export const JWT_SECRET = 'some-jwt-secret' -export const JWT_PAYLOAD = (userID?: number) => ({ - id: userID?.toString(), -}) -export const JWT_HEADER = { - alg: 'HS256', - typ: 'JWT', -} - -export const simpleInstallRequest = async ( - path: string, - options: RequestOptions, - userID?: number, -) => { - const jwtToken = sign(JWT_PAYLOAD(userID), JWT_SECRET, JWT_HEADER) - - const response = await requestCore(`${SI_API_SERVER}${path}`, { - ...options, - headers: { - Authorization: `Bearer ${jwtToken}`, - }, - }) - - return response -} diff --git a/ui/packages/platform/src/hooks/useCloudProvider.ts b/ui/packages/platform/src/hooks/useCloudProvider.ts deleted file mode 100644 index 9227f533..00000000 --- a/ui/packages/platform/src/hooks/useCloudProvider.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Reducer, useCallback, useEffect, useReducer } from 'react' - -import { getCloudInstances } from 'api/cloud/getCloudInstances' -import { getCloudProviders } from 'api/cloud/getCloudProviders' -import { getCloudRegions } from 'api/cloud/getCloudRegions' -import { CloudVolumes, getCloudVolumes } from 'api/cloud/getCloudVolumes' -import { formatVolumeDetails } from 'components/DbLabInstanceForm/utils' -import { generateToken } from 'utils/utils' - -interface State { - [key: string]: StateValue -} - -type StateValue = - | string - | number - | boolean - | undefined - | unknown - | { [key: string]: string } - -export interface useCloudProviderProps { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - initialState: any - reducer: Reducer -} - -export const useCloudProvider = ({ - initialState, - reducer, -}: useCloudProviderProps) => { - const [state, dispatch] = useReducer(reducer, initialState) - - const urlParams = new URLSearchParams(window.location.search) - const urlTaskID = urlParams.get('taskID') - const urlProvider = urlParams.get('provider') - - const fetchCloudData = useCallback( - async (provider: string) => { - const cloudRegions = await getCloudRegions(provider) - const cloudVolumes = await getCloudVolumes(provider) - const ssdCloudVolumes = cloudVolumes.response.find( - (volume: CloudVolumes) => volume.api_name === initialState?.api_name, - ) - - return { - cloudRegions: cloudRegions.response, - cloudVolumes: cloudVolumes.response, - ssdCloudVolume: ssdCloudVolumes, - } - }, - [initialState.api_name], - ) - - useEffect(() => { - const action = { - type: 'set_form_step', - formStep: urlTaskID && urlProvider ? 'simple' : initialState.formStep, - taskID: urlTaskID || undefined, - provider: urlProvider || initialState.provider, - } - - dispatch(action) - }, [urlTaskID, urlProvider, initialState.formStep, initialState.provider]) - - useEffect(() => { - const fetchInitialCloudDetails = async () => { - try { - const { cloudRegions, cloudVolumes, ssdCloudVolume } = - await fetchCloudData(initialState.provider as string) - const volumeDetails = formatVolumeDetails( - ssdCloudVolume, - initialState.storage as number, - ) - const serviceProviders = await getCloudProviders() - - dispatch({ - type: 'set_initial_state', - cloudRegions, - volumes: cloudVolumes, - ...volumeDetails, - serviceProviders: serviceProviders.response, - isLoading: false, - }) - } catch (error) { - console.error(error) - } - } - - fetchInitialCloudDetails() - }, [initialState.provider, initialState.storage, fetchCloudData]) - - useEffect(() => { - const fetchUpdatedCloudDetails = async () => { - try { - const { cloudRegions, cloudVolumes, ssdCloudVolume } = - await fetchCloudData(state.provider) - const volumeDetails = formatVolumeDetails( - ssdCloudVolume, - initialState.storage as number, - ) - - dispatch({ - type: 'update_initial_state', - cloudRegions, - volumes: cloudVolumes, - ...volumeDetails, - }) - } catch (error) { - console.error(error) - } - } - - fetchUpdatedCloudDetails() - }, [state.api_name, state.provider, initialState.storage, fetchCloudData]) - - useEffect(() => { - if (state.location.native_code && state.provider) { - const fetchUpdatedDetails = async () => { - try { - const cloudInstances = await getCloudInstances({ - provider: state.provider, - region: state.location.native_code, - }) - - dispatch({ - type: 'update_instance_type', - cloudInstances: cloudInstances.response, - instanceType: cloudInstances.response[0], - isReloading: false, - }) - } catch (error) { - console.log(error) - } - } - fetchUpdatedDetails() - } - }, [state.location.native_code, state.provider]) - - const handleReturnToForm = () => { - dispatch({ type: 'set_form_step', formStep: initialState.formStep }) - } - - const handleSetFormStep = (step: string) => { - dispatch({ type: 'set_form_step', formStep: step }) - } - - const handleGenerateToken = () => { - dispatch({ - type: 'change_verification_token', - verificationToken: generateToken(), - }) - } - - const handleChangeVolume = ( - event: React.ChangeEvent, - ) => { - const volumeApiName = event.target.value.split(' ')[0] - const selectedVolume = state.volumes.filter( - (volume: CloudVolumes) => volume.api_name === volumeApiName, - )[0] - - dispatch({ - type: 'change_volume_type', - volumeType: event.target.value, - volumePricePerHour: - selectedVolume.native_reference_price_per_1000gib_per_hour, - volumePrice: - (state.storage * - selectedVolume.native_reference_price_per_1000gib_per_hour) / - 1000, - }) - } - - return { - state, - dispatch, - handleReturnToForm, - handleSetFormStep, - handleGenerateToken, - handleChangeVolume, - } -} diff --git a/ui/packages/platform/src/hooks/usePrev.ts b/ui/packages/platform/src/hooks/usePrev.ts deleted file mode 100644 index 79123bbf..00000000 --- a/ui/packages/platform/src/hooks/usePrev.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useRef, useEffect } from 'react' - -export const usePrev = (value: T) => { - const ref = useRef() - - useEffect(() => { - ref.current = value; - }, [value]) - - return ref.current -} diff --git a/ui/packages/platform/src/index.scss b/ui/packages/platform/src/index.scss deleted file mode 100644 index aa02f71c..00000000 --- a/ui/packages/platform/src/index.scss +++ /dev/null @@ -1,27 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -@import '@postgres.ai/shared/styles/vars'; - -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 0; - font-family: 'Roboto', sans-serif; - font-size: $font-size-main; -} - -/* stylelint-disable-next-line */ -#root { - min-height: 100vh; - display: flex; - flex-direction: column; - width: 100%; -} diff --git a/ui/packages/platform/src/index.tsx b/ui/packages/platform/src/index.tsx deleted file mode 100644 index df43579c..00000000 --- a/ui/packages/platform/src/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import ReactDOM from 'react-dom' - -import { initConfig } from '@postgres.ai/shared/config' - -import App from './App' -import { unregister } from './registerServiceWorker' - -import './index.scss' - -const main = async () => { - await initConfig() - ReactDOM.render(, document.getElementById('root')) -} - -main() - -// This func disable service worker. -// Don't remove it, -// because we cannot be sure that all previous users uninstalled their service workers. -// It should be permanent, except when you want to add new service worker. -const disableSw = () => { - unregister() - - // clear all sw caches - if ('caches' in window) { - window.caches.keys().then((res) => { - res.forEach((key) => { - window.caches.delete(key) - }) - }) - } -} - -window.addEventListener('load', disableSw) diff --git a/ui/packages/platform/src/meta.json b/ui/packages/platform/src/meta.json deleted file mode 100644 index 78bc307b..00000000 --- a/ui/packages/platform/src/meta.json +++ /dev/null @@ -1 +0,0 @@ -{"buildDate":1637930584925} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/BotWrapper.tsx b/ui/packages/platform/src/pages/Bot/BotWrapper.tsx deleted file mode 100644 index 20d088d9..00000000 --- a/ui/packages/platform/src/pages/Bot/BotWrapper.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { BotPage } from "./index"; -import {RouteComponentProps} from "react-router"; -import {AlertSnackbarProvider} from "@postgres.ai/shared/components/AlertSnackbar/useAlertSnackbar"; -import { AiBotProvider } from "./hooks"; - -export interface BotWrapperProps { - orgId?: number; - envData: { - info?: { - id?: number | null - user_name?: string - } - }; - orgData: { - id: number, - is_chat_public_by_default: boolean - priveleged_until: Date - chats_private_allowed: boolean - data: { - plan: string - } | null - }, - history: RouteComponentProps['history'] - project?: string - match: { - params: { - org?: string - threadId?: string - projectId?: string | number | undefined - } - } -} - - -export const BotWrapper = (props: BotWrapperProps) => { - return ( - - - - - - ) -} diff --git a/ui/packages/platform/src/pages/Bot/ChatsList/ChatsList.tsx b/ui/packages/platform/src/pages/Bot/ChatsList/ChatsList.tsx deleted file mode 100644 index 54ea0fec..00000000 --- a/ui/packages/platform/src/pages/Bot/ChatsList/ChatsList.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React from "react"; -import { Link } from "react-router-dom"; -import { useParams } from "react-router"; -import cn from "classnames"; -import { ListItem, ListItemIcon, makeStyles, Theme, useMediaQuery } from "@material-ui/core"; -import Drawer from '@material-ui/core/Drawer'; -import List from "@material-ui/core/List"; -import Divider from "@material-ui/core/Divider"; -import ListSubheader from '@material-ui/core/ListSubheader'; -import Box from "@mui/material/Box"; -import { Spinner } from "@postgres.ai/shared/components/Spinner"; -import { HeaderButtons, HeaderButtonsProps } from "../HeaderButtons/HeaderButtons"; -import { theme } from "@postgres.ai/shared/styles/theme"; -import { useAiBot } from "../hooks"; -import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'; - - -const useStyles = makeStyles((theme) => ({ - drawerPaper: { - width: 240, - //TODO: Fix magic numbers - height: props => props.isDemoOrg ? 'calc(100vh - 122px)' : 'calc(100vh - 90px)', - marginTop: props => props.isDemoOrg ? 72 : 40, - [theme.breakpoints.down('sm')]: { - height: '100vh!important', - marginTop: '0!important', - width: 'min(100%, 360px)', - zIndex: 9999 - }, - '& > ul': { - display: 'flex', - flexDirection: 'column', - '@supports (scrollbar-gutter: stable)': { - scrollbarGutter: 'stable', - paddingRight: 0, - overflow: 'hidden', - }, - '&:hover': { - overflow: 'auto' - }, - [theme.breakpoints.down('sm')]: { - paddingBottom: 120 - } - } - }, - listPadding: { - paddingTop: 0 - }, - listSubheaderRoot: { - background: 'white', - [theme.breakpoints.down('sm')]: { - padding: 0 - }, - "@media (max-width: 960px)": { - "& .MuiFormControl-root": { - display: "none" // Hide model selector in chats list - } - } - }, - listItemLink: { - fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', - fontStyle: 'normal', - fontWeight: 'normal', - fontSize: '0.875rem', - lineHeight: '1rem', - color: '#000000', - width: '100%', - textOverflow: 'ellipsis', - overflow: 'hidden', - padding: '0.75rem 1rem', - whiteSpace: 'nowrap', - textDecoration: "none", - flex: '0 0 2.5rem', - display: 'block', - '&:hover': { - background: 'rgba(0, 0, 0, 0.04)' - }, - '&:focus': { - outline: 'none', - background: 'rgba(0, 0, 0, 0.04)' - } - }, - listItemLinkActive: { - background: 'rgba(0, 0, 0, 0.04)' - }, - listItemIcon: { - transform: 'translateY(2px)', - marginRight: 2, - minWidth: 'auto', - }, - loader: { - width: '100%', - height: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' - } - }) -); - -type ChatsListProps = { - isOpen: boolean; - onCreateNewChat: () => void; - onClose: () => void; - isDemoOrg: boolean; - onLinkClick?: (targetThreadId: string) => void; -} & HeaderButtonsProps - -export const ChatsList = (props: ChatsListProps) => { - const { - isOpen, - onCreateNewChat, - onClose, - withChatVisibilityButton, - onSettingsClick, - onLinkClick, - onConsoleClick - } = props; - - const { chatsList, chatsListLoading: loading } = useAiBot(); - - const classes = useStyles(props); - const params = useParams<{ org?: string, threadId?: string }>(); - const matches = useMediaQuery(theme.breakpoints.down('sm')); - const linkBuilder = (msgId: string) => { - if (params.org) { - return `/${params.org}/assistant/${msgId}` - } else { - return `/assistant/${msgId}` - } - } - - const handleClick = (threadId: string) => { - if (onLinkClick) { - onLinkClick(threadId) - if (matches) { - onClose() - } - } - } - - const handleCloseOnClickOutside = () => { - if (matches) { - onClose() - } - } - - const loader = ( - - - - ) - - const list = ( - - - - - - - {chatsList && chatsList.map((item) => { - const isActive = item.id === params.threadId - const link = linkBuilder(item.id) - return ( - handleClick(item.id)} - autoFocus={isActive} - > - - {!item.is_public && } - - {item.content} - - ) - }) - } - - ) - - return ( - - {loading ? loader : list} - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Command/Command.tsx b/ui/packages/platform/src/pages/Bot/Command/Command.tsx deleted file mode 100644 index a1a25cfd..00000000 --- a/ui/packages/platform/src/pages/Bot/Command/Command.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react' -import { makeStyles } from '@material-ui/core' -import SendRoundedIcon from '@material-ui/icons/SendRounded'; -import IconButton from "@material-ui/core/IconButton"; -import { TextField } from '@postgres.ai/shared/components/TextField' -import { ReadyState } from "react-use-websocket"; -import { useLocation } from "react-router-dom"; -import { - checkIsSendCmd, - checkIsNewLineCmd, - addNewLine, - checkIsPrevMessageCmd, - checkIsNextMessageCmd, -} from './utils' -import { useBuffer } from './useBuffer' -import { useCaret } from './useCaret' -import { theme } from "@postgres.ai/shared/styles/theme"; -import { isMobileDevice } from "../../../utils/utils"; -import { useAiBot } from "../hooks"; - -type Props = { - threadId?: string - orgId: number | null -} - - -const useStyles = makeStyles((theme) => ( - { - root: { - display: 'flex', - alignItems: 'flex-end', - marginTop: '20px', - border: '1px solid rgba(0, 0, 0, 0.23)', - borderRadius: '8px', - '& .MuiOutlinedInput-root': { - '& fieldset': { - border: 'none', - }, - '&:hover fieldset': { - border: 'none', - }, - '&.Mui-focused fieldset': { - border: 'none', - } - } - }, - field: { - margin: '0 8px 0 0', - flex: '1 1 100%', - fontSize: '0.875rem', - }, - fieldInput: { - fontSize: '0.875rem', - lineHeight: 'normal', - height: 'auto', - padding: '12px', - [theme.breakpoints.down('sm')]: { - fontSize: '1rem' - } - }, - iconButton: { - height: 40, - width: 40, - fontSize: 24, - transition: '.2s ease', - '&:hover': { - color: '#000' - } - }, - button: { - flex: '0 0 auto', - height: '40px', - }, - }) -) - -export const Command = React.memo((props: Props) => { - const { threadId, orgId } = props - - const classes = useStyles() - const isMobile = isMobileDevice(); - - const { - error, - wsReadyState, - wsLoading, - loading, - sendMessage, - isStreamingInProcess - } = useAiBot(); - - const sendDisabled = error !== null || loading || wsLoading || wsReadyState !== ReadyState.OPEN || isStreamingInProcess; - - // Handle value. - const [value, setValue] = useState('') - - // Input DOM Element reference. - const inputRef = useRef() - - // Messages buffer. - const buffer = useBuffer() - - // Input caret. - const caret = useCaret(inputRef) - - let location = useLocation<{skipReloading?: boolean}>(); - - const onSend = async (message: string) => { - await sendMessage({ - content: message, - thread_id: threadId || null, - org_id: orgId, - }) - } - - const triggerSend = async () => { - if (!value.trim().length || sendDisabled) return - - await onSend(value) - buffer.addNew() - setValue(buffer.getCurrent()) - } - - const handleChange = (e: React.ChangeEvent) => { - setValue(e.target.value) - buffer.switchToLast() - buffer.setToCurrent(e.target.value) - } - - const handleBlur = () => { - if ((window.innerWidth < theme.breakpoints.values.sm) && isMobile) { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }) - } - } - - const handleKeyDown = async (e: React.KeyboardEvent) => { - if (!inputRef.current) return - - // Trigger to send. - if (checkIsSendCmd(e.nativeEvent)) { - e.preventDefault() - await triggerSend() - return - } - - // Trigger line break. - if (checkIsNewLineCmd(e.nativeEvent)) { - e.preventDefault() - - const content = addNewLine(value, inputRef.current) - - setValue(content.value) - caret.setPosition(content.caretPosition) - return - } - - // Trigger to use prev message. - if (checkIsPrevMessageCmd(e.nativeEvent, inputRef.current)) { - e.preventDefault() - - const prevValue = buffer.switchPrev() - setValue(prevValue) - return - } - - // Trigger to use next message. - if (checkIsNextMessageCmd(e.nativeEvent, inputRef.current)) { - e.preventDefault() - - const nextValue = buffer.switchNext() - setValue(nextValue) - return - } - - // Skip other keyboard events to fill input. - } - - // Autofocus and clear on thread changed - useEffect(() => { - if (!inputRef.current) return - if (window.innerWidth > theme.breakpoints.values.md) inputRef.current.focus() - if (!location.state?.skipReloading) setValue('') - }, [threadId, loading]); - - return ( -
- theme.breakpoints.values.sm} - multiline - className={classes.field} - onKeyDown={handleKeyDown} - onChange={handleChange} - onBlur={handleBlur} - InputProps={{ - inputRef, - classes: { - input: classes.fieldInput, - }, - }} - value={value} - placeholder="Message..." - /> - - - -
- ) -}) diff --git a/ui/packages/platform/src/pages/Bot/Command/useBuffer.ts b/ui/packages/platform/src/pages/Bot/Command/useBuffer.ts deleted file mode 100644 index 0368bc44..00000000 --- a/ui/packages/platform/src/pages/Bot/Command/useBuffer.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useRef } from 'react' - -type Buffer = { - values: string[] - position: number -} - -const NEW_EMPTY_VALUE = '' - -const INITIAL_BUFFER: Buffer = { - values: [NEW_EMPTY_VALUE], - position: 0, -} - -export const useBuffer = () => { - const { current: buffer } = useRef(INITIAL_BUFFER) - - const getCurrent = () => buffer.values[buffer.position] - - const setToCurrent = (value: string) => buffer.values[buffer.position] = value - - const switchNext = () => { - const newPosition = buffer.position + 1 - if (newPosition in buffer.values) buffer.position = newPosition - return getCurrent() - } - - const switchPrev = () => { - const newPosition = buffer.position - 1 - if (newPosition in buffer.values) buffer.position = newPosition - return getCurrent() - } - - const switchToLast = () => { - const lastIndex = buffer.values.length - 1 - buffer.position = lastIndex - return getCurrent() - } - - const addNew = () => { - buffer.values.push(NEW_EMPTY_VALUE) - return switchToLast() - } - - return { - switchNext, - switchPrev, - switchToLast, - addNew, - getCurrent, - setToCurrent, - } -} diff --git a/ui/packages/platform/src/pages/Bot/Command/useCaret.ts b/ui/packages/platform/src/pages/Bot/Command/useCaret.ts deleted file mode 100644 index 8de590a5..00000000 --- a/ui/packages/platform/src/pages/Bot/Command/useCaret.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useState, useEffect, MutableRefObject } from 'react' - -export const useCaret = ( - elementRef: MutableRefObject, -) => { - // Keep caret position after making new line, but only after react update. - const [nextPosition, setNextPosition] = useState(null) - - useEffect(() => { - if (nextPosition === null) return - if (!elementRef.current) return - - elementRef.current.selectionStart = nextPosition - elementRef.current.selectionEnd = nextPosition - - setNextPosition(null) - }, [elementRef, nextPosition]) - - return { - setPosition: setNextPosition, - } -} diff --git a/ui/packages/platform/src/pages/Bot/Command/utils.ts b/ui/packages/platform/src/pages/Bot/Command/utils.ts deleted file mode 100644 index ba14b587..00000000 --- a/ui/packages/platform/src/pages/Bot/Command/utils.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { isMobileDevice } from "../../../utils/utils"; - -export const checkIsSendCmd = (e: KeyboardEvent): boolean => { - if (isMobileDevice()) { - return false; // On mobile devices, Enter should not send. - } - return e.code === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey; -}; - -export const checkIsNewLineCmd = (e: KeyboardEvent): boolean => { - if (isMobileDevice()) { - return e.code === 'Enter'; // On mobile devices, Enter should create a new line. - } - return e.code === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey); -}; - -export const addNewLine = ( - value: string, - element: HTMLInputElement | HTMLTextAreaElement, -) => { - const NEW_LINE_STR = '\n' - - const firstLineLength = element.selectionStart ?? value.length - const secondLineLength = element.selectionEnd ?? value.length - - const firstLine = value.substring(0, firstLineLength) - const secondLine = value.substring(secondLineLength) - - return { - value: `${firstLine}${NEW_LINE_STR}${secondLine}`, - caretPosition: firstLineLength + NEW_LINE_STR.length, - } -} - -export const checkIsPrevMessageCmd = ( - e: KeyboardEvent, - element: HTMLInputElement | HTMLTextAreaElement, -) => { - const isRightKey = - e.code === 'ArrowUp' && !e.ctrlKey && !e.metaKey && !e.shiftKey - - // Use prev message only if the caret is in the start of the input. - const targetCaretPosition = 0 - - const isRightCaretPosition = - element.selectionStart === targetCaretPosition && - element.selectionEnd === targetCaretPosition - - return isRightKey && isRightCaretPosition -} - -export const checkIsNextMessageCmd = ( - e: KeyboardEvent, - element: HTMLInputElement | HTMLTextAreaElement, -) => { - const isRightKey = - e.code === 'ArrowDown' && !e.ctrlKey && !e.metaKey && !e.shiftKey - - // Use next message only if the caret is in the end of the input. - const targetCaretPosition = element.value.length - - const isRightCaretPosition = - element.selectionStart === targetCaretPosition && - element.selectionEnd === targetCaretPosition - - return isRightKey && isRightCaretPosition -} diff --git a/ui/packages/platform/src/pages/Bot/DebugConsole/DebugConsole.tsx b/ui/packages/platform/src/pages/Bot/DebugConsole/DebugConsole.tsx deleted file mode 100644 index 21a3bb57..00000000 --- a/ui/packages/platform/src/pages/Bot/DebugConsole/DebugConsole.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import Dialog from "@material-ui/core/Dialog"; -import { DialogContent, DialogTitle, makeStyles } from "@material-ui/core"; -import { useAiBot } from "../hooks"; -import IconButton from "@material-ui/core/IconButton"; -import CloseIcon from "@material-ui/icons/Close"; -import { DebugLogs } from "../DebugLogs/DebugLogs"; -import { createMessageFragment } from "../utils"; - -const useStyles = makeStyles( - (theme) => ({ - dialogStyle: { - top: '5%!important', - right: '10%!important', - left: 'unset!important', - height: '80vh', - width: '80vw', - [theme.breakpoints.down('sm')]: { - right: 'unset!important', - top: '0!important', - height: '100vh', - width: '100vw', - } - }, - paper: { - width: '80vw', - height: '80vh', - opacity: '.5', - transition: '.2s ease', - '&:hover': { - opacity: 1 - }, - [theme.breakpoints.down('sm')]: { - opacity: 1, - width: '100vw', - height: '100vh', - margin: '0' - } - }, - dialogTitle: { - padding: '0.5rem', - '& h2': { - fontSize: '1rem', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center' - } - }, - dialogContent: { - padding: 0, - margin: 0, - } - })) - -type DebugConsoleProps = { - isOpen: boolean - onClose: () => void - threadId?: string -} - -export const DebugConsole = (props: DebugConsoleProps) => { - const { isOpen, onClose, threadId } = props; - const { debugMessages, debugMessagesLoading } = useAiBot(); - const classes = useStyles(); - const containerRef = useRef(null); - - useEffect(() => { - if (!containerRef.current || !debugMessages?.length || debugMessagesLoading || !isOpen) return; - - let code = containerRef.current.getElementsByTagName('code')?.[0]; - if (!code) { - code = document.createElement('code'); - containerRef.current.appendChild(code); - } - - const fragment = createMessageFragment( - code.hasChildNodes() ? [debugMessages[debugMessages.length - 1]] : debugMessages - ); - code.appendChild(fragment); - - }, [debugMessages, isOpen, debugMessagesLoading]); - - return ( - - - Debug console - - - - - - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/DebugDialog/DebugDialog.tsx b/ui/packages/platform/src/pages/Bot/DebugDialog/DebugDialog.tsx deleted file mode 100644 index fb64b723..00000000 --- a/ui/packages/platform/src/pages/Bot/DebugDialog/DebugDialog.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import Dialog from "@material-ui/core/Dialog"; -import DialogTitle from '@material-ui/core/DialogTitle'; -import { DialogContent, IconButton, makeStyles } from "@material-ui/core"; -import { icons } from "@postgres.ai/shared/styles/icons"; -import { DebugMessage } from "../../../types/api/entities/bot"; -import { getDebugMessages } from "../../../api/bot/getDebugMessages"; -import { DebugLogs } from "../DebugLogs/DebugLogs"; -import { createMessageFragment } from "../utils"; - -type DebugDialogProps = { - isOpen: boolean; - onClose: () => void; - messageId: string -} - -const useStyles = makeStyles( - (theme) => ({ - dialogTitle: { - fontSize: '1rem', - paddingBottom: '8px', - '& > *': { - fontSize: 'inherit' - } - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - dialogContent: { - backgroundColor: 'rgb(250, 250, 250)', - padding: '0.5rem 0', - '& pre': { - whiteSpace: 'pre-wrap!important' - } - } - })) - -export const DebugDialog = (props: DebugDialogProps) => { - const {isOpen, onClose, messageId} = props; - const classes = useStyles() - - const [debugLoading, setDebugLoading] = useState(false); - const debugMessages = useRef(null) - - const generateMessages = (messages: DebugMessage[]) => { - const container = document.getElementById(`logs-container-${messageId}`); - if (container) { - let code = container.getElementsByTagName('code')?.[0]; - if (!code) { - code = document.createElement('code'); - container.appendChild(code); - } - - const fragment = createMessageFragment(messages); - code.appendChild(fragment); - } - }; - - const getDebugMessagesForMessage = async () => { - setDebugLoading(true) - if (messageId) { - const { response} = await getDebugMessages({ message_id: messageId }) - if (response) { - debugMessages.current = response; - generateMessages(response) - } - } - } - - useEffect(() => { - if (isOpen && !debugMessages.current) { - getDebugMessagesForMessage() - .then(() => setDebugLoading(false)) - } else if (isOpen && debugMessages.current && debugMessages.current?.length > 0) { - setTimeout(() => generateMessages(debugMessages.current!), 0) - } - }, [isOpen]); - - - return ( - - - Debug - - {icons.closeIcon} - - - - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/DebugLogs/DebugLogs.tsx b/ui/packages/platform/src/pages/Bot/DebugLogs/DebugLogs.tsx deleted file mode 100644 index d556235a..00000000 --- a/ui/packages/platform/src/pages/Bot/DebugLogs/DebugLogs.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import { SyntaxHighlight } from "@postgres.ai/shared/components/SyntaxHighlight"; - -type DebugLogsProps = { - isLoading: boolean - isEmpty: boolean - id: string -} - -export const DebugLogs = (props: DebugLogsProps) => { - const { isLoading, isEmpty, id } = props; - return ( - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HeaderButtons/HeaderButtons.tsx b/ui/packages/platform/src/pages/Bot/HeaderButtons/HeaderButtons.tsx deleted file mode 100644 index 91de9438..00000000 --- a/ui/packages/platform/src/pages/Bot/HeaderButtons/HeaderButtons.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from "react"; -import { Button, makeStyles, useMediaQuery } from "@material-ui/core"; -import IconButton from "@material-ui/core/IconButton"; -import NavigateBeforeIcon from "@material-ui/icons/NavigateBefore"; -import NavigateNextIcon from '@material-ui/icons/NavigateNext'; -import AddCircleOutlineIcon from "@material-ui/icons/AddCircleOutline"; -import Box from "@mui/material/Box"; -import { theme } from "@postgres.ai/shared/styles/theme"; -import { SettingsPanel, SettingsPanelProps } from "../SettingsPanel/SettingsPanel"; - - -export type HeaderButtonsProps = { - isOpen: boolean; - onClose: () => void; - onCreateNewChat: () => void; - withChatVisibilityButton: boolean; - onSettingsClick: SettingsPanelProps["onSettingsClick"]; - onConsoleClick: SettingsPanelProps["onConsoleClick"]; -} - -const useStyles = makeStyles((theme) => ({ - container: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: '8px 5px', - flex: 1, - [theme.breakpoints.down('sm')]: { - justifyContent: 'flex-end', - } - }, - hideChatButton: { - width: '2rem', - height: '2rem' - }, - hideChatButtonIcon: { - width: '2rem', - height: '2rem', - fill: '#000' - }, - createNewChatButton: { - [theme.breakpoints.down('sm')]: { - border: 'none', - minWidth: '2rem', - height: '2rem', - padding: 0, - marginRight: '0.25rem', - '& .MuiButton-startIcon': { - margin: 0 - } - } - } -})) - -export const HeaderButtons = (props: HeaderButtonsProps) => { - const { - onClose, - onCreateNewChat, - isOpen, - onSettingsClick, - withChatVisibilityButton, - onConsoleClick - } = props; - const matches = useMediaQuery(theme.breakpoints.down('sm')); - const classes = useStyles(); - - - return ( - - { - withChatVisibilityButton && - - } - - - {isOpen - ? - : - } - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HintCards/ArrowGrowthIcon/ArrowGrowthIcon.tsx b/ui/packages/platform/src/pages/Bot/HintCards/ArrowGrowthIcon/ArrowGrowthIcon.tsx deleted file mode 100644 index 6fcafec1..00000000 --- a/ui/packages/platform/src/pages/Bot/HintCards/ArrowGrowthIcon/ArrowGrowthIcon.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { SVGProps } from 'react' -import React from 'react' - -export function ArrowGrowthIcon() { - return ( - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HintCards/CommonTypeIcon/CommonTypeIcon.tsx b/ui/packages/platform/src/pages/Bot/HintCards/CommonTypeIcon/CommonTypeIcon.tsx deleted file mode 100644 index 061f9fdb..00000000 --- a/ui/packages/platform/src/pages/Bot/HintCards/CommonTypeIcon/CommonTypeIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -export const CommonTypeIcon = () => { - return ( - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HintCards/HintCard/HintCard.tsx b/ui/packages/platform/src/pages/Bot/HintCards/HintCard/HintCard.tsx deleted file mode 100644 index 1581e80a..00000000 --- a/ui/packages/platform/src/pages/Bot/HintCards/HintCard/HintCard.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react' -import { Hint } from 'pages/Bot/hints' -import { matchHintTypeAndIcon } from "../../utils"; -import { makeStyles } from "@material-ui/core"; -import { useAiBot } from "../../hooks"; - -const useStyles = makeStyles((theme) => ({ - container: { - backgroundColor: 'transparent', - border: '1px solid rgba(0, 0, 0, 0.25)', - borderRadius: '0.5rem', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - cursor: 'pointer', - width: '11rem', - height: '6rem', - padding: '0.5rem', - color: 'black', - textAlign: 'left', - fontSize: '0.938rem', - transition: '0.2s ease-in', - - '& svg': { - width: '22px', - height: '22px', - marginBottom: '0.5rem', - '& path': { - stroke: 'black', - }, - [theme.breakpoints.down('sm')]: { - width: '16px', - height: '16px' - } - }, - - '&:hover, &:focus-visible': { - border: '1px solid rgba(0, 0, 0, 0.8)', - }, - [theme.breakpoints.down(1024)]: { - flex: '1 1 45%', - }, - [theme.breakpoints.down(480)]: { - margin: '0 0.5rem', - fontSize: '0.813rem', - height: 'auto', - }, - [theme.breakpoints.down(330)]: { - fontSize: '.75rem' - } - }, -})); - -export const HintCard = (props: Hint & {orgId: number}) => { - const { prompt, hint, type, orgId } = props; - const { sendMessage } = useAiBot(); - - const classes = useStyles(); - - const handleSendMessage = async () => { - await sendMessage({ - content: prompt, - org_id: orgId, - }) - } - - return ( - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HintCards/HintCards.tsx b/ui/packages/platform/src/pages/Bot/HintCards/HintCards.tsx deleted file mode 100644 index fa66647d..00000000 --- a/ui/packages/platform/src/pages/Bot/HintCards/HintCards.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' -import { hints } from '../hints' -import { HintCard } from "./HintCard/HintCard"; -import { makeStyles } from "@material-ui/core"; - -const useStyles = makeStyles((theme) => ({ - container: { - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'space-around', - gap: '.5rem', - marginTop: '2rem', - [theme.breakpoints.down(1200)]: { - justifyContent: 'center', - }, - [theme.breakpoints.down(480)]: { - marginBottom: '1rem', - }, - [theme.breakpoints.down(380)]: { - marginTop: '1rem', - marginBottom: '.5rem', - }, - [theme.breakpoints.down(760)]: { - '& > *:nth-child(n+3)': { - display: 'none', - }, - }, - }, -})); - -export const HintCards = React.memo(({orgId}: {orgId: number}) => { - const classes = useStyles(); - return ( -
- { - hints.map((hint) => ) - } -
- ) -}) \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HintCards/TableIcon/TableIcon.tsx b/ui/packages/platform/src/pages/Bot/HintCards/TableIcon/TableIcon.tsx deleted file mode 100644 index a4f2fb0e..00000000 --- a/ui/packages/platform/src/pages/Bot/HintCards/TableIcon/TableIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react' - -export const TableIcon = () => { - return ( - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/HintCards/WrenchIcon/WrenchIcon.tsx b/ui/packages/platform/src/pages/Bot/HintCards/WrenchIcon/WrenchIcon.tsx deleted file mode 100644 index ad2fa49d..00000000 --- a/ui/packages/platform/src/pages/Bot/HintCards/WrenchIcon/WrenchIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -export const WrenchIcon = () => { - return ( - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/ErrorMessage/ErrorMessage.tsx b/ui/packages/platform/src/pages/Bot/Messages/ErrorMessage/ErrorMessage.tsx deleted file mode 100644 index cfc973df..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/ErrorMessage/ErrorMessage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; -import Alert from '@mui/material/Alert'; -import ReactMarkdown from "react-markdown"; -import { makeStyles } from "@material-ui/core"; - -const useStyles = makeStyles(() => ({ - message: { - '& p': { - padding: 0, - margin: 0 - } - } -})) - -type ErrorMessageProps = { - content: string -} - -export const ErrorMessage = (props: ErrorMessageProps) => { - const { content } = props; - const classes = useStyles() - return ( - - {content} - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/CodeBlock/CodeBlock.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/CodeBlock/CodeBlock.tsx deleted file mode 100644 index 564c21a9..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/CodeBlock/CodeBlock.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { memo, useState } from 'react'; -import { Accordion, AccordionDetails, AccordionSummary, Typography, makeStyles, Button } from '@material-ui/core'; -import { Prism as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter' -import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' -import languages from 'react-syntax-highlighter/dist/esm/languages/prism/supported-languages'; -import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; -import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; -import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline'; -import CodeIcon from '@material-ui/icons/Code'; -import { formatLanguageName } from "../../../utils"; - -const useStyles = makeStyles((theme) => ({ - container: { - marginTop: '.5em', - width: '100%' - }, - header: { - background: 'rgb(240, 240, 240)', - borderTopLeftRadius: 8, - borderTopRightRadius: 8, - padding: '.2rem 1rem', - display: 'flex' - }, - languageName: { - fontSize: '0.813rem', - color: theme.palette.text.primary - }, - copyButton: { - marginLeft: 'auto', - color: theme.palette.text.primary, - padding: '0', - minHeight: 'auto', - fontSize: '0.813rem', - border: 0, - '&:hover': { - backgroundColor: 'transparent' - } - }, - copyButtonIcon: { - width: '0.813rem', - }, - summary: { - color: 'rgb(166, 38, 164)', - textDecoration: 'underline', - textDecorationStyle: 'dotted', - cursor: 'pointer', - backgroundColor: 'transparent', - boxShadow: 'none', - display: 'inline-flex', - minHeight: '2rem!important', - padding: 0, - '&:hover': { - textDecoration: 'none' - } - }, - summaryText: { - display: 'inline-flex', - alignItems: 'center' - }, - summaryTextIcon: { - marginRight: 8, - fontSize: '1rem' - }, - details: { - padding: 0, - backgroundColor: 'transparent' - }, - accordion: { - boxShadow: 'none', - backgroundColor: 'transparent', - }, - pre: { - width: '100%', - marginTop: '0!important', - - } -})); - -type CodeBlockProps = { value: string, language?: string | null }; - -export const CodeBlock = memo(({ value, language }: CodeBlockProps) => { - const classes = useStyles(); - const [expanded, setExpanded] = useState(false); - const [copied, setCopied] = useState(false); - - const codeLines = value.split('\n'); - const handleToggle = () => setExpanded(!expanded); - - - const isValidLanguage = language && languages.includes(language); - - const syntaxHighlighterProps: SyntaxHighlighterProps = { - showLineNumbers: true, - language: language || 'sql', - style: oneLight, - className: classes.pre, - children: value - } - - const handleCopy = () => { - if ('clipboard' in navigator) { - navigator.clipboard.writeText(value).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - } - }; - - const header = ( -
- {isValidLanguage && {language}} - -
- ) - - if (codeLines.length > 20) { - return ( - - } className={classes.summary}> - - - {expanded ? 'Hide' : 'Show'}{language ? ` ${formatLanguageName(language)}` : ''} code block ({codeLines.length} LOC) - - - -
- {header} - -
-
-
- ); - } - - return ( -
- {header} - -
- ); -}) \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/MermaidDiagram/MermaidDiagram.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/MermaidDiagram/MermaidDiagram.tsx deleted file mode 100644 index a3100717..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/MermaidDiagram/MermaidDiagram.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import mermaid from 'mermaid'; -import { makeStyles } from "@material-ui/core"; -import { MermaidDiagramControls } from "./MermaidDiagramControls"; -import cn from "classnames"; - -type MermaidDiagramProps = { - chart: string -} - -type DiagramPosition = { - x: number, - y: number -} - -type DiagramState = { - scale: number, - position: DiagramPosition, - startPosition: DiagramPosition, - dragging: boolean -} - -const useStyles = makeStyles( - (theme) => ({ - container: { - position: 'relative', - width: '100%', - overflow: 'hidden' - }, - mermaid: { - [theme.breakpoints.up('sm')]: { - display: 'flex', - justifyContent: 'center', - } - }, - })) - -mermaid.initialize({ startOnLoad: true, er: { useMaxWidth: false } }); - -export const MermaidDiagram = React.memo((props: MermaidDiagramProps) => { - const { chart } = props; - - const classes = useStyles(); - - // Consolidated state management - const [diagramState, setDiagramState] = useState({ - scale: 1, - position: { x: 0, y: 0 }, - dragging: false, - startPosition: { x: 0, y: 0 }, - }); - - const [isDiagramValid, setDiagramValid] = useState(null); - const [diagramError, setDiagramError] = useState(null) - - const diagramRef = useRef(null); - - useEffect(() => { - let isMounted = true; - if (isDiagramValid === null || chart) { - mermaid.parse(chart) - .then(() => { - if (isMounted) { - setDiagramValid(true); - mermaid.contentLoaded(); - } - }) - .catch((e) => { - if (isMounted) { - setDiagramValid(false); - setDiagramError(e.message) - console.error('Diagram contains errors:', e.message); - } - }); - } - - return () => { - isMounted = false; - }; - }, [chart, isDiagramValid]); - - const handleZoomIn = useCallback(() => { - setDiagramState((prev) => ({ - ...prev, - scale: Math.min(prev.scale + 0.1, 2), - })); - }, []); - - const handleZoomOut = useCallback(() => { - setDiagramState((prev) => ({ - ...prev, - scale: Math.max(prev.scale - 0.1, 0.8), - })); - }, []); - - const handleMouseDown = useCallback((event: React.MouseEvent) => { - setDiagramState((prev) => ({ - ...prev, - dragging: true, - startPosition: { x: event.clientX - prev.position.x, y: event.clientY - prev.position.y }, - })); - }, []); - - const handleMouseMove = useCallback((event: React.MouseEvent) => { - if (diagramState.dragging) { - setDiagramState((prev) => ({ - ...prev, - position: { x: event.clientX - prev.startPosition.x, y: event.clientY - prev.startPosition.y }, - })); - } - }, [diagramState.dragging]); - - const handleMouseUp = useCallback(() => { - setDiagramState((prev) => ({ ...prev, dragging: false })); - }, []); - - const handleTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - setDiagramState((prev) => ({ - ...prev, - dragging: true, - startPosition: { x: touch.clientX - prev.position.x, y: touch.clientY - prev.position.y }, - })); - }, []); - - const handleTouchMove = useCallback((event: React.TouchEvent) => { - if (diagramState.dragging) { - const touch = event.touches[0]; - setDiagramState((prev) => ({ - ...prev, - position: { x: touch.clientX - prev.startPosition.x, y: touch.clientY - prev.startPosition.y }, - })); - } - }, [diagramState.dragging]); - - const handleTouchEnd = useCallback(() => { - setDiagramState((prev) => ({ ...prev, dragging: false })); - }, []); - - if (isDiagramValid === null) { - return

Validating diagram...

; - } - - if (isDiagramValid) { - return ( -
-
- {chart} -
- -
- ); - } else { - return

{diagramError}

; - } -}); \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/MermaidDiagram/MermaidDiagramControls.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/MermaidDiagram/MermaidDiagramControls.tsx deleted file mode 100644 index d7d6837d..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/MermaidDiagram/MermaidDiagramControls.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import IconButton from "@material-ui/core/IconButton"; -import { ZoomInRounded, ZoomOutRounded, SaveAltRounded, FileCopyOutlined } from "@material-ui/icons"; -import { makeStyles } from "@material-ui/core"; -import React, { useCallback } from "react"; -import Divider from "@material-ui/core/Divider"; - -const useStyles = makeStyles( - () => ({ - container: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - - position: 'absolute', - bottom: 20, - right: 10, - zIndex: 2, - }, - controlButtons: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - - border: '1px solid rgba(0, 0, 0, 0.12)', - borderRadius: 8, - - background: 'white', - - "& .MuiIconButton-root": { - fontSize: '1.5rem', - color: 'rgba(0, 0, 0, 0.72)', - padding: 8, - '&:hover': { - color: 'rgba(0, 0, 0, 0.95)', - }, - '&:first-child': { - borderRadius: '8px 8px 0 0', - }, - '&:last-child': { - borderRadius: ' 0 0 8px 8px', - } - } - }, - divider: { - width: 'calc(100% - 8px)', - }, - actionButtonWrapper: { - border: '1px solid rgba(0, 0, 0, 0.12)', - borderRadius: 8, - background: 'white', - marginBottom: 8, - overflow: 'hidden' - }, - actionButton: { - fontSize: '1.5rem', - color: 'rgba(0, 0, 0, 0.72)', - padding: 8, - borderRadius: 0, - '&:hover': { - color: 'rgba(0, 0, 0, 0.95)', - }, - } - })) - - -type MermaidDiagramControlsProps = { - handleZoomIn: () => void, - handleZoomOut: () => void, - diagramRef: React.RefObject, - sourceCode: string -} - -export const MermaidDiagramControls = (props: MermaidDiagramControlsProps) => { - const { sourceCode, handleZoomOut, handleZoomIn, diagramRef } = props; - const classes = useStyles(); - - const handleSaveClick = useCallback(() => { - if (diagramRef.current) { - const svgElement = diagramRef.current.querySelector('svg'); - if (svgElement) { - const svgData = new XMLSerializer().serializeToString(svgElement); - const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(svgBlob); - - const link = document.createElement('a'); - link.href = url; - link.download = 'er-diagram.svg'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - URL.revokeObjectURL(url); - } - } - }, []); - - const handleCopyClick = async () => { - if ('clipboard' in navigator) { - await navigator.clipboard.writeText(sourceCode); - } - } - - return ( -
-
- - - -
-
- - - -
-
- - - - - - - -
-
- ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/Message.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/Message.tsx deleted file mode 100644 index 3f58cac7..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/Message.tsx +++ /dev/null @@ -1,394 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' -import ReactMarkdown, { Components } from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import remarkGfm from "remark-gfm"; -import { makeStyles } from "@material-ui/core"; -import { colors } from "@postgres.ai/shared/styles/colors"; -import { icons } from "@postgres.ai/shared/styles/icons"; -import { DebugDialog } from "../../DebugDialog/DebugDialog"; -import { CodeBlock } from "./CodeBlock/CodeBlock"; -import { disallowedHtmlTagsForMarkdown } from "../../utils"; -import { MessageStatus, StateMessage } from "../../../../types/api/entities/bot"; -import { MermaidDiagram } from "./MermaidDiagram/MermaidDiagram"; -import { useAiBot } from "../../hooks"; -import { ToolCallRenderer } from "./ToolCallRenderer/ToolCallRenderer"; -import { transformAllCustomTags } from "../utils"; -import { ThinkBlockRenderer } from './ThinkingCard/ThinkingCard'; -import { MessageHeader } from "./MessageHeader/MessageHeader"; - - -export type BaseMessageProps = { - id: string | null; - created_at?: string; - content?: string; - name?: string; - isLoading?: boolean; - formattedTime?: string; - aiModel?: string; - stateMessage?: StateMessage | null; - isCurrentStreamMessage?: boolean; - isPublic?: boolean; - threadId?: string; - status?: MessageStatus -} - -type AiMessageProps = BaseMessageProps & { - isAi: true; - content: string; - aiModel: string; - isCurrentStreamMessage?: boolean; -} - -type HumanMessageProps = BaseMessageProps & { - isAi: false; - name: string; - content: string; -} - -type LoadingMessageProps = BaseMessageProps & { - isLoading: true; - isAi: true; - content?: undefined; - stateMessage: StateMessage | null; -} - -type MessageProps = AiMessageProps | HumanMessageProps | LoadingMessageProps; - -const useStyles = makeStyles( - (theme) => ({ - message: { - padding: 10, - paddingLeft: 60, - position: 'relative', - whiteSpace: 'normal', - [theme.breakpoints.down('xs')]: { - paddingLeft: 30 - }, - '& .markdown pre': { - [theme.breakpoints.down('sm')]: { - display: 'inline-block', - minWidth: '100%', - width: 'auto', - }, - [theme.breakpoints.up('md')]: { - display: 'block', - maxWidth: 'auto', - width: 'auto', - }, - [theme.breakpoints.up('lg')]: { - display: 'block', - maxWidth: 'auto', - width: 'auto', - }, - }, - }, - messageAvatar: { - top: '10px', - left: '15px', - position: 'absolute', - width: 30, - height: 30, - [theme.breakpoints.down('xs')]: { - width: 24, - height: 24, - left: 0, - '& svg': { - width: 24, - height: 24, - } - } - }, - messageAvatarImage: { - width: '100%', - borderRadius: '50%' - }, - messageAuthor: { - fontSize: 14, - fontWeight: 'bold', - }, - messageInfo: { - display: 'inline-block', - marginLeft: 10, - padding: 0, - fontSize: '0.75rem', - color: colors.pgaiDarkGray, - transition: '.2s ease', - background: "none", - border: "none", - textDecoration: "none", - '@media (max-width: 450px)': { - '&:nth-child(1)': { - display: 'none' - } - } - }, - messageInfoActive: { - borderBottom: '1px solid currentcolor', - cursor: 'pointer', - '&:hover': { - color: '#404040' - } - }, - messageHeader: { - height: '1.125rem', - display: 'flex', - flexWrap: 'wrap', - alignItems: 'baseline', - '@media (max-width: 450px)': { - height: 'auto', - } - }, - additionalInfo: { - '@media (max-width: 450px)': { - width: '100%', - marginTop: 4, - marginLeft: -10, - - } - }, - badge: { - fontSize: '0.5rem', - minWidth: '0.714rem', - height: '0.714rem', - padding: '0 0.25rem' - }, - messagesSpinner: { - display: 'flex', - justifyContent: 'center', - padding: 10 - }, - markdown: { - margin: '5px 5px 5px 0', - fontSize: 14, - '& h1': { - marginTop: 5 - }, - '& table': { - borderCollapse: 'collapse', - borderSpacing: 0 - }, - '& tr': { - borderTop: '1px solid #c6cbd1', - background: '#fff' - }, - '& th, & td': { - padding: '10px 13px', - border: '1px solid #dfe2e5' - }, - '& table tr:nth-child(2n)': { - background: '#f6f8fa' - }, - - '& blockquote': { - color: '#666', - margin: 0, - paddingLeft: '3em', - borderLeft: '0.5em #eee solid' - }, - '& img.emoji': { - marginTop: 5 - }, - '& code': { - border: '1px dotted silver', - display: 'inline-block', - borderRadius: 3, - padding: 2, - backgroundColor: '#f6f8fa', - marginBottom: 3, - fontSize: '13px !important', - fontFamily: "'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New'," + - " 'andale mono', 'lucida console', monospace", - }, - '& pre code': { - background: 'none', - border: 0, - margin: 0, - borderRadius: 0, - display: 'inline', - padding: 0, - }, - '& div:not([class]):not([role])': { - display: 'block', - marginBlockStart: '1em', - marginBlockEnd: '1em', - marginInlineStart: 0, - marginInlineEnd: 0, - //animation: `$typing 0.5s steps(30, end), $blinkCaret 0.75s step-end infinite`, - }, - '& .MuiExpansionPanel-root div': { - marginBlockStart: 0, - marginBlockEnd: 0, - }, - }, - loading: { - display: 'block', - marginBlockStart: '1em', - marginBlockEnd: '1em', - marginInlineStart: 0, - marginInlineEnd: 0, - fontSize: 14, - color: colors.pgaiDarkGray, - '&:after': { - overflow: 'hidden', - display: 'inline-block', - verticalAlign: 'bottom', - animation: '$ellipsis steps(4,end) 1.2s infinite', - content: "'\\2026'", - width: 0, - } - }, - '@keyframes ellipsis': { - 'to': { - width: '0.9em' - }, - }, - '@keyframes typing': { - from: { width: 0 }, - to: { width: '100%' }, - }, - '@keyframes blinkCaret': { - from: { borderRightColor: 'transparent' }, - to: { borderRightColor: 'transparent' }, - '50%': { borderRightColor: 'black' }, - }, - }), -) - -export const Message = React.memo((props: MessageProps) => { - const { - id, - isAi, - formattedTime, - content, - name, - created_at, - isLoading, - aiModel, - stateMessage, - isCurrentStreamMessage, - isPublic, - threadId, - status - } = props; - - const { updateMessageStatus } = useAiBot() - - const elementRef = useRef(null); - - - const [isDebugVisible, setDebugVisible] = useState(false); - - - const classes = useStyles(); - - useEffect(() => { - if (!isAi || isCurrentStreamMessage || status === 'read') return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && threadId && id) { - updateMessageStatus(threadId, id, 'read'); - observer.disconnect(); - } - }, - { threshold: 0.1 } - ); - - if (elementRef.current) { - observer.observe(elementRef.current); - } - - return () => { - observer.disconnect(); - }; - }, [id, updateMessageStatus, isCurrentStreamMessage, isAi, threadId, status]); - - const contentToRender = useMemo(() => { - if (!content) return ''; - return transformAllCustomTags(content?.replace(/\n/g, ' \n')); - }, [content]); - - const toggleDebugDialog = () => { - setDebugVisible(prevState => !prevState) - } - - - const renderers = useMemo(() => ({ - p: ({ node, ...props }) =>
, - img: ({ node, ...props }) => , - code: ({ node, inline, className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || ''); - const matchMermaid = /language-mermaid/.test(className || ''); - if (!inline) { - return ( - <> - {matchMermaid && !isCurrentStreamMessage && } - - - ) - } else { - return {children} - } - }, - toolcall: ToolCallRenderer, - thinkblock: ThinkBlockRenderer, - }), []); - - return ( - <> - {id && } -
-
- {isAi - ? Postgres.AI Assistant avatar - : icons.userChatIcon} -
- -
- {isLoading - ? -
-
- {stateMessage && stateMessage.state ? stateMessage.state : 'Thinking'} -
-
- : <> - - {stateMessage && stateMessage.state &&
- {stateMessage.state} -
} - - } -
-
- - ) -}) \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/MessageHeader/MessageHeader.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/MessageHeader/MessageHeader.tsx deleted file mode 100644 index 57656e19..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/MessageHeader/MessageHeader.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from "react"; -import cn from "classnames"; -import { permalinkLinkBuilder } from "../../../utils"; -import { makeStyles } from "@material-ui/core"; -import { colors } from "@postgres.ai/shared/styles/colors"; -import { BaseMessageProps } from "../Message"; - - -const useStyles = makeStyles( - () => ({ - messageAuthor: { - fontSize: 14, - fontWeight: 'bold', - }, - messageInfo: { - display: 'inline-block', - marginLeft: 10, - padding: 0, - fontSize: '0.75rem', - color: colors.pgaiDarkGray, - transition: '.2s ease', - background: "none", - border: "none", - textDecoration: "none", - '@media (max-width: 450px)': { - '&:nth-child(1)': { - display: 'none' - } - } - }, - messageInfoActive: { - borderBottom: '1px solid currentcolor', - cursor: 'pointer', - '&:hover': { - color: '#404040' - } - }, - messageHeader: { - height: '1.125rem', - display: 'flex', - flexWrap: 'wrap', - alignItems: 'baseline', - '@media (max-width: 450px)': { - height: 'auto', - } - }, - additionalInfo: { - '@media (max-width: 450px)': { - width: '100%', - marginTop: 4, - marginLeft: -10, - - } - }, - }), -) - -type MessageHeaderProps = Pick< - BaseMessageProps, - 'name' | 'id' | 'formattedTime' | 'isPublic' | 'isLoading' | 'aiModel' -> & { - isAi: boolean; - toggleDebugDialog: () => void; - createdAt: BaseMessageProps["created_at"]; -}; - -export const MessageHeader = (props: MessageHeaderProps) => { - const {isAi, formattedTime, id, name, createdAt, isLoading, aiModel, toggleDebugDialog, isPublic} = props; - const classes = useStyles(); - return ( -
- - {isAi ? 'Postgres.AI' : name} - - {createdAt && formattedTime && - - {formattedTime} - - } -
- {id && isPublic && <> - | - - permalink - - } - {!isLoading && isAi && id && <> - | - - } - { - aiModel && isAi && <> - | - - {aiModel} - - - } -
-
- ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/ThinkingCard/ThinkingCard.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/ThinkingCard/ThinkingCard.tsx deleted file mode 100644 index 05b200f2..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/ThinkingCard/ThinkingCard.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "@postgres.ai/shared/components/Button2"; -import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; -import { CardContent, Collapse } from "@mui/material"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; - -type ThinkBlockProps = { - 'data-think'?: string; - node?: { - properties?: { - 'data-think'?: string; - dataThink?: string; - }; - }; -} - -type ThinkingCardProps = { - content: string; -} - -const ThinkingCard = ({ content }: ThinkingCardProps) => { - const [expanded, setExpanded] = useState(true); - // TODO: Add "again" - // TODO: Replace with "reasoned for X seconds" - return ( - <> - - - - - - {content} - - - - - ) -} - -export const ThinkBlockRenderer = React.memo((props: ThinkBlockProps) => { - const dataThink = - props?.['data-think'] || - props?.node?.properties?.['data-think'] || - props?.node?.properties?.dataThink; - - if (!dataThink) return null; - - let rawText = ''; - try { - rawText = JSON.parse(dataThink); - } catch (err) { - console.error('Failed to parse data-think JSON:', err); - } - - return ( - - ) -}, (prevProps, nextProps) => { - return prevProps['data-think'] === nextProps['data-think']; -}) \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Message/ToolCallRenderer/ToolCallRenderer.tsx b/ui/packages/platform/src/pages/Bot/Messages/Message/ToolCallRenderer/ToolCallRenderer.tsx deleted file mode 100644 index b9192774..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Message/ToolCallRenderer/ToolCallRenderer.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from "react"; -import { SourcesShortList } from "../../Sources/SourcesShortList"; -import { SourcesFullList } from "../../Sources/SourcesFullList"; -import { Box } from "@mui/material"; - - -type MarkdownNode = { - type: string; - tagName: string; - properties?: { - ['data-json']?: string; - dataJson?: string; - }; - children?: MarkdownNode[]; -} - -type ToolCallRendererProps = { - 'data-json'?: string; - node?: MarkdownNode; -} - -export const ToolCallRenderer = (props: ToolCallRendererProps) => { - const [isSourcesVisible, setSourcesVisible] = useState(false); - - const dataJson = - props?.['data-json'] || - props?.node?.properties?.dataJson; - - if (!dataJson) { - return null; - } - - - let parsed; - try { - const preparedData = JSON.parse(dataJson); - - const cleaned = preparedData.replace(/\\n/g, '').trim(); - - parsed = JSON.parse(cleaned); - } catch (err) { - console.error("ToolCall parsing error: ", err); - return null; - } - - - const toggleSources = () => { - setSourcesVisible(prevState => !prevState) - } - - return ( - <> - - Search query: {parsed?.[0]?.arguments?.input} - Count: {parsed?.[0]?.arguments?.match_count} - Categories: {parsed?.[0]?.arguments?.categories?.join(', ')} - - - {isSourcesVisible && } - - ); -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Messages.tsx b/ui/packages/platform/src/pages/Bot/Messages/Messages.tsx deleted file mode 100644 index db9c5e4a..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Messages.tsx +++ /dev/null @@ -1,312 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { useRef, useEffect, useState } from 'react'; -import { makeStyles, Typography } from "@material-ui/core"; -import cn from "classnames"; -import { ResizeObserver } from '@juggle/resize-observer'; -import {colors} from "@postgres.ai/shared/styles/colors"; -import {PageSpinner} from "@postgres.ai/shared/components/PageSpinner"; -import { usePrev } from 'hooks/usePrev'; -import { getMaxScrollTop, getUserMessagesCount } from './utils'; -import Format from "../../../utils/format"; -import { BotMessage } from "../../../types/api/entities/bot"; -import { Message } from "./Message/Message"; -import { useAiBot } from "../hooks"; -import { HintCards } from "../HintCards/HintCards"; -import { ErrorMessage } from "./ErrorMessage/ErrorMessage"; -import { KBStats } from "../../../components/KBStats/KBStats"; - -const useStyles = makeStyles( - (theme) => ({ - root: { - borderRadius: 4, - overflow: 'hidden', - flex: '1 0 160px', - display: 'flex', - flexDirection: 'column', - }, - emptyChat: { - justifyContent: 'center', - alignItems: 'center', - textAlign: 'center' - }, - emptyChatMessage: { - maxWidth: '80%', - fontSize: 14, - [theme.breakpoints.down(330)]: { - fontSize: 12 - } - }, - messages: { - overflowY: 'auto', - flex: '1 1 100%' - }, - message: { - padding: 10, - paddingLeft: 60, - position: 'relative', - whiteSpace: 'normal', - [theme.breakpoints.down('xs')]: { - paddingLeft: 30 - }, - '& .markdown pre': { - [theme.breakpoints.down('sm')]: { - display: 'inline-block', - minWidth: '100%', - width: 'auto', - }, - [theme.breakpoints.up('md')]: { - display: 'block', - maxWidth: 'auto', - width: 'auto', - }, - [theme.breakpoints.up('lg')]: { - display: 'block', - maxWidth: 'auto', - width: 'auto', - }, - }, - }, - messageAvatar: { - top: '10px', - left: '15px', - position: 'absolute', - width: 30, - height: 30, - [theme.breakpoints.down('xs')]: { - width: 24, - height: 24, - left: 0, - '& svg': { - width: 24, - height: 24, - } - } - }, - messageAvatarImage: { - width: '100%', - borderRadius: '50%' - }, - messageAuthor: { - fontSize: 14, - fontWeight: 'bold', - }, - messageInfo: { - display: 'inline-block', - marginLeft: 10, - fontSize: '0.75rem', - color: colors.pgaiDarkGray, - transition: '.2s ease' - }, - messageInfoActive: { - '&:hover': { - color: '#404040' - } - }, - messageHeader: { - height: '1.125rem', - }, - messagesSpinner: { - display: 'flex', - justifyContent: 'center', - padding: 10 - } - }), -) - -type Time = string - -type FormattedTime = { - [id: string]: Time -} - -export const Messages = React.memo(({orgId, threadId}: {orgId: number, threadId?: string}) => { - const { - messages, - loading: isLoading, - wsLoading: isWaitingForAnswer, - stateMessage, - currentStreamMessage, - isStreamingInProcess, - errorMessage - } = useAiBot(); - - const rootRef = useRef(null); - const wrapperRef = useRef(null); - const atBottomRef = useRef(true); - const shouldSkipScrollCalcRef = useRef(false); - const classes = useStyles(); - const [formattedTimes, setFormattedTimes] = useState({}); - - // Scroll handlers. - const scrollBottom = () => { - shouldSkipScrollCalcRef.current = true; - if (rootRef.current) { - rootRef.current.scrollTop = getMaxScrollTop(rootRef.current); - } - atBottomRef.current = true; - }; - - const scrollBottomIfNeed = () => { - if (!atBottomRef.current) { - return; - } - - scrollBottom(); - }; - - // Listening resizing of wrapper. - useEffect(() => { - const observedElement = wrapperRef.current; - if (!observedElement) return; - - const resizeObserver = new ResizeObserver(scrollBottomIfNeed); - resizeObserver.observe(observedElement); - - return () => resizeObserver.unobserve(observedElement); - }, [wrapperRef.current]); - - // Scroll to bottom if user sent new message. - const userMessagesCount = getUserMessagesCount(messages || [] as BotMessage[]); - const prevUserMessagesCount = usePrev(userMessagesCount); - - useEffect(() => { - if ((userMessagesCount > (prevUserMessagesCount || 0)) && rootRef.current) { - scrollBottom(); - } - }, [prevUserMessagesCount, userMessagesCount]); - - useEffect(() => { - if (!isLoading && !isStreamingInProcess) { - scrollBottomIfNeed(); - } - }, [isLoading, scrollBottomIfNeed, isStreamingInProcess]); - - useEffect(() => { - const updateTimes = () => { - if (messages && messages.length > 0) { - const newFormattedTimes: FormattedTime = {}; - messages.forEach(message => { - newFormattedTimes[message.id] = Format.timeAgo(message.created_at) || ''; - }); - setFormattedTimes(newFormattedTimes); - } - }; - - updateTimes(); - - const intervalId = setInterval(updateTimes, 60000); - - return () => clearInterval(intervalId); - }, [messages]); - - // Check auto-scroll condition. - const calcIsAtBottom = () => { - if (shouldSkipScrollCalcRef.current) { - shouldSkipScrollCalcRef.current = false; - return; - } - if (rootRef.current) { - atBottomRef.current = rootRef.current.scrollTop >= getMaxScrollTop(rootRef.current); - } - }; - - if (isLoading) { - return ( -
- -
- ) - } - - if (!messages || messages.length === 0) { - return ( -
- - Postgres.AI Assistant can make mistakes.
- Consider checking important information.
- Depending on settings, LLM service provider such as GCP or OpenAI is used. -
- - -
- ) - } - - return ( -
-
-
- {messages && - messages.map((message) => { - const { - id, - is_ai, - last_name, - first_name, - display_name, - slack_profile, - created_at, - content, - ai_model, - is_public, - status - } = message; - let name = 'You'; - - if (first_name || last_name) { - name = `${first_name || ''} ${last_name || ''}`.trim(); - } else if (display_name) { - name = display_name; - } else if (slack_profile) { - name = slack_profile; - } - - let formattedTime = ''; - - if (formattedTimes) { - formattedTime = formattedTimes[id] - } - - return ( - - ) - })} - { - currentStreamMessage && - } - {isWaitingForAnswer && - - } - { - errorMessage && - } -
-
-
- ); -}); diff --git a/ui/packages/platform/src/pages/Bot/Messages/Sources/SourceCard/SourceCard.tsx b/ui/packages/platform/src/pages/Bot/Messages/Sources/SourceCard/SourceCard.tsx deleted file mode 100644 index 1f6b9c53..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Sources/SourceCard/SourceCard.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import { makeStyles } from "@material-ui/core"; -import { colors } from "@postgres.ai/shared/styles/colors"; -import { ArrowDropDown, ArrowDropDownOutlined, KeyboardArrowDown, KeyboardArrowUp } from "@material-ui/icons"; - -const useStyles = makeStyles((theme) => ({ - shortContainer: { - backgroundColor: 'transparent', - border: '1px solid rgba(0, 0, 0, 0.25)', - borderRadius: '0.5rem', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - cursor: 'pointer', - width: '8rem', - height: '5rem', - padding: '0.5rem', - color: 'black', - textAlign: 'left', - fontSize: '0.938rem', - transition: '0.2s ease-in', - textDecoration: "none", - overflow: 'hidden', - '&:hover, &:focus-visible': { - border: '1px solid rgba(0, 0, 0, 0.8)', - }, - [theme.breakpoints.down(330)]: { - fontSize: '.75rem' - }, - }, - fullContainer: { - width: '100%', - height: 'auto', - border: 'none!important', - }, - showMoreContainer: { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - fontSize: '1.5rem', - color: colors.pgaiDarkGray, - width: '2rem', - }, - link: { - fontSize: '0.688rem', - marginBottom: 4, - color: colors.pgaiDarkGray - }, - content: { - fontSize: '0.75rem', - display: '-webkit-box', - '-webkit-line-clamp': 3, - '-webkit-box-orient': 'vertical', - overflow: 'hidden', - textOverflow: 'ellipsis', - wordWrap: 'break-word', - overflowWrap: 'break-word', - }, - title: { - fontSize: '1rem', - display: '-webkit-box', - '-webkit-line-clamp': 2, - ' -webkit-box-orient': 'vertical', - overflow: 'hidden', - textOverflow: 'ellipsis', - fontWeight: 500 - }, - fullListCardContent: { - fontSize: '0.875rem', - marginTop: 4, - } -})); - -type SourceCardProps = { - title?: string; - content?: string; - url?: string; - variant: 'shortListCard' | 'fullListCard' | 'showMoreCard', - isVisible?: boolean; - onShowFullListClick?: () => void; -} - -export const SourceCard = (props: SourceCardProps) => { - const { title, content, url, variant, isVisible, onShowFullListClick } = props; - const classes = useStyles(); - - if (variant === 'shortListCard') { - return ( - - - {new URL(url || '').hostname} - - - {title} - - - ) - } else if (variant === 'fullListCard') { - return ( - - - {new URL(url || '').hostname} - - - {title} - - - {content} - - - ) - } else if (variant === 'showMoreCard') { - return ( - - ) - } else { - return null; - } -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Sources/SourcesFullList.tsx b/ui/packages/platform/src/pages/Bot/Messages/Sources/SourcesFullList.tsx deleted file mode 100644 index cb27a37b..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Sources/SourcesFullList.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Box } from '@mui/material'; -import { Button } from '@postgres.ai/shared/components/Button2'; -import React, { useMemo, useState } from 'react' -import { ToolCallDataItem, ToolCallResultItem } from "../../../../types/api/entities/bot"; -import { SourceCard } from './SourceCard/SourceCard'; - - -type SourcesFullListProps = { - toolCallResult: ToolCallResultItem[] -} - -const INITIAL_COUNT = 10; - -export const SourcesFullList = (props: SourcesFullListProps) => { - const { toolCallResult } = props; - - const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT); - - const sortedData = useMemo(() => { - if (!toolCallResult) return []; - - const aggregated: ToolCallDataItem[] = []; - - toolCallResult.forEach(item => { - if (item?.function_name === 'rag_search') { - aggregated.push(...item.data); - } - }); - - const uniqueItemsMap = new Map(); - - aggregated.forEach(item => { - if (item.url && !uniqueItemsMap.has(item.url)) { - uniqueItemsMap.set(item.url, item); - } - }); - - return Array.from(uniqueItemsMap.values()) - .sort((a, b) => b.similarity - a.similarity); - - }, [toolCallResult]); - - const handleShowMore = () => { - setVisibleCount((prev) => prev + INITIAL_COUNT); - }; - - const visibleItems = sortedData.slice(0, visibleCount); - - return ( - - {visibleItems.map((source) => ( - - - - ))} - - {visibleCount < sortedData.length && ( - - )} - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/Sources/SourcesShortList.tsx b/ui/packages/platform/src/pages/Bot/Messages/Sources/SourcesShortList.tsx deleted file mode 100644 index 86755ad5..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/Sources/SourcesShortList.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useMemo } from 'react'; -import Box from "@mui/material/Box/Box"; -import { SourceCard } from "./SourceCard/SourceCard"; -import { ToolCallDataItem, ToolCallResultItem } from "../../../../types/api/entities/bot"; -import { useMediaQuery } from '@mui/material'; - -type SourcesShortListProps = { - toolCallResult: ToolCallResultItem[] - isVisible: boolean - onChangeVisibility: () => void -} - - -export const SourcesShortList = (props: SourcesShortListProps) => { - const { toolCallResult, isVisible, onChangeVisibility } = props - const isMobile = useMediaQuery('@media (max-width: 760px)') - - const sortedData = useMemo(() => { - if (!toolCallResult) return [] - - let aggregated: ToolCallDataItem[] = [] - toolCallResult.forEach(item => { - if (item?.function_name === 'rag_search') { - aggregated = aggregated.concat(item.data) - } - }) - - aggregated.sort((a, b) => b.similarity - a.similarity) - - return aggregated - }, [toolCallResult]) - - const visibleCount = isMobile ? 2 : 4 - const visibleItems = sortedData.slice(0, visibleCount) - - return ( - - {visibleItems.map((source, index) => ( - - - - ))} - - {sortedData.length > visibleCount && ( - - )} - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/Messages/utils.ts b/ui/packages/platform/src/pages/Bot/Messages/utils.ts deleted file mode 100644 index 017fedfd..00000000 --- a/ui/packages/platform/src/pages/Bot/Messages/utils.ts +++ /dev/null @@ -1,121 +0,0 @@ -import {BotMessage} from "../../../types/api/entities/bot"; - -export const getMaxScrollTop = (element: HTMLElement) => - element.scrollHeight - element.clientHeight - - -export const getUserMessagesCount = (messages: BotMessage[]) => { - if (!messages) { - return 0 - } - - const keys = Object.keys(messages) - - return keys.reduce((count, key) => { - const idx = Number(key) - return !messages[idx].is_ai ? count + 1 : count - }, 0) -} - -const THINK_REGEX = /([\s\S]*?)<\/think>/g; -const TOOLCALL_REGEX = /([\s\S]*?)<\/toolcall>/g; - -export function unescapeHtml(escaped: string): string { - return escaped - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'"); -} - -const THINK_OPEN = ''; -const THINK_CLOSE = ''; - -/* WIP: Rendering refactoring must be done in the future */ -function transformThinkingBlocksPartial(text: string): string { - let result = ''; - let currentIndex = 0; - - while (true) { - const openIdx = text.indexOf(THINK_OPEN, currentIndex); - if (openIdx === -1) { - result += text.slice(currentIndex); - break; - } - - result += text.slice(currentIndex, openIdx); - - const afterOpen = openIdx + THINK_OPEN.length; - const closeIdx = text.indexOf(THINK_CLOSE, afterOpen); - if (closeIdx === -1) { - const partialContent = text.slice(afterOpen); - result += makeThinkblockHTML(partialContent, false); - break; - } else { - const finalContent = text.slice(afterOpen, closeIdx); - result += makeThinkblockHTML(finalContent, true); - currentIndex = closeIdx + THINK_CLOSE.length; - } - } - - return result; -} - -function transformThinkingBlocksFinal(text: string): string { - return text.replace(THINK_REGEX, (_, innerContent) => { - return makeThinkblockHTML(innerContent, true); - }); -} - -function makeThinkblockHTML(content: string, isFinal: boolean): string { - const status = isFinal ? 'final' : 'partial'; - let json = JSON.stringify(content); - json = json - .replace(/'/g, '\\u0027') - .replace(//g, '\\u003e') - .replace(/&/g, '\\u0026'); - - return ` - - - -`; -} - -function makeToolCallHTML(content: string): string { - let json = JSON.stringify(content); - - json = json - .replace(/'/g, '\\u0027') - .replace(//g, '\\u003e') - .replace(/&/g, '\\u0026'); - - return ` - - - -`; -} - -function transformToolCallBlocksFinal(text: string): string { - return text.replace(TOOLCALL_REGEX, (_, innerContent: string) => { - return makeToolCallHTML(innerContent); - }); -} - -export function transformAllCustomTags(text: string): string { - let result = text; - - if (text.includes("") && text.includes("")) { - result = transformThinkingBlocksFinal(text); - } - - if (result.includes("") && result.includes("")) { - result = transformToolCallBlocksFinal(result); - } - - return result; -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/ModelSelector/ModelSelector.tsx b/ui/packages/platform/src/pages/Bot/ModelSelector/ModelSelector.tsx deleted file mode 100644 index 2b26eda0..00000000 --- a/ui/packages/platform/src/pages/Bot/ModelSelector/ModelSelector.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { FormControl, Select, MenuItem, Typography, InputLabel, useMediaQuery } from "@mui/material"; -import { SelectChangeEvent } from "@mui/material/Select"; - -import { useAiBot } from "../hooks"; - -export const ModelSelector = () => { - const { aiModel, aiModels, setAiModel } = useAiBot(); - const isSmallScreen = useMediaQuery("(max-width: 960px)"); - - const handleChange = (event: SelectChangeEvent) => { - const [vendor, name] = (event.target.value as string).split("/"); - const model = aiModels?.find( - (model) => model.vendor === vendor && model.name === name - ); - if (model) setAiModel(model); - }; - - const truncateText = (text: string, maxLength: number) => { - return text.length > maxLength ? text.substring(0, maxLength) + "..." : text; - }; - - return ( - - - - ); -}; diff --git a/ui/packages/platform/src/pages/Bot/SettingsDialog/SettingsDialog.tsx b/ui/packages/platform/src/pages/Bot/SettingsDialog/SettingsDialog.tsx deleted file mode 100644 index 6a5dcab4..00000000 --- a/ui/packages/platform/src/pages/Bot/SettingsDialog/SettingsDialog.tsx +++ /dev/null @@ -1,387 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { useEffect, useState } from 'react' -import { useRouteMatch } from "react-router-dom"; -import { - Button, - Dialog, - FormControlLabel, - IconButton, - makeStyles, - Radio, - RadioGroup, - TextField, Theme, - Typography, -} from '@material-ui/core' -import MuiDialogTitle from '@material-ui/core/DialogTitle' -import MuiDialogContent from '@material-ui/core/DialogContent' -import MuiDialogActions from '@material-ui/core/DialogActions' -import FormLabel from '@mui/material/FormLabel' -import { styles } from '@postgres.ai/shared/styles/styles' -import { icons } from '@postgres.ai/shared/styles/icons' -import { Spinner } from '@postgres.ai/shared/components/Spinner' -import { useAiBot, Visibility } from "../hooks"; -import { AiModel } from "../../../types/api/entities/bot"; -import settings from "../../../utils/settings"; -import { Link } from "@postgres.ai/shared/components/Link2"; -import { ExternalIcon } from "@postgres.ai/shared/icons/External"; -import Divider from "@material-ui/core/Divider"; -import cn from "classnames"; - -type DialogTitleProps = { - id: string - children: React.ReactNode - onClose: () => void -} - -type PublicChatDialogProps = { - isOpen: boolean - onClose: () => void - threadId: string | null - orgAlias: string - isSubscriber: boolean -} - -const useDialogTitleStyles = makeStyles( - (theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, - dialogTitle: { - fontSize: 16, - lineHeight: '19px', - fontWeight: 600, - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: 4, - color: theme.palette.grey[500], - }, - }), - { index: 1 }, -) - -const DialogTitle = (props: DialogTitleProps) => { - const classes = useDialogTitleStyles() - const { children, onClose, ...other } = props - return ( - - {children} - {onClose ? ( - - {icons.closeIcon} - - ) : null} - - ) -} - -const useDialogContentStyles = makeStyles( - (theme) => ({ - dialogContent: { - paddingTop: 10, - padding: theme.spacing(2), - }, - }), - { index: 1 }, -) - -const DialogContent = (props: { children: React.ReactNode }) => { - const classes = useDialogContentStyles() - return ( - - {props.children} - - ) -} - -const useDialogActionsStyles = makeStyles( - (theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, - }), - { index: 1 }, -) - -const DialogActions = (props: { children: React.ReactNode }) => { - const classes = useDialogActionsStyles() - return ( - - {props.children} - - ) -} - -const useDialogStyles = makeStyles( - (theme) => ({ - textField: { - ...styles.inputField, - marginTop: '0px', - width: 480, - [theme.breakpoints.down('sm')]: { - - } - }, - copyButton: { - marginTop: '-3px', - fontSize: '20px', - }, - urlContainer: { - marginTop: 8, - paddingLeft: 20, - [theme.breakpoints.down('sm')]: { - padding: 0, - width: '100%', - '& .MuiTextField-root': { - maxWidth: 'calc(100% - 36px)' - } - }, - }, - radioGroup: { - fontSize: 12, - '&:not(:last-child)': { - marginBottom: 12 - } - }, - dialogContent: { - paddingTop: 10, - }, - unlockNote: { - marginTop: 2, - '& ol': { - paddingLeft: 18, - marginTop: 4, - marginBottom: 0 - } - }, - unlockNoteDemo: { - paddingLeft: 20 - }, - formControlLabel: { - '& .Mui-disabled > *, & .Mui-disabled': { - color: 'rgba(0, 0, 0, 0.6)' - }, - [theme.breakpoints.down('sm')]: { - marginRight: 0, - alignItems: 'flex-start', - '&:first-child': { - marginTop: 6 - } - }, - }, - formControlLabelRadio: { - [theme.breakpoints.down('sm')]: { - padding: '4px 9px' - } - }, - externalIcon: { - width: 14, - height: 14, - marginLeft: 4, - transform: 'translateY(2px)', - }, - divider: { - margin: '12px 0' - } - }), - { index: 1 }, -) - -export const SettingsDialog = (props: PublicChatDialogProps) => { - const { - onClose, - isOpen, - threadId, - orgAlias, - isSubscriber - } = props; - - const { - chatVisibility, - changeChatVisibility, - isChangeVisibilityLoading, - getChatsList, - aiModels, - aiModel: activeModel, - setAiModel: setActiveModel, - setChatVisibility - } = useAiBot(); - - const [model, setModel] = useState(activeModel) - const [visibility, setVisibility] = useState(chatVisibility); - - const classes = useDialogStyles(); - - const publicUrl = `https://postgres.ai/chats/${threadId}`; - - const isDemoOrg = useRouteMatch(`/${settings.demoOrgAlias}`); - - const handleCopyUrl = () => { - if ('clipboard' in navigator) { - navigator.clipboard.writeText(publicUrl); - } - } - - const handleSaveChanges = () => { - if (model && model !== activeModel) { - setActiveModel(model) - } - if (visibility !== chatVisibility && threadId) { - changeChatVisibility(threadId, visibility === Visibility.PUBLIC) - getChatsList(); - } else if (visibility !== chatVisibility) { - setChatVisibility(visibility) - } - onClose() - } - - useEffect(() => { - if (isOpen) { - if (visibility !== chatVisibility) { - setVisibility(chatVisibility) - } - if (model?.name !== activeModel?.name) { - setModel(activeModel) - } - } - }, [isOpen]); - - const urlField = ( -
- event.target.select()} - InputProps={{ - readOnly: true, - id: 'sharedUrl', - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel, - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper, - }} - /> - - - {icons.copyIcon} - -
- ) - - return ( - - - Chat settings - - - <> - Visibility - { - setVisibility(event.target.value as Visibility) - }} - className={classes.radioGroup} - > - } - label={<>Public: anyone can view chats, but only team members can respond} - aria-label="Public: anyone can view chats, but only team members can respond" - /> - {visibility === Visibility.PUBLIC && threadId && ( -
{urlField}
- )} - } - label={<>Private: chats are visible only to members of your organization} - aria-label="Private: chats are visible only to members of your organization" - disabled={Boolean(isDemoOrg) || !isSubscriber} - /> - {Boolean(isDemoOrg) && Private chats are not allowed in "Demo"} - {!Boolean(isDemoOrg) && !isSubscriber && - Unlock private conversations by either: -
    -
  1. - - Installing a DBLab SE instance - - -
  2. -
  3. - - Becoming a Postgres.AI consulting customer - - -
  4. -
-
} -
- -
- - - - - -
- ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/SettingsPanel/SettingsPanel.tsx b/ui/packages/platform/src/pages/Bot/SettingsPanel/SettingsPanel.tsx deleted file mode 100644 index 27bb931d..00000000 --- a/ui/packages/platform/src/pages/Bot/SettingsPanel/SettingsPanel.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useMemo } from 'react'; -import cn from "classnames"; -import { Button, makeStyles, useMediaQuery } from "@material-ui/core"; -import SettingsOutlinedIcon from "@material-ui/icons/SettingsOutlined"; -import { colors } from "@postgres.ai/shared/styles/colors"; -import { theme } from "@postgres.ai/shared/styles/theme"; -import { permalinkLinkBuilder } from "../utils"; -import { useAiBot } from "../hooks"; -import DeveloperModeIcon from "@material-ui/icons/DeveloperMode"; -import { ModelSelector } from "../ModelSelector/ModelSelector"; -import { Skeleton } from "@mui/material"; - -export type SettingsPanelProps = { - onSettingsClick: () => void; - onConsoleClick: () => void; -} - -const useStyles = makeStyles((theme) => ({ - label: { - backgroundColor: colors.primary.main, - color: colors.primary.contrastText, - display: 'inline-block', - borderRadius: 3, - fontSize: 10, - lineHeight: '12px', - padding: 2, - paddingLeft: 3, - paddingRight: 3, - verticalAlign: 'text-top', - textDecoration: 'none', - '& > span': { - textTransform: 'capitalize' - } - }, - labelVisibility: { - marginRight: '0.5rem', - [theme.breakpoints.down('sm')]: { - marginRight: '0.25rem' - }, - '&:hover': { - backgroundColor: colors.secondary1.main - } - }, - labelPrivate: { - backgroundColor: colors.pgaiDarkGray, - }, - disabled: { - pointerEvents: "none" - }, - button: { - marginLeft: '0.5rem', - [theme.breakpoints.down('sm')]: { - border: 'none', - minWidth: '2rem', - height: '2rem', - padding: 0, - marginLeft: '0.25rem', - '& .MuiButton-startIcon': { - margin: 0 - } - } - } - }), -) - -export const SettingsPanel = (props: SettingsPanelProps) => { - const { onSettingsClick, onConsoleClick } = props; - const { loading } = useAiBot() - const classes = useStyles(); - const matches = useMediaQuery(theme.breakpoints.down('sm')); - const { messages, chatVisibility, aiModelsLoading } = useAiBot(); - const permalinkId = useMemo(() => messages?.[0]?.id, [messages]); - - return ( - <> - {permalinkId && <> - {loading - ? - : - {chatVisibility} thread - - } - } - {!aiModelsLoading && } - - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/hints.ts b/ui/packages/platform/src/pages/Bot/hints.ts deleted file mode 100644 index 6ff3bcd1..00000000 --- a/ui/packages/platform/src/pages/Bot/hints.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type HintType = 'design' | 'settings' | 'performance' | 'common' - -export type Hint = { - hint: string, - prompt: string, - type: HintType -} - -export const hints: Hint[] = [ - { - hint: 'Help me design an IoT system DB schema', - prompt: 'Help me design an IoT system DB schema', - type: 'design' - }, - { - hint: 'Should I enable wal_compression?', - prompt: 'Should I enable wal_compression?', - type: 'settings', - }, - { - hint: 'Demonstrate benefits of Index-Only Scans', - prompt: 'Show the benefits of Index-Only Scans. Invent a test case, create two types of queries, run them on Postgres 16, and show the plans. Then explain the difference.', - type: 'performance', - }, - { - hint: 'What do people say about subtransactions?', - prompt: 'What do people say about subtransactions?', - type: 'common' - }, -] \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/hooks.tsx b/ui/packages/platform/src/pages/Bot/hooks.tsx deleted file mode 100644 index 9c103847..00000000 --- a/ui/packages/platform/src/pages/Bot/hooks.tsx +++ /dev/null @@ -1,705 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { - createContext, - Dispatch, - SetStateAction, - useCallback, - useContext, - useEffect, - useState -} from "react"; -import useWebSocket, {ReadyState} from "react-use-websocket"; -import { useLocation } from "react-router-dom"; -import { - BotMessage, - DebugMessage, - AiModel, - StateMessage, - StreamMessage, - ErrorMessage, - MessageStatus -} from "../../types/api/entities/bot"; -import {getChatsWithWholeThreads} from "../../api/bot/getChatsWithWholeThreads"; -import {getChats} from "api/bot/getChats"; -import {useAlertSnackbar} from "@postgres.ai/shared/components/AlertSnackbar/useAlertSnackbar"; -import {localStorage} from "../../helpers/localStorage"; -import { updateChatVisibility } from "../../api/bot/updateChatVisibility"; -import { getAiModels } from "../../api/bot/getAiModels"; -import { getDebugMessages } from "../../api/bot/getDebugMessages"; - - -const WS_URL = process.env.REACT_APP_WS_URL || ''; - -const DEFAULT_MODEL_NAME = 'gpt-4o-mini' - -export enum Visibility { - PUBLIC = 'public', - PRIVATE = 'private' -} - -type ErrorType = { - code?: number; - message: string; - type?: 'connection' | 'chatNotFound'; -} - -type SendMessageType = { - content: string; - thread_id?: string | null; - org_id?: number | null; -} - -type UseAiBotReturnType = { - messages: BotMessage[] | null; - error: ErrorType | null; - loading: boolean; - sendMessage: (args: SendMessageType) => Promise; - clearChat: () => void; - wsLoading: boolean; - wsReadyState: ReadyState; - changeChatVisibility: (threadId: string, isPublic: boolean) => void; - isChangeVisibilityLoading: boolean; - unsubscribe: (threadId: string) => void; - chatVisibility: Visibility; - setChatVisibility: Dispatch>; - debugMessages: DebugMessage[] | null; - getDebugMessagesForWholeThread: () => void; - chatsList: UseBotChatsListHook['chatsList']; - chatsListLoading: UseBotChatsListHook['loading']; - getChatsList: UseBotChatsListHook['getChatsList']; - aiModel: UseAiModelsList['aiModel']; - setAiModel: UseAiModelsList['setAiModel']; - aiModels: UseAiModelsList['aiModels']; - aiModelsLoading: UseAiModelsList['loading']; - debugMessagesLoading: boolean; - stateMessage: StateMessage | null; - isStreamingInProcess: boolean; - currentStreamMessage: StreamMessage | null; - errorMessage: ErrorMessage | null; - updateMessageStatus: (threadId: string, messageId: string, status: MessageStatus) => void -} - -type UseAiBotArgs = { - threadId?: string; - orgId?: number - isPublicByDefault?: boolean - userId?: number | null -} - -export const useAiBotProviderValue = (args: UseAiBotArgs): UseAiBotReturnType => { - const { threadId, orgId, isPublicByDefault, userId } = args; - const { showMessage, closeSnackbar } = useAlertSnackbar(); - const { - aiModels, - aiModel, - setAiModel, - loading: aiModelsLoading - } = useAiModelsList(orgId); - let location = useLocation<{skipReloading?: boolean}>(); - - const { - chatsList, - loading: chatsListLoading, - getChatsList, - } = useBotChatsList(orgId); - - const [messages, setMessages] = useState(null); - const [errorMessage, setErrorMessage] = useState(null) - const [debugMessages, setDebugMessages] = useState(null); - const [debugMessagesLoading, setDebugMessagesLoading] = useState(false); - const [isLoading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [wsLoading, setWsLoading] = useState(false); - const [chatVisibility, setChatVisibility] = useState(Visibility.PUBLIC); - const [stateMessage, setStateMessage] = useState(null); - const [currentStreamMessage, setCurrentStreamMessage] = useState(null); - const [isStreamingInProcess, setStreamingInProcess] = useState(false); - - const [isChangeVisibilityLoading, setIsChangeVisibilityLoading] = useState(false); - - const token = localStorage.getAuthToken() - - const onWebSocketError = (error: WebSocketEventMap['error']) => { - console.error('WebSocket error:', error); - showMessage('WebSocket connection error: attempting to reconnect'); - } - - const onWebSocketMessage = (event: WebSocketEventMap['message']) => { - if (event.data) { - const messageData: BotMessage | DebugMessage | StateMessage | StreamMessage | ErrorMessage = JSON.parse(event.data); - if (messageData) { - const isThreadMatching = threadId && threadId === messageData.thread_id; - const isParentMatching = !threadId && 'parent_id' in messageData && messageData.parent_id && messages; - const isDebugMessage = messageData.type === 'debug'; - const isStateMessage = messageData.type === 'state'; - const isStreamMessage = messageData.type === 'stream'; - const isErrorMessage = messageData.type === 'error'; - const isToolCallResultMessage = messageData.type === 'tool_call_result'; - - if (isThreadMatching || isParentMatching || isDebugMessage || isStateMessage || isStreamMessage || isErrorMessage || isToolCallResultMessage) { - switch (messageData.type) { - case 'debug': - handleDebugMessage(messageData) - break; - case 'state': - handleStateMessage(messageData, Boolean(isThreadMatching)) - break; - case 'stream': - handleStreamMessage(messageData, Boolean(isThreadMatching)) - break; - case 'message': - handleBotMessage(messageData) - break; - case 'error': - handleErrorMessage(messageData) - break; - case 'tool_call_result': - handleToolCallResultMessage(messageData) - break; - } - } else if (threadId !== messageData.thread_id) { - const threadInList = chatsList?.find((item) => item.thread_id === messageData.thread_id) - if (!threadInList) getChatsList() - if (currentStreamMessage) setCurrentStreamMessage(null) - if (wsLoading) setWsLoading(false); - if (isStreamingInProcess) setStreamingInProcess(false) - } - } else { - showMessage('An error occurred. Please try again') - } - } else { - showMessage('An error occurred. Please try again') - } - - setLoading(false); - } - - const handleDebugMessage = (message: DebugMessage) => { - let currentDebugMessages = [...(debugMessages || [])]; - currentDebugMessages.push(message) - setDebugMessages(currentDebugMessages) - } - - const handleStateMessage = (message: StateMessage, isThreadMatching?: boolean) => { - if (isThreadMatching || !threadId) { - if (message.state) { - setStateMessage(message) - } else { - setStateMessage(null) - } - } - } - - const handleStreamMessage = (message: StreamMessage, isThreadMatching?: boolean) => { - if (isThreadMatching || !threadId) { - if (!isStreamingInProcess) setStreamingInProcess(true) - setCurrentStreamMessage(message) - setWsLoading(false); - } - } - - const handleBotMessage = (message: BotMessage) => { - if (messages && messages.length > 0) { - let currentMessages = [...messages]; - const lastMessage = currentMessages[currentMessages.length - 1]; - if (lastMessage && !lastMessage.id && message.parent_id) { - lastMessage.id = message.parent_id; - lastMessage.created_at = message.created_at; - lastMessage.is_public = message.is_public; - } - - currentMessages.push(message); - if (currentStreamMessage) setCurrentStreamMessage(null) - setMessages(currentMessages); - setWsLoading(false); - setStreamingInProcess(false); - if (document.visibilityState === "hidden") { - if (Notification.permission === "granted") { - new Notification("New message", { - body: 'New message from Postgres.AI Assistant', - icon: '/images/bot_avatar.png' - }); - } - } - } - } - - const handleToolCallResultMessage = (message: BotMessage) => { - if (messages && messages.length > 0) { - let currentMessages = [...messages]; - const lastMessage = currentMessages[currentMessages.length - 1]; - if (lastMessage && !lastMessage.id && message.parent_id) { - lastMessage.id = message.parent_id; - lastMessage.created_at = message.created_at; - lastMessage.is_public = message.is_public; - } - - currentMessages.push(message); - setMessages(currentMessages); - } - } - - const handleErrorMessage = (message: ErrorMessage) => { - if (message && message.message) { - let error = { - hint: null, - details: null - }; - const jsonMatch = message.message.match(/{.*}/); - const json = jsonMatch ? JSON.parse(jsonMatch[0]) : null; - - if (json) { - const { hint, details } = json; - if (hint) error["hint"] = hint - if (details) error["details"] = details - } - const errorMessage: ErrorMessage = { - type: "error", - message: `${error.details}\n\n${error.hint}`, - thread_id: message.thread_id - } - setLoading(false) - setWsLoading(false) - setErrorMessage(errorMessage) - } - } - - const onWebSocketOpen = () => { - console.log('WebSocket connection established'); - if (threadId) { - subscribe(threadId) - } - setWsLoading(false); - closeSnackbar(); - } - const onWebSocketClose = (event: WebSocketEventMap['close']) => { - console.log('WebSocket connection closed', event); - showMessage('WebSocket connection error: attempting to reconnect'); - } - - const { sendMessage: wsSendMessage, readyState, } = useWebSocket(WS_URL, { - protocols: ['Authorization', token || ''], - shouldReconnect: () => true, - reconnectAttempts: 50, - reconnectInterval: 5000, // ms - onError: onWebSocketError, - onMessage: onWebSocketMessage, - onClose: onWebSocketClose, - onOpen: onWebSocketOpen - }) - - const getChatMessages = useCallback(async (threadId: string) => { - setError(null); - setDebugMessages(null); - setErrorMessage(null); - if (threadId) { - setLoading(true); - try { - const { response } = await getChatsWithWholeThreads({id: threadId}); - subscribe(threadId) - if (response && response.length > 0) { - setMessages(response); - } else { - setError({ - code: 404, - message: 'Specified chat not found or you have no access.', - type: 'chatNotFound' - }) - } - } catch (e) { - setError(e as unknown as ErrorType) - showMessage('Connection error') - } finally { - setLoading(false); - } - } - }, []); - - useEffect(() => { - let isCancelled = false; - setError(null); - setWsLoading(false); - - if (threadId && !location.state?.skipReloading) { - getChatMessages(threadId) - .catch((e) => { - if (!isCancelled) { - setError(e); - } - }); - } else if (threadId) { - subscribe(threadId) - } - return () => { - isCancelled = true; - }; - }, [getChatMessages, location.state?.skipReloading, threadId]); - - useEffect(() => { - const fetchData = async () => { - if (threadId) { - const { response } = await getChatsWithWholeThreads({id: threadId}); - if (response && response.length > 0) { - setMessages(response); - } - } - }; - - let intervalId: NodeJS.Timeout | null = null; - - if (readyState === ReadyState.CLOSED) { - intervalId = setInterval(fetchData, 20000); - } - - return () => { - if (intervalId) { - clearInterval(intervalId); - } - }; - }, [readyState, threadId]); - - const sendMessage = async ({content, thread_id, org_id}: SendMessageType) => { - setWsLoading(true) - setErrorMessage(null) - if (!thread_id) { - setLoading(true) - } - try { - //TODO: fix it - if (messages && messages.length > 0) { - setMessages((prevState) => [...prevState as BotMessage[], { content, is_ai: false, created_at: new Date().toISOString() } as BotMessage]) - } else { - setMessages([{ content, is_ai: false, created_at: new Date().toISOString() } as BotMessage]) - } - wsSendMessage(JSON.stringify({ - action: 'send', - payload: { - content, - thread_id, - org_id, - ai_model: `${aiModel?.vendor}/${aiModel?.name}`, - is_public: chatVisibility === 'public' - } - })) - setError(error) - - } catch (e) { - setError(e as unknown as ErrorType) - } finally { - setLoading(false) - } - } - - const clearChat = () => { - setMessages(null); - setDebugMessages(null); - setWsLoading(false); - } - - const changeChatVisibility = async (threadId: string, isPublic: boolean) => { - setIsChangeVisibilityLoading(true) - try { - const { error } = await updateChatVisibility({ - thread_id: threadId, - is_public: isPublic - }) - if (error) { - showMessage('Failed to change chat visibility. Please try again later.') - } else if (messages?.length) { - const newMessages: BotMessage[] = messages.map((message) => ({ - ...message, - is_public: isPublic - })) - setMessages(newMessages) - } - } catch (e) { - showMessage('Failed to change chat visibility. Please try again later.') - } finally { - setIsChangeVisibilityLoading(false) - } - } - - const subscribe = (threadId: string) => { - wsSendMessage(JSON.stringify({ - action: 'subscribe', - payload: { - thread_id: threadId, - } - })) - } - - const unsubscribe = (threadId: string) => { - wsSendMessage(JSON.stringify({ - action: 'unsubscribe', - payload: { - thread_id: threadId, - } - })) - } - - const updateMessageStatus = (threadId: string, messageId: string, status: MessageStatus) => { - wsSendMessage(JSON.stringify({ - action: 'message_status_update', - payload: { - thread_id: threadId, - message_id: messageId, - read_by: userId, - status - } - })) - if (messages && messages.length > 0) { - const updatedMessages = messages.map((item) => { - if (item.id === messageId) { - item["status"] = status - } - return item - }); - setMessages(updatedMessages) - } - } - - const getDebugMessagesForWholeThread = async () => { - setDebugMessagesLoading(true) - if (threadId) { - const { response } = await getDebugMessages({thread_id: threadId}) - if (response) { - setDebugMessages(response) - } - } - setDebugMessagesLoading(false) - } - - useEffect(() => { - if ('Notification' in window) { - Notification.requestPermission().then(permission => { - if (permission === "granted") { - console.log("Permission for notifications granted"); - } else { - console.log("Permission for notifications denied"); - } - }); - } - }, []) - - useEffect(() => { - if (messages && messages.length > 0) { - const newVisibility = messages[0].is_public ? Visibility.PUBLIC : Visibility.PRIVATE; - if (newVisibility !== chatVisibility) { - setChatVisibility(newVisibility) - } - } - }, [messages]); - - useEffect(() => { - const newVisibility = isPublicByDefault ? Visibility.PUBLIC : Visibility.PRIVATE; - if (newVisibility !== chatVisibility) { - setChatVisibility(newVisibility) - } - }, [isPublicByDefault, threadId]); - - return { - error: error, - wsLoading: wsLoading, - wsReadyState: readyState, - loading: isLoading, - changeChatVisibility, - isChangeVisibilityLoading, - sendMessage, - clearChat, - messages, - getDebugMessagesForWholeThread, - unsubscribe, - chatsList, - chatsListLoading, - getChatsList, - aiModel, - setAiModel, - aiModels, - aiModelsLoading, - chatVisibility, - setChatVisibility, - debugMessages, - debugMessagesLoading, - stateMessage, - isStreamingInProcess, - currentStreamMessage, - errorMessage, - updateMessageStatus - } -} - -type AiBotContextType = UseAiBotReturnType; - -const AiBotContext = createContext(undefined); - -type AiBotProviderProps = { - children: React.ReactNode; - args: UseAiBotArgs; -}; - -export const AiBotProvider = ({ children, args }: AiBotProviderProps) => { - const aiBot = useAiBotProviderValue(args); - return ( - - {children} - - ); -}; - -export const useAiBot = (): AiBotContextType => { - const context = useContext(AiBotContext); - if (context === undefined) { - throw new Error('useAiBotContext must be used within an AiBotProvider'); - } - return context; -}; - -type UseBotChatsListHook = { - chatsList: BotMessage[] | null; - error: Response | null; - loading: boolean; - getChatsList: () => void; -}; - -export const useBotChatsList = (orgId?: number): UseBotChatsListHook => { - const [chatsList, setChatsList] = useState(null); - const [isLoading, setLoading] = useState(false); - const [error, setError] = useState(null) - - const getChatsList = useCallback(async () => { - setLoading(true); - try { - const queryString = `?parent_id=is.null&org_id=eq.${orgId}`; - const { response, error } = await getChats({ query: queryString }); - - setChatsList(response); - setError(error) - - } catch (e) { - setError(e as unknown as Response) - } finally { - setLoading(false) - } - }, []); - - useEffect(() => { - let isCancelled = false; - - getChatsList() - .catch((e) => { - if (!isCancelled) { - setError(e); - } - }); - - return () => { - isCancelled = true; - }; - }, [getChatsList]); - - return { - chatsList, - error, - getChatsList, - loading: isLoading - } -} - -type UseAiModelsList = { - aiModels: AiModel[] | null - error: Response | null - aiModel: AiModel | null - loading: boolean - setAiModel: (model: AiModel) => void -} - -export const useAiModelsList = (orgId?: number): UseAiModelsList => { - const [llmModels, setLLMModels] = useState(null); - const [error, setError] = useState(null); - const [userModel, setUserModel] = useState(null); - const [loading, setLoading] = useState(false) - - const getModels = useCallback(async () => { - let models = null; - setLoading(true); - try { - const { response } = await getAiModels(orgId); - setLLMModels(response); - const currentModel = window.localStorage.getItem('bot.ai_model'); - const parsedModel: AiModel = currentModel ? JSON.parse(currentModel) : null; - - if (currentModel && parsedModel.name !== userModel?.name && response) { - // Check if the parsedModel exists in the response models - const modelInResponse = response.find( - (model) => - model.name.includes(parsedModel.name) - ); - - if (modelInResponse) { - setUserModel(modelInResponse); - window.localStorage.setItem('bot.ai_model', JSON.stringify(modelInResponse)); - } else { - // Model from localStorage does not exist in response - // Find a default model - const defaultModel = response.find((model) => - model.name.includes(DEFAULT_MODEL_NAME) - ); - - if (defaultModel) { - setUserModel(defaultModel); - window.localStorage.setItem('bot.ai_model', JSON.stringify(defaultModel)); - } - } - } else if (response) { - // Find a model where the model name includes the DEFAULT_MODEL_NAME - const matchingModel = response.find((model) => - model.name.includes(DEFAULT_MODEL_NAME) - ); - if (matchingModel) { - setModel(matchingModel); - window.localStorage.setItem('bot.ai_model', JSON.stringify(matchingModel)); - } - } - } catch (e) { - setError(e as unknown as Response); - } - setLoading(false); - return models; - }, []); - - - useEffect(() => { - let isCancelled = false; - - getModels() - .catch((e) => { - if (!isCancelled) { - setError(e); - } - }); - return () => { - isCancelled = true; - }; - }, [getModels]); - - const setModel = (model: AiModel) => { - if (model !== userModel) { - setUserModel(model); - window.localStorage.setItem('bot.ai_model', JSON.stringify(model)) - } - } - - return { - aiModels: llmModels, - error, - setAiModel: setModel, - loading, - aiModel: userModel, - } -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/index.tsx b/ui/packages/platform/src/pages/Bot/index.tsx deleted file mode 100644 index 0ea845ab..00000000 --- a/ui/packages/platform/src/pages/Bot/index.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { useEffect, useMemo, useState } from 'react'; -import cn from "classnames"; -import Box from '@mui/material/Box/Box'; -import { makeStyles, Typography, useMediaQuery } from "@material-ui/core"; -import { useHistory, useRouteMatch } from "react-router-dom"; -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper'; -import { ErrorWrapper } from "../../components/Error/ErrorWrapper"; -import { Messages } from './Messages/Messages'; -import { Command } from './Command/Command'; -import { ChatsList } from "./ChatsList/ChatsList"; -import { BotWrapperProps } from "./BotWrapper"; -import { useAiBot } from "./hooks"; -import { usePrev } from "../../hooks/usePrev"; -import {HeaderButtons} from "./HeaderButtons/HeaderButtons"; -import settings from "../../utils/settings"; -import { SettingsDialog } from "./SettingsDialog/SettingsDialog"; -import { theme } from "@postgres.ai/shared/styles/theme"; -import { colors } from "@postgres.ai/shared/styles/colors"; -import { SettingsPanel } from "./SettingsPanel/SettingsPanel"; -import { DebugConsole } from "./DebugConsole/DebugConsole"; -import Store from "../../stores/store"; -import Actions from "../../actions/actions"; -import { Link } from "@postgres.ai/shared/components/Link2"; - -type BotPageProps = BotWrapperProps; - -type DbLabInstance = { - id: number; - plan: string | null; -} - -type RefluxState = { - dbLabInstances: { - data: Record; - } -} | null - -const useStyles = makeStyles( - (theme) => ({ - actions: { - display: 'flex', - alignItems: 'center', - alignSelf: 'flex-end', - marginTop: -20, - [theme.breakpoints.down('sm')]: { - marginTop: -22 - }, - '@media (max-width: 370px)': { - '& > button': { - display: 'none' // hide settings and debug window buttons on smallest screens - } - } - }, - hiddenButtons: { - width: 192, - marginLeft: 52, - [theme.breakpoints.down('sm')]: { - //width: 'min(100%, 320px)', - width: 79, - marginLeft: 'auto' - }, - }, - toggleListButton: { - flex: '0 0 auto', - }, - contentContainer: { - height: '100%', - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - transition: theme.transitions.create('margin', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.leavingScreen, - }), - marginRight: 4, - }, - isChatsListVisible: { - transition: theme.transitions.create('margin', { - easing: theme.transitions.easing.easeOut, - duration: theme.transitions.duration.enteringScreen, - }), - marginRight: 244, - [theme.breakpoints.down('sm')]: { - marginRight: 0, - } - }, - label: { - backgroundColor: colors.primary.main, - color: colors.primary.contrastText, - display: 'inline-block', - borderRadius: 3, - fontSize: 10, - lineHeight: '12px', - padding: 2, - paddingLeft: 3, - paddingRight: 3, - verticalAlign: 'text-top', - marginRight: 8 - }, - labelPrivate: { - backgroundColor: colors.pgaiDarkGray, - }, - publicChatNote: { - marginTop: 4, - marginBottom: -14, - alignSelf: 'center', - fontSize: 12 - } - }), - { index: 1 }, -) - -export const BotPage = (props: BotPageProps) => { - const { match, project, orgData, orgId } = props; - - const { - messages, - error, - clearChat, - unsubscribe, - getDebugMessagesForWholeThread, - getChatsList, - chatVisibility - } = useAiBot(); - - const matches = useMediaQuery(theme.breakpoints.down('sm')); - - const [isChatsListVisible, setChatsListVisible] = useState(window?.innerWidth > 640); - const [isSettingsDialogVisible, setSettingsDialogVisible] = useState(false); - const [isDebugConsoleVisible, setDebugConsoleVisible] = useState(false); - - const history = useHistory(); - - const prevThreadId = usePrev(match.params.threadId); - - const isDemoOrg = useRouteMatch(`/${settings.demoOrgAlias}`); - - const classes = useStyles(); - - const breadcrumbs = ( - - ); - - const toggleChatsList = () => { - setChatsListVisible((prevState) => !prevState) - } - - const toggleSettingsDialog = () => { - setSettingsDialogVisible((prevState) => !prevState) - } - - const handleOpenDebugConsole = () => { - setDebugConsoleVisible(true); - getDebugMessagesForWholeThread() - } - - const toggleDebugConsole = () => { - setDebugConsoleVisible((prevState) => !prevState) - } - - const handleCreateNewChat = () => { - clearChat(); - history.push(`/${match.params.org}/assistant`); - } - - const handleChatListLinkClick = (targetThreadId: string) => { - if (match.params.threadId && match.params.threadId !== targetThreadId) { - unsubscribe(match.params.threadId) - } - } - - const isSubscriber = useMemo(() => orgData?.chats_private_allowed, [orgData?.chats_private_allowed]); - - const publicChatMessage = useMemo(() => { - if (isDemoOrg) { - return <>AI can make mistakes❗️ All chats here are currently public. To enable private conversations, create your organization; - } - - if (!isSubscriber) { - return <>AI can make mistakes❗️ All chats here are currently public. Connect DBLab SE or become a consulting client to enable private conversations; - } - - if (isSubscriber && chatVisibility === 'public') { - return <>AI can make mistakes❗️ This chat is public, you can change it in Settings. - } - - return null; - }, [isDemoOrg, match.params.org, chatVisibility, isSubscriber]); - - useEffect(() => { - if (!match.params.threadId && !prevThreadId && messages && messages.length > 0 && messages[0].id) { - // hack that skip additional loading chats_ancestors_and_descendants - history.replace(`/${match.params.org}/assistant/${messages[0].id}`, { skipReloading: true }) - getChatsList(); - } else if (prevThreadId && !match.params.threadId) { - clearChat() - } - }, [match.params.threadId, match.params.org, messages, prevThreadId]); - - useEffect(() => { - // fixes hack with skipping additional loading chats_ancestors_and_descendants - history.replace({ state: {} }) - }, []); - - useEffect(() => { - if (isDebugConsoleVisible) setDebugConsoleVisible(false) - }, [match.params.threadId]); - - - if (error && error.code === 404) { - return ( - <> - {breadcrumbs} - - - ) - } - - return ( - <> - - - - - { - } - - - - - - - - {publicChatMessage} - - - ) -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Bot/utils.ts b/ui/packages/platform/src/pages/Bot/utils.ts deleted file mode 100644 index 440c8ee2..00000000 --- a/ui/packages/platform/src/pages/Bot/utils.ts +++ /dev/null @@ -1,117 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { API_URL_PREFIX } from "../../config/env"; -import { DebugMessage } from "../../types/api/entities/bot"; -import { HintType } from "./hints"; -import { FunctionComponent } from "react"; -import { ArrowGrowthIcon } from "./HintCards/ArrowGrowthIcon/ArrowGrowthIcon"; -import { WrenchIcon } from "./HintCards/WrenchIcon/WrenchIcon"; -import { TableIcon } from "./HintCards/TableIcon/TableIcon"; -import { CommonTypeIcon } from "./HintCards/CommonTypeIcon/CommonTypeIcon"; - -export const permalinkLinkBuilder = (id: string): string => { - const apiUrl = process.env.REACT_APP_API_URL_PREFIX || API_URL_PREFIX; - const isV2API = /https?:\/\/.*v2\.postgres\.ai\b/.test(apiUrl); - return `https://${isV2API ? 'v2.' : ''}postgres.ai/chats/${id}`; -}; - -export const disallowedHtmlTagsForMarkdown= [ - 'script', - 'style', - 'iframe', - 'form', - 'input', - 'link', - 'meta', - 'embed', - 'object', - 'applet', - 'base', - 'frame', - 'frameset', - 'audio', - 'video', - 'button', - 'select', - 'option', - 'textarea' -]; - -export const createMessageFragment = (messages: DebugMessage[]): DocumentFragment => { - const fragment = document.createDocumentFragment(); - - messages.forEach((item) => { - const textBeforeLink = `[${item.created_at}]: `; - const parts = item.content.split(/(https?:\/\/[^\s)"']+)/g); - - const messageContent = parts.map((part) => { - if(/https?:\/\/[^\s)"']+/.test(part)) { - const link = document.createElement('a'); - link.href = part; - link.target = '_blank'; - link.textContent = part; - return link; - } else { - return document.createTextNode(part); - } - }); - - fragment.appendChild(document.createTextNode(textBeforeLink)); - messageContent.forEach((node) => fragment.appendChild(node)); - fragment.appendChild(document.createTextNode('\n')); - }); - - return fragment; -}; - -export const formatLanguageName = (language: string): string => { - const specificCases: { [key: string]: string } = { - "sql": "SQL", - "pl/pgsql": "PL/pgSQL", - "pl/python": "PL/Python", - "json": "JSON", - "yaml": "YAML", - "html": "HTML", - "xml": "XML", - "css": "CSS", - "csv": "CSV", - "toml": "TOML", - "ini": "INI", - "r": "R", - "php": "PHP", - "sqlalchemy": "SQLAlchemy", - "xslt": "XSLT", - "xsd": "XSD", - "ajax": "AJAX", - "tsql": "TSQL", - "pl/sql": "PL/SQL", - "dax": "DAX", - "sparql": "SPARQL" - }; - - const normalizedLanguage = language.toLowerCase(); - - if (specificCases[normalizedLanguage]) { - return specificCases[normalizedLanguage]; - } else { - return language.charAt(0).toUpperCase() + language.slice(1).toLowerCase(); - } -} - -export function matchHintTypeAndIcon(hintType: HintType): FunctionComponent { - switch (hintType) { - case 'performance': - return ArrowGrowthIcon - case 'settings': - return WrenchIcon - case 'design': - return TableIcon - default: - return CommonTypeIcon - } -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Branch/index.tsx b/ui/packages/platform/src/pages/Branch/index.tsx deleted file mode 100644 index 93e4673f..00000000 --- a/ui/packages/platform/src/pages/Branch/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useParams } from 'react-router-dom' - -import { BranchesPage } from '@postgres.ai/shared/pages/Branches/Branch' - -import { getBranches } from 'api/branches/getBranches' -import { deleteBranch } from 'api/branches/deleteBranch' -import { getSnapshotList } from 'api/branches/getSnapshotList' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import { ROUTES } from 'config/routes' - -type Params = { - org: string - project?: string - instanceId: string - branchId: string -} - -export const Branch = () => { - const params = useParams() - - const routes = { - branch: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.createPath(params), - branches: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.BRANCHES.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.BRANCHES.createPath({ - org: params.org, - instanceId: params.instanceId, - }), - snapshot: (snapshotId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - snapshotId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - org: params.org, - instanceId: params.instanceId, - snapshotId, - }), - createClone: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.CLONES.ADD.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.CLONES.ADD.createPath({ - org: params.org, - instanceId: params.instanceId, - }), - } - - const api = { - getBranches, - deleteBranch, - getSnapshotList, - } - - const elements = { - breadcrumbs: ( - - ), - } - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/Clone/index.tsx b/ui/packages/platform/src/pages/Clone/index.tsx deleted file mode 100644 index 2277fa59..00000000 --- a/ui/packages/platform/src/pages/Clone/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useParams } from 'react-router-dom' - -import { Clone as ClonePage } from '@postgres.ai/shared/pages/Clone' - -import { getSnapshots } from 'api/snapshots/getSnapshots' -import { getInstance } from 'api/instances/getInstance' -import { getClone } from 'api/clones/getClone' -import { resetClone } from 'api/clones/resetClone' -import { destroyClone } from 'api/clones/destroyClone' -import { updateClone } from 'api/clones/updateClone' -import { createSnapshot } from 'api/snapshots/createSnapshot' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import { ROUTES } from 'config/routes' - -type Params = { - org: string - project?: string - instanceId: string - cloneId: string -} - -export const Clone = () => { - const params = useParams() - - const routes = { - instance: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.createPath(params), - snapshot: (snapshotId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - snapshotId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - org: params.org, - instanceId: params.instanceId, - snapshotId, - }), - createSnapshot: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.SNAPSHOTS.ADD.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - cloneId: params.cloneId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.SNAPSHOTS.ADD.createPath({ - org: params.org, - instanceId: params.instanceId, - cloneId: params.cloneId, - }) - } - - const api = { - getSnapshots, - createSnapshot, - getInstance, - getClone, - resetClone, - destroyClone, - updateClone, - } - - const elements = { - breadcrumbs: ( - - ), - } - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/Consulting/ConsultingWrapper.tsx b/ui/packages/platform/src/pages/Consulting/ConsultingWrapper.tsx deleted file mode 100644 index bcf7e7c1..00000000 --- a/ui/packages/platform/src/pages/Consulting/ConsultingWrapper.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import { Consulting } from "./index"; -import { RouteComponentProps } from "react-router"; - -export interface ConsultingWrapperProps { - orgId?: number; - history: RouteComponentProps['history'] - project?: string - match: { - params: { - org?: string - } - } - orgData: { - consulting_type: string | null - alias: string - role: { - id: number - } - } -} - -export const ConsultingWrapper = (props: ConsultingWrapperProps) => { - return ; -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Consulting/TransactionsTable/TransactionsTable.tsx b/ui/packages/platform/src/pages/Consulting/TransactionsTable/TransactionsTable.tsx deleted file mode 100644 index 15e112ef..00000000 --- a/ui/packages/platform/src/pages/Consulting/TransactionsTable/TransactionsTable.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; -import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; -import { Transaction } from "stores/consulting"; -import { formatPostgresInterval } from "../utils"; -import { Link } from "@postgres.ai/shared/components/Link2"; - - -type TransactionsTableProps = { - transactions: Transaction[], - alias: string -} - -export const TransactionsTable = ({ transactions, alias }: TransactionsTableProps) => ( - - - - - Action - Amount - Date - Details - - - - {transactions.map(({ amount, created_at, issue_id, description, id }: Transaction) => ( - - {amount.charAt(0) === '-' ? 'Utilize' : 'Replenish'} - {formatPostgresInterval(amount || '00')} - {new Date(created_at)?.toISOString()?.split('T')?.[0]} - - {issue_id ? ( - - {description} - - ) : description} - - - ))} - -
-
-); \ No newline at end of file diff --git a/ui/packages/platform/src/pages/Consulting/index.tsx b/ui/packages/platform/src/pages/Consulting/index.tsx deleted file mode 100644 index 1f04f753..00000000 --- a/ui/packages/platform/src/pages/Consulting/index.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import React, { useEffect } from "react"; -import ConsolePageTitle from "../../components/ConsolePageTitle"; -import Alert from "@mui/material/Alert"; -import { Grid, Typography } from "@mui/material"; -import Button from "@mui/material/Button"; -import Box from "@mui/material/Box/Box"; -import { observer } from "mobx-react-lite"; -import { consultingStore } from "../../stores/consulting"; -import { ConsultingWrapperProps } from "./ConsultingWrapper"; -import { makeStyles } from "@material-ui/core"; -import { PageSpinner } from "@postgres.ai/shared/components/PageSpinner"; -import { ProductCardWrapper } from "../../components/ProductCard/ProductCardWrapper"; -import { Link } from "@postgres.ai/shared/components/Link2"; -import { ConsoleBreadcrumbsWrapper } from "../../components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper"; -import { formatPostgresInterval } from "./utils"; -import { TransactionsTable } from "./TransactionsTable/TransactionsTable"; - - - -const useStyles = makeStyles((theme) => ({ - sectionLabel: { - fontSize: '14px!important', - fontWeight: '700!important' as 'bold', - }, - productCardProjects: { - flex: '1 1 0', - marginRight: '20px', - height: 'maxContent', - gap: 20, - maxHeight: '100%', - - '& svg': { - width: '206px', - height: '130px', - }, - - [theme.breakpoints.down('sm')]: { - flex: '100%', - marginTop: '20px', - minHeight: 'auto !important', - - '&:nth-child(1) svg': { - marginBottom: 0, - }, - - '&:nth-child(2) svg': { - marginBottom: 0, - }, - }, - }, -})) - -export const Consulting = observer((props: ConsultingWrapperProps) => { - const { orgId, orgData, match } = props; - - const classes = useStyles(); - - useEffect(() => { - if (orgId) { - consultingStore.getOrgBalance(orgId); - consultingStore.getTransactions(orgId); - } - }, [orgId]); - - const breadcrumbs = ( - - ) - - if (consultingStore.loading) { - return ( - - {breadcrumbs} - - - - - - ) - } - - if (orgData.consulting_type === null) { - return ( - - {breadcrumbs} - - - Learn more) - } - ]} - > -

- Your organization is not a consulting customer yet. To learn more about Postgres.AI consulting, visit this page: Consulting. -

-

- Reach out to the team to discuss consulting opportunities: consulting@postgres.ai. -

-
-
-
- ) - } - - return ( -
- {breadcrumbs} - - - - - - Thank you for choosing Postgres.AI as your PostgreSQL consulting partner. Your plan: {orgData.consulting_type.toUpperCase()}. - - - - - - - Issue tracker (GitLab): - - - - https://gitlab.com/postgres-ai/postgresql-consulting/support/{orgData.alias} - - - - - - - - Book a Zoom call: - - - - https://calend.ly/postgres - - - - - - - - Email: - - - - consulting@postgres.ai - - - - - {consultingStore.orgBalance?.[0]?.balance?.charAt(0) === '-' && - Consulting hours overdrawn - } - {orgData.consulting_type === 'retainer' && - - Retainer balance: - - - {formatPostgresInterval(consultingStore.orgBalance?.[0]?.balance || '00') || 0} - - } - {orgData.consulting_type === 'retainer' && - - - - } - - {orgData.consulting_type === 'retainer' && - - Activity: - - { - consultingStore.transactions?.length === 0 - ? - No activity yet - - : - } - } - -
- ); -}); - diff --git a/ui/packages/platform/src/pages/Consulting/utils.ts b/ui/packages/platform/src/pages/Consulting/utils.ts deleted file mode 100644 index 361feae7..00000000 --- a/ui/packages/platform/src/pages/Consulting/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import parse, { IPostgresInterval } from "postgres-interval" - -export function formatPostgresInterval(balance: string): string { - const interval: IPostgresInterval = parse(balance); - - const units: Partial, string>> = { - years: 'y', - months: 'mo', - days: 'd', - hours: 'h', - minutes: 'm', - seconds: 's', - milliseconds: 'ms', - }; - - const sign = Object.keys(units) - .map((key) => interval[key as keyof IPostgresInterval] || 0) - .find((value) => value !== 0) ?? 0; - - const isNegative = sign < 0; - - const formattedParts = (Object.keys(units) as (keyof typeof units)[]) - .map((key) => { - const value = interval[key]; - return value && Math.abs(value) > 0 ? `${Math.abs(value)}${units[key]}` : null; - }) - .filter(Boolean); - - return (isNegative ? '-' : '') + formattedParts.join(' '); -} \ No newline at end of file diff --git a/ui/packages/platform/src/pages/CreateBranch/index.tsx b/ui/packages/platform/src/pages/CreateBranch/index.tsx deleted file mode 100644 index b1f144f1..00000000 --- a/ui/packages/platform/src/pages/CreateBranch/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useParams } from 'react-router-dom' - -import { CreateBranchPage } from '@postgres.ai/shared/pages/CreateBranch' - -import { getBranches } from 'api/branches/getBranches' -import { createBranch } from 'api/branches/createBranch' -import { getSnapshots } from 'api/snapshots/getSnapshots' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import { ROUTES } from 'config/routes' - -type Params = { - org: string - project?: string - instanceId: string - branchId: string -} - -export const CreateBranch = () => { - const params = useParams() - - const routes = { - branch: (branchId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.BRANCHES.BRANCH.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - branchId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.BRANCHES.BRANCH.createPath({ - ...params, - branchId, - }), - } - - const api = { - getBranches, - createBranch, - getSnapshots, - } - - const elements = { - breadcrumbs: ( - - ), - } - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/CreateClone/index.tsx b/ui/packages/platform/src/pages/CreateClone/index.tsx deleted file mode 100644 index 73fe958a..00000000 --- a/ui/packages/platform/src/pages/CreateClone/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useParams } from 'react-router-dom' - -import { CreateClone as CreateClonePage } from '@postgres.ai/shared/pages/CreateClone' - -import { ROUTES } from 'config/routes' -import { getInstance } from 'api/instances/getInstance' -import { getSnapshots } from 'api/snapshots/getSnapshots' -import { createClone } from 'api/clones/createClone' -import { getClone } from 'api/clones/getClone' -import { getBranches } from 'api/branches/getBranches' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -type Params = { - org: string - project?: string - instanceId: string -} - -export const CreateClone = () => { - const params = useParams() - - const routes = { - clone: (cloneId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.CLONES.CLONE.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - cloneId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.CLONES.CLONE.createPath({ - ...params, - cloneId, - }), - } - - const api = { - getSnapshots, - getInstance, - createClone, - getClone, - getBranches - } - - const elements = { - breadcrumbs: ( - - ), - } - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/CreateSnapshot/index.tsx b/ui/packages/platform/src/pages/CreateSnapshot/index.tsx deleted file mode 100644 index 02d73a25..00000000 --- a/ui/packages/platform/src/pages/CreateSnapshot/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useParams } from 'react-router-dom' - -import { CreateSnapshotPage } from '@postgres.ai/shared/pages/CreateSnapshot' - -import { getInstance } from 'api/instances/getInstance' -import { createSnapshot } from 'api/snapshots/createSnapshot' -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' - -import { ROUTES } from 'config/routes' - -type Params = { - org: string - project?: string - instanceId: string - snapshotId: string -} - -export const CreateSnapshot = () => { - const params = useParams() - - const routes = { - snapshot: (snapshotId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - snapshotId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - ...params, - snapshotId, - }), - } - - const api = { - getInstance, - createSnapshot, - } - - const elements = { - breadcrumbs: ( - - ), - } - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/Instance/index.tsx b/ui/packages/platform/src/pages/Instance/index.tsx deleted file mode 100644 index 42f9ac15..00000000 --- a/ui/packages/platform/src/pages/Instance/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useState } from 'react' -import { useParams } from 'react-router-dom' - -import { Instance as InstancePage } from '@postgres.ai/shared/pages/Instance' - -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper' -import { ROUTES } from 'config/routes' -import { getInstance } from 'api/instances/getInstance' -import { getInstanceRetrieval } from 'api/instances/getInstanceRetrieval' -import { refreshInstance } from 'api/instances/refreshInstance' -import { getSnapshots } from 'api/snapshots/getSnapshots' -import { createSnapshot } from 'api/snapshots/createSnapshot' -import { getBranchSnapshot } from 'api/snapshots/getBranchSnapshot' -import { destroyClone } from 'api/clones/destroyClone' -import { resetClone } from 'api/clones/resetClone' -import { bannersStore } from 'stores/banners' -import { getWSToken } from 'api/instances/getWSToken' -import { getConfig } from 'api/configs/getConfig' -import { getFullConfig } from 'api/configs/getFullConfig' -import { getSeImages } from 'api/configs/getSeImages' -import { testDbSource } from 'api/configs/testDbSource' -import { updateConfig } from 'api/configs/updateConfig' -import { getEngine } from 'api/engine/getEngine' -import { createBranch } from 'api/branches/createBranch' -import { getBranches } from 'api/branches/getBranches' -import { getSnapshotList } from 'api/branches/getSnapshotList' -import { deleteBranch } from 'api/branches/deleteBranch' -import { initWS } from 'api/engine/initWS' -import { destroySnapshot } from 'api/snapshots/destroySnapshot' - -type Params = { - org: string - project?: string - instanceId: string -} - -export const Instance = ({ - renderCurrentTab, -}: { - renderCurrentTab?: number -}) => { - const params = useParams() - const [projectAlias, setProjectAlias] = useState('') - - const routes = { - createBranch: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.BRANCHES.ADD.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.BRANCHES.ADD.createPath(params), - createSnapshot: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.SNAPSHOTS.ADD.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.SNAPSHOTS.ADD.createPath(params), - createClone: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.CLONES.ADD.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.CLONES.ADD.createPath(params), - - clone: (cloneId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.CLONES.CLONE.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - cloneId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.CLONES.CLONE.createPath({ - ...params, - cloneId, - }), - branch: (branchId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.BRANCHES.BRANCH.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - branchId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.BRANCHES.BRANCH.createPath({ - ...params, - branchId, - }), - branches: () => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.BRANCHES.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.BRANCHES.createPath(params), - snapshot: (snapshotId: string) => - params.project - ? ROUTES.ORG.PROJECT.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - org: params.org, - project: params.project, - instanceId: params.instanceId, - snapshotId, - }) - : ROUTES.ORG.INSTANCES.INSTANCE.SNAPSHOTS.SNAPSHOT.createPath({ - ...params, - snapshotId, - }), - } - - const api = { - getInstance, - getInstanceRetrieval, - getBranchSnapshot, - getSnapshots, - createSnapshot, - destroyClone, - refreshInstance, - resetClone, - getWSToken, - getConfig, - getFullConfig, - getSeImages, - updateConfig, - testDbSource, - getEngine, - createBranch, - getBranches, - getSnapshotList, - deleteBranch, - destroySnapshot, - initWS, - } - - const callbacks = { - showDeprecatedApiBanner: bannersStore.showDeprecatedApi, - hideDeprecatedApiBanner: bannersStore.hideDeprecatedApi, - } - - const instanceTitle = `#${params.instanceId} ${ - projectAlias - ? `(${projectAlias})` - : params.project - ? `(${params.project})` - : '' - }` - - const elements = { - breadcrumbs: ( - - ), - } - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Command/index.tsx b/ui/packages/platform/src/pages/JoeInstance/Command/index.tsx deleted file mode 100644 index 55b0b649..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Command/index.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react' -import { makeStyles } from '@material-ui/core' - -import { Button } from '@postgres.ai/shared/components/Button' -import { TextField } from '@postgres.ai/shared/components/TextField' - - -import { - checkIsSendCmd, - checkIsNewLineCmd, - addNewLine, - checkIsPrevMessageCmd, - checkIsNextMessageCmd, -} from './utils' - -import { useBuffer } from './useBuffer' -import { useCaret } from './useCaret' - -type Props = { - isDisabled: boolean - onSend: (value: string) => void -} - -const LABEL_FONT_SIZE = '14px' - -const useStyles = makeStyles( - { - root: { - display: 'flex', - alignItems: 'flex-end', - marginTop: '20px', - }, - field: { - margin: '0 8px 0 0', - flex: '1 1 100%', - fontSize: LABEL_FONT_SIZE, - }, - fieldInput: { - fontSize: '14px', - lineHeight: 'normal', - height: 'auto', - padding: '12px', - }, - fieldLabel: { - fontSize: LABEL_FONT_SIZE, - }, - button: { - flex: '0 0 auto', - height: '40px', - }, - }, - { index: 1 }, -) - -export const Command = React.memo((props: Props) => { - const { isDisabled, onSend } = props - - const classes = useStyles() - - // Handle value. - const [value, setValue] = useState('') - - // Input DOM Element reference. - const inputRef = useRef() - - // Messages buffer. - const buffer = useBuffer() - - // Input caret. - const caret = useCaret(inputRef) - - const triggerSend = () => { - if (!value.trim().length) return - - onSend(value) - buffer.addNew() - setValue(buffer.getCurrent()) - } - - const handleChange = (e: React.ChangeEvent) => { - setValue(e.target.value) - buffer.switchToLast() - buffer.setToCurrent(e.target.value) - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!inputRef.current) return - - // Trigger to send. - if (checkIsSendCmd(e.nativeEvent)) { - e.preventDefault() - triggerSend() - return - } - - // Trigger line break. - if (checkIsNewLineCmd(e.nativeEvent)) { - e.preventDefault() - - const content = addNewLine(value, inputRef.current) - - setValue(content.value) - caret.setPosition(content.caretPosition) - return - } - - // Trigger to use prev message. - if (checkIsPrevMessageCmd(e.nativeEvent, inputRef.current)) { - e.preventDefault() - - const prevValue = buffer.switchPrev() - setValue(prevValue) - return - } - - // Trigger to use next message. - if (checkIsNextMessageCmd(e.nativeEvent, inputRef.current)) { - e.preventDefault() - - const nextValue = buffer.switchNext() - setValue(nextValue) - return - } - - // Skip other keyboard events to fill input. - } - - // Autofocus. - useEffect(() => { - if (!inputRef.current) return - - inputRef.current.focus() - }, []) - - return ( -
- - -
- ) -}) diff --git a/ui/packages/platform/src/pages/JoeInstance/Command/useBuffer.ts b/ui/packages/platform/src/pages/JoeInstance/Command/useBuffer.ts deleted file mode 100644 index 0368bc44..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Command/useBuffer.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useRef } from 'react' - -type Buffer = { - values: string[] - position: number -} - -const NEW_EMPTY_VALUE = '' - -const INITIAL_BUFFER: Buffer = { - values: [NEW_EMPTY_VALUE], - position: 0, -} - -export const useBuffer = () => { - const { current: buffer } = useRef(INITIAL_BUFFER) - - const getCurrent = () => buffer.values[buffer.position] - - const setToCurrent = (value: string) => buffer.values[buffer.position] = value - - const switchNext = () => { - const newPosition = buffer.position + 1 - if (newPosition in buffer.values) buffer.position = newPosition - return getCurrent() - } - - const switchPrev = () => { - const newPosition = buffer.position - 1 - if (newPosition in buffer.values) buffer.position = newPosition - return getCurrent() - } - - const switchToLast = () => { - const lastIndex = buffer.values.length - 1 - buffer.position = lastIndex - return getCurrent() - } - - const addNew = () => { - buffer.values.push(NEW_EMPTY_VALUE) - return switchToLast() - } - - return { - switchNext, - switchPrev, - switchToLast, - addNew, - getCurrent, - setToCurrent, - } -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Command/useCaret.ts b/ui/packages/platform/src/pages/JoeInstance/Command/useCaret.ts deleted file mode 100644 index 8de590a5..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Command/useCaret.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useState, useEffect, MutableRefObject } from 'react' - -export const useCaret = ( - elementRef: MutableRefObject, -) => { - // Keep caret position after making new line, but only after react update. - const [nextPosition, setNextPosition] = useState(null) - - useEffect(() => { - if (nextPosition === null) return - if (!elementRef.current) return - - elementRef.current.selectionStart = nextPosition - elementRef.current.selectionEnd = nextPosition - - setNextPosition(null) - }, [elementRef, nextPosition]) - - return { - setPosition: setNextPosition, - } -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Command/utils.ts b/ui/packages/platform/src/pages/JoeInstance/Command/utils.ts deleted file mode 100644 index 47b68f46..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Command/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const checkIsSendCmd = (e: KeyboardEvent) => - e.code === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey - -export const checkIsNewLineCmd = (e: KeyboardEvent) => - e.code === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey) - -export const addNewLine = ( - value: string, - element: HTMLInputElement | HTMLTextAreaElement, -) => { - const NEW_LINE_STR = '\n' - - const firstLineLength = element.selectionStart ?? value.length - const secondLineLength = element.selectionEnd ?? value.length - - const firstLine = value.substring(0, firstLineLength) - const secondLine = value.substring(secondLineLength) - - return { - value: `${firstLine}${NEW_LINE_STR}${secondLine}`, - caretPosition: firstLineLength + NEW_LINE_STR.length, - } -} - -export const checkIsPrevMessageCmd = ( - e: KeyboardEvent, - element: HTMLInputElement | HTMLTextAreaElement, -) => { - const isRightKey = - e.code === 'ArrowUp' && !e.ctrlKey && !e.metaKey && !e.shiftKey - - // Use prev message only if the caret is in the start of the input. - const targetCaretPosition = 0 - - const isRightCaretPosition = - element.selectionStart === targetCaretPosition && - element.selectionEnd === targetCaretPosition - - return isRightKey && isRightCaretPosition -} - -export const checkIsNextMessageCmd = ( - e: KeyboardEvent, - element: HTMLInputElement | HTMLTextAreaElement, -) => { - const isRightKey = - e.code === 'ArrowDown' && !e.ctrlKey && !e.metaKey && !e.shiftKey - - // Use next message only if the caret is in the end of the input. - const targetCaretPosition = element.value.length - - const isRightCaretPosition = - element.selectionStart === targetCaretPosition && - element.selectionEnd === targetCaretPosition - - return isRightKey && isRightCaretPosition -} diff --git a/ui/packages/platform/src/pages/JoeInstance/JoeInstanceWrapper.jsx b/ui/packages/platform/src/pages/JoeInstance/JoeInstanceWrapper.jsx deleted file mode 100644 index c9984d10..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/JoeInstanceWrapper.jsx +++ /dev/null @@ -1,268 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import { colors } from '@postgres.ai/shared/styles/colors' -import { styles } from '@postgres.ai/shared/styles/styles' -import JoeInstance from 'pages/JoeInstance' - -export const JoeInstanceWrapper = (props) => { - const useStyles = makeStyles( - (theme) => ({ - messageArtifacts: { - 'box-shadow': 'none', - 'min-height': 20, - 'font-size': '14px', - 'margin-top': '15px!important', - '& > .MuiExpansionPanelSummary-root': { - minHeight: 20, - backgroundColor: '#FFFFFF', - border: '1px solid', - borderColor: colors.secondary2.main, - width: 'auto', - color: colors.secondary2.main, - borderRadius: 3, - overflow: 'hidden', - }, - '& > .MuiExpansionPanelSummary-root.Mui-expanded': { - minHeight: 20, - }, - '& .MuiCollapse-hidden': { - height: '0px!important', - }, - }, - advancedExpansionPanelSummary: { - 'justify-content': 'left', - padding: '0px', - 'background-color': '#FFFFFF', - width: 'auto', - color: colors.secondary2.main, - 'border-radius': '3px', - 'padding-left': '15px', - display: 'inline-block', - - '& div.MuiExpansionPanelSummary-content': { - 'flex-grow': 'none', - margin: 0, - '& > p.MuiTypography-root.MuiTypography-body1': { - 'font-size': '12px!important', - }, - display: 'inline-block', - }, - '& > .MuiExpansionPanelSummary-expandIcon': { - marginRight: 0, - padding: '5px!important', - color: colors.secondary2.main, - }, - }, - advancedExpansionPanelDetails: { - padding: 5, - 'padding-left': 0, - 'padding-right': 0, - 'font-size': '14px!important', - '& > p': { - 'font-size': '14px!important', - }, - }, - messageArtifactLink: { - cursor: 'pointer', - color: '#0000ee', - }, - messageArtifactTitle: { - fontWeight: 'normal', - marginBottom: 0, - }, - messageArtifact: { - 'box-shadow': 'none', - 'min-height': 20, - 'font-size': '14px', - '&.MuiExpansionPanel-root.Mui-expanded': { - marginBottom: 0, - marginTop: 0, - }, - '& > .MuiExpansionPanelSummary-root': { - minHeight: 20, - backgroundColor: '#FFFFFF', - border: 'none', - width: 'auto', - color: colors.secondary2.darkDark, - borderRadius: 3, - overflow: 'hidden', - marginBottom: 0, - }, - '& > .MuiExpansionPanelSummary-root.Mui-expanded': { - minHeight: 20, - }, - }, - messageArtifactExpansionPanelSummary: { - 'justify-content': 'left', - padding: '5px', - 'background-color': '#FFFFFF', - width: 'auto', - color: colors.secondary2.darkDark, - 'border-radius': '3px', - 'padding-left': '0px', - display: 'inline-block', - - '& div.MuiExpansionPanelSummary-content': { - 'flex-grow': 'none', - margin: 0, - '& > p.MuiTypography-root.MuiTypography-body1': { - 'font-size': '12px!important', - }, - display: 'inline-block', - }, - '& > .MuiExpansionPanelSummary-expandIcon': { - marginRight: 0, - padding: '5px!important', - color: colors.secondary2.darkDark, - }, - }, - messageArtifactExpansionPanelDetails: { - padding: 5, - 'padding-right': 0, - 'padding-left': 0, - 'font-size': '14px!important', - '& > p': { - 'font-size': '14px!important', - }, - }, - code: { - width: '100%', - 'margin-top': 0, - '& > div': { - paddingTop: 8, - padding: 8, - }, - 'background-color': 'rgb(246, 248, 250)', - '& > div > textarea': { - fontFamily: - '"Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",' + - ' "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace', - color: 'black', - fontSize: '13px', - }, - }, - messageArtifactsContainer: { - width: '100%', - }, - heading: { - fontWeight: 'normal', - fontSize: 12, - color: colors.secondary2.main, - }, - message: { - padding: 10, - 'padding-left': 60, - position: 'relative', - - '& .markdown pre': { - [theme.breakpoints.down('sm')]: { - display: 'inline-block', - minWidth: '100%', - width: 'auto', - }, - [theme.breakpoints.up('md')]: { - display: 'block', - maxWidth: 'auto', - width: 'auto', - }, - [theme.breakpoints.up('lg')]: { - display: 'block', - maxWidth: 'auto', - width: 'auto', - }, - }, - '&:hover $repeatCmdButton': { - display: 'inline-block', - }, - }, - messageAvatar: { - top: '10px', - left: '15px', - position: 'absolute', - }, - messageAuthor: { - fontSize: 14, - fontWeight: 'bold', - }, - messageTime: { - display: 'inline-block', - marginLeft: 10, - fontSize: '14px', - color: colors.pgaiDarkGray, - }, - sqlCode: { - padding: 0, - border: 'none!important', - 'margin-top': 0, - 'margin-bottom': 0, - '& > .MuiInputBase-fullWidth > fieldset': { - border: 'none!important', - }, - '& > .MuiInputBase-fullWidth': { - padding: 5, - }, - '& > div > textarea': { - fontFamily: - '"Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",' + - ' "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace', - color: 'black', - fontSize: '14px', - }, - }, - messageStatusContainer: { - marginTop: 15, - }, - messageStatus: { - border: '1px solid #CCCCCC', - borderColor: colors.pgaiLightGray, - borderRadius: 3, - display: 'inline-block', - padding: 3, - fontSize: '12px', - lineHeight: '12px', - paddingLeft: 6, - paddingRight: 6, - }, - messageStatusIcon: { - '& svg': { - marginBottom: -2, - }, - 'margin-right': 3, - }, - messageProgress: { - '& svg': { - marginBottom: -2, - display: 'inline-block', - }, - }, - actions: { - display: 'flex', - justifyContent: 'space-between', - }, - channelsList: { - ...styles.inputField, - marginBottom: 0, - marginRight: '8px', - flex: '0 1 240px', - }, - clearChatButton: { - flex: '0 0 auto', - }, - repeatCmdButton: { - fontSize: '12px', - padding: '2px 5px', - marginLeft: 10, - display: 'none', - lineHeight: '14px', - marginTop: '-4px', - }, - messageHeader: { - height: '18px', - }, - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Messages/Banner/index.tsx b/ui/packages/platform/src/pages/JoeInstance/Messages/Banner/index.tsx deleted file mode 100644 index e8af480a..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Messages/Banner/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Status, Props as StatusProps } from '@postgres.ai/shared/components/Status' - -import styles from './styles.module.scss' - -type Props = { - messages: { - type: StatusProps['type'] - value: string - }[] -} - -export const Banner = (props: Props) => { - const { messages } = props - - return ( -
- {messages.map((message) => { - return ( - - {message.value} - - ) - })} -
- ) -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Messages/Banner/styles.module.scss b/ui/packages/platform/src/pages/JoeInstance/Messages/Banner/styles.module.scss deleted file mode 100644 index 338f401b..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Messages/Banner/styles.module.scss +++ /dev/null @@ -1,23 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -@import '@postgres.ai/shared/styles/vars'; - -.root { - flex: 0 0 auto; - padding: 12px; - border-top: 1px solid $color-gray; - font-size: $font-size-small; - display: flex; - flex-direction: column; -} - -.content { - + .content { - margin-top: 12px; - } -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Messages/index.jsx b/ui/packages/platform/src/pages/JoeInstance/Messages/index.jsx deleted file mode 100644 index f4e304c2..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Messages/index.jsx +++ /dev/null @@ -1,284 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { useRef, useEffect } from 'react'; -import { - Button, - TextField, - ExpansionPanel, - ExpansionPanelSummary, - ExpansionPanelDetails, - Typography -} from '@material-ui/core'; -import { ExpandMore as ExpandMoreIcon } from '@material-ui/icons'; -import ReactMarkdown from 'react-markdown'; -import rehypeRaw from 'rehype-raw'; -import remarkGfm from 'remark-gfm'; -import { ResizeObserver } from '@juggle/resize-observer'; - -import { icons } from '@postgres.ai/shared/styles/icons'; -import { Spinner } from '@postgres.ai/shared/components/Spinner'; - -import { usePrev } from 'hooks/usePrev'; - -import { getMaxScrollTop, getMessageArtifactIds, getUserMessagesCount } from './utils'; -import { Banner } from './Banner'; - -import styles from './styles.module.scss'; - -export const Messages = (props) => { - const { - classes, - messages, - markdownLink, - sendCommand, - loadMessageArtifacts, - preformatJoeMessageStatus, - systemMessages - } = props; - - const rootRef = useRef(); - const wrapperRef = useRef(); - const atBottomRef = useRef(true); - const shouldSkipScrollCalcRef = useRef(false); - - // Scroll handlers. - const scrollBottom = () => { - shouldSkipScrollCalcRef.current = true; - rootRef.current.scrollTop = getMaxScrollTop(rootRef.current); - atBottomRef.current = true; - }; - - const scrollBottomIfNeed = () => { - if (!atBottomRef.current) { - return; - } - - scrollBottom(); - }; - - // Listening resizing of wrapper. - useEffect(() => { - const observedElement = wrapperRef.current; - if (!observedElement) return; - - const resizeObserver = new ResizeObserver(scrollBottomIfNeed); - resizeObserver.observe(observedElement); - - return () => resizeObserver.unobserve(observedElement); - }, [wrapperRef.current]); - - // Scroll to bottom if user sent new message. - const userMessagesCount = getUserMessagesCount(messages); - const prevUserMessagesCount = usePrev(userMessagesCount); - - if (userMessagesCount > prevUserMessagesCount) { - scrollBottom(); - } - - // Check auto-scroll condition. - const calcIsAtBottom = () => { - if (shouldSkipScrollCalcRef.current) { - shouldSkipScrollCalcRef.current = false; - return; - } - - atBottomRef.current = rootRef.current.scrollTop >= getMaxScrollTop(rootRef.current); - }; - - return ( -
-
-
- {messages && - // TODO(Anton): Objects doesn't guarantee keys order. - Object.keys(messages).map((m) => { - const msgArtifacts = getMessageArtifactIds(messages[m].message); - const otherArtifactsExists = - messages[m].message.indexOf('Other artifacts are provided in the thread') !== -1; - - return ( -
-
- {messages[m].author_id ? icons.userChatIcon : icons.joeChatIcon} -
-
- - {messages[m].author_id ? 'You' : 'Joe Bot'} - - {messages[m].formattedTime} - {(!messages[m].parent_id || - (messages[m].parent_id && messages[messages[m].parent_id])) && ( - - )} -
- {messages[m].parent_id ? ( -
- { - { - return markdownLink(properties, m); - }, - p: 'div' - }} - /> - } -
- ) : ( -
- -
- )} - {messages[m].delivery_status === 'artifact_attached' && otherArtifactsExists ? ( -
- - } - onClick={(event) => { - loadMessageArtifacts(event, messages[m].id); - }} - aria-controls={'message-' + messages[m].id + '-artifacts-content'} - id={'message-' + messages[m].id + '-artifacts-header'} - className={classes.advancedExpansionPanelSummary} - > - - Other artifacts - - - - {!messages[m].artifacts || - (messages[m].artifacts && messages[m].artifacts.isProcessing) ? ( - - ) : null} - - {messages[m].artifacts && messages[m].artifacts.files ? ( -
- {Object.keys(messages[m].artifacts.files).map((f) => { - let artifactAlreadyExists = - msgArtifacts.indexOf(messages[m].artifacts.files[f].id) !== -1; - return !artifactAlreadyExists ? ( - - } - className={ - classes.messageArtifactExpansionPanelSummary - } - aria-controls={ - 'artifact-' + - messages[m].artifacts.files[f].id + - '-content' - } - id={ - 'artifact-' + - messages[m].artifacts.files[f].id + - '-header' - } - > - - {messages[m].artifacts.files[f].title} - - - - - - - ) : null; - })} -
- ) : null} -
-
-
- ) : null} - {!messages[m].author_id ? ( -
- {preformatJoeMessageStatus(messages[m].status)} -
- ) : null} -
- ); - })} -
-
- - { Boolean(systemMessages.length) && } -
- ); -}; diff --git a/ui/packages/platform/src/pages/JoeInstance/Messages/styles.module.scss b/ui/packages/platform/src/pages/JoeInstance/Messages/styles.module.scss deleted file mode 100644 index 55701107..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Messages/styles.module.scss +++ /dev/null @@ -1,23 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -@import '@postgres.ai/shared/styles/vars'; - -.root { - margin-top: 20px; - border: 1px $color-gray solid; - border-radius: 4px; - overflow: hidden; - flex: 1 0 160px; - display: flex; - flex-direction: column; -} - -.messages { - overflow-y: scroll; - flex: 1 1 100%; -} diff --git a/ui/packages/platform/src/pages/JoeInstance/Messages/utils.ts b/ui/packages/platform/src/pages/JoeInstance/Messages/utils.ts deleted file mode 100644 index e1f34c0a..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/Messages/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -export const getMaxScrollTop = (element: HTMLElement) => - element.scrollHeight - element.clientHeight - -export const getMessageArtifactIds = (message: string) => { - const ids: number[] = [] - - if (!message) { - return ids - } - - const match = message.match(/\/artifact\/(\d*)/) - if (!match || (match && match.length < 3)) { - return ids - } - - for (let i = 1; i < match.length; i = i + 2) { - ids.push(parseInt(match[i], 10)) - } - - return ids -} - -export const getUserMessagesCount = (messages: { - [x: string]: { author_id: string } -}) => { - if (!messages) { - return 0 - } - - const keys = Object.keys(messages) - - return keys.reduce((count, key) => { - return messages[key].author_id ? count + 1 : count - }, 0) -} diff --git a/ui/packages/platform/src/pages/JoeInstance/index.jsx b/ui/packages/platform/src/pages/JoeInstance/index.jsx deleted file mode 100644 index 35f7b748..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/index.jsx +++ /dev/null @@ -1,453 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { - Button, - TextField, - MenuItem, - ExpansionPanel, - ExpansionPanelSummary, - ExpansionPanelDetails, - Typography -} from '@material-ui/core'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; - -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner'; -import { Spinner } from '@postgres.ai/shared/components/Spinner'; -import { colors } from '@postgres.ai/shared/styles/colors'; -import { styles } from '@postgres.ai/shared/styles/styles'; -import { icons } from '@postgres.ai/shared/styles/icons'; - -import Store from 'stores/store'; -import Actions from 'actions/actions'; -import { ErrorWrapper } from 'components/Error/ErrorWrapper'; -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper'; -import ConsolePageTitle from 'components/ConsolePageTitle'; - -import { getSystemMessages } from './utils'; -import { Messages } from './Messages'; -import { Command } from './Command'; - -import './styles.scss'; - -const VERIFY_MESSAGES_TIMEOUT = 60 * 1000; -class JoeInstance extends Component { - state = { - command: '', - channelId: null, - artifacts: {} - }; - - componentDidMount() { - const that = this; - const instanceId = this.props.match.params.instanceId; - const orgId = this.props.orgId; - - - this.unsubscribe = Store.listen(function () { - const auth = this.data && this.data.auth ? this.data.auth : null; - const joeInstance = this.data && this.data.joeInstance && - this.getJoeInstance(instanceId) ? this.getJoeInstance(instanceId) : null; - - if (auth && auth.token && !joeInstance.isProcessing && - !that.state.data) { - Actions.getJoeInstance(auth.token, orgId, 0, instanceId); - } - - if (auth && auth.token && !joeInstance.isChannelsProcessing && - !joeInstance.isChannelsProcessed && joeInstance.isProcessed && - !joeInstance.channelsError && joeInstance.data && joeInstance.data.id) { - Actions.getJoeInstanceChannels(auth.token, instanceId); - } - - that.setState({ - data: this.data - }); - - if (!that.state.channelId && Object.keys(joeInstance.channels).length > 0) { - that.setState({ - channelId: joeInstance.channels[Object.keys(joeInstance.channels)[0]].channelId - }, () => { - that.sendHelpCommand(); - }); - } - }); - - Actions.refresh(); - } - - sendHelpCommand = () => { - const channelId = this.state.channelId; - const instanceId = this.props.match.params.instanceId; - const instance = this.state.data && this.state.data.joeInstance && - this.state.data.joeInstance.instances[instanceId] ? - this.state.data.joeInstance.instances[instanceId] : null; - const messages = instance && instance.messages && - instance.messages[channelId] ? - instance.messages[channelId] : null; - - if (messages && Object.keys(messages).length > 0) { - return; - } - - this.sendCommand('help'); - }; - - sendCommand = (cmd) => { - const instanceId = this.props.match.params.instanceId; - const auth = this.state.data && this.state.data.auth ? - this.state.data.auth : null; - const instance = this.state.data && this.state.data.joeInstance && - this.state.data.joeInstance.instances[instanceId] ? - this.state.data.joeInstance.instances[instanceId] : null; - - // Send command. - Actions.sendJoeInstanceCommand( - auth.token, instanceId, - this.state.channelId, - cmd, - instance.channels[this.state.channelId].sessionId - ); - }; - - handleChangeChannel = (event) => { - this.setState({ channelId: event.target.value }, () => { - this.sendHelpCommand(); - }); - }; - - handleChangeCommand = (event) => { - this.setState({ - command: event.target.value - }); - }; - - componentWillUnmount() { - const instanceId = this.props.match.params.instanceId; - - this.unsubscribe(); - if (this.updateInterval) { - clearInterval(this.updateInterval); - } - - if (this.domObserver) { - this.domObserver.disconnect(); - } - - Actions.closeJoeWebSocketConnection(instanceId); - } - - onSend = (value) => { - const instanceId = this.props.match.params.instanceId; - const auth = this.state.data && this.state.data.auth ? - this.state.data.auth : null; - const instance = this.state.data && this.state.data.joeInstance && - this.state.data.joeInstance.instances[instanceId] ? - this.state.data.joeInstance.instances[instanceId] : null; - - this.sendCommand(value); - - if (!this.updateInterval) { - this.updateInterval = setInterval(() => { - Actions.getJoeInstanceMessages( - auth.token, - instanceId, - this.state.channelId, - instance.channels[this.state.channelId].sessionId - ); - }, VERIFY_MESSAGES_TIMEOUT); - } - }; - - loadMessageArtifacts = (event, messageId) => { - const auth = this.state.data && this.state.data.auth ? - this.state.data.auth : null; - const channelId = this.state.channelId; - const instanceId = this.props.match.params.instanceId; - const instance = this.state.data && this.state.data.joeInstance && - this.state.data.joeInstance.instances[instanceId] ? - this.state.data.joeInstance.instances[instanceId] : null; - const messages = instance && instance.messages && - instance.messages[channelId] ? - instance.messages[channelId] : null; - - if (messages && messages[messageId].artifacts && - messages[messageId].artifacts.files) { - // Artifacts already loaded. - return true; - } - - Actions.getJoeMessageArtifacts( - auth.token, - instanceId, - channelId, - messageId - ); - - return true; - }; - - preformatJoeMessageStatus = (status) => { - const { classes } = this.props; - let icon; - let text; - let color; - - switch (status) { - case 'error': - icon = icons.failedIcon; - text = 'Failed'; - color = colors.state.error; - break; - case 'ok': - icon = icons.okIcon; - text = 'Completed'; - color = colors.state.ok; - break; - case 'running': - icon = icons.hourGlassIcon; - text = 'Processing'; - color = colors.state.processing; - break; - default: - return null; - } - - return ( -
- {icon} {text} -
- ); - }; - - markdownLink = (linkProps, messageId) => { - const { classes } = this.props; - const { href, target, children } = linkProps; - const instanceId = this.props.match.params.instanceId; - const channelId = this.state.channelId; - const instance = this.state.data && this.state.data.joeInstance && - this.state.data.joeInstance.instances[instanceId] ? - this.state.data.joeInstance.instances[instanceId] : null; - const messages = instance && instance.messages && - instance.messages[channelId] ? - instance.messages[channelId] : null; - let artifactId = false; - let artifacts = messages[messageId] && messages[messageId].artifacts ? - messages[messageId].artifacts : null; - - - let match = href.match(/\/artifact\/(\d*)/); - if (match && match.length > 1) { - artifactId = match[1]; - } - - if (href.startsWith('/artifact/') && artifactId) { - return ( -
- - } - onClick={ - event => { - this.setState({ artifacts: { [artifactId]: !this.state.artifacts[artifactId] } }); - this.loadMessageArtifacts(event, messages[messageId].id); - } - } - aria-controls={'message-' + messageId + '-artifacts-content'} - id={'message-' + messageId + '-artifacts-header'} - className={classes.advancedExpansionPanelSummary} - > - - {children} - - - - {(!artifacts || (artifacts && artifacts.isProcessing)) ? ( - ) : ''} - {artifacts && artifacts.files ? ( -
-
- -
-
- ) : null} -
-
-
- ); - } - - return ( - {children} - ); - }; - - render() { - const { classes } = this.props; - const instanceId = this.props.match.params.instanceId; - const channelId = this.state.channelId; - const instance = this.state.data && this.state.data.joeInstance && - this.state.data.joeInstance.instances[instanceId] ? - this.state.data.joeInstance.instances[instanceId] : null; - const messages = instance && instance.messages && - instance.messages[channelId] ? - instance.messages[channelId] : null; - - const breadcrumbs = ( - - ); - - if (!this.state.data || - (instance && (instance.isChannelsProcessing || instance.isProcessing))) { - return ( - <> - {breadcrumbs} - - - - ); - } - - if (instance.error) { - return ( - <> - {breadcrumbs} - - - - ); - } - - if (instance.channelsError) { - return ( - <> - {breadcrumbs} - - - - ); - } - - return ( - <> - {breadcrumbs} - - - - { instance && instance.channelsErrorMessage ? ( -
- {instance.channelsErrorMessage} -
- ) : null } - -
- this.handleChangeChannel(event)} - select - label='Project and database' - inputProps={{ - name: 'channel', - id: 'channel' - }} - InputLabelProps={{ - shrink: true, - style: styles.inputFieldLabel - }} - FormHelperTextProps={{ - style: styles.inputFieldHelper - }} - variant='outlined' - className={classes.channelsList} - > - {Object.keys(instance.channels).map(c => { - return ( - - {instance.channels[c].channelId} - - ); - })} - - -
- - - - - - ); - } -} - -JoeInstance.propTypes = { - classes: PropTypes.object.isRequired, - theme: PropTypes.object.isRequired -}; - -export default JoeInstance diff --git a/ui/packages/platform/src/pages/JoeInstance/styles.scss b/ui/packages/platform/src/pages/JoeInstance/styles.scss deleted file mode 100644 index 13cede3c..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/styles.scss +++ /dev/null @@ -1,101 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -.markdown { - margin: 5px; - margin-left: 0; - font-size: 14px; -} - -.markdown h1 { - margin-top: 5px; -} - -.markdown table { - border-collapse: collapse; - border-spacing: 0; -} - -.markdown pre { - border: 1px solid #ccc; - background: #f6f8fa; - padding: 5px; - font-size: 13px !important; - margin: 0; - border-radius: 3px; -} - -.markdown blockquote { - color: #666; - margin: 0; - padding-left: 3em; - border-left: 0.5em #eee solid; -} - -.markdown tr { - border-top: 1px solid #c6cbd1; - background: #fff; -} - -.markdown th, -.markdown td { - padding: 10px 13px; - border: 1px solid #dfe2e5; -} - -.markdown table tr:nth-child(2n) { - background: #f6f8fa; -} - -/* stylelint-disable-next-line selector-no-qualifying-type */ -.markdown img.emoji { - margin-top: 5px; -} - -.markdown code { - /* stylelint-disable-next-line color-named */ - border: 1px dotted silver; - display: inline-block; - border-radius: 3px; - padding: 2px; - background-color: #f6f8fa; - margin-bottom: 3px; - font-size: 13px !important; - font-family: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', - 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -} - -.markdown pre { - overflow-wrap: break-word; - white-space: pre-wrap; -} - -.markdown pre code { - background: none; - border: 0; - margin: 0; - border-radius: 0; - display: inline; - padding: 0; - font-size: 13px !important; - font-family: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', - 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -} - -.markdown div:not([class]) { - display: block; - margin-block-start: 1em; - margin-block-end: 1em; - margin-inline-start: 0; - margin-inline-end: 0; -} - -/* stylelint-disable-next-line selector-class-pattern */ -.markdown .MuiExpansionPanel-root div { - margin-block-start: 0; - margin-block-end: 0; -} diff --git a/ui/packages/platform/src/pages/JoeInstance/utils.ts b/ui/packages/platform/src/pages/JoeInstance/utils.ts deleted file mode 100644 index befec871..00000000 --- a/ui/packages/platform/src/pages/JoeInstance/utils.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -type Instance = { - channels?: { - [key in string]?: { - commandErrorMessage?: string - wsErrorMessage?: string - } - } -} - -export const getSystemMessages = (instance: Instance, channelId: string) => { - const channels = instance?.channels - if (!channels) return [] - - const channel = channels[channelId] - if (!channel) return [] - - const { commandErrorMessage, wsErrorMessage } = channel - - const systemMessages = [] - - if (commandErrorMessage) { - systemMessages.push({ - type: 'error' as const, - value: commandErrorMessage, - }) - } - - if (wsErrorMessage) { - systemMessages.push({ - type: 'warning' as const, - value: wsErrorMessage, - }) - } - - return systemMessages -} diff --git a/ui/packages/platform/src/pages/JoeSessionCommand/JoeSessionCommandWrapper.jsx b/ui/packages/platform/src/pages/JoeSessionCommand/JoeSessionCommandWrapper.jsx deleted file mode 100644 index eeced9de..00000000 --- a/ui/packages/platform/src/pages/JoeSessionCommand/JoeSessionCommandWrapper.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import { makeStyles } from '@material-ui/core' -import JoeSessionCommand from 'pages/JoeSessionCommand' -import { styles } from '@postgres.ai/shared/styles/styles' - -export const JoeSessionCommandWrapper = (props) => { - const useStyles = makeStyles( - (theme) => ({ - root: { - display: 'flex', - 'flex-direction': 'column', - flex: '1 1 100%', - - '& h4': { - marginTop: '2em', - marginBottom: '0.7em', - }, - }, - appBar: { - position: 'relative', - }, - title: { - marginLeft: theme.spacing(2), - flex: 1, - fontSize: '16px', - }, - actions: { - display: 'flex', - }, - visFrame: { - height: '100%', - }, - nextButton: { - marginLeft: '10px', - }, - flameGraphContainer: { - padding: '20px', - }, - bottomSpace: { - ...styles.bottomSpace, - }, - warningContainer: { - marginTop: theme.spacing(2) - } - }), - { index: 1 }, - ) - - const classes = useStyles() - - return -} diff --git a/ui/packages/platform/src/pages/JoeSessionCommand/TabPanel/index.tsx b/ui/packages/platform/src/pages/JoeSessionCommand/TabPanel/index.tsx deleted file mode 100644 index 65790bf9..00000000 --- a/ui/packages/platform/src/pages/JoeSessionCommand/TabPanel/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ -import Box from '@mui/material/Box' -import { Typography, makeStyles } from '@material-ui/core' -import { TabPanelProps } from '@postgres.ai/platform/src/components/types' - -const useStyles = makeStyles( - { - root: { - marginTop: 0, - }, - content: { - padding: '10px 0 0 0', - }, - }, - { index: 1 }, -) - -export const TabPanel = (props: TabPanelProps) => { - const { children, value, index } = props - - const classes = useStyles() - - return ( - - ) -} diff --git a/ui/packages/platform/src/pages/JoeSessionCommand/index.js b/ui/packages/platform/src/pages/JoeSessionCommand/index.js deleted file mode 100644 index 3d5547b4..00000000 --- a/ui/packages/platform/src/pages/JoeSessionCommand/index.js +++ /dev/null @@ -1,512 +0,0 @@ -/*-------------------------------------------------------------------------- - * Copyright (c) 2019-2021, Postgres.ai, Nikolay Samokhvalov nik@postgres.ai - * All Rights Reserved. Proprietary and confidential. - * Unauthorized copying of this file, via any medium is strictly prohibited - *-------------------------------------------------------------------------- - */ - -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import Dialog from '@material-ui/core/Dialog'; -import Button from '@material-ui/core/Button'; -import AppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import IconButton from '@material-ui/core/IconButton'; -import Typography from '@material-ui/core/Typography'; -import CloseIcon from '@material-ui/icons/Close'; -import Slide from '@material-ui/core/Slide'; -import Tabs from '@material-ui/core/Tabs'; -import Tab from '@material-ui/core/Tab'; -import { formatDistanceToNowStrict } from 'date-fns'; - -import { FormattedText } from '@postgres.ai/shared/components/FormattedText'; -import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner'; -import { Spinner } from '@postgres.ai/shared/components/Spinner'; -import { isValidDate } from '@postgres.ai/shared/utils/date' - -import Store from 'stores/store'; -import Actions from 'actions/actions'; -import { ErrorWrapper } from 'components/Error/ErrorWrapper'; -import { ConsoleBreadcrumbsWrapper } from 'components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper'; -import ConsolePageTitle from 'components/ConsolePageTitle'; -import FlameGraph from 'components/FlameGraph'; -import { visualizeTypes } from 'assets/visualizeTypes'; -import Urls from 'utils/urls'; -import Permissions from 'utils/permissions'; -import format from 'utils/format'; - -import { TabPanel } from './TabPanel'; -import Alert from "@mui/material/Alert"; - -const hashLinkVisualizePrefix = 'visualize-'; - -function a11yProps(index) { - return { - 'id': `plan-tab-${index}`, - 'aria-controls': `plan-tabpanel-${index}` - }; -} - -const FullScreenDialogTransition = React.forwardRef(function Transition(props, ref) { - return ; -}); - -class JoeSessionCommand extends Component { - componentDidMount() { - const that = this; - const commandId = this.getCommandId(); - const orgId = this.props.orgId ? this.props.orgId : null; - - this.handleChangeTab(null, 0); - - this.unsubscribe = Store.listen(function () { - const auth = this.data && this.data.auth ? this.data.auth : null; - const command = this.data && this.data.command ? - this.data.command : null; - - that.setState({ data: this.data }); - if (auth && auth.token && !command.isProcessing && !command.error && - !that.state && orgId) { - Actions.getJoeSessionCommand(auth.token, orgId, commandId); - } - - if (!Urls.isSharedUrl() && !this.data.shareUrl.isProcessing && - !this.data.sharedUrls['command'][commandId]) { - Actions.getSharedUrl(this.data.auth.token, orgId, 'command', commandId); - } - - setTimeout(()=> { - const commandData = command ? command.data : null; - if (!!commandData && !this.hashLocationProcessed) { - this.hashLocationProcessed = true; - that.handleHashLocation(); - } - }, 10); - }); - - Actions.refresh(); - } - - componentWillUnmount() { - this.unsubscribe(); - } - - handleHashLocation() { - const hash = this.props.history.location.hash; - if (!hash || !hash.includes(hashLinkVisualizePrefix)) { - return; - } - - const type = hash.replace('#', '').replace(hashLinkVisualizePrefix, ''); - - if (type === visualizeTypes.flame) { - this.showFlameGraphVisualization(); - return; - } - - this.showExternalVisualization(type); - } - - setHashUrl(hashUrl) { - this.props.history.replace(this.props.history.location.pathname + hashUrl); - } - - removeHashUrl() { - this.props.history.replace(this.props.history.location.pathname); - } - - getCommandId = () => { - if (this.props.match && this.props.match.params && this.props.match.params.commandId) { - return parseInt(this.props.match.params.commandId, 10); - } - - return parseInt(this.props.commandId, 10); - }; - - getCommandData = () => { - const commandId = this.getCommandId(); - const data = this.state && this.state.data && this.state.data.command && - this.state.data.command.data ? this.state.data.command.data : null; - - return data && data.commandId === commandId ? data : null; - }; - - getExternalVisualization = () => { - return this.state && this.state.data && - this.state.data.externalVisualization ? this.state.data.externalVisualization : null; - }; - - isExplain = () => { - const data = this.getCommandData(); - return !!data && data.command === 'explain'; - }; - - planHasNoRows = () => { - if (!this.isExplain()) return false; - const data = this.getCommandData(); - const planExecJson = data && data.planExecJson; - if (!planExecJson) return false; - - const planExecJsonParsed = JSON.parse(planExecJson); - if (!Array.isArray(planExecJsonParsed) || planExecJsonParsed.length === 0) return false; - - const plan = planExecJsonParsed[0] && planExecJsonParsed[0]["Plan"]; - return plan && plan["Actual Rows"] === 0; - } - - showExternalVisualization = (type) => { - const data = this.getCommandData(); - - if (!this.isExplain() || data.planExecJson.length === 0) { - return; - } - - this.setHashUrl('#' + hashLinkVisualizePrefix + type); - - Actions.getExternalVisualizationData( - type, - { - json: data.planExecJson, - text: data.planExecText - }, - data.query - ); - }; - - closeExternalVisualization = () => { - this.props.history.replace(this.getCommandId()); - Actions.closeExternalVisualization(); - this.setState({ - showFlameGraph: false - }); - }; - - handleExternalVisualizationClick = (type) => { - return () => { - this.showExternalVisualization(type); - }; - }; - - showFlameGraphVisualization = () => { - this.setHashUrl('#' + hashLinkVisualizePrefix + visualizeTypes.flame); - this.setState({ - showFlameGraph: true - }); - }; - - handleChangeTab = (event, tabValue) => { - this.setState({ tab: tabValue }); - }; - - showShareDialog = () => { - const commandId = this.getCommandId(); - const orgId = this.props.orgId ? this.props.orgId : null; - - if (!orgId) { - return; - } - - Actions.showShareUrlDialog( - parseInt(orgId, 10), - 'command', - commandId, - 'Anyone on the internet with the special link can view query, plan and ' + - 'all parameters. Check that there is no sensitive data.' - ); - }; - - render() { - const { classes } = this.props; - const data = this.getCommandData(); - const commandId = this.getCommandId(); - const sessionId = this.props.sessionId || this.props.match.params.sessionId; - const orgId = this.props.orgId ? this.props.orgId : null; - let allowShare = false; - - if (this.props.orgData && orgId) { - let permissions = Permissions.getPermissions(this.props.orgData); - allowShare = permissions.shareUrl; - } - - const breadcrumbs = ( - - ) - - if (this.state && this.state.data && this.state.data.command.error) { - return ( -
- {breadcrumbs} - -
- ); - } - - if (!data) { - return ( -
- {breadcrumbs} - - -
- ); - } - - let username = 'Unknown'; - if (data.slackUsername && data.slackUid) { - username = `${data.slackUsername} (${data.slackUid})`; - } else if (data.username) { - username = data.username; - } else if (data.useremail) { - username = data.useremail; - } - - const externalVisualization = this.getExternalVisualization(); - - const showFlameGraph = this.state.showFlameGraph; - const disableVizButtons = showFlameGraph || - (externalVisualization && externalVisualization.isProcessing); - const openVizDialog = showFlameGraph || (externalVisualization && - externalVisualization.url && externalVisualization.url.length > 0); - const title = `Command #${commandId} (${data.command}) from session #${sessionId}`; - - let shareUrlButton = ( - - ); - - let titleActions = []; - if (!Urls.isSharedUrl() && allowShare) { - titleActions.push(shareUrlButton); - } - - let isShared = this.state.data.sharedUrls && this.state.data.sharedUrls['command'] && - this.state.data.sharedUrls['command'][commandId] && - this.state.data.sharedUrls['command'][commandId].uuid; - - return ( -
- { breadcrumbs } - - - - { this.isExplain() && -
- - - - - -
- } - - {this.planHasNoRows() &&
- Query returned 0 rows. This may not reflect production performance or use the same query plan. If you expect results, try adjusting parameters (e.g., different ID values). - -
} - -
-

Author:

-

- { username } -

-
- -

Command:

- - - { data.query && ( - -

Query:

- -
- )} - - {this.isExplain() ? ( -
-

Plan:

- - - - - - - - - - - - - - - - - - -

Statistics:

- - -

Query locks:

- - -

Details:

- - Uploaded:  - {isValidDate(new Date(data.createdAt)) - ? formatDistanceToNowStrict(new Date(data.createdAt), { - addSuffix: true, - }) - : '-'} -   - ({ format.formatTimestampUtc(data.createdAt) }) - -
- ) : ( -
-

Response:

- -
- )} - - { data.error && data.error.length > 0 && - - } - -
- - - - - Visualization - - - - - - - - { showFlameGraph && -
-

Flame Graph (buffers):

- - -

Flame Graph (timing):

- -
- } - - { externalVisualization.url && -