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"` }