ソースを参照

Merge branch 'frontend-integration' into helm-manifest

sunguroku 5 年 前
コミット
189449df70

+ 151 - 0
dashboard/src/components/Selector.tsx

@@ -0,0 +1,151 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  activeValue: string,
+  options: { value: string, label: string }[],
+  setActiveValue: (x: string) => void,
+  dropdownLabel: string
+};
+
+type StateType = {
+};
+
+export default class Selector extends Component<PropsType, StateType> {
+  state = {
+    expanded: false
+  }
+
+  renderOptionList = () => {
+    let { options, activeValue, setActiveValue } = this.props;
+    return options.map((option: { value: string, label: string }, i: number) => {
+      return (
+        <Option
+          key={i}
+          selected={option.value === activeValue}
+          onClick={() => setActiveValue(option.value)}
+        >
+          {option.label}
+        </Option>
+      );
+    });
+  }
+
+  renderDropdown = () => {
+    if (this.state.expanded) {
+      return (
+        <div>
+          <CloseOverlay onClick={() => this.setState({ expanded: false })}/>
+          <Dropdown>
+            <DropdownLabel>
+              {this.props.dropdownLabel}
+            </DropdownLabel>
+            {this.renderOptionList()}
+          </Dropdown>
+        </div>
+      )
+    }
+  }
+
+  render() {
+    let { activeValue } = this.props;
+    return (
+      <StyledSelector>
+        <MainSelector
+          onClick={() => this.setState({ expanded: !this.state.expanded })}
+          expanded={this.state.expanded}
+        >
+          <TextWrap>
+            {activeValue === '' ? 'All' : activeValue}
+          </TextWrap>
+          <i className="material-icons">arrow_drop_down</i>
+        </MainSelector>
+        {this.renderDropdown()}
+      </StyledSelector>
+    );
+  }
+}
+
+const TextWrap = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const DropdownLabel = styled.div`
+  font-size: 13px;
+  color: #ffffff44;
+  font-weight: 500;
+  margin: 10px 13px;
+`;
+
+const Option = styled.div` 
+  width: 100%;
+  border-bottom: 1px solid #ffffff10;
+  height: 35px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean }) => props.selected ? '#ffffff11' : ''};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const CloseOverlay = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 999;
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  right: 0;
+  top: calc(100% + 5px);
+  background: #26282f;
+  width: calc(100% + 80px);
+  max-height: 300px;
+  padding-bottom: 20px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  box-shadow: 0 8px 20px 0px #00000055;
+`;
+
+const StyledSelector = styled.div`
+  position: relative;
+`;
+
+const MainSelector = styled.div`
+  width: 150px;
+  height: 30px;
+  border: 1px solid #ffffff66;
+  font-size: 13px;
+  padding: 5px 10px;
+  padding-left: 12px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff33' : '#ffffff11'};
+
+  :hover {
+    background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff33' : '#ffffff22'};
+  }
+
+  > i {
+    font-size: 20px;
+    transform: ${(props: { expanded: boolean }) => props.expanded ? 'rotate(180deg)' : ''};
+  }
+`;

+ 4 - 1
dashboard/src/main/Main.tsx

@@ -114,6 +114,9 @@ const GlobalStyle = createGlobalStyle`
     box-sizing: border-box;
     font-family: 'Work Sans', sans-serif;
   }
+  body {
+    background: #202227;
+  }
 `;
 
 const StyledMain = styled.div`
@@ -122,6 +125,6 @@ const StyledMain = styled.div`
   position: fixed;
   top: 0;
   left: 0;
-  background: #24272a;
+  background: #202227;
   color: white;
 `;

+ 43 - 23
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -5,14 +5,20 @@ import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
 
 import ChartList from './chart/ChartList';
+import NamespaceSelector from './NamespaceSelector';
 
 type PropsType = {
 };
 
 type StateType = {
+  namespace: string
 };
 
 export default class Dashboard extends Component<PropsType, StateType> {
+  state = {
+    namespace: ''
+  }
+
   render() {
     let { currentCluster } = this.context;
 
@@ -37,8 +43,22 @@ export default class Dashboard extends Component<PropsType, StateType> {
         </InfoSection>
 
         <LineBreak />
-
-        <ChartList currentCluster={currentCluster} />
+        
+        <ControlRow>
+          <Button disabled={true}>
+            <i className="material-icons">add</i> Add a Chart
+          </Button>
+          <NamespaceSelector
+            setNamespace={(namespace) => this.setState({ namespace })}
+            namespace={this.state.namespace}
+            currentCluster={currentCluster}
+          />
+        </ControlRow>
+
+        <ChartList
+          currentCluster={currentCluster}
+          namespace={this.state.namespace}
+        />
       </div>
     );
   }
@@ -46,6 +66,14 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
 Dashboard.contextType = Context;
 
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
 const TopRow = styled.div`
   display: flex;
   align-items: center;
@@ -75,43 +103,35 @@ const InfoLabel = styled.div`
 const InfoSection = styled.div`
   margin-top: 20px;
   font-family: 'Work Sans', sans-serif;
-  margin-left: 7px;
+  margin-left: 0px;
   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;
+  height: 30px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
   margin-right: 10px;
-  padding-right: 13px;
+  font-weight: 500;
+  padding-right: 15px;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: not-allowed;
 
-  background: #616FEEcc;
+  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
   :hover {
-    background: #505edddd;
+    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
   }
 
   > i {
@@ -122,7 +142,7 @@ const Button = styled.div`
     border-radius: 20px;
     display: flex;
     align-items: center;
-    margin-top: -1px;
+    margin-right: 8px;
     justify-content: center;
   }
 `;
@@ -196,7 +216,7 @@ const Title = styled.div`
   font-size: 20px;
   font-weight: 500;
   font-family: 'Work Sans', sans-serif;
-  margin-left: 20px;
+  margin-left: 18px;
   color: #ffffff;
   white-space: nowrap;
   overflow: hidden;
@@ -210,7 +230,7 @@ const TitleSection = styled.div`
   display: flex;
   flex-direction: row;
   align-items: center;
-  padding-left: 17px;
+  padding-left: 0px;
 
   > i {
     margin-left: 10px;

+ 87 - 0
dashboard/src/main/home/dashboard/NamespaceSelector.tsx

@@ -0,0 +1,87 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+
+import Selector from '../../../components/Selector';
+
+type PropsType = {
+  setNamespace: (x: string) => void,
+  currentCluster: string,
+  namespace: string
+};
+
+type StateType = {
+  namespaceOptions: { label: string, value: string }[]
+};
+
+
+// TODO: display selected in option dropdown and actually filter!
+
+export default class NamespaceSelector extends Component<PropsType, StateType> {
+  state = {
+    namespaceOptions: [] as { label: string, value: string }[]
+  }
+
+  updateOptions = () => {
+    let { currentCluster, setCurrentError } = this.context;
+
+    api.getNamespaces('<token>', { context: currentCluster }, {}, (err: any, res: any) => {
+      if (err) {
+        setCurrentError('Could not read clusters: ' + JSON.stringify(err));
+      } else {
+        let namespaceOptions: { label: string, value: string }[] = [];
+        res.data.items.forEach((x: { metadata: { name: string }}, i: number) => {
+          namespaceOptions.push({ label: x.metadata.name, value: x.metadata.name });
+        })
+        this.setState({ namespaceOptions });
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.updateOptions();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps !== this.props) {
+      this.updateOptions();
+    }
+  }
+
+  render() {
+    return ( 
+      <StyledNamespaceSelector>
+        <Label>
+          <i className="material-icons">filter_alt</i> Filter
+        </Label>
+        <Selector
+          activeValue={this.props.namespace}
+          setActiveValue={(namespace) => this.props.setNamespace(namespace)}
+          options={this.state.namespaceOptions}
+          dropdownLabel='Namespace:'
+        />
+      </StyledNamespaceSelector>
+    );
+  }
+}
+
+NamespaceSelector.contextType = Context;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledNamespaceSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

+ 3 - 2
dashboard/src/main/home/dashboard/chart/ChartList.tsx

@@ -9,7 +9,8 @@ import Chart from './Chart';
 import Loading from '../../../../components/Loading';
 
 type PropsType = {
-  currentCluster: string
+  currentCluster: string,
+  namespace: string
 };
 
 type StateType = {
@@ -28,7 +29,7 @@ export default class ChartList extends Component<PropsType, StateType> {
     
     this.setState({ loading: true });
     api.getCharts('<token>', {
-      namespace: '',
+      namespace: this.props.namespace,
       context: currentCluster,
       storage: 'secret',
       limit: 20,

+ 5 - 0
dashboard/src/shared/api.tsx

@@ -50,6 +50,10 @@ const getCharts = baseApi<{
   statusFilter: string[]
 }>('GET', '/api/charts');
 
+const getNamespaces = baseApi<{
+  context: string
+}>('GET', '/api/k8s/namespaces');
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -60,4 +64,5 @@ export default {
   updateUser,
   getContexts,
   getCharts,
+  getNamespaces
 }

+ 1 - 1
docker/.env

@@ -12,4 +12,4 @@ DB_PASS=porter
 DB_NAME=porter
 COOKIE_SECRETS=secret
 
-QUICK_START=true
+QUICK_START=false

+ 5 - 1
internal/forms/chart.go

@@ -53,7 +53,11 @@ type ListChartForm struct {
 // url.Values (the parsed query params). It calls the underlying
 // PopulateHelmOptionsFromQueryParams
 func (lcf *ListChartForm) PopulateListFromQueryParams(vals url.Values) {
-	lcf.PopulateHelmOptionsFromQueryParams(vals)
+	lcf.ChartForm.PopulateHelmOptionsFromQueryParams(vals)
+
+	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
+		lcf.ListFilter.Namespace = namespace[0]
+	}
 
 	if limit, ok := vals["limit"]; ok && len(limit) == 1 {
 		if limitInt, err := strconv.ParseInt(limit[0], 10, 64); err == nil {

+ 20 - 0
internal/helm/agent_test.go

@@ -129,6 +129,26 @@ var listReleaseTests = []listReleaseTest{
 			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
 		},
 	},
+	listReleaseTest{
+		name:      "simple test only default namespace",
+		namespace: "default",
+		filter: &helm.ListFilter{
+			Namespace:    "",
+			Limit:        20,
+			Skip:         0,
+			ByDate:       false,
+			StatusFilter: []string{"deployed"},
+		},
+		releases: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
+		},
+		expRes: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+		},
+	},
 	listReleaseTest{
 		name:      "simple test limit",
 		namespace: "",

+ 30 - 0
server/api/chart_handler_test.go

@@ -27,6 +27,7 @@ type releaseStub struct {
 
 type chartTest struct {
 	initializers []func(tester *tester)
+	namespace    string
 	msg          string
 	method       string
 	endpoint     string
@@ -47,6 +48,8 @@ func testChartRequests(t *testing.T, tests []*chartTest, canQuery bool) {
 			init(tester)
 		}
 
+		tester.app.TestAgents.HelmAgent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(c.namespace)
+
 		req, err := http.NewRequest(
 			c.method,
 			c.endpoint,
@@ -105,6 +108,33 @@ var listChartsTests = []*chartTest{
 			chartReleaseBodyValidator,
 		},
 	},
+	&chartTest{
+		initializers: []func(tester *tester){
+			initDefaultCharts,
+		},
+		msg:       "List charts",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/charts?" + url.Values{
+			"namespace":    []string{"default"},
+			"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([]releaseStub{
+			sampleReleaseStubs[0],
+			sampleReleaseStubs[2],
+		}),
+		useCookie: true,
+		validators: []func(c *chartTest, tester *tester, t *testing.T){
+			chartReleaseBodyValidator,
+		},
+	},
 }
 
 func TestHandleListCharts(t *testing.T) {

+ 6 - 6
server/api/user_handler.go

@@ -50,12 +50,12 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 		session.Values["user_id"] = user.ID
 		session.Save(r, w)
 
+		w.WriteHeader(http.StatusCreated)
+
 		if err := app.sendUserID(w, user.ID); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
-
-		w.WriteHeader(http.StatusCreated)
 	}
 }
 
@@ -69,12 +69,12 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 
 	userID, _ := session.Values["user_id"].(uint)
 
+	w.WriteHeader(http.StatusOK)
+
 	if err := app.sendUserID(w, userID); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
-
-	w.WriteHeader(http.StatusOK)
 }
 
 // HandleLoginUser checks the request header for cookie and validates the user.
@@ -119,12 +119,12 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		app.logger.Warn().Err(err)
 	}
 
+	w.WriteHeader(http.StatusOK)
+
 	if err := app.sendUserID(w, storedUser.ID); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
-
-	w.WriteHeader(http.StatusOK)
 }
 
 // HandleLogoutUser detaches the user from the session