Procházet zdrojové kódy

Merge branch 'beta.2.log-streaming'

sunguroku před 5 roky
rodič
revize
a61acfd036

+ 1 - 1
dashboard/src/main/home/dashboard/NamespaceSelector.tsx

@@ -25,7 +25,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
   }
   }
 
 
   updateOptions = () => {
   updateOptions = () => {
-    let { currentCluster, setCurrentError } = this.context;
+    let { currentCluster } = this.context;
 
 
     api.getNamespaces('<token>', { context: currentCluster }, {}, (err: any, res: any) => {
     api.getNamespaces('<token>', { context: currentCluster }, {}, (err: any, res: any) => {
       if (err) {
       if (err) {

+ 8 - 4
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -11,7 +11,7 @@ import RevisionSection from './RevisionSection';
 import ValuesYaml from './ValuesYaml';
 import ValuesYaml from './ValuesYaml';
 import GraphSection from './GraphSection';
 import GraphSection from './GraphSection';
 import ListSection from './ListSection';
 import ListSection from './ListSection';
-import LogSection from './LogSection';
+import LogSection from './log/LogSection';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import SettingsSection from './SettingsSection';
 import SettingsSection from './SettingsSection';
 
 
@@ -26,6 +26,7 @@ type StateType = {
   showRevisions: boolean,
   showRevisions: boolean,
   currentTab: string,
   currentTab: string,
   components: ResourceType[],
   components: ResourceType[],
+  podSelectors: string[]
   revisionPreview: ChartType | null,
   revisionPreview: ChartType | null,
   devOpsMode: boolean
   devOpsMode: boolean
 };
 };
@@ -145,6 +146,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     showRevisions: false,
     showRevisions: false,
     currentTab: 'values-form',
     currentTab: 'values-form',
     components: [] as ResourceType[],
     components: [] as ResourceType[],
+    podSelectors: [] as string[],
     revisionPreview: null as (ChartType | null),
     revisionPreview: null as (ChartType | null),
     devOpsMode: false
     devOpsMode: false
   }
   }
@@ -161,7 +163,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       if (err) {
       if (err) {
         console.log(err)
         console.log(err)
       } else {
       } else {
-        this.setState({ components: res.data });
+        console.log(res.data)
+        this.setState({ components: res.data.Objects, podSelectors: res.data.PodSelectors });
       }
       }
     });
     });
   }
   }
@@ -189,7 +192,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         if (err) {
         if (err) {
           console.log(err)
           console.log(err)
         } else {
         } else {
-          this.setState({ components: res.data });
+          this.setState({ components: res.data, podSelectors: res.data.PodSelectors });
         }
         }
       });
       });
 
 
@@ -276,7 +279,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       );
       );
     } else if (this.state.currentTab === 'logs') {
     } else if (this.state.currentTab === 'logs') {
       return (
       return (
-        <LogSection
+        <LogSection 
+          selectors={this.state.podSelectors}
         />
         />
       );
       );
     } else if (this.state.currentTab === 'values-form') {
     } else if (this.state.currentTab === 'values-form') {

+ 0 - 29
dashboard/src/main/home/dashboard/expanded-chart/LogSection.tsx

@@ -1,29 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-
-type PropsType = {
-};
-
-type StateType = {
-};
-
-export default class LogSection extends Component<PropsType, StateType> {
-  state = {
-  }
-
-  render() {
-    return (
-      <StyledLogSection>
-        (Logs unimplemented)
-      </StyledLogSection>
-    );
-  }
-}
-
-const StyledLogSection = styled.div`
-  width: 100%;
-  height: 100%;
-  background: #202227;
-  position: relative;
-  padding: 20px;
-`;

+ 103 - 0
dashboard/src/main/home/dashboard/expanded-chart/log/LogSection.tsx

@@ -0,0 +1,103 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import api from '../../../../../shared/api';
+import { ResourceType, ChartType } from '../../../../../shared/types';
+import Logs from './Logs';
+import { Context } from '../../../../../shared/Context';
+
+type PropsType = {
+  selectors: string[],
+};
+
+type StateType = {
+  logs: string[]
+  pods: string[],
+  selectedPod: string,
+};
+
+export default class LogSection extends Component<PropsType, StateType> {
+  state = {
+    logs: [] as string[],
+    pods: [] as string[],
+    selectedPod: null as string,
+    matchingPods: [] as any[]
+  }
+
+  renderLogs = () => {
+    return <Logs key={this.state.selectedPod} selectedPod={this.state.selectedPod} />
+  }
+
+  renderPodTabs = () => {
+    return this.state.pods.map((pod, i) => {
+      return (
+        <Tab 
+          key={i}
+          selected={(this.state.selectedPod == pod)} 
+          onClick={() => {
+          this.setState({selectedPod: pod})
+          }
+        }>
+          {pod}
+        </Tab>
+      )
+    })
+  }
+
+  componentDidMount() {
+    const { selectors } = this.props;
+
+    api.getMatchingPods('<token>', { 
+      context: this.context.currentCluster,
+      selectors,
+    }, {}, (err: any, res: any) => {
+      this.setState({pods: res.data, selectedPod: res.data[0]})
+    })
+  }
+
+  render() {
+    return (
+      <StyledLogSection>
+        <TabWrapper>
+          {this.renderPodTabs()}
+        </TabWrapper>
+        {this.renderLogs()}
+      </StyledLogSection>
+    );
+  }
+}
+
+LogSection.contextType = Context;
+
+const TabWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  width: 30%;
+  float: left;
+`
+
+const Tab = styled.div`
+  align-items: center;
+  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
+  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : '##ffffff11'};
+  height: 100%;
+  justify-content: center;
+  font-size: 13px;
+  padding: 15px 13px;
+  margin-right: 10px;
+  border-radius: 5px;
+  text-shadow: 0px 0px 8px none;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
+const StyledLogSection = styled.span`
+  width: 100%;
+  height: 100%;
+  position: relative;
+  padding: 0px;
+  user-select: text;
+`;

+ 63 - 0
dashboard/src/main/home/dashboard/expanded-chart/log/Logs.tsx

@@ -0,0 +1,63 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import { Context } from '../../../../../shared/Context';
+
+type PropsType = {
+  selectedPod: string,
+};
+
+type StateType = {
+  logs: string[]
+};
+
+export default class Logs extends Component<PropsType, StateType> {
+  state = {
+    logs: [] as string[],
+  }
+
+  scrollRef = React.createRef<HTMLDivElement>()
+  ws = new WebSocket(`ws://localhost:8080/api/k8s/default/pod/${this.props.selectedPod}/logs?context=${this.context.currentCluster}`)
+
+  scrollToBottom = () => {
+    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
+  }
+
+  renderLogs = () => {
+    return this.state.logs.map((log, i) => {
+        return <div key={i}>{log}</div>
+    })
+  }
+
+  componentDidMount() {
+    this.ws.onopen = () => {
+      console.log('connected to websocket')
+    }
+
+    this.ws.onmessage = evt => {
+      this.setState({ logs: [...this.state.logs, evt.data] }, () => {
+        this.scrollToBottom()
+      })
+    }
+  }
+
+  render() {
+    return (
+      <LogStream ref={this.scrollRef}>
+        {this.renderLogs()}
+      </LogStream>
+    );
+  }
+}
+
+Logs.contextType = Context;
+
+
+const LogStream = styled.div`
+  width: 70%;
+  height: 100%;
+  background: #202227;
+  position: relative;
+  padding: 0px;
+  user-select: text;
+  overflow: auto;
+`;

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

@@ -70,6 +70,11 @@ const getNamespaces = baseApi<{
   context: string
   context: string
 }>('GET', '/api/k8s/namespaces');
 }>('GET', '/api/k8s/namespaces');
 
 
+const getMatchingPods = baseApi<{
+  context: string,
+  selectors: string[]
+}>('GET', `/api/k8s/pods`);
+
 const getRevisions = baseApi<{
 const getRevisions = baseApi<{
   namespace: string,
   namespace: string,
   context: string,
   context: string,
@@ -126,6 +131,7 @@ export default {
   getChart,
   getChart,
   getChartComponents,
   getChartComponents,
   getNamespaces,
   getNamespaces,
+  getMatchingPods,
   getRevisions,
   getRevisions,
   rollbackChart,
   rollbackChart,
   upgradeChartValues,
   upgradeChartValues,

+ 2 - 2
dashboard/src/shared/types.tsx

@@ -34,7 +34,7 @@ export interface ResourceType {
   ID: number,
   ID: number,
   Kind: string,
   Kind: string,
   Name: string,
   Name: string,
-  RawYAML: Object,
+  RawYAML: any,
   Relations: any
   Relations: any
 }
 }
 
 
@@ -42,7 +42,7 @@ export interface NodeType {
   id: number,
   id: number,
   name: string,
   name: string,
   kind: string,
   kind: string,
-  RawYAML?: Object,
+  RawYAML?: any,
   x: number,
   x: number,
   y: number,
   y: number,
   w: number,
   w: number,

+ 1 - 0
go.mod

@@ -26,6 +26,7 @@ require (
 	github.com/google/go-github/v32 v32.1.0
 	github.com/google/go-github/v32 v32.1.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/sessions v1.2.1
+	github.com/gorilla/websocket v1.4.2
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/jinzhu/gorm v1.9.16
 	github.com/jinzhu/gorm v1.9.16
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd

+ 2 - 0
go.sum

@@ -443,6 +443,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
 github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
 github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
 github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
 github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
 github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
 github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
 github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=

+ 86 - 60
internal/helm/grapher/relation.go

@@ -31,7 +31,8 @@ type SpecRel struct {
 
 
 // ParsedObjs has methods GetControlRel and GetLabelRel that updates its objects array.
 // ParsedObjs has methods GetControlRel and GetLabelRel that updates its objects array.
 type ParsedObjs struct {
 type ParsedObjs struct {
-	Objects []Object
+	Objects      []Object
+	PodSelectors []string
 }
 }
 
 
 // Relations is embedded into the Object struct and contains arrays of the three types of relationships.
 // Relations is embedded into the Object struct and contains arrays of the three types of relationships.
@@ -62,6 +63,7 @@ type MatchExpression struct {
 func (parsed *ParsedObjs) GetControlRel() {
 func (parsed *ParsedObjs) GetControlRel() {
 	// First collect all children (Pods) that are not included in the yaml as top-level object.
 	// First collect all children (Pods) that are not included in the yaml as top-level object.
 	children := []Object{}
 	children := []Object{}
+	selectors := []string{}
 	for i, obj := range parsed.Objects {
 	for i, obj := range parsed.Objects {
 		yaml := obj.RawYAML
 		yaml := obj.RawYAML
 		kind := getField(yaml, "kind")
 		kind := getField(yaml, "kind")
@@ -75,85 +77,73 @@ func (parsed *ParsedObjs) GetControlRel() {
 		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
 		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
 			rs := getField(yaml, "spec", "replicas")
 			rs := getField(yaml, "spec", "replicas")
 
 
-			if rs != nil && rs.(int) > 0 {
-				// Add Pods for controller objects
-				template := getField(yaml, "spec", "template")
-				if template == nil {
-					continue
+			// replica defaults to 1 if unspecified
+			if rs == nil {
+				rs = 1
+			}
+
+			// Add Pods for controller objects
+			template := getField(yaml, "spec", "template")
+			if template == nil {
+				continue
+			}
+
+			for j := 0; j < rs.(int); j++ {
+				cid := len(parsed.Objects) + len(children)
+				crel := ControlRel{
+					Relation: Relation{
+						Source: obj.ID,
+						Target: cid,
+					},
+					Replicas: rs.(int),
 				}
 				}
 
 
-				for j := 0; j < rs.(int); j++ {
-					cid := len(parsed.Objects) + len(children)
-					crel := ControlRel{
-						Relation: Relation{
-							Source: obj.ID,
-							Target: cid,
+				pod := Object{
+					ID:        cid,
+					Kind:      "Pod",
+					Name:      obj.Name + "-" + strconv.Itoa(j), // tentative name pre-deploy
+					Namespace: obj.Namespace,
+					RawYAML:   template.(map[string]interface{}),
+					Relations: Relations{
+						ControlRels: []ControlRel{
+							crel,
 						},
 						},
-						Replicas: rs.(int),
-					}
+					},
+				}
 
 
-					pod := Object{
-						ID:        cid,
-						Kind:      "Pod",
-						Name:      obj.Name + "-" + strconv.Itoa(j), // tentative name pre-deploy
-						Namespace: obj.Namespace,
-						RawYAML:   template.(map[string]interface{}),
-						Relations: Relations{
-							ControlRels: []ControlRel{
-								crel,
-							},
-						},
-					}
+				children = append(children, pod)
+				obj.Relations.ControlRels = append(obj.Relations.ControlRels, crel)
+				parsed.Objects[i] = obj
+			}
+
+			// Get pod label selectors
+			matchLabels, _ := aggregateLabelSelectors(yaml)
 
 
-					children = append(children, pod)
-					obj.Relations.ControlRels = append(obj.Relations.ControlRels, crel)
-					parsed.Objects[i] = obj
+			// stringify selectors
+			selector := ""
+			for i, ml := range matchLabels {
+				selector = selector + ml.key + "=" + ml.value
+				if i != len(matchLabels)-1 {
+					selector = selector + ","
 				}
 				}
 			}
 			}
+			selectors = append(selectors, selector)
 		}
 		}
 	}
 	}
 
 
 	// add children to the objects array at the end.
 	// add children to the objects array at the end.
 	parsed.Objects = append(parsed.Objects, children...)
 	parsed.Objects = append(parsed.Objects, children...)
+	parsed.PodSelectors = selectors
 }
 }
 
 
 // GetLabelRel is generates relationships between objects connected by selector-label.
 // GetLabelRel is generates relationships between objects connected by selector-label.
 // It supports both Equality-based and Set-based operators with MatchLabels and MatchExpressions, respectively.
 // It supports both Equality-based and Set-based operators with MatchLabels and MatchExpressions, respectively.
 func (parsed *ParsedObjs) GetLabelRel() {
 func (parsed *ParsedObjs) GetLabelRel() {
 	for i, o := range parsed.Objects {
 	for i, o := range parsed.Objects {
+
 		// Skip Pods
 		// Skip Pods
 		yaml := o.RawYAML
 		yaml := o.RawYAML
-		matchLabels := []MatchLabel{}
-		matchExpressions := []MatchExpression{}
-
-		// First check for the outdated syntax (matchLabels were added in recent k8s version)
-		if l := getField(yaml, "spec", "selector"); l != nil {
-			simple := true
-			if ml := getField(yaml, "spec", "selector", "matchLabels"); ml != nil {
-				matchLabels = addMatchLabels(matchLabels, ml.(map[string]interface{}))
-				simple = false
-			}
-
-			if me := getField(yaml, "spec", "selector", "matchExpressions"); me != nil {
-				for _, o := range me.([]interface{}) {
-					ot := o.(map[string]interface{})
-					values := []string{}
-					for _, arg := range ot["values"].([]interface{}) {
-						values = append(values, arg.(string))
-					}
-					matchExpressions = append(matchExpressions, MatchExpression{
-						key:      ot["key"].(string),
-						operator: ot["operator"].(string),
-						values:   values,
-					})
-				}
-				simple = false
-			}
-
-			if simple {
-				matchLabels = addMatchLabels(matchLabels, l.(map[string]interface{}))
-			}
-		}
+		matchLabels, matchExpressions := aggregateLabelSelectors(yaml)
 
 
 		// Find ID's of targets that match the label selector
 		// Find ID's of targets that match the label selector
 		targetID := parsed.findLabelsBySelector(o.ID, matchLabels, matchExpressions)
 		targetID := parsed.findLabelsBySelector(o.ID, matchLabels, matchExpressions)
@@ -252,6 +242,42 @@ func (parsed *ParsedObjs) GetSpecRel() {
 	}
 	}
 }
 }
 
 
+// LabelRel helpers
+func aggregateLabelSelectors(yaml map[string]interface{}) ([]MatchLabel, []MatchExpression) {
+	matchLabels := []MatchLabel{}
+	matchExpressions := []MatchExpression{}
+
+	// First check for the outdated syntax (matchLabels were added in recent k8s version)
+	if l := getField(yaml, "spec", "selector"); l != nil {
+		simple := true
+		if ml := getField(yaml, "spec", "selector", "matchLabels"); ml != nil {
+			matchLabels = addMatchLabels(matchLabels, ml.(map[string]interface{}))
+			simple = false
+		}
+
+		if me := getField(yaml, "spec", "selector", "matchExpressions"); me != nil {
+			for _, o := range me.([]interface{}) {
+				ot := o.(map[string]interface{})
+				values := []string{}
+				for _, arg := range ot["values"].([]interface{}) {
+					values = append(values, arg.(string))
+				}
+				matchExpressions = append(matchExpressions, MatchExpression{
+					key:      ot["key"].(string),
+					operator: ot["operator"].(string),
+					values:   values,
+				})
+			}
+			simple = false
+		}
+
+		if simple {
+			matchLabels = addMatchLabels(matchLabels, l.(map[string]interface{}))
+		}
+	}
+	return matchLabels, matchExpressions
+}
+
 // SpecRel helpers
 // SpecRel helpers
 func (parsed *ParsedObjs) findObjectByNameAndKind(parentID int, name interface{}, kind string) []int {
 func (parsed *ParsedObjs) findObjectByNameAndKind(parentID int, name interface{}, kind string) []int {
 	targets := []int{}
 	targets := []int{}

+ 35 - 12
internal/kubernetes/agent.go

@@ -1,11 +1,12 @@
 package kubernetes
 package kubernetes
 
 
 import (
 import (
-	"bytes"
+	"bufio"
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 
 
+	"github.com/gorilla/websocket"
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -27,23 +28,45 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 	)
 }
 }
 
 
+// GetPodsByLabel retrieves pods with matching labels
+func (a *Agent) GetPodsByLabel(selector string) (*v1.PodList, error) {
+	// Search in all namespaces for matching pods
+	return a.Clientset.CoreV1().Pods("").List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: selector,
+		},
+	)
+}
+
 // GetPodLogs streams real-time logs from a given pod.
 // GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(pod *v1.Pod) (string, error) {
-	podLogOpts := v1.PodLogOptions{}
-	req := a.Clientset.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &podLogOpts)
+func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+	// follow logs
+	tails := int64(30)
+	podLogOpts := v1.PodLogOptions{
+		Follow:    true,
+		TailLines: &tails,
+	}
+	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
 	podLogs, err := req.Stream(context.TODO())
 	podLogs, err := req.Stream(context.TODO())
 	if err != nil {
 	if err != nil {
-		return "Error: Cannot open log stream.", fmt.Errorf("Cannot open log stream for pod %s", pod.Name)
+		return fmt.Errorf("Cannot open log stream for pod %s", name)
 	}
 	}
 	defer podLogs.Close()
 	defer podLogs.Close()
 
 
-	buf := new(bytes.Buffer)
-	_, err = io.Copy(buf, podLogs)
+	r := bufio.NewReader(podLogs)
+	for {
+		bytes, err := r.ReadBytes('\n')
 
 
-	if err != nil {
-		return "Error: Cannot encode Pod logs.", fmt.Errorf("Cannot copy logs from pod %s to buf", pod.Name)
-	}
-	str := buf.String()
+		if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
+			return writeErr
+		}
 
 
-	return str, nil
+		if err != nil {
+			if err != io.EOF {
+				return err
+			}
+			return nil
+		}
+	}
 }
 }

+ 26 - 0
server/api/errors.go

@@ -19,6 +19,14 @@ type HTTPError struct {
 type ErrorCode int64
 type ErrorCode int64
 
 
 var (
 var (
+	// ErrorUpgradeWebsocket describes an error while upgrading http to a websocket endpoint.
+	ErrorUpgradeWebsocket = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"could not upgrade to websocket",
+		},
+	}
+
 	// ErrorDataWrite describes an error in writing to the database
 	// ErrorDataWrite describes an error in writing to the database
 	ErrorDataWrite = HTTPError{
 	ErrorDataWrite = HTTPError{
 		Code: 500,
 		Code: 500,
@@ -27,6 +35,14 @@ var (
 		},
 		},
 	}
 	}
 
 
+	// ErrorWebsocketWrite describes an error in writing to websocket connection
+	ErrorWebsocketWrite = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"could not write data via websocket",
+		},
+	}
+
 	// ErrorDataRead describes an error when reading from the database
 	// ErrorDataRead describes an error when reading from the database
 	ErrorDataRead = HTTPError{
 	ErrorDataRead = HTTPError{
 		Code: 500,
 		Code: 500,
@@ -129,6 +145,16 @@ func (app *App) handleErrorDataWrite(err error, w http.ResponseWriter) {
 	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataWrite, w)
 	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataWrite, w)
 }
 }
 
 
+// handleErrorWebsocketWrite handles an error from websocket.WriteMessage
+func (app *App) handleErrorWebsocketWrite(err error, w http.ResponseWriter) {
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorWebsocketWrite, w)
+}
+
+// handleErrorUpgradeWebsocket handles error in upgrading a http endpoint to websocket conn
+func (app *App) handleErrorUpgradeWebsocket(err error, w http.ResponseWriter) {
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorUpgradeWebsocket, w)
+}
+
 // handleErrorDataRead handles a database read error due to an internal error, such as
 // handleErrorDataRead handles a database read error due to an internal error, such as
 // the database connection or gorm internals
 // the database connection or gorm internals
 func (app *App) handleErrorDataRead(err error, w http.ResponseWriter) {
 func (app *App) handleErrorDataRead(err error, w http.ResponseWriter) {

+ 137 - 0
server/api/k8s_handler.go

@@ -5,8 +5,10 @@ import (
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
 
 
+	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 
 
+	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 )
 )
 
 
@@ -16,6 +18,11 @@ const (
 	ErrK8sValidate
 	ErrK8sValidate
 )
 )
 
 
+var upgrader = websocket.Upgrader{
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+}
+
 // 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) {
 	session, err := app.store.Get(r, app.cookieName)
 	session, err := app.store.Get(r, app.cookieName)
@@ -69,3 +76,133 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 }
 }
+
+// HandleGetPodLogs returns real-time logs of the pod via websockets
+// TODO: Refactor repeated calls.
+func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
+
+	// get session to retrieve correct kubeconfig
+	session, err := app.store.Get(r, app.cookieName)
+
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	podName := chi.URLParam(r, "name")
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{},
+	}
+	form.PopulateK8sOptionsFromQueryParams(vals)
+
+	if sessID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateK8sOptionsFromUserID(sessID, app.repo.User)
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.testing {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	// allow all hosts for now. TODO: Implement CORS
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	err = agent.GetPodLogs(namespace, podName, conn)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}
+
+// HandleListPods returns all pods that match the given selectors
+// TODO: Refactor repeated calls.
+func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
+
+	// get session to retrieve correct kubeconfig
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{},
+	}
+	form.PopulateK8sOptionsFromQueryParams(vals)
+
+	if sessID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateK8sOptionsFromUserID(sessID, app.repo.User)
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.testing {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	pods := []string{}
+	for _, selector := range vals["selectors"] {
+		podsList, err := agent.GetPodsByLabel(selector)
+
+		if err != nil {
+			app.handleErrorFormValidation(err, ErrK8sValidate, w)
+			return
+		}
+
+		for _, pod := range podsList.Items {
+			pods = append(pods, pod.ObjectMeta.Name)
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(pods); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}

+ 1 - 1
server/api/release_handler.go

@@ -145,7 +145,7 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	parsed.GetLabelRel()
 	parsed.GetLabelRel()
 	parsed.GetSpecRel()
 	parsed.GetSpecRel()
 
 
-	if err := json.NewEncoder(w).Encode(parsed.Objects); err != nil {
+	if err := json.NewEncoder(w).Encode(parsed); err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 		return
 	}
 	}

+ 8 - 0
server/requestlog/log_entry.go

@@ -1,6 +1,7 @@
 package requestlog
 package requestlog
 
 
 import (
 import (
+	"bufio"
 	"errors"
 	"errors"
 	"io"
 	"io"
 	"net"
 	"net"
@@ -91,6 +92,13 @@ func (r *responseStats) Write(p []byte) (n int, err error) {
 	r.wc.Write(p[:n])
 	r.wc.Write(p[:n])
 	return
 	return
 }
 }
+func (r *responseStats) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	h, ok := r.w.(http.Hijacker)
+	if !ok {
+		return nil, nil, errors.New("ResponseWriter Interface does not support hijacking")
+	}
+	return h.Hijack()
+}
 func (r *responseStats) size() (hdr, body int64) {
 func (r *responseStats) size() (hdr, body int64) {
 	if r.code == 0 {
 	if r.code == 0 {
 		return headerSize(r.w.Header()), 0
 		return headerSize(r.w.Header()), 0

+ 2 - 0
server/router/router.go

@@ -53,6 +53,8 @@ func New(
 
 
 		// /api/k8s routes
 		// /api/k8s routes
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
+		r.Method("GET", "/k8s/{namespace}/pod/{name}/logs", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetPodLogs, l)))
+		r.Method("GET", "/k8s/pods", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListPods, l)))
 	})
 	})
 
 
 	fs := http.FileServer(http.Dir(staticFilePath))
 	fs := http.FileServer(http.Dir(staticFilePath))