Explorar o código

Merge branch 'frontend-integration' of https://github.com/porter-dev/porter into frontend-integration

sunguroku %!s(int64=5) %!d(string=hai) anos
pai
achega
85aa794519

+ 1 - 1
.air.toml

@@ -15,7 +15,7 @@ full_bin = "tmp/migrate; tmp/app"
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.
-exclude_dir = ["tmp"]
+exclude_dir = ["tmp", "dashboard"]
 # Watch these directories if you specified.
 include_dir = []
 # Exclude files.

+ 20 - 4
dashboard/package-lock.json

@@ -420,6 +420,11 @@
       "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
       "dev": true
     },
+    "@types/qs": {
+      "version": "6.9.5",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz",
+      "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ=="
+    },
     "@types/react": {
       "version": "16.9.49",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.49.tgz",
@@ -1281,6 +1286,12 @@
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
           "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
           "dev": true
+        },
+        "qs": {
+          "version": "6.7.0",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+          "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
+          "dev": true
         }
       }
     },
@@ -2683,6 +2694,12 @@
           "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=",
           "dev": true
         },
+        "qs": {
+          "version": "6.7.0",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+          "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
+          "dev": true
+        },
         "safe-buffer": {
           "version": "5.1.2",
           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -5275,10 +5292,9 @@
       "dev": true
     },
     "qs": {
-      "version": "6.7.0",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
-      "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
-      "dev": true
+      "version": "6.9.4",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
+      "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
     },
     "querystring": {
       "version": "0.2.0",

+ 2 - 0
dashboard/package.json

@@ -3,9 +3,11 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@types/qs": "^6.9.5",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "dotenv": "^8.2.0",
+    "qs": "^6.9.4",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",

+ 34 - 0
dashboard/src/components/Loading.tsx

@@ -0,0 +1,34 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import loading from '../assets/loading.gif';
+
+type PropsType = {
+};
+
+type StateType = {
+};
+
+export default class Loading extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    return (
+      <StyledLoading>
+        <Spinner src={loading} />
+      </StyledLoading>
+    );
+  }
+}
+
+const Spinner = styled.img`
+  width: 20px;
+`;
+
+const StyledLoading = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

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

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 import AceEditor from 'react-ace';
 
 import 'ace-builds/src-noconflict/mode-yaml';
-import 'ace-builds/src-noconflict/theme-monokai';
+import 'ace-builds/src-noconflict/theme-terminal';
 
 type PropsType = {
   value: string,
@@ -44,7 +44,7 @@ class YamlEditor extends Component<PropsType, StateType> {
           <AceEditor
             mode='yaml'
             value={this.props.value}
-            theme='monokai'
+            theme='terminal'
             onChange={this.props.onChange}
             name='codeEditor'
             editorProps={{ $blockScrolling: true }}

+ 2 - 2
dashboard/src/main/CurrentError.tsx

@@ -89,11 +89,11 @@ const StyledCurrentError = styled.div`
   height: 50px;
   font-size: 13px;
   border-radius: 3px;
-  background: #383842dd;
+  background: #272731cc;
   border: 1px solid #ffffff55;
   display: flex;
   align-items: center;
-  color: #FFDB8C;
+  color: #ffffff;
 
   > i {
     font-size: 18px;

+ 18 - 2
dashboard/src/main/Login.tsx

@@ -109,6 +109,10 @@ export default class Login extends Component<PropsType, StateType> {
               {this.renderCredentialError()}
             </InputWrapper>
             <Button onClick={this.handleLogin}>Continue</Button>
+
+            <Helper>Don't have an account?
+              <Link href='/register'>Sign up</Link>
+            </Helper>
           </FormWrapper>
         </LoginPanel>
       </StyledLogin>
@@ -118,6 +122,18 @@ export default class Login extends Component<PropsType, StateType> {
 
 Login.contextType = Context;
 
+const Link = styled.a`
+  margin-left: 5px;
+  color: #819BFD;
+`;
+
+const Helper = styled.div`
+  margin: 52px 0px 20px;
+  font-size: 14px;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff44;
+`;
+
 const OverflowWrapper = styled.div`
   position: absolute;
   top: 0;
@@ -206,7 +222,7 @@ const Prompt = styled.div`
 
 const Logo = styled.img`
   width: 150px;
-  margin-top: 63px;
+  margin-top: 53px;
   user-select: none;
 `;
 
@@ -237,7 +253,7 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 430px;
+  height: 450px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 53 - 41
dashboard/src/main/Main.tsx

@@ -1,5 +1,6 @@
 import React, { Component } from 'react';
 import styled, { createGlobalStyle } from 'styled-components';
+import { BrowserRouter, Route, Redirect, Switch } from 'react-router-dom';
 import close from '../assets/close.png';
 
 import api from '../shared/api';
@@ -9,13 +10,13 @@ import Login from './Login';
 import Register from './Register';
 import CurrentError from './CurrentError';
 import Home from './home/Home';
-import { BrowserRouter, Route, Redirect, Switch } from 'react-router-dom';
+import Loading from '../components/Loading';
 
 type PropsType = {
 };
 
 type StateType = {
-  isLoading: boolean,
+  loading: boolean,
   isLoggedIn: boolean,
   initialized: boolean,
 };
@@ -23,7 +24,7 @@ type StateType = {
 export default class Main extends Component<PropsType, StateType> {
   
   state = {
-    isLoading: false,
+    loading: true,
     isLoggedIn : false,
     initialized: (localStorage.getItem("init") == 'true')
   }
@@ -32,10 +33,10 @@ export default class Main extends Component<PropsType, StateType> {
     let { setUserId } = this.context;
     api.checkAuth('', {}, {}, (err: any, res: any) => {
       if (res.data) {
-        setUserId(res.data.id)
-        this.setState({ isLoggedIn: true, initialized: true})
+        setUserId(res.data.id);
+        this.setState({ isLoggedIn: true, initialized: true, loading: false });
       } else {
-        this.setState({ isLoggedIn: false })
+        this.setState({ isLoggedIn: false, loading: false })
       }
     });
   }
@@ -49,46 +50,56 @@ export default class Main extends Component<PropsType, StateType> {
     this.setState({ isLoggedIn: true, initialized: true });
   }
 
+  renderMain = () => {
+    if (this.state.loading) {
+      return <Loading />
+    }
+
+    return (
+      <Switch>
+        <Route path='/login' render={() => {
+          if (!this.state.isLoggedIn) {
+            return <Login authenticate={this.authenticate} />
+          } else {
+            return <Redirect to='/' />
+          }
+        }} />
+
+        <Route path='/register' render={() => {
+          if (!this.state.isLoggedIn) {
+            return <Register authenticate={this.initialize} />
+          } else {
+            return <Redirect to='/' />
+          }
+        }} />
+
+        <Route path='/dashboard' render={() => {
+          if (this.state.isLoggedIn && this.state.initialized) {
+            return <Home logOut={() => this.setState({ isLoggedIn: false, initialized: true })} />
+          } else {
+            return <Redirect to='/' />
+          }
+        }}/>
+
+        <Route path='/' render={() => {
+          if (this.state.isLoggedIn) {
+            return <Redirect to='/dashboard'/>
+          } else if (this.state.initialized) {
+            return <Redirect to='/login'/>
+          } else {
+            return <Redirect to='/register' />
+          }
+        }}/>
+      </Switch>
+    );
+  }
+
   render() {
     return (
       <StyledMain>
         <GlobalStyle />
         <BrowserRouter>
-          <Switch>
-            <Route path='/login' render={() => {
-              if (!this.state.isLoggedIn) {
-                return <Login authenticate={this.authenticate} />
-              } else {
-                return <Redirect to='/' />
-              }
-            }} />
-
-            <Route path='/register' render={() => {
-              if (!this.state.isLoggedIn) {
-                return <Register authenticate={this.initialize} />
-              } else {
-                return <Redirect to='/' />
-              }
-            }} />
-
-            <Route path='/dashboard' render={() => {
-              if (this.state.isLoggedIn && this.state.initialized) {
-                return <Home logOut={() => this.setState({ isLoggedIn: false, initialized: true })} />
-              } else {
-                return <Redirect to='/' />
-              }
-            }}/>
-
-            <Route path='/' render={() => {
-              if (this.state.isLoggedIn) {
-                return <Redirect to='/dashboard'/>
-              } else if (this.state.initialized) {
-                return <Redirect to='/login'/>
-              } else {
-                return <Redirect to='/register' />
-              }
-            }}/>
-          </Switch>
+          {this.renderMain()}
         </BrowserRouter>
         <CurrentError />
       </StyledMain>
@@ -101,6 +112,7 @@ Main.contextType = Context;
 const GlobalStyle = createGlobalStyle`
   * {
     box-sizing: border-box;
+    font-family: 'Work Sans', sans-serif;
   }
 `;
 

+ 18 - 2
dashboard/src/main/Register.tsx

@@ -131,6 +131,10 @@ export default class Register extends Component<PropsType, StateType> {
               {this.renderConfirmPasswordError()}
             </InputWrapper>
             <Button onClick={this.handleRegister}>Continue</Button>
+
+            <Helper>Have an account?
+              <Link href='/login'>Sign in</Link>
+            </Helper>
           </FormWrapper>
         </LoginPanel>
       </StyledRegister>
@@ -140,6 +144,18 @@ export default class Register extends Component<PropsType, StateType> {
 
 Register.contextType = Context;
 
+const Link = styled.a`
+  margin-left: 5px;
+  color: #819BFD;
+`;
+
+const Helper = styled.div`
+  margin: 30px 0px 20px;
+  font-size: 14px;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff44;
+`;
+
 const OverflowWrapper = styled.div`
   position: absolute;
   top: 0;
@@ -228,7 +244,7 @@ const Prompt = styled.div`
 
 const Logo = styled.img`
   width: 150px;
-  margin-top: 50px;
+  margin-top: 40px;
   user-select: none;
 `;
 
@@ -259,7 +275,7 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 430px;
+  height: 450px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 61 - 9
dashboard/src/main/home/Home.tsx

@@ -7,6 +7,7 @@ import { Context } from '../../shared/Context';
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
 import ClusterConfigModal from './modals/ClusterConfigModal';
+import Loading from '../../components/Loading';
 
 type PropsType = {
   logOut: () => void
@@ -16,8 +17,34 @@ type StateType = {
 };
 
 export default class Home extends Component<PropsType, StateType> {
+
+  renderDashboard = () => {
+    let { currentCluster, setCurrentModal, setCurrentModalData } = this.context;
+
+    if (currentCluster === '') {
+      return (
+        <DashboardWrapper>
+          <Placeholder>
+            <Bold>Porter - Getting Started</Bold><br /><br />
+            1. Navigate to <A onClick={() => setCurrentModal('ClusterConfigModal')}>+ Add a Cluster</A> and provide a kubeconfig. *<br /><br />
+            2. Choose which contexts you would like to use from the <A onClick={() => {
+              setCurrentModal('ClusterConfigModal');
+              setCurrentModalData({ currentTab: 'select' });
+            }}>Select Clusters</A> tab.<br /><br />
+            3. For additional information, please refer to our <A>docs</A>.<br /><br /><br />
+            
+            * Make sure all fields are explicitly declared (e.g., certs and keys).
+          </Placeholder>
+        </DashboardWrapper>
+      );
+    } else if (!currentCluster) {
+      return <Loading />
+    }
+
+    return <DashboardWrapper><Dashboard /></DashboardWrapper>
+  }
+
   render() {
-    console.log(this.context)
     return (
       <StyledHome>
         <ReactModal
@@ -30,7 +57,9 @@ export default class Home extends Component<PropsType, StateType> {
         </ReactModal>
 
         <Sidebar logOut={this.props.logOut} />
-        <Dashboard />
+        <StyledDashboard>
+          {this.renderDashboard()}
+        </StyledDashboard>
       </StyledHome>
     );
   }
@@ -51,27 +80,50 @@ const MediumModalStyles = {
     margin: '0 auto',
     height: '575px',
     top: 'calc(50% - 289px)',
-    backgroundColor: '#24272a',
+    backgroundColor: '#202227',
     animation: 'floatInModal 0.5s 0s',
     overflow: 'visible',
   },
 };
 
-const DummyDashboard = styled.div`
+const StyledDashboard = styled.div`
   height: 100%;
   width: 100vw;
-  font-family: 'Work Sans', sans-serif;
+  padding-top: 80px;
   overflow-y: auto;
   display: flex;
-  letter-spacing: 10px;
   flex: 1;
   justify-content: center;
-  padding-bottom: 30px;
-  align-items: center;
-  background: ${props => props.theme.bg};
+  background: #202227;
   position: relative;
 `;
 
+const DashboardWrapper = styled.div`
+  width: 80%;
+  min-width: 300px;
+  padding-bottom: 120px;
+`;
+
+const A = styled.a`
+  color: #ffffff;
+  text-decoration: underline;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+`;
+
+const Placeholder = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  margin-left: 20px;
+  margin-top: 24vh;
+  user-select: none;
+`;
+
+const Bold = styled.div`
+  font-weight: bold;
+  font-size: 20px;
+`;
+
 const StyledHome = styled.div`
   width: 100vw;
   height: 100vh;

+ 13 - 53
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -3,44 +3,21 @@ import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
 import { Context } from '../../../shared/Context';
-import api from '../../../shared/api';
-import { StorageType } from '../../../shared/types';
 
-class Dashboard extends Component {
+import ChartList from './chart/ChartList';
 
-  componentDidMount() {
-    let { userId, setCurrentError, currentCluster } = this.context;
-    
-    api.getCharts('<token>', {
-      user_id: userId,
-      helm: {
-        namespace: '',
-        context: currentCluster,
-        storage: 'memory',
-      },
-      filter: {
-        namespace: '',
-        limit: 20,
-        skip: 0,
-        byDate: false,
-        statusFilter: ['deployed']
-      }
-    }, {}, (err: any, res: any) => {
-      if (err) {
-        setCurrentError(JSON.stringify(err));
-      } else {
-        
-        console.log(res);
-      }
-    });
-  }
+type PropsType = {
+};
+
+type StateType = {
+};
 
+export default class Dashboard extends Component<PropsType, StateType> {
   render() {
     let { currentCluster } = this.context;
 
     return ( 
-      <StyledDashboard>
-        <DashboardWrapper>
+      <div>
         <TitleSection>
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -60,14 +37,14 @@ class Dashboard extends Component {
         </InfoSection>
 
         <LineBreak />
-        </DashboardWrapper>
-      </StyledDashboard>
+
+        <ChartList currentCluster={currentCluster} />
+      </div>
     );
   }
 }
 
 Dashboard.contextType = Context;
-export default Dashboard;
 
 const TopRow = styled.div`
   display: flex;
@@ -228,6 +205,7 @@ const Title = styled.div`
 
 const TitleSection = styled.div`
   height: 80px;
+  margin-top: 10px;
   margin-bottom: 10px;
   display: flex;
   flex-direction: row;
@@ -246,22 +224,4 @@ const TitleSection = styled.div`
     }
     margin-bottom: -3px;
   }
-`;
-
-const StyledDashboard = styled.div`
-  height: 100%;
-  width: 100vw;
-  padding-top: 80px;
-  overflow-y: auto;
-  display: flex;
-  flex: 1;
-  justify-content: center;
-  background: #24272a;
-  position: relative;
-`;
-
-const DashboardWrapper = styled.div`
-  width: 80%;
-  min-width: 300px;
-  padding-bottom: 120px;
-`;
+`;

+ 269 - 0
dashboard/src/main/home/dashboard/chart/Chart.tsx

@@ -0,0 +1,269 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ChartType } from '../../../../shared/types';
+
+type PropsType = {
+  chart: ChartType
+};
+
+type StateType = {
+};
+
+export default class Chart extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+  }
+
+  renderIcon = () => {
+    let { chart } = this.props;
+
+    if (chart.chart.metadata.icon && chart.chart.metadata.icon !== '') {
+      return <Icon src={chart.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 { chart } = this.props;
+    return ( 
+      <StyledChart
+        onMouseEnter={() => this.setState({ expand: true })}
+        onMouseLeave={() => this.setState({ expand: false })}
+        expand={this.state.expand}
+      >
+        <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>
+
+        <Version>v{chart.version}</Version>
+
+        <TagWrapper>
+          Namespace
+          <NamespaceTag>
+            {chart.namespace}
+          </NamespaceTag>
+        </TagWrapper>
+      </StyledChart>
+    );
+  }
+}
+
+const Version = styled.div`
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  font-size: 12px;
+  color: #aaaabb;
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 10px;
+`;
+
+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: 12px;
+  right: 12px;
+  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 Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  background: none;
+  font-size: 16px;
+  top: 11px;
+  left: 14px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  position: absolute;
+
+  > i {
+    font-size: 17px;
+    margin-top: -1px;
+  }
+`;
+
+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;
+  margin-left: 20px;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+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 Title = styled.div`
+  position: relative;
+  text-decoration: none;
+  padding: 12px 35px 12px 45px;
+  font-size: 14px;
+  font-family: 'Work Sans', sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  width: 80%;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  animation: fadeIn 0.5s;
+
+  >img {
+    background: none;
+    top: 12px;
+    left: 13px;
+
+    padding: 5px 4px;
+    width: 24px;
+    position: absolute;
+  }
+`;
+
+const StyledChart = styled.div`
+  background: #26282f;
+  cursor: pointer;
+  margin-bottom: 25px;
+  padding: 1px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  position: relative;
+  border: 2px solid #9EB4FF00;
+  width: calc(100% + 2px);
+  height: calc(100% + 2px);
+
+  animation: ${(props: { expand: boolean }) => props.expand ? 'expand' : 'shrink'} 0.12s;
+  animation-fill-mode: forwards;
+  animation-timing-function: ease;
+
+  @keyframes expand {
+    from { 
+      width: calc(100% + 2px); 
+      padding-top: 4px;
+      padding-bottom: 15px;
+      margin-left: 0px;
+      box-shadow: 0 5px 8px 0px #00000033;
+      padding-left: 1px;
+      margin-bottom: 25px;
+      margin-top: 0px;
+    }
+    to {
+      width: calc(100% + 22px);
+      padding-top: 7px;
+      padding-bottom: 20px;
+      margin-left: -10px; 
+      box-shadow: 0 8px 20px 0px #00000030;
+      padding-left: 5px;
+      margin-bottom: 21px;
+      margin-top: -4px;
+    }
+  }
+
+  @keyframes shrink {
+    from { 
+      width: calc(100% + 22px);
+      padding-top: 7px;
+      padding-bottom: 20px;
+      margin-left: -10px; 
+      box-shadow: 0 8px 20px 0px #00000030;
+      padding-left: 5px;
+      margin-bottom: 21px;
+      margin-top: -4px;
+    }
+    to {
+      width: calc(100% + 2px); 
+      padding-top: 4px;
+      padding-bottom: 15px;
+      margin-left: 0px; 
+      box-shadow: 0 5px 8px 0px #00000033;
+      padding-left: 1px;
+      margin-bottom: 25px;
+      margin-top: 0px;
+    }
+  }
+`;

+ 91 - 0
dashboard/src/main/home/dashboard/chart/ChartList.tsx

@@ -0,0 +1,91 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+import { ChartType } from '../../../../shared/types';
+
+import Chart from './Chart';
+import Loading from '../../../../components/Loading';
+
+type PropsType = {
+  currentCluster: string
+};
+
+type StateType = {
+  charts: ChartType[],
+  loading: boolean
+};
+
+export default class ChartList extends Component<PropsType, StateType> {
+  state = {
+    charts: [] as ChartType[],
+    loading: false,
+  }
+
+  updateCharts = () => {
+    let { setCurrentError, currentCluster } = this.context;
+    
+    this.setState({ loading: true });
+    api.getCharts('<token>', {
+      namespace: '',
+      context: currentCluster,
+      storage: 'secret',
+      limit: 20,
+      skip: 0,
+      byDate: false,
+      statusFilter: ['deployed']
+    }, {}, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(JSON.stringify(err));
+        this.setState({ loading: false });
+      } else {
+        if (res.data) {
+          this.setState({ charts: res.data });
+        }
+        this.setState({ loading: false });
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.updateCharts();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps !== this.props) {
+      this.updateCharts();
+    }
+  }
+
+  renderChartList = () => {
+    if (this.state.loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    }
+
+    return this.state.charts.map((x: ChartType, i: number) => {
+      return (
+        <Chart key={i} chart={x} />
+      )
+    })
+  }
+
+
+  render() {
+    return (
+      <StyledChartList>
+        {this.renderChartList()}
+      </StyledChartList>
+    );
+  }
+}
+
+ChartList.contextType = Context;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 100px;
+`;

+ 7 - 3
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -35,7 +35,7 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
     // Parse kubeconfig to retrieve all possible clusters
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
-        setCurrentError('getAllClusters: ' + JSON.stringify(err));
+        setCurrentError(JSON.stringify(err));
       } else {
         this.setState({ kubeContexts: res.data });
       }
@@ -43,7 +43,11 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
   }
 
   componentDidMount() {
-    let { setCurrentError, userId } = this.context;
+    let { setCurrentError, userId, currentModalData } = this.context;
+
+    if (currentModalData && currentModalData.currentTab) {
+      this.setState({ currentTab: 'select' });
+    }
 
     api.getUser('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
@@ -391,5 +395,5 @@ const StyledClusterConfigModal= styled.div`
   padding: 25px 30px;
   overflow: hidden;
   border-radius: 6px;
-  background: #24272a;
+  background: #202227;
 `;

+ 17 - 15
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -14,7 +14,6 @@ type PropsType = {
 };
 
 type StateType = {
-  configExists: boolean,
   showDrawer: boolean,
   initializedDrawer: boolean,
   kubeContexts: KubeContextConfig[]
@@ -24,7 +23,6 @@ export default class ClusterSection extends Component<PropsType, StateType> {
 
   // Need to track initialized for animation mounting
   state = {
-    configExists: true,
     showDrawer: false,
     initializedDrawer: false,
     kubeContexts: [] as KubeContextConfig[]
@@ -36,17 +34,21 @@ export default class ClusterSection extends Component<PropsType, StateType> {
     // TODO: query with selected filter once implemented
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
-        setCurrentError('getContexts: ' + JSON.stringify(err));
+        setCurrentError('Could not read clusters: ' + JSON.stringify(err));
       } else {
-        
-        // Filter selected (temporary)
-        let kubeContexts = res.data.filter((x: KubeContextConfig) => x.selected);
-        if (kubeContexts.length > 0) {
-          this.setState({ kubeContexts });
-          setCurrentCluster(kubeContexts[0].name);
-        } else {
-          this.setState({ kubeContexts: [] });
-          setCurrentCluster(null);
+
+        // TODO: handle uninitialized kubeconfig
+        if (res.data) {
+
+          // Filter selected (temporary)
+          let kubeContexts = res.data.filter((x: KubeContextConfig) => x.selected);
+          if (kubeContexts.length > 0) {
+            this.setState({ kubeContexts });
+            setCurrentCluster(kubeContexts[0].name);
+          } else {
+            this.setState({ kubeContexts: [] });
+            setCurrentCluster('');
+          }
         }
       }
     });
@@ -99,7 +101,7 @@ export default class ClusterSection extends Component<PropsType, StateType> {
       return (
         <ClusterSelector showDrawer={showDrawer}>
           <LinkWrapper>
-            <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
+            <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
             <ClusterName>{currentCluster}</ClusterName>
           </LinkWrapper>
           <DrawerButton onClick={this.toggleDrawer}>
@@ -225,10 +227,10 @@ const DropdownIcon = styled.span`
 
 const ClusterIcon = styled.div`
   > i {
-    font-size: 16px;
+    font-size: 18px;
     display: flex;
     align-items: center;
-    margin-bottom: -2px;
+    margin-bottom: 0px;
     margin-left: 15px;
     margin-right: 10px;
   }

+ 3 - 3
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -34,7 +34,7 @@ export default class Drawer extends Component<PropsType, StateType> {
             active={kubeContext.name === currentCluster}
             onClick={() => setCurrentCluster(kubeContext.name)}
           >
-            <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
+            <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
             <ClusterName>{kubeContext.name}</ClusterName>
           </ClusterOption>
         );
@@ -174,10 +174,10 @@ const CloseButtonImg = styled.img`
 
 const ClusterIcon = styled.div`
   > i {
-    font-size: 16px;
+    font-size: 18px;
     display: flex;
     align-items: center;
-    margin-bottom: -2px;
+    margin-bottom: 0px;
     margin-left: 15px;
     margin-right: 10px;
   }

+ 2 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -190,9 +190,10 @@ const SidebarBg = styled.div`
   top: 0;
   left: 0;
   width: 100%;
-  background-color: #333748;
+  background-color: #292c35;
   height: 100%;
   z-index: -1;
+  box-shadow: 8px 0px 8px 0px #00000010;
 `;
 
 const SidebarLabel = styled.div`

+ 7 - 13
dashboard/src/shared/api.tsx

@@ -41,19 +41,13 @@ const getContexts = baseApi<{}, { id: number }>('GET', pathParams => {
 });
 
 const getCharts = baseApi<{
-  user_id: number,
-  helm: {
-    namespace: string,
-    context: string,
-    storage: string
-  },
-  filter: {
-    namespace: string,
-    limit: number,
-    skip: number,
-    byDate: boolean,
-    statusFilter: string[]
-  }
+  namespace: string,
+  context: string,
+  storage: string
+  limit: number,
+  skip: number,
+  byDate: boolean,
+  statusFilter: string[]
 }>('GET', '/api/charts');
 
 // Bundle export to allow default api import (api.<method> is more readable)

+ 5 - 1
dashboard/src/shared/baseApi.tsx

@@ -1,4 +1,5 @@
 import axios from 'axios';
+import qs from 'qs';
 
 // 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) => {
@@ -39,7 +40,10 @@ export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((p
       });
     } else {
       axios.get(endpointString, {
-        params
+        params,
+        paramsSerializer: function(params) {
+          return qs.stringify(params, { arrayFormat: 'repeat' })
+        }
       })
       .then(res => {
         callback && callback(null, res);

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

@@ -6,6 +6,29 @@ export interface KubeContextConfig {
   user: string
 }
 
+export interface ChartType {
+  name: string,
+  info: {
+    last_deployed: string,
+    deleted: string,
+    description: string,
+    status: string
+  },
+  chart: {
+    metadata: {
+      name: string,
+      home: string,
+      sources: string,
+      version: string,
+      description: string,
+      icon: string,
+      apiVersion: string
+    },
+  },
+  version: number,
+  namespace: string
+}
+
 export enum StorageType {
   Secret = 'secret',
   ConfigMap = 'configmap',

+ 104 - 39
docs/API.md

@@ -15,6 +15,7 @@
   - [`DELETE /api/users/{id}`](#delete-apiusersid)
 - [`/api/charts`](#apicharts)
   - [`GET /api/charts`](#get-apicharts)
+  - [`GET /api/charts/{name}/history`](#get-apichartsnamehistory)
   - [`GET /api/charts/{name}/{revision}`](#get-apichartsnamerevision)
 - [`/api/k8s`](#apik8s)
   - [`GET /api/k8s/namespaces`](#get-apik8snamespaces)
@@ -424,31 +425,103 @@ User object with only the id field. Other fields are empty - with values in para
 
 **URL parameters:** N/A
 
-**Query parameters:** N/A
-
-**Request Body**:
+**Query parameters:** 
 
 ```js
 {
-  "user_id": Number,
-  "helm": {
-    // The namespace of the cluster to be used
-    "namespace": String,
-    // The name of the context in the kubeconfig being used
-    "context": String,
-    // The Helm storage option to use
-    "storage": String("secret"|"configmap"|"memory")
+  "namespace": String,
+  "context": String,
+  "storage": String("secret"|"configmap"|"memory"),
+  "limit": Number,
+  "skip": Number,
+  "byDate": Boolean,
+  "statusFilter": []String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
+}
+```
+
+**Request Body**: N/A
+
+**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
+
+```js
+[]Chart{
+  // Name is the name of the release
+  "name": String,
+  "info": Info{
+    // LastDeployed is when the release was last deployed.
+    "last_deployed": String,
+    // Deleted tracks when this object was deleted.
+    "deleted": String,
+    // Description is human-friendly "log entry" about this release.
+    "description": String,
+    // Status is the current state of the release
+    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
   },
-  "filter": {
-    "namespace": String,
-    "limit": Number,
-    "skip": Number,
-    "byDate": Boolean,
-    "statusFilter": []String
-  }
+  "chart": Chart{
+    "metadata": Metadata{
+      // The name of the chart
+      "name": String,
+      // The URL to a relevant project page, git repo, or contact person
+      "home": String,
+      // Sources is a list of URLs to the source code of this chart
+      "sources": []String,
+      // A SemVer 2 conformant version string of the chart
+      "version": String,
+      // A one-sentence description of the chart
+      "description": String,
+      // The URL to an icon file.
+      "icon": String,
+      // The API Version of this chart.
+      "apiVersion": String,
+    },
+    "templates": []File{
+      // Name is the path-like name of the template.
+      "name": String,
+      // Data is the template as byte data.
+      "data": String
+    },
+    // Values are default config for this chart.
+    "values": Map[String]{}
+  },
+  // The set of extra Values added to the chart, which override the 
+  // default values inside of the chart
+  "config": Map[String]{},
+  // Manifest is the string representation of the rendered template
+  "manifest": String,
+  // Version is an int which represents the revision of the release.
+  "version": Number,
+  // Namespace is the kubernetes namespace of the release.
+  "namespace": String
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** TBD
+
+#### `GET /api/charts/{name}/history`
+
+**Description:** Gets a history of revisions for a given deployed chart based on the release `name`.
+
+**URL parameters:** 
+
+- `name` The name of the release.
+
+**Query parameters:** 
+
+```js
+{
+  // The namespace of the cluster to be used
+  "namespace": String,
+  // The name of the context in the kubeconfig being used
+  "context": String,
+  // The Helm storage option to use
+  "storage": String("secret"|"configmap"|"memory")
 }
 ```
 
+**Request Body**: N/A
+
 **Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
 
 ```js
@@ -516,24 +589,21 @@ User object with only the id field. Other fields are empty - with values in para
 - `name` The name of the release.
 - `revision` The number of the release (set to `0` for the latest deployed release).
 
-**Query parameters:** N/A
-
-**Request Body**:
+**Query parameters:** 
 
 ```js
 {
-  "user_id": Number,
-  "helm": {
-    // The namespace of the cluster to be used
-    "namespace": String,
-    // The name of the context in the kubeconfig being used
-    "context": String,
-    // The Helm storage option to use
-    "storage": String("secret"|"configmap"|"memory")
-  }
+  // The namespace of the cluster to be used
+  "namespace": String,
+  // The name of the context in the kubeconfig being used
+  "context": String,
+  // The Helm storage option to use
+  "storage": String("secret"|"configmap"|"memory")
 }
 ```
 
+**Request Body**: N/A
+
 **Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
 
 ```js
@@ -602,18 +672,13 @@ Chart{
 
 **Query parameters:** N/A
 
-**Request Body**: 
-
 ```js
-{
-  "user_id": Number,
-  "k8s": {
-    // The name of the context in the kubeconfig being used
-    "context": String,
-  }
-}
+// The name of the context in the kubeconfig being used
+"context": String,
 ```
 
+**Request Body**: N/A
+
 **Successful Response Body**: the full body is determined by the [namespace specification](https://pkg.go.dev/k8s.io/api/core/v1#NamespaceList), but we're primarily only interested in namespace `name`:
 
 ```js

+ 64 - 11
internal/forms/chart.go

@@ -1,39 +1,92 @@
 package forms
 
 import (
+	"net/url"
+	"strconv"
+
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
 // ChartForm is the generic base type for CRUD operations on charts
 type ChartForm struct {
-	HelmOptions *helm.Form `json:"helm" form:"required"`
-	UserID      uint       `json:"user_id"`
+	*helm.Form
+}
+
+// PopulateHelmOptionsFromQueryParams populates fields in the ChartForm using the passed
+// url.Values (the parsed query params)
+func (cf *ChartForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
+	if context, ok := vals["context"]; ok && len(context) == 1 {
+		cf.Context = context[0]
+	}
+
+	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
+		cf.Namespace = namespace[0]
+	}
+
+	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
+		cf.Storage = storage[0]
+	}
 }
 
-// PopulateHelmOptions uses the passed user ID to populate the HelmOptions object
-func (cf *ChartForm) PopulateHelmOptions(repo repository.UserRepository) error {
-	user, err := repo.ReadUser(cf.UserID)
+// PopulateHelmOptionsFromUserID uses the passed user ID to populate the HelmOptions object
+func (cf *ChartForm) PopulateHelmOptionsFromUserID(userID uint, repo repository.UserRepository) error {
+	user, err := repo.ReadUser(userID)
 
 	if err != nil {
 		return err
 	}
 
-	cf.HelmOptions.AllowedContexts = user.ContextToSlice()
-	cf.HelmOptions.KubeConfig = user.RawKubeConfig
+	cf.AllowedContexts = user.ContextToSlice()
+	cf.KubeConfig = user.RawKubeConfig
 
 	return nil
 }
 
 // ListChartForm represents the accepted values for listing Helm charts
 type ListChartForm struct {
-	ChartForm
-	ListFilter *helm.ListFilter `json:"filter" form:"required"`
+	*ChartForm
+	*helm.ListFilter
+}
+
+// PopulateListFromQueryParams populates fields in the ListChartForm using the passed
+// url.Values (the parsed query params). It calls the underlying
+// PopulateHelmOptionsFromQueryParams
+func (lcf *ListChartForm) PopulateListFromQueryParams(vals url.Values) {
+	lcf.PopulateHelmOptionsFromQueryParams(vals)
+
+	if limit, ok := vals["limit"]; ok && len(limit) == 1 {
+		if limitInt, err := strconv.ParseInt(limit[0], 10, 64); err == nil {
+			lcf.ListFilter.Limit = int(limitInt)
+		}
+	}
+
+	if skip, ok := vals["skip"]; ok && len(skip) == 1 {
+		if skipInt, err := strconv.ParseInt(skip[0], 10, 64); err == nil {
+			lcf.ListFilter.Skip = int(skipInt)
+		}
+	}
+
+	if byDate, ok := vals["byDate"]; ok && len(byDate) == 1 {
+		if byDateBool, err := strconv.ParseBool(byDate[0]); err == nil {
+			lcf.ListFilter.ByDate = byDateBool
+		}
+	}
+
+	if statusFilter, ok := vals["statusFilter"]; ok {
+		lcf.ListFilter.StatusFilter = statusFilter
+	}
 }
 
 // GetChartForm represents the accepted values for getting a single Helm chart
 type GetChartForm struct {
-	ChartForm
+	*ChartForm
 	Name     string `json:"name" form:"required"`
-	Revision int    `json:"release"`
+	Revision int    `json:"revision"`
+}
+
+// ListChartHistoryForm represents the accepted values for getting a single Helm chart
+type ListChartHistoryForm struct {
+	*ChartForm
+	Name string `json:"name" form:"required"`
 }

+ 16 - 7
internal/forms/k8s.go

@@ -1,26 +1,35 @@
 package forms
 
 import (
+	"net/url"
+
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
 // K8sForm is the generic base type for CRUD operations on k8s objects
 type K8sForm struct {
-	K8sOptions *kubernetes.OutOfClusterConfig `json:"k8s" form:"required"`
-	UserID     uint                           `json:"user_id"`
+	*kubernetes.OutOfClusterConfig
+}
+
+// PopulateK8sOptionsFromQueryParams populates fields in the ChartForm using the passed
+// url.Values (the parsed query params)
+func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(vals url.Values) {
+	if context, ok := vals["context"]; ok && len(context) == 1 {
+		kf.Context = context[0]
+	}
 }
 
-// PopulateK8sOptions uses the passed user ID to populate the HelmOptions object
-func (kf *K8sForm) PopulateK8sOptions(repo repository.UserRepository) error {
-	user, err := repo.ReadUser(kf.UserID)
+// PopulateK8sOptionsFromUserID uses the passed userID to populate the HelmOptions object
+func (kf *K8sForm) PopulateK8sOptionsFromUserID(userID uint, repo repository.UserRepository) error {
+	user, err := repo.ReadUser(userID)
 
 	if err != nil {
 		return err
 	}
 
-	kf.K8sOptions.AllowedContexts = user.ContextToSlice()
-	kf.K8sOptions.KubeConfig = user.RawKubeConfig
+	kf.AllowedContexts = user.ContextToSlice()
+	kf.KubeConfig = user.RawKubeConfig
 
 	return nil
 }

+ 9 - 0
internal/helm/agent.go

@@ -34,3 +34,12 @@ func (a *Agent) GetRelease(
 
 	return cmd.Run(name)
 }
+
+// GetReleaseHistory returns a list of charts for a specific release
+func (a *Agent) GetReleaseHistory(
+	name string,
+) ([]*release.Release, error) {
+	cmd := action.NewHistory(a.ActionConfig)
+
+	return cmd.Run(name)
+}

+ 34 - 0
internal/helm/agent_test.go

@@ -212,3 +212,37 @@ func TestGetReleases(t *testing.T) {
 		compareReleaseToStubs(t, []*release.Release{rel}, []releaseStub{tc.expRes})
 	}
 }
+
+var listReleaseHistoryTests = []listReleaseTest{
+	listReleaseTest{
+		name:      "simple history test",
+		namespace: "default",
+		releases: []releaseStub{
+			releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+		},
+		expRes: []releaseStub{
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+			releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
+		},
+	},
+}
+
+func TestListReleaseHistory(t *testing.T) {
+	for _, tc := range listReleaseHistoryTests {
+		agent := newAgentFixture(t, tc.namespace)
+		makeReleases(t, agent, tc.releases)
+
+		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+		// namespace, so we have to reset the namespace of the storage driver
+		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+
+		releases, err := agent.GetReleaseHistory("wordpress")
+
+		if err != nil {
+			t.Errorf("%v", err)
+		}
+
+		compareReleaseToStubs(t, releases, tc.expRes)
+	}
+}

+ 4 - 3
internal/kubernetes/kubeconfig.go

@@ -36,15 +36,16 @@ func GetRestrictedClientConfigFromBytes(
 	// put allowed clusters in a map
 	aContextMap := createAllowedContextMap(allowedContexts)
 
-	// discover all allowed clusters
-	for name, context := range rawConf.Contexts {
+	context, ok := rawConf.Contexts[contextName]
+
+	if ok {
 		userName := context.AuthInfo
 		clusterName := context.Cluster
 		authInfo, userFound := rawConf.AuthInfos[userName]
 		cluster, clusterFound := rawConf.Clusters[clusterName]
 
 		// make sure the cluster is "allowed"
-		_, isAllowed := aContextMap[name]
+		_, isAllowed := aContextMap[contextName]
 
 		if userFound && clusterFound && isAllowed {
 			copyConf.Clusters[clusterName] = cluster

+ 100 - 15
server/api/chart_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"net/url"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -18,16 +19,32 @@ const (
 
 // HandleListCharts retrieves a list of charts with various filter options
 func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
-	// get the filter options
-	form := &forms.ListChartForm{}
+	session, err := app.store.Get(r, app.cookieName)
 
-	// decode from JSON to form value
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+	if err != nil {
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 	}
 
-	form.PopulateHelmOptions(app.repo.User)
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.ListChartForm{
+		ChartForm: &forms.ChartForm{
+			Form: &helm.Form{},
+		},
+		ListFilter: &helm.ListFilter{},
+	}
+	form.PopulateListFromQueryParams(vals)
+
+	if sessID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateHelmOptionsFromUserID(sessID, app.repo.User)
+	}
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -37,15 +54,14 @@ func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
 
 	// create a new agent
 	var agent *helm.Agent
-	var err error
 
 	if app.testing {
 		agent = app.TestAgents.HelmAgent
 	} else {
-		agent, err = helm.GetAgentOutOfClusterConfig(form.HelmOptions, app.logger)
+		agent, err = helm.GetAgentOutOfClusterConfig(form.ChartForm.Form, app.logger)
 	}
 
-	releases, err := agent.ListReleases(form.HelmOptions.Namespace, form.ListFilter)
+	releases, err := agent.ListReleases(form.Namespace, form.ListFilter)
 
 	if err != nil {
 		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
@@ -60,28 +76,37 @@ func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
 
 // HandleGetChart retrieves a single chart based on a name and revision
 func (app *App) HandleGetChart(w http.ResponseWriter, r *http.Request) {
-	name := chi.URLParam(r, "name")
-	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+	session, err := app.store.Get(r, app.cookieName)
 
-	// decode from JSON to form value
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 	}
 
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
 	// get the filter options
 	form := &forms.GetChartForm{
+		ChartForm: &forms.ChartForm{
+			Form: &helm.Form{},
+		},
 		Name:     name,
 		Revision: int(revision),
 	}
 
-	// decode from JSON to form value
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 	}
 
-	form.PopulateHelmOptions(app.repo.User)
+	form.PopulateHelmOptionsFromQueryParams(vals)
+
+	if sessID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateHelmOptionsFromUserID(sessID, app.repo.User)
+	}
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -95,7 +120,7 @@ func (app *App) HandleGetChart(w http.ResponseWriter, r *http.Request) {
 	if app.testing {
 		agent = app.TestAgents.HelmAgent
 	} else {
-		agent, err = helm.GetAgentOutOfClusterConfig(form.HelmOptions, app.logger)
+		agent, err = helm.GetAgentOutOfClusterConfig(form.ChartForm.Form, app.logger)
 	}
 
 	release, err := agent.GetRelease(form.Name, form.Revision)
@@ -110,3 +135,63 @@ func (app *App) HandleGetChart(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+// HandleListChartHistory retrieves a history of charts based on a chart name
+func (app *App) HandleListChartHistory(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	name := chi.URLParam(r, "name")
+
+	// get the filter options
+	form := &forms.ListChartHistoryForm{
+		ChartForm: &forms.ChartForm{
+			Form: &helm.Form{},
+		},
+		Name: name,
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	form.PopulateHelmOptionsFromQueryParams(vals)
+
+	if sessID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateHelmOptionsFromUserID(sessID, app.repo.User)
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
+
+	// create a new agent
+	var agent *helm.Agent
+
+	if app.testing {
+		agent = app.TestAgents.HelmAgent
+	} else {
+		agent, err = helm.GetAgentOutOfClusterConfig(form.ChartForm.Form, app.logger)
+	}
+
+	release, err := agent.GetReleaseHistory(form.Name)
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(release); err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+}

+ 64 - 29
server/api/chart_handler_test.go

@@ -3,6 +3,7 @@ package api_test
 import (
 	"encoding/json"
 	"net/http"
+	"net/url"
 	"reflect"
 	"strings"
 	"testing"
@@ -85,24 +86,18 @@ var listChartsTests = []*chartTest{
 		initializers: []func(tester *tester){
 			initDefaultCharts,
 		},
-		msg:      "List charts",
-		method:   "GET",
-		endpoint: "/api/charts",
-		body: `{
-			"user_id": 1,
-			"helm": {
-				"namespace": "",
-				"context": "context-test",
-				"storage": "memory"
-			},
-			"filter": {
-				"namespace": "",
-				"limit": 20,
-				"skip": 0,
-				"byDate": false,
-				"statusFilter": ["deployed"]
-			}
-		}`,
+		msg:    "List charts",
+		method: "GET",
+		endpoint: "/api/charts?" + url.Values{
+			"namespace":    []string{""},
+			"context":      []string{"context-test"},
+			"storage":      []string{"memory"},
+			"limit":        []string{"20"},
+			"skip":         []string{"0"},
+			"byDate":       []string{"false"},
+			"statusFilter": []string{"deployed"},
+		}.Encode(),
+		body:      "",
 		expStatus: http.StatusOK,
 		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
 		useCookie: true,
@@ -121,17 +116,14 @@ var getChartTests = []*chartTest{
 		initializers: []func(tester *tester){
 			initDefaultCharts,
 		},
-		msg:      "Get charts",
-		method:   "GET",
-		endpoint: "/api/charts/airwatch/0",
-		body: `{
-			"user_id": 1,
-			"helm": {
-				"namespace": "",
-				"context": "context-test",
-				"storage": "memory"
-			}
-		}`,
+		msg:    "Get charts",
+		method: "GET",
+		endpoint: "/api/charts/airwatch/0?" + url.Values{
+			"namespace": []string{""},
+			"context":   []string{"context-test"},
+			"storage":   []string{"memory"},
+		}.Encode(),
+		body:      "",
 		expStatus: http.StatusOK,
 		expBody:   releaseStubToChartJSON(sampleReleaseStubs[0]),
 		useCookie: true,
@@ -145,6 +137,32 @@ func TestHandleGetChart(t *testing.T) {
 	testChartRequests(t, getChartTests, true)
 }
 
+var listChartHistoryTests = []*chartTest{
+	&chartTest{
+		initializers: []func(tester *tester){
+			initHistoryCharts,
+		},
+		msg:    "List chart history",
+		method: "GET",
+		endpoint: "/api/charts/wordpress/history?" + url.Values{
+			"namespace": []string{""},
+			"context":   []string{"context-test"},
+			"storage":   []string{"memory"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   releaseStubsToChartJSON(historyReleaseStubs),
+		useCookie: true,
+		validators: []func(c *chartTest, tester *tester, t *testing.T){
+			chartReleaseBodyValidator,
+		},
+	},
+}
+
+func TestHandleListChartHistory(t *testing.T) {
+	testChartRequests(t, listChartHistoryTests, true)
+}
+
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 func initDefaultCharts(tester *tester) {
@@ -159,12 +177,29 @@ func initDefaultCharts(tester *tester) {
 	agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace("")
 }
 
+func initHistoryCharts(tester *tester) {
+	initUserDefault(tester)
+
+	agent := tester.app.TestAgents.HelmAgent
+
+	makeReleases(agent, historyReleaseStubs)
+
+	// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+	// namespace, so we have to reset the namespace of the storage driver
+	agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace("")
+}
+
 var sampleReleaseStubs = []releaseStub{
 	releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
 	releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
 	releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
 }
 
+var historyReleaseStubs = []releaseStub{
+	releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+	releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
+}
+
 func releaseStubsToChartJSON(rels []releaseStub) string {
 	releases := make([]*release.Release, 0)
 

+ 21 - 7
server/api/k8s_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"net/url"
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 
@@ -17,15 +18,29 @@ const (
 
 // HandleListNamespaces retrieves a list of namespaces
 func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
-	form := &forms.K8sForm{}
+	session, err := app.store.Get(r, app.cookieName)
 
-	// decode from JSON to form value
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
-		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 	}
 
-	form.PopulateK8sOptions(app.repo.User)
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{},
+	}
+	form.PopulateK8sOptionsFromQueryParams(vals)
+
+	if sessID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateK8sOptionsFromUserID(sessID, app.repo.User)
+	}
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -35,12 +50,11 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 
 	// create a new agent
 	var agent *kubernetes.Agent
-	var err error
 
 	if app.testing {
 		agent = app.TestAgents.K8sAgent
 	} else {
-		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.K8sOptions)
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
 	namespaces, err := agent.ListNamespaces()

+ 7 - 10
server/api/k8s_handler_test.go

@@ -3,6 +3,7 @@ package api_test
 import (
 	"encoding/json"
 	"net/http"
+	"net/url"
 	"reflect"
 	"strings"
 	"testing"
@@ -76,16 +77,12 @@ var listNamespacesTests = []*k8sTest{
 		initializers: []func(tester *tester){
 			initDefaultK8s,
 		},
-		msg:      "List namespaces",
-		method:   "GET",
-		endpoint: "/api/k8s/namespaces",
-		body: `{
-			"user_id": 1,
-			"k8s": {
-				"namespace": "",
-				"context": "context-test"
-			}
-		}`,
+		msg:    "List namespaces",
+		method: "GET",
+		endpoint: "/api/k8s/namespaces?" + url.Values{
+			"context": []string{"context-test"},
+		}.Encode(),
+		body:      "",
 		expStatus: http.StatusOK,
 		expBody:   objectsToJSON(defaultObjects),
 		useCookie: true,

+ 4 - 3
server/router/router.go

@@ -28,11 +28,12 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 
 		// /api/charts routes
-		r.Method("GET", "/charts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListCharts, l), mw.BodyParam))
-		r.Method("GET", "/charts/{name}/{revision}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleGetChart, l), mw.BodyParam))
+		r.Method("GET", "/charts", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListCharts, l)))
+		r.Method("GET", "/charts/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListChartHistory, l)))
+		r.Method("GET", "/charts/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetChart, l)))
 
 		// /api/k8s routes
-		r.Method("GET", "/k8s/namespaces", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListNamespaces, l), mw.BodyParam))
+		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 	})
 
 	return r