소스 검색

POR-1633: support logs on new view (#3478)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town 2 년 전
부모
커밋
4c6ed86fdc

+ 172 - 0
api/server/handlers/porter_app/logs_apply_v2.go

@@ -0,0 +1,172 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"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/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AppLogsHandler handles the /apps/logs endpoint
+type AppLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAppLogsHandler returns a new AppLogsHandler
+func NewAppLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppLogsHandler {
+	return &AppLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AppLogsRequest represents the accepted fields on a request to the /apps/logs endpoint
+type AppLogsRequest struct {
+	DeploymentTargetID string    `schema:"deployment_target_id"`
+	ServiceName        string    `schema:"service_name"`
+	AppName            string    `schema:"app_name"`
+	Limit              uint      `schema:"limit"`
+	StartRange         time.Time `schema:"start_range,omitempty"`
+	EndRange           time.Time `schema:"end_range,omitempty"`
+	SearchParam        string    `schema:"search_param"`
+	Direction          string    `schema:"direction"`
+}
+
+const (
+	lokiLabel_PorterAppName     = "porter_run_app_name"
+	lokiLabel_PorterServiceName = "porter_run_service_name"
+	lokiLabel_Namespace         = "namespace"
+)
+
+// ServeHTTP gets logs for a given app, service, and deployment target
+func (c *AppLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-logs")
+	defer span.End()
+	r = r.Clone(ctx)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &AppLogsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.AppName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: request.AppName})
+
+	if request.ServiceName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide service name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	if request.StartRange.IsZero() || request.EndRange.IsZero() {
+		err := telemetry.Error(ctx, span, nil, "must provide start and end range")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "start-range", Value: request.StartRange.String()},
+		telemetry.AttributeKV{Key: "end-range", Value: request.EndRange.String()},
+	)
+
+	k8sAgent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get agent"), http.StatusInternalServerError))
+		return
+	}
+
+	agentSvc, err := porter_agent.GetAgentService(k8sAgent.Clientset)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get agent service")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get agent service"), http.StatusInternalServerError))
+		return
+	}
+
+	matchLabels := map[string]string{
+		lokiLabel_Namespace:     namespace,
+		lokiLabel_PorterAppName: request.AppName,
+	}
+
+	if request.ServiceName != "all" {
+		matchLabels[lokiLabel_PorterServiceName] = request.ServiceName
+	}
+
+	logRequest := &types.LogRequest{
+		Limit:       request.Limit,
+		StartRange:  &request.StartRange,
+		EndRange:    &request.EndRange,
+		MatchLabels: matchLabels,
+		Direction:   request.Direction,
+		SearchParam: request.SearchParam,
+	}
+
+	logs, err := porter_agent.Logs(k8sAgent.Clientset, agentSvc, logRequest)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get logs")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get logs"), http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 144 - 0
api/server/handlers/porter_app/stream_logs.go

@@ -0,0 +1,144 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// StreamLogsLokiHandler handles the /apps/logs/loki endpoint
+type StreamLogsLokiHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewStreamLogsLokiHandler returns a new StreamLogsLokiHandler
+func NewStreamLogsLokiHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamLogsLokiHandler {
+	return &StreamLogsLokiHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP streams live logs for a given app, service, and deployment target
+func (c *StreamLogsLokiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-stream-app-logs")
+	defer span.End()
+	r = r.Clone(ctx)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &AppLogsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.AppName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: request.AppName})
+
+	if request.ServiceName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide service name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	if request.StartRange.IsZero() {
+		dayAgo := time.Now().Add(-24 * time.Hour)
+		request.StartRange = dayAgo
+	}
+
+	startTime, err := request.StartRange.MarshalText()
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error marshaling start time")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "start-time", Value: string(startTime)})
+
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	labels := []string{
+		fmt.Sprintf("%s=%s", lokiLabel_Namespace, namespace),
+		fmt.Sprintf("%s=%s", lokiLabel_PorterAppName, request.AppName),
+	}
+
+	if request.ServiceName != "all" {
+		labels = append(labels, fmt.Sprintf("%s=%s", lokiLabel_PorterServiceName, request.ServiceName))
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "labels", Value: strings.Join(labels, ",")})
+
+	err = agent.StreamPorterAgentLokiLog(labels, string(startTime), request.SearchParam, 0, safeRW)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error streaming logs")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 59 - 0
api/server/router/porter_app.go

@@ -832,5 +832,64 @@ func getPorterAppRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/logs -> cluster.NewAppLogsHandler
+	appLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/logs",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appLogsHandler := porter_app.NewAppLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appLogsEndpoint,
+		Handler:  appLogsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/logs/loki -> namespace.NewStreamLogsLokiHandler
+	streamLogsLokiEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/logs/loki",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamLogsLokiHandler := porter_app.NewStreamLogsLokiHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: streamLogsLokiEndpoint,
+		Handler:  streamLogsLokiHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 16 - 5
api/types/incident.go

@@ -112,6 +112,16 @@ type GetLogRequest struct {
 	Direction   string     `schema:"direction"`
 	Direction   string     `schema:"direction"`
 }
 }
 
 
+// LogRequest is a request for logs from the porter agent. It generalizes pod selectors and namespace to just MatchLabels.
+type LogRequest struct {
+	Limit       uint              `schema:"limit"`
+	StartRange  *time.Time        `schema:"start_range"`
+	EndRange    *time.Time        `schema:"end_range"`
+	SearchParam string            `schema:"search_param"`
+	MatchLabels map[string]string `schema:"match_labels"`
+	Direction   string            `schema:"direction"`
+}
+
 // You may either provide the pod selector directly, or the chart name,
 // You may either provide the pod selector directly, or the chart name,
 // in which case we will attempt to find the correct pod within the timeframe.
 // in which case we will attempt to find the correct pod within the timeframe.
 type GetChartLogsWithinTimeRangeRequest struct {
 type GetChartLogsWithinTimeRangeRequest struct {
@@ -146,11 +156,12 @@ type LogLine struct {
 }
 }
 
 
 type LogMetadata struct {
 type LogMetadata struct {
-	PodName      string `json:"pod_name"`
-	PodNamespace string `json:"pod_namespace"`
-	Revision     string `json:"revision"`
-	OutputStream string `json:"output_stream"`
-	AppName      string `json:"app_name"`
+	PodName      string            `json:"pod_name"`
+	PodNamespace string            `json:"pod_namespace"`
+	Revision     string            `json:"revision"`
+	OutputStream string            `json:"output_stream"`
+	AppName      string            `json:"app_name"`
+	RawLabels    map[string]string `json:"raw_labels"`
 }
 }
 
 
 type GetLogResponse struct {
 type GetLogResponse struct {

+ 4 - 1
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -25,6 +25,7 @@ import Banner from "components/porter/Banner";
 import Button from "components/porter/Button";
 import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Icon from "components/porter/Icon";
 import save from "assets/save-01.svg";
 import save from "assets/save-01.svg";
+import LogsTab from "./tabs/LogsTab";
 
 
 // commented out tabs are not yet implemented
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -32,7 +33,7 @@ const validTabs = [
   // "activity",
   // "activity",
   // "events",
   // "events",
   "overview",
   "overview",
-  // "logs",
+  "logs",
   // "metrics",
   // "metrics",
   // "debug",
   // "debug",
   "environment",
   "environment",
@@ -245,6 +246,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           noBuffer
           noBuffer
           options={[
           options={[
             { label: "Overview", value: "overview" },
             { label: "Overview", value: "overview" },
+            { label: "Logs", value: "logs" },
             { label: "Environment", value: "environment" },
             { label: "Environment", value: "environment" },
             ...(latestProto.build
             ...(latestProto.build
               ? [
               ? [
@@ -272,6 +274,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           ))
           ))
           .with("environment", () => <Environment />)
           .with("environment", () => <Environment />)
           .with("settings", () => <Settings />)
           .with("settings", () => <Settings />)
+          .with("logs", () => <LogsTab />)
           .otherwise(() => null)}
           .otherwise(() => null)}
         <Spacer y={2} />
         <Spacer y={2} />
       </form>
       </form>

+ 35 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/LogsTab.tsx

@@ -0,0 +1,35 @@
+import { PorterApp } from "@porter-dev/api-contracts";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import React, { useMemo } from "react";
+import { useFormContext, useFormState } from "react-hook-form";
+import Logs from "../../validate-apply/logs/Logs"
+import {
+    defaultSerialized,
+    deserializeService,
+} from "lib/porter-apps/services";
+import Error from "components/porter/Error";
+import Button from "components/porter/Button";
+import { useLatestRevision } from "../LatestRevisionContext";
+
+const LogsTab: React.FC = () => {
+    const { projectId, clusterId, latestProto , deploymentTargetId} = useLatestRevision();
+
+    const appName = latestProto.name
+    const serviceNames = Object.keys(latestProto.services)
+
+    return (
+        <>
+            <Logs
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                serviceNames={serviceNames}
+                deploymentTargetId={deploymentTargetId}
+            />
+        </>
+    );
+};
+
+export default LogsTab;

+ 15 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/StyledLogs.tsx

@@ -55,6 +55,21 @@ const StyledLogs: React.FC<Props> = ({
                         </LogInnerPill>
                         </LogInnerPill>
                     </StyledLogsTableData>
                     </StyledLogsTableData>
                 )
                 )
+            case "service_name":
+                if (log.metadata?.raw_labels?.porter_run_service_name == null || log.metadata?.raw_labels?.porter_run_service_name === "") {
+                    return null;
+                }
+                return (
+                    <StyledLogsTableData width={"100px"}>
+                        <LogInnerPill
+                            color={"white"}
+                            key={index}
+                            onClick={() => filter.setValue(log.metadata?.raw_labels?.porter_run_service_name ?? GenericLogFilter.getDefaultOption("service_name").value)}
+                        >
+                            {log.metadata.raw_labels?.porter_run_service_name}
+                        </LogInnerPill>
+                    </StyledLogsTableData>
+                )
             default:
             default:
                 return null;
                 return null;
         }
         }

+ 19 - 6
dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts

@@ -10,7 +10,7 @@ export interface PorterLog {
     line: AnserJsonEntry[];
     line: AnserJsonEntry[];
     lineNumber: number;
     lineNumber: number;
     timestamp?: string;
     timestamp?: string;
-    metadata?: z.infer<typeof AgentLogMetadataSchema>;
+    metadata?: z.infer<typeof agentLogMetadataValidator>;
 }
 }
 
 
 export interface PaginationInfo {
 export interface PaginationInfo {
@@ -18,20 +18,31 @@ export interface PaginationInfo {
     nextCursor: string | null;
     nextCursor: string | null;
 }
 }
 
 
-const AgentLogMetadataSchema = z.object({
+const rawLabelsValidator = z.object({
+    porter_run_absolute_name: z.string().optional(),
+    porter_run_app_id: z.string().optional(),
+    porter_run_app_name: z.string().optional(),
+    porter_run_app_revision_id: z.string().optional(),
+    porter_run_service_name: z.string().optional(),
+    porter_run_service_type: z.string().optional(),
+});
+export type RawLabels = z.infer<typeof rawLabelsValidator>;
+
+const agentLogMetadataValidator = z.object({
     pod_name: z.string(),
     pod_name: z.string(),
     pod_namespace: z.string(),
     pod_namespace: z.string(),
     revision: z.string(),
     revision: z.string(),
     output_stream: z.string(),
     output_stream: z.string(),
     app_name: z.string(),
     app_name: z.string(),
+    raw_labels: rawLabelsValidator.optional(),
 });
 });
 
 
-export const AgentLogSchema = z.object({
+export const agentLogValidator = z.object({
     line: z.string(),
     line: z.string(),
     timestamp: z.string(),
     timestamp: z.string(),
-    metadata: AgentLogMetadataSchema.optional(),
+    metadata: agentLogMetadataValidator.optional(),
 });
 });
-export type AgentLog = z.infer<typeof AgentLogSchema>;
+export type AgentLog = z.infer<typeof agentLogValidator>;
 
 
 export interface GenericFilterOption {
 export interface GenericFilterOption {
     label: string;
     label: string;
@@ -42,7 +53,7 @@ export const GenericFilterOption = {
         return { label, value };
         return { label, value };
     }
     }
 }
 }
-export type LogFilterName = 'revision' | 'output_stream' | 'pod_name';
+export type LogFilterName = 'revision' | 'output_stream' | 'pod_name' | 'service_name';
 export interface GenericLogFilter {
 export interface GenericLogFilter {
     name: LogFilterName;
     name: LogFilterName;
     displayName: string;
     displayName: string;
@@ -57,6 +68,8 @@ export const GenericLogFilter = {
 
 
     getDefaultOption: (filterName: LogFilterName) => {
     getDefaultOption: (filterName: LogFilterName) => {
         switch (filterName) {
         switch (filterName) {
+            case 'service_name':
+                return GenericFilterOption.of('All', 'all');
             case 'revision':
             case 'revision':
                 return GenericFilterOption.of('All', 'all');
                 return GenericFilterOption.of('All', 'all');
             case 'output_stream':
             case 'output_stream':

+ 2 - 2
dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts

@@ -6,7 +6,7 @@ import Anser from "anser";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
 import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
 import { ChartType } from "shared/types";
 import { ChartType } from "shared/types";
-import { AgentLog, AgentLogSchema, Direction, PorterLog, PaginationInfo, GenericLogFilter, LogFilterName } from "./types";
+import { AgentLog, agentLogValidator, Direction, PorterLog, PaginationInfo, GenericLogFilter, LogFilterName } from "./types";
 import { Service } from "../../new-app-flow/serviceTypes";
 import { Service } from "../../new-app-flow/serviceTypes";
 
 
 const MAX_LOGS = 5000;
 const MAX_LOGS = 5000;
@@ -16,7 +16,7 @@ const QUERY_LIMIT = 1000;
 export const parseLogs = (logs: any[] = []): PorterLog[] => {
 export const parseLogs = (logs: any[] = []): PorterLog[] => {
   return logs.map((log: any, idx) => {
   return logs.map((log: any, idx) => {
     try {
     try {
-      const parsed: AgentLog = AgentLogSchema.parse(log);
+      const parsed: AgentLog = agentLogValidator.parse(log);
 
 
       // TODO Move log parsing to the render method
       // TODO Move log parsing to the render method
       const ansiLog = Anser.ansiToJson(parsed.line);
       const ansiLog = Anser.ansiToJson(parsed.line);

+ 537 - 0
dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx

@@ -0,0 +1,537 @@
+import React, {
+    useCallback,
+    useContext,
+    useEffect,
+    useRef,
+    useState,
+} from "react";
+
+import styled from "styled-components";
+
+import spinner from "assets/loading.gif";
+import api from "shared/api";
+import { useLogs } from "./utils";
+import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "../../expanded-app/logs/types";
+import dayjs, { Dayjs } from "dayjs";
+import Loading from "components/Loading";
+import _ from "lodash";
+import Banner from "components/porter/Banner";
+import LogSearchBar from "components/LogSearchBar";
+import LogQueryModeSelectionToggle from "components/LogQueryModeSelectionToggle";
+import Fieldset from "components/porter/Fieldset";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+import { Service } from "../../new-app-flow/serviceTypes";
+import LogFilterContainer from "../../expanded-app/logs/LogFilterContainer";
+import StyledLogs from "../../expanded-app/logs/StyledLogs";
+
+type Props = {
+    projectId: number;
+    clusterId: number;
+    appName: string;
+    serviceNames: string[];
+    deploymentTargetId: string;
+};
+
+const Logs: React.FC<Props> = ({
+    projectId,
+    clusterId,
+    appName,
+    serviceNames,
+    deploymentTargetId,
+}) => {
+    const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+    const [scrollToBottomEnabled, setScrollToBottomEnabled] = useState(true);
+    const [enteredSearchText, setEnteredSearchText] = useState("");
+    const [searchText, setSearchText] = useState("");
+    const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
+    const [notification, setNotification] = useState<string>();
+
+    const [hasPorterAgent, setHasPorterAgent] = useState(true);
+    const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+    const [isLoading, setIsLoading] = useState(true);
+    const [logsError, setLogsError] = useState<string | undefined>(undefined);
+
+    const [selectedFilterValues, setSelectedFilterValues] = useState<Record<LogFilterName, string>>({
+        service_name:  GenericLogFilter.getDefaultOption("service_name").value,
+        pod_name: "", // not supported yet
+        revision: "", // not supported yet
+        output_stream: GenericLogFilter.getDefaultOption("output_stream").value,
+    });
+
+    const isAgentVersionUpdated = (agentImage: string | undefined) => {
+        if (agentImage == null) {
+            return false;
+        }
+        const version = agentImage.split(":").pop();
+        if (version === "dev") {
+            return true;
+        }
+        //make sure version is above v3.1.3
+        if (version == null) {
+            return false;
+        }
+        const versionParts = version.split(".");
+        if (versionParts.length < 3) {
+            return false;
+        }
+        const major = parseInt(versionParts[0]);
+        const minor = parseInt(versionParts[1]);
+        const patch = parseInt(versionParts[2]);
+        if (major < 3) {
+            return false;
+        } else if (major > 3) {
+            return true;
+        }
+        if (minor < 1) {
+            return false;
+        } else if (minor > 1) {
+            return true;
+        }
+        return patch >= 7;
+    }
+
+    const [filters, setFilters] = useState<GenericLogFilter[]>([
+        {
+            name: "service_name",
+            displayName: "Service",
+            default: GenericLogFilter.getDefaultOption("service_name"),
+            options: serviceNames.map(s => {
+                return GenericFilterOption.of(s, s)
+            }) ?? [],
+            setValue: (value: string) => {
+                setSelectedFilterValues((s) => ({
+                    ...s,
+                    service_name: value,
+                }));
+            }
+        },
+        {
+            name: "output_stream",
+            displayName: "Output Stream",
+            default: GenericLogFilter.getDefaultOption("output_stream"),
+            options: serviceNames.map(s => {
+                return GenericFilterOption.of(s, s)
+            }) ?? [],
+            setValue: (value: string) => {
+                setSelectedFilterValues((s) => ({
+                    ...s,
+                    output_stream: value,
+                }));
+            }
+        },
+    ]);
+
+    const notify = (message: string) => {
+        setNotification(message);
+
+        setTimeout(() => {
+            setNotification(undefined);
+        }, 5000);
+    };
+
+    const { logs, refresh, moveCursor, paginationInfo } = useLogs(
+        projectId,
+        clusterId,
+        selectedFilterValues,
+        appName,
+        selectedFilterValues.service_name,
+        deploymentTargetId,
+        enteredSearchText,
+        notify,
+        setIsLoading,
+        selectedDate,
+    );
+
+    useEffect(() => {
+        if (!isLoading && scrollToBottomRef.current && scrollToBottomEnabled) {
+            const scrollPosition = scrollToBottomRef.current.offsetTop + scrollToBottomRef.current.offsetHeight - window.innerHeight;
+            scrollToBottomRef.current.scrollIntoView({
+                behavior: "smooth",
+                top: scrollPosition,
+            });
+        }
+    }, [isLoading, logs, scrollToBottomRef, scrollToBottomEnabled]);
+
+
+    const resetFilters = () => {
+        setSelectedFilterValues({
+            output_stream: GenericLogFilter.getDefaultOption("output_stream").value,
+            revision: "", // not supported yet
+            pod_name: "", // not supported yet
+            service_name: GenericLogFilter.getDefaultOption("service_name").value,
+        });
+    };
+
+    const onLoadPrevious = useCallback(() => {
+        if (!selectedDate) {
+            setSelectedDate(dayjs(logs[0].timestamp).toDate());
+            return;
+        }
+
+        moveCursor(Direction.backward);
+    }, [logs, selectedDate]);
+
+    const resetSearch = () => {
+        setSearchText("");
+        setEnteredSearchText("");
+        resetFilters();
+    };
+
+    const setSelectedDateIfUndefined = () => {
+        if (selectedDate == null) {
+            setSelectedDate(dayjs().toDate());
+        }
+    };
+
+    const renderContents = () => {
+        return (
+            <>
+                <FlexRow>
+                    <Flex>
+                        <LogSearchBar
+                            searchText={searchText}
+                            setSearchText={setSearchText}
+                            setEnteredSearchText={setEnteredSearchText}
+                            setSelectedDate={setSelectedDateIfUndefined}
+                        />
+                        <LogQueryModeSelectionToggle
+                            selectedDate={selectedDate}
+                            setSelectedDate={setSelectedDate}
+                            resetSearch={resetSearch}
+                        />
+                    </Flex>
+                    <Flex>
+                        <ScrollButton onClick={() => setScrollToBottomEnabled((s) => !s)}>
+                            <Checkbox checked={scrollToBottomEnabled}>
+                                <i className="material-icons">done</i>
+                            </Checkbox>
+                            Scroll to bottom
+                        </ScrollButton>
+                        <Spacer inline width="10px" />
+                        <ScrollButton
+                            onClick={() => {
+                                refresh();
+                            }}
+                        >
+                            <i className="material-icons">autorenew</i>
+                            Refresh
+                        </ScrollButton>
+                    </Flex>
+                </FlexRow>
+                <Spacer y={0.5} />
+                <>
+                    <LogFilterContainer
+                        filters={filters}
+                        selectedFilterValues={selectedFilterValues}
+                    />
+                    <Spacer y={0.5} />
+                </>
+                <LogsSectionWrapper>
+                    <StyledLogsSection>
+                        {isLoading && <Loading message="Waiting for logs..." />}
+                        {!isLoading && logs.length !== 0 && (
+                            <>
+                                <LoadMoreButton
+                                    active={
+                                        logs.length !== 0 && paginationInfo.previousCursor !== null
+                                    }
+                                    role="button"
+                                    onClick={onLoadPrevious}
+                                >
+                                    Load Previous
+                                </LoadMoreButton>
+                                <StyledLogs
+                                    logs={logs}
+                                    filters={filters}
+                                />
+                                <LoadMoreButton
+                                    active={selectedDate && logs.length !== 0}
+                                    role="button"
+                                    onClick={() => moveCursor(Direction.forward)}
+                                >
+                                    Load more
+                                </LoadMoreButton>
+                            </>
+                        )}
+                        {!isLoading && logs.length === 0 && selectedDate != null && (
+                            <Message>
+                                No logs found for this time range.
+                                <Highlight onClick={() => setSelectedDate(undefined)}>
+                                    <i className="material-icons">autorenew</i>
+                                    Reset
+                                </Highlight>
+                            </Message>
+                        )}
+                        {!isLoading && logs.length === 0 && selectedDate == null && (
+                            <Loading message="Waiting for logs..." />
+                        )}
+                        <div ref={scrollToBottomRef} />
+                    </StyledLogsSection>
+                    <NotificationWrapper
+                        key={JSON.stringify(logs)}
+                        active={!!notification}
+                    >
+                        <Banner>{notification}</Banner>
+                    </NotificationWrapper>
+                </LogsSectionWrapper>
+            </>
+        );
+    };
+
+    useEffect(() => {
+        // determine if the agent is installed properly - if not, start by render upgrade screen
+        checkForAgent();
+    }, []);
+
+    useEffect(() => {
+        if (!isPorterAgentInstalling) {
+            return;
+        }
+
+        const checkForAgentInterval = setInterval(checkForAgent, 3000);
+
+        return () => clearInterval(checkForAgentInterval);
+    }, [isPorterAgentInstalling]);
+
+    const checkForAgent = async () => {
+        const project_id = projectId
+        const cluster_id = clusterId
+
+        try {
+            const res = await api.detectPorterAgent("<token>", {}, { project_id, cluster_id });
+
+            setHasPorterAgent(true);
+
+            const agentImage = res.data?.image;
+            if (!isAgentVersionUpdated(agentImage)) {
+                notify("Porter agent is outdated. Please upgrade to see logs.");
+            }
+        } catch (err) {
+            if (err.response?.status === 404) {
+                setHasPorterAgent(false);
+            }
+        }
+    };
+
+    const installAgent = async () => {
+        const project_id = projectId;
+        const cluster_id = clusterId;
+
+        setIsPorterAgentInstalling(true);
+
+        api
+            .installPorterAgent("<token>", {}, { project_id, cluster_id })
+            .then()
+            .catch((err) => {
+                setIsPorterAgentInstalling(false);
+                console.log(err);
+            });
+    };
+
+    const triggerInstall = () => {
+        installAgent();
+    };
+
+    return isPorterAgentInstalling ? (
+        <Fieldset>
+            <Container row>
+                <Spinner src={spinner} />
+                <Spacer inline x={1} />
+                <Text color="helper">The Porter agent is being installed . . .</Text>
+            </Container>
+        </Fieldset>
+    ) : !hasPorterAgent ? (
+        <Fieldset>
+            <Text size={16}>We couldn't detect the Porter agent on your cluster</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+                In order to use the Logs tab, you need to install the Porter agent.
+            </Text>
+            <Spacer y={1} />
+            <Button onClick={() => triggerInstall()}>
+                <I className="material-icons">add</I> Install Porter agent
+            </Button>
+        </Fieldset>
+    ) : logsError ? (
+        <Fieldset>
+            <Container row>
+                <WarnI className="material-icons">warning</WarnI>
+                <Text color="helper">
+                    Porter encountered an error retrieving logs for this application.
+                </Text>
+            </Container>
+        </Fieldset>
+    ) : (
+        renderContents()
+    );
+};
+
+export default Logs;
+
+const I = styled.i`
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const WarnI = styled.i`
+  font-size: 18px;
+  display: flex;
+  align-items: center;
+  margin-right: 10px;
+  justify-content: center;
+  opacity: 0.6;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+`;
+
+const Checkbox = styled.div<{ checked: boolean }>`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  border-radius: 3px;
+  background: ${(props) => (props.checked ? "#ffffff22" : "#ffffff11")};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props) => (props.checked ? "" : "none")};
+  }
+`;
+
+const ScrollButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  font-size: 13px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 10px;
+  padding-left: 8px;
+  > i {
+    font-size: 16px;
+    margin-right: 5px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: #8590ff;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+`;
+
+const StyledLogsSection = styled.div`
+  width: 100%;
+  height: 600px;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  background: #000000;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const LoadMoreButton = styled.div<{ active: boolean }>`
+  width: 100%;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  padding-block: 10px;
+  background: #1f2023;
+  cursor: pointer;
+  font-family: monospace;
+`;
+
+const NotificationWrapper = styled.div<{ active?: boolean }>`
+  position: absolute;
+  bottom: 10px;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  left: 50%;
+  transform: translateX(-50%);
+  width: fit-content;
+  background: #101420;
+  z-index: 9999;
+
+  @keyframes bounceIn {
+    0% {
+      transform: translateZ(-1400px);
+      opacity: 0;
+    }
+    100% {
+      transform: translateZ(0);
+      opacity: 1;
+    }
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;

+ 407 - 0
dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts

@@ -0,0 +1,407 @@
+import dayjs, { Dayjs } from "dayjs";
+import _ from "lodash";
+import { useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import Anser from "anser";
+import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
+import { AgentLog, agentLogValidator, Direction, PorterLog, PaginationInfo, LogFilterName } from "../../expanded-app/logs/types";
+import { Service } from "../../new-app-flow/serviceTypes";
+
+const MAX_LOGS = 5000;
+const MAX_BUFFER_LOGS = 1000;
+const QUERY_LIMIT = 1000;
+
+export const parseLogs = (logs: any[] = []): PorterLog[] => {
+  return logs.map((log: any, idx) => {
+    try {
+      const parsed: AgentLog = agentLogValidator.parse(log);
+      // TODO Move log parsing to the render method
+      const ansiLog = Anser.ansiToJson(parsed.line);
+      return {
+        line: ansiLog,
+        lineNumber: idx + 1,
+        timestamp: parsed.timestamp,
+        metadata: parsed.metadata,
+      };
+    } catch (err) {
+      console.log(err)
+      return {
+        line: Anser.ansiToJson(log.toString()),
+        lineNumber: idx + 1,
+        timestamp: undefined,
+      }
+    }
+  });
+};
+
+export const useLogs = (
+    projectID: number,
+    clusterID: number,
+  selectedFilterValues: Record<LogFilterName, string>,
+  appName: string,
+  serviceName: string,
+  deploymentTargetId: string,
+  searchParam: string,
+  notify: (message: string) => void,
+  setLoading: (isLoading: boolean) => void,
+  // if setDate is set, results are not live
+  setDate?: Date,
+  timeRange?: {
+    startTime?: Dayjs,
+    endTime?: Dayjs,
+  },
+) => {
+  const isLive = !setDate;
+  const logsBufferRef = useRef<PorterLog[]>([]);
+  const [logs, setLogs] = useState<PorterLog[]>([]);
+  const [paginationInfo, setPaginationInfo] = useState<PaginationInfo>({
+    previousCursor: null,
+    nextCursor: null,
+  });
+
+  // if we are live:
+  // - start date is initially set to 2 weeks ago
+  // - the query has an end date set to current date
+  // - moving the cursor forward does nothing
+
+  // if we are not live:
+  // - end date is set to the setDate
+  // - start date is initially set to 2 weeks ago, but then gets set to the
+  //   result of the initial query
+  // - moving the cursor both forward and backward changes the start and end dates
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+
+  const updateLogs = (
+    newLogs: PorterLog[],
+    direction: Direction = Direction.forward
+  ) => {
+    // Nothing to update here
+    if (!newLogs.length) {
+      return;
+    }
+
+    setLogs((logs) => {
+      let updatedLogs = _.cloneDeep(logs);
+      /**
+       * If direction = Direction.forward, we want to append the new logs
+       * at the end of the current logs, else we want to append before the current logs
+       *
+       */
+      if (direction === Direction.forward) {
+        const lastLineNumber = updatedLogs.at(-1)?.lineNumber ?? 0;
+
+        updatedLogs.push(
+          ...newLogs.map((log, idx) => ({
+            ...log,
+            lineNumber: lastLineNumber + idx + 1,
+          }))
+        );
+
+        // For direction = Direction.forward, remove logs from the front
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+          updatedLogs = updatedLogs.slice(logsToBeRemoved);
+        }
+      } else {
+        updatedLogs = newLogs.concat(
+          updatedLogs.map((log) => ({
+            ...log,
+            lineNumber: log.lineNumber + newLogs.length,
+          }))
+        );
+
+        // For direction = Direction.backward, remove logs from the back
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+
+          updatedLogs = updatedLogs.slice(0, logsToBeRemoved);
+        }
+      }
+
+      return updatedLogs;
+    });
+  };
+
+  /**
+   * Flushes the logs buffer. If `discard` is true,
+   * it will update `current logs` before executing
+   * the flush operation
+   */
+  const flushLogsBuffer = (discard: boolean = false) => {
+    if (!discard) {
+      updateLogs(logsBufferRef.current ?? []);
+    }
+
+    logsBufferRef.current = [];
+  };
+
+  const pushLogs = (newLogs: PorterLog[]) => {
+    logsBufferRef.current.push(...newLogs);
+
+    if (logsBufferRef.current.length >= MAX_BUFFER_LOGS) {
+      flushLogsBuffer();
+    }
+  };
+
+  const setupWebsocket = (websocketKey: string) => {
+    const websocketBaseURL = `/api/projects/${projectID}/clusters/${clusterID}/apps/logs/loki`;
+
+    const searchParams = {
+      app_name: appName,
+      service_name: serviceName,
+      deployment_target_id: deploymentTargetId,
+      search_param: searchParam,
+    }
+
+    const q = new URLSearchParams(searchParams).toString();
+
+    const endpoint = `${websocketBaseURL}?${q}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        // Nothing to do here
+        if (evt.data == null) {
+          return;
+        }
+        const jsonData = evt.data.trim().split("\n")
+        const newLogs: any[] = [];
+        jsonData.forEach((data: string) => {
+          try {
+            const jsonLog = JSON.parse(data);
+            newLogs.push(jsonLog)
+          } catch (err) {
+            // TODO: better error handling
+            // console.log(err)
+          }
+        });
+        const newLogsParsed = parseLogs(newLogs);
+        pushLogs(newLogsParsed);
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
+  };
+
+  const queryLogs = async (
+    startDate: string,
+    endDate: string,
+    direction: Direction,
+    limit: number = QUERY_LIMIT
+  ): Promise<{
+    logs: PorterLog[];
+    previousCursor: string | null;
+    nextCursor: string | null;
+  }> => {
+    try {
+      const getLogsReq = {
+        app_name: appName,
+        service_name: serviceName,
+        deployment_target_id: deploymentTargetId,
+        search_param: searchParam,
+        start_range: startDate,
+        end_range: endDate,
+        limit,
+        direction,
+      };
+
+      const logsResp = await api.appLogs(
+          "<token>",
+          getLogsReq,
+          {
+            cluster_id: clusterID,
+            project_id: projectID,
+          }
+      )
+
+      if (logsResp.data == null) {
+        return {
+          logs: [],
+          previousCursor: null,
+          nextCursor: null,
+        };
+      }
+
+      const newLogs = parseLogs(logsResp.data.logs);
+      if (direction === Direction.backward) {
+        newLogs.reverse();
+      }
+      return {
+        logs: newLogs,
+        previousCursor:
+          // There are no more historical logs so don't set the previous cursor
+          newLogs.length < QUERY_LIMIT && direction == Direction.backward
+            ? null
+            : logsResp.data.backward_continue_time,
+        nextCursor: logsResp.data.forward_continue_time,
+      };
+    } catch {
+      return {
+        logs: [],
+        previousCursor: null,
+        nextCursor: null,
+      };
+    }
+  };
+
+  const refresh = async () => {
+    setLoading(true);
+    setLogs([]);
+    flushLogsBuffer(true);
+    const endDate = timeRange?.endTime != null ? timeRange.endTime : dayjs(setDate);
+    const oneDayAgo = timeRange?.startTime != null ? timeRange.startTime : endDate.subtract(1, "day");
+
+    const { logs: initialLogs, previousCursor, nextCursor } = await queryLogs(
+      oneDayAgo.toISOString(),
+      endDate.toISOString(),
+      Direction.backward
+    );
+
+    setPaginationInfo({
+      previousCursor,
+      nextCursor,
+    });
+
+    updateLogs(initialLogs);
+
+    if (!isLive && !initialLogs.length) {
+      notify(
+        "You have no logs for this time period. Try with a different time range."
+      );
+    }
+
+    closeAllWebsockets();
+    const suffix = Math.random().toString(36).substring(2, 15);
+    const websocketKey = `${appName}-${serviceName}-websocket-${suffix}`;
+
+    setLoading(false);
+
+    if (isLive) {
+      setupWebsocket(websocketKey);
+
+    }
+  };
+
+  const moveCursor = async (direction: Direction) => {
+    if (direction === Direction.backward) {
+      // we query by setting the endDate equal to the previous startDate, and setting the direction
+      // to "backward"
+      const refDate = paginationInfo.previousCursor ?? dayjs().toISOString();
+      const oneDayAgo = dayjs(refDate).subtract(1, "day");
+
+      const { logs: newLogs, previousCursor } = await queryLogs(
+        oneDayAgo.toISOString(),
+        refDate,
+        Direction.backward
+      );
+
+      const logsToUpdate = paginationInfo.previousCursor
+        ? newLogs.slice(0, -1)
+        : newLogs;
+
+      updateLogs(logsToUpdate, direction);
+
+      if (!logsToUpdate.length) {
+        notify("You have reached the beginning of the logs");
+      }
+
+      setPaginationInfo((paginationInfo) => ({
+        ...paginationInfo,
+        previousCursor,
+      }));
+    } else {
+      if (isLive) {
+        return;
+      }
+
+      // we query by setting the startDate equal to the previous endDate, setting the endDate equal to the
+      // current time, and setting the direction to "forward"
+      const refDate = paginationInfo.nextCursor ?? dayjs(setDate).toISOString();
+      const currDate = dayjs();
+
+      const { logs: newLogs, nextCursor } = await queryLogs(
+        refDate,
+        currDate.toISOString(),
+        Direction.forward
+      );
+
+      const logsToUpdate = paginationInfo.nextCursor
+        ? newLogs.slice(1)
+        : newLogs;
+
+      // If previously we had next cursor set, it is likely that the log might have a duplicate entry so we ignore the first line
+      updateLogs(logsToUpdate);
+
+      if (!logsToUpdate.length) {
+        notify("You are already at the latest logs");
+      }
+
+      setPaginationInfo((paginationInfo) => ({
+        ...paginationInfo,
+        nextCursor,
+      }));
+    }
+  };
+
+  useEffect(() => {
+    setLogs([]);
+    flushLogsBuffer(true);
+  }, []);
+
+  /**
+   * In some situations, we might never hit the limit for the max buffer size.
+   * An example is if the total logs for the pod < MAX_BUFFER_LOGS.
+   *
+   * For handling situations like this, we would want to force a flush operation
+   * on the buffer so that we dont have any stale logs
+   */
+  useEffect(() => {
+    /**
+     * We don't want users to wait for too long for the initial
+     * logs to appear. So we use a setTimeout for 1s to force-flush
+     * logs after 1s of load
+     */
+    setTimeout(flushLogsBuffer, 500);
+
+    const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000);
+
+    return () => clearInterval(flushLogsBufferInterval);
+  }, []);
+
+  useEffect(() => {
+    refresh();
+  }, [appName, serviceName, deploymentTargetId, searchParam, setDate, selectedFilterValues]);
+
+  useEffect(() => {
+    // if the streaming is no longer live, close all websockets
+    if (!isLive) {
+      closeAllWebsockets();
+    }
+  }, [isLive]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  return {
+    logs,
+    refresh,
+    moveCursor,
+    paginationInfo,
+  };
+};

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

@@ -278,6 +278,27 @@ const getLogsWithinTimeRange = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/applications/logs`
     `/api/projects/${project_id}/clusters/${cluster_id}/applications/logs`
 );
 );
 
 
+const appLogs = baseApi<
+    {
+        app_name: string;
+        service_name: string;
+        deployment_target_id: string;
+        limit: number;
+        start_range: string;
+        end_range: string;
+        search_param?: string;
+        direction?: string;
+    },
+    {
+        project_id: number;
+        cluster_id: number;
+    }
+>(
+    "GET",
+    ({ project_id, cluster_id }) =>
+        `/api/projects/${project_id}/clusters/${cluster_id}/apps/logs`
+);
+
 const getFeedEvents = baseApi<
 const getFeedEvents = baseApi<
   {},
   {},
   {
   {
@@ -2910,6 +2931,7 @@ export default {
   rollbackPorterApp,
   rollbackPorterApp,
   createSecretAndOpenGitHubPullRequest,
   createSecretAndOpenGitHubPullRequest,
   getLogsWithinTimeRange,
   getLogsWithinTimeRange,
+  appLogs,
   getFeedEvents,
   getFeedEvents,
   updateStackStep,
   updateStackStep,
   // -----------------------------------
   // -----------------------------------

+ 69 - 0
internal/kubernetes/porter_agent/v2/agent_server.go

@@ -309,6 +309,75 @@ func GetHistoricalLogs(
 	return logsResp, nil
 	return logsResp, nil
 }
 }
 
 
+// Logs returns logs from the porter agent matching the provided labels and other query parameters
+func Logs(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	req *types.LogRequest,
+) (*types.GetLogResponse, error) {
+	vals := make(map[string]string)
+
+	if req.Limit != 0 {
+		vals["limit"] = fmt.Sprintf("%d", req.Limit)
+	}
+
+	if req.StartRange != nil {
+		startVal, err := req.StartRange.MarshalText()
+		if err != nil {
+			return nil, err
+		}
+
+		vals["start_range"] = string(startVal)
+	}
+
+	if req.EndRange != nil {
+		endVal, err := req.EndRange.MarshalText()
+		if err != nil {
+			return nil, err
+		}
+
+		vals["end_range"] = string(endVal)
+	}
+
+	if req.SearchParam != "" {
+		vals["search_param"] = req.SearchParam
+	}
+
+	if req.Direction != "" {
+		vals["direction"] = req.Direction
+	}
+
+	if req.MatchLabels != nil {
+		json, err := json.Marshal(req.MatchLabels)
+		if err != nil {
+			return nil, fmt.Errorf("error marshalling match labels map to json: %w", err)
+		}
+		vals["match_labels_json"] = string(json)
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/logs",
+		vals,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	logsResp := &types.GetLogResponse{}
+
+	err = json.Unmarshal(rawQuery, logsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return logsResp, nil
+}
+
 func GetPodValues(
 func GetPodValues(
 	clientset kubernetes.Interface,
 	clientset kubernetes.Interface,
 	service *v1.Service,
 	service *v1.Service,