Преглед изворни кода

Merge branch '0.5.0-live-update-on-chart-revisions' of https://github.com/porter-dev/porter into 0.5.0-live-update-on-chart-revisions

jusrhee пре 4 година
родитељ
комит
2aa3ea7fa6

+ 4 - 1
dashboard/src/components/values-form/FormWrapper.tsx

@@ -78,7 +78,10 @@ export default class FormWrapper extends Component<PropsType, StateType> {
       };
       if (tabs) {
         tabs.forEach((tab: any, i: number) => {
-          if (tab?.name && tab.label) {
+          // Exclude value if omitFromLaunch is set
+          let omit =
+            tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
+          if (tab?.name && tab.label && !omit) {
             // If a tab is valid, extract state
             tab.sections?.forEach((section: Section, i: number) => {
               section?.contents?.forEach((item: FormElement, i: number) => {

+ 27 - 2
dashboard/src/components/values-form/Heading.tsx

@@ -1,9 +1,20 @@
 import React from "react";
 import styled from "styled-components";
 
-export default function Heading(props: { isAtTop?: boolean; children: any }) {
+export default function Heading(props: {
+  isAtTop?: boolean;
+  children: any;
+  docs?: string;
+}) {
   return (
-    <StyledHeading isAtTop={props.isAtTop}>{props.children}</StyledHeading>
+    <StyledHeading isAtTop={props.isAtTop}>
+      {props.children}
+      {props.docs && (
+        <a href={props.docs} target="_blank">
+          <i className="material-icons">help_outline</i>
+        </a>
+      )}
+    </StyledHeading>
   );
 }
 
@@ -15,4 +26,18 @@ const StyledHeading = styled.div<{ isAtTop: boolean }>`
   margin-bottom: 5px;
   display: flex;
   align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 16px;
+      margin-left: 12px;
+      color: #858faaaa;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
 `;

+ 114 - 0
dashboard/src/components/values-form/ServiceRow.tsx

@@ -0,0 +1,114 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { hardcodedNames, hardcodedIcons } from "shared/hardcodedNameDict";
+
+type PropsType = {
+  service: {
+    clusterIP: string;
+    name: string;
+    release: string;
+    app: string;
+    namespace: string;
+    type?: string;
+  };
+};
+
+type StateType = any;
+
+export default class ServiceRow extends Component<PropsType, StateType> {
+  render() {
+    let { clusterIP, name, namespace, type, app, release } = this.props.service;
+    name = name || release;
+    type = type || app;
+    return (
+      <>
+        {name &&
+          type &&
+          hardcodedNames[type] &&
+          hardcodedIcons[type] &&
+          namespace !== "kube-system" && (
+            <StyledServiceRow>
+              <Flex>
+                <Icon src={hardcodedIcons[type]} />
+                <Type>{hardcodedNames[type]}</Type>
+                <Name>{name}</Name> <Dash>-</Dash> <IP>{clusterIP}</IP>
+              </Flex>
+              <TagWrapper>
+                Namespace: <NamespaceTag>{namespace}</NamespaceTag>
+              </TagWrapper>
+            </StyledServiceRow>
+          )}
+      </>
+    );
+  }
+}
+
+ServiceRow.contextType = Context;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const TagWrapper = styled.div`
+  float: right;
+  height: 20px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border-right: 0;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  border-radius: 3px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-left: 3px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Dash = styled.div`
+  margin-right: 10px;
+`;
+
+const Icon = styled.img`
+  width: 20px;
+  margin-right: 12px;
+`;
+
+const Type = styled.div`
+  color: #aaaabb;
+  margin-right: 15px;
+`;
+
+const Name = styled.div`
+  margin-right: 10px;
+`;
+
+const IP = styled.div`
+  user-select: text;
+  font-weight: 500;
+`;
+
+const StyledServiceRow = styled.div`
+  width: 100%;
+  height: 40px;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  border-radius: 5px;
+  padding: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;

+ 27 - 9
dashboard/src/components/values-form/ValuesForm.tsx

@@ -18,6 +18,7 @@ import SelectRow from "./SelectRow";
 import Helper from "./Helper";
 import Heading from "./Heading";
 import ExpandableResource from "../ExpandableResource";
+import ServiceRow from "./ServiceRow";
 import VeleroForm from "../forms/VeleroForm";
 import InputArray from "./InputArray";
 import KeyValueArray from "./KeyValueArray";
@@ -68,22 +69,38 @@ export default class ValuesForm extends Component<PropsType, StateType> {
 
       switch (item.type) {
         case "heading":
-          return <Heading key={i}>{item.label}</Heading>;
+          return (
+            <Heading key={i} docs={item.settings?.docs}>
+              {item.label}
+            </Heading>
+          );
         case "subtitle":
           return <Helper key={i}>{item.label}</Helper>;
+        case "service-ip-list":
+          if (Array.isArray(item.value)) {
+            return (
+              <ResourceList key={key}>
+                {item.value?.map((service: any, i: number) => {
+                  return <ServiceRow service={service} key={i} />;
+                })}
+              </ResourceList>
+            );
+          }
         case "resource-list":
           if (Array.isArray(item.value)) {
             return (
               <ResourceList key={key}>
                 {item.value?.map((resource: any, i: number) => {
-                  return (
-                    <ExpandableResource
-                      key={i}
-                      resource={resource}
-                      isLast={i === item.value.length - 1}
-                      roundAllCorners={true}
-                    />
-                  );
+                  if (resource.data) {
+                    return (
+                      <ExpandableResource
+                        key={i}
+                        resource={resource}
+                        isLast={i === item.value.length - 1}
+                        roundAllCorners={true}
+                      />
+                    );
+                  }
                 })}
               </ResourceList>
             );
@@ -181,6 +198,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <InputRow
               key={key}
               width="100%"
+              placeholder={item.placeholder}
               isRequired={item.required}
               type="password"
               value={this.getInputValue(item)}

+ 7 - 2
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -11,9 +11,14 @@ import api from "shared/api";
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
+  release: any;
 };
 
-const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
+const Chart: React.FunctionComponent<Props> = ({
+  chart,
+  controllers,
+  release,
+}) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
   const context = useContext(Context);
@@ -115,7 +120,7 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
         </TagWrapper>
       </BottomWrapper>
 
-      <Version>v{chart.version}</Version>
+      <Version>v{release?.metadata?.labels?.version || chart.version}</Version>
     </StyledChart>
   );
 };

+ 268 - 238
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -1,238 +1,268 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ChartType, StorageType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
-
-import Chart from "./Chart";
-import Loading from "components/Loading";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-type Props = {
-  currentCluster: ClusterType;
-  namespace: string;
-  // TODO Convert to enum
-  sortType: string;
-  currentView: PorterUrl;
-};
-
-const ChartList: React.FunctionComponent<Props> = ({
-  namespace,
-  sortType,
-  currentView,
-}) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-  const [charts, setCharts] = useState<ChartType[]>([]);
-  const [controllers, setControllers] = useState<
-    Record<string, Record<string, any>>
-  >({});
-  const [isLoading, setIsLoading] = useState(false);
-  const [isError, setIsError] = useState(false);
-
-  const context = useContext(Context);
-
-  const updateCharts = async () => {
-    try {
-      const { currentCluster, currentProject } = context;
-      setIsLoading(true);
-      const res = await api.getCharts(
-        "<token>",
-        {
-          namespace: namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-          limit: 50,
-          skip: 0,
-          byDate: false,
-          statusFilter: [
-            "deployed",
-            "uninstalled",
-            "pending",
-            "pending-install",
-            "pending-upgrade",
-            "pending-rollback",
-            "superseded",
-            "failed",
-          ],
-        },
-        { id: currentProject.id }
-      );
-      const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
-      setIsError(false);
-      return sortedCharts;
-    } catch (error) {
-      console.log(error);
-      context.setCurrentError(JSON.stringify(error));
-      setIsError(true);
-    }
-  };
-
-  const setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = context;
-    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
-
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [object.metadata.uid]: object,
-        }));
-      },
-      onclose: () => {
-        console.log("closing websocket");
-      },
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(kind);
-      },
-    };
-
-    newWebsocket(kind, apiPath, wsConfig);
-
-    openWebsocket(kind);
-  };
-
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
-  };
-
-  // Setup basic websockets on start
-  useEffect(() => {
-    setControllerWebsockets([
-      "deployment",
-      "statefulset",
-      "daemonset",
-      "replicaset",
-    ]);
-
-    return () => {
-      closeAllWebsockets();
-    };
-  }, []);
-
-  useEffect(() => {
-    let isSubscribed = true;
-
-    if (namespace || namespace === "") {
-      updateCharts().then((charts) => {
-        if (isSubscribed) {
-          setCharts(charts);
-          setIsLoading(false);
-        }
-      });
-    }
-    return () => (isSubscribed = false);
-  }, [namespace, currentView]);
-
-  const renderChartList = () => {
-    if (isLoading || (!namespace && namespace !== "")) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (isError) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error connecting to cluster.
-        </Placeholder>
-      );
-    } else if (charts.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
-        </Placeholder>
-      );
-    }
-
-    return charts.map((chart: ChartType, i: number) => {
-      return (
-        <Chart
-          key={`${chart.namespace}-${chart.name}`}
-          chart={chart}
-          controllers={controllers || {}}
-        />
-      );
-    });
-  };
-
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
-};
-
-export default ChartList;
-
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 320px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
-const LoadingWrapper = styled.div`
-  padding-top: 100px;
-`;
-
-const StyledChartList = styled.div`
-  padding-bottom: 85px;
-`;
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
+
+import Chart from "./Chart";
+import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  currentCluster: ClusterType;
+  namespace: string;
+  // TODO Convert to enum
+  sortType: string;
+  currentView: PorterUrl;
+};
+
+const ChartList: React.FunctionComponent<Props> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          namespace: namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "superseded",
+            "failed",
+          ],
+        },
+        { id: currentProject.id }
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    }
+  };
+
+  const handleReleaseWSNotification = (object: any) => {
+    if (object.type === "helm.sh/release.v1") {
+      setReleases((oldReleases) => {
+        const currentRelease = oldReleases[object.metadata.labels.name];
+        const currentReleaseVersion = Number(
+          currentRelease?.metadata?.labels?.version
+        );
+        const newReleaseVersion = Number(object?.metadata?.labels?.version);
+        if (currentReleaseVersion > newReleaseVersion) {
+          return {
+            ...oldReleases,
+          };
+        }
+
+        return {
+          ...oldReleases,
+          [object.metadata.labels.name]: object,
+        };
+      });
+    }
+  };
+
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        if (event.Kind === "secrets") {
+          handleReleaseWSNotification(object);
+          return;
+        }
+
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiPath, wsConfig);
+
+    openWebsocket(kind);
+  };
+
+  const setControllerWebsockets = (controllers: any[]) => {
+    controllers.map((kind: string) => {
+      return setupWebsocket(kind);
+    });
+  };
+
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
+      "deployment",
+      "statefulset",
+      "daemonset",
+      "replicaset",
+      "secrets",
+    ]);
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          setIsLoading(false);
+        }
+      });
+    }
+    return () => (isSubscribed = false);
+  }, [namespace, currentView]);
+
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (isError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (charts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
+        </Placeholder>
+      );
+    }
+
+    return charts.map((chart: ChartType, i: number) => {
+      return (
+        <Chart
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
+          controllers={controllers || {}}
+          release={releases[chart.name] || {}}
+        />
+      );
+    });
+  };
+
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
+
+export default ChartList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 85px;
+`;

+ 60 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -67,6 +67,66 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
   componentDidMount() {
     this.refreshHistory();
+    this.connectToLiveUpdates();
+  }
+
+  connectToLiveUpdates() {
+    let { chart } = this.props;
+    let { currentCluster, currentProject } = this.context;
+
+    const apiPath = `/api/projects/${currentProject.id}/k8s/helm_releases?cluster_id=${currentCluster.id}&charts=${chart.name}`;
+    const protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    const url = `${protocol}://${window.location.host}`;
+
+    const ws = new WebSocket(`${url}${apiPath}`);
+
+    ws.onopen = () => {
+      console.log("connected to chart live updates websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+
+      if (event.event_type == "UPDATE") {
+        let object = event.Object;
+
+        this.setState(
+          (prevState) => {
+            const { revisions: oldRevisions } = prevState;
+            // Copy old array to clean up references
+            const prevRevisions = [...oldRevisions];
+
+            // Check if it's an update of a revision or if it's a new one
+            const revisionIndex = prevRevisions.findIndex((rev) => {
+              if (rev.version === object.version) {
+                return true;
+              }
+            });
+
+            // Place new one at top of the array or update the old one
+            if (revisionIndex > -1) {
+              prevRevisions.splice(revisionIndex, 1, object);
+            } else {
+              return { ...prevState, revisions: [object, ...prevRevisions] };
+            }
+
+            return { ...prevState, revisions: prevRevisions };
+          },
+          () => {
+            this.props.setRevision(this.state.revisions[0], true);
+          }
+        );
+      }
+    };
+
+    ws.onclose = () => {
+      console.log("closing chart live updates websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
   }
 
   // Handle update of values.yaml

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -11,7 +11,7 @@ import Loading from "components/Loading";
 import LaunchFlow from "./launch-flow/LaunchFlow";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 
-import hardcodedNames from "./hardcodedNameDict";
+import { hardcodedNames } from "shared/hardcodedNameDict";
 import semver from "semver";
 
 const tabOptions = [

+ 1 - 1
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -9,7 +9,7 @@ import { PorterTemplate } from "shared/types";
 import Helper from "components/values-form/Helper";
 import Selector from "components/Selector";
 
-import hardcodedNames from "../hardcodedNameDict";
+import { hardcodedNames } from "shared/hardcodedNameDict";
 
 type PropsType = {
   currentTemplate: any;

+ 0 - 20
dashboard/src/main/home/launch/hardcodedNameDict.tsx

@@ -1,20 +0,0 @@
-const hardcodedNames: { [key: string]: string } = {
-  docker: "Docker",
-  "https-issuer": "HTTPS Issuer",
-  metabase: "Metabase",
-  mongodb: "MongoDB",
-  mysql: "MySQL",
-  postgresql: "PostgreSQL",
-  redis: "Redis",
-  ubuntu: "Ubuntu",
-  web: "Web Service",
-  worker: "Worker",
-  job: "Job",
-  "cert-manager": "Cert Manager",
-  elasticsearch: "Elasticsearch",
-  prometheus: "Prometheus",
-  rabbitmq: "RabbitMQ",
-  logdna: "LogDNA",
-};
-
-export default hardcodedNames;

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -8,7 +8,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { pushFiltered } from "shared/routing";
 
-import hardcodedNames from "../hardcodedNameDict";
+import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
 import SettingsPage from "./SettingsPage";
 

+ 53 - 0
dashboard/src/shared/hardcodedNameDict.tsx

@@ -0,0 +1,53 @@
+const hardcodedNames: { [key: string]: string } = {
+  docker: "Docker",
+  "https-issuer": "HTTPS Issuer",
+  metabase: "Metabase",
+  mongodb: "MongoDB",
+  mysql: "MySQL",
+  postgresql: "PostgreSQL",
+  redis: "Redis",
+  ubuntu: "Ubuntu",
+  web: "Web Service",
+  worker: "Worker",
+  job: "Job",
+  "cert-manager": "Cert Manager",
+  elasticsearch: "Elasticsearch",
+  prometheus: "Prometheus",
+  rabbitmq: "RabbitMQ",
+  logdna: "LogDNA",
+  "tailscale-relay": "Tailscale",
+};
+
+const hardcodedIcons: { [key: string]: string } = {
+  "https-issuer":
+    "https://cdn4.iconfinder.com/data/icons/macster-2/100/https__-512.png",
+  metabase:
+    "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
+  mongodb:
+    "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
+  mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
+  postgresql:
+    "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",
+  redis:
+    "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png",
+  ubuntu: "Ubuntu",
+  web:
+    "https://user-images.githubusercontent.com/65516095/111255214-07d3da80-85ed-11eb-99e2-fddcbdb99bdb.png",
+  worker:
+    "https://user-images.githubusercontent.com/65516095/111255250-1b7f4100-85ed-11eb-8bd1-7b17be3e0e06.png",
+  job:
+    "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png",
+  "cert-manager":
+    "https://raw.githubusercontent.com/jetstack/cert-manager/master/logo/logo.png",
+  elasticsearch:
+    "https://ria.gallerycdn.vsassets.io/extensions/ria/elastic/0.13.3/1530754501320/Microsoft.VisualStudio.Services.Icons.Default",
+  prometheus:
+    "https://raw.githubusercontent.com/prometheus/prometheus.github.io/master/assets/prometheus_logo-cb55bb5c346.png",
+  rabbitmq:
+    "https://bitnami.com/assets/stacks/rabbitmq/img/rabbitmq-stack-220x234.png",
+  logdna:
+    "https://user-images.githubusercontent.com/65516095/118185526-a2447480-b40a-11eb-9bdb-82aa0a306f26.png",
+  "tailscale-relay": "Tailscale",
+};
+
+export { hardcodedNames, hardcodedIcons };

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -135,6 +135,7 @@ export interface FormElement {
   placeholder?: string;
   value?: any;
   settings?: {
+    docs?: string;
     default?: number | string | boolean;
     options?: any[];
     omitUnitFromValue?: boolean;

+ 215 - 1
internal/kubernetes/agent.go

@@ -3,10 +3,13 @@ package kubernetes
 import (
 	"bufio"
 	"bytes"
+	"compress/gzip"
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
@@ -45,6 +48,8 @@ import (
 	"k8s.io/client-go/tools/remotecommand"
 
 	"github.com/porter-dev/porter/internal/config"
+
+	rspb "helm.sh/helm/v3/pkg/release"
 )
 
 // Agent is a Kubernetes agent for performing operations that interact with the
@@ -556,6 +561,8 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 		informer = factory.Core().V1().Namespaces().Informer()
 	case "pod":
 		informer = factory.Core().V1().Pods().Informer()
+	case "secrets":
+		informer = factory.Core().V1().Secrets().Informer()
 	}
 
 	stopper := make(chan struct{})
@@ -605,7 +612,214 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
 				defer conn.Close()
-				defer close(stopper)
+				close(stopper)
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
+}
+
+var b64 = base64.StdEncoding
+
+var magicGzip = []byte{0x1f, 0x8b, 0x08}
+
+func decodeRelease(data string) (*rspb.Release, error) {
+	// base64 decode string
+	b, err := b64.DecodeString(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// For backwards compatibility with releases that were stored before
+	// compression was introduced we skip decompression if the
+	// gzip magic header is not found
+	if bytes.Equal(b[0:3], magicGzip) {
+		r, err := gzip.NewReader(bytes.NewReader(b))
+		if err != nil {
+			return nil, err
+		}
+		defer r.Close()
+		b2, err := ioutil.ReadAll(r)
+		if err != nil {
+			return nil, err
+		}
+		b = b2
+	}
+
+	var rls rspb.Release
+	// unmarshal release object bytes
+	if err := json.Unmarshal(b, &rls); err != nil {
+		return nil, err
+	}
+	return &rls, nil
+}
+
+func contains(s []string, str string) bool {
+	for _, v := range s {
+		if v == str {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
+	tweakListOptionsFunc := func(options *metav1.ListOptions) {
+		options.LabelSelector = selectors
+	}
+
+	factory := informers.NewSharedInformerFactoryWithOptions(
+		a.Clientset,
+		0,
+		informers.WithTweakListOptions(tweakListOptionsFunc),
+	)
+
+	informer := factory.Core().V1().Secrets().Informer()
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(errorchan)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			secretObj, ok := newObj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			if secretObj.Type != "helm.sh/release.v1" {
+				return
+			}
+
+			releaseData, ok := secretObj.Data["release"]
+
+			if !ok {
+				errorchan <- fmt.Errorf("release field not found")
+				return
+			}
+
+			helm_object, err := decodeRelease(string(releaseData))
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			if len(chartList) > 0 && !contains(chartList, helm_object.Name) {
+				return
+			}
+
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		AddFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if secretObj.Type != "helm.sh/release.v1" {
+				return
+			}
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			releaseData, ok := secretObj.Data["release"]
+
+			if !ok {
+				errorchan <- fmt.Errorf("release field not found")
+				return
+			}
+
+			helm_object, err := decodeRelease(string(releaseData))
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			if len(chartList) > 0 && !contains(chartList, helm_object.Name) {
+				return
+			}
+
+			msg := Message{
+				EventType: "ADD",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if secretObj.Type != "helm.sh/release.v1" {
+				return
+			}
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			releaseData, ok := secretObj.Data["release"]
+
+			if !ok {
+				errorchan <- fmt.Errorf("release field not found")
+				return
+			}
+
+			helm_object, err := decodeRelease(string(releaseData))
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			if (len(chartList) > 0) && !contains(chartList, helm_object.Name) {
+				return
+			}
+
+			msg := Message{
+				EventType: "DELETE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				close(stopper)
 				errorchan <- nil
 				return
 			}

+ 4 - 0
internal/models/templates.go

@@ -30,6 +30,9 @@ type FormTab struct {
 	Name     string         `yaml:"name" json:"name"`
 	Label    string         `yaml:"label" json:"label"`
 	Sections []*FormSection `yaml:"sections" json:"sections,omitempty"`
+	Settings struct {
+		OmitFromLaunch bool `yaml:"omitFromLaunch,omitempty" json:"omitFromLaunch,omitempty"`
+	} `yaml:"settings,omitempty" json:"settings,omitempty"`
 }
 
 // FormSection is a section of a form
@@ -51,6 +54,7 @@ type FormContent struct {
 	Placeholder string       `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
 	Value       interface{}  `yaml:"value,omitempty" json:"value,omitempty"`
 	Settings    struct {
+		Docs               string      `yaml:"docs,omitempty" json:"docs,omitempty"`
 		Default            interface{} `yaml:"default,omitempty" json:"default,omitempty"`
 		Unit               interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
 		OmitUnitFromValue  bool        `yaml:"omitUnitFromValue,omitempty" json:"omitUnitFromValue,omitempty"`

+ 69 - 0
server/api/k8s_handler.go

@@ -1073,6 +1073,75 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	}
 }
 
+func (app *App) HandleStreamHelmReleases(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get session to retrieve correct kubeconfig
+	_, err = app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	selectors := ""
+	if vals["selectors"] != nil {
+		selectors = vals["selectors"][0]
+	}
+
+	var chartList []string
+
+	if vals["charts"] != nil {
+		chartList = vals["charts"]
+	}
+
+	err = agent.StreamHelmReleases(conn, chartList, selectors)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}
+
 // HandleDetectPrometheusInstalled detects a prometheus installation in the target cluster
 func (app *App) HandleDetectPrometheusInstalled(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)

+ 14 - 0
server/router/router.go

@@ -1314,6 +1314,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/k8s/helm_releases",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleStreamHelmReleases, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/pods",