diff --git a/apps/spruce/cypress/integration/image/event_log.ts b/apps/spruce/cypress/integration/image/event_log.ts new file mode 100644 index 000000000..519c1f825 --- /dev/null +++ b/apps/spruce/cypress/integration/image/event_log.ts @@ -0,0 +1,24 @@ +describe("event log page", () => { + const IMAGE_EVENT_LIMIT = 5; + it("load more button should return twice as many events", () => { + cy.visit("/image/ubuntu2204/event-log"); + cy.dataCy("image-event-log-card").should("have.length", IMAGE_EVENT_LIMIT); + cy.dataCy("load-more-button").click(); + cy.dataCy("image-event-log-card").should( + "have.length", + 2 * IMAGE_EVENT_LIMIT, + ); + }); + + it("should show no events when filtering for a nonexistent item", () => { + cy.visit("/image/ubuntu2204/event-log"); + cy.dataCy("image-event-log-card").should("have.length", IMAGE_EVENT_LIMIT); + cy.dataCy("image-event-log-name-filter").first().click(); + cy.get('input[placeholder="Search name"]').type("bogus{enter}"); + cy.dataCy("image-event-log-card") + .first() + .within(() => { + cy.dataCy("image-event-log-table-row").should("have.length", 0); + }); + }); +}); diff --git a/apps/spruce/src/components/Settings/EventLog/index.ts b/apps/spruce/src/components/Settings/EventLog/index.ts index c061ae102..54530044c 100644 --- a/apps/spruce/src/components/Settings/EventLog/index.ts +++ b/apps/spruce/src/components/Settings/EventLog/index.ts @@ -1,4 +1,4 @@ export { EventDiffTable } from "./EventDiffTable"; export { EventLog } from "./EventLog"; -export { EVENT_LIMIT, useEvents } from "./useEvents"; +export { useEvents } from "hooks/useEvents"; export type { Event } from "./types"; diff --git a/apps/spruce/src/constants/externalResources.ts b/apps/spruce/src/constants/externalResources.ts index f44a238f0..fffdf5fad 100644 --- a/apps/spruce/src/constants/externalResources.ts +++ b/apps/spruce/src/constants/externalResources.ts @@ -158,3 +158,8 @@ export const getHoneycombSystemMetricsUrl = ( }; export const adminSettingsURL = `${getUiUrl()}/admin`; + +export const buildHostConfigurationRepoURL = + "https://github.com/10gen/buildhost-configuration"; +export const buildHostPostConfigRepoURL = + "https://github.com/10gen/buildhost-post-config"; diff --git a/apps/spruce/src/gql/client/cache.ts b/apps/spruce/src/gql/client/cache.ts index c027f8fa0..c28fd0a3e 100644 --- a/apps/spruce/src/gql/client/cache.ts +++ b/apps/spruce/src/gql/client/cache.ts @@ -1,4 +1,5 @@ import { InMemoryCache } from "@apollo/client"; +import { IMAGE_EVENT_LIMIT } from "pages/image/tabs/EventLogTab/useImageEvents"; export const cache = new InMemoryCache({ typePolicies: { @@ -35,6 +36,31 @@ export const cache = new InMemoryCache({ }, }, }, + Image: { + fields: { + events: { + keyArgs: ["$imageId"], + merge(existing, incoming, { args }) { + const { + count: existingCount = 0, + eventLogEntries: existingEntries = [], + } = existing || {}; + const { count: incomingCount, eventLogEntries: incomingEntries } = + incoming; + const count = existingCount + incomingCount; + const page = args?.page ?? 0; + const merged = existingEntries ? existingEntries.slice(0) : []; + for (let i = 0; i < incomingEntries.length; ++i) { + merged[page * IMAGE_EVENT_LIMIT + i] = incomingEntries[i]; + } + return { + count, + eventLogEntries: merged, + }; + }, + }, + }, + }, ProjectEvents: { fields: { count: { diff --git a/apps/spruce/src/gql/generated/types.ts b/apps/spruce/src/gql/generated/types.ts index 92ad82c5d..cd827070d 100644 --- a/apps/spruce/src/gql/generated/types.ts +++ b/apps/spruce/src/gql/generated/types.ts @@ -6254,6 +6254,38 @@ export type ImageDistrosQuery = { } | null; }; +export type ImageEventsQueryVariables = Exact<{ + imageId: Scalars["String"]["input"]; + limit: Scalars["Int"]["input"]; + page: Scalars["Int"]["input"]; +}>; + +export type ImageEventsQuery = { + __typename?: "Query"; + image?: { + __typename?: "Image"; + id: string; + events: { + __typename?: "ImageEventsPayload"; + count: number; + eventLogEntries: Array<{ + __typename?: "ImageEvent"; + amiAfter: string; + amiBefore?: string | null; + timestamp: Date; + entries: Array<{ + __typename?: "ImageEventEntry"; + action: ImageEventEntryAction; + after: string; + before: string; + name: string; + type: ImageEventType; + }>; + }>; + }; + } | null; +}; + export type ImagePackagesQueryVariables = Exact<{ imageId: Scalars["String"]["input"]; opts: PackageOpts; diff --git a/apps/spruce/src/gql/queries/image-events.graphql b/apps/spruce/src/gql/queries/image-events.graphql new file mode 100644 index 000000000..f430d6a25 --- /dev/null +++ b/apps/spruce/src/gql/queries/image-events.graphql @@ -0,0 +1,20 @@ +query ImageEvents($imageId: String!, $limit: Int!, $page: Int!) { + image(imageId: $imageId) { + events(limit: $limit, page: $page) { + count + eventLogEntries { + amiAfter + amiBefore + entries { + action + after + before + name + type + } + timestamp + } + } + id + } +} diff --git a/apps/spruce/src/gql/queries/index.ts b/apps/spruce/src/gql/queries/index.ts index cb62d77dc..c4a52e023 100644 --- a/apps/spruce/src/gql/queries/index.ts +++ b/apps/spruce/src/gql/queries/index.ts @@ -24,6 +24,7 @@ import HOST_EVENTS from "./host-events.graphql"; import HOST from "./host.graphql"; import HOSTS from "./hosts.graphql"; import IMAGE_DISTROS from "./image-distros.graphql"; +import IMAGE_EVENTS from "./image-events.graphql"; import IMAGE_PACKAGES from "./image-packages.graphql"; import IMAGES from "./images.graphql"; import INSTANCE_TYPES from "./instance-types.graphql"; @@ -111,6 +112,7 @@ export { HOST, HOSTS, IMAGE_DISTROS, + IMAGE_EVENTS, IMAGE_PACKAGES, IMAGES, INSTANCE_TYPES, diff --git a/apps/spruce/src/components/Settings/EventLog/useEvents.ts b/apps/spruce/src/hooks/useEvents/index.ts similarity index 94% rename from apps/spruce/src/components/Settings/EventLog/useEvents.ts rename to apps/spruce/src/hooks/useEvents/index.ts index 19826d05f..19632f607 100644 --- a/apps/spruce/src/components/Settings/EventLog/useEvents.ts +++ b/apps/spruce/src/hooks/useEvents/index.ts @@ -1,7 +1,5 @@ import { useState } from "react"; -export const EVENT_LIMIT = 15; - export const useEvents = (limit: number) => { const [allEventsFetched, setAllEventsFetched] = useState(false); const [prevCount, setPrevCount] = useState(0); diff --git a/apps/spruce/src/pages/distroSettings/tabs/EventLogTab/useDistroEvents.ts b/apps/spruce/src/pages/distroSettings/tabs/EventLogTab/useDistroEvents.ts index 5fe39d9e7..40289e0c8 100644 --- a/apps/spruce/src/pages/distroSettings/tabs/EventLogTab/useDistroEvents.ts +++ b/apps/spruce/src/pages/distroSettings/tabs/EventLogTab/useDistroEvents.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useQuery } from "@apollo/client"; -import { EVENT_LIMIT, useEvents } from "components/Settings/EventLog"; +import { useEvents } from "components/Settings/EventLog"; import { useToastContext } from "context/toast"; import { DistroEventsQuery, @@ -8,9 +8,11 @@ import { } from "gql/generated/types"; import { DISTRO_EVENTS } from "gql/queries"; +const DISTRO_EVENT_LIMIT = 15; + export const useDistroEvents = ( distroId: string, - limit: number = EVENT_LIMIT, + limit: number = DISTRO_EVENT_LIMIT, ) => { const dispatchToast = useToastContext(); diff --git a/apps/spruce/src/pages/image/ImageEventLog/Header.tsx b/apps/spruce/src/pages/image/ImageEventLog/Header.tsx new file mode 100644 index 000000000..f6c8ea007 --- /dev/null +++ b/apps/spruce/src/pages/image/ImageEventLog/Header.tsx @@ -0,0 +1,36 @@ +import styled from "@emotion/styled"; +import { Disclaimer, Subtitle } from "@leafygreen-ui/typography"; +import { size } from "constants/tokens"; +import { useDateFormat } from "hooks"; + +interface HeaderProps { + amiBefore: string; + amiAfter: string; + timestamp: Date; +} + +export const Header: React.FC = ({ + amiAfter, + amiBefore, + timestamp, +}) => { + const getDateCopy = useDateFormat(); + + return ( + + + {getDateCopy(timestamp)} + + + AMI changed from {amiBefore} to {amiAfter} + + + ); +}; + +const StyledHeader = styled.div` + display: flex; + flex-direction: column; + gap: ${size.xxs}; + padding-bottom: ${size.s}; +`; diff --git a/apps/spruce/src/pages/image/ImageEventLog/ImageEventLog.tsx b/apps/spruce/src/pages/image/ImageEventLog/ImageEventLog.tsx new file mode 100644 index 000000000..dc6c74c10 --- /dev/null +++ b/apps/spruce/src/pages/image/ImageEventLog/ImageEventLog.tsx @@ -0,0 +1,69 @@ +import styled from "@emotion/styled"; +import Card from "@leafygreen-ui/card"; +import { Subtitle } from "@leafygreen-ui/typography"; +import { LoadingButton } from "components/Buttons"; +import { size } from "constants/tokens"; +import { ImageEvent } from "gql/generated/types"; +import { Header } from "./Header"; +import { ImageEventLogTable } from "./ImageEventLogTable"; + +type ImageEventLogProps = { + allEventsFetched: boolean; + events: ImageEvent[]; + handleFetchMore: () => void; + loading?: boolean; +}; + +export const ImageEventLog: React.FC = ({ + allEventsFetched, + events, + handleFetchMore, + loading, +}) => { + const allEventsFetchedCopy = + events.length > 0 ? "No more events to show." : "No events to show."; + + return ( + + {events.map((event) => { + const { amiAfter, amiBefore, entries, timestamp } = event; + return ( + +
+ + + ); + })} + {!allEventsFetched && events.length > 0 && ( + + Load more events + + )} + {allEventsFetched && {allEventsFetchedCopy}} + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: ${size.l}; +`; + +const ImageEventLogCard = styled(Card)` + width: 100%; + padding: ${size.m}; +`; diff --git a/apps/spruce/src/pages/image/ImageEventLog/ImageEventLogTable.tsx b/apps/spruce/src/pages/image/ImageEventLog/ImageEventLogTable.tsx new file mode 100644 index 000000000..739717667 --- /dev/null +++ b/apps/spruce/src/pages/image/ImageEventLog/ImageEventLogTable.tsx @@ -0,0 +1,168 @@ +import { useRef, useState } from "react"; +import styled from "@emotion/styled"; +import Badge, { Variant } from "@leafygreen-ui/badge"; +import { + ColumnFiltersState, + useLeafyGreenTable, + LGColumnDef, + filterFns, + getFilteredRowModel, + getFacetedUniqueValues, +} from "@leafygreen-ui/table"; +import { toSentenceCase } from "@evg-ui/lib/utils/string"; +import { BaseTable } from "components/Table/BaseTable"; +import { tableColumnOffset } from "constants/tokens"; +import { + ImageEventEntry, + ImageEventEntryAction, + ImageEventType, +} from "gql/generated/types"; + +const imageEventEntryActionTreeData = [ + { + title: "ADDED", + value: ImageEventEntryAction.Added, + key: ImageEventEntryAction.Added, + }, + { + title: "UPDATED", + value: ImageEventEntryAction.Updated, + key: ImageEventEntryAction.Updated, + }, + { + title: "DELETED", + value: ImageEventEntryAction.Deleted, + key: ImageEventEntryAction.Deleted, + }, +]; + +const imageEventTypeTreeData = [ + { + title: "Package", + value: ImageEventType.Package, + key: ImageEventType.Package, + }, + { + title: "Toolchain", + value: ImageEventType.Toolchain, + key: ImageEventType.Toolchain, + }, + { + title: "Operating System", + value: ImageEventType.OperatingSystem, + key: ImageEventType.OperatingSystem, + }, +]; + +interface ImageEventLogTableProps { + entries: ImageEventEntry[]; +} + +export const ImageEventLogTable: React.FC = ({ + entries, +}) => { + const [columnFilters, setColumnFilters] = useState([]); + const tableContainerRef = useRef(null); + const table = useLeafyGreenTable({ + columns, + containerRef: tableContainerRef, + data: entries, + defaultColumn: { + enableColumnFilter: false, + }, + onColumnFiltersChange: setColumnFilters, + state: { + columnFilters, + }, + getFilteredRowModel: getFilteredRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + const hasFilters = columnFilters.length > 0; + + const emptyMessage = hasFilters + ? "No data to display" + : "No changes detected within the scope. The scope can be expanded upon request from the runtime environments team."; + + return ( + + {emptyMessage} + + } + /> + ); +}; + +const columns: LGColumnDef[] = [ + { + header: "Name", + accessorKey: "name", + enableColumnFilter: true, + filterFn: filterFns.includesString, + meta: { + search: { + "data-cy": "image-event-log-name-filter", + placeholder: "Search name", + }, + }, + }, + { + header: "Type", + accessorKey: "type", + cell: ({ getValue }) => { + const value = getValue() as ImageEventType; + return toSentenceCase(value); + }, + enableColumnFilter: true, + filterFn: filterFns.arrIncludesSome, + meta: { + treeSelect: { + "data-cy": "image-event-log-type-filter", + filterOptions: true, + options: imageEventTypeTreeData, + }, + }, + }, + { + header: "Before", + accessorKey: "before", + }, + { + header: "After", + accessorKey: "after", + }, + { + header: "Action", + accessorKey: "action", + enableColumnFilter: true, + filterFn: filterFns.arrIncludesSome, + meta: { + treeSelect: { + "data-cy": "image-event-log-action-filter", + filterOptions: true, + options: imageEventEntryActionTreeData, + }, + }, + cell: ({ getValue }) => { + const value = getValue() as ImageEventEntryAction; + if (value === ImageEventEntryAction.Updated) { + return {value}; + } + if (value === ImageEventEntryAction.Deleted) { + return {value}; + } + if (value === ImageEventEntryAction.Added) { + return {value}; + } + }, + }, +]; + +const DefaultEmptyMessage = styled.span` + margin-left: ${tableColumnOffset}; +`; diff --git a/apps/spruce/src/pages/image/ImageEventLog/index.ts b/apps/spruce/src/pages/image/ImageEventLog/index.ts new file mode 100644 index 000000000..103e47754 --- /dev/null +++ b/apps/spruce/src/pages/image/ImageEventLog/index.ts @@ -0,0 +1 @@ +export { ImageEventLog } from "./ImageEventLog"; diff --git a/apps/spruce/src/pages/image/Tabs.tsx b/apps/spruce/src/pages/image/Tabs.tsx index 625af71d8..9d12e0356 100644 --- a/apps/spruce/src/pages/image/Tabs.tsx +++ b/apps/spruce/src/pages/image/Tabs.tsx @@ -3,7 +3,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { ImageTabRoutes } from "constants/routes"; import useScrollToAnchor from "hooks/useScrollToAnchor"; import { Header } from "./Header"; -import { BuildInformationTab } from "./tabs/index"; +import { BuildInformationTab, EventLogTab } from "./tabs/index"; type ImageTabsProps = { imageId: string; @@ -28,6 +28,10 @@ export const ImageTabs: React.FC = ({ path={ImageTabRoutes.BuildInformation} element={} /> + } + /> ); diff --git a/apps/spruce/src/pages/image/tabs/EventLogTab/EventLogTab.test.tsx b/apps/spruce/src/pages/image/tabs/EventLogTab/EventLogTab.test.tsx new file mode 100644 index 000000000..749ad6024 --- /dev/null +++ b/apps/spruce/src/pages/image/tabs/EventLogTab/EventLogTab.test.tsx @@ -0,0 +1,469 @@ +import { MockedProvider } from "@apollo/client/testing"; +import { RenderFakeToastContext } from "context/toast/__mocks__"; +import { + ImageEventType, + ImageEventsQuery, + ImageEventsQueryVariables, + ImageEventEntryAction, +} from "gql/generated/types"; +import { IMAGE_EVENTS } from "gql/queries"; +import { + renderWithRouterMatch as render, + screen, + waitFor, + within, + userEvent, +} from "test_utils"; +import { ApolloMock } from "types/gql"; +import { EventLogTab } from "./EventLogTab"; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); +enum Column { + Name = 0, + Type = 1, + Before = 2, + After = 3, + Action = 4, +} + +describe("image event log page", async () => { + it("does not show a load more button when all events are shown", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + + // The load more button should not be present on the page because there are no more events. + await waitFor(() => { + expect(screen.getByDataCy("load-more-button")).toBeInTheDocument(); + }); + expect( + screen.queryByText("No more events to show."), + ).not.toBeInTheDocument(); + }); + + it("shows proper timestamps", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + + // Expect correct timestamps on the page. + const timestampElements = screen.queryAllByDataCy("event-log-timestamp"); + expect(timestampElements).toHaveLength(5); + const expectedTimestamps = [ + "Aug 7, 2024, 9:57:00 PM UTC", + "Aug 7, 2023, 9:57:00 PM UTC", + "Aug 7, 2022, 9:57:00 PM UTC", + "Aug 7, 2021, 9:57:00 PM UTC", + "Aug 7, 2020, 9:57:00 PM UTC", + ]; + + timestampElements.forEach((element, index) => { + expect(element.textContent?.trim()).toBe(expectedTimestamps[index]); + }); + }); + + it("shows proper AMI changes", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + + // Expect correct AMI text on the page. + const amiElements = screen.queryAllByDataCy("event-log-ami"); + expect(amiElements).toHaveLength(5); + const expectedAmiTexts = [ + "AMI changed from ami-03e245926032896f9 to ami-03bfb241d1718c8a2", + "AMI changed from ami-03e24592603281234 to ami-03e245926032896f9", + "AMI changed from ami-03e24592603281235 to ami-03e24592603281234", + "AMI changed from ami-03e24592603281236 to ami-03e24592603281235", + "AMI changed from ami-03e24592603281237 to ami-03e24592603281236", + ]; + amiElements.forEach((element, index) => { + expect(element.textContent?.trim()).toBe(expectedAmiTexts[index]); + }); + }); + + it("shows proper text for cards with empty tables", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + + const expectedEmptyMessage = + "No changes detected within the scope. The scope can be expanded upon request from the runtime environments team."; + + const cards = screen.getAllByDataCy("image-event-log-card"); + expect( + within(cards[0]).queryByDataCy("image-event-log-empty-message"), + ).toBeNull(); + + // Expects cards to contain the empty message. + for (let i = 1; i <= 4; i++) { + const emptyMessageElement = within(cards[i]).getByDataCy( + "image-event-log-empty-message", + ); + expect(emptyMessageElement).toHaveTextContent(expectedEmptyMessage); + } + }); + + it("shows proper name field table entries", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + + // Expect each row of the table to have the correct name. + expect(within(rows[0]).getAllByRole("cell")[Column.Name]).toHaveTextContent( + "apache2-bin", + ); + expect(within(rows[1]).getAllByRole("cell")[Column.Name]).toHaveTextContent( + "golang", + ); + expect(within(rows[2]).getAllByRole("cell")[Column.Name]).toHaveTextContent( + "containerd.io", + ); + }); + + it("supports name field filter for existing entries", async () => { + const user = userEvent.setup(); + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + await user.click(within(card0).getByDataCy("image-event-log-name-filter")); + const searchBar = screen.getByPlaceholderText("Search name"); + + // Filter for golang. + await user.type(searchBar, "golang{enter}"); + expect(searchBar).toHaveValue("golang"); + await waitFor(() => { + expect( + within(card0).queryAllByDataCy("image-event-log-table-row"), + ).toHaveLength(1); + }); + }); + + it("supports name field filter for non-existent entries", async () => { + const user = userEvent.setup(); + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + await user.click(within(card0).getByDataCy("image-event-log-name-filter")); + const searchBar = screen.getByPlaceholderText("Search name"); + + // Filter for nonexistent item. + await user.type(searchBar, "blahblah{enter}"); + await waitFor(() => { + expect(searchBar).toHaveValue("blahblah"); + }); + await waitFor(() => { + expect( + within(card0).queryAllByDataCy("image-event-log-table-row"), + ).toHaveLength(0); + }); + }); + + it("shows proper type field table entries", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + + // Expect each row to display the correct type. + expect(within(rows[0]).getAllByRole("cell")[Column.Type]).toHaveTextContent( + "Package", + ); + expect(within(rows[1]).getAllByRole("cell")[Column.Type]).toHaveTextContent( + "Toolchain", + ); + expect(within(rows[2]).getAllByRole("cell")[Column.Type]).toHaveTextContent( + "Package", + ); + }); + + it("supports type field filter", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + within(card0).getByDataCy("image-event-log-type-filter").click(); + const treeSelectOptions = await screen.findByDataCy("tree-select-options"); + + // Set filter to Toolchain. + within(treeSelectOptions).getByText("Toolchain").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(1); + }); + + // Clear filter. + within(treeSelectOptions).getByText("Toolchain").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(3); + }); + + // Set filter to Package. + within(treeSelectOptions).getByText("Package").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(2); + }); + + // Clear filter. + within(treeSelectOptions).getByText("Package").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(3); + }); + }); + + it("shows proper before field table entries", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + + // Expect each row to have the correct before version. + expect( + within(rows[0]).getAllByRole("cell")[Column.Before], + ).toHaveTextContent("2.4.52-1ubuntu4.8"); + expect( + within(rows[1]).getAllByRole("cell")[Column.Before], + ).toHaveTextContent(""); + expect( + within(rows[2]).getAllByRole("cell")[Column.Before], + ).toHaveTextContent("1.6.28-1"); + }); + + it("shows proper after field table entries", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + + // Expect each row to have the correct after version. + expect( + within(rows[0]).getAllByRole("cell")[Column.After], + ).toHaveTextContent("2.4.52-1ubuntu4.10"); + expect( + within(rows[1]).getAllByRole("cell")[Column.After], + ).toHaveTextContent("go1.21.13"); + expect( + within(rows[2]).getAllByRole("cell")[Column.After], + ).toHaveTextContent(""); + }); + + it("shows proper action field table entries", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + + // Expect each row to have the correct action. + expect( + within(rows[0]).getAllByRole("cell")[Column.Action], + ).toHaveTextContent(ImageEventEntryAction.Updated); + expect( + within(rows[1]).getAllByRole("cell")[Column.Action], + ).toHaveTextContent(ImageEventEntryAction.Added); + expect( + within(rows[2]).getAllByRole("cell")[Column.Action], + ).toHaveTextContent(ImageEventEntryAction.Deleted); + }); + + it("supports filtering for action field", async () => { + const { Component } = RenderFakeToastContext( + , + ); + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryAllByDataCy("image-event-log-card")).toHaveLength(5); + }); + const card0 = screen.getAllByDataCy("image-event-log-card")[0]; + within(card0).getByDataCy("image-event-log-action-filter").click(); + const treeSelectOptions = await screen.findByDataCy("tree-select-options"); + + // Filter for UPDATED field. + within(treeSelectOptions).getByText("UPDATED").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(1); + }); + + // Clear filter. + within(treeSelectOptions).getByText("UPDATED").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(3); + }); + + // Filter for ADDED field. + within(treeSelectOptions).getByText("ADDED").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(1); + }); + + // Clear filter. + within(treeSelectOptions).getByText("ADDED").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(3); + }); + + // Filter for DELETED field. + within(treeSelectOptions).getByText("DELETED").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(1); + }); + + // Clear filter. + within(treeSelectOptions).getByText("DELETED").click(); + await waitFor(() => { + const rows = within(card0).getAllByDataCy("image-event-log-table-row"); + expect(rows).toHaveLength(3); + }); + }); +}); + +const imageEventsMock: ApolloMock = + { + request: { + query: IMAGE_EVENTS, + variables: { + imageId: "ubuntu2204", + limit: 5, + page: 0, + }, + }, + result: { + data: { + image: { + __typename: "Image", + id: "ubuntu2204", + events: { + __typename: "ImageEventsPayload", + count: 5, + eventLogEntries: [ + { + __typename: "ImageEvent", + amiAfter: "ami-03bfb241d1718c8a2", + amiBefore: "ami-03e245926032896f9", + entries: [ + { + __typename: "ImageEventEntry", + type: ImageEventType.Package, + name: "apache2-bin", + before: "2.4.52-1ubuntu4.8", + after: "2.4.52-1ubuntu4.10", + action: ImageEventEntryAction.Updated, + }, + { + __typename: "ImageEventEntry", + type: ImageEventType.Toolchain, + name: "golang", + before: "", + after: "go1.21.13", + action: ImageEventEntryAction.Added, + }, + { + __typename: "ImageEventEntry", + type: ImageEventType.Package, + name: "containerd.io", + before: "1.6.28-1", + after: "", + action: ImageEventEntryAction.Deleted, + }, + ], + timestamp: new Date("2024-08-07T17:57:00-04:00"), + }, + { + __typename: "ImageEvent", + amiAfter: "ami-03e245926032896f9", + amiBefore: "ami-03e24592603281234", + entries: [], + timestamp: new Date("2023-08-07T17:57:00-04:00"), + }, + { + __typename: "ImageEvent", + amiAfter: "ami-03e24592603281234", + amiBefore: "ami-03e24592603281235", + entries: [], + timestamp: new Date("2022-08-07T17:57:00-04:00"), + }, + { + __typename: "ImageEvent", + amiAfter: "ami-03e24592603281235", + amiBefore: "ami-03e24592603281236", + entries: [], + timestamp: new Date("2021-08-07T17:57:00-04:00"), + }, + { + __typename: "ImageEvent", + amiAfter: "ami-03e24592603281236", + amiBefore: "ami-03e24592603281237", + entries: [], + timestamp: new Date("2020-08-07T17:57:00-04:00"), + }, + ], + }, + }, + }, + }, + }; diff --git a/apps/spruce/src/pages/image/tabs/EventLogTab/EventLogTab.tsx b/apps/spruce/src/pages/image/tabs/EventLogTab/EventLogTab.tsx new file mode 100755 index 000000000..1c880b69d --- /dev/null +++ b/apps/spruce/src/pages/image/tabs/EventLogTab/EventLogTab.tsx @@ -0,0 +1,67 @@ +import styled from "@emotion/styled"; +import { palette } from "@leafygreen-ui/palette"; +import { Body, BodyProps } from "@leafygreen-ui/typography"; +import { StyledLink } from "components/styles"; +import { + buildHostConfigurationRepoURL, + buildHostPostConfigRepoURL, +} from "constants/externalResources"; +import { size } from "constants/tokens"; +import { ImageEventLog } from "pages/image/ImageEventLog"; +import { IMAGE_EVENT_LIMIT } from "pages/image/tabs/EventLogTab/useImageEvents"; +import { useImageEvents } from "./useImageEvents"; + +const { gray } = palette; + +type EventLogTabProps = { + imageId: string; +}; + +export const EventLogTab: React.FC = ({ imageId }) => { + const { allEventsFetched, events, fetchMore, loading } = + useImageEvents(imageId); + + return ( + <> + + + With the exception of static hosts, AMI changes correspond to changes + in the{" "} + + buildhost-configuration + {" "} + and{" "} + + buildhost-post-config + {" "} + repos. + + + { + fetchMore({ + variables: { + imageId, + page: Math.floor(events.length / IMAGE_EVENT_LIMIT), + }, + }); + }} + loading={loading} + /> + + ); +}; + +const Container = styled.div` + align-items: start; + display: flex; + justify-content: space-between; + margin-bottom: ${size.l}; + color: ${gray}; +`; + +const StyledBody = styled(Body)` + color: ${gray.base}; +`; diff --git a/apps/spruce/src/pages/image/tabs/EventLogTab/useImageEvents.ts b/apps/spruce/src/pages/image/tabs/EventLogTab/useImageEvents.ts new file mode 100755 index 000000000..de708a343 --- /dev/null +++ b/apps/spruce/src/pages/image/tabs/EventLogTab/useImageEvents.ts @@ -0,0 +1,52 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@apollo/client"; +import { useToastContext } from "context/toast"; +import { + ImageEventsQuery, + ImageEventsQueryVariables, +} from "gql/generated/types"; +import { IMAGE_EVENTS } from "gql/queries"; +import { useEvents } from "hooks/useEvents"; + +export const IMAGE_EVENT_LIMIT = 5; + +export const useImageEvents = ( + imageId: string, + page: number = 0, + limit: number = IMAGE_EVENT_LIMIT, +) => { + const dispatchToast = useToastContext(); + + const { allEventsFetched, onCompleted, setPrevCount } = + useEvents(IMAGE_EVENT_LIMIT); + const { data, fetchMore, loading, previousData } = useQuery< + ImageEventsQuery, + ImageEventsQueryVariables + >(IMAGE_EVENTS, { + variables: { + imageId, + limit, + page, + }, + notifyOnNetworkStatusChange: true, + onCompleted: ({ image }) => onCompleted(image?.events?.count ?? 0), + onError: (e) => { + dispatchToast.error(e.message); + }, + }); + const events = useMemo( + () => data?.image?.events?.eventLogEntries ?? [], + [data], + ); + + useEffect(() => { + setPrevCount(previousData?.image?.events?.count ?? 0); + }, [previousData, setPrevCount]); + + return { + allEventsFetched, + events, + fetchMore, + loading, + }; +}; diff --git a/apps/spruce/src/pages/image/tabs/index.tsx b/apps/spruce/src/pages/image/tabs/index.tsx index c11546104..bc176d30b 100644 --- a/apps/spruce/src/pages/image/tabs/index.tsx +++ b/apps/spruce/src/pages/image/tabs/index.tsx @@ -1 +1,2 @@ +export { EventLogTab } from "./EventLogTab/EventLogTab"; export { BuildInformationTab } from "./BuildInformationTab/index"; diff --git a/apps/spruce/src/pages/projectSettings/tabs/EventLogTab/useProjectSettingsEvents.ts b/apps/spruce/src/pages/projectSettings/tabs/EventLogTab/useProjectSettingsEvents.ts index 47b18646d..5e2cb6bee 100644 --- a/apps/spruce/src/pages/projectSettings/tabs/EventLogTab/useProjectSettingsEvents.ts +++ b/apps/spruce/src/pages/projectSettings/tabs/EventLogTab/useProjectSettingsEvents.ts @@ -1,6 +1,5 @@ import { useEffect } from "react"; import { useQuery } from "@apollo/client"; -import { EVENT_LIMIT, useEvents } from "components/Settings/EventLog"; import { useToastContext } from "context/toast"; import { ProjectEventLogsQuery, @@ -9,11 +8,14 @@ import { RepoEventLogsQueryVariables, } from "gql/generated/types"; import { PROJECT_EVENT_LOGS, REPO_EVENT_LOGS } from "gql/queries"; +import { useEvents } from "hooks/useEvents"; + +const PROJECT_EVENT_LIMIT = 15; export const useProjectSettingsEvents = ( projectIdentifier: string, isRepo: boolean, - limit: number = EVENT_LIMIT, + limit: number = PROJECT_EVENT_LIMIT, ) => { const dispatchToast = useToastContext();