diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 4f69dd136e..89d93ac0ea 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -100,7 +100,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.164", + "@porter-dev/api-contracts": "^0.2.168", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", @@ -2786,9 +2786,9 @@ } }, "node_modules/@porter-dev/api-contracts": { - "version": "0.2.164", - "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.164.tgz", - "integrity": "sha512-yq3rX6YVbTFCTh4p1UdXNSUHsSD/ED0M5JdhaRee9PRBAJ5wsNg2FUXN4zYcOM3e5qqmgRwF25RFc9jm4wKthQ==", + "version": "0.2.168", + "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.168.tgz", + "integrity": "sha512-z1TRl5wEsNtR49Aj8ZeJstBxuY/bsL1F9+o4Y4r4n1Y+yIFwDw+6LzJCrb8v0WktL+L7XUEa61MtE0mxVTWGTw==", "dev": true, "dependencies": { "@bufbuild/protobuf": "^1.1.0" diff --git a/dashboard/package.json b/dashboard/package.json index 4be9258892..38decc97bb 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -107,7 +107,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.164", + "@porter-dev/api-contracts": "^0.2.168", "@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/assets/inference-grad.svg b/dashboard/src/assets/inference-grad.svg new file mode 100644 index 0000000000..ff7bbb8ae0 --- /dev/null +++ b/dashboard/src/assets/inference-grad.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dashboard/src/assets/inference.png b/dashboard/src/assets/inference.png new file mode 100644 index 0000000000..6d96158288 Binary files /dev/null and b/dashboard/src/assets/inference.png differ diff --git a/dashboard/src/assets/inference.svg b/dashboard/src/assets/inference.svg new file mode 100644 index 0000000000..d8fcc342c8 --- /dev/null +++ b/dashboard/src/assets/inference.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/src/assets/llm.svg b/dashboard/src/assets/llm.svg new file mode 100644 index 0000000000..f68a39de14 --- /dev/null +++ b/dashboard/src/assets/llm.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/src/components/porter/DashboardHeader.tsx b/dashboard/src/components/porter/DashboardHeader.tsx new file mode 100644 index 0000000000..1807cc3045 --- /dev/null +++ b/dashboard/src/components/porter/DashboardHeader.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import styled from "styled-components"; + +import TitleSection from "../TitleSection"; +import Container from "./Container"; +import Spacer from "./Spacer"; +import Tooltip from "./Tooltip"; + +type Props = { + image?: string; + title: React.ReactNode; + description?: string; + materialIconClass?: string; + capitalize?: boolean; + prefix?: React.ReactNode; +}; + +const DashboardHeader: React.FC = ({ + image, + title, + description, + materialIconClass, + capitalize, + prefix, +}) => { + return ( + <> + + {prefix} + + {title} + + + + {description && ( + <> + + + + + + {description} + + + )} + + + ); +}; + +export default DashboardHeader; + +const TopRow = styled.div` + display: flex; + align-items: center; +`; + +const Description = styled.div` + color: #aaaabb; + margin-top: 13px; + margin-left: 1px; + font-size: 13px; +`; + +const InfoLabel = styled.div` + width: 72px; + height: 20px; + display: flex; + align-items: center; + color: #aaaabb; + font-size: 13px; + > i { + color: #aaaabb; + font-size: 18px; + margin-right: 5px; + } +`; + +const InfoSection = styled.div` + font-family: "Work Sans", sans-serif; + margin-left: 0px; +`; diff --git a/dashboard/src/components/porter/Dropdown.tsx b/dashboard/src/components/porter/Dropdown.tsx index a215b834c6..7b28ea7163 100644 --- a/dashboard/src/components/porter/Dropdown.tsx +++ b/dashboard/src/components/porter/Dropdown.tsx @@ -4,8 +4,6 @@ import styled, { keyframes } from "styled-components"; import Container from "./Container"; import Spacer from "./Spacer"; -import Tag from "./Tag"; -import Text from "./Text"; type Props = { key: string; diff --git a/dashboard/src/components/porter/Tag.tsx b/dashboard/src/components/porter/Tag.tsx index 2ca8320660..65812170d4 100644 --- a/dashboard/src/components/porter/Tag.tsx +++ b/dashboard/src/components/porter/Tag.tsx @@ -2,6 +2,7 @@ import React from "react"; import styled from "styled-components"; type Props = { + size?: number; backgroundColor?: string; children: React.ReactNode; hoverable?: boolean; @@ -13,6 +14,7 @@ type Props = { }; const Tag: React.FC = ({ + size, backgroundColor, hoverable = true, hoverColor, @@ -23,6 +25,7 @@ const Tag: React.FC = ({ }) => { return ( = ({ export default Tag; const StyledTag = styled.div<{ + size?: number; hoverable: boolean; backgroundColor: string; hoverColor: string; @@ -46,7 +50,7 @@ const StyledTag = styled.div<{ }>` display: flex; justify-content: center; - font-size: 13px; + font-size: ${({ size }) => size ?? 13}px; padding: 3px 5px; border-radius: ${({ borderRadiusPixels }) => borderRadiusPixels}px; background: ${({ backgroundColor }) => backgroundColor}; diff --git a/dashboard/src/components/porter/Toggle.tsx b/dashboard/src/components/porter/Toggle.tsx index 4a10756771..01dba6674c 100644 --- a/dashboard/src/components/porter/Toggle.tsx +++ b/dashboard/src/components/porter/Toggle.tsx @@ -46,14 +46,26 @@ const StyledToggle = styled.div` align-items: center; `; -const Item = styled.div<{ active: boolean; activeColor?: string; inactiveColor?: string }>` +const Item = styled.div<{ + active: boolean; + activeColor?: string; + inactiveColor?: string; +}>` display: flex; align-items: center; height: 100%; cursor: pointer; justify-content: center; padding: 10px; - opacity: ${(props) => props.active ? "1" : "0.4"}; + opacity: ${(props) => (props.active ? "1" : "0.4")}; background: ${(props) => - props.active ? props.activeColor ?? "#ffffff11" : props.inactiveColor ?? "transparent"}; -`; \ No newline at end of file + props.active + ? props.activeColor ?? "#ffffff11" + : props.inactiveColor ?? "transparent"}; +`; + +export const ToggleIcon = styled.img` + height: 12px; + margin: 0 5px; + min-width: 12px; +`; diff --git a/dashboard/src/lib/addons/deepgram.ts b/dashboard/src/lib/addons/deepgram.ts new file mode 100644 index 0000000000..6bc7572d1b --- /dev/null +++ b/dashboard/src/lib/addons/deepgram.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +const modelUrlValidator = z.object({ + url: z.string().nonempty(), +}); +export const deepgramConfigValidator = z.object({ + type: z.literal("deepgram"), + deepgramAPIKey: z.string().nonempty().default("*********"), + quayUsername: z.string().nonempty().default("username"), + quaySecret: z.string().nonempty().default("secret"), + quayEmail: z.string().nonempty().default(""), + releaseTag: z.string().nonempty().default("release-240426"), + modelUrls: z.array(modelUrlValidator).default([]), +}); + +export type DeepgramConfigValidator = z.infer; diff --git a/dashboard/src/lib/addons/index.ts b/dashboard/src/lib/addons/index.ts index 89e04281fd..5fc4a1a945 100644 --- a/dashboard/src/lib/addons/index.ts +++ b/dashboard/src/lib/addons/index.ts @@ -3,6 +3,7 @@ import { Addon, AddonType, Datadog, + Deepgram, Metabase, Mezmo, Newrelic, @@ -17,6 +18,7 @@ import { z } from "zod"; import { serviceStringValidator } from "lib/porter-apps/values"; import { datadogConfigValidator } from "./datadog"; +import { deepgramConfigValidator } from "./deepgram"; import { metabaseConfigValidator } from "./metabase"; import { mezmoConfigValidator } from "./mezmo"; import { newrelicConfigValidator } from "./newrelic"; @@ -26,6 +28,7 @@ import { redisConfigValidator } from "./redis"; import { tailscaleConfigValidator } from "./tailscale"; import { ADDON_TEMPLATE_DATADOG, + ADDON_TEMPLATE_DEEPGRAM, ADDON_TEMPLATE_METABASE, ADDON_TEMPLATE_MEZMO, ADDON_TEMPLATE_NEWRELIC, @@ -58,6 +61,7 @@ export const clientAddonValidator = z.object({ metabaseConfigValidator, newrelicConfigValidator, tailscaleConfigValidator, + deepgramConfigValidator, quivrConfigValidator, ]), }); @@ -67,6 +71,13 @@ export type ClientAddonType = z.infer< export type ClientAddon = z.infer & { template: AddonTemplate; }; +export type ClientModelAddon = ClientAddon & { + template: AddonTemplate & { isModelTemplate: true }; +}; +export const isClientModelAddon = ( + addon: ClientAddon +): addon is ClientModelAddon => addon.template.isModelTemplate ?? false; + export const legacyAddonValidator = z.object({ name: z.string(), namespace: z.string(), @@ -157,6 +168,16 @@ export function defaultClientAddon( }), template: ADDON_TEMPLATE_TAILSCALE, })) + .with("deepgram", () => ({ + ...clientAddonValidator.parse({ + expanded: true, + name: { readOnly: false, value: "deepgram" }, + config: deepgramConfigValidator.parse({ + type: "deepgram", + }), + }), + template: ADDON_TEMPLATE_DEEPGRAM, + })) .with("quivr", () => ({ ...clientAddonValidator.parse({ expanded: true, @@ -179,6 +200,7 @@ function addonTypeEnumProto(type: ClientAddon["config"]["type"]): AddonType { .with("metabase", () => AddonType.METABASE) .with("newrelic", () => AddonType.NEWRELIC) .with("tailscale", () => AddonType.TAILSCALE) + .with("deepgram", () => AddonType.DEEPGRAM) .with("quivr", () => AddonType.QUIVR) .exhaustive(); } @@ -269,6 +291,17 @@ export function clientAddonToProto( }), case: "tailscale" as const, })) + .with({ type: "deepgram" }, (data) => ({ + value: new Deepgram({ + apiKey: data.deepgramAPIKey, + ecrUsername: data.quayUsername, + ecrPassword: data.quaySecret, + ecrEmail: data.quayEmail, + releaseTag: data.releaseTag, + modelUrls: data.modelUrls.map(({ url }) => url), + }), + case: "deepgram" as const, + })) .with({ type: "quivr" }, (data) => ({ value: new Quivr({ ingressEnabled: data.exposedToExternalTraffic, @@ -405,6 +438,15 @@ export function clientAddonFromProto({ authKey: data.value.authKey ?? "", subnetRoutes: data.value.subnetRoutes.map((r) => ({ route: r })), })) + .with({ case: "deepgram" }, (data) => ({ + type: "deepgram" as const, + deepgramAPIKey: data.value.apiKey ?? "", + quayUsername: data.value.ecrUsername ?? "", + quaySecret: data.value.ecrPassword ?? "", + quayEmail: data.value.ecrEmail ?? "", + releaseTag: data.value.releaseTag ?? "", + modelUrls: data.value.modelUrls.map((url) => ({ url })) ?? [], + })) .with({ case: "quivr" }, (data) => ({ type: "quivr" as const, exposedToExternalTraffic: data.value.ingressEnabled ?? false, @@ -434,6 +476,7 @@ export function clientAddonFromProto({ .with({ case: "metabase" }, () => ADDON_TEMPLATE_METABASE) .with({ case: "newrelic" }, () => ADDON_TEMPLATE_NEWRELIC) .with({ case: "tailscale" }, () => ADDON_TEMPLATE_TAILSCALE) + .with({ case: "deepgram" }, () => ADDON_TEMPLATE_DEEPGRAM) .with({ case: "quivr" }, () => ADDON_TEMPLATE_QUIVR) .exhaustive(); diff --git a/dashboard/src/lib/addons/template.ts b/dashboard/src/lib/addons/template.ts index ba409ced3c..2016fe6d7e 100644 --- a/dashboard/src/lib/addons/template.ts +++ b/dashboard/src/lib/addons/template.ts @@ -1,6 +1,8 @@ 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 DeepgramForm from "main/home/add-on-dashboard/deepgram/DeepgramForm"; +import DeepgramOverview from "main/home/add-on-dashboard/deepgram/DeepgramOverview"; 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"; @@ -50,6 +52,7 @@ export type AddonTemplate = { tags: AddonTemplateTag[]; tabs: AddonTab[]; // this what is rendered on the dashboard after the addon is deployed defaultValues: ClientAddon["config"] & { type: T }; + isModelTemplate?: boolean; }; export const ADDON_TEMPLATE_REDIS: AddonTemplate<"redis"> = { @@ -282,6 +285,46 @@ export const ADDON_TEMPLATE_TAILSCALE: AddonTemplate<"tailscale"> = { }, }; +export const ADDON_TEMPLATE_DEEPGRAM: AddonTemplate<"deepgram"> = { + type: "deepgram", + displayName: "Deepgram", + description: "A popular speech-to-text service.", + icon: "https://play-lh.googleusercontent.com/wczDL05-AOb39FcL58L32h6j_TrzzGTXDLlOrOmJ-aNsnoGsT1Gkk2vU4qyTb7tGxRw=w240-h480-rw", + tags: ["Networking"], + tabs: [ + { + name: "overview", + displayName: "Overview", + component: DeepgramOverview, + }, + { + name: "configuration", + displayName: "Configuration", + component: DeepgramForm, + }, + { + name: "logs", + displayName: "Logs", + component: Logs, + isOnlyForPorterOperators: true, + }, + { + name: "settings", + displayName: "Settings", + component: Settings, + }, + ], + defaultValues: { + type: "deepgram", + deepgramAPIKey: "", + quayUsername: "", + quaySecret: "", + quayEmail: "", + releaseTag: "", + modelUrls: [{ url: "" }], + }, + isModelTemplate: true, +}; export const ADDON_TEMPLATE_QUIVR: AddonTemplate<"quivr"> = { type: "quivr", displayName: "Quivr", @@ -331,3 +374,7 @@ export const SUPPORTED_ADDON_TEMPLATES: Array> = ADDON_TEMPLATE_TAILSCALE, ADDON_TEMPLATE_QUIVR, ]; + +export const SUPPORTED_MODEL_ADDON_TEMPLATES: Array< + AddonTemplate +> = [ADDON_TEMPLATE_DEEPGRAM]; diff --git a/dashboard/src/lib/hooks/useAddon.ts b/dashboard/src/lib/hooks/useAddon.ts index be173a5fa2..a42e1f055a 100644 --- a/dashboard/src/lib/hooks/useAddon.ts +++ b/dashboard/src/lib/hooks/useAddon.ts @@ -25,9 +25,11 @@ import { type DeploymentTarget } from "./useDeploymentTarget"; export const useAddonList = ({ projectId, deploymentTarget, + includeLegacyAddons = true, }: { projectId?: number; deploymentTarget?: DeploymentTarget; + includeLegacyAddons?: boolean; }): { addons: ClientAddon[]; legacyAddons: LegacyClientAddon[]; @@ -144,7 +146,11 @@ export const useAddonList = ({ }); }, { - enabled: !!projectId && projectId !== -1 && !!deploymentTarget, + enabled: + !!projectId && + projectId !== -1 && + !!deploymentTarget && + includeLegacyAddons, refetchOnWindowFocus: false, refetchInterval: 5000, } diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index b2ff094b4a..8f2fc6aa96 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -41,6 +41,7 @@ import OryLogin from "../auth/OryLogin"; import AddonDashboard from "./add-on-dashboard/AddOnDashboard"; import AddonTemplates from "./add-on-dashboard/AddonTemplates"; import AddonView from "./add-on-dashboard/AddonView"; +import InferenceDashboard from "./add-on-dashboard/InferenceDashboard"; import LegacyAddOnDashboard from "./add-on-dashboard/legacy_AddOnDashboard"; import LegacyNewAddOnFlow from "./add-on-dashboard/legacy_NewAddOnFlow"; import AppView from "./app-dashboard/app-view/AppView"; @@ -553,6 +554,18 @@ const Home: React.FC = (props) => { )} + + + + + + + + + + + + { diff --git a/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx b/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx index 425e7fb128..8786062ad2 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx @@ -29,8 +29,15 @@ import healthy from "assets/status-healthy.png"; import DashboardHeader from "../cluster-dashboard/DashboardHeader"; // filter out postgres and redis addons because those are managed in the datastores tab now -const isDisplayableAddon = (addon: ClientAddon): boolean => { - return addon.config.type !== "postgres" && addon.config.type !== "redis"; +// filter out model template addons because those are managed in the infrastructure tab +const isDisplayableAddon = ( + addon: ClientAddon | LegacyClientAddon +): boolean => { + return "chart" in addon + ? true + : addon.config.type !== "postgres" && + addon.config.type !== "redis" && + !addon.template.isModelTemplate; }; const AddonDashboard: React.FC = () => { @@ -52,20 +59,19 @@ const AddonDashboard: React.FC = () => { }); 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; }); // If an addon name exists in both the legacy and new addon lists, show the new addon - const uniqueAddons: Array = [ - ...displayableAddons, + const uniqueDisplayableAddons: Array = [ + ...addons, ...legacyDisplayableAddons.filter( - (a) => !displayableAddons.some((b) => b.name.value === a.name) + (a) => !addons.some((b) => b.name.value === a.name) ), - ]; + ].filter(isDisplayableAddon); - return uniqueAddons; + return uniqueDisplayableAddons; }, [addons, legacyAddons, defaultDeploymentTarget]); return ( diff --git a/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx b/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx index 2303e32955..43031c2884 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonForm.tsx @@ -22,9 +22,11 @@ import Configuration from "./common/Configuration"; type Props = { template: AddonTemplate; + filterModels?: boolean; }; const AddonForm = ({ template, + filterModels, }: Props): JSX.Element => { const { currentProject, currentCluster } = useContext(Context); const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } = @@ -72,7 +74,9 @@ const AddonForm = ({ legacyAddons.some((a) => a.name === watchName) ) { setError("name.value", { - message: "An addon with this name already exists", + message: filterModels + ? "A model with this name already exists" + : "An addon with this name already exists", }); } else { clearErrors("name.value"); @@ -94,7 +98,7 @@ const AddonForm = ({ { - history.push(`/addons/new`); + history.push(filterModels ? `/inference/new` : `/addons/new`); }} /> ({ currentStep={currentStep} steps={[ <> - Add-on name + + {filterModels ? "Model name" : "Add-on name"} + Lowercase letters, numbers, and "-" only. @@ -117,7 +123,7 @@ const AddonForm = ({ diff --git a/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx b/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx index 4789d9165b..6e902e66f2 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx @@ -1,5 +1,6 @@ import React, { createContext, useMemo, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Contract } from "@porter-dev/api-contracts"; import { useQueryClient } from "@tanstack/react-query"; import { FormProvider, useForm } from "react-hook-form"; import { useHistory } from "react-router"; @@ -8,11 +9,20 @@ 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 { updateExistingClusterContract } from "lib/clusters"; +import { type ClientPreflightCheck } from "lib/clusters/types"; import { useAddon } from "lib/hooks/useAddon"; -import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster"; +import { + getErrorMessageFromNetworkCall, + preflightChecks, +} from "lib/hooks/useCluster"; import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; -import { type UpdateClusterButtonProps } from "../infrastructure-dashboard/ClusterFormContextProvider"; +import { useClusterContext } from "../infrastructure-dashboard/ClusterContextProvider"; +import ClusterFormContextProvider, { + type UpdateClusterButtonProps, +} from "../infrastructure-dashboard/ClusterFormContextProvider"; +import PreflightChecksModal from "../infrastructure-dashboard/modals/PreflightChecksModal"; type AddonFormContextType = { updateAddonButtonProps: UpdateClusterButtonProps; @@ -43,6 +53,12 @@ const AddonFormContextProvider: React.FC = ({ children, }) => { const [updateAddonError, setUpdateAddonError] = useState(""); + const [failingPreflightChecks, setFailingPreflightChecks] = useState< + ClientPreflightCheck[] + >([]); + const [isCheckingQuotas, setIsCheckingQuotas] = useState(false); + const { cluster } = useClusterContext(); + const { defaultDeploymentTarget } = useDefaultDeploymentTarget(); const { updateAddon } = useAddon(); const queryClient = useQueryClient(); @@ -57,12 +73,81 @@ const AddonFormContextProvider: React.FC = ({ formState: { isSubmitting, errors }, } = addonForm; + const modelAddonPreflightChecks = async ( + addon: ClientAddon, + projectId: number + ): Promise => { + // TODO: figure out why data.template is undefined here. If it is defined, we can use data.template.isModelTemplate instead of hardcoding the type + if ( + addon.config.type === "deepgram" && + cluster.contract?.config && + cluster.contract.config.cluster.cloudProvider === "AWS" + ) { + let clientContract = cluster.contract.config; + if ( + !clientContract.cluster.config.nodeGroups.some( + (n) => n.nodeGroupType === "CUSTOM" + ) + ) { + clientContract = { + ...clientContract, + cluster: { + ...clientContract.cluster, + config: { + ...clientContract.cluster.config, + nodeGroups: [ + ...clientContract.cluster.config.nodeGroups, + { + nodeGroupType: "CUSTOM", + instanceType: "g4dn.xlarge", + minInstances: 0, + maxInstances: 1, + }, + ], + }, + }, + }; + } + const contract = Contract.fromJsonString( + atob(cluster.contract.base64_contract), + { + ignoreUnknownFields: true, + } + ); + const contractCluster = contract.cluster; + if (contractCluster) { + const newContract = new Contract({ + ...contract, + cluster: updateExistingClusterContract( + clientContract, + contractCluster + ), + }); + setIsCheckingQuotas(true); + const preflightCheckResults = await preflightChecks( + newContract, + projectId + ); + return preflightCheckResults; + } + } + }; + const onSubmit = handleSubmit(async (data) => { if (!projectId) { return; } + setFailingPreflightChecks([]); setUpdateAddonError(""); try { + const preflightCheckResults = await modelAddonPreflightChecks( + data, + projectId + ); + if (preflightCheckResults) { + setFailingPreflightChecks(preflightCheckResults); + } + setIsCheckingQuotas(false); await updateAddon({ projectId, deploymentTargetId: defaultDeploymentTarget.id, @@ -91,6 +176,9 @@ const AddonFormContextProvider: React.FC = ({ props.status = "loading"; props.isDisabled = true; } + if (isCheckingQuotas) { + props.loadingText = "Checking quotas..."; + } if (updateAddonError) { props.status = ( @@ -103,7 +191,7 @@ const AddonFormContextProvider: React.FC = ({ } return props; - }, [isSubmitting, errors, errors?.name?.value]); + }, [isSubmitting, errors, errors?.name?.value, isCheckingQuotas]); if (!projectId) { return ; @@ -120,6 +208,16 @@ const AddonFormContextProvider: React.FC = ({
{children}
+ {failingPreflightChecks.length > 0 && ( + + { + setFailingPreflightChecks([]); + }} + preflightChecks={failingPreflightChecks} + /> + + )} ); diff --git a/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx b/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx index 0b1122cac2..d64c99f870 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx @@ -8,75 +8,100 @@ import { type ClientAddonType } from "lib/addons"; import { AddonTemplateTagColor, SUPPORTED_ADDON_TEMPLATES, + SUPPORTED_MODEL_ADDON_TEMPLATES, type AddonTemplate, } from "lib/addons/template"; import { Context } from "shared/Context"; import addOnGrad from "assets/add-on-grad.svg"; +import inferenceGrad from "assets/inference-grad.svg"; import DashboardHeader from "../cluster-dashboard/DashboardHeader"; +import ClusterContextProvider from "../infrastructure-dashboard/ClusterContextProvider"; import AddonForm from "./AddonForm"; import AddonFormContextProvider from "./AddonFormContextProvider"; -const AddonTemplates: React.FC = () => { - const { currentProject } = useContext(Context); +type Props = { + filterModels?: boolean; +}; +const AddonTemplates: React.FC = ({ filterModels }) => { + const { currentProject, currentCluster } = 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); + const addonName = filterModels + ? queryParams.get("model_name") + : queryParams.get("addon_name"); + return ( + filterModels ? SUPPORTED_MODEL_ADDON_TEMPLATES : 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} - - ))} - - ); - } - )} + {(filterModels + ? SUPPORTED_MODEL_ADDON_TEMPLATES + : SUPPORTED_ADDON_TEMPLATES + ).map((template: AddonTemplate) => { + return ( + { + history.push( + filterModels + ? `/inference/new?model_name=${template.type}` + : `/addons/new?addon_name=${template.type}` + ); + }} + > + + {template.displayName} + {template.description} + + {template.tags.map((t) => ( + + {t} + + ))} + + ); + })} ); @@ -89,7 +114,11 @@ const StyledTemplateComponent = styled.div` height: 100%; `; -const Tag = styled.div<{ size?: string; bottom?: string; left?: string }>` +export const Tag = styled.div<{ + size?: string; + bottom?: string; + left?: string; +}>` position: absolute; bottom: ${(props) => props.bottom || "auto"}; left: ${(props) => props.left || "auto"}; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonView.tsx b/dashboard/src/main/home/add-on-dashboard/AddonView.tsx index 5f28c9f08c..7cd2f68f97 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonView.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonView.tsx @@ -14,9 +14,11 @@ import AddonFormContextProvider from "./AddonFormContextProvider"; import AddonHeader from "./AddonHeader"; import AddonTabs from "./AddonTabs"; -type Props = RouteComponentProps; +type Props = RouteComponentProps & { + filterModels?: boolean; +}; -const AddonView: React.FC = ({ match }) => { +const AddonView: React.FC = ({ match, filterModels }) => { const { currentProject, currentCluster } = useContext(Context); const params = useMemo(() => { const { params } = match; @@ -41,7 +43,7 @@ const AddonView: React.FC = ({ match }) => { - + diff --git a/dashboard/src/main/home/add-on-dashboard/InferenceDashboard.tsx b/dashboard/src/main/home/add-on-dashboard/InferenceDashboard.tsx new file mode 100644 index 0000000000..c56fa7a729 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/InferenceDashboard.tsx @@ -0,0 +1,232 @@ +import React, { useContext, useMemo, useState } from "react"; +import styled from "styled-components"; +import { match, P } from "ts-pattern"; + +import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder"; +import Loading from "components/Loading"; +import Button from "components/porter/Button"; +import Container from "components/porter/Container"; +import DashboardHeader from "components/porter/DashboardHeader"; +import DashboardPlaceholder from "components/porter/DashboardPlaceholder"; +import Icon from "components/porter/Icon"; +import Link from "components/porter/Link"; +import SearchBar from "components/porter/SearchBar"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import Toggle, { ToggleIcon } from "components/porter/Toggle"; +import { isClientModelAddon, type ClientModelAddon } from "lib/addons"; +import { useAddonList } from "lib/hooks/useAddon"; +import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; + +import { Context } from "shared/Context"; +import grid from "assets/grid.png"; +import inferenceGrad from "assets/inference-grad.svg"; +import list from "assets/list.png"; +import healthy from "assets/status-healthy.png"; + +const InferenceDashboard: React.FC = () => { + const { currentProject, currentCluster } = useContext(Context); + const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } = + useDefaultDeploymentTarget(); + + const [searchValue, setSearchValue] = useState(""); + const [view, setView] = useState<"grid" | "list">("grid"); + + const { addons, isLoading: isAddonListLoading } = useAddonList({ + projectId: currentProject?.id, + deploymentTarget: defaultDeploymentTarget, + includeLegacyAddons: false, + }); + + const models: ClientModelAddon[] = useMemo(() => { + const modelAddons = addons.filter(isClientModelAddon); + + return modelAddons; + }, [addons, defaultDeploymentTarget]); + + return ( + + + Inference + + Beta + + } + capitalize={false} + description="Run open source ML models in your own cloud." + /> + {match([ + currentCluster?.status, + models.length, + isAddonListLoading || isDefaultDeploymentTargetLoading, + ]) + .with(["UPDATING_UNAVAILABLE", P._, P._], () => ( + + )) + .with([P._, P.number, true], () => ) + .with([P._, 0, false], () => ( + + No ML models have been deployed yet + + Get started by deploying a model. + + + + + + )) + .otherwise(() => ( +
+ + + + , value: "grid" }, + { label: , value: "list" }, + ]} + active={view} + setActive={(s: string) => { + setView(s as "grid" | "list"); + }} + /> + + + + + + + {match(view) + .with("grid", () => ( +
+ {models.map((model) => ( + + + + {model.name.value} + + + + + ))} +
+ )) + .with("list", () => ( +
+ {models.map((model) => ( + + + + {model.name.value} + + + + + ))} +
+ )) + .exhaustive()} +
+ ))} +
+ ); +}; + +export default InferenceDashboard; + +const Badge = styled.div` + background: linear-gradient(60deg, #4b366d 0%, #6475b9 100%); + color: white; + border-radius: 3px; + padding: 2px 5px; + margin-right: -5px; + font-size: 13px; +`; + +const StyledInferenceDashboard = styled.div` + width: 100%; + height: 100%; +`; + +const I = styled.i` + color: white; + font-size: 14px; + display: flex; + align-items: center; + margin-right: 5px; + justify-content: center; +`; + +const Block = styled(Link)` + height: 75px; + flex-direction: column; + display: flex; + justify-content: space-between; + cursor: pointer; + padding: 20px; + color: ${(props) => props.theme.text.primary}; + 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 StatusIcon = styled.img` + position: absolute; + top: 20px; + right: 20px; + height: 18px; +`; + +const Row = styled(Link)<{ isAtBottom?: boolean }>` + cursor: pointer; + display: block; + padding: 15px; + border-bottom: ${(props) => + props.isAtBottom ? "none" : "1px solid #494b4f"}; + background: ${(props) => props.theme.clickable.bg}; + position: relative; + border: 1px solid #494b4f; + border-radius: 5px; + margin-bottom: 15px; + animation: fadeIn 0.3s 0s; +`; + +const MidIcon = styled.img<{ height?: string }>` + height: ${(props) => props.height || "18px"}; + margin-right: 11px; +`; diff --git a/dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx b/dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx index 92f0afa50c..8889656e96 100644 --- a/dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx +++ b/dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx @@ -1,6 +1,7 @@ import React from "react"; import { match } from "ts-pattern"; +import DeepgramForm from "main/home/add-on-dashboard/deepgram/DeepgramForm"; import { type ClientAddon } from "lib/addons"; import DatadogForm from "../datadog/DatadogForm"; @@ -21,6 +22,7 @@ const Configuration: React.FC = ({ type }) => { .with("metabase", () => ) .with("newrelic", () => ) .with("tailscale", () => ) + .with("deepgram", () => ) .with("quivr", () => ) .otherwise(() => null); }; diff --git a/dashboard/src/main/home/add-on-dashboard/deepgram/DeepgramForm.tsx b/dashboard/src/main/home/add-on-dashboard/deepgram/DeepgramForm.tsx new file mode 100644 index 0000000000..2712a34e05 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/deepgram/DeepgramForm.tsx @@ -0,0 +1,169 @@ +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 AddonSaveButton from "main/home/add-on-dashboard/AddonSaveButton"; +import { type ClientAddon } from "lib/addons"; + +const DeepgramForm: React.FC = () => { + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + const { + append, + fields: urls, + remove, + } = useFieldArray({ + control, + name: "config.modelUrls", + }); + + return ( +
+ Release tag + + + The release tag for the specific deepgram model you would like to deploy + + + + + Deepgram API Key + + The API Key provided to you by Deepgram + + + + Quay.io Username + + + The username to the container registry provided to you by Deepgram + + + + + Quay.io secret + + + The username to the container registry provided to you by Deepgram + + + + + Quay.io email + + + The username to the container registry provided to you by Deepgram + + + + + Model URLs + + The URLs of models you would like to download. + + {urls.map((url, i) => { + return ( +
+ + + { + remove(i); + }} + > + cancel + + + +
+ ); + })} + + + +
+ ); +}; + +export default DeepgramForm; + +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/deepgram/DeepgramOverview.tsx b/dashboard/src/main/home/add-on-dashboard/deepgram/DeepgramOverview.tsx new file mode 100644 index 0000000000..757cd5e113 --- /dev/null +++ b/dashboard/src/main/home/add-on-dashboard/deepgram/DeepgramOverview.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import AceEditor from "react-ace"; + +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; + +const DeepgramOverview: React.FC = () => { + return ( +
+ Example usage + + + You can use this model over an auto-generated endpoint from any app + running on Porter. + + + +
+ ); +}; + +export default DeepgramOverview; diff --git a/dashboard/src/main/home/sidebar/Sidebar.tsx b/dashboard/src/main/home/sidebar/Sidebar.tsx index da01fbed11..51f4402072 100644 --- a/dashboard/src/main/home/sidebar/Sidebar.tsx +++ b/dashboard/src/main/home/sidebar/Sidebar.tsx @@ -18,6 +18,7 @@ import collapseSidebar from "assets/collapse-sidebar.svg"; import compliance from "assets/compliance.svg"; import database from "assets/database.svg"; import sliders from "assets/env-groups.svg"; +import inference from "assets/inference.svg"; import integrations from "assets/integrations.svg"; import lock from "assets/lock.svg"; import pr_icon from "assets/pull_request_icon.svg"; @@ -312,7 +313,7 @@ class Sidebar extends Component { @@ -327,6 +328,18 @@ class Sidebar extends Component { .otherwise(() => null)} + + + + + Inference + + Beta + + {!currentProject.sandbox_enabled && ( { )} - {!currentProject.sandbox_enabled && ( { )} - diff --git a/dashboard/src/shared/routing.tsx b/dashboard/src/shared/routing.tsx index 1dee3781d0..5ca301f67f 100644 --- a/dashboard/src/shared/routing.tsx +++ b/dashboard/src/shared/routing.tsx @@ -18,6 +18,7 @@ export type PorterUrl = | "addons" | "compliance" | "environment-groups" + | "inference" | "stacks"; export const PorterUrls = [ @@ -39,6 +40,7 @@ export const PorterUrls = [ "addons", "compliance", "environment-groups", + "inference", "stacks", "ory", ]; diff --git a/go.mod b/go.mod index 96b70c1f75..6d64e1d743 100644 --- a/go.mod +++ b/go.mod @@ -89,7 +89,7 @@ require ( github.com/nats-io/nats.go v1.24.0 github.com/open-policy-agent/opa v0.44.0 github.com/ory/client-go v1.9.0 - github.com/porter-dev/api-contracts v0.2.164 + github.com/porter-dev/api-contracts v0.2.168 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 8557a216ca..7fc2cb017c 100644 --- a/go.sum +++ b/go.sum @@ -1570,8 +1570,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw= -github.com/porter-dev/api-contracts v0.2.164 h1:99Y96YH9CfAl/aPjnqXbsiEgMHUFxDM9wC5G5sQnmyQ= -github.com/porter-dev/api-contracts v0.2.164/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= +github.com/porter-dev/api-contracts v0.2.168 h1:3OizOvzyqVP5aDTVVUhbDWIsTYHEyjjMkzrObIKfg/g= +github.com/porter-dev/api-contracts v0.2.168/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=