diff --git a/api/server/handlers/cloud_provider/list_aws.go b/api/server/handlers/cloud_provider/list_aws.go index e7b0e72d61..18b0ac1b62 100644 --- a/api/server/handlers/cloud_provider/list_aws.go +++ b/api/server/handlers/cloud_provider/list_aws.go @@ -85,10 +85,26 @@ func (c *ListAwsAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - res.Accounts = append(res.Accounts, AwsAccount{ + account := AwsAccount{ CloudProviderID: targetArn.AccountID, ProjectID: uint(link.ProjectID), - }) + } + if contains(res.Accounts, account) { + continue + } + + res.Accounts = append(res.Accounts, account) } c.WriteResult(w, r, res) } + +// contains will check if the list of AwsAccounts contains the specified account +// TODO: replace this with an upgrade to Go 1.21 in favor of slices.Contains() +func contains(s []AwsAccount, e AwsAccount) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/api/server/handlers/datastore/delete.go b/api/server/handlers/datastore/delete.go new file mode 100644 index 0000000000..2157b8074d --- /dev/null +++ b/api/server/handlers/datastore/delete.go @@ -0,0 +1,119 @@ +package datastore + +import ( + "context" + "net/http" + + "github.com/porter-dev/porter/api/server/authz" + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/apierrors" + "github.com/porter-dev/porter/api/server/shared/config" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// DeleteRequest describes an inbound datastore deletion request +type DeleteRequest struct { + Type string `json:"type" form:"required"` + Name string `json:"name" form:"required"` +} + +// DeleteDatastoreHandler is a struct for handling datastore deletion requests +type DeleteDatastoreHandler struct { + handlers.PorterHandlerReadWriter + authz.KubernetesAgentGetter +} + +// NewDeleteDatastoreHandler constructs a datastore DeleteDatastoreHandler +func NewDeleteDatastoreHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *DeleteDatastoreHandler { + return &DeleteDatastoreHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config), + } +} + +func (h *DeleteDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-datastore-delete") + defer span.End() + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + + request := &StatusRequest{} + if ok := h.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "error decoding request") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "datastore-name", Value: request.Name}, + telemetry.AttributeKV{Key: "datastore-type", Value: request.Type}, + ) + + cluster, err := h.getClusterForDatastore(ctx, r, project.ID, request.Name) + if err != nil { + err = telemetry.Error(ctx, span, err, "unable to find datastore on any associated cluster") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID}) + + helmAgent, err := h.GetHelmAgent(ctx, r, cluster, "ack-system") + if err != nil { + err := telemetry.Error(ctx, span, err, "unable to get helm client for cluster") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + _, err = helmAgent.GetRelease(ctx, request.Name, 0, false) + if err != nil { + err := telemetry.Error(ctx, span, err, "unable to get helm release") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + _, err = helmAgent.UninstallChart(ctx, request.Name) + if err != nil { + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID}) + err := telemetry.Error(ctx, span, err, "unable to uninstall chart") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + // if the release was deleted by helm without error, mark it as accepted + w.WriteHeader(http.StatusAccepted) +} + +func (h *DeleteDatastoreHandler) getClusterForDatastore(ctx context.Context, r *http.Request, projectID uint, datastoreName string) (*models.Cluster, error) { + ctx, span := telemetry.NewSpan(ctx, "get-cluster-for-datastore") + + if r == nil { + return nil, telemetry.Error(ctx, span, nil, "missing http request object") + } + + clusters, err := h.Repo().Cluster().ListClustersByProjectID(projectID) + if err != nil { + return nil, telemetry.Error(ctx, span, err, "unable to get project clusters") + } + + for _, cluster := range clusters { + helmAgent, err := h.GetHelmAgent(ctx, r, cluster, "ack-system") + if err != nil { + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID}) + return nil, telemetry.Error(ctx, span, err, "unable to get helm client for cluster") + } + + _, err = helmAgent.GetRelease(ctx, datastoreName, 0, false) + if err == nil { + return cluster, nil + } + } + + return nil, telemetry.Error(ctx, span, nil, "unable to find datastore on any associated cluster") +} diff --git a/api/server/handlers/datastore/list.go b/api/server/handlers/datastore/list.go index b63b06d828..603a391be9 100644 --- a/api/server/handlers/datastore/list.go +++ b/api/server/handlers/datastore/list.go @@ -62,7 +62,7 @@ type ListDatastoresHandler struct { authz.KubernetesAgentGetter } -// NewListDatastoresHandler constructs a datastore ListHandler +// NewListDatastoresHandler constructs a datastore ListDatastoresHandler func NewListDatastoresHandler( config *config.Config, decoderValidator shared.RequestDecoderValidator, diff --git a/api/server/router/datastore.go b/api/server/router/datastore.go index 3a31a93c3c..6030dc8f95 100644 --- a/api/server/router/datastore.go +++ b/api/server/router/datastore.go @@ -58,7 +58,7 @@ func getDatastoreRoutes( } routes := make([]*router.Route, 0) - // GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewListHandler + // GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewListDatastoresHandler listEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ Verb: types.APIVerbGet, @@ -86,7 +86,35 @@ func getDatastoreRoutes( Router: r, }) - // GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores/{datastore_type}/{datastore_name} -> cloud_provider.NewListHandler + // DELETE /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewDeleteDatastoreHandler + deleteEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbDelete, + Method: types.HTTPVerbDelete, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + deleteHandler := datastore.NewDeleteDatastoreHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: deleteEndpoint, + Handler: deleteHandler, + Router: r, + }) + + // GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores/{datastore_type}/{datastore_name} -> cloud_provider.NewListDatastoresHandler getEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ Verb: types.APIVerbGet, diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index 56627d6d61..3457c7fb75 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -1,14 +1,14 @@ -import React, { useEffect, useState, useContext, useRef } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Route, RouteComponentProps, Switch, withRouter } from "react-router"; import styled, { ThemeProvider } from "styled-components"; -import { createPortal } from "react-dom"; import api from "shared/api"; -import midnight from "shared/themes/midnight"; -import standard from "shared/themes/standard"; import { Context } from "shared/Context"; import { PorterUrl, pushFiltered, pushQueryParams } from "shared/routing"; -import { ClusterType, ProjectType, ProjectListType } from "shared/types"; +import midnight from "shared/themes/midnight"; +import standard from "shared/themes/standard"; +import { ClusterType, ProjectListType, ProjectType } from "shared/types"; import ConfirmOverlay from "components/ConfirmOverlay"; import Loading from "components/Loading"; @@ -17,37 +17,39 @@ import Dashboard from "./dashboard/Dashboard"; import Integrations from "./integrations/Integrations"; import LaunchWrapper from "./launch/LaunchWrapper"; +import AddOnDashboard from "./add-on-dashboard/AddOnDashboard"; +import AppDashboard from "./app-dashboard/AppDashboard"; +import CreateDatabase from "./database-dashboard/CreateDatabase"; +import DatabaseDashboard from "./database-dashboard/DatabaseDashboard"; import Navbar from "./navbar/Navbar"; import ProjectSettings from "./project-settings/ProjectSettings"; import Sidebar from "./sidebar/Sidebar"; -import AppDashboard from "./app-dashboard/AppDashboard"; -import AddOnDashboard from "./add-on-dashboard/AddOnDashboard"; -import DatabaseDashboard from "./database-dashboard/DatabaseDashboard"; -import CreateDatabase from "./database-dashboard/CreateDatabase"; -import { fakeGuardedRoute } from "shared/auth/RouteGuard"; -import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc"; -import discordLogo from "../../assets/discord.svg"; -import Onboarding from "./onboarding/Onboarding"; -import ModalHandler from "./ModalHandler"; -import { NewProjectFC } from "./new-project/NewProject"; -import InfrastructureRouter from "./infrastructure/InfrastructureRouter"; -import { overrideInfraTabEnabled } from "utils/infrastructure"; import NoClusterPlaceHolder from "components/NoClusterPlaceHolder"; -import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow"; +import Button from "components/porter/Button"; import Modal from "components/porter/Modal"; -import Text from "components/porter/Text"; import Spacer from "components/porter/Spacer"; -import Button from "components/porter/Button"; -import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow"; -import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp"; -import CreateApp from "./app-dashboard/create-app/CreateApp"; +import Text from "components/porter/Text"; +import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc"; +import { fakeGuardedRoute } from "shared/auth/RouteGuard"; +import ClusterResourcesProvider from "shared/ClusterResourcesContext"; +import DeploymentTargetProvider from "shared/DeploymentTargetContext"; +import { overrideInfraTabEnabled } from "utils/infrastructure"; +import discordLogo from "../../assets/discord.svg"; +import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow"; import AppView from "./app-dashboard/app-view/AppView"; import Apps from "./app-dashboard/apps/Apps"; -import DeploymentTargetProvider from "shared/DeploymentTargetContext"; +import CreateApp from "./app-dashboard/create-app/CreateApp"; +import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp"; +import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow"; import PreviewEnvs from "./cluster-dashboard/preview-environments/v2/PreviewEnvs"; import SetupApp from "./cluster-dashboard/preview-environments/v2/setup-app/SetupApp"; -import ClusterResourcesProvider from "shared/ClusterResourcesContext"; +import DatabaseView from "./database-dashboard/DatabaseView"; +import InfrastructureRouter from "./infrastructure/InfrastructureRouter"; +import ModalHandler from "./ModalHandler"; +import { NewProjectFC } from "./new-project/NewProject"; +import Onboarding from "./onboarding/Onboarding"; + // Guarded components const GuardedProjectSettings = fakeGuardedRoute("settings", "", [ @@ -198,7 +200,7 @@ const Home: React.FC = (props) => { } else { setHasFinishedOnboarding(true); } - } catch (error) {} + } catch (error) { } }; useEffect(() => { @@ -460,8 +462,14 @@ const Home: React.FC = (props) => { + + + + + + - + @@ -486,17 +494,17 @@ const Home: React.FC = (props) => { overrideInfraTabEnabled({ projectID: currentProject?.id, })) && ( - { - return ( - - - - ); - }} - /> - )} + { + return ( + + + + ); + }} + /> + )} { @@ -553,7 +561,7 @@ const Home: React.FC = (props) => { render={() => } /> {currentProject?.validate_apply_v2 && - currentProject.preview_envs_enabled ? ( + currentProject.preview_envs_enabled ? ( <> diff --git a/dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx b/dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx index 0657172fbc..e17d5054d8 100644 --- a/dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx +++ b/dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx @@ -1,10 +1,9 @@ +import EnvEditorModal from "main/home/modals/EnvEditorModal"; +import Modal from "main/home/modals/Modal"; import React, { useEffect, useState } from "react"; import styled from "styled-components"; -import Modal from "main/home/modals/Modal"; -import EnvEditorModal from "main/home/modals/EnvEditorModal"; import upload from "assets/upload.svg"; -import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray"; import { dotenv_parse } from "shared/string_utils"; export type KeyValueType = { @@ -348,7 +347,7 @@ const Label = styled.div` const StyledInputArray = styled.div` margin-bottom: 15px; - margin-top: 22px; + margin-top: 10px; `; export const MultiLineInputer = styled.textarea` diff --git a/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx b/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx index f2c8d57680..048b2baad3 100644 --- a/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx +++ b/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx @@ -1,220 +1,179 @@ +import React, { useContext, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import _ from "lodash"; -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import { Link } from "react-router-dom"; import styled from "styled-components"; -import calendar from "assets/calendar-number.svg"; -import database from "assets/database.svg"; -import grid from "assets/grid.png"; -import list from "assets/list.png"; -import notFound from "assets/not-found.png"; -import healthy from "assets/status-healthy.png"; -import time from "assets/time.png"; -import letter from "assets/vector.svg"; - -import { Context } from "shared/Context"; -import api from "shared/api"; -import { hardcodedIcons } from "shared/hardcodedNameDict"; -import { search } from "shared/search"; - +import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder"; +import Loading from "components/Loading"; import Button from "components/porter/Button"; import Container from "components/porter/Container"; +import DashboardPlaceholder from "components/porter/DashboardPlaceholder"; import Fieldset from "components/porter/Fieldset"; import PorterLink from "components/porter/Link"; import SearchBar from "components/porter/SearchBar"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import Toggle from "components/porter/Toggle"; -import { Link } from "react-router-dom"; -import { readableDate } from "shared/string_utils"; - -import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder"; -import DashboardPlaceholder from "components/porter/DashboardPlaceholder"; import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader"; -import loading from "assets/loading.gif"; -type Props = {}; +import api from "shared/api"; +import { Context } from "shared/Context"; +import { search } from "shared/search"; +import database from "assets/database.svg"; +import grid from "assets/grid.png"; +import list from "assets/list.png"; +import loading from "assets/loading.gif"; +import notFound from "assets/not-found.png"; +import healthy from "assets/status-healthy.png"; -const templateWhitelist = [ - "elasticache-redis", - "rds-postgresql", - "rds-postgresql-aurora", -]; +import { getDatastoreIcon } from "./icons"; +import { + cloudProviderListResponseValidator, + datastoreListResponseValidator, + type CloudProviderDatastore, + type CloudProviderWithSource, +} from "./types"; +import { datastoreField } from "./utils"; + +type Props = { + projectId: number; +}; -const Apps: React.FC = ({ -}) => { - const { currentProject, currentCluster } = useContext(Context); +const DatabaseDashboard: React.FC = ({ projectId }) => { + const { currentCluster } = useContext(Context); const [searchValue, setSearchValue] = useState(""); const [view, setView] = useState<"grid" | "list">("grid"); - const [sort, setSort] = useState<"calendar" | "letter">("calendar"); - // Placeholder (replace w useQuery) - const [databases, setDatabases] = useState([]); - const [status, setStatus] = useState(""); - const [databaseStatuses, setDatabaseStatuses] = useState({}); - - const filteredDatabases = useMemo(() => { - const filteredBySearch = search( - databases ?? [], - searchValue, - { - keys: ["name", "chart.metadata.name"], - isCaseSensitive: false, - } - ); - - return _.sortBy(filteredBySearch); - }, [databases, searchValue]); - - const updateDatabaseStatuses = async (): Promise => { - const newStatuses = {}; - for (const db of filteredDatabases) { - try { - if (databaseStatuses[db.name] !== "available") { - console.log(db) - const statusRes = await api.getDatabaseStatus("", { - name: db.name, - type: db.chart.metadata.name - }, { - project_id: currentProject?.id ?? 0, - cluster_id: currentCluster?.id ?? 0, - }); - if (statusRes.data.status === "available") { - newStatuses[db.name] = statusRes.data.status; - } - else { - newStatuses[db.name] = "updating"; - } - }// Assuming status is returned in this field - } catch (err) { - console.error("Error fetching database status for", db.name, err); - newStatuses[db.name] = "error"; // Or some error state - } - - } - setDatabaseStatuses(newStatuses); - }; - - - const getExpandedChartLinkURL = useCallback((x: any) => { - const params = new Proxy(new URLSearchParams(window.location.search), { - get: (searchParams, prop: string) => searchParams.get(prop), - }); - const cluster = currentCluster?.name; - const route = `/applications/${cluster}/${x.namespace}/${x.name}`; - const newParams = { - // @ts-ignore - project_id: params.project_id, - closeChartRedirectUrl: '/databases', - }; - const newURLSearchParams = new URLSearchParams( - _.omitBy(newParams, _.isNil) - ); - return `${route}?${newURLSearchParams.toString()}`; - }, [currentCluster]); - - const getAddOns = async () => { - try { - setStatus("loading"); - const res = await api.getCharts( + const { data: cloudProviderResponse } = useQuery( + ["cloudProviders", projectId], + async () => { + const response = await api.getAwsCloudProviders( "", + {}, { - limit: 50, - skip: 0, - byDate: false, - statusFilter: [ - "deployed", - "uninstalled", - "pending", - "pending-install", - "pending-upgrade", - "pending-rollback", - "failed", - ], - }, - { - id: currentProject?.id || -1, - cluster_id: currentCluster?.id || -1, - namespace: "ack-system", + project_id: projectId, } ); - setStatus("complete"); - const charts = res.data || []; - const filtered = charts.filter((app: any) => { - return ( - templateWhitelist.includes(app.chart.metadata.name) - ); - }); - setDatabases(filtered); - } catch (err) { - setStatus("error"); - }; - }; - useEffect(() => { - // Call once when the component mounts - void updateDatabaseStatuses(); + const results = await cloudProviderListResponseValidator.parseAsync( + response.data + ); + return results; + }, + { + enabled: !!projectId, + } + ); - // Set up the interval for polling every 5 minutes - const intervalId = setInterval(() => { - void updateDatabaseStatuses(); - }, 60000); // 60000 milliseconds = 5 minutes + const cloudProviders = cloudProviderResponse?.accounts; - // Clear interval on component unmount - return () => clearInterval(intervalId); - }, [filteredDatabases]); + const { data: datastores, isFetched: isLoaded } = useQuery( + [projectId], + async () => { + if (cloudProviders === undefined) { + return; + } - useEffect(() => { - // currentCluster sometimes returns as -1 and passes null check + const results = await Promise.all( + cloudProviders.map( + async ( + cloudProvider: CloudProviderWithSource + ): Promise => { + const response = await api.getDatastores( + "", + {}, + { + project_id: cloudProvider.project_id, + cloud_provider_name: "aws", + cloud_provider_id: cloudProvider.cloud_provider_id, + include_metadata: true, + } + ); + + const results = await datastoreListResponseValidator.parseAsync( + response.data + ); + return results.datastores.map( + (datastore): CloudProviderDatastore => { + return { + cloud_provider_name: "aws", + cloud_provider_id: cloudProvider.cloud_provider_id, + datastore, + project_id: cloudProvider.project_id, + }; + } + ); + } + ) + ); - if (currentProject?.id >= 0 && currentCluster?.id >= 0) { - getAddOns(); + if (results.length === 0) { + return; + } + + return results.flat(1); + }, + { + enabled: !!cloudProviders, + refetchInterval: 10000, + refetchOnWindowFocus: false, } - }, [currentCluster, currentProject]); + ); + + const filteredDatabases = useMemo(() => { + const filteredBySearch = search( + datastores === undefined ? [] : datastores, + searchValue, + { + keys: ["name"], + isCaseSensitive: false, + } + ); + + return _.sortBy(filteredBySearch, ["name"]); + }, [datastores, searchValue]); - const renderStatusIcon = (dbName: string): JSX.Element => { - const status: string = databaseStatuses[dbName]; + const renderStatusIcon = (status: string): JSX.Element => { switch (status) { case "available": return ; case "": return <>; case "error": - return - - - {"Creating database"} - - + return ( + + + + {"Creating database"} + + + ); case "updating": - return - - - {"Creating database"} - - + return ( + + + + {"Creating database"} + + + ); default: return <>; } }; - - const renderContents = () => { + const renderContents = (): JSX.Element => { if (currentCluster?.status === "UPDATING_UNAVAILABLE") { return ; } - if (status === "loading") { + if (datastores === undefined || !isLoaded) { return ; } - if (databases.length === 0) { + if (datastores.length === 0) { return ( No databases have been created yet @@ -225,7 +184,8 @@ const Apps: React.FC = ({ + + + ); +}; + +export default SettingsTab; + +const StyledTemplateComponent = styled.div` +width: 100%; +animation: fadeIn 0.3s 0s; +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +`; + +const InnerWrapper = styled.div<{ full?: boolean }>` + width: 100%; + height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")}; + padding: 30px; + padding-bottom: 15px; + position: relative; + overflow: auto; + margin-bottom: 30px; + border-radius: 5px; + background: ${(props) => props.theme.fg}; + border: 1px solid #494b4f; +`; diff --git a/dashboard/src/main/home/database-dashboard/types.ts b/dashboard/src/main/home/database-dashboard/types.ts new file mode 100644 index 0000000000..22828fa311 --- /dev/null +++ b/dashboard/src/main/home/database-dashboard/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const datastoreEnvValidator = z.object({ + name: z.string(), + linked_applications: z.string().array().default([]), + secret_variables: z.record(z.string()).default({}), + variables: z.record(z.string()).default({}), + version: z.number(), +}); + +export type DatastoreEnvWithSource = z.infer; + +export const datastoreMetadataValidator = z.object({ + name: z.string(), + value: z.string().default(""), +}); + +export type DatastoreMetadataWithSource = z.infer< + typeof datastoreMetadataValidator +>; + +export const datastoreValidator = z.object({ + name: z.string(), + type: z.string(), + status: z.string().default(""), + metadata: datastoreMetadataValidator.array().default([]), + env: datastoreEnvValidator.optional(), + connection_string: z.string().default(""), +}); + +export type DatastoreWithSource = z.infer; + +export const datastoreListResponseValidator = z.object({ + datastores: datastoreValidator.array(), +}); + +export const cloudProviderValidator = z.object({ + cloud_provider_id: z.string(), + project_id: z.number(), +}); + +export type CloudProviderWithSource = z.infer; + +export const cloudProviderListResponseValidator = z.object({ + accounts: cloudProviderValidator.array(), +}); + +export const cloudProviderDatastoreSchema = z.object({ + project_id: z.number(), + cloud_provider_name: z.string(), + cloud_provider_id: z.string(), + datastore: datastoreValidator, +}); + +export type CloudProviderDatastore = z.infer< + typeof cloudProviderDatastoreSchema +>; diff --git a/dashboard/src/main/home/database-dashboard/utils.tsx b/dashboard/src/main/home/database-dashboard/utils.tsx new file mode 100644 index 0000000000..715493c50a --- /dev/null +++ b/dashboard/src/main/home/database-dashboard/utils.tsx @@ -0,0 +1,23 @@ +import { type DatastoreWithSource } from "./types"; + +export const datastoreField = ( + datastore: DatastoreWithSource, + field: string +): string => { + if (datastore.metadata?.length === 0) { + return ""; + } + + const properties = datastore.metadata?.filter( + (metadata) => metadata.name === field + ); + if (properties === undefined || properties.length === 0) { + return ""; + } + + if (properties.length === 0) { + return ""; + } + + return properties[0].value; +}; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 3b47485e15..69b8885b0f 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -2674,6 +2674,18 @@ const provisionDatabase = baseApi< `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/provision/rds` ); +const getAwsCloudProviders = baseApi< + {}, + { + project_id: number; + } +>( + "GET", + ({ project_id }) => { + return `/api/projects/${project_id}/cloud-providers/aws`; + } +); + const getDatabases = baseApi< {}, { @@ -2685,6 +2697,59 @@ const getDatabases = baseApi< ({ project_id, cluster_id }) => `/api/projects/${project_id}/clusters/${cluster_id}/databases` ); + +const getDatastores = baseApi< + {}, + { + project_id: number; + cloud_provider_name: string; + cloud_provider_id: string; + datastore_name?: string; + datastore_type?: string; + include_env_group?: boolean; + include_metadata?: boolean; + } +>( + "GET", + ({ project_id, cloud_provider_name, cloud_provider_id, datastore_name, datastore_type, include_env_group, include_metadata }) => { + const queryParams = new URLSearchParams(); + + if (datastore_name) { + queryParams.set("name", datastore_name); + } + + if (datastore_type) { + queryParams.set("type", datastore_type); + } + + if (include_env_group) { + queryParams.set("include_env_group", "true"); + } + + if (include_metadata) { + queryParams.set("include_metadata", "true"); + } + + return `/api/projects/${project_id}/cloud-providers/${cloud_provider_name}/${cloud_provider_id}/datastores?${queryParams.toString()}`; + } +); + +const deleteDatastore = baseApi< + { + name: string; + type: string; + }, + { + project_id: number; + cloud_provider_name: string; + cloud_provider_id: string; + } +>( + "DELETE", + ({ project_id, cloud_provider_name, cloud_provider_id }) => + `/api/projects/${project_id}/cloud-providers/${cloud_provider_name}/${cloud_provider_id}/datastores` +); + const getPreviousLogsForContainer = baseApi< { container_name: string; @@ -3537,7 +3602,10 @@ export default { provisionDatabase, preflightCheck, requestQuotaIncrease, + getAwsCloudProviders, getDatabases, + getDatastores, + deleteDatastore, getPreviousLogsForContainer, upgradePorterAgent, deletePRDeployment,