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

Merge pull request #53 from porter-dev/frontend-integration

Frontend integration
abelanger5 5 лет назад
Родитель
Сommit
9fc5c9086d

+ 4 - 2
dashboard/src/components/YamlEditor.tsx

@@ -49,6 +49,7 @@ class YamlEditor extends Component<PropsType, StateType> {
             name='codeEditor'
             editorProps={{ $blockScrolling: true }}
             width='100%'
+            height='295px'
             style={{ borderRadius: '5px' }}
           />
         </Editor>
@@ -65,9 +66,10 @@ const Editor = styled.form`
   width: calc(100% - 0px);
   border-radius: 5px;
   border: 1px solid #ffffff22;
-  height: 295px;
-  overflow: auto;
 `;
 
 const Holder = styled.div`
+  .ace_scrollbar {
+    display: none;
+  }
 `;

+ 47 - 7
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -3,26 +3,57 @@ import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
 import { Context } from '../../../shared/Context';
+import { ChartType } from '../../../shared/types';
+import api from '../../../shared/api';
 
 import ChartList from './chart/ChartList';
 import NamespaceSelector from './NamespaceSelector';
+import ExpandedChart from './expanded-chart/ExpandedChart';
 
 type PropsType = {
 };
 
 type StateType = {
-  namespace: string
+  namespace: string,
+  currentChart: ChartType | null
 };
 
 export default class Dashboard extends Component<PropsType, StateType> {
   state = {
-    namespace: ''
+    namespace: '',
+    currentChart: null as (ChartType | null)
   }
 
-  render() {
+  // Allows rollback to update the top-level chart
+  refreshChart = () => {
+    let { currentCluster } = this.context;
+    api.getChart('<token>', {
+      namespace: this.state.namespace,
+      context: currentCluster,
+      storage: 'secret'
+    }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ currentChart: res.data });
+      }
+    });
+  }
+
+  renderContents = () => {
     let { currentCluster } = this.context;
 
-    return ( 
+    if (this.state.currentChart) {
+      return (
+        <ExpandedChart
+          currentChart={this.state.currentChart}
+          refreshChart={this.refreshChart}
+          setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
+        />
+      );
+    }
+
+    return (
       <div>
         <TitleSection>
           <ProjectIcon>
@@ -39,14 +70,14 @@ export default class Dashboard extends Component<PropsType, StateType> {
               <i className="material-icons">info</i> Info
             </InfoLabel>
           </TopRow>
-            <Description>Porter dashboard for {currentCluster}.</Description>
+          <Description>Porter dashboard for {currentCluster}.</Description>
         </InfoSection>
 
         <LineBreak />
         
         <ControlRow>
           <Button disabled={true}>
-            <i className="material-icons">add</i> Add a Chart
+            <i className="material-icons">add</i> Deploy a Chart
           </Button>
           <NamespaceSelector
             setNamespace={(namespace) => this.setState({ namespace })}
@@ -58,10 +89,19 @@ export default class Dashboard extends Component<PropsType, StateType> {
         <ChartList
           currentCluster={currentCluster}
           namespace={this.state.namespace}
+          setCurrentChart={(x: ChartType) => this.setState({ currentChart: x })}
         />
       </div>
     );
   }
+
+  render() {
+    return (
+      <div>
+        {this.renderContents()}
+      </div>
+    );
+  }
 }
 
 Dashboard.contextType = Context;
@@ -142,7 +182,7 @@ const Button = styled.div`
     border-radius: 20px;
     display: flex;
     align-items: center;
-    margin-right: 8px;
+    margin-right: 5px;
     justify-content: center;
   }
 `;

+ 3 - 2
dashboard/src/main/home/dashboard/NamespaceSelector.tsx

@@ -29,9 +29,10 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
 
     api.getNamespaces('<token>', { context: currentCluster }, {}, (err: any, res: any) => {
       if (err) {
-        setCurrentError('Could not read clusters: ' + JSON.stringify(err));
+        // setCurrentError('Could not read clusters: ' + JSON.stringify(err));
+        this.setState({ namespaceOptions: [{ label: 'All', value: '' }] });
       } else {
-        let namespaceOptions: { label: string, value: string }[] = [];
+        let namespaceOptions: { label: string, value: string }[] = [{ label: 'All', value: '' }];
         res.data.items.forEach((x: { metadata: { name: string }}, i: number) => {
           namespaceOptions.push({ label: x.metadata.name, value: x.metadata.name });
         })

+ 44 - 25
dashboard/src/main/home/dashboard/chart/Chart.tsx

@@ -2,9 +2,11 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { ChartType } from '../../../../shared/types';
+import { Context } from '../../../../shared/Context';
 
 type PropsType = {
-  chart: ChartType
+  chart: ChartType,
+  setCurrentChart: (c: ChartType) => void
 };
 
 type StateType = {
@@ -33,12 +35,14 @@ export default class Chart extends Component<PropsType, StateType> {
   }
 
   render() {
-    let { chart } = this.props;
+    let { chart, setCurrentChart } = this.props;
+
     return ( 
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
         onMouseLeave={() => this.setState({ expand: false })}
         expand={this.state.expand}
+        onClick={() => setCurrentChart(chart)}
       >
         <Title>
           <IconWrapper>
@@ -47,30 +51,42 @@ export default class Chart extends Component<PropsType, StateType> {
           {chart.name}
         </Title>
 
-        <InfoWrapper>
-          <StatusIndicator>
-            <StatusColor status={chart.info.status} />
-            {chart.info.status}
-          </StatusIndicator>
+        <BottomWrapper>
+          <InfoWrapper>
+            <StatusIndicator>
+              <StatusColor status={chart.info.status} />
+              {chart.info.status}
+            </StatusIndicator>
 
-          <LastDeployed>
-            <Dot>•</Dot> Last deployed {this.readableDate(chart.info.last_deployed)}
-          </LastDeployed>
-        </InfoWrapper>
+            <LastDeployed>
+              <Dot>•</Dot> Last deployed {this.readableDate(chart.info.last_deployed)}
+            </LastDeployed>
+          </InfoWrapper>
 
-        <Version>v{chart.version}</Version>
+          <TagWrapper>
+            Namespace
+            <NamespaceTag>
+              {chart.namespace}
+            </NamespaceTag>
+          </TagWrapper>
+        </BottomWrapper>
 
-        <TagWrapper>
-          Namespace
-          <NamespaceTag>
-            {chart.namespace}
-          </NamespaceTag>
-        </TagWrapper>
+        <Version>v{chart.version}</Version>
       </StyledChart>
     );
   }
 }
 
+Chart.contextType = Context;
+
+const BottomWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-right: 11px;
+  margin-top: 12px;
+`;
+
 const Version = styled.div`
   position: absolute;
   top: 12px;
@@ -86,7 +102,10 @@ const Dot = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
-  margin-top: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
 `;
 
 const LastDeployed = styled.div`
@@ -99,9 +118,6 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  position: absolute;
-  bottom: 12px;
-  right: 12px;
   height: 20px;
   font-size: 12px;
   display: flex;
@@ -127,6 +143,9 @@ const NamespaceTag = styled.div`
   padding-left: 7px;
   border-top-left-radius: 0px;
   border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 `;
 
 const Icon = styled.img`
@@ -219,13 +238,13 @@ const StyledChart = styled.div`
 
   animation: ${(props: { expand: boolean }) => props.expand ? 'expand' : 'shrink'} 0.12s;
   animation-fill-mode: forwards;
-  animation-timing-function: ease;
+  animation-timing-function: ease-out;
 
   @keyframes expand {
     from { 
       width: calc(100% + 2px); 
       padding-top: 4px;
-      padding-bottom: 15px;
+      padding-bottom: 14px;
       margin-left: 0px;
       box-shadow: 0 5px 8px 0px #00000033;
       padding-left: 1px;
@@ -258,7 +277,7 @@ const StyledChart = styled.div`
     to {
       width: calc(100% + 2px); 
       padding-top: 4px;
-      padding-bottom: 15px;
+      padding-bottom: 14px;
       margin-left: 0px; 
       box-shadow: 0 5px 8px 0px #00000033;
       padding-left: 1px;

+ 57 - 10
dashboard/src/main/home/dashboard/chart/ChartList.tsx

@@ -10,24 +10,33 @@ import Loading from '../../../../components/Loading';
 
 type PropsType = {
   currentCluster: string,
-  namespace: string
+  namespace: string,
+  setCurrentChart: (c: ChartType) => void
 };
 
 type StateType = {
   charts: ChartType[],
-  loading: boolean
+  loading: boolean,
+  error: boolean
 };
 
 export default class ChartList extends Component<PropsType, StateType> {
   state = {
     charts: [] as ChartType[],
     loading: false,
+    error: false,
   }
 
   updateCharts = () => {
-    let { setCurrentError, currentCluster } = this.context;
-    
+    let { currentCluster } = this.context;
+
     this.setState({ loading: true });
+    setTimeout(() => {
+      if (this.state.loading) {
+        this.setState({ loading: false, error: true });
+      }
+    }, 1000);
+
     api.getCharts('<token>', {
       namespace: this.props.namespace,
       context: currentCluster,
@@ -38,13 +47,15 @@ export default class ChartList extends Component<PropsType, StateType> {
       statusFilter: ['deployed']
     }, {}, (err: any, res: any) => {
       if (err) {
-        setCurrentError(JSON.stringify(err));
-        this.setState({ loading: false });
+        // setCurrentError(JSON.stringify(err));
+        this.setState({ loading: false, error: true });
       } else {
         if (res.data) {
           this.setState({ charts: res.data });
+        } else {
+          this.setState({ charts: [] });
         }
-        this.setState({ loading: false });
+        this.setState({ loading: false, error: false });
       }
     });
   }
@@ -54,19 +65,40 @@ export default class ChartList extends Component<PropsType, StateType> {
   }
 
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props) {
+
+    // Ret2: Prevents reload when opening ClusterConfigModal
+    if (prevProps.currentCluster !== this.props.currentCluster || 
+      prevProps.namespace !== this.props.namespace) {
       this.updateCharts();
     }
   }
 
   renderChartList = () => {
-    if (this.state.loading) {
+    let { loading, error, charts } = this.state;
+
+    if (loading) {
       return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error) {
+      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 charts found in this namespace.
+        </Placeholder>
+      );
     }
 
     return this.state.charts.map((x: ChartType, i: number) => {
       return (
-        <Chart key={i} chart={x} />
+        <Chart
+          key={i}
+          chart={x}
+          setCurrentChart={this.props.setCurrentChart}
+        />
       )
     })
   }
@@ -83,6 +115,21 @@ export default class ChartList extends Component<PropsType, StateType> {
 
 ChartList.contextType = Context;
 
+const Placeholder = styled.div`
+  padding-top: 100px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding-top: 100px;
 `;

+ 265 - 0
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -0,0 +1,265 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../../assets/close.png';
+
+import { ChartType } from '../../../../shared/types';
+import { Context } from '../../../../shared/Context';
+
+import RevisionSection from './RevisionSection';
+
+type PropsType = {
+  currentChart: ChartType,
+  setCurrentChart: (x: ChartType | null) => void,
+  refreshChart: () => void
+};
+
+type StateType = {
+  showRevisions: boolean
+};
+
+export default class ExpandedChart extends Component<PropsType, StateType> {
+  state = {
+    showRevisions: false
+  }
+
+  renderIcon = () => {
+    let { currentChart } = this.props;
+
+    if (currentChart.chart.metadata.icon && currentChart.chart.metadata.icon !== '') {
+      return <Icon src={currentChart.chart.metadata.icon} />
+    } else {
+      return <i className="material-icons">tonality</i>
+    }
+  }
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+    return `${time} on ${date}`;
+  }
+
+  render() {
+    let { currentChart, setCurrentChart, refreshChart } = this.props;
+    let chart = currentChart;
+
+    return ( 
+      <StyledExpandedChart>
+        <TitleSection>
+          <Title>
+            <IconWrapper>
+              {this.renderIcon()}
+            </IconWrapper>
+            {chart.name}
+          </Title>
+          <InfoWrapper>
+            <StatusIndicator>
+              <StatusColor status={chart.info.status} />
+              {chart.info.status}
+            </StatusIndicator>
+
+            <LastDeployed>
+              <Dot>•</Dot>Last deployed {this.readableDate(chart.info.last_deployed)}
+            </LastDeployed>
+          </InfoWrapper>
+
+          <TagWrapper>
+            Namespace
+            <NamespaceTag>
+              {chart.namespace}
+            </NamespaceTag>
+          </TagWrapper>
+        </TitleSection>
+
+        <CloseButton onClick={() => setCurrentChart(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <RevisionSection
+          showRevisions={this.state.showRevisions}
+          toggleShowRevisions={() => this.setState({ showRevisions: !this.state.showRevisions })}
+          chart={chart}
+          refreshChart={refreshChart}
+        />
+
+        <ChartSection>
+          <Placeholder>(Under construction)</Placeholder>
+        </ChartSection>
+      </StyledExpandedChart>
+    );
+  }
+}
+
+ExpandedChart.contextType = Context;
+
+const Placeholder = styled.div`
+  color: #ffffff66;
+  padding-bottom: 30px;
+`;
+
+const ChartSection = styled.div`
+  display: flex;
+  margin-top: 20px;
+  border-radius: 5px;
+  flex: 1;
+  width: 100%;
+  background: #ffffff11;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;
+
+const StatusColor = styled.div`
+  margin-bottom: 1px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) => (props.status === 'deployed' ? '#4797ff' : props.status === 'failed' ? "#ed5f85" : "#f5cb42")};
+  border-radius: 20px;
+  margin-right: 16px;
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: 6px;
+  margin-top: 22px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 10px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  position: absolute;
+  bottom: 0px;
+  right: 0px;
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const StatusIndicator = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: 'Hind Siliguri', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  background: red;
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;;
+  border-radius: 10px;
+  background: #26282f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px; 
+  display: flex;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from { opacity: 0; transform: translateY(30px) }
+    to { opacity: 1; transform: translateY(0px) }
+  }
+`;

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

@@ -0,0 +1,351 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import loading from '../../../../assets/loading.gif';
+
+import api from '../../../../shared/api';
+import { Context } from '../../../../shared/Context';
+import { ChartType } from '../../../../shared/types';
+import Chart from '../chart/Chart';
+
+type PropsType = {
+  showRevisions: boolean,
+  toggleShowRevisions: () => void,
+  chart: ChartType,
+  refreshChart: () => void
+};
+
+type StateType = {
+  revisions: ChartType[],
+  rollbackRevision: number | null,
+  loading: boolean
+};
+
+export default class RevisionSection extends Component<PropsType, StateType> {
+  state = {
+    revisions: [] as ChartType[],
+    rollbackRevision: null as (number | null),
+    loading: false
+  }
+
+  refreshHistory = () => {
+    let { chart } = this.props;
+
+    api.getRevisions('<token>', {
+      namespace: chart.namespace,
+      context: this.context.currentCluster,
+      storage: 'secret'
+    }, { name: chart.name }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ revisions: res.data.reverse() });
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.refreshHistory();
+  }
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+    return `${time} on ${date}`;
+  }
+
+  handleRollback = () => {
+    let revisionNumber = this.state.rollbackRevision;
+    this.setState({ loading: true, rollbackRevision: null });
+
+    api.rollbackChart('<token>', {
+      namespace: this.props.chart.namespace,
+      context: this.context.currentCluster,
+      storage: 'secret'
+    }, {
+      name: this.props.chart.name,
+      revision: revisionNumber
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ loading: false });
+        this.props.refreshChart();
+        this.refreshHistory();
+      }
+    });
+  }
+
+  renderRevisionList = () => {
+    return this.state.revisions.map((revision: any, i: number) => {
+      return (
+        <Tr key={i}>
+          <Td>{revision.version}</Td>
+          <Td>{this.readableDate(revision.info.last_deployed)}</Td>
+          <Td>{revision.info.status}</Td>
+          <Td>
+            <RollbackButton
+              disabled={revision.version === this.props.chart.version}
+              onClick={() => this.setState({ rollbackRevision: revision.version })}
+            >
+              {revision.version === this.props.chart.version ? 'Current' : 'Revert'}
+            </RollbackButton>
+          </Td>
+        </Tr>
+      );
+    });
+  }
+
+  renderExpanded = () => {
+    if (this.props.showRevisions) {
+      return (
+        <RevisionsTable>
+          <tbody>
+            <Tr>
+              <Th>Revision No.</Th>
+              <Th>Timestamp</Th>
+              <Th>Status</Th>
+              <Th>Rollback</Th>
+            </Tr>
+            {this.renderRevisionList()}
+          </tbody>
+        </RevisionsTable>
+      )
+    }
+  }
+
+  renderConfirmOverlay = () => {
+    if (this.state.rollbackRevision) {
+      return (
+        <ConfirmOverlay>
+          {`Are you sure you want to revert to version ${this.state.rollbackRevision}?`}
+          <ButtonRow>
+            <ConfirmButton
+              onClick={() => this.handleRollback()}
+            >
+              Yes
+            </ConfirmButton>
+            <ConfirmButton
+              onClick={() => this.setState({ rollbackRevision: null })}
+            >
+              No
+            </ConfirmButton>
+          </ButtonRow>
+        </ConfirmOverlay>
+      );
+    }
+  }
+
+  renderContents = () => {
+    if (this.state.loading) {
+      return (
+        <LoadingPlaceholder>
+          <StatusWrapper>
+            <LoadingGif src={loading} /> Updating . . .
+          </StatusWrapper>
+        </LoadingPlaceholder>
+      )
+    }
+
+    return (
+      <div>
+        <RevisionHeader
+          showRevisions={this.props.showRevisions}
+          onClick={this.props.toggleShowRevisions}
+        >
+          Current Revision - <Revision>No. {this.props.chart.version}</Revision>
+          <i className="material-icons">expand_more</i>
+        </RevisionHeader>
+
+        <RevisionList>
+          {this.renderExpanded()}
+        </RevisionList>
+      </div>
+    );
+  }
+
+  render() {
+    return (
+      <StyledRevisionSection showRevisions={this.props.showRevisions}>
+        {this.renderContents()}
+        {this.renderConfirmOverlay()}
+      </StyledRevisionSection>
+    );
+  }
+}
+
+RevisionSection.contextType = Context;
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+const ConfirmOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0,0,0,0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+  }
+`;
+
+const ButtonRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 180px;
+  margin-top: 30px;
+`;
+
+const ConfirmButton = styled.div`
+  font-size: 18px;
+  padding: 10px 15px;
+  outline: none; 
+  border: 1px solid white;
+  border-radius: 10px; 
+  text-align: center; 
+  width: 80px;
+  cursor: pointer;
+  opacity: 0;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  animation: linEnter 0.3s 0.1s;
+  animation-fill-mode: forwards;
+  @keyframes linEnter {
+    from { transform: translateY(20px); opacity: 0; }
+    to   { transform: translateY(0px); opacity: 1; }
+  }
+  :hover {
+    background: white;
+    color: #232323;
+  }
+`;
+
+const RevisionList = styled.div`
+  overflow-y: auto;
+  max-height: 215px;
+`;
+
+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 Tr = styled.tr`
+  line-height: 1.8em;
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionHeader = styled.div`
+  color: #ffffff66;
+  display: flex;
+  align-items: center;
+  height: 40px;
+  font-size: 14px;
+  width: 100%;
+  padding-left: 15px;
+  cursor: pointer;
+  background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    margin-left: 12px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff18' : ''};
+    transform: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'rotate(180deg)' : ''};
+  }
+`;
+
+const StyledRevisionSection = styled.div`
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) => props.showRevisions ? '255px' : '40px'};
+  background: #ffffff11;
+  margin-top: 25px;
+  overflow: hidden;
+  border-radius: 5px;
+  animation: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'expandRevisions 0.3s' : ''};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from { max-height: 40px }
+    to { max-height: 250px }
+  }
+`;

+ 33 - 0
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -161,6 +161,9 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
             value={this.state.rawKubeconfig}
             onChange={(e: any) => this.setState({ rawKubeconfig: e })}
           />
+          <UploadButton>
+            <i className="material-icons">cloud_upload</i> Upload Kubeconfig
+          </UploadButton>
           <SaveButton
             text='Save Kubeconfig'
             onClick={this.handleSaveKubeconfig}
@@ -219,6 +222,36 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
 
 ClusterConfigModal.contextType = Context;
 
+const UploadButton = styled.button`
+  display: flex;
+  align-items: center;
+  position: absolute;
+  bottom: 25px;
+  left: 30px;
+  height: 40px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: #ffffff11;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: not-allowed;
+  user-select: none;
+  :focus { outline: 0 }
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
 const Checkbox = styled.div`
   width: 15px;
   height: 15px;

+ 28 - 1
dashboard/src/shared/api.tsx

@@ -50,10 +50,34 @@ const getCharts = baseApi<{
   statusFilter: string[]
 }>('GET', '/api/charts');
 
+const getChart = baseApi<{
+  namespace: string,
+  context: string,
+  storage: string
+}, { name: string, revision: number }>('GET', pathParams => {
+  return `/api/charts/${pathParams.name}/${pathParams.revision}`;
+});
+
 const getNamespaces = baseApi<{
   context: string
 }>('GET', '/api/k8s/namespaces');
 
+const getRevisions = baseApi<{
+  namespace: string,
+  context: string,
+  storage: string
+}, { name: string }>('GET', pathParams => {
+  return `/api/charts/${pathParams.name}/history`;
+});
+
+const rollbackChart = baseApi<{
+  namespace: string,
+  context: string,
+  storage: string
+}, { name: string, revision: number }>('POST', pathParams => {
+  return `/api/charts/rollback/${pathParams.name}/${pathParams.revision}`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -64,5 +88,8 @@ export default {
   updateUser,
   getContexts,
   getCharts,
-  getNamespaces
+  getChart,
+  getNamespaces,
+  getRevisions,
+  rollbackChart
 }

+ 2 - 0
dashboard/src/shared/baseApi.tsx

@@ -1,6 +1,8 @@
 import axios from 'axios';
 import qs from 'qs';
 
+// axios.defaults.timeout = 2500;
+
 // Partial function that accepts a generic params type and returns an api method
 export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((pathParams: S) => string) | string) => {
   return (token: string, params: T, pathParams: S, callback?: (err: any, res: any) => void) => {