From 6faaf53f455a2b3519aa0295e51098778892586c Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Fri, 26 Apr 2024 15:37:33 -0400 Subject: [PATCH 1/9] Correctly check for sandbox on apps page, fix relative date functions (#4584) --- dashboard/src/main/home/Home.tsx | 17 ++++++++--------- .../src/main/home/app-dashboard/apps/Apps.tsx | 4 ++-- .../home/app-dashboard/create-app/CreateApp.tsx | 13 ++++++------- .../main/home/project-settings/BillingPage.tsx | 7 +++++-- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index 5f7d97ab5e..d005f3bebb 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -24,7 +24,9 @@ import { fakeGuardedRoute } from "shared/auth/RouteGuard"; import { Context } from "shared/Context"; import DeploymentTargetProvider from "shared/DeploymentTargetContext"; import { pushFiltered, pushQueryParams, type PorterUrl } from "shared/routing"; -import { relativeDate, timeFrom } from "shared/string_utils"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + import midnight from "shared/themes/midnight"; import standard from "shared/themes/standard"; import { @@ -66,6 +68,8 @@ import Onboarding from "./onboarding/Onboarding"; import ProjectSettings from "./project-settings/ProjectSettings"; import Sidebar from "./sidebar/Sidebar"; +dayjs.extend(relativeTime); + // Guarded components const GuardedProjectSettings = fakeGuardedRoute("settings", "", [ "get", @@ -377,13 +381,8 @@ const Home: React.FC = (props) => { if (timestamp === "") { return true; } - - const diff = timeFrom(timestamp); - if (diff.when === "future") { - return false; - } - - return true; + const timestampDate = dayjs(timestamp); + return timestampDate.isBefore(dayjs(new Date())); }; const showCardBanner = !hasPaymentEnabled; @@ -424,7 +423,7 @@ const Home: React.FC = (props) => { connect a valid payment method . Your free trial is ending {" "} - {relativeDate(plan.trial_info.ending_before, true)}. + {dayjs().to(dayjs(plan.trial_info.ending_before))} )} {!trialExpired && showBillingModal && ( diff --git a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx index 3cca952dd0..4b1560db19 100644 --- a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx +++ b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx @@ -256,7 +256,7 @@ const Apps: React.FC = () => { Get started by creating an application. - {currentProject?.billing_enabled && !hasPaymentEnabled ? ( + {currentProject?.sandbox_enabled && currentProject?.billing_enabled && !hasPaymentEnabled ? ( )} - {showBillingModal && ( + {currentProject?.sandbox_enabled && showBillingModal && ( { setShowBillingModal(false); diff --git a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx index 489c9385ab..8c2885eb18 100644 --- a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx +++ b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx @@ -436,7 +436,7 @@ const CreateApp: React.FC = ({ history }) => { let stringifiedJson = "unable to stringify errors"; try { stringifiedJson = JSON.stringify(errors); - } catch (e) {} + } catch (e) { } void updateAppStep({ step: "stack-launch-failure", errorMessage: `Form validation error (visible to user): ${errorMessage}. Stringified JSON errors (invisible to user): ${stringifiedJson}`, @@ -545,8 +545,8 @@ const CreateApp: React.FC = ({ history }) => { 0 + porterAppFormMethods.getValues("app.name.value") + .length > 0 ? "#FFCC00" : "helper" } @@ -681,9 +681,8 @@ const CreateApp: React.FC = ({ history }) => { } > {detectedServices.count > 0 - ? `Detected ${detectedServices.count} service${ - detectedServices.count > 1 ? "s" : "" - } from porter.yaml.` + ? `Detected ${detectedServices.count} service${detectedServices.count > 1 ? "s" : "" + } from porter.yaml.` : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`} @@ -778,7 +777,7 @@ const CreateApp: React.FC = ({ history }) => { }} /> )} - {currentProject?.billing_enabled && !hasPaymentEnabled && ( + {currentProject?.sandbox_enabled && currentProject?.billing_enabled && !hasPaymentEnabled && ( { history.push("/apps"); diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 81b9fab5dd..666090a1f2 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -17,7 +17,8 @@ import { usePorterCredits, useSetDefaultPaymentMethod, } from "lib/hooks/useStripe"; -import { relativeDate } from "shared/string_utils"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; import { Context } from "shared/Context"; import cardIcon from "assets/credit-card.svg"; @@ -27,6 +28,8 @@ import trashIcon from "assets/trash.png"; import BillingModal from "../modals/BillingModal"; import Bars from "./Bars"; +dayjs.extend(relativeTime); + function BillingPage(): JSX.Element { const { setCurrentOverlay } = useContext(Context); const [shouldCreate, setShouldCreate] = useState(false); @@ -233,7 +236,7 @@ function BillingPage(): JSX.Element { plan.trial_info.ending_before !== "" ? ( Free trial ends{" "} - {relativeDate(plan.trial_info.ending_before, true)} + {dayjs().to(dayjs(plan.trial_info.ending_before))} ) : ( Started on {readableDate(plan.starting_on)} From 89c1b620f0eb99554e680ba9470334cb70fc645d Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Fri, 26 Apr 2024 18:19:22 -0400 Subject: [PATCH 2/9] Add porter standard trial days (#4574) --- api/types/billing_metronome.go | 7 +++++++ internal/billing/metronome.go | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index f53ecc6318..d321786b35 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -35,6 +35,13 @@ type AddCustomerPlanRequest struct { EndingBeforeUTC string `json:"ending_before,omitempty"` // NetPaymentTermDays is the number of days after issuance of invoice after which the invoice is due NetPaymentTermDays int `json:"net_payment_terms_days,omitempty"` + // Trial is the trial period for the plan + Trial TrialSpec `json:"trial_spec,omitempty"` +} + +// TrialSpec is the trial period for the plan +type TrialSpec struct { + LengthInDays int64 `json:"length_in_days"` } // EndCustomerPlanRequest represents a request to end the plan for a specific customer. diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index 67631d334c..d6ac4f649f 100644 --- a/internal/billing/metronome.go +++ b/internal/billing/metronome.go @@ -19,6 +19,7 @@ const ( metronomeBaseUrl = "https://api.metronome.com/v1/" defaultCollectionMethod = "charge_automatically" defaultMaxRetries = 10 + porterStandardTrialDays = 15 ) // MetronomeClient is the client used to call the Metronome API @@ -53,13 +54,17 @@ func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail s ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") defer span.End() + var trialDays uint planID := m.PorterStandardPlanID projID := strconv.FormatUint(uint64(projectID), 10) + if sandboxEnabled { planID = m.PorterCloudPlanID // This is necessary to avoid conflicts with Porter standard projects projID = fmt.Sprintf("porter-cloud-%s", projID) + } else { + trialDays = porterStandardTrialDays } customerID, err = m.createCustomer(ctx, userEmail, projectName, projID, billingID) @@ -67,7 +72,7 @@ func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail s return customerID, customerPlanID, telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID)) } - customerPlanID, err = m.addCustomerPlan(ctx, customerID, planID) + customerPlanID, err = m.addCustomerPlan(ctx, customerID, planID, trialDays) return customerID, customerPlanID, err } @@ -107,7 +112,7 @@ func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, p } // addCustomerPlan will start the customer on the given plan -func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID) (customerPlanID uuid.UUID, err error) { +func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID, trialDays uint) (customerPlanID uuid.UUID, err error) { ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") defer span.End() @@ -127,6 +132,12 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU StartingOnUTC: startOn, } + if trialDays != 0 { + req.Trial = types.TrialSpec{ + LengthInDays: int64(trialDays), + } + } + var result struct { Data struct { CustomerPlanID uuid.UUID `json:"id"` From 8d4913176a7fc89f9a632a43fbddad83aba647cb Mon Sep 17 00:00:00 2001 From: ianedwards Date: Sat, 27 Apr 2024 12:34:17 -0400 Subject: [PATCH 3/9] fix env group typo (#4585) --- dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx b/dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx index 977e832c2e..e53a62f943 100644 --- a/dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx +++ b/dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx @@ -180,14 +180,17 @@ const EnvVarsTab: React.FC = ({ envGroup, fetchEnvGroup }) => { } }); + const envGroupProvider = + envGroup.type === "doppler" ? "Doppler" : "Infisical"; + return ( <> Environment variables {envGroup.type === "doppler" || envGroup.type === "infisical" ? ( - {envGroup.type === "doppler" ? "Doppler" : "Infisical"} environment - variables can only be updated from the Doppler dashboard. + {envGroupProvider} environment variables can only be updated from the{" "} + {envGroupProvider} dashboard. ) : ( From 171bf19a9583b8de06cd688681cafe6d8d6f27f1 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Mon, 29 Apr 2024 12:09:20 -0400 Subject: [PATCH 4/9] add back engine versionselector for postgres (#4586) --- .../forms/DatastoreForm.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx b/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx index e3f01d21af..c13e60cbd9 100644 --- a/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx +++ b/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx @@ -66,6 +66,7 @@ const DatastoreForm: React.FC = () => { const watchWorkloadType = watch("workloadType", "unspecified"); const watchEngine = watch("engine", "UNKNOWN"); const watchInstanceClass = watch("config.instanceClass", "unspecified"); + const watchEngineVersion = watch("config.engineVersion", ""); const { updateDatastoreButtonProps } = useDatastoreFormContext(); @@ -291,6 +292,26 @@ const DatastoreForm: React.FC = () => { setCurrentStep(4); }} /> + {template === DATASTORE_TEMPLATE_AWS_RDS && ( + <> + + + activeValue={watchEngineVersion.toString()} + width="300px" + options={DATASTORE_TEMPLATE_AWS_RDS.supportedEngineVersions.map( + (v) => ({ + value: v.name, + label: v.displayName, + key: v.name, + }) + )} + setActiveValue={(value: string) => { + setValue("config.engineVersion", value); + }} + label={"Postgres version"} + /> + + )} )} , From 41362b6167483a87f70a9e5ee0da5b1a914ec364 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Mon, 29 Apr 2024 14:05:25 -0400 Subject: [PATCH 5/9] boilerplate addon dashboard code (#4526) --- api/server/handlers/addons/delete.go | 76 ++ api/server/handlers/addons/get.go | 104 +++ api/server/handlers/addons/list.go | 28 +- .../handlers/addons/tailscale_services.go | 107 +++ api/server/handlers/addons/update.go | 97 +++ api/server/router/addons.go | 31 - api/server/router/deployment_target.go | 146 ++++ api/types/request.go | 1 + dashboard/package-lock.json | 14 +- dashboard/package.json | 2 +- .../CreateDeploymentTargetModal.tsx | 2 +- dashboard/src/lib/addons/datadog.ts | 13 + dashboard/src/lib/addons/index.ts | 282 ++++++- dashboard/src/lib/addons/metabase.ts | 24 + dashboard/src/lib/addons/mezmo.ts | 7 + dashboard/src/lib/addons/newrelic.ts | 15 + dashboard/src/lib/addons/tailscale.ts | 11 + dashboard/src/lib/addons/template.ts | 210 +++++ dashboard/src/lib/hooks/useAddon.ts | 776 ++++++++++++++++++ dashboard/src/main/home/Home.tsx | 38 +- .../home/add-on-dashboard/AddOnDashboard.tsx | 267 +++--- .../add-on-dashboard/AddonContextProvider.tsx | 142 ++++ .../main/home/add-on-dashboard/AddonForm.tsx | 170 ++++ .../AddonFormContextProvider.tsx | 134 +++ .../home/add-on-dashboard/AddonHeader.tsx | 205 +++++ .../home/add-on-dashboard/AddonSaveButton.tsx | 34 + .../main/home/add-on-dashboard/AddonTabs.tsx | 120 +++ .../home/add-on-dashboard/AddonTemplates.tsx | 171 ++++ .../main/home/add-on-dashboard/AddonView.tsx | 70 ++ .../home/add-on-dashboard/NewAddOnFlow.tsx | 284 ------- .../add-on-dashboard/common/Configuration.tsx | 26 + .../home/add-on-dashboard/common/Logs.tsx | 243 ++++++ .../home/add-on-dashboard/common/Settings.tsx | 89 ++ .../add-on-dashboard/datadog/DatadogForm.tsx | 121 +++ .../legacy_AddOnDashboard.tsx | 398 +++++++++ .../add-on-dashboard/legacy_NewAddOnFlow.tsx | 302 +++++++ .../metabase/MetabaseForm.tsx | 354 ++++++++ .../home/add-on-dashboard/mezmo/MezmoForm.tsx | 43 + .../newrelic/NewRelicForm.tsx | 180 ++++ .../tailscale/TailscaleForm.tsx | 131 +++ .../tailscale/TailscaleOverview.tsx | 116 +++ .../src/main/home/app-dashboard/apps/Apps.tsx | 11 +- .../database-dashboard/DatabaseDashboard.tsx | 122 ++- dashboard/src/shared/api.tsx | 60 +- go.mod | 2 +- go.sum | 2 + internal/kubernetes/agent.go | 12 +- internal/telemetry/span.go | 4 + package-lock.json | 6 - 49 files changed, 5191 insertions(+), 612 deletions(-) create mode 100644 api/server/handlers/addons/delete.go create mode 100644 api/server/handlers/addons/get.go create mode 100644 api/server/handlers/addons/tailscale_services.go create mode 100644 api/server/handlers/addons/update.go create mode 100644 dashboard/src/lib/addons/datadog.ts create mode 100644 dashboard/src/lib/addons/metabase.ts create mode 100644 dashboard/src/lib/addons/mezmo.ts create mode 100644 dashboard/src/lib/addons/newrelic.ts create mode 100644 dashboard/src/lib/addons/tailscale.ts create mode 100644 dashboard/src/lib/addons/template.ts create mode 100644 dashboard/src/lib/hooks/useAddon.ts create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonForm.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonSaveButton.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/AddonView.tsx delete mode 100644 dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/common/Logs.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/common/Settings.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/legacy_AddOnDashboard.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/legacy_NewAddOnFlow.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleForm.tsx create mode 100644 dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx delete mode 100644 package-lock.json diff --git a/api/server/handlers/addons/delete.go b/api/server/handlers/addons/delete.go new file mode 100644 index 0000000000..bdc1d28b78 --- /dev/null +++ b/api/server/handlers/addons/delete.go @@ -0,0 +1,76 @@ +package addons + +import ( + "net/http" + + "connectrpc.com/connect" + "github.com/google/uuid" + porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1" + "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/server/shared/requestutils" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// DeleteAddonHandler handles requests to the /addons/delete endpoint +type DeleteAddonHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewDeleteAddonHandler returns a new DeleteAddonHandler +func NewDeleteAddonHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *DeleteAddonHandler { + return &DeleteAddonHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +func (c *DeleteAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-addon") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget) + + addonName, reqErr := requestutils.GetURLParamString(r, types.URLParamAddonName) + if reqErr != nil { + err := telemetry.Error(ctx, span, reqErr, "error parsing addon name") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier + if deploymentTarget.ID != uuid.Nil { + deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{ + Id: deploymentTarget.ID.String(), + } + } + + if addonName == "" { + err := telemetry.Error(ctx, span, nil, "no addon name provided") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + deleteAddonRequest := connect.NewRequest(&porterv1.DeleteAddonRequest{ + ProjectId: int64(project.ID), + DeploymentTargetIdentifier: deploymentTargetIdentifier, + AddonName: addonName, + }) + + _, err := c.Config().ClusterControlPlaneClient.DeleteAddon(ctx, deleteAddonRequest) + if err != nil { + err = telemetry.Error(ctx, span, err, "error deleting addon") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + c.WriteResult(w, r, "") +} diff --git a/api/server/handlers/addons/get.go b/api/server/handlers/addons/get.go new file mode 100644 index 0000000000..0a64690b8a --- /dev/null +++ b/api/server/handlers/addons/get.go @@ -0,0 +1,104 @@ +package addons + +import ( + "encoding/base64" + "net/http" + + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/porter-dev/api-contracts/generated/go/helpers" + porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1" + "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/server/shared/requestutils" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// AddonHandler handles requests to the /addons/{addon_name} endpoint +type AddonHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewAddonHandler returns a new AddonHandler +func NewAddonHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *AddonHandler { + return &AddonHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// AddonResponse represents the response from the /addons/{addon_name} endpoints +type AddonResponse struct { + Addon string `json:"addon"` +} + +func (c *AddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-get-addon") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget) + + addonName, reqErr := requestutils.GetURLParamString(r, types.URLParamAddonName) + if reqErr != nil { + err := telemetry.Error(ctx, span, reqErr, "error parsing addon name") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "addon-name", Value: addonName}) + + var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier + if deploymentTarget.ID != uuid.Nil { + deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{ + Id: deploymentTarget.ID.String(), + } + } + + if addonName == "" { + err := telemetry.Error(ctx, span, nil, "no addon name provided") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + addonRequest := connect.NewRequest(&porterv1.AddonRequest{ + ProjectId: int64(project.ID), + DeploymentTargetIdentifier: deploymentTargetIdentifier, + AddonName: addonName, + }) + + resp, err := c.Config().ClusterControlPlaneClient.Addon(ctx, addonRequest) + if err != nil { + err = telemetry.Error(ctx, span, err, "error getting addon") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + if resp == nil || resp.Msg == nil { + err = telemetry.Error(ctx, span, nil, "addon response is nil") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + by, err := helpers.MarshalContractObject(ctx, resp.Msg.Addon) + if err != nil { + err = telemetry.Error(ctx, span, err, "error marshaling addon") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + encoded := base64.StdEncoding.EncodeToString(by) + + res := &AddonResponse{ + Addon: encoded, + } + + c.WriteResult(w, r, res) +} diff --git a/api/server/handlers/addons/list.go b/api/server/handlers/addons/list.go index 37957fd463..c11d1736cb 100644 --- a/api/server/handlers/addons/list.go +++ b/api/server/handlers/addons/list.go @@ -5,6 +5,7 @@ import ( "net/http" "connectrpc.com/connect" + "github.com/google/uuid" "github.com/porter-dev/api-contracts/generated/go/helpers" porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1" "github.com/porter-dev/porter/api/server/handlers" @@ -16,7 +17,7 @@ import ( "github.com/porter-dev/porter/internal/telemetry" ) -// LatestAddonsHandler handles requests to the /addons/latest endpoint +// LatestAddonsHandler handles requests to the /addons endpoint type LatestAddonsHandler struct { handlers.PorterHandlerReadWriter } @@ -32,12 +33,7 @@ func NewLatestAddonsHandler( } } -// LatestAddonsRequest represents the request for the /addons/latest endpoint -type LatestAddonsRequest struct { - DeploymentTargetID string `schema:"deployment_target_id"` -} - -// LatestAddonsResponse represents the response from the /addons/latest endpoint +// LatestAddonsResponse represents the response from the /addons endpoint type LatestAddonsResponse struct { Base64Addons []string `json:"base64_addons"` } @@ -47,29 +43,17 @@ func (c *LatestAddonsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) defer span.End() project, _ := r.Context().Value(types.ProjectScope).(*models.Project) - cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster) - - request := &LatestAddonsRequest{} - if ok := c.DecodeAndValidate(w, r, request); !ok { - err := telemetry.Error(ctx, span, nil, "error decoding request") - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) - return - } - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID}, - ) + deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget) var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier - if request.DeploymentTargetID != "" { + if deploymentTarget.ID != uuid.Nil { deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{ - Id: request.DeploymentTargetID, + Id: deploymentTarget.ID.String(), } } latestAddonsReq := connect.NewRequest(&porterv1.LatestAddonsRequest{ ProjectId: int64(project.ID), - ClusterId: int64(cluster.ID), DeploymentTargetIdentifier: deploymentTargetIdentifier, }) diff --git a/api/server/handlers/addons/tailscale_services.go b/api/server/handlers/addons/tailscale_services.go new file mode 100644 index 0000000000..e1fbb68c02 --- /dev/null +++ b/api/server/handlers/addons/tailscale_services.go @@ -0,0 +1,107 @@ +package addons + +import ( + "fmt" + "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" +) + +// TailscaleServicesHandler handles requests to the /addons/tailscale-services endpoint +type TailscaleServicesHandler struct { + handlers.PorterHandlerReadWriter + authz.KubernetesAgentGetter +} + +// NewTailscaleServicesHandler returns a new TailscaleServicesHandler +func NewTailscaleServicesHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *TailscaleServicesHandler { + return &TailscaleServicesHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config), + } +} + +// TailscaleServicesResponse represents the response from the /addons/tailscale-services endpoints +type TailscaleServicesResponse struct { + Services []TailscaleService `json:"services"` +} + +// TailscaleService represents a Tailscale service +type TailscaleService struct { + Name string `json:"name"` + IP string `json:"ip"` + Port int `json:"port"` +} + +// ServeHTTP returns all services that can be accessed through Tailscale +// TODO: move this logic to CCP +func (c *TailscaleServicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-get-tailscale-services") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget) + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "namespace", Value: deploymentTarget.Namespace}, + telemetry.AttributeKV{Key: "cluster-id", Value: deploymentTarget.ClusterID}, + ) + + cluster, err := c.Repo().Cluster().ReadCluster(project.ID, deploymentTarget.ClusterID) + if err != nil { + err = telemetry.Error(ctx, span, err, "error reading cluster") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + agent, err := c.GetAgent(r, cluster, deploymentTarget.Namespace) + if err != nil { + err = telemetry.Error(ctx, span, err, "error getting agent") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + svcList, err := agent.ListServices(ctx, deploymentTarget.Namespace, "porter.run/tailscale-svc=true") + if err != nil { + err = telemetry.Error(ctx, span, err, "error listing services") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + var services []TailscaleService + for _, svc := range svcList.Items { + var port int + if len(svc.Spec.Ports) > 0 { + port = int(svc.Spec.Ports[0].Port) + } + service := TailscaleService{ + Name: svc.Name, + IP: svc.Spec.ClusterIP, + Port: port, + } + if appName, ok := svc.Labels["porter.run/app-name"]; ok { + if serviceName, ok := svc.Labels["porter.run/service-name"]; ok { + service.Name = fmt.Sprintf("%s (%s)", serviceName, appName) + } + } + + services = append(services, service) + } + + resp := TailscaleServicesResponse{ + Services: services, + } + + c.WriteResult(w, r, resp) +} diff --git a/api/server/handlers/addons/update.go b/api/server/handlers/addons/update.go new file mode 100644 index 0000000000..1de4f0f3e5 --- /dev/null +++ b/api/server/handlers/addons/update.go @@ -0,0 +1,97 @@ +package addons + +import ( + "encoding/base64" + "net/http" + + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/porter-dev/api-contracts/generated/go/helpers" + porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1" + "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" +) + +// UpdateAddonHandler handles requests to the /addons/update endpoint +type UpdateAddonHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewUpdateAddonHandler returns a new UpdateAddonHandler +func NewUpdateAddonHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *UpdateAddonHandler { + return &UpdateAddonHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// UpdateAddonRequest represents the request for the /addons/update endpoint +type UpdateAddonRequest struct { + B64Addon string `json:"b64_addon"` +} + +func (c *UpdateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-update-addon") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget) + + request := &UpdateAddonRequest{} + if ok := c.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "error decoding request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier + if deploymentTarget.ID != uuid.Nil { + deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{ + Id: deploymentTarget.ID.String(), + } + } + + if request.B64Addon == "" { + err := telemetry.Error(ctx, span, nil, "no addon provided") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + decoded, err := base64.StdEncoding.DecodeString(request.B64Addon) + if err != nil { + err := telemetry.Error(ctx, span, err, "error decoding yaml") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + addon := &porterv1.Addon{} + err = helpers.UnmarshalContractObject(decoded, addon) + if err != nil { + err := telemetry.Error(ctx, span, err, "error unmarshalling addon") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + updateAddonRequest := connect.NewRequest(&porterv1.UpdateAddonRequest{ + ProjectId: int64(project.ID), + DeploymentTargetIdentifier: deploymentTargetIdentifier, + Addon: addon, + }) + + _, err = c.Config().ClusterControlPlaneClient.UpdateAddon(ctx, updateAddonRequest) + if err != nil { + err = telemetry.Error(ctx, span, err, "error updating addon") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + c.WriteResult(w, r, "") +} diff --git a/api/server/router/addons.go b/api/server/router/addons.go index 36a8fd4575..07e0b55378 100644 --- a/api/server/router/addons.go +++ b/api/server/router/addons.go @@ -1,10 +1,7 @@ package router import ( - "fmt" - "github.com/go-chi/chi/v5" - "github.com/porter-dev/porter/api/server/handlers/addons" "github.com/porter-dev/porter/api/server/shared" "github.com/porter-dev/porter/api/server/shared/config" "github.com/porter-dev/porter/api/server/shared/router" @@ -57,34 +54,6 @@ func getAddonRoutes( var routes []*router.Route - // GET /api/projects/{project_id}/clusters/{cluster_id}/addons/latest -> addons.LatestAddonsHandler - latestAddonsEndpoint := factory.NewAPIEndpoint( - &types.APIRequestMetadata{ - Verb: types.APIVerbGet, - Method: types.HTTPVerbGet, - Path: &types.Path{ - Parent: basePath, - RelativePath: fmt.Sprintf("%s/latest", relPath), - }, - Scopes: []types.PermissionScope{ - types.UserScope, - types.ProjectScope, - types.ClusterScope, - }, - }, - ) - - latestAddonsHandler := addons.NewLatestAddonsHandler( - config, - factory.GetDecoderValidator(), - factory.GetResultWriter(), - ) - - routes = append(routes, &router.Route{ - Endpoint: latestAddonsEndpoint, - Handler: latestAddonsHandler, - Router: r, - }) return routes, newPath } diff --git a/api/server/router/deployment_target.go b/api/server/router/deployment_target.go index 36aee15e43..7b88cf87ce 100644 --- a/api/server/router/deployment_target.go +++ b/api/server/router/deployment_target.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/go-chi/chi/v5" + "github.com/porter-dev/porter/api/server/handlers/addons" "github.com/porter-dev/porter/api/server/handlers/deployment_target" "github.com/porter-dev/porter/api/server/handlers/porter_app" "github.com/porter-dev/porter/api/server/shared" @@ -145,6 +146,151 @@ func getDeploymentTargetRoutes( Router: r, }) + // GET /api/projects/{project_id}/targets/{deployment_target_identifier}/addons -> addons.LatestAddonsHandler + listAddonsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/addons", relPath), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.DeploymentTargetScope, + }, + }, + ) + + listAddonsHandler := addons.NewLatestAddonsHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: listAddonsEndpoint, + Handler: listAddonsHandler, + Router: r, + }) + + // GET /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/{addon_name} -> addons.AddonHandler + addonEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/addons/{%s}", relPath, types.URLParamAddonName), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.DeploymentTargetScope, + }, + }, + ) + + addonHandler := addons.NewAddonHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: addonEndpoint, + Handler: addonHandler, + Router: r, + }) + + // GET /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/tailscale-services -> addons.TailscaleServicesHandler + tailscaleServicesEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/addons/tailscale-services", relPath), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.DeploymentTargetScope, + }, + }, + ) + + tailscaleServicesHandler := addons.NewTailscaleServicesHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: tailscaleServicesEndpoint, + Handler: tailscaleServicesHandler, + Router: r, + }) + + // POST /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/update -> addons.UpdateAddonHandler + updateAddonEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbUpdate, + Method: types.HTTPVerbPost, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/addons/update", relPath), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.DeploymentTargetScope, + }, + }, + ) + + updateAddonHandler := addons.NewUpdateAddonHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: updateAddonEndpoint, + Handler: updateAddonHandler, + Router: r, + }) + + // DELETE /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/{addon_name} -> addons.DeleteAddonHandler + deleteAddonEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbDelete, + Method: types.HTTPVerbDelete, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/addons/{%s}", relPath, types.URLParamAddonName), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.DeploymentTargetScope, + }, + }, + ) + + deleteAddonHandler := addons.NewDeleteAddonHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: deleteAddonEndpoint, + Handler: deleteAddonHandler, + Router: r, + }) + // POST /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/app-event-webhooks -> porter_app.NewAppEventWebhooksHandler appEventWebhooks := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/api/types/request.go b/api/types/request.go index cf2c48b5b7..a27ffc48d4 100644 --- a/api/types/request.go +++ b/api/types/request.go @@ -61,6 +61,7 @@ const ( URLParamCloudProviderType URLParam = "cloud_provider_type" URLParamCloudProviderID URLParam = "cloud_provider_id" URLParamDeploymentTargetID URLParam = "deployment_target_id" + URLParamAddonName URLParam = "addon_name" // URLParamDeploymentTargetIdentifier can be either the deployment target id or deployment target name URLParamDeploymentTargetIdentifier URLParam = "deployment_target_identifier" URLParamWebhookID URLParam = "webhook_id" diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index ec6d436000..287b03bb8c 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -98,7 +98,7 @@ "@babel/preset-typescript": "^7.15.0", "@ianvs/prettier-plugin-sort-imports": "^4.1.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@porter-dev/api-contracts": "^0.2.142", + "@porter-dev/api-contracts": "^0.2.155", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", @@ -2757,9 +2757,9 @@ } }, "node_modules/@porter-dev/api-contracts": { - "version": "0.2.142", - "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.142.tgz", - "integrity": "sha512-WRIuZGQ8VXx6CIG4ODtfb+wlOSWCSJOm5uBXyn67eAwvNQsnj+RAzhSBqNUz+2XlIVGv2SET1BXV6uJhB2gQ8g==", + "version": "0.2.155", + "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.155.tgz", + "integrity": "sha512-Tar/IsKoUSmz8Q8Fw9ozflrAI+yAGzOIdx5WmZ5iCSCkvudSLnDp7xQ0po/traPzYLUldZjaNsw0KKXnOb1myQ==", "dev": true, "dependencies": { "@bufbuild/protobuf": "^1.1.0" @@ -20271,9 +20271,9 @@ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, "@porter-dev/api-contracts": { - "version": "0.2.142", - "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.142.tgz", - "integrity": "sha512-WRIuZGQ8VXx6CIG4ODtfb+wlOSWCSJOm5uBXyn67eAwvNQsnj+RAzhSBqNUz+2XlIVGv2SET1BXV6uJhB2gQ8g==", + "version": "0.2.155", + "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.155.tgz", + "integrity": "sha512-Tar/IsKoUSmz8Q8Fw9ozflrAI+yAGzOIdx5WmZ5iCSCkvudSLnDp7xQ0po/traPzYLUldZjaNsw0KKXnOb1myQ==", "dev": true, "requires": { "@bufbuild/protobuf": "^1.1.0" diff --git a/dashboard/package.json b/dashboard/package.json index 467d3350bf..a3ec259d94 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -105,7 +105,7 @@ "@babel/preset-typescript": "^7.15.0", "@ianvs/prettier-plugin-sort-imports": "^4.1.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@porter-dev/api-contracts": "^0.2.142", + "@porter-dev/api-contracts": "^0.2.155", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", diff --git a/dashboard/src/components/CreateDeploymentTargetModal.tsx b/dashboard/src/components/CreateDeploymentTargetModal.tsx index a10233816f..0e1f664a85 100644 --- a/dashboard/src/components/CreateDeploymentTargetModal.tsx +++ b/dashboard/src/components/CreateDeploymentTargetModal.tsx @@ -6,7 +6,7 @@ import { z } from "zod"; import target from "assets/target.svg"; import { useDeploymentTargetList } from "../lib/hooks/useDeploymentTarget"; -import { RestrictedNamespaces } from "../main/home/add-on-dashboard/AddOnDashboard"; +import { RestrictedNamespaces } from "../main/home/add-on-dashboard/legacy_AddOnDashboard"; import api from "../shared/api"; import { Context } from "../shared/Context"; import InputRow from "./form-components/InputRow"; diff --git a/dashboard/src/lib/addons/datadog.ts b/dashboard/src/lib/addons/datadog.ts new file mode 100644 index 0000000000..0709ba6f0f --- /dev/null +++ b/dashboard/src/lib/addons/datadog.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const datadogConfigValidator = z.object({ + type: z.literal("datadog"), + cpuCores: z.number().default(0.5), + ramMegabytes: z.number().default(512), + site: z.string().nonempty().default("datadoghq.com"), + apiKey: z.string().nonempty().default("*******"), + loggingEnabled: z.boolean().default(false), + apmEnabled: z.boolean().default(false), + dogstatsdEnabled: z.boolean().default(false), +}); +export type DatadogConfigValidator = z.infer; diff --git a/dashboard/src/lib/addons/index.ts b/dashboard/src/lib/addons/index.ts index 1e1a2ea3d9..9241e8c842 100644 --- a/dashboard/src/lib/addons/index.ts +++ b/dashboard/src/lib/addons/index.ts @@ -1,20 +1,43 @@ +import { DomainType } from "@porter-dev/api-contracts"; import { Addon, AddonType, + Datadog, + Metabase, + Mezmo, + Newrelic, + Postgres, + Redis, + Tailscale, } from "@porter-dev/api-contracts/src/porter/v1/addons_pb"; import { match } from "ts-pattern"; import { z } from "zod"; import { serviceStringValidator } from "lib/porter-apps/values"; +import { datadogConfigValidator } from "./datadog"; +import { metabaseConfigValidator } from "./metabase"; +import { mezmoConfigValidator } from "./mezmo"; +import { newrelicConfigValidator } from "./newrelic"; import { defaultPostgresAddon, postgresConfigValidator } from "./postgres"; import { redisConfigValidator } from "./redis"; +import { tailscaleConfigValidator } from "./tailscale"; +import { + ADDON_TEMPLATE_DATADOG, + ADDON_TEMPLATE_METABASE, + ADDON_TEMPLATE_MEZMO, + ADDON_TEMPLATE_NEWRELIC, + ADDON_TEMPLATE_POSTGRES, + ADDON_TEMPLATE_REDIS, + ADDON_TEMPLATE_TAILSCALE, + type AddonTemplate, +} from "./template"; export const clientAddonValidator = z.object({ expanded: z.boolean().default(false), canDelete: z.boolean().default(true), name: z.object({ - readOnly: z.boolean(), + readOnly: z.boolean().default(false), value: z .string() .min(1, { message: "Name must be at least 1 character" }) @@ -27,30 +50,106 @@ export const clientAddonValidator = z.object({ config: z.discriminatedUnion("type", [ postgresConfigValidator, redisConfigValidator, + datadogConfigValidator, + mezmoConfigValidator, + metabaseConfigValidator, + newrelicConfigValidator, + tailscaleConfigValidator, ]), }); -export type ClientAddon = z.infer; +export type ClientAddon = z.infer & { + template: AddonTemplate; +}; +export const legacyAddonValidator = z.object({ + name: z.string(), + namespace: z.string(), + info: z.object({ + last_deployed: z.string(), + }), + chart: z.object({ + metadata: z + .object({ + name: z.string().optional(), + icon: z.string().optional(), + }) + .optional(), + }), +}); +export type LegacyClientAddon = z.infer; export function defaultClientAddon( type: ClientAddon["config"]["type"] ): ClientAddon { return match(type) - .with("postgres", () => - clientAddonValidator.parse({ + .returnType() + .with("postgres", () => ({ + ...clientAddonValidator.parse({ expanded: true, name: { readOnly: false, value: "postgres" }, config: defaultPostgresAddon(), - }) - ) - .with("redis", () => - clientAddonValidator.parse({ + }), + template: ADDON_TEMPLATE_POSTGRES, + })) + .with("redis", () => ({ + ...clientAddonValidator.parse({ expanded: true, name: { readOnly: false, value: "redis" }, config: redisConfigValidator.parse({ type: "redis", }), - }) - ) + }), + template: ADDON_TEMPLATE_REDIS, + })) + .with("datadog", () => ({ + ...clientAddonValidator.parse({ + expanded: true, + name: { readOnly: false, value: "datadog" }, + config: datadogConfigValidator.parse({ + type: "datadog", + }), + }), + template: ADDON_TEMPLATE_DATADOG, + })) + .with("mezmo", () => ({ + ...clientAddonValidator.parse({ + expanded: true, + name: { readOnly: false, value: "mezmo" }, + config: mezmoConfigValidator.parse({ + type: "mezmo", + }), + }), + template: ADDON_TEMPLATE_MEZMO, + })) + .with("metabase", () => ({ + ...clientAddonValidator.parse({ + expanded: true, + name: { readOnly: false, value: "metabase" }, + config: metabaseConfigValidator.parse({ + type: "metabase", + }), + }), + template: ADDON_TEMPLATE_METABASE, + })) + .with("newrelic", () => ({ + ...clientAddonValidator.parse({ + expanded: true, + name: { readOnly: false, value: "newrelic" }, + config: newrelicConfigValidator.parse({ + type: "newrelic", + }), + }), + template: ADDON_TEMPLATE_NEWRELIC, + })) + .with("tailscale", () => ({ + ...clientAddonValidator.parse({ + expanded: true, + name: { readOnly: false, value: "tailscale" }, + config: tailscaleConfigValidator.parse({ + type: "tailscale", + }), + }), + template: ADDON_TEMPLATE_TAILSCALE, + })) .exhaustive(); } @@ -58,27 +157,98 @@ function addonTypeEnumProto(type: ClientAddon["config"]["type"]): AddonType { return match(type) .with("postgres", () => AddonType.POSTGRES) .with("redis", () => AddonType.REDIS) + .with("datadog", () => AddonType.DATADOG) + .with("mezmo", () => AddonType.MEZMO) + .with("metabase", () => AddonType.METABASE) + .with("newrelic", () => AddonType.NEWRELIC) + .with("tailscale", () => AddonType.TAILSCALE) .exhaustive(); } export function clientAddonToProto(addon: ClientAddon): Addon { const config = match(addon.config) + .returnType() .with({ type: "postgres" }, (data) => ({ - value: { + value: new Postgres({ cpuCores: data.cpuCores.value, ramMegabytes: data.ramMegabytes.value, storageGigabytes: data.storageGigabytes.value, - }, + }), case: "postgres" as const, })) .with({ type: "redis" }, (data) => ({ - value: { + value: new Redis({ cpuCores: data.cpuCores.value, ramMegabytes: data.ramMegabytes.value, storageGigabytes: data.storageGigabytes.value, - }, + }), case: "redis" as const, })) + .with({ type: "datadog" }, (data) => ({ + value: new Datadog({ + cpuCores: data.cpuCores, + ramMegabytes: data.ramMegabytes, + site: data.site, + apiKey: data.apiKey, + loggingEnabled: data.loggingEnabled, + apmEnabled: data.apmEnabled, + dogstatsdEnabled: data.dogstatsdEnabled, + }), + case: "datadog" as const, + })) + .with({ type: "mezmo" }, (data) => ({ + value: new Mezmo({ + ingestionKey: data.ingestionKey, + }), + case: "mezmo" as const, + })) + .with({ type: "metabase" }, (data) => ({ + value: new Metabase({ + ingressEnabled: data.exposedToExternalTraffic, + domains: [ + { + name: data.customDomain, + type: DomainType.UNSPECIFIED, + }, + { + name: data.porterDomain, + type: DomainType.PORTER, + }, + // if not exposed, remove all domains + ].filter((d) => d.name !== "" && data.exposedToExternalTraffic), + datastore: { + host: data.datastore.host, + port: BigInt(data.datastore.port), + databaseName: data.datastore.databaseName, + masterUsername: data.datastore.username, + masterUserPasswordLiteral: data.datastore.password, + }, + }), + case: "metabase" as const, + })) + .with({ type: "newrelic" }, (data) => ({ + value: new Newrelic({ + licenseKey: data.licenseKey, + insightsKey: data.insightsKey, + personalApiKey: data.personalApiKey, + accountId: data.accountId, + loggingEnabled: data.loggingEnabled, + kubeEventsEnabled: data.kubeEventsEnabled, + metricsAdapterEnabled: data.metricsAdapterEnabled, + prometheusEnabled: data.prometheusEnabled, + pixieEnabled: data.pixieEnabled, + }), + case: "newrelic" as const, + })) + .with({ type: "tailscale" }, (data) => ({ + value: new Tailscale({ + authKey: data.authKey, + subnetRoutes: data.subnetRoutes + .map((r) => r.route) + .filter((r) => r !== ""), + }), + case: "tailscale" as const, + })) .exhaustive(); const proto = new Addon({ @@ -107,6 +277,7 @@ export function clientAddonFromProto({ } const config = match(addon.config) + .returnType() .with({ case: "postgres" }, (data) => ({ type: "postgres" as const, cpuCores: { @@ -140,15 +311,84 @@ export function clientAddonFromProto({ }, password: secrets.REDIS_PASSWORD, })) + .with({ case: "datadog" }, (data) => ({ + type: "datadog" as const, + cpuCores: data.value.cpuCores ?? 0, + ramMegabytes: data.value.ramMegabytes ?? 0, + site: data.value.site ?? "", + apiKey: data.value.apiKey ?? "", + loggingEnabled: data.value.loggingEnabled ?? false, + apmEnabled: data.value.apmEnabled ?? false, + dogstatsdEnabled: data.value.dogstatsdEnabled ?? false, + })) + .with({ case: "mezmo" }, (data) => ({ + type: "mezmo" as const, + ingestionKey: data.value.ingestionKey ?? "", + })) + .with({ case: "metabase" }, (data) => ({ + type: "metabase" as const, + exposedToExternalTraffic: data.value.ingressEnabled ?? false, + porterDomain: + data.value.domains.find((domain) => domain.type === DomainType.PORTER) + ?.name ?? "", + customDomain: + data.value.domains.find( + (domain) => domain.type === DomainType.UNSPECIFIED + )?.name ?? "", + datastore: { + host: data.value.datastore?.host ?? "", + port: Number(data.value.datastore?.port) ?? 0, + databaseName: data.value.datastore?.databaseName ?? "", + username: data.value.datastore?.masterUsername ?? "", + password: data.value.datastore?.masterUserPasswordLiteral ?? "", + }, + })) + .with({ case: "newrelic" }, (data) => ({ + type: "newrelic" as const, + licenseKey: data.value.licenseKey ?? "", + insightsKey: data.value.insightsKey ?? "", + personalApiKey: data.value.personalApiKey ?? "", + accountId: data.value.accountId ?? "", + loggingEnabled: data.value.loggingEnabled ?? false, + kubeEventsEnabled: data.value.kubeEventsEnabled ?? false, + metricsAdapterEnabled: data.value.metricsAdapterEnabled ?? false, + prometheusEnabled: data.value.prometheusEnabled ?? false, + pixieEnabled: data.value.pixieEnabled ?? false, + })) + .with({ case: "tailscale" }, (data) => ({ + type: "tailscale" as const, + authKey: data.value.authKey ?? "", + subnetRoutes: data.value.subnetRoutes.map((r) => ({ route: r })), + })) .exhaustive(); - const clientAddon = clientAddonValidator.parse({ - name: { readOnly: false, value: addon.name }, - envGroups: addon.envGroups.map((envGroup) => ({ - value: envGroup.name, - })), - config, - }); + const template = match(addon.config) + .with({ case: "postgres" }, () => ADDON_TEMPLATE_POSTGRES) + .with({ case: "redis" }, () => ADDON_TEMPLATE_REDIS) + .with({ case: "datadog" }, () => ADDON_TEMPLATE_DATADOG) + .with({ case: "mezmo" }, () => ADDON_TEMPLATE_MEZMO) + .with({ case: "metabase" }, () => ADDON_TEMPLATE_METABASE) + .with({ case: "newrelic" }, () => ADDON_TEMPLATE_NEWRELIC) + .with({ case: "tailscale" }, () => ADDON_TEMPLATE_TAILSCALE) + .exhaustive(); + + const clientAddon = { + ...clientAddonValidator.parse({ + name: { readOnly: false, value: addon.name }, + envGroups: addon.envGroups.map((envGroup) => ({ + value: envGroup.name, + })), + config, + }), + template, + }; return clientAddon; } + +export const tailscaleServiceValidator = z.object({ + name: z.string(), + ip: z.string(), + port: z.number(), +}); +export type ClientTailscaleService = z.infer; diff --git a/dashboard/src/lib/addons/metabase.ts b/dashboard/src/lib/addons/metabase.ts new file mode 100644 index 0000000000..e38290afa2 --- /dev/null +++ b/dashboard/src/lib/addons/metabase.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const metabaseConfigValidator = z.object({ + type: z.literal("metabase"), + exposedToExternalTraffic: z.boolean().default(true), + porterDomain: z.string().default(""), + customDomain: z.string().default(""), + datastore: z + .object({ + host: z.string().nonempty(), + port: z.number(), + databaseName: z.string().nonempty(), + username: z.string().nonempty(), + password: z.string().nonempty(), + }) + .default({ + host: "", + port: 0, + databaseName: "", + username: "", + password: "", + }), +}); +export type MetabaseConfigValidator = z.infer; diff --git a/dashboard/src/lib/addons/mezmo.ts b/dashboard/src/lib/addons/mezmo.ts new file mode 100644 index 0000000000..615bd4dff3 --- /dev/null +++ b/dashboard/src/lib/addons/mezmo.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const mezmoConfigValidator = z.object({ + type: z.literal("mezmo"), + ingestionKey: z.string().nonempty().default("*******"), +}); +export type MezmoConfigValidator = z.infer; diff --git a/dashboard/src/lib/addons/newrelic.ts b/dashboard/src/lib/addons/newrelic.ts new file mode 100644 index 0000000000..2a0b2d1a08 --- /dev/null +++ b/dashboard/src/lib/addons/newrelic.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const newrelicConfigValidator = z.object({ + type: z.literal("newrelic"), + licenseKey: z.string().nonempty().default("*******"), + insightsKey: z.string().nonempty().default("*******"), + personalApiKey: z.string().nonempty().default("*******"), + accountId: z.string().nonempty().default(""), + loggingEnabled: z.boolean().default(false), + kubeEventsEnabled: z.boolean().default(false), + metricsAdapterEnabled: z.boolean().default(false), + prometheusEnabled: z.boolean().default(false), + pixieEnabled: z.boolean().default(false), +}); +export type NewrelicConfigValidator = z.infer; diff --git a/dashboard/src/lib/addons/tailscale.ts b/dashboard/src/lib/addons/tailscale.ts new file mode 100644 index 0000000000..b28a9cfcc6 --- /dev/null +++ b/dashboard/src/lib/addons/tailscale.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +const subnetRouteValidator = z.object({ + route: z.string(), +}); +export const tailscaleConfigValidator = z.object({ + type: z.literal("tailscale"), + authKey: z.string().nonempty().default("*******"), + subnetRoutes: z.array(subnetRouteValidator).default([]), +}); +export type TailscaleConfigValidator = z.infer; diff --git a/dashboard/src/lib/addons/template.ts b/dashboard/src/lib/addons/template.ts new file mode 100644 index 0000000000..51cdbfc47d --- /dev/null +++ b/dashboard/src/lib/addons/template.ts @@ -0,0 +1,210 @@ +import Logs from "main/home/add-on-dashboard/common/Logs"; +import Settings from "main/home/add-on-dashboard/common/Settings"; +import DatadogForm from "main/home/add-on-dashboard/datadog/DatadogForm"; +import MetabaseForm from "main/home/add-on-dashboard/metabase/MetabaseForm"; +import MezmoForm from "main/home/add-on-dashboard/mezmo/MezmoForm"; +import NewRelicForm from "main/home/add-on-dashboard/newrelic/NewRelicForm"; +import TailscaleForm from "main/home/add-on-dashboard/tailscale/TailscaleForm"; +import TailscaleOverview from "main/home/add-on-dashboard/tailscale/TailscaleOverview"; + +import { type ClientAddon } from "."; + +export type AddonTemplateTag = + | "Monitoring" + | "Logging" + | "Analytics" + | "Networking" + | "Database"; + +export const AddonTemplateTagColor: { + [key in AddonTemplateTag]: string; +} = { + Monitoring: "#774B9E", + Logging: "#F72585", + Analytics: "#1CCAD8", + Networking: "#FF680A", + Database: "#5FAD56", +}; + +export type AddonTab = { + name: string; + displayName: string; + component: React.FC; + isOnlyForPorterOperators?: boolean; +}; + +export const DEFAULT_ADDON_TAB = { + name: "configuration", + displayName: "Configuration", + component: () => null, +}; + +export type AddonTemplate = { + type: ClientAddon["config"]["type"]; + displayName: string; + description: string; + icon: string; + tags: AddonTemplateTag[]; + tabs: AddonTab[]; // this what is rendered on the dashboard after the addon is deployed +}; + +export const ADDON_TEMPLATE_REDIS: AddonTemplate = { + type: "redis", + displayName: "Redis", + description: "An in-memory database that persists on disk.", + icon: "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png", + tags: ["Database"], + tabs: [], +}; + +export const ADDON_TEMPLATE_POSTGRES: AddonTemplate = { + type: "postgres", + displayName: "Postgres", + description: "An object-relational database system.", + icon: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg", + tags: ["Database"], + tabs: [], +}; + +export const ADDON_TEMPLATE_DATADOG: AddonTemplate = { + type: "datadog", + displayName: "DataDog", + description: + "Pipe logs, metrics and APM data from your workloads to DataDog.", + icon: "https://datadog-live.imgix.net/img/dd_logo_70x75.png", + tags: ["Monitoring"], + tabs: [ + { + name: "configuration", + displayName: "Configuration", + component: DatadogForm, + }, + { + name: "logs", + displayName: "Logs", + component: Logs, + isOnlyForPorterOperators: true, + }, + { + name: "settings", + displayName: "Settings", + component: Settings, + }, + ], +}; + +export const ADDON_TEMPLATE_MEZMO: AddonTemplate = { + type: "mezmo", + displayName: "Mezmo", + description: "A popular logging management system.", + icon: "https://media.licdn.com/dms/image/D560BAQEDU9GQqUZHsQ/company-logo_200_200/0/1664831631499/mezmo_logo?e=2147483647&v=beta&t=h-mCuJh3FSVhXKvvGcfFrL6w9LPaCexypRcw2QWboEs", + tags: ["Logging"], + tabs: [ + { + name: "configuration", + displayName: "Configuration", + component: MezmoForm, + }, + { + name: "logs", + displayName: "Logs", + component: Logs, + isOnlyForPorterOperators: true, + }, + { + name: "settings", + displayName: "Settings", + component: Settings, + }, + ], +}; + +export const ADDON_TEMPLATE_METABASE: AddonTemplate = { + type: "metabase", + displayName: "Metabase", + description: "An open-source business intelligence tool.", + icon: "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg", + tags: ["Analytics"], + tabs: [ + { + name: "configuration", + displayName: "Configuration", + component: MetabaseForm, + }, + { + name: "logs", + displayName: "Logs", + component: Logs, + }, + { + name: "settings", + displayName: "Settings", + component: Settings, + }, + ], +}; + +export const ADDON_TEMPLATE_NEWRELIC: AddonTemplate = { + type: "newrelic", + displayName: "New Relic", + description: "Monitor your applications and infrastructure.", + icon: "https://companieslogo.com/img/orig/NEWR-de5fcb2e.png?t=1681801483", + tags: ["Monitoring"], + tabs: [ + { + name: "configuration", + displayName: "Configuration", + component: NewRelicForm, + }, + { + name: "logs", + displayName: "Logs", + component: Logs, + isOnlyForPorterOperators: true, + }, + { + name: "settings", + displayName: "Settings", + component: Settings, + }, + ], +}; + +export const ADDON_TEMPLATE_TAILSCALE: AddonTemplate = { + type: "tailscale", + displayName: "Tailscale", + description: "A VPN for your applications and datastores.", + icon: "https://play-lh.googleusercontent.com/wczDL05-AOb39FcL58L32h6j_TrzzGTXDLlOrOmJ-aNsnoGsT1Gkk2vU4qyTb7tGxRw=w240-h480-rw", + tags: ["Networking"], + tabs: [ + { + name: "overview", + displayName: "Overview", + component: TailscaleOverview, + }, + { + name: "configuration", + displayName: "Configuration", + component: TailscaleForm, + }, + { + name: "logs", + displayName: "Logs", + component: Logs, + isOnlyForPorterOperators: true, + }, + { + name: "settings", + displayName: "Settings", + component: Settings, + }, + ], +}; + +export const SUPPORTED_ADDON_TEMPLATES: AddonTemplate[] = [ + ADDON_TEMPLATE_DATADOG, + ADDON_TEMPLATE_MEZMO, + ADDON_TEMPLATE_METABASE, + ADDON_TEMPLATE_NEWRELIC, + ADDON_TEMPLATE_TAILSCALE, +]; diff --git a/dashboard/src/lib/hooks/useAddon.ts b/dashboard/src/lib/hooks/useAddon.ts new file mode 100644 index 0000000000..df2f52efa4 --- /dev/null +++ b/dashboard/src/lib/hooks/useAddon.ts @@ -0,0 +1,776 @@ +import { useEffect, useRef, useState } from "react"; +import { Addon, AddonWithEnvVars } from "@porter-dev/api-contracts"; +import { useQuery } from "@tanstack/react-query"; +import Anser, { type AnserJsonEntry } from "anser"; +import { match } from "ts-pattern"; +import { z } from "zod"; + +import { + clientAddonFromProto, + clientAddonToProto, + legacyAddonValidator, + type ClientAddon, + type LegacyClientAddon, +} from "lib/addons"; + +import api from "shared/api"; +import { + useWebsockets, + type NewWebsocketOptions, +} from "shared/hooks/useWebsockets"; +import { isJSON, valueExists } from "shared/util"; + +import { type DeploymentTarget } from "./useDeploymentTarget"; + +export const useAddonList = ({ + projectId, + deploymentTarget, +}: { + projectId?: number; + deploymentTarget?: DeploymentTarget; +}): { + addons: ClientAddon[]; + legacyAddons: LegacyClientAddon[]; + isLoading: boolean; + isLegacyAddonsLoading: boolean; + isError: boolean; +} => { + const { + data: addons = [], + isLoading, + isError, + } = useQuery( + ["listAddons", projectId, deploymentTarget], + async () => { + if (!projectId || projectId === -1 || !deploymentTarget) { + return; + } + + const res = await api.listAddons( + "", + {}, + { + projectId, + deploymentTargetId: deploymentTarget.id, + } + ); + + const parsed = await z + .object({ + base64_addons: z.array(z.string()), + }) + .parseAsync(res.data); + + const clientAddons: ClientAddon[] = parsed.base64_addons + .map((a) => { + const proto = AddonWithEnvVars.fromJsonString(atob(a), { + ignoreUnknownFields: true, + }); + if (!proto.addon) { + return null; + } + return clientAddonFromProto({ + addon: proto.addon, + }); + }) + .filter(valueExists); + + return clientAddons; + }, + { + enabled: !!projectId && projectId !== -1 && !!deploymentTarget, + refetchOnWindowFocus: false, + refetchInterval: 5000, + } + ); + + const { data: legacyAddons = [], isLoading: isLegacyAddonsLoading } = + useQuery( + ["listLegacyAddons", projectId, deploymentTarget], + async () => { + if (!projectId || projectId === -1 || !deploymentTarget) { + return; + } + + const res = await api.getCharts( + "", + { + limit: 50, + skip: 0, + byDate: false, + statusFilter: [ + "deployed", + "uninstalled", + "pending", + "pending-install", + "pending-upgrade", + "pending-rollback", + "failed", + ], + }, + { + id: projectId, + cluster_id: deploymentTarget.cluster_id, + namespace: "all", + } + ); + + const parsed = await z.array(legacyAddonValidator).parseAsync(res.data); + + return parsed + .filter((a) => { + return ![ + "web", + "worker", + "job", + "umbrella", + "postgresql-managed", // managed in datastores tab + "redis-managed", // managed in datastores tab + ].includes(a.chart?.metadata?.name ?? ""); + }) + .filter((a) => { + return ![ + "ack-system", + "cert-manager", + "ingress-nginx", + "kube-node-lease", + "kube-public", + "kube-system", + "monitoring", + "porter-agent-system", + "external-secrets", + ].includes(a.namespace ?? ""); + }); + }, + { + enabled: !!projectId && projectId !== -1 && !!deploymentTarget, + refetchOnWindowFocus: false, + refetchInterval: 5000, + } + ); + + return { + addons, + legacyAddons, + isLoading, + isLegacyAddonsLoading, + isError, + }; +}; + +export const useAddon = (): { + updateAddon: ({ + projectId, + deploymentTargetId, + addon, + }: { + projectId: number; + deploymentTargetId: string; + addon: ClientAddon; + }) => Promise; + deleteAddon: ({ + projectId, + deploymentTargetId, + addon, + }: { + projectId: number; + deploymentTargetId: string; + addon: ClientAddon; + }) => Promise; + getAddon: ({ + projectId, + deploymentTargetId, + addonName, + refreshIntervalSeconds, + }: { + projectId?: number; + deploymentTargetId: string; + addonName?: string; + refreshIntervalSeconds?: number; + }) => { + addon: ClientAddon | undefined; + isLoading: boolean; + isError: boolean; + }; +} => { + const updateAddon = async ({ + projectId, + deploymentTargetId, + addon, + }: { + projectId: number; + deploymentTargetId: string; + addon: ClientAddon; + }): Promise => { + const proto = clientAddonToProto(addon); + + await api.updateAddon( + "", + { + b64_addon: btoa(proto.toJsonString({ emitDefaultValues: true })), + }, + { + projectId, + deploymentTargetId, + } + ); + }; + + const deleteAddon = async ({ + projectId, + deploymentTargetId, + addon, + }: { + projectId: number; + deploymentTargetId: string; + addon: ClientAddon; + }): Promise => { + await api.deleteAddon( + "", + {}, + { + projectId, + deploymentTargetId, + addonName: addon.name.value, + } + ); + }; + + const getAddon = ({ + projectId, + deploymentTargetId, + addonName, + refreshIntervalSeconds = 0, + }: { + projectId?: number; + deploymentTargetId: string; + addonName?: string; + refreshIntervalSeconds?: number; + }): { + addon: ClientAddon | undefined; + isLoading: boolean; + isError: boolean; + } => { + const { data, isLoading, isError } = useQuery( + ["getAddon", projectId, deploymentTargetId, addonName], + async () => { + if (!projectId || projectId === -1 || !addonName) { + return undefined; + } + + const res = await api.getAddon( + "", + {}, + { + projectId, + deploymentTargetId, + addonName, + } + ); + + const parsed = await z + .object({ + addon: z.string(), + }) + .parseAsync(res.data); + + const proto = Addon.fromJsonString(atob(parsed.addon), { + ignoreUnknownFields: true, + }); + + if (!proto) { + return undefined; + } + + return clientAddonFromProto({ + addon: proto, + }); + }, + { + enabled: !!projectId && projectId !== -1 && !!addonName, + retryDelay: 5000, + refetchInterval: refreshIntervalSeconds * 1000, + } + ); + + return { + addon: data, + isLoading, + isError, + }; + }; + + return { + updateAddon, + deleteAddon, + getAddon, + }; +}; + +const addonControllersValidator = z.array( + z.object({ + metadata: z.object({ + uid: z.string(), + name: z.string(), + }), + spec: z.object({ + selector: z.object({ + matchLabels: z.record(z.string()), + }), + }), + }) +); +const addonPodValidator = z.object({ + metadata: z.object({ + name: z.string(), + }), + status: z.object({ + phase: z + .string() + .pipe( + z.enum(["UNKNOWN", "Running", "Pending", "Failed"]).catch("UNKNOWN") + ), + }), +}); +export type ClientAddonPod = { + name: string; + status: "running" | "pending" | "failed"; +}; +export type ClientAddonStatus = { + pods: ClientAddonPod[]; + isLoading: boolean; +}; +export const useAddonStatus = ({ + projectId, + deploymentTarget, + addon, +}: { + projectId?: number; + deploymentTarget: DeploymentTarget; + addon?: ClientAddon; +}): ClientAddonStatus => { + const [isInitializingStatus, setIsInitializingStatus] = + useState(false); + const [controllerPodMap, setControllerPodMap] = useState< + Record + >({}); + + const { newWebsocket, openWebsocket, closeAllWebsockets, closeWebsocket } = + useWebsockets(); + + const controllersResp = useQuery( + ["listControllers", projectId, addon], + async () => { + if (!projectId || projectId === -1 || !addon) { + return; + } + + const resp = await api.getChartControllers( + "", + {}, + { + name: addon.name.value, + namespace: deploymentTarget.namespace, + cluster_id: deploymentTarget.cluster_id, + revision: 0, + id: projectId, + } + ); + const parsed = await addonControllersValidator.parseAsync(resp.data); + + return parsed; + }, + { + enabled: !!projectId && projectId !== -1 && !!addon, + retryDelay: 5000, + } + ); + + useEffect(() => { + setIsInitializingStatus(true); + if (!controllersResp.isSuccess || !controllersResp.data) { + return; + } + + const setupPodWebsocketWithSelectors = ( + controllerUid: string, + selectors: string + ): void => { + if (!projectId || projectId === -1 || !deploymentTarget) { + return; + } + const websocketKey = `${Math.random().toString(36).substring(2, 15)}`; + const apiEndpoint = `/api/projects/${projectId}/clusters/${deploymentTarget.cluster_id}/pod/status?selectors=${selectors}`; + + const options: NewWebsocketOptions = { + onopen: () => { + // console.log("connected to websocket for selectors: ", selectors); + }, + onmessage: (evt: MessageEvent) => { + const event = JSON.parse(evt.data); + const object = event.Object; + object.metadata.kind = event.Kind; + + void updatePodsForController(controllerUid, selectors); + }, + onclose: () => { + // console.log("closing websocket"); + }, + onerror: () => { + // console.log(err); + closeWebsocket(websocketKey); + }, + }; + + newWebsocket(websocketKey, apiEndpoint, options); + openWebsocket(websocketKey); + }; + + const controllers = controllersResp.data; + + const initializeControllers = async (): Promise => { + try { + // this initializes the controllerPodMap on mount + const controllerPodMap: Record = {}; + for (const controller of controllers) { + const selectors = Object.keys( + controller.spec.selector.matchLabels + ).map((key) => `${key}=${controller.spec.selector.matchLabels[key]}`); + const pods = await getPodsForSelectors(selectors.join(",")); + controllerPodMap[controller.metadata.uid] = pods; + } + setControllerPodMap(controllerPodMap); + + // this sets up websockets for each controller, for pod updates + for (const controller of controllers) { + const selectors = Object.keys( + controller.spec.selector.matchLabels + ).map((key) => `${key}=${controller.spec.selector.matchLabels[key]}`); + setupPodWebsocketWithSelectors( + controller.metadata.uid, + selectors.join(",") + ); + } + } catch (err) { + // TODO: handle error + } finally { + setIsInitializingStatus(false); + } + }; + + void initializeControllers(); + }, [controllersResp.data]); + + const getPodsForSelectors = async ( + selectors: string + ): Promise => { + if (!projectId || projectId === -1 || !deploymentTarget) { + return []; + } + try { + const res = await api.getMatchingPods( + "", + { + namespace: deploymentTarget.namespace, + selectors: [selectors], + }, + { + id: projectId, + cluster_id: deploymentTarget.cluster_id, + } + ); + const parsed = z.array(addonPodValidator).safeParse(res.data); + if (!parsed.success) { + // console.log(parsed.error); + return []; + } + const clientPods: ClientAddonPod[] = parsed.data + .map((pod) => { + if (pod.status.phase === "UNKNOWN") { + return undefined; + } + + return { + name: pod.metadata.name, + status: match(pod.status.phase) + .with("Running", () => "running" as const) + .with("Pending", () => "pending" as const) + .with("Failed", () => "failed" as const) + .exhaustive(), + }; + }) + .filter(valueExists); + + return clientPods; + } catch (err) { + return []; + } + }; + + const updatePodsForController = async ( + controllerUid: string, + selectors: string + ): Promise => { + const pods = await getPodsForSelectors(selectors); + + setControllerPodMap((prev) => { + return { + ...prev, + [controllerUid]: pods, + }; + }); + }; + + useEffect(() => { + return () => { + closeAllWebsockets(); + }; + }, []); + + return { + pods: Object.keys(controllerPodMap) + .map((c) => controllerPodMap[c]) + .flat(), + isLoading: isInitializingStatus, + }; +}; + +export type Log = { + line: AnserJsonEntry[]; + lineNumber: number; + timestamp?: string; + controllerName: string; + podName: string; +}; + +export const useAddonLogs = ({ + projectId, + deploymentTarget, + addon, +}: { + projectId?: number; + deploymentTarget: DeploymentTarget; + addon?: ClientAddon; +}): { logs: Log[]; refresh: () => void; isInitializing: boolean } => { + const [logs, setLogs] = useState([]); + const logsBufferRef = useRef([]); + const { newWebsocket, openWebsocket, closeAllWebsockets } = useWebsockets(); + const [isInitializing, setIsInitializing] = useState(true); + + const fetchControllers = async (): Promise< + z.infer + > => { + if (!projectId || projectId === -1 || !addon) { + throw new Error("Invalid parameters"); + } + + const resp = await api.getChartControllers( + "", + {}, + { + name: addon.name.value, + namespace: deploymentTarget.namespace, + cluster_id: deploymentTarget.cluster_id, + revision: 0, + id: projectId, + } + ); + const parsed = await addonControllersValidator.parseAsync(resp.data); + + return parsed; + }; + + const controllersQuery = useQuery( + ["listControllers", projectId, addon], + fetchControllers, + { + retryDelay: 5000, + enabled: !!projectId && projectId !== -1 && !!addon, + } + ); + + useEffect(() => { + const fetchPodsAndSetUpWebsockets = async ( + controllers: z.infer + ): Promise => { + closeAllWebsockets(); + for (const controller of controllers) { + const selectors = Object.keys(controller.spec.selector.matchLabels) + .map((key) => `${key}=${controller.spec.selector.matchLabels[key]}`) + .join(","); + + const pods = await fetchPodsForSelectors(selectors); + + for (const pod of pods) { + setupWebsocket(pod.metadata.name, controller.metadata.name); + } + } + setIsInitializing(false); + }; + if (controllersQuery.isSuccess && controllersQuery.data) { + void fetchPodsAndSetUpWebsockets(controllersQuery.data); + } + }, [controllersQuery.data, controllersQuery.isSuccess]); + + const fetchPodsForSelectors = async ( + selectors: string + ): Promise>> => { + if (!projectId || projectId === -1 || !deploymentTarget) { + return []; + } + try { + const res = await api.getMatchingPods( + "", + { + namespace: deploymentTarget.namespace, + selectors: [selectors], + }, + { + id: projectId, + cluster_id: deploymentTarget.cluster_id, + } + ); + const parsed = await z.array(addonPodValidator).parseAsync(res.data); + return parsed; + } catch (err) { + return []; + } + }; + + const parseLogs = ( + logs: string[] = [], + controllerName: string, + podName: string + ): Log[] => { + return logs.filter(Boolean).map((logLine: string, idx) => { + try { + if (!isJSON(logLine)) { + return { + line: Anser.ansiToJson(logLine), + lineNumber: idx + 1, + timestamp: undefined, + controllerName, + podName, + }; + } + + const parsedLine = JSON.parse(logLine); + const ansiLog = Anser.ansiToJson(parsedLine.line); + return { + line: ansiLog, + lineNumber: idx + 1, + timestamp: parsedLine.timestamp, + controllerName, + podName, + }; + } catch (err) { + // console.error(err, logLine); + return { + line: Anser.ansiToJson(logLine), + lineNumber: idx + 1, + timestamp: undefined, + controllerName, + podName, + }; + } + }); + }; + + const updateLogs = (newLogs: Log[]): void => { + if (!newLogs.length) { + return; + } + + setLogs((logs) => { + const updatedLogs = [...logs, ...newLogs]; + updatedLogs.sort((a, b) => { + if (a.timestamp && b.timestamp) { + return ( + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + } + return a.lineNumber - b.lineNumber; + }); + + return updatedLogs; + }); + }; + + const flushLogsBuffer = (): void => { + updateLogs(logsBufferRef.current ?? []); + logsBufferRef.current = []; + }; + + const pushLogs = (newLogs: Log[]): void => { + logsBufferRef.current.push(...newLogs); + }; + + const setupWebsocket = (podName: string, controllerName: string): void => { + if (!projectId || projectId === -1 || !deploymentTarget || !addon) { + return; + } + + const websocketKey = `${Math.random().toString(36).substring(2, 15)}`; + const params = new URLSearchParams({ + pod_selector: podName, + namespace: deploymentTarget.namespace, + }); + + const apiEndpoint = `/api/projects/${projectId}/clusters/${ + deploymentTarget.cluster_id + }/namespaces/${deploymentTarget.namespace}/logs/loki?${params.toString()}`; + + const config: NewWebsocketOptions = { + onopen: () => { + // console.log("Opened websocket:", websocketKey); + }, + onmessage: (evt: MessageEvent) => { + if (!evt?.data || typeof evt.data !== "string") { + return; + } + const newLogs = parseLogs( + evt.data.trim().split("\n"), + controllerName, + podName + ); + pushLogs(newLogs); + }, + onclose: () => { + // console.log("Closed websocket:", websocketKey); + }, + }; + + newWebsocket(websocketKey, apiEndpoint, config); + openWebsocket(websocketKey); + }; + + const refresh = async (): Promise => { + if (!projectId || projectId === -1 || !addon || !deploymentTarget) { + return; + } + setLogs([]); + flushLogsBuffer(); + setIsInitializing(true); + await controllersQuery.refetch(); + }; + + useEffect(() => { + setTimeout(flushLogsBuffer, 500); + const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000); + return () => { + clearInterval(flushLogsBufferInterval); + }; + }, []); + + useEffect(() => { + return () => { + closeAllWebsockets(); + }; + }, []); + + return { + logs, + refresh, + isInitializing, + }; +}; diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index d005f3bebb..ecc5695cff 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -1,4 +1,6 @@ import React, { useContext, useEffect, useRef, useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; import { createPortal } from "react-dom"; import { Route, @@ -24,9 +26,6 @@ import { fakeGuardedRoute } from "shared/auth/RouteGuard"; import { Context } from "shared/Context"; import DeploymentTargetProvider from "shared/DeploymentTargetContext"; import { pushFiltered, pushQueryParams, type PorterUrl } from "shared/routing"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; - import midnight from "shared/themes/midnight"; import standard from "shared/themes/standard"; import { @@ -35,8 +34,11 @@ import { type ProjectType, } from "shared/types"; -import AddOnDashboard from "./add-on-dashboard/AddOnDashboard"; -import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow"; +import AddonDashboard from "./add-on-dashboard/AddOnDashboard"; +import AddonTemplates from "./add-on-dashboard/AddonTemplates"; +import AddonView from "./add-on-dashboard/AddonView"; +import LegacyAddOnDashboard from "./add-on-dashboard/legacy_AddOnDashboard"; +import LegacyNewAddOnFlow from "./add-on-dashboard/legacy_NewAddOnFlow"; import AppView from "./app-dashboard/app-view/AppView"; import AppDashboard from "./app-dashboard/AppDashboard"; import Apps from "./app-dashboard/apps/Apps"; @@ -219,7 +221,7 @@ const Home: React.FC = (props) => { } else { setHasFinishedOnboarding(true); } - } catch (error) { } + } catch (error) {} }; useEffect(() => { @@ -422,7 +424,7 @@ const Home: React.FC = (props) => { > connect a valid payment method - . Your free trial is ending {" "} + . Your free trial is ending{" "} {dayjs().to(dayjs(plan.trial_info.ending_before))} )} @@ -547,10 +549,28 @@ const Home: React.FC = (props) => { - + {currentProject?.capi_provisioner_enabled && + currentProject?.simplified_view_enabled && + currentProject?.beta_features_enabled ? ( + + ) : ( + + )} + + + + + + - + {currentProject?.capi_provisioner_enabled && + currentProject?.simplified_view_enabled && + currentProject?.beta_features_enabled ? ( + + ) : ( + + )} { + return addon.config.type !== "postgres" && addon.config.type !== "redis"; +}; -const AddOnDashboard: React.FC = ({}) => { - const { defaultDeploymentTarget } = useDefaultDeploymentTarget(); +const AddonDashboard: React.FC = () => { const { currentProject, currentCluster } = useContext(Context); - const [addOns, setAddOns] = useState([]); + const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } = + useDefaultDeploymentTarget(); + const [searchValue, setSearchValue] = useState(""); const [view, setView] = useState("grid"); - const [isLoading, setIsLoading] = useState(true); - const filteredAddOns = useMemo(() => { - const filtered = addOns.filter((app) => { - return ( - app.namespace === defaultDeploymentTarget.namespace && - !templateBlacklist.includes(app.chart.metadata.name) - ); - }); + const { + addons, + legacyAddons, + isLoading: isAddonListLoading, + isLegacyAddonsLoading, + } = useAddonList({ + projectId: currentProject?.id, + deploymentTarget: defaultDeploymentTarget, + }); - const filteredBySearch = search(filtered ?? [], searchValue, { - keys: ["name", "chart.metadata.name"], - isCaseSensitive: false, + const filteredAddons: Array = useMemo(() => { + const displayableAddons = addons.filter(isDisplayableAddon); + const legacyDisplayableAddons = legacyAddons.sort((a, b) => { + return a.info.last_deployed > b.info.last_deployed ? -1 : 1; }); - return _.sortBy(filteredBySearch); - }, [addOns, searchValue]); - - const getAddOns = async () => { - try { - setIsLoading(true); - const res = await api.getCharts( - "", - { - limit: 50, - skip: 0, - byDate: false, - statusFilter: [ - "deployed", - "uninstalled", - "pending", - "pending-install", - "pending-upgrade", - "pending-rollback", - "failed", - ], - }, - { - id: currentProject.id, - cluster_id: currentCluster.id, - namespace: "all", - } - ); - setIsLoading(false); - const charts = res.data || []; - setAddOns(charts); - } catch (err) { - setIsLoading(false); - } - }; - - useEffect(() => { - // currentCluster sometimes returns as -1 and passes null check - if (currentProject?.id >= 0 && currentCluster?.id >= 0) { - getAddOns(); - } - }, [currentCluster, currentProject]); + // If an addon name exists in both the legacy and new addon lists, show the new addon + const uniqueAddons: Array = [ + ...displayableAddons, + ...legacyDisplayableAddons.filter( + (a) => !displayableAddons.some((b) => b.name.value === a.name) + ), + ]; - 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-expect-error - project_id: params.project_id, - closeChartRedirectUrl: "/addons", - }; - const newURLSearchParams = new URLSearchParams( - _.omitBy(newParams, _.isNil) - ); - return `${route}?${newURLSearchParams.toString()}`; - }, - [currentCluster] - ); + return uniqueAddons; + }, [addons, legacyAddons, defaultDeploymentTarget]); return ( @@ -163,9 +95,10 @@ const AddOnDashboard: React.FC = ({}) => { - ) : addOns.length === 0 || - (filteredAddOns.length === 0 && searchValue === "") ? ( - isLoading ? ( + ) : filteredAddons.length === 0 || + (filteredAddons.length === 0 && searchValue === "") ? ( + isDefaultDeploymentTargetLoading || + (isAddonListLoading && isLegacyAddonsLoading) ? ( ) : ( @@ -212,7 +145,7 @@ const AddOnDashboard: React.FC = ({}) => { - {filteredAddOns.length === 0 ? ( + {filteredAddons.length === 0 ? (
@@ -223,57 +156,96 @@ const AddOnDashboard: React.FC = ({}) => {
- ) : isLoading ? ( + ) : isDefaultDeploymentTargetLoading || + (isAddonListLoading && isLegacyAddonsLoading) ? ( ) : view === "grid" ? ( - {(filteredAddOns ?? []).map((app: any, i: number) => { + {filteredAddons.map((addon: ClientAddon | LegacyClientAddon) => { + const isLegacyAddon = "chart" in addon; + if (isLegacyAddon) { + return ( + + + + {addon.name} + + + + + + + {readableDate(addon.info.last_deployed)} + + + + ); + } return ( - + - - {app.name} + + {addon.name.value} - - - - {readableDate(app.info.last_deployed)} - - ); })} ) : ( - {(filteredAddOns ?? []).map((app: any, i: number) => { + {filteredAddons.map((addon: ClientAddon | LegacyClientAddon) => { + const isLegacyAddon = "chart" in addon; + if (isLegacyAddon) { + return ( + + + + {addon.name} + + + + + + + + {readableDate(addon.info.last_deployed)} + + + + ); + } return ( - + - - {app.name} + + {addon.name.value} - - - - - {readableDate(app.info.last_deployed)} - - ); })} @@ -286,7 +258,7 @@ const AddOnDashboard: React.FC = ({}) => { ); }; -export default AddOnDashboard; +export default AddonDashboard; const PlaceholderIcon = styled.img` height: 13px; @@ -335,15 +307,8 @@ const MidIcon = styled.img<{ height?: string }>` margin-right: 11px; `; -const SmallIcon = styled.img<{ opacity?: string }>` - margin-left: 2px; - height: 14px; - opacity: ${(props) => props.opacity || 1}; - margin-right: 10px; -`; - const Block = styled(Link)` - height: 110px; + height: 75px; flex-direction: column; display: flex; justify-content: space-between; @@ -390,9 +355,9 @@ const StyledAppDashboard = styled.div` height: 100%; `; -const CentralContainer = styled.div` - display: flex; - flex-direction: column; - justify-content: left; - align-items: left; +const SmallIcon = styled.img<{ opacity?: string }>` + margin-left: 2px; + height: 14px; + opacity: ${(props) => props.opacity || 1}; + margin-right: 10px; `; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx b/dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx new file mode 100644 index 0000000000..b484f49f19 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx @@ -0,0 +1,142 @@ +import React, { createContext, useCallback, useContext } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import styled from "styled-components"; + +import Loading from "components/Loading"; +import Container from "components/porter/Container"; +import Link from "components/porter/Link"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { type ClientAddon } from "lib/addons"; +import { + useAddon, + useAddonStatus, + type ClientAddonStatus, +} from "lib/hooks/useAddon"; +import { + useDefaultDeploymentTarget, + type DeploymentTarget, +} from "lib/hooks/useDeploymentTarget"; + +import { Context } from "shared/Context"; +import notFound from "assets/not-found.png"; + +type AddonContextType = { + addon: ClientAddon; + projectId: number; + deploymentTarget: DeploymentTarget; + status: ClientAddonStatus; + deleteAddon: () => Promise; +}; + +const AddonContext = createContext(null); + +export const useAddonContext = (): AddonContextType => { + const ctx = React.useContext(AddonContext); + if (!ctx) { + throw new Error( + "useAddonContext must be used within a AddonContextProvider" + ); + } + return ctx; +}; + +type AddonContextProviderProps = { + addonName?: string; + children: JSX.Element; +}; + +export const AddonContextProvider: React.FC = ({ + addonName, + children, +}) => { + const { currentProject } = useContext(Context); + const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } = + useDefaultDeploymentTarget(); + const { getAddon, deleteAddon } = useAddon(); + const { + addon, + isLoading: isAddonLoading, + isError, + } = getAddon({ + projectId: currentProject?.id, + deploymentTargetId: defaultDeploymentTarget.id, + addonName, + refreshIntervalSeconds: 5, + }); + const queryClient = useQueryClient(); + + const status = useAddonStatus({ + projectId: currentProject?.id, + deploymentTarget: defaultDeploymentTarget, + addon, + }); + + const paramsExist = + !!addonName && + !!defaultDeploymentTarget && + !!currentProject && + currentProject.id !== -1; + + const deleteContextAddon = useCallback(async () => { + if (!paramsExist || !addon) { + return; + } + + await deleteAddon({ + projectId: currentProject.id, + deploymentTargetId: defaultDeploymentTarget.id, + addon, + }); + + await queryClient.invalidateQueries(["listAddons"]); + }, [paramsExist]); + + if (isDefaultDeploymentTargetLoading || isAddonLoading || !paramsExist) { + return ; + } + + if (isError || !addon) { + return ( + + + + + No addon matching "{addonName}" was found. + + + + Return to dashboard + + ); + } + + return ( + + {children} + + ); +}; + +const PlaceholderIcon = styled.img` + height: 13px; + margin-right: 12px; + opacity: 0.65; +`; +const Placeholder = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 13px; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx b/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx new file mode 100644 index 0000000000..b93737e0ec --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx @@ -0,0 +1,170 @@ +import React, { useContext, useEffect, useMemo } from "react"; +import { useFormContext } from "react-hook-form"; +import { useHistory } from "react-router"; +import styled from "styled-components"; + +import Loading from "components/Loading"; +import Back from "components/porter/Back"; +import { ControlledInput } from "components/porter/ControlledInput"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import VerticalSteps from "components/porter/VerticalSteps"; +import { defaultClientAddon, type ClientAddon } from "lib/addons"; +import { type AddonTemplate } from "lib/addons/template"; +import { useAddonList } from "lib/hooks/useAddon"; +import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; + +import { Context } from "shared/Context"; + +import DashboardHeader from "../cluster-dashboard/DashboardHeader"; +import ClusterContextProvider from "../infrastructure-dashboard/ClusterContextProvider"; +import Configuration from "./common/Configuration"; + +type Props = { + template: AddonTemplate; +}; +const AddonForm: React.FC = ({ template }) => { + const { currentProject, currentCluster } = useContext(Context); + const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } = + useDefaultDeploymentTarget(); + + const history = useHistory(); + const { + addons, + isLoading: isAddonListLoading, + isLegacyAddonsLoading, + legacyAddons, + } = useAddonList({ + projectId: currentProject?.id, + deploymentTarget: defaultDeploymentTarget, + }); + + const { + reset, + register, + watch, + setError, + clearErrors, + formState: { errors }, + } = useFormContext(); + const watchName = watch("name.value", ""); + const currentStep = useMemo(() => { + if (!watchName) { + return 0; + } + return 1; + }, [watchName]); + + useEffect(() => { + reset(defaultClientAddon(template.type)); + }, [template]); + + useEffect(() => { + if ( + addons.some((a) => a.name.value === watchName) || + legacyAddons.some((a) => a.name === watchName) + ) { + setError("name.value", { + message: "An addon with this name already exists", + }); + } else { + clearErrors("name.value"); + } + }, [watchName]); + + if ( + isDefaultDeploymentTargetLoading || + isAddonListLoading || + isLegacyAddonsLoading + ) { + return ; + } + + return ( + + +
+ + { + history.push(`/addons/new`); + }} + /> + } + title={`Configure new ${template.displayName} instance`} + capitalize={false} + disableLineBreak + /> + + + Add-on name + + + Lowercase letters, numbers, and "-" only. + + + + , + <> + + , + ]} + /> + + +
+
+
+ ); +}; + +export default AddonForm; + +const Div = styled.div` + width: 100%; + max-width: 900px; +`; + +const CenterWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const DarkMatter = styled.div` + width: 100%; + margin-top: -5px; +`; + +const Icon = styled.img` + margin-right: 15px; + height: 30px; + animation: floatIn 0.5s; + animation-fill-mode: forwards; + + @keyframes floatIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0px); + } + } +`; + +const StyledConfigureTemplate = styled.div` + height: 100%; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx b/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx new file mode 100644 index 0000000000..4789d9165b --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx @@ -0,0 +1,134 @@ +import React, { createContext, useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { FormProvider, useForm } from "react-hook-form"; +import { useHistory } from "react-router"; +import styled from "styled-components"; + +import Loading from "components/Loading"; +import { Error as ErrorComponent } from "components/porter/Error"; +import { clientAddonValidator, type ClientAddon } from "lib/addons"; +import { useAddon } from "lib/hooks/useAddon"; +import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster"; +import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; + +import { type UpdateClusterButtonProps } from "../infrastructure-dashboard/ClusterFormContextProvider"; + +type AddonFormContextType = { + updateAddonButtonProps: UpdateClusterButtonProps; + projectId: number; +}; + +const AddonFormContext = createContext(null); + +export const useAddonFormContext = (): AddonFormContextType => { + const ctx = React.useContext(AddonFormContext); + if (!ctx) { + throw new Error( + "useAddonFormContext must be used within a AddonFormContextProvider" + ); + } + return ctx; +}; + +type AddonFormContextProviderProps = { + projectId?: number; + redirectOnSubmit?: boolean; + children: JSX.Element; +}; + +const AddonFormContextProvider: React.FC = ({ + projectId, + redirectOnSubmit, + children, +}) => { + const [updateAddonError, setUpdateAddonError] = useState(""); + const { defaultDeploymentTarget } = useDefaultDeploymentTarget(); + const { updateAddon } = useAddon(); + const queryClient = useQueryClient(); + const history = useHistory(); + + const addonForm = useForm({ + reValidateMode: "onSubmit", + resolver: zodResolver(clientAddonValidator), + }); + const { + handleSubmit, + formState: { isSubmitting, errors }, + } = addonForm; + + const onSubmit = handleSubmit(async (data) => { + if (!projectId) { + return; + } + setUpdateAddonError(""); + try { + await updateAddon({ + projectId, + deploymentTargetId: defaultDeploymentTarget.id, + addon: data, + }); + + await queryClient.invalidateQueries(["getAddon"]); + + if (redirectOnSubmit) { + history.push(`/addons/${data.name.value}`); + } + } catch (err) { + setUpdateAddonError( + getErrorMessageFromNetworkCall(err, "Addon deployment") + ); + } + }); + + const updateAddonButtonProps = useMemo(() => { + const props: UpdateClusterButtonProps = { + status: "", + isDisabled: false, + loadingText: "Deploying addon...", + }; + if (isSubmitting) { + props.status = "loading"; + props.isDisabled = true; + } + + if (updateAddonError) { + props.status = ( + + ); + } + if (Object.keys(errors).length > 0) { + // TODO: remove this and properly handle form validation errors + console.log("errors", errors); + } + + return props; + }, [isSubmitting, errors, errors?.name?.value]); + + if (!projectId) { + return ; + } + + return ( + + + +
{children}
+
+
+
+ ); +}; + +export default AddonFormContextProvider; + +const Wrapper = styled.div` + height: fit-content; + margin-bottom: 10px; + width: 100%; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx b/dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx new file mode 100644 index 0000000000..e908db2a93 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx @@ -0,0 +1,205 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; +import { match, P } from "ts-pattern"; + +import Container from "components/porter/Container"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import Tooltip from "components/porter/Tooltip"; +import TitleSection from "components/TitleSection"; +import { type ClientAddonPod } from "lib/hooks/useAddon"; +import { prefixSubdomain } from "lib/porter-apps/services"; + +import { useAddonContext } from "./AddonContextProvider"; + +const AddonHeader: React.FC = () => { + const { addon, status } = useAddonContext(); + + const domain = useMemo(() => { + return match(addon.config) + .with({ type: "metabase" }, (config) => { + return config.customDomain || config.porterDomain; + }) + .otherwise(() => ""); + }, [addon]); + + return ( + + + {addon.name.value} + + {domain && ( + <> + + + + + {domain} + + + + + )} + +
+ + Deploy status + + {match(status) + .with({ isLoading: true }, () => ( + Initializing... + )) + .with( + { + pods: P.when((pods: ClientAddonPod[]) => + pods.every((p) => p.status === "running") + ), + }, + () => Deployed + ) + .with( + { + pods: P.when((pods) => pods.some((p) => p.status === "failed")), + }, + () => Failed + ) + .with( + { + pods: P.when((pods) => + pods.some((p) => p.status === "pending") + ), + }, + () => Deploying + ) + .otherwise(() => null)} + + + {status.isLoading ? ( + + ) : ( + + {status.pods.map((p, i) => { + return ( + + + {`Pod: ${p.name}`} + + + + {p.status} + + + } + position="right" + > +
+ +
+
+ ); + })} +
+ )} +
+
+ ); +}; + +export default AddonHeader; + +const HeaderWrapper = styled.div` + position: relative; +`; + +const getBackgroundGradient = (status: string): string => { + switch (status) { + case "loading": + return "linear-gradient(#76767644, #76767622)"; + case "running": + return "linear-gradient(#01a05d, #0f2527)"; + case "failed": + return "linear-gradient(#E1322E, #25100f)"; + case "pending": + return "linear-gradient(#E49621, #25270f)"; + default: + return "linear-gradient(#76767644, #76767622)"; // Default or unknown status + } +}; + +const Bar = styled.div<{ + isFirst: boolean; + isLast: boolean; + status: string; + animate?: boolean; +}>` + height: 20px; + max-width: 20px; + display: flex; + flex: 1; + border-top-left-radius: ${(props) => (props.isFirst ? "5px" : "0")}; + border-bottom-left-radius: ${(props) => (props.isFirst ? "5px" : "0")}; + border-top-right-radius: ${(props) => (props.isLast ? "5px" : "0")}; + border-bottom-right-radius: ${(props) => (props.isLast ? "5px" : "0")}; + background: ${(props) => getBackgroundGradient(props.status)}; + ${(props) => + props.animate + ? "animation: loadingAnimation 1.5s infinite;" + : "animation: fadeIn 0.3s 0s;"} + @keyframes loadingAnimation { + 0% { + opacity: 0.3; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.3; + } + } + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const StatusBars = styled.div` + display: flex; + gap: 2px; +`; + +const LoadingBars: React.FC = () => { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ); +}; + +const StyledLoadingBars = styled.div` + display: flex; + gap: 2px; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonSaveButton.tsx b/dashboard/src/main/home/add-on-dashboard/AddonSaveButton.tsx new file mode 100644 index 0000000000..6361919970 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonSaveButton.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import Button from "components/porter/Button"; + +import { useAddonFormContext } from "./AddonFormContextProvider"; + +type Props = { + height?: string; + disabledTooltipPosition?: "top" | "bottom" | "left" | "right"; +}; +const AddonSaveButton: React.FC = ({ + height, + disabledTooltipPosition, +}) => { + const { updateAddonButtonProps } = useAddonFormContext(); + + return ( + + ); +}; + +export default AddonSaveButton; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx b/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx new file mode 100644 index 0000000000..14ff0119d0 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx @@ -0,0 +1,120 @@ +import React, { useContext, useEffect, useMemo } from "react"; +import AnimateHeight from "react-animate-height"; +import { useFormContext } from "react-hook-form"; +import { useHistory } from "react-router"; +import styled from "styled-components"; +import { match } from "ts-pattern"; + +import Banner from "components/porter/Banner"; +import Spacer from "components/porter/Spacer"; +import TabSelector from "components/TabSelector"; +import { type ClientAddon } from "lib/addons"; +import { + DEFAULT_ADDON_TAB, + SUPPORTED_ADDON_TEMPLATES, +} from "lib/addons/template"; + +import { Context } from "shared/Context"; + +import { useAddonContext } from "./AddonContextProvider"; +import AddonSaveButton from "./AddonSaveButton"; + +type Props = { + tabParam?: string; +}; + +const AddonTabs: React.FC = ({ tabParam }) => { + const history = useHistory(); + const { addon } = useAddonContext(); + const { user } = useContext(Context); + + const { + reset, + formState: { isDirty }, + } = useFormContext(); + + useEffect(() => { + reset(addon); + }, [addon]); + + const addonTemplate = useMemo(() => { + return SUPPORTED_ADDON_TEMPLATES.find( + (template) => template.type === addon.config.type + ); + }, [addon]); + + const tabs = useMemo(() => { + if (addonTemplate) { + return addonTemplate.tabs + .filter( + (t) => + !t.isOnlyForPorterOperators || + (t.isOnlyForPorterOperators && user.isPorterUser) + ) + .map((tab) => ({ + label: tab.displayName, + value: tab.name, + })); + } + return [ + { + label: DEFAULT_ADDON_TAB.displayName, + value: DEFAULT_ADDON_TAB.name, + }, + ]; + }, [addonTemplate]); + + const currentTab = useMemo(() => { + if (tabParam && tabs.some((tab) => tab.value === tabParam)) { + return tabParam; + } + return tabs[0].value; + }, [tabParam, tabs]); + + return ( + + + + } + > + Changes you are currently previewing have not been saved. + + + + + { + history.push(`/addons/${addon.name.value}/${tab}`); + }} + /> + + {addonTemplate?.tabs + .filter( + (t) => + !t.isOnlyForPorterOperators || + (t.isOnlyForPorterOperators && user.isPorterUser) + ) + .map((tab) => + match(currentTab) + .with(tab.name, () => ) + .otherwise(() => null) + )} + + ); +}; + +export default AddonTabs; + +const DashboardWrapper = styled.div` + width: 100%; + min-width: 300px; + height: fit-content; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx b/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx new file mode 100644 index 0000000000..0945c20f00 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx @@ -0,0 +1,171 @@ +import React, { useContext, useMemo } from "react"; +import { useHistory, useLocation } from "react-router"; +import styled from "styled-components"; + +import Back from "components/porter/Back"; +import Spacer from "components/porter/Spacer"; +import { + AddonTemplateTagColor, + SUPPORTED_ADDON_TEMPLATES, + type AddonTemplate, +} from "lib/addons/template"; + +import { Context } from "shared/Context"; +import addOnGrad from "assets/add-on-grad.svg"; + +import DashboardHeader from "../cluster-dashboard/DashboardHeader"; +import AddonForm from "./AddonForm"; +import AddonFormContextProvider from "./AddonFormContextProvider"; + +const AddonTemplates: React.FC = () => { + const { currentProject } = useContext(Context); + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + const history = useHistory(); + + const templateMatch = useMemo(() => { + const addonName = queryParams.get("addon_name"); + return SUPPORTED_ADDON_TEMPLATES.find((t) => t.type === addonName); + }, [queryParams]); + + if (templateMatch) { + return ( + + + + ); + } + + return ( + + + + + {SUPPORTED_ADDON_TEMPLATES.map((template: AddonTemplate) => { + return ( + { + history.push(`/addons/new?addon_name=${template.type}`); + }} + > + + {template.displayName} + {template.description} + + {template.tags.map((t) => ( + + {t} + + ))} + + ); + })} + + + ); +}; + +export default AddonTemplates; + +const StyledTemplateComponent = styled.div` + width: 100%; + height: 100%; +`; + +const Tag = styled.div<{ size?: string; bottom?: string; left?: string }>` + position: absolute; + bottom: ${(props) => props.bottom || "auto"}; + left: ${(props) => props.left || "auto"}; + font-size: 10px; + background: linear-gradient( + 45deg, + rgba(88, 24, 219, 1) 0%, + rgba(72, 12, 168, 1) 100% + ); // added gradient for shiny effect + padding: 10px; + border-radius: 4px; + opacity: 0.85; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); +`; + +const TemplateDescription = styled.div` + margin-bottom: 26px; + color: #ffffff66; + text-align: center; + font-weight: default; + padding: 0px 25px; + line-height: 1.4; + font-size: 12px; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const TemplateTitle = styled.div` + width: 80%; + text-align: center; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const TemplateBlock = styled.div` + align-items: center; + user-select: none; + display: flex; + font-size: 13px; + padding: 3px 0px 5px; + flex-direction: column; + align-item: center; + justify-content: space-between; + height: 180px; + cursor: pointer; + color: #ffffff; + position: relative; + border-radius: 5px; + background: ${(props) => props.theme.clickable.bg}; + border: 1px solid #494b4f; + :hover { + border: 1px solid #7a7b80; + } + + animation: fadeIn 0.3s 0s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const TemplateListWrapper = styled.div` + overflow: visible; + margin-top: 15px; + padding-bottom: 50px; + display: grid; + grid-column-gap: 30px; + grid-row-gap: 30px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +`; + +const Icon = styled.img` + height: 25px; + margin-top: 30px; + margin-bottom: 5px; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonView.tsx b/dashboard/src/main/home/add-on-dashboard/AddonView.tsx new file mode 100644 index 0000000000..5f28c9f08c --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/AddonView.tsx @@ -0,0 +1,70 @@ +import React, { useContext, useMemo } from "react"; +import { withRouter, type RouteComponentProps } from "react-router"; +import styled from "styled-components"; +import { z } from "zod"; + +import Back from "components/porter/Back"; +import Spacer from "components/porter/Spacer"; + +import { Context } from "shared/Context"; + +import ClusterContextProvider from "../infrastructure-dashboard/ClusterContextProvider"; +import { AddonContextProvider } from "./AddonContextProvider"; +import AddonFormContextProvider from "./AddonFormContextProvider"; +import AddonHeader from "./AddonHeader"; +import AddonTabs from "./AddonTabs"; + +type Props = RouteComponentProps; + +const AddonView: React.FC = ({ match }) => { + const { currentProject, currentCluster } = useContext(Context); + const params = useMemo(() => { + const { params } = match; + const validParams = z + .object({ + tab: z.string().optional(), + addonName: z.string().optional(), + }) + .safeParse(params); + + if (!validParams.success) { + return { + tab: undefined, + }; + } + + return validParams.data; + }, [match]); + + return ( + + + + + + + + + + + + + ); +}; + +export default withRouter(AddonView); + +const StyledExpandedAddon = styled.div` + width: 100%; + height: 100%; + + animation: fadeIn 0.5s 0s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; diff --git a/dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx b/dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx deleted file mode 100644 index e4311b2d15..0000000000 --- a/dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useEffect, useState, useContext, useMemo } from "react"; -import styled from "styled-components"; -import DashboardHeader from "../cluster-dashboard/DashboardHeader"; -import semver from "semver"; -import _ from "lodash"; - -import addOnGrad from "assets/add-on-grad.svg"; -import notFound from "assets/not-found.png"; - -import { Context } from "shared/Context"; -import api from "shared/api"; -import { search } from "shared/search"; - -import TemplateList from "../launch/TemplateList"; -import SearchBar from "components/porter/SearchBar"; -import Spacer from "components/porter/Spacer"; -import Loading from "components/Loading"; -import ExpandedTemplate from "./ExpandedTemplate"; -import ConfigureTemplate from "./ConfigureTemplate"; -import Back from "components/porter/Back"; -import Fieldset from "components/porter/Fieldset"; -import Text from "components/porter/Text"; -import Container from "components/porter/Container"; -import Select from "components/porter/Select"; - -type Props = { -}; - -const HIDDEN_CHARTS = [ - "agent", - "elasticache-chart", - "elasticache-memcached", - "elasticache-redis", - "loki", - "porter-agent", - "rds-chart", - "rds-postgresql", - "rds-postgresql-aurora", - "postgresql-managed", - "redis-managed", -]; - -// For Charts that don't exist locally we need to add them in manually -const TAG_MAPPING = { - "DATA_STORE": ["mysql"], - "DATA_BASE": ["mysql"] -} - -const NewAddOnFlow: React.FC = ({ -}) => { - const { capabilities, currentProject, currentCluster, user } = useContext(Context); - const [isLoading, setIsLoading] = useState(true); - const [searchValue, setSearchValue] = useState(""); - const [addOnTemplates, setAddOnTemplates] = useState([]); - const [currentTemplate, setCurrentTemplate] = useState(null); - const [currentForm, setCurrentForm] = useState(null); - const [selectedTag, setSelectedTag] = useState(null); - - const allFilteredTemplates = useMemo(() => { - const filteredBySearch = search( - addOnTemplates ?? [], - searchValue, - { - keys: ["name"], - isCaseSensitive: false, - } - ); - - return _.sortBy(filteredBySearch); - }, [addOnTemplates, searchValue]); - - const appTemplates = useMemo(() => { - return allFilteredTemplates.filter(template => - template.tags?.includes("APP")); - }, [allFilteredTemplates]); - - const dataStoreTemplates = useMemo(() => { - return allFilteredTemplates.filter(template => template.tags?.includes("DATA_STORE")); - }, [allFilteredTemplates]); - - const filteredTemplates = useMemo(() => { - return _.differenceBy( - allFilteredTemplates, - [...appTemplates, ...dataStoreTemplates] - ); - }, [allFilteredTemplates, appTemplates, dataStoreTemplates]); - - const getTemplates = async () => { - setIsLoading(true); - const default_addon_helm_repo_url = capabilities?.default_addon_helm_repo_url; - try { - const res = await api.getTemplates( - "", - { - repo_url: default_addon_helm_repo_url, - }, - { - project_id: currentProject.id, - } - ); - setIsLoading(false); - let sortedVersionData = res.data.map((template: any) => { - let versions = template.versions.reverse(); - versions = template.versions.sort(semver.rcompare); - return { - ...template, - versions, - currentVersion: versions[0], - }; - }); - sortedVersionData.sort((a: any, b: any) => (a.name > b.name ? 1 : -1)); - sortedVersionData = sortedVersionData.filter( - (template: any) => !HIDDEN_CHARTS.includes(template?.name) - ); - - sortedVersionData = sortedVersionData.map((template: any) => { - const testTemplate: string[] = template?.tags || [] - // Assign tags based on TAG_MAPPING - for (const tag in TAG_MAPPING) { - if (TAG_MAPPING[tag].includes(template.name)) { - testTemplate?.push(tag); - } - } - - return { ...template, tags: testTemplate }; - }); - setAddOnTemplates(sortedVersionData); - } catch (error) { - setIsLoading(false); - } - }; - - - useEffect(() => { - getTemplates(); - }, [currentProject, currentCluster]); - - return ( - - { - (currentForm && currentTemplate) ? ( - { setCurrentForm(null); }} - /> - ) : ( - <> - - - { - currentTemplate ? ( - { setCurrentForm(form); }} - goBack={() => { setCurrentTemplate(null); }} - /> - ) : ( - <> - - - - {/* */} + + + + {allFilteredTemplates.length === 0 && ( +
+ + + No matching add-ons were found. + +
+ )} + {isLoading ? ( + + ) : ( + <> + + + {appTemplates?.length > 0 && ( + <> + +
+ + Apps and Services + +
+
+ For developer productivity. +
+ { + setCurrentTemplate(x); + }} + /> + + )} + + {dataStoreTemplates?.length > 0 && ( + <> +
+ + Pre-Production Datastores + +
+
+ + Pre-production datastores are not highly available and + use ephemeral storage. + +
+ { + setCurrentTemplate(x); + }} + /> + + )} + + {filteredTemplates?.length > 0 && + (currentProject?.full_add_ons || user.isPorterUser) && ( + <> +
+ + All Add-Ons + +
+
+ Full list of add-ons +
+ + { + setCurrentTemplate(x); + }} + /> + + )} + + )} + + )} + + )} +
+ ); +}; + +export default LegacyNewAddOnFlow; + +const PlaceholderIcon = styled.img` + height: 13px; + margin-right: 12px; + opacity: 0.65; +`; + +const DarkMatter = styled.div` + width: 100%; + margin-top: -35px; +`; + +const I = styled.i` + font-size: 16px; + padding: 4px; + cursor: pointer; + border-radius: 50%; + margin-right: 15px; + background: ${(props) => props.theme.fg}; + color: ${(props) => props.theme.text.primary}; + border: 1px solid ${(props) => props.theme.border}; + :hover { + filter: brightness(150%); + } +`; + +const StyledTemplateComponent = styled.div` + width: 100%; + height: 100%; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx b/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx new file mode 100644 index 0000000000..cf53c1792f --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx @@ -0,0 +1,354 @@ +import React, { useMemo, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useHistory } from "react-router"; +import styled from "styled-components"; +import { z } from "zod"; + +import CopyToClipboard from "components/CopyToClipboard"; +import Loading from "components/Loading"; +import Button from "components/porter/Button"; +import Checkbox from "components/porter/Checkbox"; +import CollapsibleContainer from "components/porter/CollapsibleContainer"; +import Container from "components/porter/Container"; +import { ControlledInput } from "components/porter/ControlledInput"; +import Image from "components/porter/Image"; +import Modal from "components/porter/Modal"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { type ClientAddon } from "lib/addons"; +import { datastoreValidator, type ClientDatastore } from "lib/databases/types"; +import { useDatastoreList } from "lib/hooks/useDatabaseList"; + +import api from "shared/api"; +import { stringifiedDNSRecordType } from "utils/ip"; +import copy from "assets/copy-left.svg"; +import upload from "assets/upload.svg"; + +import { DATASTORE_ENGINE_POSTGRES } from "../../database-dashboard/constants"; +import { DatastoreList } from "../../database-dashboard/DatabaseDashboard"; +import { useClusterContext } from "../../infrastructure-dashboard/ClusterContextProvider"; +import { useAddonFormContext } from "../AddonFormContextProvider"; +import AddonSaveButton from "../AddonSaveButton"; + +const MetabaseForm: React.FC = () => { + const { cluster } = useClusterContext(); + const { + register, + formState: { errors }, + control, + watch, + } = useFormContext(); + const watchExposedToExternalTraffic = watch( + "config.exposedToExternalTraffic", + false + ); + return ( +
+ Metabase configuration + + ( + { + onChange(!value); + }} + > + Expose to external traffic + + )} + /> + + + Custom domain + + + Add an optional custom domain to access Metabase. If you do not + provide a custom domain, Porter will provision a domain for you. + + {cluster.ingress_ip !== "" && ( + <> + +
+ + To configure a custom domain, you must add{" "} + {stringifiedDNSRecordType(cluster.ingress_ip)} pointing to the + following Ingress IP for your cluster:{" "} + +
+ + + {cluster.ingress_ip} + + + + + + + + + )} + +
+ + + Datastore connection info + + + Specify the connection credentials for your datastore. + + + + + +
+ ); +}; + +const MetabaseDatastoreConnection: React.FC = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + const [showInjectCredentialsModal, setShowInjectCredentialsModal] = + useState(false); + const history = useHistory(); + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ Host + + +
+ Port + + +
+ Database name + + +
+ Username + + +
+ Password + + +
+ + + + + + + {showInjectCredentialsModal && ( + { + setShowInjectCredentialsModal(false); + }} + /> + )} +
+ ); +}; + +type ModalProps = { + onClose: () => void; +}; +const InjectDatastoreCredentialsModal: React.FC = ({ onClose }) => { + const [isInjectingCredentials, setIsInjectingCredentials] = useState(false); + const { datastores } = useDatastoreList(); + const { projectId } = useAddonFormContext(); + const postgresDatastores = useMemo(() => { + return datastores.filter( + (d) => d.template.highLevelType === DATASTORE_ENGINE_POSTGRES + ); + }, [datastores]); + const { setValue } = useFormContext(); + + const injectCredentials = async ( + datastore: ClientDatastore + ): Promise => { + try { + setIsInjectingCredentials(true); + const response = await api.getDatastore( + "", + {}, + { + project_id: projectId, + datastore_name: datastore.name, + } + ); + + const results = await z + .object({ datastore: datastoreValidator }) + .parseAsync(response.data); + + const credential = results.datastore.credential; + setValue("config.datastore.host", credential.host); + setValue("config.datastore.port", credential.port); + setValue("config.datastore.databaseName", credential.database_name); + setValue("config.datastore.username", credential.username); + setValue("config.datastore.password", credential.password); + onClose(); + } catch (err) { + console.log(err); + } finally { + setIsInjectingCredentials(false); + } + }; + + return ( + + + + Inject credentials from a Porter Postgres datastore + {isInjectingCredentials && ( + <> + + + + )} + + + {postgresDatastores.length === 0 ? ( + + No postgres datastores were found. Please create a postgres + datastore in the Datastores tab first. + + ) : ( + <> + + Select a datastore to inject its connection credentials into + Metabase. + + + + + )} + + + ); +}; + +const InnerModalContents = styled.div` + overflow-y: auto; + max-height: 80vh; +`; + +export default MetabaseForm; + +const I = styled.i` + font-size: 16px; + margin-right: 7px; +`; + +const Code = styled.span` + font-family: monospace; +`; + +const IdContainer = styled.div` + background: #26292e; + border-radius: 5px; + padding: 10px; + display: flex; + width: 100%; + border-radius: 5px; + border: 1px solid ${({ theme }) => theme.border}; + align-items: center; + user-select: text; +`; + +const CopyContainer = styled.div` + display: flex; + align-items: center; + margin-left: auto; +`; + +const CopyIcon = styled.img` + cursor: pointer; + margin-left: 5px; + margin-right: 5px; + width: 15px; + height: 15px; + :hover { + opacity: 0.8; + } +`; diff --git a/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx b/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx new file mode 100644 index 0000000000..2984263319 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { useFormContext } from "react-hook-form"; + +import { ControlledInput } from "components/porter/ControlledInput"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { type ClientAddon } from "lib/addons"; + +import AddonSaveButton from "../AddonSaveButton"; + +const MezmoForm: React.FC = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + return ( +
+ Mezmo configuration + + + This installs the Mezmo agent, which forwards all logs from Porter to + Mezmo for ingestion. It may take around 30 minutes for the logs to + arrive in your Mezmo instance. Be aware that this incurs additional + costs based on your retention settings. By default, all logs are + ingested - to reduce costs, you can filter out the logs from Mezmo. + + + Ingestion Key + + + + +
+ ); +}; + +export default MezmoForm; diff --git a/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx b/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx new file mode 100644 index 0000000000..7e07d4f8f7 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import Checkbox from "components/porter/Checkbox"; +import { ControlledInput } from "components/porter/ControlledInput"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { type ClientAddon } from "lib/addons"; + +import AddonSaveButton from "../AddonSaveButton"; + +const NewRelicForm: React.FC = () => { + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + return ( +
+ NewRelic configuration + + + This installs the NewRelic agent, which forwards all logs & metrics from + your applications to NewRelic for ingestion. + + + NewRelic License Key + + + + NewRelic Insights Key + + + + NewRelic Personal API Key + + + + NewRelic Account ID + + + + Logging + + + Enable logging and forward all logs to newRelic + + + ( + { + onChange(!value); + }} + > + Logging enabled + + )} + /> + + Kubernetes Events + + + Enable forwarding of Kubernetes events to NewRelic + + + ( + { + onChange(!value); + }} + > + Kubernetes events forwarding enabled + + )} + /> + + Metrics Adapter + + + Enable the metrics adapter to forward metrics to NewRelic + + + ( + { + onChange(!value); + }} + > + Metrics Adapter enabled + + )} + /> + + Prometheus + + + Enable the NewRelic prometheus collector for apps exposing Prometheus + metrics + + + ( + { + onChange(!value); + }} + > + Prometheus enabled + + )} + /> + + Pixie + + + Enable Pixie - an open-source observability tool for Kubernetes + applications + + + ( + { + onChange(!value); + }} + > + Pixie enabled + + )} + /> + + +
+ ); +}; + +export default NewRelicForm; diff --git a/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleForm.tsx b/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleForm.tsx new file mode 100644 index 0000000000..2a9e4f5e89 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleForm.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import styled from "styled-components"; + +import Button from "components/porter/Button"; +import { ControlledInput } from "components/porter/ControlledInput"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { type ClientAddon } from "lib/addons"; + +import AddonSaveButton from "../AddonSaveButton"; + +const TailscaleForm: React.FC = () => { + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + const { + append, + fields: routes, + remove, + } = useFieldArray({ + control, + name: "config.subnetRoutes", + }); + + return ( +
+ Tailscale configuration + + Auth key + + + You can generate an auth key from the Tailscale dashboard by going to + "Settings" -{">"} "Auth Keys" and generating a + one-off key. Auth keys will expire after 90 days by default. To disable + key expiry{" "} + + consult the Tailscale docs. + + + + + + Subnet routes (optional) + + + By default, the subnet routes for this cluster and all datastores + connected to this cluster are routed through Tailscale. Enter any + additional subnet routes you would like to route through Tailscale here. + + + {routes.map((route, i) => { + return ( +
+ + + { + remove(i); + }} + > + cancel + + + +
+ ); + })} + + + +
+ ); +}; + +export default TailscaleForm; + +const AnnotationContainer = styled.div` + display: flex; + align-items: center; + gap: 5px; +`; + +const DeleteButton = styled.div` + width: 15px; + height: 15px; + display: flex; + align-items: center; + margin-left: 8px; + margin-top: -3px; + justify-content: center; + + > i { + font-size: 17px; + color: #ffffff44; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + :hover { + color: #ffffff88; + } + } +`; diff --git a/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx b/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx new file mode 100644 index 0000000000..d456d4ba30 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { match } from "ts-pattern"; +import { z } from "zod"; + +import Loading from "components/Loading"; +import { + StyledTable, + StyledTd, + StyledTh, + StyledTHead, + StyledTr, +} from "components/OldTable"; +import ClickToCopy from "components/porter/ClickToCopy"; +import { Error as ErrorComponent } from "components/porter/Error"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { + tailscaleServiceValidator, + type ClientTailscaleService, +} from "lib/addons"; + +import api from "shared/api"; + +import { useAddonContext } from "../AddonContextProvider"; + +const TailscaleOverview: React.FC = () => { + const { projectId, deploymentTarget } = useAddonContext(); + + const tailscaleServicesResp = useQuery( + ["getTailscaleServices", projectId, deploymentTarget], + async () => { + if (!projectId || projectId === -1) { + return []; + } + + const res = await api.getTailscaleServices( + "", + {}, + { + projectId, + deploymentTargetId: deploymentTarget.id, + } + ); + + const parsed = await z + .object({ + services: z.array(tailscaleServiceValidator), + }) + .parseAsync(res.data); + + return parsed.services; + }, + { + enabled: !!projectId && projectId !== -1, + retryDelay: 5000, + } + ); + + return ( +
+ Networking + + + Please make sure that you{" "} + + approve all advertised subnet routes in Tailscale. + {" "} + Once that is completed, the following services can be reached through + your Tailscale VPN by IP: + + + {match(tailscaleServicesResp) + .with({ status: "loading" }, () => ) + .with({ status: "error" }, ({ error }) => ( + + )) + .with({ status: "success", data: [] }, () => ( + No services found + )) + .with({ status: "success" }, ({ data }) => ( + + + + Name + IP + Port + + + + {data.map((service) => ( + + + {service.name} + + + {service.ip} + + + {service.port} + + + ))} + + + )) + .exhaustive()} +
+ ); +}; + +export default TailscaleOverview; diff --git a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx index 4b1560db19..3d1cf066b3 100644 --- a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx +++ b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx @@ -144,12 +144,13 @@ const Apps: React.FC = () => { return; } - const res = await api.listLatestAddons( + const res = await api.listAddons( "", + {}, { - deployment_target_id: currentDeploymentTarget.id, - }, - { clusterId: currentCluster.id, projectId: currentProject.id } + deploymentTargetId: currentDeploymentTarget.id, + projectId: currentProject.id, + } ); const parsed = await z @@ -292,7 +293,7 @@ const Apps: React.FC = () => { back={() => { setShowBillingModal(false); }} - onCreate={() => { + onCreate={async () => { history.push("/apps/new/app"); }} /> diff --git a/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx b/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx index a138aa4011..881e8977eb 100644 --- a/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx +++ b/dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx @@ -1,6 +1,6 @@ import React, { useContext, useMemo, useState } from "react"; import _ from "lodash"; -import { Link } from "react-router-dom"; +import { Link, useHistory } from "react-router-dom"; import styled from "styled-components"; import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder"; @@ -19,9 +19,7 @@ import StatusDot from "components/porter/StatusDot"; import Text from "components/porter/Text"; import Toggle from "components/porter/Toggle"; import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader"; -import { isAWSCluster } from "lib/clusters/types"; import { type ClientDatastore } from "lib/databases/types"; -import { useClusterList } from "lib/hooks/useCluster"; import { useDatastoreList } from "lib/hooks/useDatabaseList"; import { Context } from "shared/Context"; @@ -39,7 +37,6 @@ import EngineTag from "./tags/EngineTag"; const DatabaseDashboard: React.FC = () => { const { currentProject, currentCluster } = useContext(Context); - const { clusters, isLoading: isLoadingClusters } = useClusterList(); const [searchValue, setSearchValue] = useState(""); const [view, setView] = useState<"grid" | "list">("grid"); @@ -49,6 +46,7 @@ const DatabaseDashboard: React.FC = () => { const [engineFilter, setEngineFilter] = useState< "all" | "POSTGRES" | "AURORA-POSTGRES" | "REDIS" >("all"); + const history = useHistory(); const { datastores, isLoading } = useDatastoreList({ refetchIntervalMilliseconds: 5000, @@ -96,7 +94,7 @@ const DatabaseDashboard: React.FC = () => {
@@ -124,32 +122,10 @@ const DatabaseDashboard: React.FC = () => { ); } - if (datastores === undefined || isLoading || isLoadingClusters) { + if (datastores === undefined || isLoading) { return ; } - if (clusters.filter(isAWSCluster).length === 0) { - return ( -
- Datastores are not supported for this project. - - - Datastores are only supported for projects with a provisioned AWS - cluster. - - - - To get started with datastores, you will need to create an AWS - cluster. Contact our team if you are interested in enabling - multi-cluster support. - - - -
- ); - } if (currentCluster?.status === "UPDATING_UNAVAILABLE") { return ; } @@ -330,41 +306,12 @@ const DatabaseDashboard: React.FC = () => { )} ) : ( - - {(filteredDatastores ?? []).map( - (datastore: ClientDatastore, i: number) => { - return ( - - - - - {datastore.name} - - - - - - - - - - - {readableDate(datastore.created_at)} - - - - - ); - } - )} - + { + history.push(`/datastores/${d.name}`); + }} + /> )} ); @@ -386,12 +333,56 @@ const DatabaseDashboard: React.FC = () => { export default DatabaseDashboard; +export const DatastoreList: React.FC<{ + datastores: ClientDatastore[]; + onClick: (datastore: ClientDatastore) => void | Promise; +}> = ({ datastores, onClick }) => { + return ( + + {datastores.map((datastore: ClientDatastore, i: number) => { + return ( + { + await onClick(datastore); + }} + > + + + + {datastore.name} + + + + + + + + + + + {readableDate(datastore.created_at)} + + + + + ); + })} + + ); +}; + const MidIcon = styled.img<{ height?: string }>` height: ${(props) => props.height || "18px"}; margin-right: 11px; `; -const Row = styled(Link)<{ isAtBottom?: boolean }>` +const Row = styled.div<{ isAtBottom?: boolean }>` cursor: pointer; display: block; padding: 15px; @@ -403,6 +394,9 @@ const Row = styled(Link)<{ isAtBottom?: boolean }>` border-radius: 5px; margin-bottom: 15px; animation: fadeIn 0.3s 0s; + :hover { + border: 1px solid #7a7b80; + } `; const List = styled.div` diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index d83975f022..b0e50afe44 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -1302,16 +1302,58 @@ const getAppTemplate = baseApi< return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`; }); -const listLatestAddons = baseApi< +const listAddons = baseApi< + {}, { - deployment_target_id?: string; + projectId: number; + deploymentTargetId: string; + } +>("GET", ({ projectId, deploymentTargetId }) => { + return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons`; +}); + +const getAddon = baseApi< + {}, + { + projectId: number; + deploymentTargetId: string; + addonName: string; + } +>("GET", ({ projectId, deploymentTargetId, addonName }) => { + return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/${addonName}`; +}); + +const getTailscaleServices = baseApi< + {}, + { + projectId: number; + deploymentTargetId: string; + } +>("GET", ({ projectId, deploymentTargetId }) => { + return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/tailscale-services`; +}); + +const updateAddon = baseApi< + { + b64_addon: string; }, { projectId: number; - clusterId: number; + deploymentTargetId: string; } ->("GET", ({ projectId, clusterId }) => { - return `/api/projects/${projectId}/clusters/${clusterId}/addons/latest`; +>("POST", ({ projectId, deploymentTargetId }) => { + return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/update`; +}); + +const deleteAddon = baseApi< + {}, + { + projectId: number; + deploymentTargetId: string; + addonName: string; + } +>("DELETE", ({ projectId, deploymentTargetId, addonName }) => { + return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/${addonName}`; }); const getGitlabProcfileContents = baseApi< @@ -2316,7 +2358,7 @@ const createEnvironmentGroups = baseApi< infisical_env?: { slug: string; path: string; - } + }; }, { id: number; @@ -3797,7 +3839,11 @@ export default { createDeploymentTarget, getDeploymentTarget, getAppTemplate, - listLatestAddons, + listAddons, + getAddon, + getTailscaleServices, + updateAddon, + deleteAddon, getGitlabProcfileContents, getProjectClusters, getProjectRegistries, diff --git a/go.mod b/go.mod index af77afeb64..4b720a31ba 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( github.com/matryer/is v1.4.0 github.com/nats-io/nats.go v1.24.0 github.com/open-policy-agent/opa v0.44.0 - github.com/porter-dev/api-contracts v0.2.156 + github.com/porter-dev/api-contracts v0.2.157 github.com/riandyrn/otelchi v0.5.1 github.com/santhosh-tekuri/jsonschema/v5 v5.0.1 github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d diff --git a/go.sum b/go.sum index 6d031f79db..68a0c415cd 100644 --- a/go.sum +++ b/go.sum @@ -1554,6 +1554,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw= github.com/porter-dev/api-contracts v0.2.156 h1:IooB1l6tl+jiGecj2IzYsPoIJxnePaJntDpKSwJBxgc= github.com/porter-dev/api-contracts v0.2.156/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= +github.com/porter-dev/api-contracts v0.2.157 h1:xjC1q4/8ZUl5QLVyCkTfIiMZn+k8h0c9AO9nrCFcZ1Y= +github.com/porter-dev/api-contracts v0.2.157/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M= github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= diff --git a/internal/kubernetes/agent.go b/internal/kubernetes/agent.go index 502a03f07b..32b99fa447 100644 --- a/internal/kubernetes/agent.go +++ b/internal/kubernetes/agent.go @@ -587,6 +587,16 @@ func (a *Agent) GetSecret(name string, namespace string) (*v1.Secret, error) { ) } +// ListServices lists services in a namespace +func (a *Agent) ListServices(ctx context.Context, namespace string, labelSelector string) (*v1.ServiceList, error) { + return a.Clientset.CoreV1().Services(namespace).List( + ctx, + metav1.ListOptions{ + LabelSelector: labelSelector, + }, + ) +} + // CreateSecret creates the secret given its name and namespace func (a *Agent) CreateSecret(secret *v1.Secret, namespace string) (*v1.Secret, error) { _, err := a.Clientset.CoreV1().Secrets(namespace).Get( @@ -1924,7 +1934,6 @@ func (a *Agent) StreamPorterAgentLokiLog( Stdout: rw, Stderr: rw, }) - if err != nil { errorchan <- err return @@ -1984,7 +1993,6 @@ func (a *Agent) CreateImagePullSecrets( }, metav1.CreateOptions{}, ) - if err != nil { return nil, err } diff --git a/internal/telemetry/span.go b/internal/telemetry/span.go index c367686460..04769545e8 100644 --- a/internal/telemetry/span.go +++ b/internal/telemetry/span.go @@ -45,6 +45,10 @@ func AddKnownContextVariablesToSpan(ctx context.Context, span trace.Span) { if project, ok := ctx.Value(types.ProjectScope).(*models.Project); ok { WithAttributes(span, AttributeKV{Key: "project-id", Value: project.ID}) } + + if deploymentTarget, ok := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget); ok { + WithAttributes(span, AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID}) + } } // AttributeKV is a wrapper for otel attributes KV diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 58da796032..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "porter", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 2e67a709e969db0ddb2ce784687974294d8ea39e Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Mon, 29 Apr 2024 14:26:03 -0400 Subject: [PATCH 6/9] remove timestamp for legacy addons on grid view (#4587) --- .../home/add-on-dashboard/AddOnDashboard.tsx | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx b/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx index 4d07be1bee..425e7fb128 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx @@ -20,13 +20,11 @@ import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; import { Context } from "shared/Context"; import { hardcodedIcons } from "shared/hardcodedNameDict"; -import { readableDate } from "shared/string_utils"; import addOnGrad from "assets/add-on-grad.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 DashboardHeader from "../cluster-dashboard/DashboardHeader"; @@ -180,12 +178,6 @@ const AddonDashboard: React.FC = () => { - - - - {readableDate(addon.info.last_deployed)} - - ); } @@ -225,13 +217,6 @@ const AddonDashboard: React.FC = () => { - - - - - {readableDate(addon.info.last_deployed)} - - ); } @@ -354,10 +339,3 @@ const StyledAppDashboard = styled.div` width: 100%; height: 100%; `; - -const SmallIcon = styled.img<{ opacity?: string }>` - margin-left: 2px; - height: 14px; - opacity: ${(props) => props.opacity || 1}; - margin-right: 10px; -`; From cb7a7b06599e06c5936d4ad63986ec7a446fb888 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Mon, 29 Apr 2024 15:07:58 -0400 Subject: [PATCH 7/9] do not alert on reprovision (#4588) --- .../ClusterFormContextProvider.tsx | 21 +++++++++++-------- .../forms/CreateClusterForm.tsx | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx b/dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx index fe405337c3..939c77a9a5 100644 --- a/dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx +++ b/dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx @@ -68,7 +68,7 @@ type ClusterFormContextProviderProps = { projectId?: number; isAdvancedSettingsEnabled?: boolean; isMultiClusterEnabled?: boolean; - redirectOnSubmit?: boolean; + isCreatingCluster?: boolean; children: JSX.Element; }; @@ -76,7 +76,7 @@ const ClusterFormContextProvider: React.FC = ({ projectId, isAdvancedSettingsEnabled = false, isMultiClusterEnabled = false, - redirectOnSubmit, + isCreatingCluster, children, }) => { const history = useHistory(); @@ -179,12 +179,6 @@ const ClusterFormContextProvider: React.FC = ({ setShowFailedPreflightChecksModal(true); } if (response.createContractResponse) { - void reportToAnalytics({ - projectId, - step: "provisioning-started", - provider: data.cluster.cloudProvider, - region: data.cluster.config.region, - }); await api.saveOnboardingState( "", { current_step: "clean_up" }, @@ -192,7 +186,13 @@ const ClusterFormContextProvider: React.FC = ({ ); await queryClient.invalidateQueries(["getCluster"]); - if (redirectOnSubmit) { + if (isCreatingCluster) { + void reportToAnalytics({ + projectId, + step: "provisioning-started", + provider: data.cluster.cloudProvider, + region: data.cluster.config.region, + }); history.push( `/infrastructure/${response.createContractResponse.contract_revision.cluster_id}` ); @@ -230,6 +230,9 @@ const ClusterFormContextProvider: React.FC = ({ cloudProviderCredentialIdentifier: string, region: string ): Promise => { + if (!projectId) { + return []; + } const response = await api.cloudProviderMachineTypes( "", { diff --git a/dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx b/dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx index 4f2f1c0634..431c0f25c3 100644 --- a/dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx +++ b/dashboard/src/main/home/infrastructure-dashboard/forms/CreateClusterForm.tsx @@ -51,9 +51,9 @@ const CreateClusterForm: React.FC = () => { return ( {match(selectedCloudProvider) From f5ef333fc1c8e2cfb3979d7060f1f2502bb1a678 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Mon, 29 Apr 2024 16:01:00 -0400 Subject: [PATCH 8/9] Use pointer for trialspec (#4589) --- api/server/handlers/project/create.go | 1 + api/types/billing_metronome.go | 2 +- internal/billing/metronome.go | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index 8b6e4765d0..27ff346f56 100644 --- a/api/server/handlers/project/create.go +++ b/api/server/handlers/project/create.go @@ -103,6 +103,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) if err != nil { err = telemetry.Error(ctx, span, err, "error creating Metronome customer") p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return } proj.UsageID = customerID proj.UsagePlanID = customerPlanID diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index d321786b35..502afa244a 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -36,7 +36,7 @@ type AddCustomerPlanRequest struct { // NetPaymentTermDays is the number of days after issuance of invoice after which the invoice is due NetPaymentTermDays int `json:"net_payment_terms_days,omitempty"` // Trial is the trial period for the plan - Trial TrialSpec `json:"trial_spec,omitempty"` + Trial *TrialSpec `json:"trial_spec,omitempty"` } // TrialSpec is the trial period for the plan diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index d6ac4f649f..0e9a786332 100644 --- a/internal/billing/metronome.go +++ b/internal/billing/metronome.go @@ -133,7 +133,7 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU } if trialDays != 0 { - req.Trial = types.TrialSpec{ + req.Trial = &types.TrialSpec{ LengthInDays: int64(trialDays), } } From 5099d3adc1f66e6c46fa21780810ee19ba49cf6a Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Tue, 30 Apr 2024 11:58:38 -0400 Subject: [PATCH 9/9] genericize types, add controlled input password view (#4591) --- dashboard/src/assets/eye-off.svg | 1 + dashboard/src/assets/eye.svg | 1 + .../src/components/porter/ControlledInput.tsx | 60 +++++++--- dashboard/src/lib/addons/index.ts | 5 +- dashboard/src/lib/addons/metabase.ts | 2 +- dashboard/src/lib/addons/template.ts | 113 +++++++++++++++--- .../main/home/add-on-dashboard/AddonForm.tsx | 17 ++- .../home/add-on-dashboard/AddonTemplates.tsx | 55 +++++---- .../add-on-dashboard/datadog/DatadogForm.tsx | 4 +- .../metabase/MetabaseForm.tsx | 5 +- .../home/add-on-dashboard/mezmo/MezmoForm.tsx | 4 +- .../newrelic/NewRelicForm.tsx | 6 +- .../tailscale/TailscaleForm.tsx | 2 +- 13 files changed, 199 insertions(+), 76 deletions(-) create mode 100644 dashboard/src/assets/eye-off.svg create mode 100644 dashboard/src/assets/eye.svg diff --git a/dashboard/src/assets/eye-off.svg b/dashboard/src/assets/eye-off.svg new file mode 100644 index 0000000000..c9fc51a35f --- /dev/null +++ b/dashboard/src/assets/eye-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/src/assets/eye.svg b/dashboard/src/assets/eye.svg new file mode 100644 index 0000000000..f2885c1efa --- /dev/null +++ b/dashboard/src/assets/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/src/components/porter/ControlledInput.tsx b/dashboard/src/components/porter/ControlledInput.tsx index 33f4708da0..be33b51502 100644 --- a/dashboard/src/components/porter/ControlledInput.tsx +++ b/dashboard/src/components/porter/ControlledInput.tsx @@ -1,5 +1,12 @@ -import React from "react"; +import React, { useState } from "react"; import styled from "styled-components"; + +import eyeOff from "assets/eye-off.svg"; +import eye from "assets/eye.svg"; + +import Container from "./Container"; +import Icon from "./Icon"; +import Spacer from "./Spacer"; import Tooltip from "./Tooltip"; /* @@ -45,6 +52,11 @@ export const ControlledInput = React.forwardRef< }, ref ) => { + const [isVisible, setIsVisible] = useState(false); + const toggleVisibility = (): void => { + setIsVisible(!isVisible); + }; + return disabled && disabledTooltip ? ( @@ -72,29 +84,41 @@ export const ControlledInput = React.forwardRef< ) : ( - - {label && } - +
+ + + {label && } + + + {type === "password" && ( + <> + +
+ +
+ + )} +
{error && ( error {error} )} - +
); } ); diff --git a/dashboard/src/lib/addons/index.ts b/dashboard/src/lib/addons/index.ts index 9241e8c842..1346720c36 100644 --- a/dashboard/src/lib/addons/index.ts +++ b/dashboard/src/lib/addons/index.ts @@ -57,8 +57,11 @@ export const clientAddonValidator = z.object({ tailscaleConfigValidator, ]), }); +export type ClientAddonType = z.infer< + typeof clientAddonValidator +>["config"]["type"]; export type ClientAddon = z.infer & { - template: AddonTemplate; + template: AddonTemplate; }; export const legacyAddonValidator = z.object({ name: z.string(), diff --git a/dashboard/src/lib/addons/metabase.ts b/dashboard/src/lib/addons/metabase.ts index e38290afa2..d85ef5ed26 100644 --- a/dashboard/src/lib/addons/metabase.ts +++ b/dashboard/src/lib/addons/metabase.ts @@ -8,7 +8,7 @@ export const metabaseConfigValidator = z.object({ datastore: z .object({ host: z.string().nonempty(), - port: z.number(), + port: z.coerce.number(), databaseName: z.string().nonempty(), username: z.string().nonempty(), password: z.string().nonempty(), diff --git a/dashboard/src/lib/addons/template.ts b/dashboard/src/lib/addons/template.ts index 51cdbfc47d..ad43a7b624 100644 --- a/dashboard/src/lib/addons/template.ts +++ b/dashboard/src/lib/addons/template.ts @@ -7,7 +7,7 @@ import NewRelicForm from "main/home/add-on-dashboard/newrelic/NewRelicForm"; import TailscaleForm from "main/home/add-on-dashboard/tailscale/TailscaleForm"; import TailscaleOverview from "main/home/add-on-dashboard/tailscale/TailscaleOverview"; -import { type ClientAddon } from "."; +import { type ClientAddon, type ClientAddonType } from "."; export type AddonTemplateTag = | "Monitoring" @@ -39,34 +39,68 @@ export const DEFAULT_ADDON_TAB = { component: () => null, }; -export type AddonTemplate = { - type: ClientAddon["config"]["type"]; +export type AddonTemplate = { + type: T; displayName: string; description: string; icon: string; tags: AddonTemplateTag[]; tabs: AddonTab[]; // this what is rendered on the dashboard after the addon is deployed + defaultValues: ClientAddon["config"] & { type: T }; }; -export const ADDON_TEMPLATE_REDIS: AddonTemplate = { +export const ADDON_TEMPLATE_REDIS: AddonTemplate<"redis"> = { type: "redis", displayName: "Redis", description: "An in-memory database that persists on disk.", icon: "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png", tags: ["Database"], tabs: [], + defaultValues: { + type: "redis", + cpuCores: { + value: 0.5, + readOnly: false, + }, + ramMegabytes: { + value: 512, + readOnly: false, + }, + storageGigabytes: { + value: 1, + readOnly: false, + }, + password: "", + }, }; -export const ADDON_TEMPLATE_POSTGRES: AddonTemplate = { +export const ADDON_TEMPLATE_POSTGRES: AddonTemplate<"postgres"> = { type: "postgres", displayName: "Postgres", description: "An object-relational database system.", icon: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg", tags: ["Database"], tabs: [], + defaultValues: { + type: "postgres", + cpuCores: { + value: 0.5, + readOnly: false, + }, + ramMegabytes: { + value: 512, + readOnly: false, + }, + storageGigabytes: { + value: 1, + readOnly: false, + }, + username: "postgres", + password: "postgres", + }, }; -export const ADDON_TEMPLATE_DATADOG: AddonTemplate = { +export const ADDON_TEMPLATE_DATADOG: AddonTemplate<"datadog"> = { type: "datadog", displayName: "DataDog", description: @@ -91,9 +125,19 @@ export const ADDON_TEMPLATE_DATADOG: AddonTemplate = { component: Settings, }, ], + defaultValues: { + type: "datadog", + cpuCores: 0.5, + ramMegabytes: 512, + site: "datadoghq.com", + apiKey: "", + loggingEnabled: false, + apmEnabled: false, + dogstatsdEnabled: false, + }, }; -export const ADDON_TEMPLATE_MEZMO: AddonTemplate = { +export const ADDON_TEMPLATE_MEZMO: AddonTemplate<"mezmo"> = { type: "mezmo", displayName: "Mezmo", description: "A popular logging management system.", @@ -117,9 +161,13 @@ export const ADDON_TEMPLATE_MEZMO: AddonTemplate = { component: Settings, }, ], + defaultValues: { + type: "mezmo", + ingestionKey: "", + }, }; -export const ADDON_TEMPLATE_METABASE: AddonTemplate = { +export const ADDON_TEMPLATE_METABASE: AddonTemplate<"metabase"> = { type: "metabase", displayName: "Metabase", description: "An open-source business intelligence tool.", @@ -142,9 +190,22 @@ export const ADDON_TEMPLATE_METABASE: AddonTemplate = { component: Settings, }, ], + defaultValues: { + type: "metabase", + exposedToExternalTraffic: true, + porterDomain: "", + customDomain: "", + datastore: { + host: "", + port: 0, + databaseName: "", + username: "", + password: "", + }, + }, }; -export const ADDON_TEMPLATE_NEWRELIC: AddonTemplate = { +export const ADDON_TEMPLATE_NEWRELIC: AddonTemplate<"newrelic"> = { type: "newrelic", displayName: "New Relic", description: "Monitor your applications and infrastructure.", @@ -168,9 +229,21 @@ export const ADDON_TEMPLATE_NEWRELIC: AddonTemplate = { component: Settings, }, ], + defaultValues: { + type: "newrelic", + licenseKey: "", + insightsKey: "", + personalApiKey: "", + accountId: "", + loggingEnabled: false, + kubeEventsEnabled: false, + metricsAdapterEnabled: false, + prometheusEnabled: false, + pixieEnabled: false, + }, }; -export const ADDON_TEMPLATE_TAILSCALE: AddonTemplate = { +export const ADDON_TEMPLATE_TAILSCALE: AddonTemplate<"tailscale"> = { type: "tailscale", displayName: "Tailscale", description: "A VPN for your applications and datastores.", @@ -199,12 +272,18 @@ export const ADDON_TEMPLATE_TAILSCALE: AddonTemplate = { component: Settings, }, ], + defaultValues: { + type: "tailscale", + authKey: "", + subnetRoutes: [], + }, }; -export const SUPPORTED_ADDON_TEMPLATES: AddonTemplate[] = [ - ADDON_TEMPLATE_DATADOG, - ADDON_TEMPLATE_MEZMO, - ADDON_TEMPLATE_METABASE, - ADDON_TEMPLATE_NEWRELIC, - ADDON_TEMPLATE_TAILSCALE, -]; +export const SUPPORTED_ADDON_TEMPLATES: Array> = + [ + ADDON_TEMPLATE_DATADOG, + ADDON_TEMPLATE_MEZMO, + ADDON_TEMPLATE_METABASE, + ADDON_TEMPLATE_NEWRELIC, + ADDON_TEMPLATE_TAILSCALE, + ]; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx b/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx index b93737e0ec..2303e32955 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx @@ -9,7 +9,7 @@ import { ControlledInput } from "components/porter/ControlledInput"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import VerticalSteps from "components/porter/VerticalSteps"; -import { defaultClientAddon, type ClientAddon } from "lib/addons"; +import { type ClientAddon, type ClientAddonType } from "lib/addons"; import { type AddonTemplate } from "lib/addons/template"; import { useAddonList } from "lib/hooks/useAddon"; import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; @@ -20,10 +20,12 @@ import DashboardHeader from "../cluster-dashboard/DashboardHeader"; import ClusterContextProvider from "../infrastructure-dashboard/ClusterContextProvider"; import Configuration from "./common/Configuration"; -type Props = { - template: AddonTemplate; +type Props = { + template: AddonTemplate; }; -const AddonForm: React.FC = ({ template }) => { +const AddonForm = ({ + template, +}: Props): JSX.Element => { const { currentProject, currentCluster } = useContext(Context); const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } = useDefaultDeploymentTarget(); @@ -56,7 +58,12 @@ const AddonForm: React.FC = ({ template }) => { }, [watchName]); useEffect(() => { - reset(defaultClientAddon(template.type)); + reset({ + expanded: true, + name: { readOnly: false, value: template.type }, + config: template.defaultValues, + template, + }); }, [template]); useEffect(() => { diff --git a/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx b/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx index 0945c20f00..0b1122cac2 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx @@ -4,6 +4,7 @@ import styled from "styled-components"; import Back from "components/porter/Back"; import Spacer from "components/porter/Spacer"; +import { type ClientAddonType } from "lib/addons"; import { AddonTemplateTagColor, SUPPORTED_ADDON_TEMPLATES, @@ -47,31 +48,35 @@ const AddonTemplates: React.FC = () => { disableLineBreak /> - {SUPPORTED_ADDON_TEMPLATES.map((template: AddonTemplate) => { - return ( - { - history.push(`/addons/new?addon_name=${template.type}`); - }} - > - - {template.displayName} - {template.description} - - {template.tags.map((t) => ( - - {t} - - ))} - - ); - })} + {SUPPORTED_ADDON_TEMPLATES.map( + (template: AddonTemplate) => { + return ( + { + history.push(`/addons/new?addon_name=${template.type}`); + }} + > + + {template.displayName} + + {template.description} + + + {template.tags.map((t) => ( + + {t} + + ))} + + ); + } + )} ); diff --git a/dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx b/dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx index 03ac2573b6..cfed959394 100644 --- a/dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx +++ b/dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx @@ -39,10 +39,10 @@ const DatadogForm: React.FC = () => { DataDog API Key diff --git a/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx b/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx index cf53c1792f..09d6fabf78 100644 --- a/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx +++ b/dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx @@ -133,6 +133,7 @@ const MetabaseDatastoreConnection: React.FC = () => { type="text" width="600px" {...register("config.datastore.host")} + placeholder="my-host.com" error={errors.config?.datastore?.host?.message} /> @@ -174,6 +175,7 @@ const MetabaseDatastoreConnection: React.FC = () => { type="text" width="600px" {...register("config.datastore.username")} + placeholder="my-username" error={errors.config?.datastore?.username?.message} /> @@ -184,9 +186,10 @@ const MetabaseDatastoreConnection: React.FC = () => { diff --git a/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx b/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx index 2984263319..1455e88b2f 100644 --- a/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx +++ b/dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx @@ -28,10 +28,10 @@ const MezmoForm: React.FC = () => { Ingestion Key diff --git a/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx b/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx index 7e07d4f8f7..e8b1122d5a 100644 --- a/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx +++ b/dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx @@ -28,7 +28,7 @@ const NewRelicForm: React.FC = () => { NewRelic License Key { NewRelic Insights Key { NewRelic Personal API Key {