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

Logs Redesign, Live Activity Feed Refresh (#3311)

Feroze Mohideen 2 лет назад
Родитель
Сommit
f14a11638d
28 измененных файлов с 963 добавлено и 386 удалено
  1. 8 0
      api/server/handlers/cluster/detect_agent_installed.go
  2. 1 0
      api/types/agent.go
  3. 3 0
      dashboard/src/assets/canceled.svg
  4. 3 0
      dashboard/src/assets/failure.svg
  5. 1 0
      dashboard/src/assets/filter-outline-icon.svg
  6. BIN
      dashboard/src/assets/filter-outline-new.png
  7. 0 3
      dashboard/src/main/home/Home.tsx
  8. 31 15
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  9. 0 3
      dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx
  10. 51 31
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx
  11. 5 27
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx
  12. 8 5
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx
  13. 146 35
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx
  14. 2 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx
  15. 3 3
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx
  16. 3 3
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx
  17. 71 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/DeployEventFocusView.tsx
  18. 12 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx
  19. 4 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/PredeployEventFocusView.tsx
  20. 30 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts
  21. 5 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/utils.ts
  22. 45 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterComponent.tsx
  23. 72 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterContainer.tsx
  24. 147 201
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx
  25. 161 0
      dashboard/src/main/home/app-dashboard/expanded-app/logs/StyledLogs.tsx
  26. 43 1
      dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts
  27. 108 20
      dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts
  28. 0 31
      dashboard/src/shared/types.tsx

+ 8 - 0
api/server/handlers/cluster/detect_agent_installed.go

@@ -54,6 +54,7 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	res := &types.DetectAgentResponse{
 		Version:       getAgentVersionFromDeployment(depl),
 		ShouldUpgrade: false,
+		Image:         getImageFromDeployment(depl),
 	}
 
 	if res.Version != "v3" {
@@ -74,3 +75,10 @@ func getAgentVersionFromDeployment(depl *v1.Deployment) string {
 
 	return "v1"
 }
+
+func getImageFromDeployment(depl *v1.Deployment) string {
+	if len(depl.Spec.Template.Spec.Containers) > 0 {
+		return depl.Spec.Template.Spec.Containers[0].Image
+	}
+	return ""
+}

+ 1 - 0
api/types/agent.go

@@ -4,6 +4,7 @@ type DetectAgentResponse struct {
 	Version       string `json:"version"`
 	LatestVersion string `json:"latest_version"`
 	ShouldUpgrade bool   `json:"should_upgrade"`
+	Image         string `json:"image"`
 }
 
 type GetAgentStatusResponse struct {

+ 3 - 0
dashboard/src/assets/canceled.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 1L1 11M11 11L1 0.999998" stroke="#FFBF00" stroke-width="1" stroke-linecap="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/failure.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 1L1 11M11 11L1 0.999998" stroke="#FF6060" stroke-width="1" stroke-linecap="round"/>
+</svg>

+ 1 - 0
dashboard/src/assets/filter-outline-icon.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 410.73"><path fill-rule="nonzero" d="M335.62 410.73H164.96V239.89L13.31 59.96C7.33 52.52 3.19 44.79 1.29 37.65c-1.79-6.72-1.76-13.28.34-19.1 2.3-6.44 6.92-11.63 13.91-14.9C20.35 1.41 26.3.13 33.4.1L472.7.04c7.93-.29 14.95.96 20.74 3.44 7.02 2.97 12.28 7.87 15.44 14.17 3.05 6.1 3.93 13.27 2.34 21.06-1.5 7.24-5.17 15.11-11.32 23.16l-151.94 178.1v170.76h-12.34zm95.61-347.71-69.16 81.05-18.67-16.01 69.16-81.05 18.67 16.01zm-84.8 99.39-24.45 28.66-18.68-16.01 24.45-28.66 18.68 16.01zM189.64 386.06h133.64V235.48l3-8L480.45 46.79c3.77-4.97 5.94-9.39 6.7-13.04.45-2.2.35-3.95-.24-5.12-.49-.97-1.58-1.87-3.19-2.55-2.53-1.13-6.06-1.64-10.44-1.42l-439.84.06c-3.33-.05-5.83.41-7.5 1.18-.68.32-1.09.65-1.18.92-.32.91-.2 2.48.33 4.46 1.05 3.96 3.61 8.57 7.38 13.28L186.7 227.59l2.94 7.89v150.58z"/></svg>

BIN
dashboard/src/assets/filter-outline-new.png


+ 0 - 3
dashboard/src/main/home/Home.tsx

@@ -407,9 +407,6 @@ const Home: React.FC<Props> = (props) => {
             <Route path="/apps/new/app">
               <NewAppFlow />
             </Route>
-            <Route path="/apps/:appName/events/:eventId">
-              <ExpandedApp />
-            </Route>
             <Route path="/apps/:appName/:tab">
               <ExpandedApp />
             </Route>

+ 31 - 15
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState, useContext } from "react";
-import { RouteComponentProps, useParams, withRouter } from "react-router";
+import { RouteComponentProps, useLocation, useParams, withRouter } from "react-router";
 import styled from "styled-components";
 import yaml from "js-yaml";
 
@@ -30,7 +30,6 @@ import BuildSettingsTab from "../build-settings/BuildSettingsTab";
 import Button from "components/porter/Button";
 import Services from "../new-app-flow/Services";
 import { Service } from "../new-app-flow/serviceTypes";
-import ConfirmOverlay from "components/porter/ConfirmOverlay";
 import Fieldset from "components/porter/Fieldset";
 import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
@@ -46,9 +45,10 @@ import _ from "lodash";
 import AnimateHeight from "react-animate-height";
 import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
+import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import HelmValuesTab from "./HelmValuesTab";
-import PorterAppRevisionSection from "./PorterAppRevisionSection";
 import SettingsTab from "./SettingsTab";
+import PorterAppRevisionSection from "./PorterAppRevisionSection";
 
 type Props = RouteComponentProps & {};
 
@@ -62,6 +62,7 @@ const icons = [
 
 const validTabs = [
   "activity",
+  "events",
   "overview",
   "logs",
   "metrics",
@@ -118,7 +119,15 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [tempPorterApp, setTempPorterApp] = useState<PorterApp>();
   const [buildView, setBuildView] = useState<BuildMethod>("docker");
 
-  const { eventId, tab } = useParams<Params>();
+  const { tab } = useParams<Params>();
+  const { search } = useLocation();
+  const queryParams = new URLSearchParams(search);
+  const logFilterQueryParamOpts = {
+    revision: queryParams.get('version'),
+    output_stream: queryParams.get('output_stream'),
+    service: queryParams.get('service'),
+  }
+  const eventId = queryParams.get('event_id');
   const selectedTab: ValidTab = tab != null && validTabs.includes(tab) ? tab : DEFAULT_TAB;
 
   useEffect(() => {
@@ -165,7 +174,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           revision: revision,
         }
       );
-
       let preDeployChartData;
       // get the pre-deploy chart
       try {
@@ -184,7 +192,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       } catch (err) {
         // that's ok if there's an error, just means there is no pre-deploy chart
       }
-
       // update apps and release
       const newAppData = {
         app: resPorterApp?.data,
@@ -207,7 +214,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         )
         .then((res) => res.data);
-
       const populateEnvGroupsPromises = envGroups?.map((envGroup) =>
         api
           .getEnvGroup<PopulatedEnvGroup>(
@@ -223,11 +229,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           )
           .then((res) => res.data)
       );
-
       const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
-
       const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(newAppData.chart.name));
-
       setSyncedEnvGroups(filteredEnvGroups)
       setPorterJson(porterJson);
       setAppData(newAppData);
@@ -249,7 +252,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         newAppData.app.builder != null && newAppData.app.builder.includes("heroku")
       );
       setPorterYaml(finalPorterYaml);
-
       // Only check GHA status if no built image is set
       const hasBuiltImage = !!resChartData.data.config?.global?.image
         ?.repository;
@@ -324,7 +326,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         );
       });
-
       try {
         await Promise.all(removeApplicationToEnvGroupPromises);
       } catch (error) {
@@ -657,7 +658,18 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           chart={appData.chart}
           stackName={appData?.app?.name}
           appData={appData}
-          eventId={eventId}
+        />;
+      case "events":
+        if (eventId != null && eventId !== "") {
+          return <EventFocusView
+            eventId={eventId}
+            appData={appData}
+          />;
+        }
+        return <ActivityFeed
+          chart={appData.chart}
+          stackName={appData?.app?.name}
+          appData={appData}
         />;
       case "overview":
         return (
@@ -745,7 +757,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           deleteApplication={deletePorterApp}
         />;
       case "logs":
-        return <LogSection currentChart={appData.chart} services={services} />;
+        return <LogSection
+          currentChart={appData.chart}
+          services={services.filter(Service.isNonRelease)}
+          appName={appData.app.name}
+          filterOpts={logFilterQueryParamOpts}
+        />;
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
       case "debug":
@@ -779,7 +796,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           chart={appData.chart}
           stackName={appData?.app?.name}
           appData={appData}
-          eventId={eventId}
         />;
     }
   };

+ 0 - 3
dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx

@@ -151,9 +151,7 @@ class PorterAppRevisionSection extends Component<PropsType, StateType> {
 
     // Handle update of values.yaml
     componentDidUpdate(prevProps: PropsType) {
-        console.log('calling component did update')
         if (this.props.forceRefreshRevisions) {
-            console.log("force refresh revisions")
             this.props.refreshRevisionsOff();
 
             // Force refresh occurs on submit -> set current to newest
@@ -161,7 +159,6 @@ class PorterAppRevisionSection extends Component<PropsType, StateType> {
                 this.props.setRevision(this.state.revisions[0], true);
             });
         } else if (this.props.chart !== prevProps.chart) {
-            console.log("refresh history")
             this.refreshHistory();
         }
     }

+ 51 - 31
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -19,17 +19,17 @@ import _ from "lodash";
 import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Container from "components/porter/Container";
-import EventFocusView from "./events/focus-views/EventFocusView";
-import { PorterAppEvent } from "shared/types";
+import { PorterAppEvent } from "./events/types";
 
 type Props = {
   chart: any;
   stackName: string;
   appData: any;
-  eventId?: string;
 };
 
-const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) => {
+const EVENTS_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
   const [events, setEvents] = useState<PorterAppEvent[]>([]);
@@ -39,6 +39,7 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   const [numPages, setNumPages] = useState<number>(0);
   const [hasPorterAgent, setHasPorterAgent] = useState(false);
   const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+  const [shouldAnimate, setShouldAnimate] = useState(true);
 
   const getEvents = async () => {
     setLoading(true)
@@ -60,14 +61,38 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
       );
 
       setNumPages(res.data.num_pages);
-      setEvents(res.data.events?.map((event: any) => PorterAppEvent.toPorterAppEvent(event)) ?? []);
+      setEvents(res.data.events);
     } catch (err) {
       setError(err);
     } finally {
       setLoading(false);
+      setShouldAnimate(false);
     }
   };
 
+  const updateEvents = async () => {
+    if (!currentProject || !currentCluster) {
+      return;
+    }
+    try {
+      const res = await api.getFeedEvents(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          stack_name: stackName,
+          page,
+        }
+      );
+      setError(undefined)
+      setNumPages(res.data.num_pages);
+      setEvents(res.data.events?.map((event: any) => PorterAppEvent.toPorterAppEvent(event)) ?? []);
+    } catch (err) {
+      setError(err);
+    }
+  }
+
   useEffect(() => {
     const checkForAgent = async () => {
       const project_id = currentProject?.id;
@@ -92,10 +117,12 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
     if (!hasPorterAgent) {
       checkForAgent();
     } else {
+      const intervalId = setInterval(updateEvents, EVENTS_POLL_INTERVAL);
       getEvents();
+      return () => clearInterval(intervalId);
     }
 
-  }, [currentProject, currentCluster, hasPorterAgent, page, eventId]);
+  }, [currentProject, currentCluster, hasPorterAgent, page]);
 
   const installAgent = async () => {
     const project_id = currentProject?.id;
@@ -140,13 +167,6 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
     );
   }
 
-  if (eventId != null) {
-    return <EventFocusView
-      eventId={eventId}
-      appData={appData}
-    />;
-  }
-
   if (!loading && !hasPorterAgent) {
     return (
       <Fieldset>
@@ -178,13 +198,13 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   }
 
   return (
-    <StyledActivityFeed>
+    <StyledActivityFeed shouldAnimate={shouldAnimate}>
       {events.map((event, i) => {
         return (
           <EventWrapper isLast={i === events.length - 1} key={i}>
-            {i !== events.length - 1 && events.length > 1 && <Line />}
-            <Dot />
-            <Time>
+            {i !== events.length - 1 && events.length > 1 && <Line shouldAnimate={shouldAnimate} />}
+            <Dot shouldAnimate={shouldAnimate} />
+            <Time shouldAnimate={shouldAnimate}>
               <Text>{feedDate(event.created_at).split(", ")[0]}</Text>
               <Spacer x={0.5} />
               <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
@@ -224,26 +244,26 @@ const I = styled.i`
   margin-right: 5px;
 `;
 
-const Time = styled.div`
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+const Time = styled.div<{ shouldAnimate: boolean }>`
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
   width: 90px;
 `;
 
-const Line = styled.div`
+const Line = styled.div<{ shouldAnimate: boolean }>`
   width: 1px;
   height: calc(100% + 30px);
   background: #414141;
   position: absolute;
   left: 3px;
   top: 36px;
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
 `;
 
-const Dot = styled.div`
+const Dot = styled.div<{ shouldAnimate: boolean }>`
   width: 7px;
   height: 7px;
   background: #fff;
@@ -251,9 +271,9 @@ const Dot = styled.div`
   position: absolute;
   left: 0;
   top: 36px;
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
 `;
 
 const EventWrapper = styled.div<{
@@ -266,9 +286,9 @@ const EventWrapper = styled.div<{
   margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
 `;
 
-const StyledActivityFeed = styled.div`
+const StyledActivityFeed = styled.div<{ shouldAnimate: boolean }>`
   width: 100%;
-  animation: fadeIn 0.3s 0s;
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0s;"}
   @keyframes fadeIn {
     from {
       opacity: 0;

+ 5 - 27
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx

@@ -7,15 +7,14 @@ import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
 
-import { PorterAppEvent } from "shared/types";
 import { StyledEventCard } from "./EventCard";
-import styled from "styled-components";
 import AppEventModal from "../../../status/AppEventModal";
 import { readableDate } from "shared/string_utils";
 import dayjs from "dayjs";
 import Anser from "anser";
 import api from "shared/api";
 import { Direction } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -72,17 +71,15 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
       <Container row spaced>
         <Container row>
           <Icon height="16px" src={app_event} />
-          <Spacer inline width="10px" />
+          <Spacer inline x={1} />
           <Text>{event.metadata.summary}</Text>
         </Container>
       </Container>
       <Spacer y={0.5} />
       <Container row spaced>
-        <TempWrapper>
-          <Link onClick={getAppLogs} hasunderline>
-            View details
-          </Link>
-        </TempWrapper>
+        <Link onClick={getAppLogs} hasunderline>
+          View details
+        </Link>
       </Container>
       {showModal && (
         <AppEventModal
@@ -99,22 +96,3 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
 
 export default AppEventCard;
 
-const TempWrapper = styled.div`
-  margin-top: -3px;
-`;
-
-const ViewDetailsButton = styled.div<{ width?: string }>`
-  border-radius: 5px;
-  height: 30px;
-  font-size: 13px;
-  color: white;
-  display: flex;
-  align-items: center;
-  padding: 0px 10px;
-  background: #ffffff11;
-  border: 1px solid #aaaabb33;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-`;

+ 8 - 5
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 
 import build from "assets/build.png";
@@ -11,11 +11,14 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
+import api from "shared/api";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
-import { PorterAppEvent } from "shared/types";
+import JSZip from "jszip";
+import Anser, { AnserJsonEntry } from "anser";
 import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -30,7 +33,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       case "FAILED":
         return <Text color="#FF6060">Build failed</Text>;
       default:
-        return <Text color="#aaaabb66">Build in progress...</Text>;
+        return <Text color="helper">Build in progress...</Text>;
     }
   };
 
@@ -41,7 +44,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       case "FAILED":
         return (
           <Wrapper>
-            <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+            <Link to={`/apps/${appData.app.name}/events?event_id=${event.id}`} hasunderline>
               <Container row>
                 <Icon src={document} height="10px" />
                 <Spacer inline width="5px" />
@@ -91,7 +94,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           <Spacer inline x={1} />

+ 146 - 35
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx

@@ -1,43 +1,144 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 
 
 import deploy from "assets/deploy.png";
-import refresh from "assets/refresh.png";
+import document from "assets/document.svg";
 
 import Text from "components/porter/Text";
 import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
-import Modal from "components/porter/Modal";
-import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon } from '../utils';
+import { getStatusIcon } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import styled from "styled-components";
-import Button from "components/porter/Button";
-import api from "shared/api";
 import Link from "components/porter/Link";
 import ChangeLogModal from "../../../ChangeLogModal";
+import { PorterAppDeployEvent } from "../types";
+import AnimateHeight from "react-animate-height";
 
 type Props = {
-  event: PorterAppEvent;
+  event: PorterAppDeployEvent;
   appData: any;
 };
 
 const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
-  const [loading, setLoading] = useState<boolean>(false);
   const [diffModalVisible, setDiffModalVisible] = useState(false);
   const [revertModalVisible, setRevertModalVisible] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(false);
 
-  const renderStatusText = (event: PorterAppEvent) => {
+  const renderStatusText = () => {
     switch (event.status) {
       case "SUCCESS":
-        return event?.metadata?.image_tag ? <Text color="#68BF8B">Deployed <Code>{event?.metadata?.image_tag}</Code></Text> : <Text color="#68BF8B">Deployment successful</Text>;
+        return event.metadata.image_tag != null ?
+          event.metadata.service_status != null ?
+            <Text color="#68BF8B">
+              Deployed <Code>{event.metadata.image_tag}</Code> to {Object.keys(event.metadata.service_status).length} service{Object.keys(event.metadata.service_status).length === 1 ? "" : "s"}
+            </Text> :
+            <Text color="#68BF8B">
+              Deployed <Code>{event.metadata.image_tag}</Code>
+            </Text>
+          :
+          <Text color="#68BF8B">
+            Deployment successful
+          </Text>;
       case "FAILED":
-        return <Text color="#FF6060">Deployment failed</Text>;
+        if (event.metadata.service_status != null) {
+          let failedServices = 0;
+          for (const key in event.metadata.service_status) {
+            if (event.metadata.service_status[key] === "FAILED") {
+              failedServices++;
+            }
+          }
+          return (
+            <Text color="#FF6060">
+              Failed to deploy <Code>{event.metadata.image_tag}</Code> to {failedServices} service{failedServices === 1 ? "" : "s"}
+            </Text>
+          );
+        } else {
+          return (
+            <Text color="#FF6060">
+              Deployment failed
+            </Text>
+          );
+        }
+      case "CANCELED":
+        if (event.metadata.service_status != null) {
+          let canceledServices = 0;
+          for (const key in event.metadata.service_status) {
+            if (event.metadata.service_status[key] === "CANCELED") {
+              canceledServices++;
+            }
+          }
+          return (
+            <Text color="#FFBF00">
+              Canceled deploy of <Code>{event.metadata.image_tag}</Code> to {canceledServices} service{canceledServices === 1 ? "" : "s"}
+            </Text>
+          );
+        } else {
+          return (
+            <Text color="#FFBF00">
+              Deployment canceled
+            </Text>
+          );
+        }
       default:
-        return <Text color="#aaaabb66">Deployment in progress...</Text>;
+        if (event.metadata.service_status != null) {
+          return (
+            <Text color="helper">
+              Deploying <Code>{event.metadata.image_tag}</Code> to {Object.keys(event.metadata.service_status).length} service{Object.keys(event.metadata.service_status).length === 1 ? "" : "s"}...
+            </Text>
+          );
+        } else {
+          return (
+            <Text color="helper">
+              Deploying <Code>{event.metadata.image_tag}</Code> to {Object.keys(event.metadata.service_status).length} service{Object.keys(event.metadata.service_status).length === 1 ? "" : "s"}...
+            </Text>
+          );
+        }
     }
   };
+
+  const renderServiceStatus = () => {
+    const serviceStatus = event.metadata.service_status;
+    if (Object.keys(serviceStatus).length === 0) {
+      return (
+        <Container row>
+          <Text color="helper">No services found.</Text>
+        </Container>
+      );
+    }
+
+    return <ServiceStatusesContainer>
+      {Object.keys(serviceStatus).map((key) => {
+        return (
+          <Container key={key} row>
+            <Spacer inline x={1} />
+            <Container row>
+              <ServiceStatusContainer>
+                <Text>{key}</Text>
+              </ServiceStatusContainer>
+              <Spacer inline x={1} />
+              <ServiceStatusContainer>
+                <Icon height="12px" src={getStatusIcon(serviceStatus[key])} />
+                <Spacer inline x={0.5} />
+                <Text color="helper">{serviceStatus[key] === "PROGRESSING" ? "DEPLOYING" : serviceStatus[key]}</Text>
+              </ServiceStatusContainer>
+              <Spacer inline x={1} />
+              <ServiceStatusContainer>
+                <Link
+                  to={`/apps/${appData.app.name}/logs?version=${event.metadata.revision}&service=${key}`}
+                >
+                  <Icon height="12px" src={document} />
+                  <Spacer inline x={0.5} />
+                  Live logs
+                </Link>
+              </ServiceStatusContainer>
+            </Container>
+          </Container>
+        );
+      })}
+    </ServiceStatusesContainer>
+  }
   return (
     <StyledEventCard>
       <Container row spaced>
@@ -50,23 +151,33 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
-          {renderStatusText(event)}
-          {appData?.chart?.version !== event.metadata?.revision && (
+          {renderStatusText()}
+          {event.metadata.service_status != null &&
+            <>
+              <Spacer inline x={1} />
+              <TempWrapper>
+                <Link hasunderline onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
+                  View service status
+                </Link>
+              </TempWrapper>
+            </>
+          }
+          {appData?.chart?.version !== event.metadata.revision && (
             <>
               <Spacer inline x={1} />
               <TempWrapper>
                 <Link hasunderline onClick={() => setRevertModalVisible(true)}>
-                  Revert to version {event?.metadata?.revision}
+                  Revert to version {event.metadata.revision}
                 </Link>
 
               </TempWrapper>
             </>
           )}
-          <Spacer inline width="15px" />
+          <Spacer inline x={1} />
           <TempWrapper>
-            {event?.metadata?.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
+            {event.metadata.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
               View changes
             </Link>)}
             {diffModalVisible && (
@@ -91,7 +202,10 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
           </TempWrapper>
         </Container>
       </Container>
-
+      <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
+        <Spacer y={0.5} />
+        {event.metadata.service_status != null && renderServiceStatus()}
+      </AnimateHeight>
     </StyledEventCard>
   );
 };
@@ -107,20 +221,17 @@ const Code = styled.span`
   font-family: monospace;
 `;
 
-const RevertButton = styled.div<{ width?: string }>`
-  border-radius: 5px;
-  height: 30px;
-  font-size: 13px;
-  color: white;
+const ServiceStatusContainer = styled.div`
   display: flex;
-  justify-content: center;
-  align-items: center;
-  padding: 0px 10px;
-  background: #ffffff11;
-  border: 1px solid #aaaabb33;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-  width: 92px;
+  align-items: center;  
+  width: 150px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 `;
+
+const ServiceStatusesContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+ `; 

+ 2 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx

@@ -1,11 +1,11 @@
 import React from "react";
 import styled from "styled-components";
 
-import { PorterAppEvent, PorterAppEventType } from "shared/types";
 import BuildEventCard from "./BuildEventCard";
 import PreDeployEventCard from "./PreDeployEventCard";
 import AppEventCard from "./AppEventCard";
 import DeployEventCard from "./DeployEventCard";
+import { PorterAppDeployEvent, PorterAppEvent, PorterAppEventType } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -20,7 +20,7 @@ const EventCard: React.FC<Props> = ({ event, appData }) => {
       case PorterAppEventType.BUILD:
         return <BuildEventCard event={event} appData={appData} />;
       case PorterAppEventType.DEPLOY:
-        return <DeployEventCard event={event} appData={appData} />;
+        return <DeployEventCard event={event as PorterAppDeployEvent} appData={appData} />;
       case PorterAppEventType.PRE_DEPLOY:
         return <PreDeployEventCard event={event} appData={appData} />;
       default:

+ 3 - 3
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -11,11 +11,11 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
 
-import { PorterAppEvent } from "shared/types";
 import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import Link from "components/porter/Link";
 import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -51,14 +51,14 @@ const PreDeployEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           {(event.status !== "SUCCESS") &&
             <>
               <Spacer inline x={1} />
               <Wrapper>
-                <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+                <Link to={`/apps/${appData.app.name}/events?event_id=${event.id}`} hasunderline>
                   <Container row>
                     <Icon src={document} height="10px" />
                     <Spacer inline width="5px" />

+ 3 - 3
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx

@@ -6,12 +6,12 @@ import styled from "styled-components";
 import Anser, { AnserJsonEntry } from "anser";
 import JSZip from "jszip";
 import dayjs from "dayjs";
-import { PorterAppEvent } from "shared/types";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { getDuration } from "../utils";
 import Link from "components/porter/Link";
 import { PorterLog } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
 
 type Props = {
     event: PorterAppEvent;
@@ -24,7 +24,7 @@ const BuildFailureEventFocusView: React.FC<Props> = ({
 }) => {
     const [logs, setLogs] = useState<PorterLog[]>([]);
     const [isLoading, setIsLoading] = useState<boolean>(true);
-    const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+    const scrollToBottomRef = useRef<HTMLDivElement>(null);
 
     useEffect(() => {
         if (!isLoading && scrollToBottomRef.current) {
@@ -118,7 +118,7 @@ const BuildFailureEventFocusView: React.FC<Props> = ({
 
     useEffect(() => {
         getBuildLogs();
-    }, [event]);
+    }, []);
 
     return (
         <>

+ 71 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/DeployEventFocusView.tsx

@@ -0,0 +1,71 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppDeployEvent } from "../types";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    event: PorterAppDeployEvent;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts
+};
+
+const DeployEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+    filterOpts,
+}) => {
+    const renderHeaderText = () => {
+        switch (event.status) {
+            case "SUCCESS":
+                return <Text color="#68BF8B" size={16}>Deploy succeeded</Text>;
+            case "FAILED":
+                return <Text color="#FF6060" size={16}>Deploy failed</Text>;
+            case "CANCELED":
+                return <Text color="#FFBF00" size={16}>Deploy canceled</Text>;
+            default:
+                return (
+                    <Container row>
+                        <Icon height="16px" src={loading} />
+                        <Spacer inline width="10px" />
+                        <Text size={16}>Deploy in progress...</Text>
+                    </Container>
+                );
+        }
+    };
+
+    const renderDurationText = () => {
+        switch (event.status) {
+            case "PROGRESSING":
+                return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+            default:
+                return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+        }
+    }
+
+    return (
+        <>
+            <AppearingView>
+                {renderHeaderText()}
+            </AppearingView>
+            <Spacer y={0.5} />
+            {renderDurationText()}
+            <Spacer y={0.5} />
+            <LogSection
+                currentChart={appData.chart}
+                appName={appData.app.name}
+                filterOpts={filterOpts}
+            />
+        </>
+    );
+};
+
+export default DeployEventFocusView;

+ 12 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx

@@ -4,22 +4,26 @@ import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
-import { PorterAppEvent } from "shared/types";
 import Link from "components/porter/Link";
 import BuildFailureEventFocusView from "./BuildFailureEventFocusView";
 import PreDeployEventFocusView from "./PredeployEventFocusView";
 import _ from "lodash";
+import { PorterAppDeployEvent, PorterAppEvent } from "../types";
+import DeployEventFocusView from "./DeployEventFocusView";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
 
 type Props = {
     eventId: string;
     appData: any;
+    filterOpts?: LogFilterQueryParamOpts;
 };
 
-const EVENT_POLL_INTERVAL = 15000; // poll every 15 seconds
+const EVENT_POLL_INTERVAL = 5000; // poll every 5 seconds
 
 const EventFocusView: React.FC<Props> = ({
     eventId,
     appData,
+    filterOpts,
 }) => {
     const { currentProject, currentCluster } = useContext(Context);
     const [event, setEvent] = useState<PorterAppEvent | null>(null);
@@ -59,6 +63,12 @@ const EventFocusView: React.FC<Props> = ({
                 return <BuildFailureEventFocusView event={event} appData={appData} />
             case "PRE_DEPLOY":
                 return <PreDeployEventFocusView event={event} appData={appData} />
+            case "DEPLOY":
+                return <DeployEventFocusView
+                    event={event as PorterAppDeployEvent}
+                    appData={appData}
+                    filterOpts={filterOpts}
+                />
             default:
                 return null
         }

+ 4 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/PredeployEventFocusView.tsx

@@ -1,7 +1,6 @@
 import Spacer from "components/porter/Spacer";
 import React from "react";
 import dayjs from "dayjs";
-import { PorterAppEvent } from "shared/types";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { getDuration } from "../utils";
@@ -10,6 +9,7 @@ import { AppearingView } from "./EventFocusView";
 import Icon from "components/porter/Icon";
 import loading from "assets/loading.gif";
 import Container from "components/porter/Container";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -30,7 +30,8 @@ const PreDeployEventFocusView: React.FC<Props> = ({
         return (
           <Container row>
             <Icon height="16px" src={loading} />
-            <Spacer inline width="10px" /><Text size={16}>Pre-deploy in progress...</Text>
+            <Spacer inline width="10px" />
+            <Text size={16}>Pre-deploy in progress...</Text>
           </Container>
         );
     }
@@ -60,6 +61,7 @@ const PreDeployEventFocusView: React.FC<Props> = ({
           endTime: event.metadata.end_time != null ? dayjs(event.metadata.end_time).add(1, 'minute') : undefined,
         }}
         showFilter={false}
+        appName={appData.app.name}
       />
     </>
   );

+ 30 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts

@@ -0,0 +1,30 @@
+export enum PorterAppEventType {
+    BUILD = "BUILD",
+    DEPLOY = "DEPLOY",
+    APP_EVENT = "APP_EVENT",
+    PRE_DEPLOY = "PRE_DEPLOY",
+}
+export interface PorterAppEvent {
+    created_at: string;
+    updated_at: string;
+    id: string;
+    status: string;
+    type: PorterAppEventType;
+    type_source: string;
+    porter_app_id: number;
+    metadata: any;
+}
+export const PorterAppEvent = {
+    toPorterAppEvent: (data: any): PorterAppEvent => {
+        return {
+            created_at: data.created_at ?? "",
+            updated_at: data.updated_at ?? "",
+            id: data.id ?? "",
+            status: data.status ?? "",
+            type: data.type ?? "",
+            type_source: data.type_source ?? "",
+            porter_app_id: data.porter_app_id ?? "",
+            metadata: data.metadata ?? {},
+        };
+    }
+}

+ 5 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/utils.ts

@@ -1,8 +1,9 @@
-import { PorterAppEvent } from "shared/types";
 import healthy from "assets/status-healthy.png";
-import failure from "assets/failure.png";
+import failure from "assets/failure.svg";
 import loading from "assets/loading.gif";
+import canceled from "assets/canceled.svg"
 import api from "shared/api";
+import { PorterAppEvent } from "./types";
 
 export const getDuration = (event: PorterAppEvent): string => {
     const startTimeStamp = new Date(event.metadata.start_time ?? event.created_at).getTime();
@@ -44,6 +45,8 @@ export const getStatusIcon = (status: string) => {
             return failure;
         case "PROGRESSING":
             return loading;
+        case "CANCELED":
+            return canceled;
         default:
             return loading;
     }

+ 45 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterComponent.tsx

@@ -0,0 +1,45 @@
+import Text from "components/porter/Text";
+import React from "react";
+import styled from "styled-components";
+import { GenericLogFilter } from "./types";
+import Spacer from "components/porter/Spacer";
+import Select from "components/porter/Select";
+
+type Props = {
+    filter: GenericLogFilter;
+    selectedValue: string;
+};
+
+const LogFilterComponent: React.FC<Props> = ({
+    filter,
+    selectedValue,
+}) => {
+    return (
+        <StyledLogFilterComponent>
+            <Text>{filter.displayName}</Text>
+            <Spacer inline x={0.5} />
+            <Select
+                options={[filter.default, ...filter.options]}
+                height={"30px"}
+                value={selectedValue}
+                setValue={filter.setValue}
+            />
+        </StyledLogFilterComponent>
+    );
+};
+
+export default LogFilterComponent;
+
+const StyledLogFilterComponent = styled.div`
+    display: flex;
+    align-items: center;
+    animation: fadeIn 0.3s 0s;
+    @keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+    }
+`;

+ 72 - 0
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogFilterContainer.tsx

@@ -0,0 +1,72 @@
+import React from "react";
+
+import styled from "styled-components";
+import filterOutline from "assets/filter-outline-icon.svg";
+import filterOutlineWhite from "assets/filter-outline-white.svg";
+import { GenericLogFilter, LogFilterName } from "./types";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import LogFilterComponent from "./LogFilterComponent";
+
+type Props = {
+    filters: GenericLogFilter[];
+    selectedFilterValues: Record<LogFilterName, string>;
+};
+
+const LogFilterContainer: React.FC<Props> = (props) => {
+    const getIcon = () => {
+        if (props.filters.every((filter) => GenericLogFilter.isDefault(filter, props.selectedFilterValues[filter.name]))) {
+            return filterOutline;
+        }
+        return filterOutlineWhite;
+    }
+
+    const renderFilters = () => {
+        return (
+            <FiltersContainer>
+                {props.filters.map((filter, i) => {
+                    return <LogFilterComponent
+                        key={i}
+                        filter={filter}
+                        selectedValue={props.selectedFilterValues[filter.name]}
+                    />
+                })}
+            </FiltersContainer>
+        )
+    }
+
+    return (
+        <StyledLogFilterContainer>
+            <Icon src={getIcon()} height={"16px"} />
+            <Spacer inline x={1} />
+            <Bar />
+            <Spacer inline x={1} />
+            {renderFilters()}
+        </StyledLogFilterContainer>
+    );
+};
+
+export default LogFilterContainer;
+
+const Bar = styled.div`
+  width: 1px;
+  height: calc(18px);
+  background: #494b4f;
+`;
+
+const StyledLogFilterContainer = styled.div`
+  font-size: 13px;
+  padding: 10px;
+  background: ${(props) => props.theme.fg};
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  border: 1px solid #494b4f;
+  width: fit-content;
+`;
+
+const FiltersContainer = styled.div`
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+`

+ 147 - 201
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx

@@ -7,16 +7,12 @@ import React, {
 } from "react";
 
 import styled from "styled-components";
-import RadioFilter from "components/RadioFilter";
 
 import spinner from "assets/loading.gif";
-import filterOutline from "assets/filter-outline.svg";
-import filterOutlineWhite from "assets/filter-outline-white.svg";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { useLogs } from "./utils";
-import { Direction } from "./types";
-import Anser from "anser";
+import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "./types";
 import dayjs, { Dayjs } from "dayjs";
 import Loading from "components/Loading";
 import _ from "lodash";
@@ -30,35 +26,31 @@ 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 "./LogFilterContainer";
+import StyledLogs from "./StyledLogs";
 
 type Props = {
-  currentChart?: ChartType;
+  appName: string;
+  currentChart: ChartType;
   services?: Service[];
   timeRange?: {
     startTime?: Dayjs;
     endTime?: Dayjs;
   };
   showFilter?: boolean;
-};
-
-type PodFilter = {
-  podName: string;
-  podType: string;
+  filterOpts?: LogFilterQueryParamOpts;
 };
 
 const LogSection: React.FC<Props> = ({
   currentChart,
   services,
   timeRange,
+  appName,
+  filterOpts,
   showFilter = true,
 }) => {
   const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
   const { currentProject, currentCluster } = useContext(Context);
-  const [podFilter, setPodFilter] = useState<PodFilter>({
-    podName: "",
-    podType: "",
-  });
-  const [podFilterOpts, setPodFilterOpts] = useState<PodFilter[]>([]);
   const [scrollToBottomEnabled, setScrollToBottomEnabled] = useState(true);
   const [enteredSearchText, setEnteredSearchText] = useState("");
   const [searchText, setSearchText] = useState("");
@@ -69,6 +61,101 @@ const LogSection: React.FC<Props> = ({
   const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
   const [isLoading, setIsLoading] = useState(true);
   const [logsError, setLogsError] = useState<string | undefined>(undefined);
+  const getSelectorFromServiceQueryParam = (serviceName: string | null | undefined) => {
+    if (serviceName == null) {
+      return undefined;
+    }
+    const match = services?.find(s => s.name == serviceName);
+    if (match == null) {
+      return undefined;
+    }
+    return `${match.name}-${match.type == "worker" ? "wkr" : match.type}`;
+  }
+  const [selectedFilterValues, setSelectedFilterValues] = useState<Record<LogFilterName, string>>({
+    revision: filterOpts?.revision ?? GenericLogFilter.getDefaultOption("revision").value,
+    output_stream: filterOpts?.output_stream ?? GenericLogFilter.getDefaultOption("output_stream").value,
+    pod_name: getSelectorFromServiceQueryParam(filterOpts?.service) ?? GenericLogFilter.getDefaultOption("pod_name").value,
+  });
+
+  const createVersionOptions = (number: number) => {
+    return Array.from({ length: number }, (_, index) => {
+      const version = index + 1;
+      const label = version === number ? `Version ${version} (latest)` : `Version ${version}`;
+      const value = version.toString();
+      return GenericFilterOption.of(label, value);
+    }).reverse().slice(0, 3);
+  }
+
+  const isAgentVersionUpdated = (agentImage: string | undefined) => {
+    if (agentImage == null) {
+      return false;
+    }
+    const version = agentImage.split(":").pop();
+    //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;
+    }
+    if (minor < 1) {
+      return false;
+    }
+    if (patch < 3) {
+      return false;
+    }
+    return true;
+  }
+
+  const [filters, setFilters] = useState<GenericLogFilter[]>(showFilter ? [
+    {
+      name: "pod_name",
+      displayName: "Service",
+      default: GenericLogFilter.getDefaultOption("pod_name"),
+      options: services?.map(s => {
+        return GenericFilterOption.of(s.name, `${s.name}-${s.type == "worker" ? "wkr" : s.type}`)
+      }) ?? [],
+      setValue: (value: string) => {
+        setSelectedFilterValues((s) => ({
+          ...s,
+          pod_name: value,
+        }));
+      }
+    },
+    {
+      name: "revision",
+      displayName: "Version",
+      default: GenericLogFilter.getDefaultOption("revision"),
+      options: currentChart != null ? createVersionOptions(currentChart.version) : [],
+      setValue: (value: string) => {
+        setSelectedFilterValues((s) => ({
+          ...s,
+          revision: value,
+        }));
+      }
+    },
+    {
+      name: "output_stream",
+      displayName: "Output Stream",
+      default: GenericLogFilter.getDefaultOption("output_stream"),
+      options: [
+        GenericFilterOption.of("stderr", "stderr"),
+      ],
+      setValue: (value: string) => {
+        setSelectedFilterValues((s) => ({
+          ...s,
+          output_stream: value,
+        }));
+      }
+    },
+  ] : []);
 
   const notify = (message: string) => {
     setNotification(message);
@@ -78,12 +165,10 @@ const LogSection: React.FC<Props> = ({
     }, 5000);
   };
 
-  const namespace = currentChart == null ? "" : currentChart.namespace;
-
   const { logs, refresh, moveCursor, paginationInfo } = useLogs(
-    podFilter.podName,
-    podFilter.podType,
-    namespace,
+    selectedFilterValues,
+    appName,
+    currentChart == null ? "" : currentChart.namespace,
     enteredSearchText,
     notify,
     currentChart,
@@ -92,20 +177,6 @@ const LogSection: React.FC<Props> = ({
     timeRange,
   );
 
-  const refreshPodLogsValues = async () => {
-    if (currentChart == null || services == null) {
-      setPodFilterOpts([]);
-    } else {
-      const podList = services.map((service: Service) => {
-        return {
-          podName: service.name,
-          podType: service.type == "worker" ? "wkr" : service.type,
-        };
-      });
-      setPodFilterOpts(podList);
-    }
-  };
-
   useEffect(() => {
     if (!isLoading && scrollToBottomRef.current && scrollToBottomEnabled) {
       const scrollPosition = scrollToBottomRef.current.offsetTop + scrollToBottomRef.current.offsetHeight - window.innerHeight;
@@ -116,71 +187,15 @@ const LogSection: React.FC<Props> = ({
     }
   }, [isLoading, logs, scrollToBottomRef, scrollToBottomEnabled]);
 
-  useEffect(() => {
-    if (podFilter.podName != "") {
-      setSelectedDateIfUndefined();
-      return;
-    }
-  }, [podFilter]);
-
-  useEffect(() => {
-    if (selectedDate == null) {
-      resetPodFilter();
-      return;
-    }
-  }, [selectedDate]);
-
-  const resetPodFilter = () => {
-    if (podFilter.podName != "" || podFilter.podType != "") {
-      setPodFilter({ podName: "", podType: "" });
-    }
-  };
 
-  const renderLogs = () => {
-    return logs?.map((log, i) => {
-      return (
-        <Log key={[log.lineNumber, i].join(".")}>
-          <span className="line-number">{log.lineNumber}.</span>
-          <span className="line-timestamp">
-            {log.timestamp
-              ? dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")
-              : "-"}
-          </span>
-          <LogOuter key={[log.lineNumber, i].join(".")}>
-            {log.line?.map((ansi, j) => {
-              if (ansi.clearLine) {
-                return null;
-              }
-
-              return (
-                <LogInnerSpan
-                  key={[log.lineNumber, i, j].join(".")}
-                  ansi={ansi}
-                >
-                  {ansi.content.replace(/ /g, "\u00a0")}
-                </LogInnerSpan>
-              );
-            })}
-          </LogOuter>
-        </Log>
-      );
+  const resetFilters = () => {
+    setSelectedFilterValues({
+      revision: filterOpts?.revision ?? GenericLogFilter.getDefaultOption("revision").value,
+      output_stream: filterOpts?.output_stream ?? GenericLogFilter.getDefaultOption("output_stream").value,
+      pod_name: getSelectorFromServiceQueryParam(filterOpts?.service) ?? GenericLogFilter.getDefaultOption("pod_name").value,
     });
   };
 
-  const setPodFilterWithPodName = (podName: string) => {
-    if (podName == "All") {
-      resetPodFilter();
-      return;
-    }
-
-    const filtered = podFilterOpts.filter((pod) => pod.podName == podName);
-    if (filtered.length > 0) {
-      setPodFilter(filtered[0]);
-    } else {
-      resetPodFilter();
-    }
-  };
-
   const onLoadPrevious = useCallback(() => {
     if (!selectedDate) {
       setSelectedDate(dayjs(logs[0].timestamp).toDate());
@@ -193,6 +208,7 @@ const LogSection: React.FC<Props> = ({
   const resetSearch = () => {
     setSearchText("");
     setEnteredSearchText("");
+    resetFilters();
   };
 
   const setSelectedDateIfUndefined = () => {
@@ -202,14 +218,6 @@ const LogSection: React.FC<Props> = ({
   };
 
   const renderContents = () => {
-    const radioOptions = podFilterOpts?.map((pod) => {
-      return {
-        value: pod.podName,
-        label: pod.podName,
-      };
-    });
-    radioOptions.unshift({ value: "All", label: "All" });
-
     return (
       <>
         <FlexRow>
@@ -225,17 +233,6 @@ const LogSection: React.FC<Props> = ({
               setSelectedDate={setSelectedDate}
               resetSearch={resetSearch}
             />
-            {showFilter &&
-              <RadioFilter
-                icon={
-                  podFilter.podName == "" ? filterOutline : filterOutlineWhite
-                }
-                selected={podFilter.podName}
-                setSelected={setPodFilterWithPodName}
-                options={radioOptions}
-                name="Filter logs"
-              />
-            }
           </Flex>
           <Flex>
             <ScrollButton onClick={() => setScrollToBottomEnabled((s) => !s)}>
@@ -247,7 +244,6 @@ const LogSection: React.FC<Props> = ({
             <Spacer inline width="10px" />
             <ScrollButton
               onClick={() => {
-                refreshPodLogsValues();
                 refresh();
               }}
             >
@@ -256,6 +252,16 @@ const LogSection: React.FC<Props> = ({
             </ScrollButton>
           </Flex>
         </FlexRow>
+        <Spacer y={0.5} />
+        {showFilter &&
+          <>
+            <LogFilterContainer
+              filters={filters}
+              selectedFilterValues={selectedFilterValues}
+            />
+            <Spacer y={0.5} />
+          </>
+        }
         <LogsSectionWrapper>
           <StyledLogsSection>
             {isLoading || (logs.length == 0 && selectedDate == null) ? (
@@ -281,7 +287,11 @@ const LogSection: React.FC<Props> = ({
                 >
                   Load Previous
                 </LoadMoreButton>
-                {renderLogs()}
+                <StyledLogs
+                  logs={logs}
+                  appName={appName}
+                  filters={filters}
+                />
                 <LoadMoreButton
                   active={selectedDate && logs.length !== 0}
                   role="button"
@@ -343,7 +353,6 @@ const LogSection: React.FC<Props> = ({
             })
             .then((res) => {
               setHasPorterAgent(true);
-              refreshPodLogsValues();
               setIsPorterAgentInstalling(false);
               setIsLoading(false);
             })
@@ -352,6 +361,26 @@ const LogSection: React.FC<Props> = ({
               setLogsError(err);
               setIsLoading(false);
             });
+
+          const agentImage = res.data?.image;
+          if (!isAgentVersionUpdated(agentImage)) {
+            setFilters([
+              {
+                name: "pod_name",
+                displayName: "Service",
+                default: GenericLogFilter.getDefaultOption("pod_name"),
+                options: services?.map(s => {
+                  return GenericFilterOption.of(s.name, `${s.name}-${s.type == "worker" ? "wkr" : s.type}`)
+                }) ?? [],
+                setValue: (value: string) => {
+                  setSelectedFilterValues((s) => ({
+                    ...s,
+                    pod_name: value,
+                  }));
+                }
+              },
+            ])
+          }
         }
       })
       .catch((err) => {
@@ -480,7 +509,6 @@ const ScrollButton = styled.div`
 const Flex = styled.div`
   display: flex;
   align-items: center;
-  border-bottom: 25px solid transparent;
 `;
 
 const Message = styled.div`
@@ -544,55 +572,6 @@ const StyledLogsSection = styled.div`
   }
 `;
 
-const Log = styled.div`
-  font-family: monospace;
-  user-select: text;
-  display: flex;
-  align-items: flex-end;
-  gap: 8px;
-  width: 100%;
-  & > * {
-    padding-block: 5px;
-  }
-  & > .line-timestamp {
-    height: 100%;
-    color: #949effff;
-    opacity: 0.5;
-    font-family: monospace;
-    min-width: fit-content;
-    padding-inline-end: 5px;
-  }
-  & > .line-number {
-    height: 100%;
-    background: #202538;
-    display: inline-block;
-    text-align: right;
-    min-width: 45px;
-    padding-inline-end: 5px;
-    opacity: 0.3;
-    font-family: monospace;
-  }
-`;
-
-const LogOuter = styled.div`
-  display: inline-block;
-  word-wrap: anywhere;
-  flex-grow: 1;
-  font-family: monospace, sans-serif;
-  font-size: 12px;
-`;
-
-const LogInnerSpan = styled.span`
-  font-family: monospace, sans-serif;
-  font-size: 12px;
-  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
-    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
-  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
-    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
-  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
-    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
-`;
-
 const LoadMoreButton = styled.div<{ active: boolean }>`
   width: 100%;
   display: ${(props) => (props.active ? "flex" : "none")};
@@ -604,39 +583,6 @@ const LoadMoreButton = styled.div<{ active: boolean }>`
   font-family: monospace;
 `;
 
-const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
-  padding: 0 10px;
-  color: ${(props) => (props.selected ? "" : "#494b4f")};
-  border: 1px solid #494b4f;
-  height: 100%;
-  display: flex;
-  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
-  align-items: center;
-  border-radius: ${(props) =>
-    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
-  :hover {
-    border: 1px solid #7a7b80;
-    z-index: 2;
-  }
-`;
-
-const ToggleButton = styled.div`
-  background: #26292e;
-  border-radius: 5px;
-  font-size: 13px;
-  height: 30px;
-  display: flex;
-  align-items: center;
-  cursor: pointer;
-`;
-
-const TimeIcon = styled.img<{ selected?: boolean }>`
-  width: 16px;
-  height: 16px;
-  z-index: 2;
-  opacity: ${(props) => (props.selected ? "" : "50%")};
-`;
-
 const NotificationWrapper = styled.div<{ active?: boolean }>`
   position: absolute;
   bottom: 10px;

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

@@ -0,0 +1,161 @@
+import React from "react";
+import { GenericLogFilter, PorterLog } from "./types";
+import styled from "styled-components";
+import Anser from "anser";
+import dayjs from "dayjs";
+import { getPodSelectorFromPodNameAndAppName, getServiceNameFromPodNameAndAppName, getVersionTagColor } from "./utils";
+
+
+type Props = {
+    logs: PorterLog[];
+    appName: string;
+    filters: GenericLogFilter[];
+};
+
+const StyledLogs: React.FC<Props> = ({
+    logs,
+    appName,
+    filters,
+}) => {
+    const renderFilterTagForLog = (filter: GenericLogFilter, log: PorterLog, index: number) => {
+        if (log.metadata == null) {
+            return null;
+        }
+        switch (filter.name) {
+            case "revision":
+                if (log.metadata.revision == null || log.metadata.revision === "") {
+                    return null;
+                }
+                return (
+                    <LogInnerPill
+                        color={getVersionTagColor(log.metadata.revision)}
+                        key={index}
+                        onClick={() => filter.setValue(log.metadata.revision)}
+                    >
+                        {`Version: ${log.metadata.revision}`}
+                    </LogInnerPill>
+                )
+            case "pod_name":
+                if (log.metadata.pod_name == null || log.metadata.pod_name === "") {
+                    return null;
+                }
+                return (
+                    <LogInnerPill
+                        color={"white"}
+                        key={index}
+                        onClick={() => filter.setValue(getPodSelectorFromPodNameAndAppName(log.metadata.pod_name, appName))}
+                    >
+                        {getServiceNameFromPodNameAndAppName(log.metadata.pod_name, appName)}
+                    </LogInnerPill>
+                )
+            default:
+                return null;
+        }
+    }
+
+    return (
+        <StyledLogsContainer>
+            {logs.map((log, i) => {
+                return (
+                    <Log key={[log.lineNumber, i].join(".")}>
+                        <LogLabelsContainer>
+                            <LineTimestamp className="line-timestamp">
+                                {log.timestamp
+                                    ? dayjs(log.timestamp).format("MM/DD HH:mm:ss")
+                                    : "-"}
+                            </LineTimestamp>
+                            {filters.map((filter, j) => {
+                                return renderFilterTagForLog(filter, log, j)
+                            })}
+                        </LogLabelsContainer>
+                        <LogOuter key={[log.lineNumber, i].join(".")}>
+                            {log.line?.map((ansi, j) => {
+                                if (ansi.clearLine) {
+                                    return null;
+                                }
+
+                                return (
+                                    <LogInnerSpan
+                                        key={[log.lineNumber, i, j].join(".")}
+                                        ansi={ansi}
+                                    >
+                                        {ansi.content.replace(/ /g, "\u00a0")}
+                                    </LogInnerSpan>
+                                );
+                            })}
+                        </LogOuter>
+                    </Log>
+                );
+            })}
+        </StyledLogsContainer>
+    );
+};
+
+export default StyledLogs;
+
+const StyledLogsContainer = styled.div`
+`;
+
+const LogLabelsContainer = styled.div`
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 10px;
+`;
+
+const LineTimestamp = styled.span`
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+`
+
+const Log = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  width: 100%;
+  min-height: 25px;
+`;
+
+const LogInnerPill = styled.div<{ color: string }>`
+    display: inline-block;
+    vertical-align: middle;
+    width: 100px;
+    padding: 0px 5px;
+    height: 20px;
+    color: black;
+    background-color: ${(props) => props.color};
+    border-radius: 5px;
+    opacity: 1;
+    font-family: monospace;
+    cursor: pointer;
+    hover: {
+        border: 1px solid #949effff;
+    }
+    overflow: hidden;
+    white-space: nowrap;    
+    text-overflow: ellipsis;
+`
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;

+ 43 - 1
dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts

@@ -10,6 +10,7 @@ export interface PorterLog {
     line: AnserJsonEntry[];
     lineNumber: number;
     timestamp?: string;
+    metadata?: z.infer<typeof AgentLogMetadataSchema>;
 }
 
 export interface PaginationInfo {
@@ -30,4 +31,45 @@ export const AgentLogSchema = z.object({
     timestamp: z.string(),
     metadata: AgentLogMetadataSchema.optional(),
 });
-export type AgentLog = z.infer<typeof AgentLogSchema>;
+export type AgentLog = z.infer<typeof AgentLogSchema>;
+
+export interface GenericFilterOption {
+    label: string;
+    value: string;
+}
+export const GenericFilterOption = {
+    of: (label: string, value: string): GenericFilterOption => {
+        return { label, value };
+    }
+}
+export type LogFilterName = 'revision' | 'output_stream' | 'pod_name';
+export interface GenericLogFilter {
+    name: LogFilterName;
+    displayName: string;
+    default: GenericFilterOption;
+    options: GenericFilterOption[];
+    setValue: (value: string) => void;
+}
+export const GenericLogFilter = {
+    isDefault: (filter: GenericLogFilter, value: string) => {
+        return filter.default.value === value;
+    },
+
+    getDefaultOption: (filterName: LogFilterName) => {
+        switch (filterName) {
+            case 'revision':
+                return GenericFilterOption.of('All', 'all');
+            case 'output_stream':
+                return GenericFilterOption.of('stdout', 'stdout');
+            case 'pod_name':
+                return GenericFilterOption.of('All', 'all');
+            default:
+                return GenericFilterOption.of('All', 'all');
+        }
+    },
+}
+export type LogFilterQueryParamOpts = {
+    revision: string | null;
+    output_stream: string | null;
+    service: string | null;
+}

+ 108 - 20
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 { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
 import { ChartType } from "shared/types";
-import { AgentLog, AgentLogSchema, Direction, PorterLog, PaginationInfo } from "./types";
+import { AgentLog, AgentLogSchema, Direction, PorterLog, PaginationInfo, GenericLogFilter, LogFilterName } from "./types";
 
 const MAX_LOGS = 5000;
 const MAX_BUFFER_LOGS = 1000;
@@ -23,6 +23,7 @@ export const parseLogs = (logs: any[] = []): PorterLog[] => {
         line: ansiLog,
         lineNumber: idx + 1,
         timestamp: parsed.timestamp,
+        metadata: parsed.metadata,
       };
     } catch (err) {
       return {
@@ -35,8 +36,8 @@ export const parseLogs = (logs: any[] = []): PorterLog[] => {
 };
 
 export const useLogs = (
-  currentPodName: string,
-  currentPodType: string,
+  selectedFilterValues: Record<LogFilterName, string>,
+  appName: string,
   namespace: string,
   searchParam: string,
   notify: (message: string) => void,
@@ -47,11 +48,11 @@ export const useLogs = (
   timeRange?: {
     startTime?: Dayjs,
     endTime?: Dayjs,
-  }
+  },
 ) => {
   const isLive = !setDate;
   const logsBufferRef = useRef<PorterLog[]>([]);
-  const { currentCluster, currentProject, setCurrentError } = useContext(
+  const { currentCluster, currentProject } = useContext(
     Context
   );
   const [logs, setLogs] = useState<PorterLog[]>([]);
@@ -60,11 +61,9 @@ export const useLogs = (
     nextCursor: null,
   });
 
-  // if currentPodName is empty assume we are looking at all chart pod logs
-  const currentPod =
-    currentPodName == ""
-      ? currentChart?.name
-      : `${currentChart?.name}-${currentPodName}-${currentPodType}`;
+  // if currentPodName is default value we are looking at all chart pod logs
+  const currentPodSelector = selectedFilterValues.pod_name === GenericLogFilter.getDefaultOption("pod_name").value
+    ? `${currentChart?.name ?? ''}-.*` : `${currentChart?.name}-${selectedFilterValues.pod_name}-.*`;
 
   // if we are live:
   // - start date is initially set to 2 weeks ago
@@ -80,7 +79,6 @@ export const useLogs = (
   const {
     newWebsocket,
     openWebsocket,
-    closeWebsocket,
     closeAllWebsockets,
   } = useWebsockets();
 
@@ -95,7 +93,6 @@ export const useLogs = (
 
     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
@@ -134,7 +131,7 @@ export const useLogs = (
         }
       }
 
-      return updatedLogs;
+      return filterLogs(updatedLogs);
     });
   };
 
@@ -160,14 +157,14 @@ export const useLogs = (
   };
 
   const setupWebsocket = (websocketKey: string) => {
-    if (namespace == "") {
+    if (namespace == "" || currentCluster == null || currentProject == null || currentChart == null) {
       return;
     }
 
     const websocketBaseURL = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/logs/loki`;
 
     const q = new URLSearchParams({
-      pod_selector: currentPod + "-.*",
+      pod_selector: currentPodSelector,
       namespace,
       search_param: searchParam,
       revision: currentChart.version.toString(),
@@ -195,7 +192,9 @@ export const useLogs = (
             // console.log(err)
           }
         });
-        pushLogs(parseLogs(newLogs));
+        const newLogsParsed = parseLogs(newLogs);
+        const newLogsFiltered = filterLogs(newLogsParsed);
+        pushLogs(newLogsFiltered);
       },
       onclose: () => {
         console.log("Closed websocket:", websocketKey);
@@ -206,6 +205,31 @@ export const useLogs = (
     openWebsocket(websocketKey);
   };
 
+  const filterLogs = (logs: PorterLog[]) => {
+    return logs.filter(log => {
+      if (log.metadata == null) {
+        return true;
+      }
+
+      // TODO: refactor this extremely hacky way to filter out pre-deploy logs
+      if (!currentChart?.name.endsWith("-r") && log.metadata.pod_name.startsWith(`${appName}-r`)) {
+        return false;
+      }
+
+      if (selectedFilterValues.output_stream !== GenericLogFilter.getDefaultOption("output_stream").value &&
+        log.metadata.output_stream !== selectedFilterValues.output_stream) {
+        return false;
+      }
+
+      if (selectedFilterValues.revision !== GenericLogFilter.getDefaultOption("revision").value &&
+        log.metadata.revision !== selectedFilterValues.revision) {
+        return false;
+      }
+
+      return true;
+    });
+  };
+
   const queryLogs = async (
     startDate: string,
     endDate: string,
@@ -231,7 +255,7 @@ export const useLogs = (
       end_range: endDate,
       limit,
       chart_name: "",
-      pod_selector: currentPod + "-.*",
+      pod_selector: currentPodSelector,
       direction,
     };
 
@@ -290,7 +314,7 @@ export const useLogs = (
   };
 
   const refresh = async () => {
-    if (!currentPod) {
+    if (!currentPodSelector) {
       return;
     }
 
@@ -321,7 +345,7 @@ export const useLogs = (
 
     closeAllWebsockets();
     const suffix = Math.random().toString(36).substring(2, 15);
-    const websocketKey = `${currentPod}-${namespace}-websocket-${suffix}`;
+    const websocketKey = `${currentPodSelector}-${namespace}-websocket-${suffix}`;
 
     setLoading(false);
 
@@ -418,7 +442,7 @@ export const useLogs = (
 
   useEffect(() => {
     refresh();
-  }, [currentPod, namespace, searchParam, setDate]);
+  }, [currentPodSelector, namespace, searchParam, setDate, selectedFilterValues]);
 
   useEffect(() => {
     // if the streaming is no longer live, close all websockets
@@ -440,3 +464,67 @@ export const useLogs = (
     paginationInfo,
   };
 };
+
+export const getVersionTagColor = (version: string) => {
+  const colors = [
+    "#7B61FF",
+    "#FF7B61",
+    "#61FF7B",
+  ];
+
+  const versionInt = parseInt(version);
+  if (isNaN(versionInt)) {
+    return colors[0];
+  }
+  return colors[versionInt % colors.length];
+};
+
+export const getServiceNameFromPodNameAndAppName = (podName: string, porterAppName: string) => {
+  const prefix: string = porterAppName + "-";
+  if (!podName.startsWith(prefix)) {
+    return "";
+  }
+
+  podName = podName.replace(prefix, "");
+  const suffixes: string[] = ["-web", "-wkr", "-job"];
+  let index: number = -1;
+
+  for (const suffix of suffixes) {
+    const newIndex: number = podName.lastIndexOf(suffix);
+    if (newIndex > index) {
+      index = newIndex;
+    }
+  }
+
+  if (index !== -1) {
+    return podName.substring(0, index);
+  }
+
+  return "";
+}
+
+export const getPodSelectorFromPodNameAndAppName = (podName: string, porterAppName: string) => {
+  const prefix: string = porterAppName + "-";
+  if (!podName.startsWith(prefix)) {
+    return "";
+  }
+
+  podName = podName.replace(prefix, "");
+  const suffixes: string[] = ["-web", "-wkr", "-job"];
+  let index: number = -1;
+  let type = ""
+
+  for (const suffix of suffixes) {
+    const newIndex: number = podName.lastIndexOf(suffix);
+    if (newIndex > index) {
+      index = newIndex;
+      type = suffix;
+    }
+  }
+
+  if (index !== -1) {
+    return podName.substring(0, index) + type;
+  }
+
+  return "";
+}

+ 0 - 31
dashboard/src/shared/types.tsx

@@ -666,35 +666,4 @@ export interface CreateUpdatePorterAppOptions {
   full_helm_values?: string;
 }
 
-export enum PorterAppEventType {
-  BUILD = "BUILD",
-  DEPLOY = "DEPLOY",
-  APP_EVENT = "APP_EVENT",
-  PRE_DEPLOY = "PRE_DEPLOY",
-}
-export interface PorterAppEvent {
-  created_at: string;
-  updated_at: string;
-  id: string;
-  status: string;
-  type: PorterAppEventType;
-  type_source: string;
-  porter_app_id: number;
-  metadata: any;
-}
-export const PorterAppEvent = {
-  toPorterAppEvent: (data: any): PorterAppEvent => {
-    return {
-      created_at: data.created_at ?? "",
-      updated_at: data.updated_at ?? "",
-      id: data.id ?? "",
-      status: data.status ?? "",
-      type: data.type ?? "",
-      type_source: data.type_source ?? "",
-      porter_app_id: data.porter_app_id ?? "",
-      metadata: data.metadata ?? {},
-    };
-  }
-}
-