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

Merge branch 'master' into beta.3.cicd

Jo Chuang 5 лет назад
Родитель
Сommit
28b2f23baf
37 измененных файлов с 1112 добавлено и 116 удалено
  1. 23 0
      .github/ISSUE_TEMPLATE/bug.md
  2. 17 0
      .github/ISSUE_TEMPLATE/change.md
  3. 15 0
      .github/ISSUE_TEMPLATE/feature.md
  4. 30 0
      .github/PULL_REQUEST_TEMPLATE.md
  5. 2 2
      .github/workflows/staging.yaml
  6. 9 8
      README.md
  7. 87 30
      cli/cmd/auth.go
  8. 309 0
      cli/cmd/login/server.go
  9. 1 0
      cmd/app/main.go
  10. 29 0
      cmd/migrate/keyrotate/rotate.go
  11. 1 0
      cmd/migrate/main.go
  12. 101 0
      dashboard/package-lock.json
  13. 6 0
      dashboard/package.json
  14. 1 0
      dashboard/src/App.tsx
  15. 4 2
      dashboard/src/main/home/Home.tsx
  16. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  17. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  18. 18 30
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  19. 153 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  20. 8 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  21. 5 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  22. 4 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  23. 2 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  24. 3 3
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  25. 6 7
      internal/auth/token/token.go
  26. 1 1
      internal/auth/token/token_test.go
  27. 11 11
      internal/integrations/ci/actions/actions.go
  28. 21 0
      internal/models/auth_code.go
  29. 11 0
      internal/repository/auth_code.go
  30. 37 0
      internal/repository/gorm/auth_code.go
  31. 1 0
      internal/repository/gorm/repository.go
  32. 54 0
      internal/repository/memory/auth_code.go
  33. 1 0
      internal/repository/memory/repository.go
  34. 1 0
      internal/repository/repository.go
  35. 102 0
      server/api/user_handler.go
  36. 22 12
      server/router/middleware/auth.go
  37. 14 0
      server/router/router.go

+ 23 - 0
.github/ISSUE_TEMPLATE/bug.md

@@ -0,0 +1,23 @@
+---
+name: Bug Report
+about: 🐛 Found a bug? Let us know! 
+
+---
+
+# Description
+
+<!-- Please provide a high-level description of what you were trying to accomplish and what went wrong. --> 
+
+# Location
+
+- [ ] Browser 
+- [ ] CLI 
+- [ ] API
+
+# Steps to reproduce
+
+1. 
+2. 
+3. 
+
+# Additional Details

+ 17 - 0
.github/ISSUE_TEMPLATE/change.md

@@ -0,0 +1,17 @@
+---
+name: Change
+about: 🛠️ Update functionality that already exists. 
+
+---
+
+# Location
+
+- [ ] Browser 
+- [ ] CLI 
+- [ ] API
+
+# Motivation
+
+# Requirements
+
+# Additional Details

+ 15 - 0
.github/ISSUE_TEMPLATE/feature.md

@@ -0,0 +1,15 @@
+---
+name: Feature
+about: ✨ Add new functionality to the project.
+
+---
+
+# Location
+
+- [ ] Browser 
+- [ ] CLI 
+- [ ] API
+
+# Requirements
+
+# Additional Details

+ 30 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,30 @@
+## Pull request type
+
+<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed. --> 
+
+Please check the type of change your PR introduces:
+- [ ] Bugfix
+- [ ] Feature
+- [ ] Other (please describe): 
+
+## Pull request checklist
+
+Please check if your PR fulfills the following requirements:
+- [ ] If it's a backend change, tests for the changes have been added and `go test ./...` runs successfully from the root folder. 
+- [ ] If it's a frontend change, Prettier has been run
+- [ ] Docs have been reviewed and added / updated if needed
+
+## What is the current behavior?
+<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. 
+
+Issue Number: N/A
+
+-->
+
+
+## What is the new behavior?
+<!-- Please describe the behavior or changes that are being added by this PR. -->
+
+<!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. -->
+
+## Technical Spec/Implementation Notes

+ 2 - 2
.github/workflows/staging.yaml

@@ -31,7 +31,7 @@ jobs:
         EOL
     - name: Build
       run: |
-        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter-prov:latest -f ./docker/Dockerfile
+        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./docker/Dockerfile
     - name: Push
       run: |
-        docker push gcr.io/porter-dev-273614/porter-prov:latest
+        docker push gcr.io/porter-dev-273614/porter:staging

+ 9 - 8
README.md

@@ -1,6 +1,6 @@
 # Porter 
 [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/MhYNuWwqum)
-
+[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow)](https://twitter.com/getporterdev)
 
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
 
@@ -10,7 +10,7 @@
 
 For help, questions, or if you just want a place to hang out, [join our Discord community.](https://discord.gg/MhYNuWwqum)
 
-To keep updated on our progress, please watch the repo for new releases (**Watch > Custom > Releases**) and [follow us on Twitter!](https://twitter.com/getporterdev)
+To keep updated on our progress, please watch the repo for new releases (**Watch > Custom > Releases**) and [follow us on Twitter](https://twitter.com/getporterdev)!
 
 ## Why Porter?
 ### A PaaS that grows with your applications
@@ -32,7 +32,7 @@ Porter brings the simplicity of a traditional PaaS to your own cloud provider wh
 - Marketplace for one click add-ons (e.g. MongoDB, Redis, PostgreSQL)
 - Application rollback to previously deployed versions
 - Deploy webhooks that can be triggered from CI/CD pipelines
-- Native CI/CD with buildpacks for non-Dockerized apps (Coming Soon)
+- Native CI/CD with buildpacks for non-Dockerized apps (🚧 Coming Soon)
 
 ### DevOps Mode
 For those who are familiar with Kubernetes and Helm:
@@ -47,7 +47,7 @@ For those who are familiar with Kubernetes and Helm:
 
 ## Docs
 
-Below are instructions for a quickstart. For full documentation, visit our [official Docs page.](https://docs.getporter.dev)
+Below are instructions for a quickstart. For full documentation, please visit our [official Docs.](https://docs.getporter.dev)
 
 ## CLI Installation
 ### Mac 
@@ -70,19 +70,20 @@ chmod +x ./porter
 sudo mv ./porter /usr/local/bin/porter
 ```
 
-For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/docs/cli-documentation#linux). 
+For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/docs/cli-documentation#linux).
 
 ## Getting Started
 1. Sign up and log into [Porter Dashboard](https://dashboard.getporter.dev).
 
-2. Create a Project and select a cloud provider you want to provision a Kubernetes cluster in (AWS, GCP, DO). It is also possible to [link up your own Kubernetes cluster.](https://docs.getporter.dev/docs/cli-documentation#linking-your-own-private-image-registry)
+2. Create a Project and select a cloud provider you want to provision a Kubernetes cluster in (AWS, GCP, DO). It is also possible to [link up your own Kubernetes cluster.](https://docs.getporter.dev/docs/cli-documentation#connecting-to-an-existing-cluster)
 
 3. [Put in your credentials](https://docs.getporter.dev/docs/getting-started-with-porter-on-aws), then Porter will automatically provision a cluster and an image registry in your own cloud account.
 
-4. [Build and push your Docker image](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure), or connect your git repository if your application is not dockerized.
+4. [Build and push your Docker image](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure), or connect your git repository. We are currently working on supporting the latter option for non-Dockerized applications.
 
 5. From the Templates tab on the Dashboard, select the Docker template. Click on the image you have just pushed, configure the port, then hit deploy.
 
 ## Want to Help?
-We welcome all contributions. Submit an issue or a pull request to help us improve Porter!
+We welcome all contributions. Submit an issue or a pull request to help us improve Porter! If you're interested in contributing, please [join our Discord community.](https://discord.gg/MhYNuWwqum) 
+
 ![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)

+ 87 - 30
cli/cmd/auth.go

@@ -8,6 +8,7 @@ import (
 	"github.com/fatih/color"
 
 	"github.com/porter-dev/porter/cli/cmd/api"
+	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 )
@@ -56,6 +57,7 @@ var logoutCmd = &cobra.Command{
 }
 
 var token string = ""
+var manual bool = false
 
 func init() {
 	rootCmd.AddCommand(authCmd)
@@ -77,38 +79,102 @@ func init() {
 		"",
 		"bearer token for authentication",
 	)
+
+	loginCmd.PersistentFlags().BoolVar(
+		&manual,
+		"manual",
+		false,
+		"whether to prompt for manual authentication (username/pw)",
+	)
 }
 
 func login() error {
-	var client *api.Client
+	client := api.NewClientWithToken(getHost()+"/api", getToken())
+
+	user, _ := client.AuthCheck(context.Background())
+
+	if user != nil {
+		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		return nil
+	}
+
+	// check for the --manual flag
+	if manual {
+		return loginManual()
+	}
+
+	// check for a token
+	var err error
+
+	if token == "" {
+		token, err = loginBrowser.Login(getHost())
+
+		if err != nil {
+			return err
+		}
 
-	if token != "" {
 		// set the token in config
-		err := setToken(token)
+		err = setToken(token)
 
 		if err != nil {
 			return err
 		}
 
-		client = api.NewClientWithToken(getHost()+"/api", token)
+		client := api.NewClientWithToken(getHost()+"/api", token)
 
-		user, _ := client.AuthCheck(context.Background())
+		user, err := client.AuthCheck(context.Background())
 
 		if user == nil {
 			color.Red("Invalid token.")
-			return nil
+			return err
+		}
+
+		color.New(color.FgGreen).Println("Successfully logged in!")
+
+		// get a list of projects, and set the current project
+		projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+		if err != nil {
+			return err
+		}
+
+		if len(projects) > 0 {
+			setProject(projects[0].ID)
 		}
 	} else {
-		client = api.NewClient(getHost()+"/api", "cookie.json")
-	}
+		// set the token in config
+		err = setToken(token)
 
-	user, _ := client.AuthCheck(context.Background())
+		if err != nil {
+			return err
+		}
 
-	if user != nil {
-		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
-		return nil
+		client := api.NewClientWithToken(getHost()+"/api", token)
+
+		user, err := client.AuthCheck(context.Background())
+
+		if user == nil {
+			color.Red("Invalid token.")
+			return err
+		}
+
+		color.New(color.FgGreen).Println("Successfully logged in!")
+
+		projID, err := api.GetProjectIDFromToken(token)
+
+		if err != nil {
+			return err
+		}
+
+		setProject(projID)
 	}
 
+	return nil
+}
+
+func loginManual() error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+
 	var username, pw string
 
 	fmt.Println("Please log in with an email and password:")
@@ -136,26 +202,15 @@ func login() error {
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
-	// if the login was token-based, decode the claims to get the token
-	if token != "" {
-		projID, err := api.GetProjectIDFromToken(token)
-
-		if err != nil {
-			return err
-		}
+	// get a list of projects, and set the current project
+	projects, err := client.ListUserProjects(context.Background(), _user.ID)
 
-		setProject(projID)
-	} else {
-		// get a list of projects, and set the current project
-		projects, err := client.ListUserProjects(context.Background(), _user.ID)
-
-		if err != nil {
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
-		if len(projects) > 0 {
-			setProject(projects[0].ID)
-		}
+	if len(projects) > 0 {
+		setProject(projects[0].ID)
 	}
 
 	return nil
@@ -199,6 +254,8 @@ func logout(user *api.AuthCheckResponse, client *api.Client, args []string) erro
 		return err
 	}
 
+	setToken("")
+
 	color.Green("Successfully logged out")
 
 	return nil

+ 309 - 0
cli/cmd/login/server.go

@@ -0,0 +1,309 @@
+package login
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func redirect(
+	codechan chan string,
+) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		fmt.Fprint(w, successScreen)
+
+		queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+
+		codechan <- queryParams["code"][0]
+	}
+}
+
+func Login(
+	host string,
+) (string, error) {
+	listener, err := net.Listen("tcp", ":0")
+
+	if err != nil {
+		panic(err)
+	}
+
+	port := listener.Addr().(*net.TCPAddr).Port
+
+	errorchan := make(chan error)
+	codechan := make(chan string)
+
+	go func() {
+		http.HandleFunc("/", redirect(
+			codechan,
+		))
+
+		err := http.Serve(listener, nil)
+		errorchan <- err
+	}()
+
+	// open browser for host login
+	redirectHost := fmt.Sprintf("http://localhost:%d", port)
+	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
+
+	err = utils.OpenBrowser(loginURL)
+
+	if err != nil {
+		return "", fmt.Errorf("Could not open browser: %v", err)
+	}
+
+	for {
+		select {
+		case err = <-errorchan:
+			return "", err
+		case code := <-codechan:
+			return ExchangeToken(host, code)
+		}
+	}
+}
+
+type ExchangeResponse struct {
+	Token string `json:"token"`
+}
+
+func ExchangeToken(host, code string) (string, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/api/cli/login/exchange", host),
+		strings.NewReader(fmt.Sprintf(`{"authorization_code": "%s"}`, code)),
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	// create a request with the authorization code
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+
+	client := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	res, err := client.Do(req)
+
+	if err != nil {
+		return "", err
+	}
+
+	defer res.Body.Close()
+
+	resp := &ExchangeResponse{}
+
+	if err = json.NewDecoder(res.Body).Decode(resp); err != nil {
+		return "", err
+	}
+
+	return resp.Token, nil
+}
+
+const successScreen = `
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset='UTF-8'>
+    <title>Porter | Login</title>
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
+    <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@600&display=swap" rel="stylesheet">
+    <style>
+      #logo {
+        width: 80px;
+        margin-top: -30px;
+        margin-bottom: 40px;
+      }
+
+      #success {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 18px;
+        color: #CBCBD8;
+        margin-bottom: 17px;
+      }
+
+      #subtitle {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 14px;
+        color: #CBCBD8;
+      }
+
+      body{
+        margin: 0;
+        padding: 0;
+        width: 100%;
+        height: 100vh;
+        background: #f1f3f5;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+      }
+
+      #text {
+        display: flex;
+        height: 100vh;
+        align-items: center;
+        justify-content: center;
+        text-align: center;
+      }
+
+      h2{
+        color: #fff;
+        font-size: 47px;
+        line-height: 40px;
+      }
+
+      #container {
+        left: 0px;
+        top: -100px;
+        height: calc(100vh + 100px);
+        overflow: hidden;
+        position: relative;
+      }
+
+      #animate{
+        margin: 0 auto;
+        width: 20px;
+        overflow: visible;
+        position: relative;
+      }
+
+      #all{
+        overflow: hidden;
+        height: 100vh;
+        width: 100%;
+        position: fixed;
+      }
+
+      #footer{
+        color: #808080;
+        text-decoration: none;
+        position: fixed;
+        width: 752px;
+        bottom: 20px;
+        align-content: center;
+        float: none;
+        margin-left: calc(50% - 376px);
+      }
+
+      a, p{
+        text-decoration: none;
+        color: #808080;
+        letter-spacing: 6px;
+        transition: all 0.5s ease-in-out;
+        width: auto;
+        float: left;
+        margin: 0;
+        margin-right: 9px;
+      }
+
+      a:hover{
+        color: #fff;
+        letter-spacing: 2px;
+        transition: all 0.5s ease-in-out;
+      }
+    </style>
+  </head>
+  <body>
+    <link href="https://fonts.googleapis.com/css?family=Oswald:600,700" rel="stylesheet"> 
+    <div id="all">
+    <div id="container">
+      <div id="animate">
+      </div>
+    </div>
+    </div>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <img id='logo' src='https://i.ibb.co/y64zfm5/porter.png'>
+    <div id='success'>Authentication successful!</div>
+    <div id='subtitle'>You can now close this window.</div>
+    <script>
+/*      setTimeout(function () {
+        window.close();
+      }, 1000)
+      */
+
+      var container = document.getElementById('animate');
+      var emoji = ['🎉'];
+      var circles = [];
+
+      for (var i = 0; i < 15; i++) {
+        addCircle(i * 550, [10 + 0, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 0, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 200, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 200, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 400, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 400, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 600, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 600, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+      }
+
+
+
+      function addCircle(delay, range, color) {
+        setTimeout(function() {
+          var c = new Circle(range[0] + Math.random() * range[1], 80 + Math.random() * 4, color, {
+            x: -0.15 + Math.random() * 0.3,
+            y: 1 + Math.random() * 1
+          }, range);
+          circles.push(c);
+        }, delay);
+      }
+
+      function Circle(x, y, c, v, range) {
+        var _this = this;
+        this.x = x;
+        this.y = y;
+        this.color = c;
+        this.v = v;
+        this.range = range;
+        this.element = document.createElement('span');
+        /*this.element.style.display = 'block';*/
+        this.element.style.opacity = 0;
+        this.element.style.position = 'absolute';
+        this.element.style.fontSize = '26px';
+        this.element.style.color = 'hsl('+(Math.random()*360|0)+',80%,50%)';
+        this.element.innerHTML = c;
+        container.appendChild(this.element);
+
+        this.update = function() {
+          if (_this.y > 800) {
+            _this.y = 80 + Math.random() * 4;
+            _this.x = _this.range[0] + Math.random() * _this.range[1];
+          }
+          _this.y += _this.v.y;
+          _this.x += _this.v.x;
+          this.element.style.opacity = 1;
+          this.element.style.transform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+          this.element.style.webkitTransform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+          this.element.style.mozTransform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+        };
+      }
+
+      function animate() {
+        for (var i in circles) {
+          circles[i].update();
+        }
+        requestAnimationFrame(animate);
+      }
+      
+      if (Math.random() < 0.001) {
+        animate();
+      }
+
+      
+    </script>
+  </body>
+</html>
+`

+ 1 - 0
cmd/app/main.go

@@ -59,6 +59,7 @@ func main() {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.AuthCode{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 29 - 0
cmd/migrate/keyrotate/rotate.go

@@ -14,69 +14,98 @@ import (
 const stepSize = 100
 
 func Rotate(db *_gorm.DB, oldKey, newKey *[32]byte) error {
+	oldKeyBytes := make([]byte, 32)
+	newKeyBytes := make([]byte, 32)
+
+	copy(oldKeyBytes[:], oldKey[:])
+	copy(newKeyBytes[:], newKey[:])
+
+	fmt.Printf("beginning key rotation from %s to %s\n", string(oldKeyBytes), string(newKeyBytes))
+
 	err := rotateClusterModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on cluster rotation: %v\n", err)
 		return err
 	}
 
 	err = rotateClusterCandidateModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on cc rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateRegistryModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on registry rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateHelmRepoModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on hr rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateInfraModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on infra rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateKubeIntegrationModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on ki rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateBasicIntegrationModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on basic rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateOIDCIntegrationModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on oidc rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateOAuthIntegrationModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on oauth rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateGCPIntegrationModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on gcp rotation: %v\n", err)
+
 		return err
 	}
 
 	err = rotateAWSIntegrationModel(db, oldKey, newKey)
 
 	if err != nil {
+		fmt.Printf("failed on aws rotation: %v\n", err)
+
 		return err
 	}
 

+ 1 - 0
cmd/migrate/main.go

@@ -44,6 +44,7 @@ func main() {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.AuthCode{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 101 - 0
dashboard/package-lock.json

@@ -10,6 +10,8 @@
       "dependencies": {
         "@fullstory/browser": "^1.4.5",
         "@material-ui/core": "^4.11.3",
+        "@types/d3-array": "^2.9.0",
+        "@types/d3-time-format": "^3.0.0",
         "@types/js-yaml": "^3.12.5",
         "@types/lodash": "^4.14.165",
         "@types/markdown-to-jsx": "^6.11.3",
@@ -20,11 +22,15 @@
         "@visx/event": "^1.3.0",
         "@visx/gradient": "^1.0.0",
         "@visx/grid": "^1.4.0",
+        "@visx/mock-data": "^1.0.0",
+        "@visx/responsive": "^1.3.0",
         "@visx/scale": "^1.4.0",
         "@visx/shape": "^1.4.0",
         "@visx/tooltip": "^1.3.0",
         "ace-builds": "^1.4.12",
         "axios": "^0.20.0",
+        "d3-array": "^2.11.0",
+        "d3-time-format": "^3.0.0",
         "dotenv": "^8.2.0",
         "ini": ">=1.3.6",
         "js-base64": "^3.6.0",
@@ -548,6 +554,11 @@
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
     },
+    "node_modules/@types/d3-array": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.9.0.tgz",
+      "integrity": "sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ=="
+    },
     "node_modules/@types/d3-color": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
@@ -566,6 +577,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
+    "node_modules/@types/d3-random": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
+      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
+    },
     "node_modules/@types/d3-scale": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
@@ -587,6 +603,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
     },
+    "node_modules/@types/d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew=="
+    },
     "node_modules/@types/glob": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@@ -1085,11 +1106,35 @@
         "prop-types": "^15.6.2"
       }
     },
+    "node_modules/@visx/mock-data": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
+      "dependencies": {
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
+      }
+    },
     "node_modules/@visx/point": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
     },
+    "node_modules/@visx/responsive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.3.0.tgz",
+      "integrity": "sha512-RMfpjdnHKyhB/bb2i6x/vfQeYzfz+pJc3VUK+dP88lXXTkqv1O/NYIkXk2sWk6QhDw5muChHFmnZ1L8TnIOMXg==",
+      "dependencies": {
+        "@types/lodash": "^4.14.146",
+        "@types/react": "*",
+        "lodash": "^4.17.10",
+        "prop-types": "^15.6.1",
+        "resize-observer-polyfill": "1.5.1"
+      },
+      "peerDependencies": {
+        "react": "^15.0.0-0 || ^16.0.0-0"
+      }
+    },
     "node_modules/@visx/scale": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
@@ -2721,6 +2766,11 @@
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
+    "node_modules/d3-random": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
+      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
+    },
     "node_modules/d3-scale": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
@@ -7210,6 +7260,11 @@
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "dev": true
     },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "node_modules/resolve-cwd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",
@@ -10630,6 +10685,11 @@
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
     },
+    "@types/d3-array": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.9.0.tgz",
+      "integrity": "sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ=="
+    },
     "@types/d3-color": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
@@ -10648,6 +10708,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
+    "@types/d3-random": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
+      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
+    },
     "@types/d3-scale": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
@@ -10669,6 +10734,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
     },
+    "@types/d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew=="
+    },
     "@types/glob": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@@ -11144,11 +11214,32 @@
         "prop-types": "^15.6.2"
       }
     },
+    "@visx/mock-data": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
+      "requires": {
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
+      }
+    },
     "@visx/point": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
     },
+    "@visx/responsive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.3.0.tgz",
+      "integrity": "sha512-RMfpjdnHKyhB/bb2i6x/vfQeYzfz+pJc3VUK+dP88lXXTkqv1O/NYIkXk2sWk6QhDw5muChHFmnZ1L8TnIOMXg==",
+      "requires": {
+        "@types/lodash": "^4.14.146",
+        "@types/react": "*",
+        "lodash": "^4.17.10",
+        "prop-types": "^15.6.1",
+        "resize-observer-polyfill": "1.5.1"
+      }
+    },
     "@visx/scale": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
@@ -12623,6 +12714,11 @@
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
+    "d3-random": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
+      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
+    },
     "d3-scale": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
@@ -16461,6 +16557,11 @@
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "dev": true
     },
+    "resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "resolve-cwd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",

+ 6 - 0
dashboard/package.json

@@ -5,6 +5,8 @@
   "dependencies": {
     "@fullstory/browser": "^1.4.5",
     "@material-ui/core": "^4.11.3",
+    "@types/d3-array": "^2.9.0",
+    "@types/d3-time-format": "^3.0.0",
     "@types/js-yaml": "^3.12.5",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
@@ -15,11 +17,15 @@
     "@visx/event": "^1.3.0",
     "@visx/gradient": "^1.0.0",
     "@visx/grid": "^1.4.0",
+    "@visx/mock-data": "^1.0.0",
+    "@visx/responsive": "^1.3.0",
     "@visx/scale": "^1.4.0",
     "@visx/shape": "^1.4.0",
     "@visx/tooltip": "^1.3.0",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
+    "d3-array": "^2.11.0",
+    "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",

+ 1 - 0
dashboard/src/App.tsx

@@ -7,6 +7,7 @@ type PropsType = {};
 
 type StateType = {};
 
+
 export default class App extends Component<PropsType, StateType> {
   render() {
     return (

+ 4 - 2
dashboard/src/main/home/Home.tsx

@@ -127,7 +127,8 @@ class Home extends Component<PropsType, StateType> {
 
   provisionDOCR = (integrationId: number, tier: string, callback?: any) => {
     console.log("Provisioning DOCR...");
-    return api.createDOCR(
+    return api
+    .createDOCR(
       "<token>",
       {
         do_integration_id: integrationId,
@@ -137,7 +138,8 @@ class Home extends Component<PropsType, StateType> {
       {
         project_id: this.props.currentProject.id,
       }
-    );
+    )
+    .then(() => callback()); 
   };
 
   provisionDOKS = (integrationId: number, region: string) => {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -361,7 +361,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Append universal tabs
     tabOptions.push(
       { label: "Status", value: "status" },
-      // { label: "Metrics", value: "metrics" },
+      //{ label: "Metrics", value: "metrics" },
       { label: "Chart Overview", value: "graph" }
     );
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -219,7 +219,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
             ? `Current Revision`
             : `Previewing Revision (Not Deployed)`}{" "}
           - <Revision>No. {this.props.chart.version}</Revision>
-          <i className="material-icons">expand_more</i>
+          <i className="material-icons">arrow_drop_down</i>
         </RevisionHeader>
 
         <RevisionList>{this.renderExpanded()}</RevisionList>

+ 18 - 30
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,4 +1,3 @@
-/*
 import React, { useMemo, useCallback } from 'react';
 import { AreaClosed, Line, Bar } from '@visx/shape';
 import appleStock, { AppleStock } from '@visx/mock-data/lib/mocks/appleStock';
@@ -12,13 +11,18 @@ import { LinearGradient } from '@visx/gradient';
 import { max, extent, bisector } from 'd3-array';
 import { timeFormat } from 'd3-time-format';
 
+/*
+export const accentColor = '#f5cb42';
+export const accentColorDark = '#949eff';
+*/
+
 type TooltipData = AppleStock;
 
 const stock = appleStock.slice(800);
-export const background = '#3b6978';
-export const background2 = '#204051';
-export const accentColor = '#edffea';
-export const accentColorDark = '#75daad';
+export const background = '#3b697800';
+export const background2 = '#20405100';
+export const accentColor = '#949eff';
+export const accentColorDark = '#949eff';
 const tooltipStyles = {
   ...defaultStyles,
   background,
@@ -55,7 +59,7 @@ export default withTooltip<AreaProps, TooltipData>(
 
     // bounds
     const innerWidth = width - margin.left - margin.right;
-    const innerHeight = height - margin.top - margin.bottom;
+    const innerHeight = height - margin.top - margin.bottom - 20;
 
     // scales
     const dateScale = useMemo(
@@ -109,25 +113,7 @@ export default withTooltip<AreaProps, TooltipData>(
             rx={14}
           />
           <LinearGradient id="area-background-gradient" from={background} to={background2} />
-          <LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0.1} />
-          <GridRows
-            left={margin.left}
-            scale={stockValueScale}
-            width={innerWidth}
-            strokeDasharray="1,3"
-            stroke={accentColor}
-            strokeOpacity={0}
-            pointerEvents="none"
-          />
-          <GridColumns
-            top={margin.top}
-            scale={dateScale}
-            height={innerHeight}
-            strokeDasharray="1,3"
-            stroke={accentColor}
-            strokeOpacity={0.2}
-            pointerEvents="none"
-          />
+          <LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0} />
           <AreaClosed<AppleStock>
             data={stock}
             x={d => dateScale(getDate(d)) ?? 0}
@@ -194,13 +180,16 @@ export default withTooltip<AreaProps, TooltipData>(
               {`$${getStockValue(tooltipData)}`}
             </TooltipWithBounds>
             <Tooltip
-              top={innerHeight + margin.top - 14}
+              top={-10}
               left={tooltipLeft}
               style={{
                 ...defaultStyles,
-                minWidth: 72,
+                background: '#26272f',
+                color: '#aaaabb',
+                width: 100,
+                paddingTop: 35,
                 textAlign: 'center',
-                transform: 'translateX(-50%)',
+                transform: 'translateX(-60px)',
               }}
             >
               {formatDate(getDate(tooltipData))}
@@ -210,5 +199,4 @@ export default withTooltip<AreaProps, TooltipData>(
       </div>
     );
   },
-);
-*/
+);

+ 153 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -1,25 +1,95 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import ParentSize from '@visx/responsive/lib/components/ParentSize';
 
 import { Context } from "shared/Context";
 import { ChartType } from "shared/types";
 
-import Loading from "components/Loading";
+import TabSelector from "components/TabSelector";
+import AreaChart from "./AreaChart";
 
 type PropsType = {
   currentChart: ChartType;
 };
 
 type StateType = {
+  selectedRange: string,
+  selectedMetricLabel: string,
+  dropdownExpanded: boolean,
 };
 
 export default class ListSection extends Component<PropsType, StateType> {
   state = {
+    selectedRange: '1H',
+    selectedMetricLabel: 'CPU Utilization',
+    dropdownExpanded: false,
   }
 
+  renderDropdown = () => {
+    if (this.state.dropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => this.setState({ dropdownExpanded: false })} />
+          <Dropdown
+            dropdownWidth='200px'
+            dropdownMaxHeight='200px'
+            onClick={() => this.setState({ dropdownExpanded: false })}
+          >
+            {this.renderOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderOptionList = () => {
+    let metricOptions = [
+      { value: 'cpu', label: 'CPU Utilization' },
+      { value: 'ram', label: 'RAM Utilization' },
+    ];
+    return metricOptions.map(
+      (option: { value: string; label: string }, i: number) => {
+        return (
+          <Option
+            key={i}
+            selected={option.label === this.state.selectedMetricLabel}
+            onClick={() => this.setState({ selectedMetricLabel: option.label })}
+            lastItem={i === metricOptions.length - 1}
+          >
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
   render() {
     return (
       <StyledMetricsSection>
+        <ParentSize>
+          {({ width, height }) => <AreaChart width={width} height={height} />}
+        </ParentSize>
+        <MetricSelector 
+          onClick={() => this.setState({ dropdownExpanded: !this.state.dropdownExpanded })}
+        >
+          {this.state.selectedMetricLabel}
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderDropdown()}
+        </MetricSelector>
+        <RangeWrapper>
+          <TabSelector
+            options={[
+              { value: '1H', label: '1H' }, 
+              { value: '1D', label: '1D' },
+              { value: '1M', label: '1M' }, 
+              { value: '3M', label: '3M' },
+              { value: '1Y', label: '1Y' }, 
+              { value: 'ALL', label: 'ALL' },
+            ]}
+            currentTab={this.state.selectedRange}
+            setCurrentTab={(x: string) => this.setState({ selectedRange: x })}
+          />
+        </RangeWrapper>
       </StyledMetricsSection>
     );
   }
@@ -27,6 +97,88 @@ export default class ListSection extends Component<PropsType, StateType> {
 
 ListSection.contextType = Context;
 
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  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; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 4px 8px 0px #00000088;
+`;
+
+const RangeWrapper = styled.div`
+  position: absolute;
+  bottom: 10px;
+  font-weight: bold;
+  left: 0;
+  width: 100%;
+`;
+
+const MetricSelector = styled.div`
+  font-size: 16px;
+  font-weight: 500;
+  color: #ffffff;
+  position: absolute;
+  top: 0;
+  left: 5px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
 const StyledMetricsSection = styled.div`
   width: 100%;
   height: 100%;

+ 8 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -11,6 +11,7 @@ type PropsType = {
   selectPod: Function;
   isLast?: boolean;
   isFirst?: boolean;
+  setPodError: (x: string) => void;
 };
 
 type StateType = {
@@ -103,16 +104,18 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   };
 
   getPodStatus = (status: any) => {
-    if (status?.phase == "Pending" && status?.containerStatuses !== undefined) {
+    if (status?.phase === "Pending" && status?.containerStatuses !== undefined) {
       return status.containerStatuses[0].state.waiting.reason;
       // return 'waiting'
+    } else if (status?.phase === "Pending") {
+      return "Pending"
     }
 
-    if (status?.phase == "Failed") {
+    if (status?.phase === "Failed") {
       return "failed";
     }
 
-    if (status?.phase == "Running") {
+    if (status?.phase === "Running") {
       let collatedStatus = "running";
 
       status?.containerStatuses?.forEach((s: any) => {
@@ -151,6 +154,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
               key={pod.metadata?.name}
               selected={selectedPod?.metadata?.name === pod?.metadata?.name}
               onClick={() => {
+                this.props.setPodError("");
+                (status === "failed" && pod.status?.message) && this.props.setPodError(pod.status?.message);
                 selectPod(pod);
               }}
             >

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -4,6 +4,7 @@ import { Context } from "shared/Context";
 
 type PropsType = {
   selectedPod: any;
+  podError: string;
 };
 
 type StateType = {
@@ -40,7 +41,7 @@ export default class Logs extends Component<PropsType, StateType> {
       return <Message>Please select a pod to view its logs.</Message>;
     }
     if (this.state.logs.length == 0) {
-      return <Message>No logs to display from this pod.</Message>;
+      return <Message>{this.props.podError || "No logs to display from this pod."}</Message>;
     }
     return this.state.logs.map((log, i) => {
       return <Log key={i}>{log}</Log>;
@@ -206,9 +207,11 @@ const LogStream = styled.div`
 const Message = styled.div`
   display: flex;
   height: 100%;
-  width: 100%;
+  width: calc(100% - 150px);
   align-items: center;
   justify-content: center;
+  margin-left: 75px;
+  text-align: center;
   color: #ffffff44;
   font-size: 13px;
 `;

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -20,6 +20,7 @@ type StateType = {
   selectedPod: any;
   controllers: any[];
   loading: boolean;
+  podError: string;
 };
 
 export default class StatusSection extends Component<PropsType, StateType> {
@@ -29,11 +30,13 @@ export default class StatusSection extends Component<PropsType, StateType> {
     selectedPod: {} as any,
     controllers: [] as any[],
     loading: true,
+    podError: "",
   };
 
   renderLogs = () => {
     return (
       <Logs
+        podError={this.state.podError}
         key={this.state.selectedPod?.metadata?.name}
         selectedPod={this.state.selectedPod}
       />
@@ -56,6 +59,7 @@ export default class StatusSection extends Component<PropsType, StateType> {
           controller={c}
           isLast={i === this.state.controllers.length - 1}
           isFirst={i === 0}
+          setPodError={(x: string) => this.setState({ podError: x })}
         />
       );
     });

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

@@ -441,7 +441,7 @@ const StyledSidebar = styled.section`
   animation-fill-mode: forwards;
   @keyframes showSidebar {
     from {
-      margin-left: -220px;
+      margin-left: -200px;
     }
     to {
       margin-left: 0px;
@@ -452,7 +452,7 @@ const StyledSidebar = styled.section`
       margin-left: 0px;
     }
     to {
-      margin-left: -220px;
+      margin-left: -200px;
     }
   }
 `;

+ 3 - 3
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -400,10 +400,10 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           <>
             <Subtitle>
               Select the container image you would like to connect to this
-              template or
-              <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
+              template
+              {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
                 link a git repository
-              </Highlight>
+              </Highlight> */}
               .<Required>*</Required>
             </Subtitle>
             <DarkMatter />

+ 6 - 7
internal/auth/token/token.go

@@ -27,19 +27,18 @@ type Token struct {
 	IAt       *time.Time `json:"iat"`
 }
 
-func GetTokenForUser(userID, projID uint) (*Token, error) {
-	if userID == 0 || projID == 0 {
+func GetTokenForUser(userID uint) (*Token, error) {
+	if userID == 0 {
 		return nil, fmt.Errorf("id cannot be 0")
 	}
 
 	iat := time.Now()
 
 	return &Token{
-		SubKind:   User,
-		Sub:       fmt.Sprintf("%d", userID),
-		ProjectID: projID,
-		IBy:       userID,
-		IAt:       &iat,
+		SubKind: User,
+		Sub:     fmt.Sprintf("%d", userID),
+		IBy:     userID,
+		IAt:     &iat,
 	}, nil
 }
 

+ 1 - 1
internal/auth/token/token_test.go

@@ -13,7 +13,7 @@ func TestGetAndEncodeTokenForUser(t *testing.T) {
 		TokenSecret: "fakesecret",
 	}
 
-	tok, err := token.GetTokenForUser(1, 1)
+	tok, err := token.GetTokenForUser(1)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)

+ 11 - 11
internal/integrations/ci/actions/actions.go

@@ -79,31 +79,31 @@ func (g *GithubActions) Setup() (string, error) {
 }
 
 type GithubActionYAMLStep struct {
-	Name string `yaml:"name"`
-	ID   string `yaml:"id"`
-	Uses string `yaml:"uses"`
-	Run  string `yaml:"run"`
+	Name string `yaml:"name,omitempty"`
+	ID   string `yaml:"id,omitempty"`
+	Uses string `yaml:"uses,omitempty"`
+	Run  string `yaml:"run,omitempty"`
 }
 
 type GithubActionYAMLOnPushBranches struct {
-	Branches []string `yaml:"branches"`
+	Branches []string `yaml:"branches,omitempty"`
 }
 
 type GithubActionYAMLOnPush struct {
-	Push GithubActionYAMLOnPushBranches `yaml:"push"`
+	Push GithubActionYAMLOnPushBranches `yaml:"push,omitempty"`
 }
 
 type GithubActionYAMLJob struct {
-	RunsOn string                 `yaml:"runs-on"`
-	Steps  []GithubActionYAMLStep `yaml:"steps"`
+	RunsOn string                 `yaml:"runs-on,omitempty"`
+	Steps  []GithubActionYAMLStep `yaml:"steps,omitempty"`
 }
 
 type GithubActionYAML struct {
-	On GithubActionYAMLOnPush `yaml:"on"`
+	On GithubActionYAMLOnPush `yaml:"on,omitempty"`
 
-	Name string `yaml:"name"`
+	Name string `yaml:"name,omitempty"`
 
-	Jobs map[string]GithubActionYAMLJob `yaml:"jobs"`
+	Jobs map[string]GithubActionYAMLJob `yaml:"jobs,omitempty"`
 }
 
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {

+ 21 - 0
internal/models/auth_code.go

@@ -0,0 +1,21 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// AuthCode type that extends gorm.Model
+type AuthCode struct {
+	gorm.Model
+
+	Token             string `gorm:"unique"`
+	AuthorizationCode string `gorm:"unique"`
+	Expiry            *time.Time
+}
+
+func (a *AuthCode) IsExpired() bool {
+	timeLeft := a.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}

+ 11 - 0
internal/repository/auth_code.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AuthCodeRepository represents the set of queries on the AuthCode model
+type AuthCodeRepository interface {
+	CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error)
+	ReadAuthCode(code string) (*models.AuthCode, error)
+}

+ 37 - 0
internal/repository/gorm/auth_code.go

@@ -0,0 +1,37 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AuthCodeRepository uses gorm.DB for querying the database
+type AuthCodeRepository struct {
+	db *gorm.DB
+}
+
+// NewAuthCodeRepository returns a AuthCodeRepository which uses
+// gorm.DB for querying the database
+func NewAuthCodeRepository(db *gorm.DB) repository.AuthCodeRepository {
+	return &AuthCodeRepository{db}
+}
+
+// CreateAuthCode creates a new auth code
+func (repo *AuthCodeRepository) CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+// ReadAuthCode gets an invite specified by a unique token
+func (repo *AuthCodeRepository) ReadAuthCode(code string) (*models.AuthCode, error) {
+	a := &models.AuthCode{}
+
+	if err := repo.db.Where("authorization_code = ?", code).First(&a).Error; err != nil {
+		return nil, err
+	}
+
+	return a, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -20,6 +20,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Infra:            NewInfraRepository(db, key),
 		GitActionConfig:  NewGitActionConfigRepository(db),
 		Invite:           NewInviteRepository(db),
+		AuthCode:         NewAuthCodeRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 54 - 0
internal/repository/memory/auth_code.go

@@ -0,0 +1,54 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AuthCodeRepository uses gorm.DB for querying the database
+type AuthCodeRepository struct {
+	canQuery  bool
+	authCodes []*models.AuthCode
+}
+
+// NewAuthCodeRepository returns a AuthCodeRepository which uses
+// gorm.DB for querying the database
+func NewAuthCodeRepository(canQuery bool) repository.AuthCodeRepository {
+	return &AuthCodeRepository{canQuery, []*models.AuthCode{}}
+}
+
+// CreateAuthCode creates a new invite
+func (repo *AuthCodeRepository) CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.authCodes = append(repo.authCodes, a)
+	a.ID = uint(len(repo.authCodes))
+
+	return a, nil
+}
+
+// ReadAuthCode gets an auth code object specified by the unique code
+func (repo *AuthCodeRepository) ReadAuthCode(code string) (*models.AuthCode, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	var res *models.AuthCode
+
+	for _, a := range repo.authCodes {
+		if code == a.AuthorizationCode {
+			res = a
+		}
+	}
+
+	if res == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return res, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -16,6 +16,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
 		Invite:           NewInviteRepository(canQuery),
+		AuthCode:         NewAuthCodeRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 1 - 0
internal/repository/repository.go

@@ -13,6 +13,7 @@ type Repository struct {
 	Infra            InfraRepository
 	GitActionConfig  GitActionConfigRepository
 	Invite           InviteRepository
+	AuthCode         AuthCodeRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 102 - 0
server/api/user_handler.go

@@ -3,15 +3,20 @@ package api
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
+	"math/rand"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strings"
+	"time"
 
 	"golang.org/x/crypto/bcrypt"
 
 	"gorm.io/gorm"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
@@ -105,6 +110,103 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCLILoginUser verifies that a user is logged in, and generates an access
+// token for usage from the CLI
+func (app *App) HandleCLILoginUser(w http.ResponseWriter, r *http.Request) {
+	queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+
+	redirect := queryParams["redirect"][0]
+
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	// generate the token
+	jwt, err := token.GetTokenForUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(&token.TokenGeneratorConf{
+		TokenSecret: app.ServerConf.TokenGeneratorSecret,
+	})
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// generate 64 characters long authorization code
+	const letters = "abcdefghijklmnopqrstuvwxyz123456789"
+	code := make([]byte, 64)
+
+	for i := range code {
+		code[i] = letters[rand.Intn(len(letters))]
+	}
+
+	expiry := time.Now().Add(30 * time.Second)
+
+	// create auth code object and send back authorization code
+	authCode := &models.AuthCode{
+		Token:             encoded,
+		AuthorizationCode: string(code),
+		Expiry:            &expiry,
+	}
+
+	authCode, err = app.Repo.AuthCode.CreateAuthCode(authCode)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	http.Redirect(w, r, fmt.Sprintf("%s/?code=%s", redirect, url.QueryEscape(authCode.AuthorizationCode)), 302)
+}
+
+type ExchangeRequest struct {
+	AuthorizationCode string `json:"authorization_code"`
+}
+
+type ExchangeResponse struct {
+	Token string `json:"token"`
+}
+
+// HandleCLILoginExchangeToken exchanges an authorization code for a token
+func (app *App) HandleCLILoginExchangeToken(w http.ResponseWriter, r *http.Request) {
+	// read the request body and look up the authorization token
+	req := &ExchangeRequest{}
+
+	if err := json.NewDecoder(r.Body).Decode(req); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	authCode, err := app.Repo.AuthCode.ReadAuthCode(req.AuthorizationCode)
+
+	if err != nil || authCode.IsExpired() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	res := &ExchangeResponse{
+		Token: authCode.Token,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+}
+
 // HandleLoginUser checks the request header for cookie and validates the user.
 func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)

+ 22 - 12
server/router/middleware/auth.go

@@ -64,7 +64,12 @@ func (auth *Auth) BasicAuthenticateWithRedirect(next http.Handler) http.Handler
 			}
 
 			// need state parameter to validate when redirected
-			session.Values["redirect"] = r.URL.Path
+			if r.URL.RawQuery == "" {
+				session.Values["redirect"] = r.URL.Path
+			} else {
+				session.Values["redirect"] = r.URL.Path + "?" + r.URL.RawQuery
+			}
+
 			session.Save(r, w)
 
 			http.Redirect(w, r, "/dashboard", 302)
@@ -182,23 +187,28 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 		// first check for token
 		tok := auth.getTokenFromRequest(r)
 
+		var userID uint
+
 		if tok != nil && tok.ProjectID == uint(projID) {
 			next.ServeHTTP(w, r)
 			return
-		}
-
-		session, err := auth.store.Get(r, auth.cookieName)
+		} else if tok != nil {
+			userID = tok.IBy
+		} else {
+			session, err := auth.store.Get(r, auth.cookieName)
 
-		if err != nil {
-			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-			return
-		}
+			if err != nil {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
 
-		userID, ok := session.Values["user_id"].(uint)
+			sessionUserID, ok := session.Values["user_id"]
+			userID = sessionUserID.(uint)
 
-		if !ok {
-			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-			return
+			if !ok {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
 		}
 
 		// get the project

+ 14 - 0
server/router/router.go

@@ -62,6 +62,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/cli/login",
+			auth.BasicAuthenticateWithRedirect(
+				requestlog.NewHandler(a.HandleCLILoginUser, l),
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/cli/login/exchange",
+			requestlog.NewHandler(a.HandleCLILoginExchangeToken, l),
+		)
+
 		r.Method(
 			"POST",
 			"/login",