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

Merge branch 'master' of https://github.com/porter-dev/porter into 0.8.0-styling-hotfixes

jusrhee 4 лет назад
Родитель
Сommit
7c76567c0e
38 измененных файлов с 2746 добавлено и 198 удалено
  1. 14 14
      cmd/app/main.go
  2. 154 0
      dashboard/src/components/Dropdown.tsx
  3. 200 0
      dashboard/src/components/events/EventCard.tsx
  4. 56 0
      dashboard/src/components/events/EventLogs.tsx
  5. 226 0
      dashboard/src/components/events/EventsContext.tsx
  6. 113 0
      dashboard/src/components/events/EventsList.tsx
  7. 11 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  8. 161 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  9. 65 13
      dashboard/src/shared/api.tsx
  10. 4 4
      internal/analytics/identifiers.go
  11. 26 3
      internal/analytics/track_events.go
  12. 461 59
      internal/analytics/tracks.go
  13. 40 0
      internal/forms/events.go
  14. 19 0
      internal/kubernetes/agent.go
  15. 68 0
      internal/kubernetes/provisioner/global_stream.go
  16. 102 0
      internal/models/events.go
  17. 3 0
      internal/models/infra.go
  18. 31 0
      internal/repository/event.go
  19. 163 0
      internal/repository/gorm/event.go
  20. 153 0
      internal/repository/gorm/event_test.go
  21. 46 0
      internal/repository/gorm/helpers_test.go
  22. 1 0
      internal/repository/gorm/migrate.go
  23. 1 0
      internal/repository/gorm/repository.go
  24. 1 0
      internal/repository/repository.go
  25. 177 0
      server/api/agent_handler.go
  26. 8 6
      server/api/api.go
  27. 22 34
      server/api/cluster_handler.go
  28. 51 2
      server/api/deploy_handler.go
  29. 170 0
      server/api/event_handler.go
  30. 9 0
      server/api/integration_handler.go
  31. 11 2
      server/api/oauth_github_handler.go
  32. 4 2
      server/api/oauth_google_handler.go
  33. 5 0
      server/api/project_handler.go
  34. 62 53
      server/api/provision_handler.go
  35. 17 1
      server/api/registry_handler.go
  36. 14 3
      server/api/release_handler.go
  37. 5 2
      server/api/user_handler.go
  38. 72 0
      server/router/router.go

+ 14 - 14
cmd/app/main.go

@@ -58,6 +58,19 @@ func main() {
 
 	repo := gorm.NewRepository(db, &key)
 
+	a, err := api.New(&api.AppConfig{
+		Logger:     logger,
+		Repository: repo,
+		ServerConf: appConf.Server,
+		RedisConf:  &appConf.Redis,
+		CapConf:    appConf.Capabilities,
+		DBConf:     appConf.Db,
+	})
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	if appConf.Redis.Enabled {
 		redis, err := adapter.NewRedisClient(&appConf.Redis)
 
@@ -70,20 +83,7 @@ func main() {
 
 		errorChan := make(chan error)
 
-		go prov.GlobalStreamListener(redis, *repo, errorChan)
-	}
-
-	a, err := api.New(&api.AppConfig{
-		Logger:     logger,
-		Repository: repo,
-		ServerConf: appConf.Server,
-		RedisConf:  &appConf.Redis,
-		CapConf:    appConf.Capabilities,
-		DBConf:     appConf.Db,
-	})
-
-	if err != nil {
-		logger.Fatal().Err(err).Msg("")
+		go prov.GlobalStreamListener(redis, *repo, a.AnalyticsClient, errorChan)
 	}
 
 	appRouter := router.New(a)

+ 154 - 0
dashboard/src/components/Dropdown.tsx

@@ -0,0 +1,154 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Option = {
+  value: unknown;
+  label: string;
+};
+
+type DropdownProps = {
+  options: Array<Option>;
+  onSelect: (selectedOption: Option) => unknown;
+  selectLabel?: (currentLabel: string) => void;
+  selectValue?: (currentValue: any) => void;
+};
+
+const Dropdown: React.FunctionComponent<DropdownProps> = ({
+  options,
+  selectLabel,
+  selectValue,
+  onSelect,
+}) => {
+  const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
+  const [selectedOption, setSelectedOption] = useState<Option>(options[0]);
+
+  const handleSelectOption = (option: Option) => {
+    setSelectedOption(option);
+    onSelect(option);
+    typeof selectLabel === "function" && selectLabel(option.label);
+    typeof selectValue === "function" && selectValue(option.value);
+  };
+
+  const renderDropdown = () => {
+    if (isDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
+          <OptionWrapper
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setIsDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </OptionWrapper>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return options.map((option, i, originalArray) => {
+      return (
+        <Option
+          key={i}
+          selected={option.label === selectedOption.label}
+          onClick={() => handleSelectOption(option)}
+          lastItem={i === originalArray.length - 1}
+        >
+          {option.label}
+        </Option>
+      );
+    });
+  };
+
+  return (
+    <DropdownSelector
+      onClick={() => setIsDropdownExpanded(!isDropdownExpanded)}
+    >
+      <DropdownLabel>{selectedOption?.label}</DropdownLabel>
+      <i className="material-icons">arrow_drop_down</i>
+      {renderDropdown()}
+    </DropdownSelector>
+  );
+};
+
+export default Dropdown;
+
+const DropdownSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const DropdownLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const OptionWrapper = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;

+ 200 - 0
dashboard/src/components/events/EventCard.tsx

@@ -0,0 +1,200 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { Event } from "./EventsContext";
+
+type CardProps = {
+  event: Event;
+  selectEvent: (id: number) => void;
+};
+const getReadableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+// Rename to Event Card
+const EventCard: React.FunctionComponent<CardProps> = ({
+  event,
+  selectEvent,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  return (
+    <StyledCard>
+      <ContentContainer>
+        <Icon status={event.event_type} className="material-icons-outlined">
+          {event.event_type === "critical" ? "report_problem" : "info"}
+        </Icon>
+        <EventInformation>
+          <EventName>
+            <Helper>{event.resource_type}:</Helper>
+            {event.name}
+          </EventName>
+          <EventReason>
+            <Helper>Reason:</Helper>
+            {event.reason}
+          </EventReason>
+        </EventInformation>
+      </ContentContainer>
+      <ActionContainer hasOneChild={event.event_type === "normal"}>
+        {event.event_type === "critical" && (
+          <HistoryButton
+            onClick={() => selectEvent(event.id)}
+            onMouseEnter={() => setShowTooltip(true)}
+            onMouseLeave={() => setShowTooltip(false)}
+          >
+            <span className="material-icons-outlined">manage_search</span>
+            {showTooltip && <Tooltip>Open logs</Tooltip>}
+          </HistoryButton>
+        )}
+        <TimestampContainer>
+          <TimestampIcon className="material-icons-outlined">
+            access_time
+          </TimestampIcon>
+          <span>{getReadableDate(event.timestamp)}</span>
+        </TimestampContainer>
+      </ActionContainer>
+    </StyledCard>
+  );
+};
+
+export default EventCard;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  min-height: 100px;
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border: 1px solid #26282f;
+  box-shadow: 0 4px 15px 0px #00000055;
+  border-radius: 8px;
+  padding: 14px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span`
+  font-size: 35px;
+  margin-right: 14px;
+  color: ${({ status }: { status: "critical" | "normal" }) =>
+    status === "critical" ? "red" : "green"};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  font-size: 14px;
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-size: 18px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  margin-top: 8px;
+`;
+
+const ActionContainer = styled.div`
+  width: max-content;
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+  flex-direction: column;
+  justify-content: ${(props: { hasOneChild: boolean }) => {
+    return props.hasOneChild ? "flex-end" : "space-between";
+  }};
+`;
+
+const HistoryButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 0px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 5px;
+`;

+ 56 - 0
dashboard/src/components/events/EventLogs.tsx

@@ -0,0 +1,56 @@
+import React, { useContext } from "react";
+import styled from "styled-components";
+import backArrow from "assets/back_arrow.png";
+import { EventContext } from "./EventsContext";
+
+type EventLogsProps = {};
+
+const EventLogs: React.FunctionComponent<EventLogsProps> = ({}) => {
+  const { clearSelectedEvent } = useContext(EventContext);
+  return (
+    <>
+      <ControlRow>
+        <div>
+          <BackButton onClick={clearSelectedEvent}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+        </div>
+      </ControlRow>
+      <div>Show logs</div>
+    </>
+  );
+};
+
+export default EventLogs;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

+ 226 - 0
dashboard/src/components/events/EventsContext.tsx

@@ -0,0 +1,226 @@
+import React, { createContext, useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+export type Event = {
+  id: number;
+  project_id: number;
+  cluster_id: number;
+  owner_name: string;
+  owner_type: string;
+  event_type: "critical" | "normal";
+  resource_type: string;
+  name: string;
+  namespace: string;
+  message: string;
+  reason: string;
+  timestamp: string;
+};
+
+type EventController = { type: string; name: string };
+
+export type EventsContextType = {
+  isPorterAgentInstalled: boolean;
+  isPorterAgentInstalling: boolean;
+  isLoading: boolean;
+  eventList: Event[];
+  selectedEvent: Event | null;
+  availableControllers: EventController[];
+  enableNodeEvents: boolean;
+  selectEvent: (id: number) => void;
+  clearSelectedEvent: () => void;
+  setLimit: (limit: number) => void;
+  setResourceType: (newResourceType: "pod" | "hpa" | "node") => void;
+  installPorterAgent: () => Promise<void>;
+  setSelectedController: (controllerName: EventController) => void;
+};
+
+const defaultEventContext: EventsContextType = {
+  eventList: [],
+  isPorterAgentInstalled: false,
+  isPorterAgentInstalling: false,
+  isLoading: true,
+  selectedEvent: null,
+  enableNodeEvents: false,
+  availableControllers: [],
+  selectEvent: () => {},
+  clearSelectedEvent: () => {},
+  setLimit: () => {},
+  setResourceType: () => {},
+  installPorterAgent: async () => {},
+  setSelectedController: async () => {},
+};
+
+export const EventContext = createContext<EventsContextType>(
+  defaultEventContext
+);
+
+type Props = {
+  controllers: EventController[];
+  enableNodeEvents: boolean;
+};
+
+const EventsContextProvider: React.FC<Props> = ({
+  children,
+  controllers,
+  enableNodeEvents,
+}) => {
+  // Porter agent related
+  const [isPorterAgentInstalled, setIsPorterAgentInstalled] = useState<boolean>(
+    false
+  );
+  const [
+    isPorterAgentInstalling,
+    setIsPorterAgentInstalling,
+  ] = useState<boolean>(false);
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+
+  // Event related
+  const [eventList, setEventList] = useState<Event[]>([]);
+  const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
+  const [selectedController, setSelectedController] = useState<EventController>(
+    () => controllers[0] || undefined
+  );
+
+  // Pagination related
+  const [limit, setLimit] = useState<number>(10);
+  const [resourceType, setResourceType] = useState<"pod" | "hpa" | "node">(
+    "pod"
+  );
+  // Currently only implemented one sort type
+  const [sortBy] = useState<"timestamp">("timestamp");
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    checkIfPorterAgentIsInstalled();
+  }, [currentCluster, currentProject]);
+
+  useEffect(() => {
+    if (!isPorterAgentInstalling) {
+      return () => {};
+    }
+    const interval = setInterval(() => {
+      checkIfPorterAgentIsInstalled();
+    }, 500);
+
+    return () => clearInterval(interval);
+  }, [isPorterAgentInstalling]);
+
+  useEffect(() => {
+    if (!selectedController || !isPorterAgentInstalled) {
+      return;
+    }
+
+    setIsLoading(true);
+    // Clear out event list if the resource type or the selected controller changed
+    if (
+      resourceType !== eventList[0]?.resource_type ||
+      selectedController.name !== eventList[0].name
+    ) {
+      setEventList([]);
+    }
+
+    getEventList().then(() => setIsLoading(false));
+  }, [isPorterAgentInstalled, selectedController, resourceType, sortBy, limit]);
+
+  const checkIfPorterAgentIsInstalled = async () => {
+    try {
+      await api.getPorterAgentIsInstalled(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          project_id: currentProject.id,
+        }
+      );
+      setIsPorterAgentInstalled(true);
+    } catch (error) {
+      setIsPorterAgentInstalled(false);
+    }
+  };
+
+  const installPorterAgent = async () => {
+    try {
+      await api.installPorterAgent(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      setIsPorterAgentInstalling(true);
+    } catch (error) {}
+  };
+
+  const removeDuplicatedEvents = (events: Event[]) => {
+    return events.reduce<Event[]>((prev, event, arr) => {
+      if (prev.find((e) => e.id === event.id)) {
+        return prev;
+      }
+      return [...prev, event];
+    }, []);
+  };
+
+  const getEventList = async () => {
+    try {
+      const res = await api.getEvents(
+        "<token>",
+        {
+          limit,
+          skip: eventList.length,
+          type: resourceType,
+          sort_by: sortBy,
+          owner_name: selectedController?.name,
+          owner_type: selectedController?.type,
+        },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      );
+      const newEventList = removeDuplicatedEvents([...eventList, ...res.data]);
+      setEventList(newEventList);
+    } catch (error) {
+      setEventList([]);
+      setCurrentError((error as Error)?.message || JSON.stringify(error));
+    }
+  };
+
+  const selectEvent = (id: number) => {
+    const event = eventList.find((e) => e.id === id);
+    setSelectedEvent(event);
+  };
+
+  const clearSelectedEvent = () => {
+    setSelectedEvent(null);
+  };
+
+  return (
+    <EventContext.Provider
+      value={{
+        enableNodeEvents,
+        isPorterAgentInstalled,
+        isPorterAgentInstalling,
+        isLoading,
+        eventList,
+        selectedEvent,
+        availableControllers: controllers,
+        selectEvent,
+        clearSelectedEvent,
+        setLimit,
+        setResourceType,
+        installPorterAgent,
+        setSelectedController,
+      }}
+    >
+      {children}
+    </EventContext.Provider>
+  );
+};
+
+export default EventsContextProvider;

+ 113 - 0
dashboard/src/components/events/EventsList.tsx

@@ -0,0 +1,113 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import Dropdown from "components/Dropdown";
+import { EventContext } from "./EventsContext";
+import EventCard from "./EventCard";
+
+const EventsList: React.FunctionComponent = ({}) => {
+  const {
+    eventList,
+    selectEvent,
+    setResourceType,
+    setLimit,
+    availableControllers,
+    setSelectedController,
+    enableNodeEvents,
+  } = useContext(EventContext);
+
+  const handleResourceTypeSelection = (option: {
+    label: string;
+    value: "pod" | "hpa";
+  }) => {
+    setResourceType(option.value);
+  };
+
+  const handleSetLimit = (option: { label: string; value: number }) => {
+    setLimit(option.value);
+  };
+
+  const handleControllerSelection = (option: {
+    label: string;
+    value: { type: string; name: string };
+  }) => {
+    setSelectedController(option.value);
+  };
+
+  const resourceTypes = useMemo(() => {
+    if (enableNodeEvents) {
+      return [
+        { label: "Pods", value: "pod" },
+        { label: "HPA", value: "hpa" },
+        { label: "Node", value: "node" },
+      ];
+    }
+    return [
+      { label: "Pods", value: "pod" },
+      { label: "HPA", value: "hpa" },
+    ];
+  }, [enableNodeEvents]);
+
+  const controllers = useMemo(() => {
+    return availableControllers.map((c) => ({
+      label: c.name,
+      value: c,
+    }));
+  }, [availableControllers]);
+
+  return (
+    <div>
+      <ControlRow>
+        <Dropdown
+          options={resourceTypes}
+          onSelect={handleResourceTypeSelection}
+        />
+        <RightFilters>
+          <Dropdown
+            options={[
+              { label: "10 events", value: 10 },
+              { label: "20 events", value: 20 },
+              { label: "50 events", value: 50 },
+            ]}
+            onSelect={handleSetLimit}
+          />
+          <Dropdown
+            options={controllers}
+            onSelect={handleControllerSelection}
+          />
+        </RightFilters>
+      </ControlRow>
+      <EventsGrid>
+        {eventList.map((event) => {
+          return (
+            <EventCard key={event.id} event={event} selectEvent={selectEvent} />
+          );
+        })}
+      </EventsGrid>
+    </div>
+  );
+};
+
+export default EventsList;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
+const RightFilters = styled.div`
+  display: flex;
+  > div {
+    :not(:last-child) {
+      margin-right: 15px;
+    }
+  }
+`;

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

@@ -33,6 +33,7 @@ import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import { integrationList } from "shared/common";
 import DeploymentType from "./DeploymentType";
+import EventsTab from "./events/EventsTab";
 
 type Props = {
   namespace: string;
@@ -431,6 +432,14 @@ const ExpandedChart: React.FC<Props> = (props) => {
             disabled={!isAuthorized("application", "", ["get", "update"])}
           />
         );
+      case "events":
+        const parsedControllers = Object.values(controllers).map((c) => {
+          return {
+            name: c?.metadata?.name,
+            type: c?.kind,
+          };
+        });
+        return <EventsTab controllers={parsedControllers} />;
       default:
     }
   };
@@ -439,6 +448,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
     // Collate non-form tabs
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
+    leftTabOptions.push({ label: "Events", value: "events" });
+
     leftTabOptions.push({ label: "Status", value: "status" });
 
     if (props.isMetricsInstalled) {

+ 161 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -0,0 +1,161 @@
+import React from "react";
+import styled from "styled-components";
+
+import EventLogs from "components/events/EventLogs";
+import EventsList from "components/events/EventsList";
+import EventsContextProvider, {
+  EventContext,
+  EventsContextType,
+} from "components/events/EventsContext";
+import loadingSrc from "assets/loading.gif";
+
+type EventsTabProps = {
+  controllers: { type: string; name: string }[];
+};
+
+const EventsTab: React.FunctionComponent<EventsTabProps> = ({
+  controllers,
+}) => {
+  const renderContent = ({
+    isLoading,
+    isPorterAgentInstalled,
+    isPorterAgentInstalling,
+    selectedEvent,
+    installPorterAgent,
+  }: EventsContextType) => {
+    if (isPorterAgentInstalling) {
+      return (
+        <Placeholder>
+          <div>
+            <Header>
+              <Spinner src={loadingSrc} /> Installing porter agent
+            </Header>
+            This should be quick, if it takes too long please contact the porter
+            team.
+          </div>
+        </Placeholder>
+      );
+    }
+
+    if (isLoading) {
+      return (
+        <Placeholder>
+          <div>
+            <Header>
+              <Spinner src={loadingSrc} />
+            </Header>
+          </div>
+        </Placeholder>
+      );
+    }
+
+    if (!isPorterAgentInstalled) {
+      return (
+        <Placeholder>
+          <div>
+            <Header>We coulnd't detect porter agent :(</Header>
+            In order to use the events tab you should install the porter agent!
+            <InstallPorterAgentButton onClick={() => installPorterAgent()}>
+              <i className="material-icons">add</i> Install porter agent
+            </InstallPorterAgentButton>
+          </div>
+        </Placeholder>
+      );
+    }
+
+    if (!selectedEvent) {
+      return <EventsList />;
+    }
+
+    return <EventLogs />;
+  };
+
+  return (
+    <EventsPageWrapper>
+      <EventsContextProvider controllers={controllers} enableNodeEvents={false}>
+        <EventContext.Consumer>
+          {(context) => renderContent(context)}
+        </EventContext.Consumer>
+      </EventsContextProvider>
+    </EventsPageWrapper>
+  );
+};
+
+export default EventsTab;
+
+const EventsPageWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  min-height: 400px;
+  height: 50vh;
+  padding: 30px;
+  padding-bottom: 90px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;

+ 65 - 13
dashboard/src/shared/api.tsx

@@ -246,6 +246,19 @@ const deleteSlackIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
 });
 
+const generateGHAWorkflow = baseApi<
+  FullActionConfigType,
+  {
+    cluster_id: number;
+    project_id: number;
+    name: string;
+  }
+>("POST", (pathParams) => {
+  const { name, cluster_id, project_id } = pathParams;
+
+  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
+});
+
 const updateNotificationConfig = baseApi<
   {
     payload: any;
@@ -273,19 +286,6 @@ const getNotificationConfig = baseApi<
   return `/api/projects/${pathParams.project_id}/releases/${pathParams.name}/notifications`;
 });
 
-const generateGHAWorkflow = baseApi<
-  FullActionConfigType,
-  {
-    cluster_id: number;
-    project_id: number;
-    name: string;
-  }
->("POST", (pathParams) => {
-  const { name, cluster_id, project_id } = pathParams;
-
-  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
-});
-
 const deployTemplate = baseApi<
   {
     templateName: string;
@@ -1034,6 +1034,54 @@ const createWebhookToken = baseApi<
     `/api/projects/${project_id}/releases/${chart_name}/webhook_token?namespace=${namespace}&cluster_id=${cluster_id}&storage=${storage}`
 );
 
+const getPorterAgentIsInstalled = baseApi<
+  {
+    cluster_id: number;
+  },
+  { project_id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/agent/detect`;
+});
+
+const installPorterAgent = baseApi<
+  {},
+  { project_id: number, cluster_id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/agent/deploy?cluster_id=${pathParams.cluster_id}`;
+});
+
+const getEvents = baseApi<
+  {
+    limit: number;
+    skip: number;
+    type: "pod" | "node" | "hpa";
+    owner_type: string;
+    owner_name: string;
+    sort_by: "timestamp";
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  (pathParams) =>
+    `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/events`
+);
+
+const getEventById = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    event_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/events/${event_id}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1140,4 +1188,8 @@ export default {
   removeCollaborator,
   getPolicyDocument,
   createWebhookToken,
+  getPorterAgentIsInstalled,
+  installPorterAgent,
+  getEvents,
+  getEventById,
 };

+ 4 - 4
internal/analytics/identifiers.go

@@ -18,14 +18,14 @@ type segmentIdentifyNewUser struct {
 	isGithub  bool
 }
 
-// Creates a segment Identifier struct for new users. As we handle registration with github, it also accepts a param
-// to check if the new user has registered with github or not.
-func CreateSegmentIdentifyNewUser(user *models.User, registeredViaGithub bool) *segmentIdentifyNewUser {
+// CreateSegmentIdentifyUser creates an identifier for users
+func CreateSegmentIdentifyUser(user *models.User) *segmentIdentifyNewUser {
 	userId := fmt.Sprintf("%v", user.ID)
+
 	return &segmentIdentifyNewUser{
 		userId:    userId,
 		userEmail: user.Email,
-		isGithub:  registeredViaGithub,
+		isGithub:  user.GithubUserID != 0,
 	}
 }
 

+ 26 - 3
internal/analytics/track_events.go

@@ -3,7 +3,30 @@ package analytics
 type SegmentEvent string
 
 const (
-	NewUser            SegmentEvent = "New User"
-	RedeployViaWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
-	NewClusterEvent    SegmentEvent = "New Cluster Event"
+	// onboarding flow
+	UserCreate    SegmentEvent = "New User"
+	ProjectCreate SegmentEvent = "New Project Event"
+
+	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"
+	ClusterProvisioningError   SegmentEvent = "Cluster Provisioning Error"
+	ClusterProvisioningSuccess SegmentEvent = "Cluster Provisioning Success"
+
+	RegistryProvisioningStart   SegmentEvent = "Registry Provisioning Started"
+	RegistryProvisioningError   SegmentEvent = "Registry Provisioning Error"
+	RegistryProvisioningSuccess SegmentEvent = "Registry Provisioning Success"
+
+	ClusterConnectionStart   SegmentEvent = "Cluster Connection Started"
+	ClusterConnectionSuccess SegmentEvent = "Cluster Connection Success"
+
+	RegistryConnectionStart   SegmentEvent = "Registry Connection Started"
+	RegistryConnectionSuccess SegmentEvent = "Registry Connection Success"
+
+	GithubConnectionStart   SegmentEvent = "Github Connection Started"
+	GithubConnectionSuccess SegmentEvent = "Github Connection Success"
+
+	// launch flow
+	ApplicationLaunchStart   SegmentEvent = "Application Launch Started"
+	ApplicationLaunchSuccess SegmentEvent = "Application Launch Success"
+
+	ApplicationDeploymentWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
 )

+ 461 - 59
internal/analytics/tracks.go

@@ -7,103 +7,505 @@ import (
 	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 
+// TRACK FUNCTIONS
+type UserCreateTrackOpts struct {
+	*UserScopedTrackOpts
+}
+
+func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserCreate),
+	)
+}
+
+type ProjectCreateTrackOpts struct {
+	*ProjectScopedTrackOpts
+}
+
+func ProjectCreateTrack(opts *ProjectCreateTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ProjectCreate),
+	)
+}
+
+type ClusterProvisioningStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+func ClusterProvisioningStartTrack(opts *ClusterProvisioningStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningStart),
+	)
+}
+
+type ClusterProvisioningErrorTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+func ClusterProvisioningErrorTrack(opts *ClusterProvisioningErrorTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningError),
+	)
+}
+
+type ClusterProvisioningSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+func ClusterProvisioningSuccessTrack(opts *ClusterProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningSuccess),
+	)
+}
+
+type ClusterConnectionStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterCandidateID uint
+}
+
+func ClusterConnectionStartTrack(opts *ClusterConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cc_id"] = opts.ClusterCandidateID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterConnectionStart),
+	)
+}
+
+type ClusterConnectionSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterCandidateID uint
+}
+
+func ClusterConnectionSuccessTrack(opts *ClusterConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cc_id"] = opts.ClusterCandidateID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterConnectionSuccess),
+	)
+}
+
+type RegistryConnectionStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	// a random id assigned to this connection request
+	FlowID string
+}
+
+func RegistryConnectionStartTrack(opts *RegistryConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryConnectionStart),
+	)
+}
+
+type RegistryConnectionSuccessTrackOpts struct {
+	*RegistryScopedTrackOpts
+
+	// a random id assigned to this connection request
+	FlowID string
+}
+
+func RegistryConnectionSuccessTrack(opts *RegistryConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentRegistryTrack(
+		opts.RegistryScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryConnectionSuccess),
+	)
+}
+
+type GithubConnectionStartTrackOpts struct {
+	// note that this is a user-scoped track, since github repos are tied to the user
+	*UserScopedTrackOpts
+}
+
+func GithubConnectionStartTrack(opts *GithubConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, GithubConnectionStart),
+	)
+}
+
+type GithubConnectionSuccessTrackOpts struct {
+	// note that this is a user-scoped track, since github repos are tied to the user
+	*UserScopedTrackOpts
+}
+
+func GithubConnectionSuccessTrack(opts *GithubConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, GithubConnectionSuccess),
+	)
+}
+
+type ApplicationLaunchStartTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	FlowID string
+}
+
+func ApplicationLaunchStartTrack(opts *ApplicationLaunchStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationLaunchStart),
+	)
+}
+
+type ApplicationLaunchSuccessTrackOpts struct {
+	*ApplicationScopedTrackOpts
+
+	FlowID string
+}
+
+func ApplicationLaunchSuccessTrack(opts *ApplicationLaunchSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentApplicationTrack(
+		opts.ApplicationScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationLaunchSuccess),
+	)
+}
+
+type ApplicationDeploymentWebhookTrackOpts struct {
+	*ApplicationScopedTrackOpts
+
+	ImageURI string
+}
+
+func ApplicationDeploymentWebhookTrack(opts *ApplicationDeploymentWebhookTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["image_uri"] = opts.ImageURI
+
+	return getSegmentApplicationTrack(
+		opts.ApplicationScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationDeploymentWebhook),
+	)
+}
+
+type RegistryProvisioningStartTrackOpts struct {
+	// note that this is a project-scoped track, since the registry has not been created yet
+	*ProjectScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+func RegistryProvisioningStartTrack(opts *RegistryProvisioningStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningStart),
+	)
+}
+
+type RegistryProvisioningErrorTrackOpts struct {
+	// note that this is a project-scoped track, since the registry has not been created yet
+	*ProjectScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+func RegistryProvisioningErrorTrack(opts *RegistryProvisioningErrorTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningError),
+	)
+}
+
+type RegistryProvisioningSuccessTrackOpts struct {
+	*RegistryScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+func RegistryProvisioningSuccessTrack(opts *RegistryProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentRegistryTrack(
+		opts.RegistryScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningSuccess),
+	)
+}
+
+// HELPERS
+
 type segmentTrack interface {
 	getUserId() string
 	getEvent() SegmentEvent
 	getProperties() segment.Properties
 }
 
-type segmentNewUserTrack struct {
-	userId    string
-	userEmail string
+type defaultTrackOpts struct {
+	AdditionalProps map[string]interface{}
+}
+
+type defaultSegmentTrack struct {
+	event      SegmentEvent
+	properties segmentProperties
+}
+
+func getDefaultSegmentTrack(additionalProps map[string]interface{}, event SegmentEvent) *defaultSegmentTrack {
+	props := newSegmentProperties()
+	props.addAdditionalProperties(additionalProps)
+
+	return &defaultSegmentTrack{
+		event:      event,
+		properties: props,
+	}
+}
+
+func (t *defaultSegmentTrack) getEvent() SegmentEvent {
+	return t.event
 }
 
-// CreateSegmentNewUserTrack creates a track of type "New User", which
-// tracks when a user has registered
-func CreateSegmentNewUserTrack(user *models.User) *segmentNewUserTrack {
-	userId := fmt.Sprintf("%v", user.ID)
+func (t *defaultSegmentTrack) getProperties() segment.Properties {
+	props := segment.NewProperties()
 
-	return &segmentNewUserTrack{
-		userId:    userId,
-		userEmail: user.Email,
+	for key, val := range t.properties {
+		props = props.Set(key, val)
 	}
+
+	return props
+}
+
+type segmentProperties map[string]interface{}
+
+func newSegmentProperties() segmentProperties {
+	props := make(map[string]interface{})
+
+	return props
 }
 
-func (t *segmentNewUserTrack) getUserId() string {
-	return t.userId
+func (p segmentProperties) addProjectProperties(opts *ProjectScopedTrackOpts) {
+	p["proj_id"] = opts.ProjectID
 }
 
-func (t *segmentNewUserTrack) getEvent() SegmentEvent {
-	return NewUser
+func (p segmentProperties) addClusterProperties(opts *ClusterScopedTrackOpts) {
+	p["cluster_id"] = opts.ClusterID
 }
 
-func (t *segmentNewUserTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("email", t.userEmail)
+func (p segmentProperties) addRegistryProperties(opts *RegistryScopedTrackOpts) {
+	p["registry_id"] = opts.RegistryID
 }
 
-type segmentRedeployViaWebhookTrack struct {
-	userId     string
-	repository string
+func (p segmentProperties) addApplicationProperties(opts *ApplicationScopedTrackOpts) {
+	p["app_name"] = opts.Name
+	p["app_namespace"] = opts.Namespace
+	p["chart_name"] = opts.ChartName
 }
 
-// CreateSegmentRedeployViaWebhookTrack creates a track of type "Triggered Re-deploy via Webhook", which
-// tracks whenever a repository is redeployed via webhook call
-func CreateSegmentRedeployViaWebhookTrack(userId string, repository string) *segmentRedeployViaWebhookTrack {
-	return &segmentRedeployViaWebhookTrack{
-		userId:     userId,
-		repository: repository,
+func (p segmentProperties) addAdditionalProperties(props map[string]interface{}) {
+	for key, val := range props {
+		p[key] = val
 	}
 }
 
-func (t *segmentRedeployViaWebhookTrack) getUserId() string {
-	return t.userId
+type UserScopedTrack struct {
+	*defaultSegmentTrack
+
+	userID uint
+}
+
+type UserScopedTrackOpts struct {
+	*defaultTrackOpts
+
+	UserID uint
 }
 
-func (t *segmentRedeployViaWebhookTrack) getEvent() SegmentEvent {
-	return RedeployViaWebhook
+func GetUserScopedTrackOpts(userID uint) *UserScopedTrackOpts {
+	return &UserScopedTrackOpts{
+		UserID: userID,
+	}
 }
 
-func (t *segmentRedeployViaWebhookTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("repository", t.repository)
+func (u *UserScopedTrack) getUserId() string {
+	return fmt.Sprintf("%d", u.userID)
 }
 
-type segmentNewClusterEventTrack struct {
-	userId      string
-	projId      string
-	clusterName string
-	clusterType string // EKS, DOKS, or GKE
-	eventType   string // connected, provisioned, or destroyed
+func getSegmentUserTrack(opts *UserScopedTrackOpts, track *defaultSegmentTrack) *UserScopedTrack {
+	return &UserScopedTrack{
+		defaultSegmentTrack: track,
+		userID:              opts.UserID,
+	}
 }
 
-// NewClusterEventOpts are the parameters for creating a "New Cluster Event" track
-type NewClusterEventOpts struct {
-	UserId      string
-	ProjId      string
-	ClusterName string
-	ClusterType string // EKS, DOKS, or GKE
-	EventType   string // connected, provisioned, or destroyed
+type ProjectScopedTrack struct {
+	*UserScopedTrack
+
+	projectID uint
 }
 
-// CreateSegmentNewClusterEvent creates a track of type "New Cluster Event", which
-// tracks whenever a cluster is newly provisioned, connected, or destroyed.
-func CreateSegmentNewClusterEvent(opts *NewClusterEventOpts) *segmentNewClusterEventTrack {
-	return &segmentNewClusterEventTrack{
-		userId:      opts.UserId,
-		projId:      opts.ProjId,
-		clusterName: opts.ClusterName,
-		clusterType: opts.ClusterType,
-		eventType:   opts.EventType,
+type ProjectScopedTrackOpts struct {
+	*UserScopedTrackOpts
+
+	ProjectID uint
+}
+
+func GetProjectScopedTrackOpts(userID, projID uint) *ProjectScopedTrackOpts {
+	return &ProjectScopedTrackOpts{
+		UserScopedTrackOpts: GetUserScopedTrackOpts(userID),
+		ProjectID:           projID,
+	}
+}
+
+func getSegmentProjectTrack(opts *ProjectScopedTrackOpts, track *defaultSegmentTrack) *ProjectScopedTrack {
+	track.properties.addProjectProperties(opts)
+
+	return &ProjectScopedTrack{
+		UserScopedTrack: getSegmentUserTrack(opts.UserScopedTrackOpts, track),
+		projectID:       opts.ProjectID,
+	}
+}
+
+type RegistryScopedTrack struct {
+	*ProjectScopedTrack
+
+	registryID uint
+}
+
+type RegistryScopedTrackOpts struct {
+	*ProjectScopedTrackOpts
+
+	RegistryID uint
+}
+
+func GetRegistryScopedTrackOpts(userID, projID, regID uint) *RegistryScopedTrackOpts {
+	return &RegistryScopedTrackOpts{
+		ProjectScopedTrackOpts: GetProjectScopedTrackOpts(userID, projID),
+		RegistryID:             regID,
 	}
 }
 
-func (t *segmentNewClusterEventTrack) getUserId() string {
-	return t.userId
+func getSegmentRegistryTrack(opts *RegistryScopedTrackOpts, track *defaultSegmentTrack) *RegistryScopedTrack {
+	track.properties.addRegistryProperties(opts)
+
+	return &RegistryScopedTrack{
+		ProjectScopedTrack: getSegmentProjectTrack(opts.ProjectScopedTrackOpts, track),
+		registryID:         opts.RegistryID,
+	}
 }
 
-func (t *segmentNewClusterEventTrack) getEvent() SegmentEvent {
-	return NewClusterEvent
+type ClusterScopedTrack struct {
+	*ProjectScopedTrack
+
+	clusterID uint
 }
 
-func (t *segmentNewClusterEventTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("Project ID", t.projId).Set("Cluster Name", t.clusterName).Set("Cluster Type", t.clusterType).Set("Event Type", t.eventType)
+type ClusterScopedTrackOpts struct {
+	*ProjectScopedTrackOpts
+
+	ClusterID uint
+}
+
+func GetClusterScopedTrackOpts(userID, projID, clusterID uint) *ClusterScopedTrackOpts {
+	return &ClusterScopedTrackOpts{
+		ProjectScopedTrackOpts: GetProjectScopedTrackOpts(userID, projID),
+		ClusterID:              clusterID,
+	}
+}
+
+func getSegmentClusterTrack(opts *ClusterScopedTrackOpts, track *defaultSegmentTrack) *ClusterScopedTrack {
+	track.properties.addClusterProperties(opts)
+
+	return &ClusterScopedTrack{
+		ProjectScopedTrack: getSegmentProjectTrack(opts.ProjectScopedTrackOpts, track),
+		clusterID:          opts.ClusterID,
+	}
+}
+
+type ApplicationScopedTrack struct {
+	*ClusterScopedTrack
+
+	name      string
+	namespace string
+}
+
+type ApplicationScopedTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	Name      string
+	Namespace string
+	ChartName string
+}
+
+func GetApplicationScopedTrackOpts(userID, projID, clusterID uint, name, namespace, chartName string) *ApplicationScopedTrackOpts {
+	return &ApplicationScopedTrackOpts{
+		ClusterScopedTrackOpts: GetClusterScopedTrackOpts(userID, projID, clusterID),
+		Name:                   name,
+		Namespace:              namespace,
+		ChartName:              chartName,
+	}
+}
+
+func getSegmentApplicationTrack(opts *ApplicationScopedTrackOpts, track *defaultSegmentTrack) *ApplicationScopedTrack {
+	track.properties.addApplicationProperties(opts)
+
+	return &ApplicationScopedTrack{
+		ClusterScopedTrack: getSegmentClusterTrack(opts.ClusterScopedTrackOpts, track),
+		name:               opts.Name,
+		namespace:          opts.Namespace,
+	}
 }

+ 40 - 0
internal/forms/events.go

@@ -0,0 +1,40 @@
+package forms
+
+import (
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateEventForm is the input for creating a new event
+type CreateEventForm struct {
+	ResourceType string    `json:"resource_type"`
+	Name         string    `json:"name"`
+	OwnerType    string    `json:"owner_type"`
+	OwnerName    string    `json:"owner_name"`
+	EventType    string    `json:"event_type"`
+	Namespace    string    `json:"namespace"`
+	Message      string    `json:"message"`
+	Reason       string    `json:"reason"`
+	Timestamp    time.Time `json:"timestamp"`
+	Data         []string  `json:"data"`
+}
+
+func (c *CreateEventForm) ToEvent(projID uint, clusterID uint) *models.Event {
+	return &models.Event{
+		ProjectID:    projID,
+		ClusterID:    clusterID,
+		OwnerType:    c.OwnerType,
+		OwnerName:    c.OwnerName,
+		EventType:    c.EventType,
+		RefType:      c.ResourceType,
+		RefName:      c.Name,
+		RefNamespace: c.Namespace,
+		Message:      c.Message,
+		Reason:       c.Reason,
+		Timestamp:    c.Timestamp,
+		Data:         []byte(strings.Join(c.Data, "\n")),
+		Expiry:       time.Now().Add(24 * 14 * time.Hour),
+	}
+}

+ 19 - 0
internal/kubernetes/agent.go

@@ -249,6 +249,17 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 
 // CreateNamespace creates a namespace with the given name.
 func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	// check if namespace exists
+	checkNS, err := a.Clientset.CoreV1().Namespaces().Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err == nil && checkNS != nil {
+		return checkNS, nil
+	}
+
 	namespace := v1.Namespace{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: name,
@@ -262,6 +273,14 @@ func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
 	)
 }
 
+func (a *Agent) GetPorterAgent() (*appsv1.Deployment, error) {
+	return a.Clientset.AppsV1().Deployments("porter-agent-system").Get(
+		context.TODO(),
+		"porter-agent-controller-manager",
+		metav1.GetOptions{},
+	)
+}
+
 // DeleteNamespace deletes the namespace given the name.
 func (a *Agent) DeleteNamespace(name string) error {
 	return a.Clientset.CoreV1().Namespaces().Delete(

+ 68 - 0
internal/kubernetes/provisioner/global_stream.go

@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/repository"
 
 	redis "github.com/go-redis/redis/v8"
@@ -83,6 +84,7 @@ type ResourceCRUDHandler interface {
 func GlobalStreamListener(
 	client *redis.Client,
 	repo repository.Repository,
+	analyticsClient analytics.AnalyticsSegmentClient,
 	errorChan chan error,
 ) {
 	for {
@@ -163,6 +165,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraEKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.AWS,
@@ -197,6 +207,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraGCR) {
 					reg := &models.Registry{
 						ProjectID:        projID,
@@ -217,6 +235,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraGKE) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.GCP,
@@ -251,6 +277,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraDOCR) {
 					reg := &models.Registry{
 						ProjectID:       projID,
@@ -270,6 +304,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
 				} else if kind == string(models.InfraDOKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:   models.DO,
@@ -304,6 +346,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "error" {
 				infra, err := repo.Infra.ReadInfra(infraID)
@@ -319,6 +369,24 @@ func GlobalStreamListener(
 				if err != nil {
 					continue
 				}
+
+				if infra.Kind == models.InfraDOKS || infra.Kind == models.InfraGKE || infra.Kind == models.InfraEKS {
+					analyticsClient.Track(analytics.ClusterProvisioningErrorTrack(
+						&analytics.ClusterProvisioningErrorTrackOpts{
+							ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				} else if infra.Kind == models.InfraDOCR || infra.Kind == models.InfraGCR || infra.Kind == models.InfraECR {
+					analyticsClient.Track(analytics.RegistryProvisioningErrorTrack(
+						&analytics.RegistryProvisioningErrorTrackOpts{
+							ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID),
+							RegistryType:           infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "destroyed" {
 				infra, err := repo.Infra.ReadInfra(infraID)
 

+ 102 - 0
internal/models/events.go

@@ -0,0 +1,102 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// Event model that refers to a type of event from a Kubernetes cluster
+type Event struct {
+	gorm.Model
+
+	ProjectID uint
+	ClusterID uint
+
+	OwnerType string
+	OwnerName string
+
+	EventType    string
+	RefType      string
+	RefName      string
+	RefNamespace string
+	Message      string
+	Reason       string
+	Data         []byte
+
+	Timestamp time.Time
+	Expiry    time.Time
+}
+
+// EventExternal is an event to be shared over REST
+type EventExternal struct {
+	ID uint `json:"id"`
+
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	OwnerType string `json:"owner_type"`
+	OwnerName string `json:"owner_name"`
+
+	EventType    string    `json:"event_type"`
+	RefType      string    `json:"resource_type"`
+	RefName      string    `json:"name"`
+	RefNamespace string    `json:"namespace"`
+	Message      string    `json:"message"`
+	Reason       string    `json:"reason"`
+	Data         []byte    `json:"data"`
+	Timestamp    time.Time `json:"timestamp"`
+}
+
+type EventExternalSimple struct {
+	ID uint `json:"id"`
+
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	OwnerType string `json:"owner_type"`
+	OwnerName string `json:"owner_name"`
+
+	EventType    string    `json:"event_type"`
+	RefType      string    `json:"resource_type"`
+	RefName      string    `json:"name"`
+	RefNamespace string    `json:"namespace"`
+	Message      string    `json:"message"`
+	Reason       string    `json:"reason"`
+	Timestamp    time.Time `json:"timestamp"`
+}
+
+// Externalize generates an external Event to be shared over REST
+func (e *Event) Externalize() *EventExternal {
+	return &EventExternal{
+		ID:           e.ID,
+		ProjectID:    e.ProjectID,
+		ClusterID:    e.ClusterID,
+		OwnerName:    e.OwnerName,
+		OwnerType:    e.OwnerType,
+		EventType:    e.EventType,
+		RefType:      e.RefType,
+		RefName:      e.RefName,
+		RefNamespace: e.RefNamespace,
+		Reason:       e.Reason,
+		Timestamp:    e.Timestamp,
+		Data:         e.Data,
+	}
+}
+
+// Externalize generates an external Event to be shared over REST
+func (e *Event) ExternalizeSimple() *EventExternalSimple {
+	return &EventExternalSimple{
+		ID:           e.ID,
+		ProjectID:    e.ProjectID,
+		ClusterID:    e.ClusterID,
+		OwnerName:    e.OwnerName,
+		OwnerType:    e.OwnerType,
+		EventType:    e.EventType,
+		RefType:      e.RefType,
+		RefName:      e.RefName,
+		RefNamespace: e.RefNamespace,
+		Reason:       e.Reason,
+		Timestamp:    e.Timestamp,
+	}
+}

+ 3 - 0
internal/models/infra.go

@@ -48,6 +48,9 @@ type Infra struct {
 	// The project that this infra belongs to
 	ProjectID uint `json:"project_id"`
 
+	// The ID of the user that created this infra
+	CreatedByUserID uint
+
 	// Status is the status of the infra
 	Status InfraStatus `json:"status"`
 

+ 31 - 0
internal/repository/event.go

@@ -0,0 +1,31 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// ListEventOpts are the options for listing events
+type ListEventOpts struct {
+	ClusterID uint
+
+	Limit int    `schema:"limit"`
+	Skip  int    `schema:"skip"`
+	Type  string `schema:"type"`
+
+	// can only be "timestamp" for now
+	SortBy string `schema:"sort_by"`
+
+	OwnerType string `schema:"owner_type"`
+	OwnerName string `schema:"owner_name"`
+
+	// Decrypt is whether to decrypt the underlying Data field, which may not be desired
+	// for basic list operations
+	Decrypt bool
+}
+
+// EventRepository represents the set of queries on the
+// Event model
+type EventRepository interface {
+	CreateEvent(event *models.Event) (*models.Event, error)
+	ReadEvent(id uint, projID uint, clusterID uint) (*models.Event, error)
+	ListEventsByProjectID(projectID uint, opts *ListEventOpts) ([]*models.Event, error)
+	DeleteEvent(id uint) error
+}

+ 163 - 0
internal/repository/gorm/event.go

@@ -0,0 +1,163 @@
+package gorm
+
+import (
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// EventRepository uses gorm.DB for querying the database
+type EventRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewEventRepository returns an EventRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewEventRepository(db *gorm.DB, key *[32]byte) repository.EventRepository {
+	return &EventRepository{db, key}
+}
+
+// CreateEvent creates a new kube auth mechanism
+func (repo *EventRepository) CreateEvent(
+	event *models.Event,
+) (*models.Event, error) {
+	err := repo.EncryptEventData(event, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ReadEvent finds an event by id
+func (repo *EventRepository) ReadEvent(
+	id, projID, clusterID uint,
+) (*models.Event, error) {
+	event := &models.Event{}
+
+	// preload Clusters association
+	if err := repo.db.Where(
+		"id = ? AND project_id = ? AND cluster_id = ?",
+		id,
+		projID,
+		clusterID,
+	).First(&event).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptEventData(event, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ListEventsByProjectID finds all events for a given project id
+// with the given options
+func (repo *EventRepository) ListEventsByProjectID(
+	projectID uint,
+	opts *repository.ListEventOpts,
+) ([]*models.Event, error) {
+	listOpts := opts
+
+	if listOpts.Limit == 0 {
+		listOpts.Limit = 50
+	}
+
+	events := []*models.Event{}
+
+	query := repo.db.Where("project_id = ? AND cluster_id = ?", projectID, opts.ClusterID)
+
+	if listOpts.Type != "" {
+		query = query.Where(
+			"ref_type = ?",
+			strings.ToLower(listOpts.Type),
+		)
+	}
+
+	if listOpts.OwnerName != "" && listOpts.OwnerType != "" {
+		query = query.Where(
+			"owner_name = ? AND owner_type = ?",
+			listOpts.OwnerName,
+			listOpts.OwnerType,
+		)
+	}
+
+	query = query.Limit(listOpts.Limit).Offset(listOpts.Skip)
+
+	if listOpts.SortBy == "timestamp" {
+		query = query.Order("timestamp desc").Order("id desc")
+	}
+
+	if err := query.Find(&events).Error; err != nil {
+		return nil, err
+	}
+
+	if opts.Decrypt {
+		for _, event := range events {
+			repo.DecryptEventData(event, repo.key)
+		}
+	}
+
+	return events, nil
+}
+
+// DeleteEvent deletes an event by ID
+func (repo *EventRepository) DeleteEvent(
+	id uint,
+) error {
+	if err := repo.db.Where("id = ?", id).Delete(&models.Event{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// EncryptEventData will encrypt the event data before
+// writing to the DB
+func (repo *EventRepository) EncryptEventData(
+	event *models.Event,
+	key *[32]byte,
+) error {
+	if len(event.Data) > 0 {
+		cipherData, err := repository.Encrypt(event.Data, key)
+
+		if err != nil {
+			return err
+		}
+
+		event.Data = cipherData
+	}
+
+	return nil
+}
+
+// DecryptEventData will decrypt the event data before
+// returning it from the DB
+func (repo *EventRepository) DecryptEventData(
+	event *models.Event,
+	key *[32]byte,
+) error {
+	if len(event.Data) > 0 {
+		plaintext, err := repository.Decrypt(event.Data, key)
+
+		if err != nil {
+			return err
+		}
+
+		event.Data = plaintext
+	}
+
+	return nil
+}

+ 153 - 0
internal/repository/gorm/event_test.go

@@ -0,0 +1,153 @@
+package gorm_test
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+func TestCreateEvent(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_event.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	event := &models.Event{
+		ProjectID:    tester.initProjects[0].Model.ID,
+		ClusterID:    tester.initClusters[0].Model.ID,
+		RefType:      "pod",
+		RefName:      "pod-example-1",
+		RefNamespace: "default",
+		Message:      "Pod killed",
+		Reason:       "OOM: memory limit exceeded",
+		Data:         []byte("log from pod\nlog2 from pod"),
+		Expiry:       time.Now().Add(24 * time.Hour),
+	}
+
+	copyEvent := *event
+
+	event, err := tester.repo.Event.CreateEvent(event)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	event, err = tester.repo.Event.ReadEvent(event.Model.ID, 1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "ecr"
+	if event.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, event.Model.ID)
+	}
+
+	event.Model = gorm.Model{}
+
+	if diff := deep.Equal(event, &copyEvent); diff != nil {
+		t.Errorf("tokens not equal:")
+		t.Error(diff)
+	}
+}
+
+func TestListEventsByProjectIDWithLimit(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListEventsByProjectID(tester, t, &repository.ListEventOpts{
+		ClusterID: 1,
+		Limit:     10,
+		Type:      "node",
+		Decrypt:   true,
+	}, tester.initEvents[50:60])
+}
+
+func TestListEventsByProjectIDWithSkip(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListEventsByProjectID(tester, t, &repository.ListEventOpts{
+		ClusterID: 1,
+		Limit:     25,
+		Skip:      10,
+		Decrypt:   true,
+	}, tester.initEvents[10:35])
+}
+
+func TestListEventsByProjectIDWithSortBy(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListEventsByProjectID(tester, t, &repository.ListEventOpts{
+		ClusterID: 1,
+		Limit:     1,
+		Skip:      0,
+		Type:      "node",
+		Decrypt:   true,
+		SortBy:    "timestamp",
+	}, tester.initEvents[99:])
+}
+
+func testListEventsByProjectID(tester *tester, t *testing.T, opts *repository.ListEventOpts, expEvents []*models.Event) {
+	t.Helper()
+
+	events, err := tester.repo.Event.ListEventsByProjectID(
+		tester.initProjects[0].Model.ID,
+		opts,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure data is correct
+	if len(events) != len(expEvents) {
+		t.Fatalf("length of events incorrect: expected %d, got %d\n", len(expEvents), len(events))
+	}
+
+	for _, expEvent := range expEvents {
+		expEvent.Data = []byte("log from pod\nlog2 from pod")
+	}
+
+	if diff := deep.Equal(expEvents, events); diff != nil {
+		t.Errorf("incorrect events")
+		t.Error(diff)
+	}
+}

+ 46 - 0
internal/repository/gorm/helpers_test.go

@@ -1,6 +1,7 @@
 package gorm_test
 
 import (
+	"fmt"
 	"os"
 	"testing"
 	"time"
@@ -22,6 +23,7 @@ type tester struct {
 	initGRs      []*models.GitRepo
 	initRegs     []*models.Registry
 	initClusters []*models.Cluster
+	initEvents   []*models.Event
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
 	initReleases []*models.Release
@@ -63,6 +65,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.Event{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -496,6 +499,49 @@ func initInfra(tester *tester, t *testing.T) {
 	tester.initInfras = append(tester.initInfras, infra)
 }
 
+func initEvents(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	initEvents := make([]*models.Event, 0)
+
+	// init 100 events for testing purposes
+	for i := 0; i < 100; i++ {
+		refType := "pod"
+
+		if i >= 50 {
+			refType = "node"
+		}
+
+		event := &models.Event{
+			ProjectID:    tester.initProjects[0].Model.ID,
+			ClusterID:    tester.initClusters[0].Model.ID,
+			RefType:      refType,
+			RefName:      fmt.Sprintf("%s-example-%d", refType, i),
+			RefNamespace: "default",
+			Message:      "Pod killed",
+			Reason:       "OOM: memory limit exceeded",
+			Data:         []byte("log from pod\nlog2 from pod"),
+			Expiry:       expiry,
+		}
+
+		event, err := tester.repo.Event.CreateEvent(event)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		initEvents = append(initEvents, event)
+	}
+
+	tester.initEvents = initEvents
+}
+
 func initInvite(tester *tester, t *testing.T) {
 	t.Helper()
 

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

@@ -26,6 +26,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.AuthCode{},
 		&models.DNSRecord{},
 		&models.PWResetToken{},
+		&models.Event{},
 		&models.NotificationConfig{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},

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

@@ -21,6 +21,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GitActionConfig:           NewGitActionConfigRepository(db),
 		Invite:                    NewInviteRepository(db),
 		AuthCode:                  NewAuthCodeRepository(db),
+		Event:                     NewEventRepository(db, key),
 		DNSRecord:                 NewDNSRecordRepository(db),
 		PWResetToken:              NewPWResetTokenRepository(db),
 		KubeIntegration:           NewKubeIntegrationRepository(db, key),

+ 1 - 0
internal/repository/repository.go

@@ -15,6 +15,7 @@ type Repository struct {
 	Invite                    InviteRepository
 	AuthCode                  AuthCodeRepository
 	DNSRecord                 DNSRecordRepository
+	Event                     EventRepository
 	PWResetToken              PWResetTokenRepository
 	KubeIntegration           KubeIntegrationRepository
 	BasicIntegration          BasicIntegrationRepository

+ 177 - 0
server/api/agent_handler.go

@@ -0,0 +1,177 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/kubernetes"
+)
+
+// HandleDeployAgent deploys the agent in the Porter cluster
+func (app *App) HandleDeployAgent(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	chart, err := loader.LoadChartPublic(
+		app.ServerConf.DefaultAddonHelmRepoURL,
+		"porter-agent",
+		"",
+	)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	releaseForm := &forms.ReleaseForm{
+		Form: &helm.Form{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+			Storage:           "secret",
+			Namespace:         "porter-agent-system",
+		},
+	}
+
+	releaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.Repo.Cluster,
+	)
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		releaseForm,
+	)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	// create namespace if not exists
+	_, err = agent.K8sAgent.CreateNamespace("porter-agent-system")
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	// add api token to values
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	jwt, _ := token.GetTokenForAPI(userID, uint(projID))
+
+	encoded, err := jwt.EncodeToken(&token.TokenGeneratorConf{
+		TokenSecret: app.ServerConf.TokenGeneratorSecret,
+	})
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	porterAgentValues := map[string]interface{}{
+		"agent": map[string]interface{}{
+			"image":       "public.ecr.aws/o1j4x7p4/porter-agent:latest",
+			"porterHost":  app.ServerConf.ServerURL,
+			"porterPort":  "443",
+			"porterToken": encoded,
+			"privateRegistry": map[string]interface{}{
+				"enabled": false,
+			},
+			"clusterID": fmt.Sprintf("%d", releaseForm.Cluster.ID),
+			"projectID": fmt.Sprintf("%d", projID),
+		},
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:     chart,
+		Name:      "porter-agent",
+		Namespace: "porter-agent-system",
+		Cluster:   releaseForm.Cluster,
+		Repo:      *app.Repo,
+		Values:    porterAgentValues,
+	}
+
+	_, err = agent.InstallChart(conf, app.DOConf)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseDeploy,
+			Errors: []string{"error installing a new chart: " + err.Error()},
+		}, w)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleDetectPorterAgentInstalled detects if the agent is installed in the cluster
+func (app *App) HandleDetectPorterAgentInstalled(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	// detect prometheus service
+	porterAgent, err := agent.GetPorterAgent()
+
+	if err != nil || porterAgent == nil {
+		http.NotFound(w, r)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}

+ 8 - 6
server/api/api.go

@@ -95,11 +95,13 @@ type App struct {
 	GoogleUserConf    *oauth2.Config
 	SlackConf         *oauth2.Config
 
-	db              *gorm.DB
-	validator       *vr.Validate
-	translator      *ut.Translator
-	tokenConf       *token.TokenGeneratorConf
-	analyticsClient analytics.AnalyticsSegmentClient
+	// analytics client for reporting
+	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	db         *gorm.DB
+	validator  *vr.Validate
+	translator *ut.Translator
+	tokenConf  *token.TokenGeneratorConf
 }
 
 type AppCapabilities struct {
@@ -242,7 +244,7 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
-	app.analyticsClient = newSegmentClient
+	app.AnalyticsClient = newSegmentClient
 
 	app.updateChartRepoURLs()
 

+ 22 - 34
server/api/cluster_handler.go

@@ -2,7 +2,6 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"strconv"
 
@@ -17,7 +16,7 @@ import (
 // HandleCreateProjectCluster creates a new cluster
 func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -57,15 +56,6 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", projID),
-			ClusterName: cluster.Name,
-			ClusterType: "EKS",
-			EventType:   "connected",
-		},
-	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -279,14 +269,7 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 
 	extClusters := make([]*models.ClusterCandidateExternal, 0)
 
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
-
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
+	userID, _ := app.getUserIDFromRequest(r)
 
 	for _, cc := range ccs {
 		// handle write to the database
@@ -297,6 +280,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			return
 		}
 
+		app.AnalyticsClient.Track(analytics.ClusterConnectionStartTrack(
+			&analytics.ClusterConnectionStartTrackOpts{
+				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+				ClusterCandidateID:     cc.ID,
+			},
+		))
+
 		app.Logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
 
 		// if the ClusterCandidate does not have any actions to perform, create the Cluster
@@ -339,6 +329,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			}
 
 			app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
+
+			app.AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+				&analytics.ClusterConnectionSuccessTrackOpts{
+					ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), cluster.ID),
+					ClusterCandidateID:     cc.ID,
+				},
+			))
 		}
 
 		extClusters = append(extClusters, cc.Externalize())
@@ -401,14 +398,7 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
-
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
+	userID, _ := app.getUserIDFromRequest(r)
 
 	// decode actions from request
 	resolver := &models.ClusterResolverAll{}
@@ -447,13 +437,11 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", projID),
-			ClusterName: cluster.Name,
-			ClusterType: "",
-			EventType:   "connected",
+
+	app.AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+		&analytics.ClusterConnectionSuccessTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), cluster.ID),
+			ClusterCandidateID:     uint(candID),
 		},
 	))
 

+ 51 - 2
server/api/deploy_handler.go

@@ -3,18 +3,21 @@ package api
 import (
 	"encoding/json"
 	"fmt"
-	"gorm.io/gorm"
 	"net/http"
 	"net/url"
 	"strconv"
 	"strings"
 
+	"gorm.io/gorm"
+
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"gopkg.in/yaml.v2"
 )
@@ -22,6 +25,8 @@ import (
 // HandleDeployTemplate triggers a chart deployment from a template
 func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -57,6 +62,13 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
+		&analytics.ApplicationLaunchStartTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), uint(clusterID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
@@ -190,12 +202,28 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
+		&analytics.ApplicationLaunchSuccessTrackOpts{
+			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+				userID,
+				uint(projID),
+				uint(clusterID),
+				release.Name,
+				release.Namespace,
+				chart.Metadata.Name,
+			),
+			FlowID: flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusOK)
 }
 
 // HandleDeployAddon triggers a addon deployment from a template
 func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -253,6 +281,13 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
+		&analytics.ApplicationLaunchStartTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), uint(form.ReleaseForm.Cluster.ID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	agent, err := app.getAgentFromReleaseForm(
 		w,
 		r,
@@ -281,7 +316,7 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		Registries: registries,
 	}
 
-	_, err = agent.InstallChart(conf, app.DOConf)
+	rel, err := agent.InstallChart(conf, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -292,6 +327,20 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
+		&analytics.ApplicationLaunchSuccessTrackOpts{
+			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+				userID,
+				uint(projID),
+				uint(form.ReleaseForm.Cluster.ID),
+				rel.Name,
+				rel.Namespace,
+				chart.Metadata.Name,
+			),
+			FlowID: flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusOK)
 }
 

+ 170 - 0
server/api/event_handler.go

@@ -0,0 +1,170 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/gorilla/schema"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// HandleCreateEvent creates a new event in a project
+func (app *App) HandleCreateEvent(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	clusterID, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"cluster not found"},
+		}, w)
+	}
+
+	form := &forms.CreateEventForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an invite
+	event := form.ToEvent(uint(projID), uint(clusterID))
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	event, err = app.Repo.Event.CreateEvent(event)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+}
+
+// HandleListEvents lists the events that match certain conditions in a project
+func (app *App) HandleListEvents(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	clusterID, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"cluster not found"},
+		}, w)
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	opts := &repository.ListEventOpts{
+		ClusterID: uint(clusterID),
+	}
+
+	decoder := schema.NewDecoder()
+
+	decoder.IgnoreUnknownKeys(true)
+
+	if err := decoder.Decode(opts, vals); err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"bad request"},
+		}, w)
+	}
+
+	// handle write to the database
+	events, err := app.Repo.Event.ListEventsByProjectID(uint(projID), opts)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	eventExts := make([]*models.EventExternalSimple, 0)
+
+	for _, event := range events {
+		eventExts = append(eventExts, event.ExternalizeSimple())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(eventExts); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListEvents lists the events that match certain conditions in a project
+func (app *App) HandleGetEvent(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	clusterID, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"cluster not found"},
+		}, w)
+	}
+
+	eventID, err := strconv.ParseUint(chi.URLParam(r, "event_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	event, err := app.Repo.Event.ReadEvent(uint(eventID), uint(projID), uint(clusterID))
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	eventExt := event.Externalize()
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(eventExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 9 - 0
server/api/integration_handler.go

@@ -16,6 +16,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
@@ -481,6 +482,14 @@ func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request)
 
 // HandleGithubAppOauthInit redirects the user to the Porter github app authorization page
 func (app *App) HandleGithubAppOauthInit(w http.ResponseWriter, r *http.Request) {
+	userID, _ := app.getUserIDFromRequest(r)
+
+	app.AnalyticsClient.Track(analytics.GithubConnectionStartTrack(
+		&analytics.GithubConnectionStartTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(userID),
+		},
+	))
+
 	http.Redirect(w, r, app.GithubAppConf.AuthCodeURL("", oauth2.AccessTypeOffline), 302)
 }
 

+ 11 - 2
server/api/oauth_github_handler.go

@@ -131,8 +131,11 @@ func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request
 		}
 
 		// send to segment
-		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
-		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		}))
 
 		// log the user in
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
@@ -352,6 +355,12 @@ func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.GithubConnectionSuccessTrack(
+		&analytics.GithubConnectionSuccessTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		},
+	))
+
 	if session.Values["query_params"] != "" {
 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
 	} else {

+ 4 - 2
server/api/oauth_google_handler.go

@@ -95,9 +95,11 @@ func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request
 	}
 
 	// send to segment
-	app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
+	app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 
-	app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+	app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+	}))
 
 	// log the user in
 	app.Logger.Info().Msgf("New user created: %d", user.ID)

+ 5 - 0
server/api/project_handler.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -72,6 +73,10 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
+		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, projModel.ID),
+	}))
+
 	app.Logger.Info().Msgf("New project created: %d", projModel.ID)
 
 	w.WriteHeader(http.StatusCreated)

+ 62 - 53
server/api/provision_handler.go

@@ -7,8 +7,6 @@ import (
 
 	"github.com/go-chi/chi"
 
-	"fmt"
-
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -22,6 +20,7 @@ import (
 // container pod
 func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -46,6 +45,8 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -147,6 +148,7 @@ func (app *App) HandleDestroyTestInfra(w http.ResponseWriter, r *http.Request) {
 // HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
 func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -177,6 +179,8 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -219,6 +223,14 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 
 	app.Logger.Info().Msgf("New aws ecr infra created: %d", infra.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraECR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	infraExt := infra.Externalize()
@@ -336,6 +348,8 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -378,13 +392,12 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New aws eks infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.EKSName,
-			ClusterType: "EKS",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraEKS,
+			InfraID:                infra.ID,
 		},
 	))
 
@@ -402,7 +415,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -469,15 +482,6 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("AWS EKS infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.EKSName,
-			ClusterType: "EKS",
-			EventType:   "destroyed",
-		},
-	))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -485,6 +489,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 // HandleProvisionGCPGCRInfra enables GCR for a project
 func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -515,6 +520,8 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -556,6 +563,14 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 
 	app.Logger.Info().Msgf("New gcp gcr infra created: %d", infra.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraGCR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	infraExt := infra.Externalize()
@@ -600,6 +615,8 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -641,13 +658,12 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New gcp gke infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.GKEName,
-			ClusterType: "GKE",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraGKE,
+			InfraID:                infra.ID,
 		},
 	))
 
@@ -665,7 +681,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -731,15 +747,6 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("GCP GKE infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.GKEName,
-			ClusterType: "GKE",
-			EventType:   "destroyed",
-		},
-	))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -791,6 +798,7 @@ func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request
 // HandleProvisionDODOCRInfra provisions a new digitalocean DOCR instance for a project
 func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -821,6 +829,8 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -865,6 +875,14 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 
 	app.Logger.Info().Msgf("New do docr infra created: %d", infra.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraDOCR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	infraExt := infra.Externalize()
@@ -984,6 +1002,8 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
@@ -1027,13 +1047,12 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New do doks infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.DOKSName,
-			ClusterType: "DOKS",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraDOKS,
+			InfraID:                infra.ID,
 		},
 	))
 
@@ -1051,7 +1070,6 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -1119,15 +1137,6 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	app.Logger.Info().Msgf("DO DOKS infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.DOKSName,
-			ClusterType: "DOKS",
-			EventType:   "destroyed",
-		},
-	))
 
 	w.WriteHeader(http.StatusOK)
 }

+ 17 - 1
server/api/registry_handler.go

@@ -8,8 +8,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/oauth"
-
 	"github.com/porter-dev/porter/internal/registry"
 
 	"github.com/go-chi/chi"
@@ -22,12 +22,21 @@ import (
 // HandleCreateRegistry creates a new registry
 func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.RegistryConnectionStartTrack(
+		&analytics.RegistryConnectionStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	form := &forms.CreateRegistry{
 		ProjectID: uint(projID),
 	}
@@ -62,6 +71,13 @@ func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 
 	app.Logger.Info().Msgf("New registry created: %d", registry.ID)
 
+	app.AnalyticsClient.Track(analytics.RegistryConnectionSuccessTrack(
+		&analytics.RegistryConnectionSuccessTrackOpts{
+			RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(userID, uint(projID), registry.ID),
+			FlowID:                  flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 
 	regExt := registry.Externalize()

+ 14 - 3
server/api/release_handler.go

@@ -1059,8 +1059,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		notifyOpts.Status = slack.StatusFailed
 		notifyOpts.Info = upgradeErr.Error()
 
-		slackErr := notifier.Notify(notifyOpts)
-		fmt.Println("SLACK ERROR IS", slackErr)
+		notifier.Notify(notifyOpts)
 
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
@@ -1316,7 +1315,19 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 
 	notifier.Notify(notifyOpts)
 
-	app.analyticsClient.Track(analytics.CreateSegmentRedeployViaWebhookTrack("anonymous", repository.(string)))
+	userID, _ := app.getUserIDFromRequest(r)
+
+	app.AnalyticsClient.Track(analytics.ApplicationDeploymentWebhookTrack(&analytics.ApplicationDeploymentWebhookTrackOpts{
+		ImageURI: fmt.Sprintf("%v", repository),
+		ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+			userID,
+			release.ProjectID,
+			release.ClusterID,
+			release.Name,
+			release.Namespace,
+			rel.Chart.Metadata.Name,
+		),
+	}))
 
 	w.WriteHeader(http.StatusOK)
 }

+ 5 - 2
server/api/user_handler.go

@@ -54,8 +54,11 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		// send to segment
-		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, false))
-		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		}))
 
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
 

+ 72 - 0
server/router/router.go

@@ -420,6 +420,49 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			// /api/projects/{project_id}/events routes
+			r.Method(
+				"POST",
+				"/projects/{project_id}/clusters/{cluster_id}/events",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateEvent, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/events",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleListEvents, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/events/{event_id}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetEvent, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/invites routes
 			r.Method(
 				"POST",
@@ -1324,6 +1367,35 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			// api/projects/{project_id}/agent routes
+			r.Method(
+				"POST",
+				"/projects/{project_id}/agent/deploy",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeployAgent, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/agent/detect",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDetectPorterAgentInstalled, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/k8s routes
 			r.Method(
 				"GET",