Przeglądaj źródła

Merge pull request #1624 from porter-dev/nico/implement-new-previous-logs

[IMPROVEMENT] Implemented previous logs for running containers
abelanger5 4 lat temu
rodzic
commit
69a6db9489

+ 68 - 0
api/server/handlers/namespace/get_previous_logs.go

@@ -0,0 +1,68 @@
+package namespace
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPreviousLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPreviousLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetPreviousLogsHandler {
+	return &GetPreviousLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPreviousLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetPreviousPodLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	logs, err := agent.GetPreviousPodLogs(namespace, name, request.Container)
+
+	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.GetPreviousPodLogsResponse = types.GetPreviousPodLogsResponse{
+		PrevLogs: logs,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 1 - 1
api/server/handlers/namespace/stream_pod_logs.go

@@ -53,7 +53,7 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = agent.GetPodLogs(namespace, name, request.Previous, request.Container, safeRW)
+	err = agent.GetPodLogs(namespace, name, request.Container, safeRW)
 
 	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 34 - 0
api/server/router/namespace.go

@@ -328,6 +328,40 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pod/{name}/previous_logs
+	getPreviousLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/pod/{%s}/previous_logs",
+					relPath,
+					types.URLParamPodName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getPreviousLogsHandler := namespace.NewGetPreviousLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getPreviousLogsEndpoint,
+		Handler:  getPreviousLogsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/jobs/{name}/pods -> jobs.NewGetPodsHandler
 	getJobPodsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 8 - 1
api/types/namespace.go

@@ -114,5 +114,12 @@ type DeleteConfigMapRequest struct {
 
 type GetPodLogsRequest struct {
 	Container string `schema:"container_name"`
-	Previous  bool   `schema:"previous"`
+}
+
+type GetPreviousPodLogsRequest struct {
+	Container string `schema:"container_name"`
+}
+
+type GetPreviousPodLogsResponse struct {
+	PrevLogs []string `json:"previous_logs"`
 }

+ 331 - 328
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -1,82 +1,69 @@
-import React, { Component, useEffect, useRef, useState } from "react";
+import React, {
+  Component,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 import * as Anser from "anser";
 import api from "shared/api";
-import { useWebsockets } from "shared/hooks/useWebsockets";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
 
 const MAX_LOGS = 1000;
 
-type PropsType = {
-  selectedPod: any;
-  podError: string;
-  rawText?: boolean;
-};
-
-type StateType = {
-  logs: [number, Anser.AnserJsonEntry[]][];
-  numLogs: number;
-  ws: any;
-  scroll: boolean;
-  currentTab: string;
-  getPreviousLogs: boolean;
+type SelectedPodType = {
+  spec: {
+    [key: string]: any;
+    containers: {
+      [key: string]: any;
+      name: string;
+    }[];
+  };
+  metadata: {
+    name: string;
+    namespace: string;
+  };
+  status: {
+    phase: string;
+  };
 };
 
-export default class Logs extends Component<PropsType, StateType> {
-  private numLogs: React.RefObject<number>;
-
-  state = {
-    logs: [] as [number, Anser.AnserJsonEntry[]][],
-    numLogs: 0,
-    ws: null as any,
-    scroll: true,
-    currentTab: "Application",
-    getPreviousLogs: false,
-  };
+const LogsFC: React.FC<{
+  selectedPod: SelectedPodType;
+  podError: string;
+  rawText?: boolean;
+}> = ({ selectedPod, podError, rawText }) => {
+  const {
+    logs,
+    previousLogs,
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    refresh,
+  } = useLogs(selectedPod);
 
-  ws = null as any;
-  parentRef = React.createRef<HTMLDivElement>();
+  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
 
-  getPodStatus = (status: any) => {
-    if (
-      status?.phase === "Pending" &&
-      status?.containerStatuses !== undefined
-    ) {
-      return status.containerStatuses[0].state.waiting.reason;
-    } else if (status?.phase === "Pending") {
-      return "Pending";
-    }
+  const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(true);
 
-    if (status?.phase === "Failed") {
-      return "failed";
-    }
+  const wrapperRef = useRef<HTMLDivElement>();
 
-    if (status?.phase === "Running") {
-      let collatedStatus = "running";
-
-      status?.containerStatuses?.forEach((s: any) => {
-        if (s.state?.waiting) {
-          collatedStatus =
-            s.state?.waiting.reason === "CrashLoopBackOff"
-              ? "failed"
-              : "waiting";
-        } else if (s.state?.terminated) {
-          collatedStatus = "failed";
-        }
-      });
-      return collatedStatus;
+  const scrollToBottom = (smooth: boolean) => {
+    if (!wrapperRef.current) {
+      return;
     }
-  };
 
-  scrollToBottom = (smooth: boolean) => {
     if (smooth) {
-      this.parentRef.current.lastElementChild.scrollIntoView({
+      wrapperRef.current.lastElementChild.scrollIntoView({
         behavior: "smooth",
         block: "nearest",
         inline: "start",
       });
     } else {
-      this.parentRef.current.lastElementChild.scrollIntoView({
+      wrapperRef.current.lastElementChild.scrollIntoView({
         behavior: "auto",
         block: "nearest",
         inline: "start",
@@ -84,18 +71,22 @@ export default class Logs extends Component<PropsType, StateType> {
     }
   };
 
-  renderLogs = () => {
-    let { selectedPod, podError } = this.props;
+  useEffect(() => {
+    if (isScrollToBottomEnabled) {
+      scrollToBottom(true);
+    }
+  }, [isScrollToBottomEnabled, logs]);
 
+  const renderLogs = () => {
     if (podError && podError != "") {
-      return <Message>{this.props.podError}</Message>;
+      return <Message>{podError}</Message>;
     }
 
     if (!selectedPod?.metadata?.name) {
       return <Message>Please select a pod to view its logs.</Message>;
     }
 
-    if (selectedPod?.status.phase === "Succeeded" && !this.props.rawText) {
+    if (selectedPod?.status.phase === "Succeeded" && !rawText) {
       return (
         <Message>
           ⌛ This job has been completed. You can now delete this job.
@@ -104,31 +95,34 @@ export default class Logs extends Component<PropsType, StateType> {
     }
 
     if (
-      this.getPodStatus(selectedPod.status) === "failed" &&
-      this.state.logs.length === 0
+      showPreviousLogs &&
+      Array.isArray(previousLogs) &&
+      previousLogs.length
     ) {
-      return (
-        <Message>
-          No logs to display from this pod.
-          <Highlight
-            onClick={() => {
-              this.setState({ getPreviousLogs: true }, () => {
-                this.refreshLogs();
-              });
-            }}
-          >
-            <i className="material-icons">autorenew</i>
-            Get logs from crashed pod
-          </Highlight>
-        </Message>
-      );
+      return previousLogs?.map((log, i) => {
+        return (
+          <Log key={i}>
+            {log.map((ansi, j) => {
+              if (ansi.clearLine) {
+                return null;
+              }
+
+              return (
+                <LogSpan key={i + "." + j} ansi={ansi}>
+                  {ansi.content.replace(/ /g, "\u00a0")}
+                </LogSpan>
+              );
+            })}
+          </Log>
+        );
+      });
     }
 
-    if (this.state.logs.length == 0) {
+    if (!Array.isArray(logs) || logs?.length === 0) {
       return (
         <Message>
           No logs to display from this pod.
-          <Highlight onClick={this.refreshLogs}>
+          <Highlight onClick={refresh}>
             <i className="material-icons">autorenew</i>
             Refresh
           </Highlight>
@@ -136,17 +130,16 @@ export default class Logs extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.logs.map((log, i) => {
-      const key = log[0];
+    return logs?.map((log, i) => {
       return (
-        <Log key={key}>
-          {this.state.logs[i][1].map((ansi, j) => {
+        <Log key={i}>
+          {log.map((ansi, j) => {
             if (ansi.clearLine) {
               return null;
             }
 
             return (
-              <LogSpan key={key + "." + j} ansi={ansi}>
+              <LogSpan key={i + "." + j} ansi={ansi}>
                 {ansi.content.replace(/ /g, "\u00a0")}
               </LogSpan>
             );
@@ -156,278 +149,288 @@ export default class Logs extends Component<PropsType, StateType> {
     });
   };
 
-  setupWebsocket = () => {
-    let { currentCluster, currentProject } = this.context;
-    let { selectedPod } = this.props;
-    if (!selectedPod?.metadata?.name) return;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    const currentTab = this.state.currentTab;
-    if (currentTab === "Application") {
-      this.ws = new WebSocket(
-        `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?previous=${this.state.getPreviousLogs}`
-      );
-    } else {
-      this.ws = new WebSocket(
-        `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?container_name=${currentTab}&previous=${this.state.getPreviousLogs}`
-      );
-    }
+  const renderContent = () => (
+    <>
+      <Wrapper ref={wrapperRef}>{renderLogs()}</Wrapper>
+      <LogTabs>
+        {containers.map((containerName, _i, arr) => {
+          return (
+            <Tab
+              key={containerName}
+              onClick={() => {
+                setCurrentContainer(containerName);
+              }}
+              clicked={currentContainer === containerName}
+            >
+              {arr.length > 1 ? containerName : "Application"}
+            </Tab>
+          );
+        })}
+        <Tab
+          onClick={() => {
+            setCurrentContainer("system");
+          }}
+          clicked={currentContainer == "system"}
+        >
+          System
+        </Tab>
+      </LogTabs>
+      <Options>
+        <Scroll
+          onClick={() => {
+            setIsScrollToBottomEnabled(!isScrollToBottomEnabled);
+            if (isScrollToBottomEnabled) {
+              scrollToBottom(true);
+            }
+          }}
+        >
+          <input
+            type="checkbox"
+            checked={isScrollToBottomEnabled}
+            onChange={() => {}}
+          />
+          Scroll to Bottom
+        </Scroll>
+        {Array.isArray(previousLogs) && previousLogs.length > 0 && (
+          <Scroll
+            onClick={() => {
+              setShowPreviousLogs(!showPreviousLogs);
+            }}
+          >
+            <input
+              type="checkbox"
+              checked={showPreviousLogs}
+              onChange={() => {}}
+            />
+            Show previous Logs
+          </Scroll>
+        )}
+        <Refresh
+          onClick={() => {
+            // this.refreshLogs();
+            console.log("Refresh logs");
+            refresh();
+          }}
+        >
+          <i className="material-icons">autorenew</i>
+          Refresh
+        </Refresh>
+      </Options>
+    </>
+  );
+
+  if (!containers?.length) {
+    return null;
+  }
 
-    this.ws.onopen = () => { };
+  if (rawText) {
+    return <LogStreamAlt>{renderContent()}</LogStreamAlt>;
+  }
 
-    this.ws.onmessage = (evt: MessageEvent) => {
-      let ansiLog = Anser.ansiToJson(evt.data);
+  return <LogStream>{renderContent()}</LogStream>;
+};
 
-      let logs = this.state.logs;
-      logs.push([this.state.numLogs, ansiLog]);
+export default LogsFC;
 
-      // this is technically not as efficient as things could be
-      // if there are performance issues, a deque can be used in place of a list
-      // for storing logs
-      if (logs.length > MAX_LOGS) {
-        logs.shift();
-      }
+const useLogs = (currentPod: SelectedPodType) => {
+  const currentPodName = useRef<string>();
 
-      this.setState(
-        (prev) => {
-          return {
-            logs: prev.logs,
-            numLogs: prev.numLogs + 1,
-          };
-        },
-        () => {
-          if (this.state.scroll) {
-            this.scrollToBottom(false);
-          }
-        }
-      );
-    };
+  const { currentCluster, currentProject } = useContext(Context);
+  const [containers, setContainers] = useState<string[]>([]);
+  const [currentContainer, setCurrentContainer] = useState<string>("");
+  const [logs, setLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
 
-    this.ws.onerror = (err: ErrorEvent) => { };
+  const [prevLogs, setPrevLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
 
-    this.ws.onclose = () => { };
-  };
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    getWebsocket,
+    closeWebsocket,
+  } = useWebsockets();
 
-  refreshLogs = () => {
-    let { selectedPod } = this.props;
-    if (
-      this.ws &&
-      typeof this.state.currentTab === "string" &&
-      this.state.currentTab != "System"
-    ) {
-      this.ws.close();
-      this.ws = null;
-      this.setState({ logs: [] });
-      this.setupWebsocket();
-    } else if (this.state.currentTab == "System") {
-      this.retrieveEvents(selectedPod);
-    }
-  };
+  const getSystemLogs = async () => {
+    const events = await api
+      .getPodEvents(
+        "<token>",
+        {},
+        {
+          name: currentPod?.metadata?.name,
+          namespace: currentPod?.metadata?.namespace,
+          cluster_id: currentCluster?.id,
+          id: currentProject?.id,
+        }
+      )
+      .then((res) => res.data);
 
-  componentDidUpdate = (prevProps: any, prevState: any) => {
-    if (prevState.currentTab !== this.state.currentTab) {
-      let { selectedPod } = this.props;
+    let processedLogs = [] as Anser.AnserJsonEntry[][];
 
-      this.ws?.close();
+    events.items.forEach((evt: any) => {
+      let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
+      let ansiLog = Anser.ansiToJson(
+        `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
+      );
+      processedLogs.push(ansiLog);
+    });
 
-      this.setState({ logs: [] });
+    // SET LOGS FOR SYSTEM
+    setLogs((prevState) => ({
+      ...prevState,
+      system: processedLogs,
+    }));
+  };
 
-      if (this.state.currentTab == "System") {
-        this.retrieveEvents(selectedPod);
-        return;
-      }
+  const getContainerPreviousLogs = async (containerName: string) => {
+    try {
+      const logs = await api
+        .getPreviousLogsForContainer<{ previous_logs: string[] }>(
+          "<token>",
+          {
+            container_name: containerName,
+          },
+          {
+            pod_name: currentPod?.metadata?.name,
+            namespace: currentPod?.metadata?.namespace,
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+          }
+        )
+        .then((res) => res.data);
+      // Process logs
+      const processedLogs: Anser.AnserJsonEntry[][] = logs.previous_logs.map(
+        (currentLog) => {
+          let ansiLog = Anser.ansiToJson(currentLog);
+          return ansiLog;
+        }
+      );
 
-      this.setState({ getPreviousLogs: false });
-      this.setupWebsocket();
-      this.scrollToBottom(false);
-    }
+      setPrevLogs((pl) => ({
+        ...pl,
+        [containerName]: processedLogs,
+      }));
+    } catch (error) {}
   };
 
-  retrieveEvents = (selectedPod: any) => {
-    api
-      .getPodEvents(
-        "<token>",
-        {},
-        {
-          name: selectedPod?.metadata?.name,
-          namespace: selectedPod?.metadata?.namespace,
-          cluster_id: this.context.currentCluster.id,
-          id: this.context.currentProject.id,
-        }
-      )
-      .then((res) => {
-        let logs = [] as [number, Anser.AnserJsonEntry[]][];
-        // TODO: column view
-        // logs.push(Anser.ansiToJson("\u001b[33;5;196mEvent Type\u001b[0m \t || \t \u001b[43m\u001b[34m\tReason\t\u001b[0m \t ||\tMessage"))
-
-        res.data.items.forEach((evt: any) => {
-          let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
-          let ansiLog = Anser.ansiToJson(
-            `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
-          );
-          logs.push([logs.length, ansiLog]);
+  const setupWebsocket = (containerName: string, websocketKey: string) => {
+    if (!currentPod?.metadata?.name) return;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${currentPod?.metadata?.namespace}/pod/${currentPod?.metadata?.name}/logs?container_name=${containerName}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let ansiLog = Anser.ansiToJson(evt.data);
+        setLogs((logs) => {
+          const tmpLogs = { ...logs };
+          let containerLogs = tmpLogs[containerName] || [];
+
+          containerLogs.push(ansiLog);
+          // this is technically not as efficient as things could be
+          // if there are performance issues, a deque can be used in place of a list
+          // for storing logs
+          if (containerLogs.length > MAX_LOGS) {
+            containerLogs.shift();
+          }
+
+          return {
+            ...logs,
+            [containerName]: containerLogs,
+          };
         });
-        this.setState({ logs: logs });
-        console.log(res);
-      })
-      .catch((err) => {
-        console.log(err);
-      });
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
   };
 
-  componentDidMount() {
-    let { selectedPod } = this.props;
+  const refresh = () => {
+    const websocketKey = `${currentPodName.current}-${currentContainer}-websocket`;
+    closeWebsocket(websocketKey);
 
-    if (selectedPod?.spec?.containers?.length > 1) {
-      const firstContainer = selectedPod?.spec?.containers[0];
-      this.setState({ currentTab: firstContainer?.name }, () => {
-        this.setupWebsocket();
-        this.scrollToBottom(false);
-      });
-      return;
-    }
+    setPrevLogs((prev) => ({ ...prev, [currentContainer]: [] }));
+    setLogs((prev) => ({ ...prev, [currentContainer]: [] }));
 
-    if (this.state.currentTab == "Application") {
-      this.setupWebsocket();
-      this.scrollToBottom(false);
+    if (!Array.isArray(containers)) {
       return;
     }
 
-    this.retrieveEvents(selectedPod);
-  }
-
-  componentWillUnmount() {
-    if (this.ws) {
-      this.ws.close();
+    if (currentContainer === "system") {
+      getSystemLogs();
+    } else {
+      getContainerPreviousLogs(currentContainer);
+      setupWebsocket(currentContainer, websocketKey);
     }
-  }
-
-  renderContainerTabs = () => {
-    const containers = this.props.selectedPod?.spec?.containers;
+  };
 
-    if (!Array.isArray(containers) || containers?.length <= 1) {
-      return (
-        <Tab
-          onClick={() => {
-            this.setState({ currentTab: "Application" });
-          }}
-          clicked={this.state.currentTab == "Application"}
-        >
-          Application
-        </Tab>
-      );
+  useEffect(() => {
+    console.log("Selected pod updated");
+    if (currentPod?.metadata?.name === currentPodName.current) {
+      return () => {
+        closeAllWebsockets();
+      };
     }
+    currentPodName.current = currentPod?.metadata?.name;
+    const currentContainers =
+      currentPod?.spec?.containers?.map((container) => container?.name) || [];
+
+    setContainers(currentContainers);
+    setCurrentContainer(currentContainers[0]);
+    return () => {
+      closeAllWebsockets();
+    };
+  }, [currentPod]);
 
-    return (
-      <>
-        {containers.map((container: any) => {
-          return (
-            <Tab
-              key={container.name}
-              onClick={() => {
-                this.setState({ currentTab: container.name });
-              }}
-              clicked={this.state.currentTab == container.name}
-            >
-              {container.name}
-            </Tab>
-          );
-        })}
-      </>
-    );
-  };
+  // Retrieve all previous logs for containers
+  useEffect(() => {
+    closeAllWebsockets();
 
-  render() {
-    if (this.props.rawText) {
-      return (
-        <LogStreamAlt>
-          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
-          <LogTabs>
-            {this.renderContainerTabs()}
-            <Tab
-              onClick={() => {
-                this.setState({ currentTab: "System" });
-              }}
-              clicked={this.state.currentTab == "System"}
-            >
-              System
-            </Tab>
-          </LogTabs>
-          <Options>
-            <Scroll
-              onClick={() => {
-                this.setState({ scroll: !this.state.scroll }, () => {
-                  if (this.state.scroll) {
-                    this.scrollToBottom(true);
-                  }
-                });
-              }}
-            >
-              <input
-                type="checkbox"
-                checked={this.state.scroll}
-                onChange={() => { }}
-              />
-              Scroll to Bottom
-            </Scroll>
-            <Refresh
-              onClick={() => {
-                this.refreshLogs();
-              }}
-            >
-              <i className="material-icons">autorenew</i>
-              Refresh
-            </Refresh>
-          </Options>
-        </LogStreamAlt>
-      );
+    setPrevLogs({});
+    setLogs({});
+
+    if (!Array.isArray(containers)) {
+      return;
     }
 
-    return (
-      <LogStream>
-        <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
-        <LogTabs>
-          {this.renderContainerTabs()}
-          <Tab
-            onClick={() => {
-              this.setState({ currentTab: "System" });
-            }}
-            clicked={this.state.currentTab == "System"}
-          >
-            System
-          </Tab>
-        </LogTabs>
-        <Options>
-          <Scroll
-            onClick={() => {
-              this.setState({ scroll: !this.state.scroll }, () => {
-                if (this.state.scroll) {
-                  this.scrollToBottom(true);
-                }
-              });
-            }}
-          >
-            <input
-              type="checkbox"
-              checked={this.state.scroll}
-              onChange={() => { }}
-            />
-            Scroll to Bottom
-          </Scroll>
-          <Refresh
-            onClick={() => {
-              this.refreshLogs();
-            }}
-          >
-            <i className="material-icons">autorenew</i>
-            Refresh
-          </Refresh>
-        </Options>
-      </LogStream>
-    );
-  }
-}
+    getSystemLogs();
+    containers.forEach((containerName) => {
+      const websocketKey = `${currentPodName.current}-${containerName}-websocket`;
 
-Logs.contextType = Context;
+      getContainerPreviousLogs(containerName);
+
+      if (!getWebsocket(websocketKey)) {
+        setupWebsocket(containerName, websocketKey);
+      }
+    });
+  }, [containers]);
+
+  const currentLogs = useMemo(() => {
+    return logs[currentContainer] || [];
+  }, [currentContainer, logs]);
+
+  const currentPreviousLogs = useMemo(() => {
+    return prevLogs[currentContainer] || [];
+  }, [currentContainer, prevLogs]);
+
+  return {
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    logs: currentLogs,
+    previousLogs: currentPreviousLogs,
+    refresh,
+  };
+};
 
 const Highlight = styled.div`
   display: flex;
@@ -447,7 +450,7 @@ const Scroll = styled.div`
   align-items: center;
   display: flex;
   cursor: pointer;
-  width: 145px;
+  width: max-content;
   height: 100%;
 
   :hover {

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

@@ -1326,6 +1326,22 @@ const getCanCreateProject = baseApi<{}, {}>(
   () => "/api/can_create_project"
 );
 
+const getPreviousLogsForContainer = baseApi<
+  {
+    container_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    pod_name: string;
+  }
+>(
+  "GET",
+  ({ cluster_id, namespace, pod_name: name, project_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1458,4 +1474,5 @@ export default {
   getLogBuckets,
   getLogBucketLogs,
   getCanCreateProject,
+  getPreviousLogsForContainer,
 };

+ 2 - 1
dashboard/src/shared/hooks/useWebsockets.ts

@@ -99,7 +99,7 @@ export const useWebsockets = () => {
   /**
    * Close specific websocket
    */
-  const closeWebsocket = (id: string, code?: number, reason?: string) => {
+  const closeWebsocket = (id: string, code: number =  4000, reason: string = "User closed the websocket connection") => {
     const ws = websocketMap.current[id];
 
     if (!ws) {
@@ -108,6 +108,7 @@ export const useWebsockets = () => {
     }
 
     ws.close(code, reason);
+    websocketMap.current[id] = null;
   };
 
   /**

+ 70 - 7
internal/kubernetes/agent.go

@@ -554,7 +554,7 @@ func (a *Agent) DeletePod(namespace string, name string) error {
 }
 
 // GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool, selectedContainer string, rw *websocket.WebsocketSafeReadWriter) error {
+func (a *Agent) GetPodLogs(namespace string, name string, selectedContainer string, rw *websocket.WebsocketSafeReadWriter) error {
 	// get the pod to read in the list of contains
 	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
 		context.Background(),
@@ -568,11 +568,9 @@ func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool,
 		return fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
 	}
 
-	if !showPreviousLogs {
-		// see if container is ready and able to open a stream. If not, wait for container
-		// to be ready.
-		err, _ = a.waitForPod(pod)
-	}
+	// see if container is ready and able to open a stream. If not, wait for container
+	// to be ready.
+	err, _ = a.waitForPod(pod)
 
 	if err != nil && goerrors.Is(err, IsNotFoundError) {
 		return IsNotFoundError
@@ -593,7 +591,6 @@ func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool,
 		Follow:    true,
 		TailLines: &tails,
 		Container: container,
-		Previous:  showPreviousLogs,
 	}
 
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
@@ -656,6 +653,72 @@ func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool,
 	}
 }
 
+// GetPodLogs streams real-time logs from a given pod.
+func (a *Agent) GetPreviousPodLogs(namespace string, name string, selectedContainer string) ([]string, error) {
+	// get the pod to read in the list of contains
+	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	} else if err != nil {
+		return nil, fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
+	}
+
+	container := pod.Spec.Containers[0].Name
+
+	if len(selectedContainer) > 0 {
+		container = selectedContainer
+	}
+
+	tails := int64(400)
+
+	// follow logs
+	podLogOpts := v1.PodLogOptions{
+		Follow:    true,
+		TailLines: &tails,
+		Container: container,
+		Previous:  true,
+	}
+
+	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(context.TODO())
+
+	// in the case of bad request errors, such as if the pod is stuck in "ContainerCreating",
+	// we'd like to pass this through to the client.
+	if err != nil && strings.Contains(err.Error(), "not found") {
+		return nil, IsNotFoundError
+	}
+
+	if err != nil && errors.IsBadRequest(err) {
+		return nil, &BadRequestError{err.Error()}
+	} else if err != nil {
+		return nil, fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
+	}
+
+	defer podLogs.Close()
+
+	r := bufio.NewReader(podLogs)
+	var logs []string
+
+	for {
+		line, err := r.ReadString('\n')
+		logs = append(logs, line)
+
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+	}
+
+	return logs, nil
+}
+
 // StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
 func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
 	jobPods, err := a.GetJobPods(namespace, name)