Просмотр исходного кода

Merge branch 'nico/por-556-support-revision-list-and-rollback-from' of github.com:porter-dev/porter into nico/por-577-support-updates-for-source-configs-from

jnfrati 3 лет назад
Родитель
Сommit
586081234f

+ 52 - 25
dashboard/src/components/image-selector/ImageList.tsx

@@ -9,17 +9,31 @@ import { ImageType } from "shared/types";
 import Loading from "../Loading";
 import TagList from "./TagList";
 
-type PropsType = {
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  clickedImage: ImageType | null;
-  registry?: any;
-  noTagSelection?: boolean;
-  setSelectedImageUrl: (x: string) => void;
-  setSelectedTag: (x: string) => void;
-  setClickedImage: (x: ImageType) => void;
-  disableImageSelect?: boolean;
-};
+type PropsType =
+  | {
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      clickedImage: ImageType | null;
+      registry?: any;
+      noTagSelection?: boolean;
+      setSelectedImageUrl: (x: string) => void;
+      setSelectedTag: (x: string) => void;
+      setClickedImage: (x: ImageType) => void;
+      disableImageSelect?: boolean;
+      readOnly?: boolean;
+    }
+  | {
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      clickedImage: ImageType | null;
+      registry?: any;
+      noTagSelection?: boolean;
+      setSelectedImageUrl?: (x: string) => void;
+      setSelectedTag?: (x: string) => void;
+      setClickedImage?: (x: ImageType) => void;
+      disableImageSelect?: boolean;
+      readOnly: true;
+    };
 
 type StateType = {
   loading: boolean;
@@ -222,28 +236,41 @@ export default class ImageList extends Component<PropsType, StateType> {
   renderExpanded = () => {
     let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
 
-    if (!this.props.clickedImage || this.props.noTagSelection) {
+    if (this.props.readOnly && this.props.clickedImage) {
       return (
-        <div>
-          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
-          {this.renderBackButton()}
-        </div>
+        <ExpandedWrapper>
+          <TagList
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            registryId={this.props.clickedImage.registryId}
+            readOnly
+          />
+        </ExpandedWrapper>
       );
-    } else {
+    }
+
+    if (!this.props.clickedImage || this.props.noTagSelection) {
       return (
         <div>
-          <ExpandedWrapper>
-            <TagList
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedTag={setSelectedTag}
-              registryId={this.props.clickedImage.registryId}
-            />
-          </ExpandedWrapper>
+          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
           {this.renderBackButton()}
         </div>
       );
     }
+
+    return (
+      <div>
+        <ExpandedWrapper>
+          <TagList
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            setSelectedTag={setSelectedTag}
+            registryId={this.props.clickedImage.registryId}
+          />
+        </ExpandedWrapper>
+        {this.renderBackButton()}
+      </div>
+    );
   };
 
   render() {

+ 51 - 10
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -10,15 +10,27 @@ import { ImageType } from "shared/types";
 import Loading from "../Loading";
 import ImageList from "./ImageList";
 
-type PropsType = {
-  forceExpanded?: boolean;
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  setSelectedImageUrl: (x: string) => void;
-  setSelectedTag: (x: string) => void;
-  noTagSelection?: boolean;
-  disableImageSelect?: boolean;
-};
+type PropsType =
+  | {
+      forceExpanded?: boolean;
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      setSelectedImageUrl: (x: string) => void;
+      setSelectedTag: (x: string) => void;
+      noTagSelection?: boolean;
+      disableImageSelect?: boolean;
+      readOnly?: boolean;
+    }
+  | {
+      forceExpanded?: boolean;
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      setSelectedImageUrl?: (x: string) => void;
+      setSelectedTag?: (x: string) => void;
+      noTagSelection?: boolean;
+      disableImageSelect?: boolean;
+      readOnly: true;
+    };
 
 type StateType = {
   isExpanded: boolean;
@@ -94,7 +106,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
         <img src={icon} />
         <Input
-          disabled={this.props.disableImageSelect}
+          disabled={this.props.readOnly || this.props.disableImageSelect}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
           onChange={(e: any) => {
@@ -118,6 +130,35 @@ export default class ImageSelector extends Component<PropsType, StateType> {
   };
 
   render() {
+    if (this.props.readOnly) {
+      return (
+        <>
+          <StyledImageSelector isExpanded={true} forceExpanded={true}>
+            {this.renderSelected()}
+            {this.props.forceExpanded ? null : (
+              <i className="material-icons">
+                {this.state.isExpanded ? "close" : "build"}
+              </i>
+            )}
+          </StyledImageSelector>
+
+          <ImageList
+            disableImageSelect={true}
+            selectedImageUrl={this.props.selectedImageUrl}
+            selectedTag={this.props.selectedTag}
+            clickedImage={this.state.clickedImage}
+            noTagSelection={this.props.noTagSelection}
+            setSelectedImageUrl={this.props.setSelectedImageUrl}
+            setSelectedTag={this.props.setSelectedTag}
+            setClickedImage={(x: ImageType) =>
+              this.setState({ clickedImage: x })
+            }
+            readOnly
+          />
+        </>
+      );
+    }
+
     return (
       <div>
         <StyledImageSelector

+ 17 - 7
dashboard/src/components/image-selector/TagList.tsx

@@ -10,12 +10,21 @@ import Loading from "../Loading";
 
 var ecrRepoRegex = /(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?/gim;
 
-type PropsType = {
-  setSelectedTag: (x: string) => void;
-  selectedTag: string;
-  selectedImageUrl: string;
-  registryId: number;
-};
+type PropsType =
+  | {
+      setSelectedTag: (x: string) => void;
+      selectedTag: string;
+      selectedImageUrl: string;
+      registryId: number;
+      readOnly?: boolean;
+    }
+  | {
+      setSelectedTag?: (x: string) => void;
+      selectedTag: string;
+      selectedImageUrl: string;
+      registryId: number;
+      readOnly: true;
+    };
 
 type StateType = {
   loading: boolean;
@@ -123,7 +132,8 @@ export default class TagList extends Component<PropsType, StateType> {
       <>
         <TagNameAlt>
           <Label>
-            <img src={info} /> Select Image Tag
+            <img src={info} />
+            {this.props.readOnly ? "Current image tag" : "Select Image Tag"}
           </Label>
           <Refresh onClick={this.refreshTagList}>
             <i className="material-icons">autorenew</i> Refresh

+ 0 - 153
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx

@@ -1,153 +0,0 @@
-import Loading from "components/Loading";
-import TitleSection from "components/TitleSection";
-import React, { useContext, useEffect, useState } from "react";
-import { useParams } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { readableDate } from "shared/string_utils";
-import styled from "styled-components";
-import ChartList from "../chart/ChartList";
-import SortSelector from "../SortSelector";
-import Status from "./components/Status";
-import {
-  Br,
-  InfoWrapper,
-  LastDeployed,
-  LineBreak,
-  SepDot,
-  Text,
-} from "./components/styles";
-import { getStackStatus, getStackStatusMessage } from "./shared";
-import { Stack } from "./types";
-
-const ExpandedStack = () => {
-  const { namespace, stack_id } = useParams<{
-    namespace: string;
-    stack_id: string;
-  }>();
-  const { currentProject, currentCluster } = useContext(Context);
-
-  const [stack, setStack] = useState<Stack>();
-  const [sortType, setSortType] = useState("Alphabetical");
-  const [isLoading, setIsLoading] = useState(true);
-
-  useEffect(() => {
-    console.log(stack_id);
-    let isSubscribed = true;
-
-    api
-      .getStack(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          stack_id: stack_id,
-          namespace,
-        }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setStack(res.data);
-        }
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
-      });
-  }, [stack_id]);
-
-  if (isLoading) {
-    return <Loading />;
-  }
-
-  return (
-    <div>
-      <TitleSection
-        materialIconClass="material-icons-outlined"
-        icon={"lan"}
-        capitalize
-      >
-        {stack.name}
-      </TitleSection>
-      <Br />
-      <InfoWrapper>
-        <LastDeployed>
-          <Status
-            status={getStackStatus(stack)}
-            message={getStackStatusMessage(stack)}
-          />
-          <SepDot>•</SepDot>
-          <Text color="#aaaabb">
-            {!stack.latest_revision?.id
-              ? `No version found`
-              : `v${stack.latest_revision.id}`}
-          </Text>
-          <SepDot>•</SepDot>
-          Last updated {readableDate(stack.updated_at)}
-        </LastDeployed>
-      </InfoWrapper>
-
-      {/* Stack error message */}
-      {stack.latest_revision &&
-      stack.latest_revision.status === "failed" &&
-      stack.latest_revision.message?.length > 0 ? (
-        <StackErrorMessageStyles.Wrapper>
-          <StackErrorMessageStyles.Title color="#b7b7c9">
-            Error reason:
-          </StackErrorMessageStyles.Title>
-          <StackErrorMessageStyles.Text color="#aaaabb">
-            {stack.latest_revision.message}
-          </StackErrorMessageStyles.Text>
-        </StackErrorMessageStyles.Wrapper>
-      ) : null}
-
-      <LineBreak />
-
-      <SortSelector
-        setSortType={setSortType}
-        sortType={sortType}
-        currentView="stacks"
-      />
-
-      <ChartListWrapper>
-        <ChartList
-          currentCluster={currentCluster}
-          currentView="stacks"
-          namespace={namespace}
-          sortType="Alphabetical"
-          appFilters={
-            stack?.latest_revision?.resources?.map((res) => res.name) || []
-          }
-          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-        />
-      </ChartListWrapper>
-    </div>
-  );
-};
-
-export default ExpandedStack;
-
-const ChartListWrapper = styled.div`
-  width: 100%;
-  margin: auto;
-  margin-top: 20px;
-  padding-bottom: 125px;
-`;
-
-const StackErrorMessageStyles = {
-  Text: styled(Text)`
-    font-size: 14px;
-    margin-bottom: 10px;
-  `,
-  Wrapper: styled.div`
-    display: flex;
-    flex-direction: column;
-    margin-top: 5px;
-  `,
-  Title: styled(Text)`
-    font-size: 16px;
-    font-weight: bold;
-  `,
-};

+ 219 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -0,0 +1,219 @@
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import TabSelector from "components/TabSelector";
+import TitleSection from "components/TitleSection";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import ChartList from "../../chart/ChartList";
+import SortSelector from "../../SortSelector";
+import Status from "../components/Status";
+import {
+  Br,
+  InfoWrapper,
+  LastDeployed,
+  LineBreak,
+  SepDot,
+  Text,
+} from "../components/styles";
+import { getStackStatus, getStackStatusMessage } from "../shared";
+import { FullStackRevision, Stack, StackRevision } from "../types";
+import RevisionList from "./_RevisionList";
+import SourceConfig from "./_SourceConfig";
+
+const ExpandedStack = () => {
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+
+  const { pushFiltered } = useRouting();
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const [stack, setStack] = useState<Stack>();
+  const [sortType, setSortType] = useState("Alphabetical");
+  const [isLoading, setIsLoading] = useState(true);
+  const [currentTab, setCurrentTab] = useState("apps");
+
+  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
+
+  const getStack = async () => {
+    setIsLoading(true);
+    try {
+      const newStack = await api
+        .getStack<Stack>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            stack_id: stack_id,
+            namespace,
+          }
+        )
+        .then((res) => res.data);
+
+      setStack(newStack);
+      setCurrentRevision(newStack.latest_revision);
+      setIsLoading(false);
+    } catch (error) {
+      setCurrentError(error);
+      pushFiltered("/stacks", []);
+    }
+  };
+
+  useEffect(() => {
+    getStack();
+  }, [stack_id]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <div>
+      <TitleSection
+        materialIconClass="material-icons-outlined"
+        icon={"lan"}
+        capitalize
+      >
+        {stack.name}
+      </TitleSection>
+      <RevisionList
+        revisions={stack.revisions}
+        currentRevision={currentRevision}
+        latestRevision={stack.latest_revision}
+        stackId={stack.id}
+        stackNamespace={namespace}
+        onRevisionClick={(revision) => setCurrentRevision(revision)}
+        onRollback={() => getStack()}
+      ></RevisionList>
+      <Br />
+      <InfoWrapper>
+        <LastDeployed>
+          <Status
+            status={getStackStatus(stack)}
+            message={getStackStatusMessage(stack)}
+          />
+          <SepDot>•</SepDot>
+          <Text color="#aaaabb">
+            {!stack.latest_revision?.id
+              ? `No version found`
+              : `v${stack.latest_revision.id}`}
+          </Text>
+          <SepDot>•</SepDot>
+          Last updated {readableDate(stack.updated_at)}
+        </LastDeployed>
+      </InfoWrapper>
+
+      {/* Stack error message */}
+      {stack.latest_revision &&
+      stack.latest_revision.status === "failed" &&
+      stack.latest_revision.message?.length > 0 ? (
+        <StackErrorMessageStyles.Wrapper>
+          <StackErrorMessageStyles.Title color="#b7b7c9">
+            Error reason:
+          </StackErrorMessageStyles.Title>
+          <StackErrorMessageStyles.Text color="#aaaabb">
+            {stack.latest_revision.message}
+          </StackErrorMessageStyles.Text>
+        </StackErrorMessageStyles.Wrapper>
+      ) : null}
+
+      <TabSelector
+        currentTab={currentTab}
+        options={[
+          {
+            label: "Apps",
+            value: "apps",
+            component: (
+              <>
+                <Gap></Gap>
+                {currentRevision.id !== stack.latest_revision.id ? (
+                  <ChartListWrapper>
+                    <Placeholder>
+                      Not available when previewing versions
+                    </Placeholder>
+                  </ChartListWrapper>
+                ) : (
+                  <>
+                    <SortSelector
+                      setSortType={setSortType}
+                      sortType={sortType}
+                      currentView="stacks"
+                    />
+
+                    <ChartListWrapper>
+                      <ChartList
+                        currentCluster={currentCluster}
+                        currentView="stacks"
+                        namespace={namespace}
+                        sortType="Alphabetical"
+                        appFilters={
+                          stack?.latest_revision?.resources?.map(
+                            (res) => res.name
+                          ) || []
+                        }
+                        closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+                      />
+                    </ChartListWrapper>
+                  </>
+                )}
+              </>
+            ),
+          },
+          {
+            label: "Source Config",
+            value: "source_config",
+            component: (
+              <>
+                <SourceConfig revision={currentRevision}></SourceConfig>
+              </>
+            ),
+          },
+        ]}
+        setCurrentTab={(tab) => {
+          setCurrentTab(tab);
+        }}
+      ></TabSelector>
+    </div>
+  );
+};
+
+export default ExpandedStack;
+
+const ChartListWrapper = styled.div`
+  width: 100%;
+  margin: auto;
+  margin-top: 20px;
+  padding-bottom: 125px;
+`;
+
+const Gap = styled.div`
+  width: 100%;
+  background: none;
+  height: 30px;
+`;
+
+const StackErrorMessageStyles = {
+  Text: styled(Text)`
+    font-size: 14px;
+    margin-bottom: 10px;
+  `,
+  Wrapper: styled.div`
+    display: flex;
+    flex-direction: column;
+    margin-top: 5px;
+  `,
+  Title: styled(Text)`
+    font-size: 16px;
+    font-weight: bold;
+  `,
+};

+ 290 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -0,0 +1,290 @@
+import Loading from "components/Loading";
+import React, { useContext, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import { FullStackRevision, Stack, StackRevision } from "../types";
+
+type RevisionListProps = {
+  revisions: StackRevision[];
+  currentRevision: StackRevision;
+  latestRevision: StackRevision;
+  stackNamespace: string;
+  stackId: string;
+  onRevisionClick: (revision: FullStackRevision) => void;
+  onRollback: () => void;
+};
+
+const _RevisionList = ({
+  revisions,
+  currentRevision,
+  latestRevision,
+  stackNamespace,
+  stackId,
+  onRevisionClick,
+  onRollback,
+}: RevisionListProps) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [isLoading, setIsLoading] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const revisionCache = useRef<{ [id: number]: FullStackRevision }>({});
+
+  const handleRevisionPreview = (revision: StackRevision) => {
+    setIsLoading(true);
+
+    if (revisionCache.current[revision.id]) {
+      onRevisionClick(revisionCache.current[revision.id]);
+      setIsLoading(false);
+      return;
+    }
+
+    api
+      .getStackRevision<FullStackRevision>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stackNamespace,
+          revision_id: revision.id,
+          stack_id: stackId,
+        }
+      )
+      .then((res) => {
+        const newRevision = res.data;
+        revisionCache.current = {
+          ...revisionCache.current,
+          [newRevision.id]: newRevision,
+        };
+        onRevisionClick(newRevision);
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  };
+
+  const handleRevisionRollback = (revision: StackRevision) => {
+    setIsLoading(true);
+
+    api
+      .rollbackStack(
+        "<token>",
+        {
+          target_revision: revision.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stackNamespace,
+          stack_id: stackId,
+        }
+      )
+      .then(() => {
+        onRollback();
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  };
+
+  const revisionList = () => {
+    if (revisions.length === 0) {
+      return <div>No revisions</div>;
+    }
+
+    return revisions.map((revision, i) => {
+      let isCurrent = latestRevision.id === revision.id;
+      return (
+        <Tr
+          key={i}
+          onClick={() => handleRevisionPreview(revision)}
+          selected={currentRevision.id === revision.id}
+        >
+          <Td>{revision.id}</Td>
+          <Td>{readableDate(revision.created_at)}</Td>
+          <Td>
+            <RollbackButton
+              disabled={isCurrent}
+              onClick={(e) => {
+                e.stopPropagation();
+                handleRevisionRollback(revision);
+              }}
+            >
+              {isCurrent ? "Current" : "Revert"}
+            </RollbackButton>
+          </Td>
+        </Tr>
+      );
+    });
+  };
+
+  return (
+    <>
+      <StyledRevisionSection showRevisions={isExpanded}>
+        {isLoading ? (
+          <LoadingOverlay>
+            <Loading />
+          </LoadingOverlay>
+        ) : null}
+        <RevisionHeader
+          showRevisions={isExpanded}
+          isCurrent={currentRevision.id === latestRevision.id}
+          onClick={() => setIsExpanded((prev) => !prev)}
+        >
+          <RevisionPreview>
+            {currentRevision.id === latestRevision.id
+              ? `Current Revision v${currentRevision.id}`
+              : `Previewing Revision (Not Deployed) v${currentRevision.id}`}
+            <i className="material-icons">arrow_drop_down</i>
+          </RevisionPreview>
+        </RevisionHeader>
+        <TableWrapper>
+          <RevisionsTable>
+            <tbody>
+              <Tr disableHover={true}>
+                <Th>Revision No.</Th>
+                <Th>Timestamp</Th>
+                <Th>Rollback</Th>
+              </Tr>
+              {revisionList()}
+            </tbody>
+          </RevisionsTable>
+        </TableWrapper>
+      </StyledRevisionSection>
+    </>
+  );
+};
+
+export default _RevisionList;
+
+const StyledRevisionSection = styled.div`
+  position: relative;
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "255px" : "40px"};
+  background: #ffffff11;
+  margin: 25px 0px 18px;
+  overflow: hidden;
+  border-radius: 8px;
+  animation: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "expandRevisions 0.3s " : ""};
+  animation-timing-function: "ease-out";
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 15px;
+  cursor: pointer;
+  background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.showRevisions ? "#ffffff11" : ""};
+  :hover {
+    background: #ffffff18;
+    > div > i {
+      background: #ffffff22;
+    }
+  }
+
+  > div > i {
+    margin-left: 12px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "#ffffff18" : ""};
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "rotate(180deg)" : ""};
+  }
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+const Tr = styled.tr`
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#405eddbb"};
+  }
+`;
+
+const LoadingOverlay = styled.div`
+  background: #43454b90;
+  width: 100%;
+  height: 100%;
+  position: absolute;
+`;

+ 105 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -0,0 +1,105 @@
+import { Tooltip } from "@material-ui/core";
+import ImageSelector from "components/image-selector/ImageSelector";
+import React from "react";
+import styled from "styled-components";
+import { AppResource, FullStackRevision, SourceConfig } from "../types";
+
+const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
+  return (
+    <SourceConfigStyles.Wrapper>
+      {revision.source_configs.map((sourceConfig) => {
+        const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
+
+        const appList = formatAppList(apps, 2);
+        console.log({ appList });
+        return (
+          <SourceConfigStyles.ItemContainer>
+            {appList.hiddenApps?.length ? (
+              <Tooltip
+                title={
+                  <>
+                    {appList.hiddenApps.map((appName) => (
+                      <SourceConfigStyles.TooltipItem>
+                        {appName}
+                      </SourceConfigStyles.TooltipItem>
+                    ))}
+                  </>
+                }
+                placement={"bottom-end"}
+              >
+                <SourceConfigStyles.ItemTitle>
+                  Used by {appList.value}
+                </SourceConfigStyles.ItemTitle>
+              </Tooltip>
+            ) : (
+              <SourceConfigStyles.ItemTitle>
+                Used by {appList.value}
+              </SourceConfigStyles.ItemTitle>
+            )}
+            <ImageSelector
+              selectedImageUrl={sourceConfig.image_repo_uri}
+              selectedTag={sourceConfig.image_tag}
+              forceExpanded
+              readOnly
+            />
+          </SourceConfigStyles.ItemContainer>
+        );
+      })}
+    </SourceConfigStyles.Wrapper>
+  );
+};
+
+export default _SourceConfig;
+
+const getAppsFromSourceConfig = (
+  apps: AppResource[],
+  sourceConfig: SourceConfig
+) => {
+  return apps.filter((app) => {
+    return app.stack_source_config.id === sourceConfig.id;
+  });
+};
+
+const formatAppList = (apps: AppResource[], limit: number = 3) => {
+  if (apps.length <= limit) {
+    const formatter = new Intl.ListFormat("en", {
+      style: "long",
+      type: "conjunction",
+    });
+    return {
+      value: formatter.format(apps.map((app) => app.name)),
+      hiddenApps: [],
+    };
+  }
+
+  const hiddenApps = [...apps]
+    .splice(limit, apps.length)
+    .map((app) => app.name);
+
+  return {
+    value: apps
+      .map((app) => app.name)
+      .splice(0, limit)
+      .join(", ")
+      .concat(` and ${apps.length - limit} more`),
+    hiddenApps,
+  };
+};
+
+const SourceConfigStyles = {
+  Wrapper: styled.div`
+    margin-top: 30px;
+  `,
+  ItemContainer: styled.div`
+    background: #ffffff11;
+    border-radius: 15px;
+    padding: 20px 15px;
+  `,
+  ItemTitle: styled.div`
+    font-size: 16px;
+    width: fit-content;
+  `,
+  TooltipItem: styled.div`
+    font-size: 14px;
+  `,
+};

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx

@@ -8,7 +8,7 @@ import {
 } from "react-router";
 import { Context } from "shared/Context";
 import Dashboard from "./Dashboard";
-import ExpandedStack from "./ExpandedStack";
+import ExpandedStack from "./ExpandedStack/ExpandedStack";
 import LaunchRoutes from "./launch";
 
 const routes = () => {

+ 6 - 4
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -34,10 +34,12 @@ export type Stack = {
 
   revisions: StackRevision[];
 
-  latest_revision: StackRevision & {
-    resources: AppResource[];
-    source_configs: SourceConfig[];
-  };
+  latest_revision: FullStackRevision;
+};
+
+export type FullStackRevision = StackRevision & {
+  resources: AppResource[];
+  source_configs: SourceConfig[];
 };
 
 export type StackRevision = {

+ 4 - 2
dashboard/src/shared/api.tsx

@@ -1991,7 +1991,7 @@ const getStackRevision = baseApi<
     cluster_id: number;
     namespace: string;
     stack_id: string;
-    revision_id: string;
+    revision_id: number;
   }
 >(
   "GET",
@@ -2000,7 +2000,9 @@ const getStackRevision = baseApi<
 );
 
 const rollbackStack = baseApi<
-  {},
+  {
+    target_revision: number;
+  },
   {
     project_id: number;
     cluster_id: number;