From f9eea6179cb69aee0fa8314996560901829fdbc3 Mon Sep 17 00:00:00 2001 From: ianedwards Date: Tue, 19 Sep 2023 10:50:59 -0400 Subject: [PATCH 1/2] [POR-1743] Env group revision sync (#3594) --- .../handlers/environment_groups/list.go | 4 +- api/server/handlers/porter_app/get_app_env.go | 9 +- api/server/handlers/porter_app/validate.go | 2 + dashboard/package-lock.json | 19 ++ dashboard/package.json | 1 + dashboard/src/lib/hooks/useAppValidation.ts | 1 + dashboard/src/lib/porter-apps/index.ts | 7 +- dashboard/src/lib/revisions/types.ts | 2 +- .../app-view/AppDataContainer.tsx | 6 +- .../app-view/tabs/Environment.tsx | 42 ++- .../app-dashboard/create-app/CreateApp.tsx | 45 +++- .../expanded-app/env-vars/EnvVariablesTab.tsx | 87 +++--- .../app-settings/EnvGroupModal.tsx | 208 +++++++++++++++ .../validate-apply/app-settings/EnvGroups.tsx | 252 ++++++++++++++++++ .../app-settings/EnvVariables.tsx | 175 +----------- .../app-settings/ExpandableEnvGroup.tsx | 242 +++++++++++++++++ .../validate-apply/app-settings/types.ts | 11 + .../revisions-list/RevisionTableContents.tsx | 5 +- dashboard/src/shared/api.tsx | 14 + .../kubernetes/environment_groups/list.go | 2 +- 20 files changed, 906 insertions(+), 228 deletions(-) create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupModal.tsx create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts diff --git a/api/server/handlers/environment_groups/list.go b/api/server/handlers/environment_groups/list.go index c93d1f9b8f..db5f8090ff 100644 --- a/api/server/handlers/environment_groups/list.go +++ b/api/server/handlers/environment_groups/list.go @@ -39,8 +39,8 @@ type ListEnvironmentGroupsResponse struct { type EnvironmentGroupListItem struct { Name string `json:"name"` LatestVersion int `json:"latest_version"` - Variables map[string]string `json:"variables"` - SecretVariables map[string]string `json:"secret_variables"` + Variables map[string]string `json:"variables,omitempty"` + SecretVariables map[string]string `json:"secret_variables,omitempty"` CreatedAtUTC time.Time `json:"created_at"` LinkedApplications []string `json:"linked_applications,omitempty"` } diff --git a/api/server/handlers/porter_app/get_app_env.go b/api/server/handlers/porter_app/get_app_env.go index 94cf7ff5da..0ae1532115 100644 --- a/api/server/handlers/porter_app/get_app_env.go +++ b/api/server/handlers/porter_app/get_app_env.go @@ -124,10 +124,11 @@ func (c *GetAppEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } envFromProtoInp := porter_app.AppEnvironmentFromProtoInput{ - ProjectID: project.ID, - ClusterID: int(cluster.ID), - App: appProto, - K8SAgent: agent, + ProjectID: project.ID, + ClusterID: int(cluster.ID), + App: appProto, + K8SAgent: agent, + DeploymentTargetRepository: c.Repo().DeploymentTarget(), } envGroups, err := porter_app.AppEnvironmentFromProto(ctx, envFromProtoInp, porter_app.WithEnvGroupFilter(request.EnvGroups), porter_app.WithSecrets()) diff --git a/api/server/handlers/porter_app/validate.go b/api/server/handlers/porter_app/validate.go index e49f77598b..efa09d14e7 100644 --- a/api/server/handlers/porter_app/validate.go +++ b/api/server/handlers/porter_app/validate.go @@ -40,6 +40,7 @@ func NewValidatePorterAppHandler( type Deletions struct { ServiceNames []string `json:"service_names"` EnvVariableNames []string `json:"env_variable_names"` + EnvGroupNames []string `json:"env_group_names"` } // ValidatePorterAppRequest is the request object for the /apps/validate endpoint @@ -128,6 +129,7 @@ func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ Deletions: &porterv1.Deletions{ ServiceNames: request.Deletions.ServiceNames, EnvVariableNames: request.Deletions.EnvVariableNames, + EnvGroupNames: request.Deletions.EnvGroupNames, }, }) ccpResp, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateReq) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 217b58da48..de85a51219 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -136,6 +136,7 @@ "style-loader": "^2.0.0", "terser-webpack-plugin": "^4.2.3", "ts-loader": "^8.0.4", + "type-fest": "^4.3.1", "typescript": "^4.1.2", "webpack": "^4.44.2", "webpack-bundle-analyzer": "^4.4.2", @@ -13297,6 +13298,18 @@ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==", "dev": true }, + "node_modules/type-fest": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.3.1.tgz", + "integrity": "sha512-pphNW/msgOUSkJbH58x8sqpq8uQj6b0ZKGxEsLKMUnGorRcDjrUaLS+39+/ub41JNTwrrMyJcUB8+YZs3mbwqw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -25726,6 +25739,12 @@ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==", "dev": true }, + "type-fest": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.3.1.tgz", + "integrity": "sha512-pphNW/msgOUSkJbH58x8sqpq8uQj6b0ZKGxEsLKMUnGorRcDjrUaLS+39+/ub41JNTwrrMyJcUB8+YZs3mbwqw==", + "dev": true + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index a88763dd81..6cfe9dea20 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -141,6 +141,7 @@ "style-loader": "^2.0.0", "terser-webpack-plugin": "^4.2.3", "ts-loader": "^8.0.4", + "type-fest": "^4.3.1", "typescript": "^4.1.2", "webpack": "^4.44.2", "webpack-bundle-analyzer": "^4.4.2", diff --git a/dashboard/src/lib/hooks/useAppValidation.ts b/dashboard/src/lib/hooks/useAppValidation.ts index 55ac34a56a..8b677a67b9 100644 --- a/dashboard/src/lib/hooks/useAppValidation.ts +++ b/dashboard/src/lib/hooks/useAppValidation.ts @@ -117,6 +117,7 @@ export const useAppValidation = ({ commit_sha, deletions: { service_names: data.deletions.serviceNames.map((s) => s.name), + env_group_names: data.deletions.envGroupNames.map((eg) => eg.name), env_variable_names: [], }, }, diff --git a/dashboard/src/lib/porter-apps/index.ts b/dashboard/src/lib/porter-apps/index.ts index 6d8a8e5bb0..5af2ce6b9b 100644 --- a/dashboard/src/lib/porter-apps/index.ts +++ b/dashboard/src/lib/porter-apps/index.ts @@ -62,6 +62,11 @@ export const deletionValidator = z.object({ name: z.string(), }) .array(), + envGroupNames: z + .object({ + name: z.string(), + }) + .array(), }); // clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields @@ -314,7 +319,7 @@ export function clientAppFromProto({ key, value, hidden: true, - locked: false, + locked: true, deleted: false, })), ]; diff --git a/dashboard/src/lib/revisions/types.ts b/dashboard/src/lib/revisions/types.ts index 9841836eb9..23a6cc576a 100644 --- a/dashboard/src/lib/revisions/types.ts +++ b/dashboard/src/lib/revisions/types.ts @@ -20,7 +20,7 @@ export const appRevisionValidator = z.object({ name: z.string(), latest_version: z.number(), variables: z.record(z.string(), z.string()).optional(), - secrets: z.record(z.string(), z.string()).optional(), + secret_variables: z.record(z.string(), z.string()).optional(), created_at: z.string(), }), }); diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx index 122a012eca..045f5d83ad 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx @@ -110,11 +110,12 @@ const AppDataContainer: React.FC = ({ tabParam }) => { proto: latestProto, overrides: servicesFromYaml, variables: latestRevision.env.variables, - secrets: latestRevision.env.secrets, + secrets: latestRevision.env.secret_variables, }), source: latestSource, deletions: { serviceNames: [], + envGroupNames: [], }, }, }); @@ -258,10 +259,11 @@ const AppDataContainer: React.FC = ({ tabParam }) => { proto: latestProto, overrides: servicesFromYaml, variables: latestRevision.env.variables, - secrets: latestRevision.env.secrets, + secrets: latestRevision.env.secret_variables, }), source: latestSource, deletions: { + envGroupNames: [], serviceNames: [], }, }); diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx index b991682b0d..3a3cd2fdc6 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx @@ -7,12 +7,46 @@ import Error from "components/porter/Error"; import { useFormContext } from "react-hook-form"; import { PorterAppFormData } from "lib/porter-apps"; import { useLatestRevision } from "../LatestRevisionContext"; +import { useQuery } from "@tanstack/react-query"; +import api from "shared/api"; +import { z } from "zod"; +import { populatedEnvGroup } from "../../validate-apply/app-settings/types"; +import EnvGroups from "../../validate-apply/app-settings/EnvGroups"; const Environment: React.FC = () => { - const { latestRevision } = useLatestRevision(); + const { + latestRevision, + latestProto, + clusterId, + projectId, + } = useLatestRevision(); const { formState: { isSubmitting, errors }, + watch, } = useFormContext(); + const envGroupNames = watch("app.envGroups").map((eg) => eg.name); + + const { data: baseEnvGroups = [] } = useQuery( + ["getAllEnvGroups", projectId, clusterId], + async () => { + const res = await api.getAllEnvGroups( + "", + {}, + { + id: projectId, + cluster_id: clusterId, + } + ); + + const { environment_groups } = await z + .object({ + environment_groups: z.array(populatedEnvGroup).default([]), + }) + .parseAsync(res.data); + + return environment_groups; + } + ); const buttonStatus = useMemo(() => { if (isSubmitting) { @@ -32,6 +66,12 @@ const Environment: React.FC = () => { Shared among all services. + + + + ); +}; + +export default EnvGroupModal; + +const EnvGroupRow = styled.div<{ lastItem?: boolean; isSelected: boolean }>` + display: flex; + width: 100%; + font-size: 13px; + border-bottom: 1px solid + ${(props) => (props.lastItem ? "#00000000" : "#606166")}; + color: #ffffff; + user-select: none; + align-items: center; + padding: 10px 0px; + cursor: pointer; + background: ${(props) => (props.isSelected ? "#ffffff11" : "")}; + :hover { + background: #ffffff11; + } + + > img, + i { + width: 16px; + height: 18px; + margin-left: 12px; + margin-right: 12px; + font-size: 20px; + } +`; +const EnvGroupList = styled.div` + width: 100%; + border-radius: 3px; + background: #ffffff11; + border: 1px solid #ffffff44; + overflow-y: auto; +`; + +const SidebarSection = styled.section<{ $expanded?: boolean }>` + height: 100%; + overflow-y: auto; + ${(props) => + props.$expanded && + css` + grid-column: span 2; + `} +`; + +const GroupEnvPreview = styled.pre` + font-family: monospace; + margin: 0 0 10px 0; + white-space: pre-line; + word-break: break-word; + user-select: text; + .key { + color: white; + } + .value { + color: #3a48ca; + } +`; +const GroupModalSections = styled.div` + margin-top: 20px; + width: 100%; + height: 100%; + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; + max-height: 365px; +`; +const ColumnContainer = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; +`; + +const ScrollableContainer = styled.div` + flex: 1; + overflow-y: auto; + max-height: 300px; +`; + +const SubmitButtonContainer = styled.div` + margin-top: 10px; + text-align: right; +`; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx new file mode 100644 index 0000000000..bf892379a7 --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx @@ -0,0 +1,252 @@ +import React, { useContext, useMemo, useState } from "react"; +import styled from "styled-components"; +import { useFieldArray, useFormContext } from "react-hook-form"; + +import sliders from "assets/sliders.svg"; + +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { PorterAppFormData } from "lib/porter-apps"; +import ExpandableEnvGroup from "./ExpandableEnvGroup"; +import { PopulatedEnvGroup, populatedEnvGroup } from "./types"; +import { useQuery } from "@tanstack/react-query"; +import { Context } from "shared/Context"; +import api from "shared/api"; +import { z } from "zod"; +import { valueExists } from "shared/util"; +import EnvGroupModal from "./EnvGroupModal"; +import { IterableElement } from "type-fest"; + +type Props = { + appName?: string; + revisionId?: string; + baseEnvGroups?: PopulatedEnvGroup[]; + existingEnvGroupNames?: string[]; +}; + +const EnvGroups: React.FC = ({ + appName, + revisionId, + baseEnvGroups = [], + existingEnvGroupNames = [], +}) => { + const { currentCluster, currentProject } = useContext(Context); + + const [showEnvModal, setShowEnvModal] = useState(false); + const [hovered, setHovered] = useState(false); + + const { control } = useFormContext(); + const { append, remove, fields: envGroups } = useFieldArray({ + control, + name: "app.envGroups", + }); + const { + append: appendDeletion, + remove: removeDeletion, + fields: deletedEnvGroups, + } = useFieldArray({ + control, + name: "deletions.envGroupNames", + }); + + const maxEnvGroupsReached = envGroups.length >= 3; + + const { data: attachedEnvGroups = [] } = useQuery( + ["getAttachedEnvGroups", appName, revisionId], + async () => { + if (!appName || !revisionId || !currentCluster?.id || !currentProject?.id) + return []; + + const res = await api.getAttachedEnvGroups( + "", + {}, + { + project_id: currentProject.id, + cluster_id: currentCluster.id, + app_name: appName, + revision_id: revisionId, + } + ); + + const { env_groups } = await z + .object({ + env_groups: z.array(populatedEnvGroup), + }) + .parseAsync(res.data); + + return env_groups; + }, + { + enabled: + !!appName && !!revisionId && !!currentCluster && !!currentProject, + } + ); + + const populatedEnvWithFallback = useMemo(() => { + return envGroups + .map((envGroup, index) => { + const attachedEnvGroup = attachedEnvGroups.find( + (attachedEnvGroup) => attachedEnvGroup.name === envGroup.name + ); + + if (attachedEnvGroup) { + return { + id: envGroup.id, + envGroup: attachedEnvGroup, + index, + }; + } + + const baseEnvGroup = baseEnvGroups.find( + (baseEnvGroup) => baseEnvGroup.name === envGroup.name + ); + + if (baseEnvGroup) { + return { + id: envGroup.id, + envGroup: baseEnvGroup, + index, + }; + } + + return undefined; + }) + .filter(valueExists); + }, [envGroups, attachedEnvGroups, baseEnvGroups]); + + const onAdd = ( + inp: IterableElement + ) => { + const previouslyDeleted = deletedEnvGroups.findIndex( + (s) => s.name === inp.name + ); + + if (previouslyDeleted !== -1) { + removeDeletion(previouslyDeleted); + } + + append(inp); + }; + + const onRemove = (index: number) => { + const name = populatedEnvWithFallback[index].envGroup.name; + remove(index); + + if (existingEnvGroupNames.includes(name)) { + appendDeletion({ name }); + } + }; + + return ( +
+ setHovered(true)} + onMouseOut={() => setHovered(false)} + > + !maxEnvGroupsReached && setShowEnvModal(true)} + > + Load from Env Group + + + Max 4 Env Groups allowed + + + {envGroups.length > 0 && ( + <> + + Synced environment groups + {populatedEnvWithFallback.map(({ envGroup, id, index }) => { + return ( + + ); + })} + + )} + {showEnvModal ? ( + + ) : null} +
+ ); +}; + +export default EnvGroups; + +const AddRowButton = styled.div` + display: flex; + align-items: center; + width: 270px; + font-size: 13px; + color: #aaaabb; + height: 32px; + border-radius: 3px; + cursor: pointer; + background: #ffffff11; + :hover { + background: #ffffff22; + } + + > i { + color: #ffffff44; + font-size: 16px; + margin-left: 8px; + margin-right: 10px; + display: flex; + align-items: center; + justify-content: center; + } +`; + +const LoadButton = styled(AddRowButton)<{ disabled?: boolean }>` + background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")}; + border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")}; + cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; + + > i { + color: ${(props) => (props.disabled ? "#aaaaaa44" : "#ffffff44")}; + font-size: 16px; + margin-left: 8px; + margin-right: 10px; + display: flex; + align-items: center; + justify-content: center; + } + > img { + width: 14px; + margin-left: 10px; + margin-right: 12px; + opacity: ${(props) => (props.disabled ? "0.5" : "1")}; + } +`; + +const TooltipWrapper = styled.div` + position: relative; + display: inline-block; +`; + +const TooltipText = styled.span<{ visible: boolean }>` + visibility: ${(props) => (props.visible ? "visible" : "hidden")}; + width: 240px; + color: #fff; + text-align: center; + padding: 5px 0; + border-radius: 6px; + position: absolute; + z-index: 1; + bottom: 100%; + left: 50%; + margin-left: -120px; + opacity: ${(props) => (props.visible ? "1" : "0")}; + transition: opacity 0.3s; + font-size: 12px; +`; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx index 080309ef4f..44b0d5833e 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx @@ -1,59 +1,15 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React from "react"; import { Controller, useFormContext } from "react-hook-form"; import { PorterAppFormData } from "lib/porter-apps"; import EnvGroupArrayV2 from "main/home/cluster-dashboard/env-groups/EnvGroupArrayV2"; import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArrayV2"; -import styled from "styled-components"; -import Spacer from "components/porter/Spacer"; -import EnvGroupModal from "../../expanded-app/env-vars/EnvGroupModal"; -import ExpandableEnvGroup from "../../expanded-app/env-vars/ExpandableEnvGroup"; -import { NewPopulatedEnvGroup } from "components/porter-form/types"; -import sliders from "assets/sliders.svg"; -import Text from "components/porter/Text"; -import api from "shared/api"; -import { Context } from "shared/Context"; -import { z } from "zod"; -import { EnvGroup } from "@porter-dev/api-contracts"; - const EnvVariables: React.FC = () => { const { control } = useFormContext(); - const [hovered, setHovered] = useState(false); - const [syncedEnvGroups, setSyncedEnvGroups] = useState([]); - const [showEnvModal, setShowEnvModal] = useState(false); - - const [deletedEnvGroups, setDeletedEnvGroups] = useState([]) - - const maxEnvGroupsReached = syncedEnvGroups.length >= 4; - - const convertSynced = (envGroups: NewPopulatedEnvGroup[]): {}[] => { - return envGroups?.map(group => ( - new EnvGroup( - { - name: group.name, - version: BigInt(group.latest_version), - }, - ) - ) - ) - } - const removeSynced = (envGroups: [], envGroup: NewPopulatedEnvGroup): {}[] => { - return envGroups?.filter(group => ( - group?.name !== envGroup.name - ) - ) - } - const deleteEnvGroup = (envGroup: NewPopulatedEnvGroup) => { - - setDeletedEnvGroups([...deletedEnvGroups, envGroup]); - setSyncedEnvGroups(syncedEnvGroups?.filter( - (env) => env.name !== envGroup.name - )) - } return ( - <> ( @@ -64,131 +20,12 @@ const EnvVariables: React.FC = () => { onChange(x); }} fileUpload={true} - syncedEnvGroups={syncedEnvGroups} /> + syncedEnvGroups={[]} + /> - )} /> - ( - <> - setHovered(true)} - onMouseOut={() => setHovered(false)}> - !maxEnvGroupsReached && setShowEnvModal(true)} - > - Load from Env Group - - Max 4 Env Groups allowed - - - {showEnvModal && { - onChange(x); - }} - values={value} - closeModal={() => setShowEnvModal(false)} - syncedEnvGroups={syncedEnvGroups} - setSyncedEnvGroups={(x: NewPopulatedEnvGroup[]) => { - setSyncedEnvGroups(x); - onChange(convertSynced(x)); - }} - namespace={"default"} - newApp={true} />} - {!!syncedEnvGroups?.length && ( - <> - - Synced environment groups - {syncedEnvGroups?.map((envGroup: any) => { - return ( - { - deleteEnvGroup(envGroup); - onChange(removeSynced(value, envGroup)); - }} /> - ); - })} - - )} - )} /> - - - + )} + /> ); }; - export default EnvVariables; - -const AddRowButton = styled.div` - display: flex; - align-items: center; - width: 270px; - font-size: 13px; - color: #aaaabb; - height: 32px; - border-radius: 3px; - cursor: pointer; - background: #ffffff11; - :hover { - background: #ffffff22; - } - - > i { - color: #ffffff44; - font-size: 16px; - margin-left: 8px; - margin-right: 10px; - display: flex; - align-items: center; - justify-content: center; - } -`; -const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>` - background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")}; - border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")}; - cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; - - > i { - color: ${(props) => (props.disabled ? "#aaaaaa44" : "#ffffff44")}; - font-size: 16px; - margin-left: 8px; - margin-right: 10px; - display: flex; - align-items: center; - justify-content: center; - } - > img { - width: 14px; - margin-left: 10px; - margin-right: 12px; - opacity: ${(props) => (props.disabled ? "0.5" : "1")}; - } -`; - -const TooltipWrapper = styled.div` - position: relative; - display: inline-block; -`; - -const TooltipText = styled.span` - visibility: ${(props) => (props.visible ? 'visible' : 'hidden')}; - width: 240px; - color: #fff; - text-align: center; - padding: 5px 0; - border-radius: 6px; - position: absolute; - z-index: 1; - bottom: 100%; - left: 50%; - margin-left: -120px; - opacity: ${(props) => (props.visible ? '1' : '0')}; - transition: opacity 0.3s; - font-size: 12px; -`; - diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx new file mode 100644 index 0000000000..1924effdcc --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx @@ -0,0 +1,242 @@ +import React, { useState } from "react"; +import styled, { keyframes } from "styled-components"; +import { PopulatedEnvGroup } from "./types"; +import Spacer from "components/porter/Spacer"; + +type Props = { + index: number; + remove: (index: number) => void; + envGroup: PopulatedEnvGroup; +}; + +const ExpandableEnvGroup: React.FC = ({ index, remove, envGroup }) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + + + + {envGroup.name} + + + + remove(index)}> + delete + + setIsExpanded((prev) => !prev)} + > + + {isExpanded ? "arrow_drop_up" : "arrow_drop_down"} + + + + + {isExpanded ? ( + <> + {Object.entries(envGroup.variables ?? {}).map(([key, value], i) => ( + + + + + + ))} + {Object.entries(envGroup.secret_variables ?? {}).map( + ([key, value], i) => ( + + + + + + ) + )} + + ) : null} + + ); +}; + +export default ExpandableEnvGroup; + +const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +const StyledCard = styled.div` + border: 1px solid #ffffff44; + background: #ffffff11; + margin-bottom: 5px; + border-radius: 8px; + margin-top: 15px; + padding: 10px 14px; + overflow: hidden; + font-size: 13px; + animation: ${fadeIn} 0.5s; +`; + +const Flex = styled.div` + display: flex; + height: 25px; + align-items: center; + justify-content: space-between; +`; + +const ContentContainer = styled.div` + display: flex; + height: 40px; + width: 100%; + align-items: center; +`; + +const EventInformation = styled.div` + display: flex; + flex-direction: column; + justify-content: space-around; + height: 100%; +`; + +const EventName = styled.div` + font-family: "Work Sans", sans-serif; + font-weight: 500; + color: #ffffff; +`; + +const ActionContainer = styled.div` + display: flex; + align-items: center; + white-space: nowrap; + height: 100%; +`; + +const ActionButton = styled.button` + position: relative; + border: none; + background: none; + color: white; + padding: 5px; + width: 30px; + height: 30px; + margin-left: 5px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + cursor: pointer; + color: #aaaabb; + border: 1px solid #ffffff00; + + :hover { + background: #ffffff11; + border: 1px solid #ffffff44; + } + + > span { + font-size: 20px; + } +`; + +const NoVariablesTextWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + color: #ffffff99; +`; + +const InputWrapper = styled.div` + display: flex; + align-items: center; + margin-top: 5px; +`; + +type InputProps = { + disabled?: boolean; + width: string; + borderColor?: string; +}; + +const KeyInput = styled.input` + outline: none; + border: none; + margin-bottom: 5px; + font-size: 13px; + background: #ffffff11; + border: 1px solid + ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")}; + border-radius: 3px; + width: ${(props) => (props.width ? props.width : "270px")}; + color: ${(props) => (props.disabled ? "#ffffff44" : "white")}; + padding: 5px 10px; + height: 35px; +`; + +export const MultiLineInput = styled.textarea` + outline: none; + border: none; + margin-bottom: 5px; + font-size: 13px; + background: #ffffff11; + border: 1px solid + ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")}; + border-radius: 3px; + min-width: ${(props) => (props.width ? props.width : "270px")}; + max-width: ${(props) => (props.width ? props.width : "270px")}; + color: ${(props) => (props.disabled ? "#ffffff44" : "white")}; + padding: 8px 10px 5px 10px; + min-height: 35px; + max-height: 100px; + white-space: nowrap; + + ::-webkit-scrollbar { + width: 8px; + :horizontal { + height: 8px; + } + } + + ::-webkit-scrollbar-corner { + width: 10px; + background: #ffffff11; + color: white; + } + + ::-webkit-scrollbar-track { + width: 10px; + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + } + + ::-webkit-scrollbar-thumb { + background-color: darkgrey; + outline: 1px solid slategrey; + } +`; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts new file mode 100644 index 0000000000..23b63099a2 --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const populatedEnvGroup = z.object({ + name: z.string(), + latest_version: z.coerce.bigint(), + variables: z.record(z.string()).optional().default({}), + secret_variables: z.record(z.string()).optional().default({}), + linked_applications: z.array(z.string()).optional(), + created_at: z.string(), +}); +export type PopulatedEnvGroup = z.infer; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx index 3a18993f93..5deacac4a1 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx @@ -190,11 +190,12 @@ const RevisionTableContents: React.FC = ({ proto: revision.app_proto, overrides: servicesFromYaml, variables: revision.env.variables, - secrets: revision.env.secrets, + secrets: revision.env.secret_variables, }), source: latestSource, deletions: { serviceNames: [], + envGroupNames: [], }, }); setPreviewRevision( @@ -234,7 +235,7 @@ const RevisionTableContents: React.FC = ({ app: revision.app_proto, revision: revision.revision_number, variables: revision.env.variables ?? {}, - secrets: revision.env.secrets ?? {}, + secrets: revision.env.secret_variables ?? {}, }); }} > diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 66a1a20cbe..813fd0197b 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -867,6 +867,7 @@ const validatePorterApp = baseApi< deletions: { service_names: string[]; env_variable_names: string[]; + env_group_names: string[]; }; }, { @@ -932,6 +933,18 @@ const applyApp = baseApi< return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/apply`; }); +const getAttachedEnvGroups = baseApi< + {}, + { + project_id: number; + cluster_id: number; + app_name: string; + revision_id: string; + } +>("GET", (pathParams) => { + return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.app_name}/revisions/${pathParams.revision_id}/env`; +}); + const getLatestRevision = baseApi< { deployment_target_id: string; @@ -3085,6 +3098,7 @@ export default { createApp, updateAppEnvironmentGroup, applyApp, + getAttachedEnvGroups, getLatestRevision, listAppRevisions, getLatestAppRevisions, diff --git a/internal/kubernetes/environment_groups/list.go b/internal/kubernetes/environment_groups/list.go index f66ef74ad7..c79ab2926d 100644 --- a/internal/kubernetes/environment_groups/list.go +++ b/internal/kubernetes/environment_groups/list.go @@ -33,7 +33,7 @@ type EnvironmentGroup struct { // Variables are non-secret values for the EnvironmentGroup. This usually will be a configmap Variables map[string]string `json:"variables,omitempty"` // SecretVariables are secret values for the EnvironmentGroup. This usually will be a Secret on the kubernetes cluster - SecretVariables map[string][]byte `json:"secrets,omitempty"` + SecretVariables map[string][]byte `json:"secret_variables,omitempty"` // CreatedAt is only used for display purposes and is in UTC Unix time CreatedAtUTC time.Time `json:"created_at"` } From e34ed7d351f65f6ea4d2146496659470c70b695b Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Tue, 19 Sep 2023 10:59:39 -0400 Subject: [PATCH 2/2] [POR-1721] Add status footers to porter yaml v2 (#3584) --- api/server/handlers/porter_app/pod_status.go | 112 ++++++++ api/server/handlers/porter_app/status.go | 71 +++++ api/server/router/porter_app.go | 84 +++++- dashboard/src/lib/hooks/useAppStatus.ts | 215 +++++++++++++++ dashboard/src/lib/hooks/useRevisionList.ts | 58 ++-- .../app-dashboard/app-view/tabs/Overview.tsx | 14 +- .../services-settings/ServiceContainer.tsx | 74 +++-- .../services-settings/ServiceList.tsx | 6 +- .../services-settings/ServiceStatusFooter.tsx | 255 ++++++++++++++++++ dashboard/src/shared/api.tsx | 11 + dashboard/src/shared/hooks/useWebsockets.ts | 2 +- 11 files changed, 816 insertions(+), 86 deletions(-) create mode 100644 api/server/handlers/porter_app/pod_status.go create mode 100644 api/server/handlers/porter_app/status.go create mode 100644 dashboard/src/lib/hooks/useAppStatus.ts create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx diff --git a/api/server/handlers/porter_app/pod_status.go b/api/server/handlers/porter_app/pod_status.go new file mode 100644 index 0000000000..150f681083 --- /dev/null +++ b/api/server/handlers/porter_app/pod_status.go @@ -0,0 +1,112 @@ +package porter_app + +import ( + "net/http" + + "connectrpc.com/connect" + porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1" + "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" + v1 "k8s.io/api/core/v1" +) + +// PodStatusHandler is the handler for GET /apps/pods +type PodStatusHandler struct { + handlers.PorterHandlerReadWriter + authz.KubernetesAgentGetter +} + +// NewPodStatusHandler returns a new PodStatusHandler +func NewPodStatusHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *PodStatusHandler { + return &PodStatusHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config), + } +} + +// PodStatusRequest is the expected format for a request body on GET /apps/pods +type PodStatusRequest struct { + DeploymentTargetID string `schema:"deployment_target_id"` + Selectors string `schema:"selectors"` +} + +func (c *PodStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-pod-status") + defer span.End() + + request := &PodStatusRequest{} + if ok := c.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "invalid request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster) + project, _ := r.Context().Value(types.ProjectScope).(*models.Project) + + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "selectors", Value: request.Selectors}) + + if request.DeploymentTargetID == "" { + err := telemetry.Error(ctx, span, nil, "must provide deployment target id") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID}) + + deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{ + ProjectId: int64(project.ID), + DeploymentTargetId: request.DeploymentTargetID, + }) + + deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq) + if err != nil { + err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil { + err := telemetry.Error(ctx, span, err, "deployment target details resp is nil") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) { + err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + namespace := deploymentTargetDetailsResp.Msg.Namespace + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace}) + + agent, err := c.GetAgent(r, cluster, "") + if err != nil { + err = telemetry.Error(ctx, span, err, "unable to get agent") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + pods := []v1.Pod{} + + podsList, err := agent.GetPodsByLabel(request.Selectors, namespace) + if err != nil { + err = telemetry.Error(ctx, span, err, "unable to get pods by label") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + pods = append(pods, podsList.Items...) + + c.WriteResult(w, r, pods) +} diff --git a/api/server/handlers/porter_app/status.go b/api/server/handlers/porter_app/status.go new file mode 100644 index 0000000000..1691e88037 --- /dev/null +++ b/api/server/handlers/porter_app/status.go @@ -0,0 +1,71 @@ +package porter_app + +import ( + "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/server/shared/requestutils" + "github.com/porter-dev/porter/api/server/shared/websocket" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// AppStatusHandler handles the /apps/{kind}/status endpoint +type AppStatusHandler struct { + handlers.PorterHandlerReadWriter + authz.KubernetesAgentGetter +} + +// NewAppStatusHandler returns a new AppStatusHandler +func NewAppStatusHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *AppStatusHandler { + return &AppStatusHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config), + } +} + +// AppStatusRequest represents the accepted fields on a request to the /apps/{kind}/status endpoint +type AppStatusRequest struct { + Selectors string `schema:"selectors"` +} + +func (c *AppStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-app-logs") + defer span.End() + + safeRW := ctx.Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter) + request := &AppStatusRequest{} + + if ok := c.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "invalid request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster) + + agent, err := c.GetAgent(r, cluster, "") + if err != nil { + err = telemetry.Error(ctx, span, err, "unable to get agent") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + kind, _ := requestutils.GetURLParamString(r, types.URLParamKind) + + err = agent.StreamControllerStatus(kind, request.Selectors, safeRW) + + if err != nil { + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } +} diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go index 77c7c62c6f..4f6dd05bd8 100644 --- a/api/server/router/porter_app.go +++ b/api/server/router/porter_app.go @@ -47,6 +47,7 @@ func getPorterAppRoutes( factory shared.APIEndpointFactory, ) ([]*router.Route, *types.Path) { relPath := "/applications" + relPathV2 := "/apps" newPath := &types.Path{ Parent: basePath, @@ -578,7 +579,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbPost, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/parse", + RelativePath: fmt.Sprintf("%s/parse", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -607,7 +608,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbPost, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/validate", + RelativePath: fmt.Sprintf("%s/validate", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -636,7 +637,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbPost, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/create", + RelativePath: fmt.Sprintf("%s/create", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -665,7 +666,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbPost, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/apply", + RelativePath: fmt.Sprintf("%s/apply", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -723,7 +724,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: fmt.Sprintf("/apps/{%s}/latest", types.URLParamPorterAppName), + RelativePath: fmt.Sprintf("%s/{%s}/latest", relPathV2, types.URLParamPorterAppName), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -752,7 +753,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: fmt.Sprintf("/apps/{%s}/revisions", types.URLParamPorterAppName), + RelativePath: fmt.Sprintf("%s/{%s}/revisions", relPathV2, types.URLParamPorterAppName), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -781,7 +782,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/revisions", + RelativePath: fmt.Sprintf("%s/revisions", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -810,7 +811,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbPost, Path: &types.Path{ Parent: basePath, - RelativePath: fmt.Sprintf("/apps/{%s}/subdomain", types.URLParamPorterAppName), + RelativePath: fmt.Sprintf("%s/{%s}/subdomain", relPathV2, types.URLParamPorterAppName), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -839,7 +840,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: fmt.Sprintf("/apps/{%s}/{%s}/predeploy-status", types.URLParamPorterAppName, types.URLParamAppRevisionID), + RelativePath: fmt.Sprintf("%s/{%s}/{%s}/predeploy-status", relPathV2, types.URLParamPorterAppName, types.URLParamAppRevisionID), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -868,7 +869,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/logs", + RelativePath: fmt.Sprintf("%s/logs", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -897,7 +898,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/logs/loki", + RelativePath: fmt.Sprintf("%s/logs/loki", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -927,7 +928,7 @@ func getPorterAppRoutes( Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: "/apps/metrics", + RelativePath: fmt.Sprintf("%s/metrics", relPathV2), }, Scopes: []types.PermissionScope{ types.UserScope, @@ -949,6 +950,65 @@ func getPorterAppRoutes( Router: r, }) + // GET /api/projects/{project_id}/clusters/{cluster_id}/apps/status -> cluster.NewAppStatusHandler + appStatusEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/{%s}/status", relPathV2, types.URLParamKind), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.ClusterScope, + }, + IsWebsocket: true, + }, + ) + + appStatusHandler := porter_app.NewAppStatusHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: appStatusEndpoint, + Handler: appStatusHandler, + Router: r, + }) + + // GET /api/projects/{project_id}/clusters/{cluster_id}/apps/pods -> cluster.NewPodStatusHandler + appPodStatusEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/pods", relPathV2), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.ClusterScope, + }, + }, + ) + + appPodStatusHandler := porter_app.NewPodStatusHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: appPodStatusEndpoint, + Handler: appPodStatusHandler, + Router: r, + }) + // POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id} -> porter_app.NewUpdateAppRevisionStatusHandler updateAppRevisionStatusEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/dashboard/src/lib/hooks/useAppStatus.ts b/dashboard/src/lib/hooks/useAppStatus.ts new file mode 100644 index 0000000000..c4e66809fc --- /dev/null +++ b/dashboard/src/lib/hooks/useAppStatus.ts @@ -0,0 +1,215 @@ +import _ from "lodash"; +import { useEffect, useMemo, useState } from "react"; +import api from "shared/api"; +import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets"; +import { useRevisionIdToNumber } from "./useRevisionList"; +import { valueExists } from "shared/util"; + +export type PorterAppVersionStatus = { + status: 'running' | 'spinningDown' | 'failing'; + message: string; + crashLoopReason: string; +} + +type ClientPod = { + revisionId: string, + helmRevision: string, + crashLoopReason: string, + isFailing: boolean, + replicaSetName: string, +} + +export const useAppStatus = ( + { + projectId, + clusterId, + serviceNames, + deploymentTargetId, + appName, + kind = "pod", + }: { + projectId: number, + clusterId: number, + serviceNames: string[], + deploymentTargetId: string, + appName: string, + kind?: string, + } +) => { + const [servicePodMap, setServicePodMap] = useState>({}); + + const revisionIdToNumber = useRevisionIdToNumber(appName, deploymentTargetId); + + const { + newWebsocket, + openWebsocket, + closeAllWebsockets, + closeWebsocket, + } = useWebsockets(); + + const setupWebsocket = ( + serviceName: string, + ) => { + const selectors = `porter.run/service-name=${serviceName},porter.run/deployment-target-id=${deploymentTargetId}`; + const apiEndpoint = `/api/projects/${projectId}/clusters/${clusterId}/apps/${kind}/status?selectors=${selectors}`; + const websocketKey = `${serviceName}-${Math.random().toString(36).substring(2, 15)}` + + const options: NewWebsocketOptions = {}; + options.onopen = () => { + // console.log("opening status websocket for service: " + serviceName) + }; + + options.onmessage = async (evt: MessageEvent) => { + await updatePods(serviceName); + }; + + options.onclose = () => { + // console.log("closing status websocket for service: " + serviceName) + }; + + options.onerror = (err: ErrorEvent) => { + closeWebsocket(websocketKey); + }; + + newWebsocket(websocketKey, apiEndpoint, options); + openWebsocket(websocketKey); + }; + + const updatePods = async (serviceName: string) => { + const selectors = `porter.run/service-name=${serviceName},porter.run/deployment-target-id=${deploymentTargetId}`; + + try { + const res = await api.appPodStatus( + "", + { + deployment_target_id: deploymentTargetId, + selectors, + }, + { + id: projectId, + cluster_id: clusterId, + } + ); + // TODO: type the response + const data = res?.data as any[]; + let newPods = data + // Parse only data that we need + .map((pod: any) => { + const replicaSetName = + Array.isArray(pod?.metadata?.ownerReferences) && + pod?.metadata?.ownerReferences[0]?.name; + const containerStatus = + Array.isArray(pod?.status?.containerStatuses) && + pod?.status?.containerStatuses[0]; + + // const restartCount = containerStatus + // ? containerStatus.restartCount + // : "N/A"; + + // const podAge = timeFormat("%H:%M:%S %b %d, '%y")( + // new Date(pod?.metadata?.creationTimestamp) + // ); + + const isFailing = containerStatus?.state?.waiting?.reason === "CrashLoopBackOff" ?? false; + const crashLoopReason = containerStatus?.lastState?.terminated?.message ?? ""; + + return { + // namespace: pod?.metadata?.namespace, + // name: pod?.metadata?.name, + // phase: pod?.status?.phase, + // status: pod?.status, + // restartCount, + // containerStatus, + // podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A", + replicaSetName, + revisionId: pod?.metadata?.labels?.["porter.run/app-revision-id"], + helmRevision: pod?.metadata?.annotations?.["helm.sh/revision"] || "N/A", + crashLoopReason, + isFailing + }; + }); + setServicePodMap((prevState) => ({ + ...prevState, + [serviceName]: newPods, + })); + } catch (error) { + // TODO: handle error + } + }; + + useEffect(() => { + Promise.all(serviceNames.map(updatePods)); + for (let serviceName of serviceNames) { + setupWebsocket(serviceName); + } + return () => closeAllWebsockets(); + }, [projectId, clusterId, deploymentTargetId, appName, JSON.stringify(revisionIdToNumber)]); + + const processReplicaSetArray = (replicaSetArray: ClientPod[][]): PorterAppVersionStatus[] => { + return replicaSetArray.map((replicaSet, i) => { + let status: 'running' | 'failing' | 'spinningDown' = "running"; + let message = ""; + + const version = revisionIdToNumber[replicaSet[0].revisionId]; + + if (!version) { + return undefined; + } + + if (replicaSet.some((r) => r.crashLoopReason !== "") || replicaSet.some((r) => r.isFailing)) { + status = "failing"; + message = `${replicaSet.length} replica${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are" + } failing to run Version ${version}`; + } else if ( + i > 0 && replicaSetArray[i - 1].every(p => !p.isFailing) + ) { + status = "spinningDown"; + message = `${replicaSet.length} replica${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are" + } still running at Version ${version}. Attempting to spin down...`; + } else { + status = "running"; + message = `${replicaSet.length} replica${replicaSet.length === 1 ? "" : "s"} ${replicaSet.length === 1 ? "is" : "are" + } running at Version ${version}`; + } + + const crashLoopReason = + replicaSet.find((r) => r.crashLoopReason !== "")?.crashLoopReason || ""; + + return { + status, + message, + crashLoopReason, + }; + }).filter(valueExists); + } + + const serviceVersionStatus: Record = useMemo(() => { + const serviceReplicaSetMap = Object.fromEntries(Object.keys(servicePodMap).map((serviceName) => { + const pods = servicePodMap[serviceName]; + const replicaSetMap = _.sortBy(pods, ["helmRevision"]) + .reverse() + .reduce(function ( + prev, + currentPod, + i + ) { + if ( + !i || + prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName + ) { + return prev.concat([[currentPod]]); + } + prev[prev.length - 1].push(currentPod); + return prev; + }, []); + + return [serviceName, processReplicaSetArray(replicaSetMap)]; + })); + + return serviceReplicaSetMap; + }, [JSON.stringify(servicePodMap)]); + + return { + serviceVersionStatus, + }; +}; \ No newline at end of file diff --git a/dashboard/src/lib/hooks/useRevisionList.ts b/dashboard/src/lib/hooks/useRevisionList.ts index 3541ab8452..dc0b0dfa34 100644 --- a/dashboard/src/lib/hooks/useRevisionList.ts +++ b/dashboard/src/lib/hooks/useRevisionList.ts @@ -3,12 +3,12 @@ import { useContext, useEffect, useState } from "react"; import { Context } from "shared/Context"; import api from "shared/api"; import { z } from "zod"; -import {AppRevision, appRevisionValidator} from "../revisions/types"; -import {useLatestRevision} from "../../main/home/app-dashboard/app-view/LatestRevisionContext"; +import { AppRevision, appRevisionValidator } from "../revisions/types"; +import { useLatestRevision } from "../../main/home/app-dashboard/app-view/LatestRevisionContext"; export function useRevisionList(appName: string, deploymentTargetId: string) { const { currentProject, currentCluster } = useContext(Context); - const {latestRevision} = useLatestRevision(); + const { latestRevision } = useLatestRevision(); const [ revisionList, @@ -19,29 +19,33 @@ export function useRevisionList(appName: string, deploymentTargetId: string) { return []; } - const {data} = useQuery( - ["listAppRevisions", currentProject.id, currentCluster.id, appName, deploymentTargetId, latestRevision], - async () => { - const res = await api.listAppRevisions( - "", - { - deployment_target_id: deploymentTargetId, - }, - { - project_id: currentProject.id, - cluster_id: currentCluster.id, - porter_app_name: appName, - } - ); + const { data } = useQuery( + ["listAppRevisions", currentProject.id, currentCluster.id, appName, deploymentTargetId, latestRevision], + async () => { + const res = await api.listAppRevisions( + "", + { + deployment_target_id: deploymentTargetId, + }, + { + project_id: currentProject.id, + cluster_id: currentCluster.id, + porter_app_name: appName, + } + ); - const revisions = await z - .object({ - app_revisions: z.array(appRevisionValidator), - }) - .parseAsync(res.data); + const revisions = await z + .object({ + app_revisions: z.array(appRevisionValidator), + }) + .parseAsync(res.data); - return revisions; - } + return revisions; + }, + { + enabled: !!currentProject && !!currentCluster, + refetchInterval: 5000, + } ); useEffect(() => { @@ -54,10 +58,10 @@ export function useRevisionList(appName: string, deploymentTargetId: string) { } export function useRevisionIdToNumber(appName: string, deploymentTargetId: string) { - const revisionList = useRevisionList(appName, deploymentTargetId); - const revisionIdToNumber: Record = Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number ]))) + const revisionList = useRevisionList(appName, deploymentTargetId); + const revisionIdToNumber: Record = Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number]))) - return revisionIdToNumber; + return revisionIdToNumber; } export function useLatestRevisionNumber(appName: string, deploymentTargetId: string) { diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx index 975687baad..4f7dbae317 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx @@ -2,7 +2,7 @@ import { PorterApp } from "@porter-dev/api-contracts"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { PorterAppFormData } from "lib/porter-apps"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { useFormContext, useFormState } from "react-hook-form"; import ServiceList from "../../validate-apply/services-settings/ServiceList"; import { @@ -12,10 +12,19 @@ import { import Error from "components/porter/Error"; import Button from "components/porter/Button"; import { useLatestRevision } from "../LatestRevisionContext"; +import { useAppStatus } from "lib/hooks/useAppStatus"; const Overview: React.FC = () => { const { formState } = useFormContext(); - const { porterApp, latestProto, latestRevision } = useLatestRevision(); + const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTargetId } = useLatestRevision(); + + const { serviceVersionStatus } = useAppStatus({ + projectId, + clusterId, + serviceNames: Object.keys(latestProto.services).filter(name => latestProto.services[name].config.case !== "jobConfig"), + deploymentTargetId, + appName: latestProto.name, + }); const buttonStatus = useMemo(() => { if (formState.isSubmitting) { @@ -56,6 +65,7 @@ const Overview: React.FC = () => { addNewText={"Add a new service"} fieldArrayName={"app.services"} existingServiceNames={Object.keys(latestProto.services)} + serviceVersionStatus={serviceVersionStatus} /> + // + // + // )} + // + // ); + // } + + return ( + <> + {status.map((versionStatus, i) => { + return ( + <> + + + {match(versionStatus) + .with({ status: "failing" }, (vs) => { + return ( + <> + + + + {vs.message} + + + {vs.crashLoopReason && + + } + + ) + }) + .with({ status: "spinningDown" }, (vs) => { + return ( + + + + {vs.message} + + + ) + }) + .with({ status: "running" }, (vs) => { + return ( + + + + {vs.message} + + + ) + }) + .exhaustive() + } + + + {versionStatus.crashLoopReason && ( + + + + {versionStatus.crashLoopReason} + + + + )} + + ); + })} + + ); +}; + +export default ServiceStatusFooter; + +const StatusDot = styled.div<{ color?: string }>` + min-width: 7px; + max-width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 10px; + background: ${(props) => props.color || "#38a88a"}; + + box-shadow: 0 0 0 0 rgba(0, 0, 0, 1); + transform: scale(1); + animation: pulse 2s infinite; + @keyframes pulse { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(0, 0, 0, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); + } + } +`; + +const Mi = styled.i` + font-size: 16px; + margin-right: 7px; + margin-top: -1px; + color: rgb(56, 168, 138); +`; + +const I = styled.i` + font-size: 14px; + margin-right: 5px; +`; + +const StatusCircle = styled.div<{ + percentage?: any; + dashed?: boolean; +}>` + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 10px; + background: conic-gradient( + from 0deg, + #ffffff33 ${(props) => props.percentage}, + #ffffffaa 0% ${(props) => props.percentage} + ); + border: ${(props) => (props.dashed ? "1px dashed #ffffff55" : "none")}; +`; + +const Running = styled.div` + display: flex; + align-items: center; +`; + +const StyledStatusFooter = styled.div` + width: 100%; + padding: 10px 15px; + background: ${(props) => props.theme.fg2}; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border: 1px solid #494b4f; + border-top: 0; + overflow: hidden; + display: flex; + align-items: stretch; + flex-direction: row; + animation: fadeIn 0.5s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const StyledStatusFooterTop = styled(StyledStatusFooter) <{ + expanded: boolean; +}>` + height: 40px; + border-bottom: ${({ expanded }) => expanded && "0px"}; + border-bottom-left-radius: ${({ expanded }) => expanded && "0px"}; + border-bottom-right-radius: ${({ expanded }) => expanded && "0px"}; +`; + +const Message = styled.div` + padding: 20px; + background: #000000; + border-radius: 5px; + line-height: 1.5em; + border: 1px solid #aaaabb33; + font-family: monospace; + font-size: 13px; + display: flex; + align-items: center; + > img { + width: 13px; + margin-right: 20px; + } + width: 100%; + height: 101px; + overflow: hidden; +`; + +const StyledContainer = styled.div<{ + row: boolean; + spaced: boolean; +}>` + display: ${(props) => (props.row ? "flex" : "block")}; + align-items: center; + justify-content: ${(props) => + props.spaced ? "space-between" : "flex-start"}; + width: 100%; +`; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 813fd0197b..0f2c83b960 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -300,6 +300,16 @@ const appLogs = baseApi< `/api/projects/${project_id}/clusters/${cluster_id}/apps/logs` ); +const appPodStatus = baseApi< + { + deployment_target_id: string; + selectors: string; + }, + { id: number; cluster_id: number } +>("GET", (pathParams) => { + return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/apps/pods`; +}); + const getFeedEvents = baseApi< {}, { @@ -3013,6 +3023,7 @@ export default { createSecretAndOpenGitHubPullRequest, getLogsWithinTimeRange, appLogs, + appPodStatus, getFeedEvents, updateStackStep, // ----------------------------------- diff --git a/dashboard/src/shared/hooks/useWebsockets.ts b/dashboard/src/shared/hooks/useWebsockets.ts index c89dd01fb2..52e15523ed 100644 --- a/dashboard/src/shared/hooks/useWebsockets.ts +++ b/dashboard/src/shared/hooks/useWebsockets.ts @@ -49,7 +49,7 @@ export const useWebsockets = () => { const url = `${protocol}://${window.location.host}${apiEndpoint}`; - const mockFunction = () => {}; + const mockFunction = () => { }; const wsConfig: WebsocketConfig = { url,