Kaynağa Gözat

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

Frontend integration
abelanger5 5 yıl önce
ebeveyn
işleme
8d0d5548b0

+ 1 - 1
.air.toml

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

+ 20 - 4
dashboard/package-lock.json

@@ -420,6 +420,11 @@
       "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
       "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
       "dev": true
       "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": {
     "@types/react": {
       "version": "16.9.49",
       "version": "16.9.49",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.49.tgz",
       "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",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
           "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
           "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
           "dev": true
           "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=",
           "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=",
           "dev": true
           "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": {
         "safe-buffer": {
           "version": "5.1.2",
           "version": "5.1.2",
           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -5275,10 +5292,9 @@
       "dev": true
       "dev": true
     },
     },
     "qs": {
     "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": {
     "querystring": {
       "version": "0.2.0",
       "version": "0.2.0",

+ 2 - 0
dashboard/package.json

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

+ 0 - 0
dashboard/src/assets/grad.jpg → dashboard/src/assets/gradient.jpg


+ 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: 25px;
+`;
+
+const StyledLoading = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

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

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

+ 20 - 3
dashboard/src/main/Login.tsx

@@ -28,7 +28,7 @@ export default class Login extends Component<PropsType, StateType> {
   handleLogin = (): void => {
   handleLogin = (): void => {
     let { email, password } = this.state;
     let { email, password } = this.state;
     let { authenticate } = this.props;
     let { authenticate } = this.props;
-    let { setCurrentError } = this.context;
+    let { setCurrentError, setUserId } = this.context;
 
 
     // Check for valid input
     // Check for valid input
     if (!emailRegex.test(email)) {
     if (!emailRegex.test(email)) {
@@ -40,6 +40,7 @@ export default class Login extends Component<PropsType, StateType> {
         password: password
         password: password
       }, {}, (err: any, res: any) => {
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
         // TODO: case and set credential error
+        setUserId(res?.data?.id)
         err ? setCurrentError(err.response.data.errors[0]) : authenticate();
         err ? setCurrentError(err.response.data.errors[0]) : authenticate();
       });
       });
     }
     }
@@ -108,6 +109,10 @@ export default class Login extends Component<PropsType, StateType> {
               {this.renderCredentialError()}
               {this.renderCredentialError()}
             </InputWrapper>
             </InputWrapper>
             <Button onClick={this.handleLogin}>Continue</Button>
             <Button onClick={this.handleLogin}>Continue</Button>
+
+            <Helper>Don't have an account?
+              <Link href='/register'>Sign up</Link>
+            </Helper>
           </FormWrapper>
           </FormWrapper>
         </LoginPanel>
         </LoginPanel>
       </StyledLogin>
       </StyledLogin>
@@ -117,6 +122,18 @@ export default class Login extends Component<PropsType, StateType> {
 
 
 Login.contextType = Context;
 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`
 const OverflowWrapper = styled.div`
   position: absolute;
   position: absolute;
   top: 0;
   top: 0;
@@ -205,7 +222,7 @@ const Prompt = styled.div`
 
 
 const Logo = styled.img`
 const Logo = styled.img`
   width: 150px;
   width: 150px;
-  margin-top: 63px;
+  margin-top: 53px;
   user-select: none;
   user-select: none;
 `;
 `;
 
 
@@ -236,7 +253,7 @@ const GradientBg = styled.div`
 
 
 const LoginPanel = styled.div`
 const LoginPanel = styled.div`
   width: 330px;
   width: 330px;
-  height: 430px;
+  height: 450px;
   background: white;
   background: white;
   margin-top: -20px;
   margin-top: -20px;
   border-radius: 10px;
   border-radius: 10px;

+ 5 - 5
dashboard/src/main/Main.tsx

@@ -25,13 +25,14 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
   state = {
     isLoading: false,
     isLoading: false,
     isLoggedIn : false,
     isLoggedIn : false,
-    initialized: false
+    initialized: (localStorage.getItem("init") == 'true')
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    localStorage.getItem("init") == 'true' ? this.setState({initialized: true}) : this.setState({initialized: false})
+    let { setUserId } = this.context;
     api.checkAuth('', {}, {}, (err: any, res: any) => {
     api.checkAuth('', {}, {}, (err: any, res: any) => {
       if (res.data) {
       if (res.data) {
+        setUserId(res.data.id)
         this.setState({ isLoggedIn: true, initialized: true})
         this.setState({ isLoggedIn: true, initialized: true})
       } else {
       } else {
         this.setState({ isLoggedIn: false })
         this.setState({ isLoggedIn: false })
@@ -50,13 +51,12 @@ export default class Main extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     return (
     return (
-
       <StyledMain>
       <StyledMain>
         <GlobalStyle />
         <GlobalStyle />
         <BrowserRouter>
         <BrowserRouter>
           <Switch>
           <Switch>
             <Route path='/login' render={() => {
             <Route path='/login' render={() => {
-              if (!this.state.isLoggedIn && this.state.initialized) {
+              if (!this.state.isLoggedIn) {
                 return <Login authenticate={this.authenticate} />
                 return <Login authenticate={this.authenticate} />
               } else {
               } else {
                 return <Redirect to='/' />
                 return <Redirect to='/' />
@@ -64,7 +64,7 @@ export default class Main extends Component<PropsType, StateType> {
             }} />
             }} />
 
 
             <Route path='/register' render={() => {
             <Route path='/register' render={() => {
-              if (!this.state.initialized) {
+              if (!this.state.isLoggedIn) {
                 return <Register authenticate={this.initialize} />
                 return <Register authenticate={this.initialize} />
               } else {
               } else {
                 return <Redirect to='/' />
                 return <Redirect to='/' />

+ 20 - 3
dashboard/src/main/Register.tsx

@@ -30,7 +30,7 @@ export default class Register extends Component<PropsType, StateType> {
   handleRegister = (): void => {
   handleRegister = (): void => {
     let { email, password, confirmPassword } = this.state;
     let { email, password, confirmPassword } = this.state;
     let { authenticate } = this.props;
     let { authenticate } = this.props;
-    let { setCurrentError } = this.context;
+    let { setCurrentError, setUserId } = this.context;
 
 
     if (!emailRegex.test(email)) {
     if (!emailRegex.test(email)) {
       this.setState({ emailError: true });
       this.setState({ emailError: true });
@@ -48,6 +48,7 @@ export default class Register extends Component<PropsType, StateType> {
         email: email,
         email: email,
         password: password
         password: password
       }, {}, (err: any, res: any) => {
       }, {}, (err: any, res: any) => {
+        setUserId(res?.data?.id)
         err ? setCurrentError(err.response.data.errors[0]) : authenticate();
         err ? setCurrentError(err.response.data.errors[0]) : authenticate();
       });
       });
     } 
     } 
@@ -130,6 +131,10 @@ export default class Register extends Component<PropsType, StateType> {
               {this.renderConfirmPasswordError()}
               {this.renderConfirmPasswordError()}
             </InputWrapper>
             </InputWrapper>
             <Button onClick={this.handleRegister}>Continue</Button>
             <Button onClick={this.handleRegister}>Continue</Button>
+
+            <Helper>Have an account?
+              <Link href='/login'>Sign in</Link>
+            </Helper>
           </FormWrapper>
           </FormWrapper>
         </LoginPanel>
         </LoginPanel>
       </StyledRegister>
       </StyledRegister>
@@ -139,6 +144,18 @@ export default class Register extends Component<PropsType, StateType> {
 
 
 Register.contextType = Context;
 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`
 const OverflowWrapper = styled.div`
   position: absolute;
   position: absolute;
   top: 0;
   top: 0;
@@ -227,7 +244,7 @@ const Prompt = styled.div`
 
 
 const Logo = styled.img`
 const Logo = styled.img`
   width: 150px;
   width: 150px;
-  margin-top: 50px;
+  margin-top: 40px;
   user-select: none;
   user-select: none;
 `;
 `;
 
 
@@ -258,7 +275,7 @@ const GradientBg = styled.div`
 
 
 const LoginPanel = styled.div`
 const LoginPanel = styled.div`
   width: 330px;
   width: 330px;
-  height: 430px;
+  height: 450px;
   background: white;
   background: white;
   margin-top: -20px;
   margin-top: -20px;
   border-radius: 10px;
   border-radius: 10px;

+ 57 - 10
dashboard/src/main/home/Home.tsx

@@ -5,6 +5,7 @@ import ReactModal from 'react-modal';
 import { Context } from '../../shared/Context';
 import { Context } from '../../shared/Context';
 
 
 import Sidebar from './sidebar/Sidebar';
 import Sidebar from './sidebar/Sidebar';
+import Dashboard from './dashboard/Dashboard';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 
 
 type PropsType = {
 type PropsType = {
@@ -15,6 +16,29 @@ type StateType = {
 };
 };
 
 
 export default class Home extends Component<PropsType, StateType> {
 export default class Home extends Component<PropsType, StateType> {
+
+  renderDashboard = () => {
+    if (this.context.currentCluster) {
+      return <DashboardWrapper><Dashboard /></DashboardWrapper>
+    }
+
+    return (
+      <DashboardWrapper>
+        <Placeholder>
+          <Bold>Porter - Getting Started</Bold><br /><br />
+          1. Navigate to <A onClick={() => {this.context.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={() => {
+            this.context.setCurrentModal('ClusterConfigModal');
+            this.context.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>
+    );
+  }
+
   render() {
   render() {
     return (
     return (
       <StyledHome>
       <StyledHome>
@@ -28,9 +52,9 @@ export default class Home extends Component<PropsType, StateType> {
         </ReactModal>
         </ReactModal>
 
 
         <Sidebar logOut={this.props.logOut} />
         <Sidebar logOut={this.props.logOut} />
-        <DummyDashboard>
-          🏗️🏗️🏗️🏗️🏗️
-        </DummyDashboard>
+        <StyledDashboard>
+          {this.renderDashboard()}
+        </StyledDashboard>
       </StyledHome>
       </StyledHome>
     );
     );
   }
   }
@@ -51,27 +75,50 @@ const MediumModalStyles = {
     margin: '0 auto',
     margin: '0 auto',
     height: '575px',
     height: '575px',
     top: 'calc(50% - 289px)',
     top: 'calc(50% - 289px)',
-    backgroundColor: '#24272a',
+    backgroundColor: '#202227',
     animation: 'floatInModal 0.5s 0s',
     animation: 'floatInModal 0.5s 0s',
     overflow: 'visible',
     overflow: 'visible',
   },
   },
 };
 };
 
 
-const DummyDashboard = styled.div`
+const StyledDashboard = styled.div`
   height: 100%;
   height: 100%;
   width: 100vw;
   width: 100vw;
-  font-family: 'Work Sans', sans-serif;
+  padding-top: 80px;
   overflow-y: auto;
   overflow-y: auto;
   display: flex;
   display: flex;
-  letter-spacing: 10px;
   flex: 1;
   flex: 1;
   justify-content: center;
   justify-content: center;
-  padding-bottom: 30px;
-  align-items: center;
-  background: ${props => props.theme.bg};
+  background: #202227;
   position: relative;
   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`
 const StyledHome = styled.div`
   width: 100vw;
   width: 100vw;
   height: 100vh;
   height: 100vh;

+ 264 - 0
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -0,0 +1,264 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import gradient from '../../../assets/gradient.jpg';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+import { ChartType } from '../../../shared/types';
+
+import Chart from './chart/Chart';
+
+type PropsType = {
+};
+
+type StateType = {
+  charts: ChartType[]
+};
+
+export default class Dashboard extends Component<PropsType, StateType> {
+  state = {
+    charts: [] as ChartType[]
+  }
+
+  componentDidMount() {
+    let { userId, setCurrentError, currentCluster } = this.context;
+    
+    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));
+      } else {
+        if (res.data) {
+          this.setState({ charts: res.data });
+        }
+      }
+    });
+  }
+
+  renderChartList = () => {
+    return this.state.charts.map((x: ChartType, i: number) => {
+      return (
+        <Chart key={i} chart={x} />
+      )
+    })
+  }
+
+  render() {
+    let { currentCluster } = this.context;
+
+    return ( 
+      <div>
+        <TitleSection>
+          <ProjectIcon>
+            <ProjectImage src={gradient} />
+            <Overlay>{currentCluster && currentCluster[0].toUpperCase()}</Overlay>
+          </ProjectIcon>
+          <Title>{currentCluster}</Title>
+          <i className="material-icons">more_vert</i>
+        </TitleSection>
+
+        <InfoSection>
+          <TopRow>
+            <InfoLabel>
+              <i className="material-icons">info</i> Info
+            </InfoLabel>
+          </TopRow>
+            <Description>Porter dashboard for {currentCluster}.</Description>
+        </InfoSection>
+
+        <LineBreak />
+
+        {this.renderChartList()}
+      </div>
+    );
+  }
+}
+
+Dashboard.contextType = Context;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #ffffff;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7A838F;
+  font-size: 13px;
+  > i {
+    color: #8B949F;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 7px;
+  margin-bottom: 35px;
+`;
+
+const ButtonWrap = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 18px;
+  margin-top: 2px;
+  margin-bottom: 25px;
+  color: #00000020;
+`;
+
+const Button = styled.div`
+  min-width: 145px;
+  max-width: 145px;
+  display: flex;
+  flex: 1;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 5px;
+  border-radius: 20px;
+  color: white;
+  padding: 6px 8px;
+  margin-right: 10px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
+  background: #616FEEcc;
+  :hover {
+    background: #505edddd;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-top: -1px;
+    justify-content: center;
+  }
+`;
+
+const ButtonStack = styled(Button)`
+  min-width: 119px;
+  max-width: 119px;
+  background: #616FEEcc;
+  :hover {
+    background: #505edddd;
+  }
+`;
+
+const ButtonAlt = styled(Button)`
+  min-width: 150px;
+  max-width: 150px;
+  background: #7A838Fdd;
+
+  :hover {
+    background: #69727eee;
+  }
+`;
+
+const ConfigButtonAlt = styled(ButtonAlt)`
+  min-width: 166px;
+  max-width: 166px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 180px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 80px 35px;
+`;
+
+const ServiceSection = styled.div`
+  padding-bottom: 150px;
+`;
+
+const Overlay = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+`;
+
+const ProjectImage = styled.img`
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const ProjectIcon = styled.div`
+  position: relative;
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 20px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 17px;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;

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

@@ -0,0 +1,159 @@
+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 = {
+    grow: false,
+  }
+
+  render() {
+    let { chart } = this.props;
+    return ( 
+      <StyledChart
+        onMouseEnter={() => this.setState({ grow: true })}
+        onMouseLeave={() => this.setState({ grow: false })}
+        grow={this.state.grow}
+      >
+        <Title>
+          <i className="material-icons">polymer</i>
+          {chart.name}
+        </Title>
+        <StatusIndicator>
+          <StatusColor status={'Running'} />
+          {chart.info.status}
+        </StatusIndicator>
+      </StyledChart>
+    );
+  }
+}
+
+const StatusIndicator = styled.div`
+  display: flex;
+  flex: 1;
+  width: 90px;
+  height: 20px;
+  font-size: 13px;
+  margin-top: 10px;
+  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 == 'Running' ? '#4797ff' : props.status == 'Stopped' ? "#ed5f85" : "#f5cb42")};
+  border-radius: 20px;
+  margin-right: 10px;
+`;
+
+const Title = styled.div`
+  position: relative;
+  text-decoration: none;
+  padding: 16px 35px 12px 43px;
+  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;
+
+  > i {
+    color: #efefef;
+    background: none;
+    font-size: 16px;
+    top: 11px;
+    left: 14px;
+
+    padding: 5px 4px;
+    height: 20px;
+    width: 20px;
+    border-radius: 3px;
+    position: absolute;
+  }
+
+  >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;
+  padding-top: 3px;
+  padding-bottom: 18px;
+  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: { grow: boolean }) => props.grow ? 'grow' : 'shrink'} 0.12s;
+  animation-fill-mode: forwards;
+  animation-timing-function: ease-out;
+
+  @keyframes grow {
+    from { 
+      width: calc(100% + 2px); 
+      padding-top: 3px;
+      padding-bottom: 18px;
+      margin-left: 0px;
+      box-shadow: 0 5px 8px 0px #00000033;
+    }
+    to {
+      width: calc(100% + 22px);
+      padding-top: 5px;
+      padding-bottom: 22px;
+      margin-left: -10px; 
+      box-shadow: 0 8px 20px 0px #00000030;
+    }
+  }
+
+  @keyframes shrink {
+    from { 
+      width: calc(100% + 22px);
+      padding-top: 5px;
+      padding-bottom: 22px;
+      margin-left: -10px; 
+      box-shadow: 0 8px 20px 0px #00000030;
+    }
+    to {
+      width: calc(100% + 2px); 
+      padding-top: 3px;
+      padding-bottom: 18px;
+      margin-left: 0px; 
+      box-shadow: 0 5px 8px 0px #00000033;
+    }
+  }
+`;

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

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

@@ -14,36 +14,42 @@ type PropsType = {
 };
 };
 
 
 type StateType = {
 type StateType = {
-  configExists: boolean,
   showDrawer: boolean,
   showDrawer: boolean,
   initializedDrawer: boolean,
   initializedDrawer: boolean,
-  kubeContexts: KubeContextConfig[],
-  activeIndex: number,
+  kubeContexts: KubeContextConfig[]
 };
 };
 
 
 export default class ClusterSection extends Component<PropsType, StateType> {
 export default class ClusterSection extends Component<PropsType, StateType> {
 
 
   // Need to track initialized for animation mounting
   // Need to track initialized for animation mounting
   state = {
   state = {
-    configExists: true,
     showDrawer: false,
     showDrawer: false,
     initializedDrawer: false,
     initializedDrawer: false,
-    kubeContexts: [] as KubeContextConfig[],
-    activeIndex: 0,
+    kubeContexts: [] as KubeContextConfig[]
   };
   };
 
 
   updateClusters = () => {
   updateClusters = () => {
-    let { setCurrentError, userId } = this.context;
+    let { setCurrentError, userId, setCurrentCluster } = this.context;
 
 
     // TODO: query with selected filter once implemented
     // TODO: query with selected filter once implemented
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
       if (err) {
-        setCurrentError('getContexts: ' + JSON.stringify(err));
+        setCurrentError('Could not read clusters: ' + JSON.stringify(err));
       } else {
       } else {
 
 
-        // Filter selected (temporary)
-        let kubeContexts = res.data.filter((x: KubeContextConfig) => x.selected);
-        this.setState({ kubeContexts });
+        // 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(null);
+          }
+        }
       }
       }
     });
     });
   }
   }
@@ -77,8 +83,6 @@ export default class ClusterSection extends Component<PropsType, StateType> {
           toggleDrawer={this.toggleDrawer}
           toggleDrawer={this.toggleDrawer}
           showDrawer={this.state.showDrawer}
           showDrawer={this.state.showDrawer}
           kubeContexts={this.state.kubeContexts}
           kubeContexts={this.state.kubeContexts}
-          activeIndex={this.state.activeIndex}
-          setActiveIndex={(i: number): void => this.setState({ activeIndex: i })}
         />
         />
       );
       );
     }
     }
@@ -90,14 +94,15 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   }
   }
 
 
   renderContents = (): JSX.Element => {
   renderContents = (): JSX.Element => {
-    let { kubeContexts, activeIndex, showDrawer } = this.state;
+    let { kubeContexts, showDrawer } = this.state;
+    let { currentCluster } = this.context;
 
 
     if (kubeContexts.length > 0) {
     if (kubeContexts.length > 0) {
       return (
       return (
         <ClusterSelector showDrawer={showDrawer}>
         <ClusterSelector showDrawer={showDrawer}>
           <LinkWrapper>
           <LinkWrapper>
             <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
             <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
-            <ClusterName>{kubeContexts[activeIndex].name}</ClusterName>
+            <ClusterName>{currentCluster}</ClusterName>
           </LinkWrapper>
           </LinkWrapper>
           <DrawerButton onClick={this.toggleDrawer}>
           <DrawerButton onClick={this.toggleDrawer}>
             <BgAccent src={drawerBg} />
             <BgAccent src={drawerBg} />

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

@@ -9,8 +9,6 @@ type PropsType = {
   toggleDrawer: () => void,
   toggleDrawer: () => void,
   showDrawer: boolean,
   showDrawer: boolean,
   kubeContexts: KubeContextConfig[],
   kubeContexts: KubeContextConfig[],
-  activeIndex: number,
-  setActiveIndex: (i: number) => void,
   updateClusters: () => void
   updateClusters: () => void
 };
 };
 
 
@@ -20,7 +18,9 @@ type StateType = {
 export default class Drawer extends Component<PropsType, StateType> {
 export default class Drawer extends Component<PropsType, StateType> {
 
 
   renderClusterList = (): JSX.Element[] | JSX.Element => {
   renderClusterList = (): JSX.Element[] | JSX.Element => {
-    let { kubeContexts, activeIndex, setActiveIndex } = this.props;
+    let { kubeContexts } = this.props;
+    let { currentCluster, setCurrentCluster } = this.context;
+
     if (kubeContexts.length > 0) {
     if (kubeContexts.length > 0) {
       return kubeContexts.map((kubeContext: KubeContextConfig, i: number) => {
       return kubeContexts.map((kubeContext: KubeContextConfig, i: number) => {
         /*
         /*
@@ -31,8 +31,8 @@ export default class Drawer extends Component<PropsType, StateType> {
         return (
         return (
           <ClusterOption
           <ClusterOption
             key={i}
             key={i}
-            active={i === activeIndex}
-            onClick={() => setActiveIndex(i)}
+            active={kubeContext.name === currentCluster}
+            onClick={() => setCurrentCluster(kubeContext.name)}
           >
           >
             <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
             <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
             <ClusterName>{kubeContext.name}</ClusterName>
             <ClusterName>{kubeContext.name}</ClusterName>

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

@@ -1,6 +1,6 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
-import gradient from '../../../assets/grad.jpg';
+import gradient from '../../../assets/gradient.jpg';
 
 
 import api from '../../../shared/api';
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
@@ -190,9 +190,10 @@ const SidebarBg = styled.div`
   top: 0;
   top: 0;
   left: 0;
   left: 0;
   width: 100%;
   width: 100%;
-  background-color: #333748;
+  background-color: #292c35;
   height: 100%;
   height: 100%;
   z-index: -1;
   z-index: -1;
+  box-shadow: 8px 0px 8px 0px #00000010;
 `;
 `;
 
 
 const SidebarLabel = styled.div`
 const SidebarLabel = styled.div`

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

@@ -49,11 +49,7 @@ class ContextProvider extends Component {
       this.setState({ devOpsMode });
       this.setState({ devOpsMode });
     }
     }
   };
   };
-
-  componentDidMount() {
-    this.setState({ userId: 3 });
-  }
-
+  
   render() {
   render() {
     return (
     return (
       <Provider value={this.state}>{this.props.children}</Provider>
       <Provider value={this.state}>{this.props.children}</Provider>

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

@@ -1,10 +1,12 @@
 import axios from 'axios';
 import axios from 'axios';
 import { baseApi } from './baseApi';
 import { baseApi } from './baseApi';
 
 
+import { StorageType } from './types';
+
 /**
 /**
  * Generic api call format
  * Generic api call format
  * @param {string} token - Bearer token.
  * @param {string} token - Bearer token.
- * @param {Object} params - Query params.
+ * @param {Object} params - Body params.
  * @param {Object} pathParams - Path params.
  * @param {Object} pathParams - Path params.
  * @param {(err: Object, res: Object) => void} callback - Callback function.
  * @param {(err: Object, res: Object) => void} callback - Callback function.
  */
  */
@@ -38,6 +40,16 @@ const getContexts = baseApi<{}, { id: number }>('GET', pathParams => {
   return `/api/users/${pathParams.id}/contexts`;
   return `/api/users/${pathParams.id}/contexts`;
 });
 });
 
 
+const getCharts = baseApi<{
+  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)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -47,4 +59,5 @@ export default {
   getUser,
   getUser,
   updateUser,
   updateUser,
   getContexts,
   getContexts,
+  getCharts,
 }
 }

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

@@ -1,4 +1,5 @@
 import axios from 'axios';
 import axios from 'axios';
+import qs from 'qs';
 
 
 // Partial function that accepts a generic params type and returns an api method
 // 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) => {
 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 {
     } else {
       axios.get(endpointString, {
       axios.get(endpointString, {
-        params
+        params,
+        paramsSerializer: function(params) {
+          return qs.stringify(params, { arrayFormat: 'repeat' })
+        }
       })
       })
       .then(res => {
       .then(res => {
         callback && callback(null, res);
         callback && callback(null, res);

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

@@ -4,4 +4,33 @@ export interface KubeContextConfig {
   selected?: boolean,
   selected?: boolean,
   server: string,
   server: string,
   user: string
   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',
+  Memory = 'memory'
 }
 }

+ 23 - 40
docs/API.md

@@ -406,31 +406,22 @@ User{
 
 
 **URL parameters:** N/A
 **URL parameters:** N/A
 
 
-**Query parameters:** N/A
-
-**Request Body**:
+**Query parameters:** 
 
 
 ```js
 ```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")
-  },
-  "filter": {
-    "namespace": String,
-    "limit": Number,
-    "skip": Number,
-    "byDate": Boolean,
-    "statusFilter": []String
-  }
+  "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.
 **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
 ```js
@@ -498,24 +489,21 @@ User{
 - `name` The name of the release.
 - `name` The name of the release.
 - `revision` The number of the release (set to `0` for the latest deployed release).
 - `revision` The number of the release (set to `0` for the latest deployed release).
 
 
-**Query parameters:** N/A
-
-**Request Body**:
+**Query parameters:** 
 
 
 ```js
 ```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.
 **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
 ```js
@@ -584,18 +572,13 @@ Chart{
 
 
 **Query parameters:** N/A
 **Query parameters:** N/A
 
 
-**Request Body**: 
-
 ```js
 ```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`:
 **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
 ```js

+ 63 - 11
internal/forms/chart.go

@@ -1,39 +1,91 @@
 package forms
 package forms
 
 
 import (
 import (
+	"fmt"
+	"net/url"
+	"strconv"
+
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 )
 )
 
 
 // ChartForm is the generic base type for CRUD operations on charts
 // ChartForm is the generic base type for CRUD operations on charts
 type ChartForm struct {
 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) {
+	fmt.Println("HI THERE", vals, vals["context"])
+
+	if context, ok := vals["context"]; ok && len(context) == 1 {
+		fmt.Println("SETTING CONTEXT", context[0])
+
+		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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	cf.HelmOptions.AllowedContexts = user.ContextToSlice()
-	cf.HelmOptions.KubeConfig = user.RawKubeConfig
+	cf.AllowedContexts = user.ContextToSlice()
+	cf.KubeConfig = user.RawKubeConfig
 
 
 	return nil
 	return nil
 }
 }
 
 
 // ListChartForm represents the accepted values for listing Helm charts
 // ListChartForm represents the accepted values for listing Helm charts
 type ListChartForm struct {
 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
 // GetChartForm represents the accepted values for getting a single Helm chart
 type GetChartForm struct {
 type GetChartForm struct {
-	ChartForm
+	*ChartForm
 	Name     string `json:"name" form:"required"`
 	Name     string `json:"name" form:"required"`
-	Revision int    `json:"release"`
+	Revision int    `json:"revision"`
 }
 }

+ 16 - 7
internal/forms/k8s.go

@@ -1,26 +1,35 @@
 package forms
 package forms
 
 
 import (
 import (
+	"net/url"
+
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 )
 )
 
 
 // K8sForm is the generic base type for CRUD operations on k8s objects
 // K8sForm is the generic base type for CRUD operations on k8s objects
 type K8sForm struct {
 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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	kf.K8sOptions.AllowedContexts = user.ContextToSlice()
-	kf.K8sOptions.KubeConfig = user.RawKubeConfig
+	kf.AllowedContexts = user.ContextToSlice()
+	kf.KubeConfig = user.RawKubeConfig
 
 
 	return nil
 	return nil
 }
 }

+ 40 - 15
server/api/chart_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"net/http"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strconv"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
@@ -18,16 +19,32 @@ const (
 
 
 // HandleListCharts retrieves a list of charts with various filter options
 // HandleListCharts retrieves a list of charts with various filter options
 func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
 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)
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 		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
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 	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
 	// create a new agent
 	var agent *helm.Agent
 	var agent *helm.Agent
-	var err error
 
 
 	if app.testing {
 	if app.testing {
 		agent = app.TestAgents.HelmAgent
 		agent = app.TestAgents.HelmAgent
 	} else {
 	} 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 {
 	if err != nil {
 		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
 		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
 // HandleGetChart retrieves a single chart based on a name and revision
 func (app *App) HandleGetChart(w http.ResponseWriter, r *http.Request) {
 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 {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 		return
 	}
 	}
 
 
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
 	// get the filter options
 	// get the filter options
 	form := &forms.GetChartForm{
 	form := &forms.GetChartForm{
+		ChartForm: &forms.ChartForm{
+			Form: &helm.Form{},
+		},
 		Name:     name,
 		Name:     name,
 		Revision: int(revision),
 		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)
 		app.handleErrorFormDecoding(err, ErrChartDecode, w)
 		return
 		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
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 	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 {
 	if app.testing {
 		agent = app.TestAgents.HelmAgent
 		agent = app.TestAgents.HelmAgent
 	} else {
 	} 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)
 	release, err := agent.GetRelease(form.Name, form.Revision)

+ 21 - 29
server/api/chart_handler_test.go

@@ -3,6 +3,7 @@ package api_test
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"net/http"
 	"net/http"
+	"net/url"
 	"reflect"
 	"reflect"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
@@ -85,24 +86,18 @@ var listChartsTests = []*chartTest{
 		initializers: []func(tester *tester){
 		initializers: []func(tester *tester){
 			initDefaultCharts,
 			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,
 		expStatus: http.StatusOK,
 		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
 		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
 		useCookie: true,
 		useCookie: true,
@@ -121,17 +116,14 @@ var getChartTests = []*chartTest{
 		initializers: []func(tester *tester){
 		initializers: []func(tester *tester){
 			initDefaultCharts,
 			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,
 		expStatus: http.StatusOK,
 		expBody:   releaseStubToChartJSON(sampleReleaseStubs[0]),
 		expBody:   releaseStubToChartJSON(sampleReleaseStubs[0]),
 		useCookie: true,
 		useCookie: true,

+ 21 - 7
server/api/k8s_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"net/http"
 	"net/http"
+	"net/url"
 
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 
 
@@ -17,15 +18,29 @@ const (
 
 
 // HandleListNamespaces retrieves a list of namespaces
 // HandleListNamespaces retrieves a list of namespaces
 func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 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
 		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
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 	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
 	// create a new agent
 	var agent *kubernetes.Agent
 	var agent *kubernetes.Agent
-	var err error
 
 
 	if app.testing {
 	if app.testing {
 		agent = app.TestAgents.K8sAgent
 		agent = app.TestAgents.K8sAgent
 	} else {
 	} else {
-		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.K8sOptions)
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 	}
 
 
 	namespaces, err := agent.ListNamespaces()
 	namespaces, err := agent.ListNamespaces()

+ 7 - 10
server/api/k8s_handler_test.go

@@ -3,6 +3,7 @@ package api_test
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"net/http"
 	"net/http"
+	"net/url"
 	"reflect"
 	"reflect"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
@@ -76,16 +77,12 @@ var listNamespacesTests = []*k8sTest{
 		initializers: []func(tester *tester){
 		initializers: []func(tester *tester){
 			initDefaultK8s,
 			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,
 		expStatus: http.StatusOK,
 		expBody:   objectsToJSON(defaultObjects),
 		expBody:   objectsToJSON(defaultObjects),
 		useCookie: true,
 		useCookie: true,

+ 28 - 6
server/api/user_handler.go

@@ -49,11 +49,17 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 		session.Values["authenticated"] = true
 		session.Values["authenticated"] = true
 		session.Values["user_id"] = user.ID
 		session.Values["user_id"] = user.ID
 		session.Save(r, w)
 		session.Save(r, w)
+
+		if err := app.sendUserID(w, user.ID); err != nil {
+			app.handleErrorFormDecoding(err, ErrUserDecode, w)
+			return
+		}
+
 		w.WriteHeader(http.StatusCreated)
 		w.WriteHeader(http.StatusCreated)
 	}
 	}
 }
 }
 
 
-// HandleAuthCheck checks whether current session is authenticated.
+// HandleAuthCheck checks whether current session is authenticated and returns user ID if so.
 func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 	session, err := app.store.Get(r, app.cookieName)
 	session, err := app.store.Get(r, app.cookieName)
 
 
@@ -61,14 +67,14 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 	}
 
 
-	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
-		app.logger.Info().Msgf(strconv.FormatBool(auth))
-		w.WriteHeader(http.StatusOK)
-		w.Write([]byte("false"))
+	userID, _ := session.Values["user_id"].(uint)
+
+	if err := app.sendUserID(w, userID); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 		return
 	}
 	}
+
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("true"))
 }
 }
 
 
 // HandleLoginUser checks the request header for cookie and validates the user.
 // HandleLoginUser checks the request header for cookie and validates the user.
@@ -113,6 +119,11 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		app.logger.Warn().Err(err)
 		app.logger.Warn().Err(err)
 	}
 	}
 
 
+	if err := app.sendUserID(w, storedUser.ID); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }
 
 
@@ -327,3 +338,14 @@ func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
 
 
 	return nil
 	return nil
 }
 }
+
+func (app *App) sendUserID(w http.ResponseWriter, userID uint) error {
+	resUser := &models.UserExternal{
+		ID: userID,
+	}
+
+	if err := json.NewEncoder(w).Encode(resUser); err != nil {
+		return err
+	}
+	return nil
+}

+ 4 - 4
server/router/router.go

@@ -24,15 +24,15 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l), mw.URLParam))
 		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l), mw.URLParam))
 		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
-		r.Method("GET", "/auth/check", requestlog.NewHandler(a.HandleAuthCheck, l))
+		r.Method("GET", "/auth/check", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleAuthCheck, l)))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 
 
 		// /api/charts routes
 		// /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}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetChart, l)))
 
 
 		// /api/k8s routes
 		// /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
 	return r