From b5260d569996f5eee185ae692ca66f29e13512c4 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:11:31 +1000 Subject: [PATCH] feat(site): add connection log page (#18708) This is the fourth PR for moving connection events out of the audit log. This PR adds `/connectionlog` to the frontend. This page is identical in structure to the audit log, but with different filters and contents. The connection log lists sessions, and the time they start. If we support tracking the end time of a session, and we've received a disconnect event for that session, the end timestamp is also included. Demo: https://github.com/user-attachments/assets/e0fff799-0ed6-45f7-a8c0-237839659ef9 image image Since the styling is identical to that of the audit log, I've continued to use MUI table components. When the audit log is migrated off MUI/restyled, this table can be too, relatively easily. Future PRs: - Write a query to delete old events from the audit log, call it from dbpurge. - Write documentation for the endpoint / feature --- site/src/api/api.ts | 8 + site/src/api/queries/connectionlog.ts | 24 +++ site/src/components/Filter/UserFilter.tsx | 5 +- site/src/components/StatusPill/StatusPill.tsx | 43 ++++ .../dashboard/Navbar/DeploymentDropdown.tsx | 15 ++ .../modules/dashboard/Navbar/MobileMenu.tsx | 10 + site/src/modules/dashboard/Navbar/Navbar.tsx | 3 + .../dashboard/Navbar/NavbarView.test.tsx | 4 + .../modules/dashboard/Navbar/NavbarView.tsx | 4 + site/src/modules/permissions/index.ts | 7 + site/src/pages/AuditPage/AuditFilter.tsx | 15 +- .../AuditPage/AuditLogRow/AuditLogRow.tsx | 41 +--- .../ConnectionLogPage/ConnectionLogFilter.tsx | 157 ++++++++++++++ .../ConnectionLogHelpTooltip.tsx | 35 ++++ .../ConnectionLogPage.test.tsx | 129 ++++++++++++ .../ConnectionLogPage/ConnectionLogPage.tsx | 99 +++++++++ .../ConnectionLogPageView.stories.tsx | 95 +++++++++ .../ConnectionLogPageView.tsx | 146 +++++++++++++ .../ConnectionLogDescription.stories.tsx | 105 ++++++++++ .../ConnectionLogDescription.tsx | 95 +++++++++ .../ConnectionLogRow.stories.tsx | 74 +++++++ .../ConnectionLogRow/ConnectionLogRow.tsx | 195 ++++++++++++++++++ site/src/router.tsx | 3 + site/src/testHelpers/entities.ts | 90 ++++++++ site/src/utils/connection.ts | 33 +++ site/src/utils/http.ts | 16 ++ 26 files changed, 1406 insertions(+), 45 deletions(-) create mode 100644 site/src/api/queries/connectionlog.ts create mode 100644 site/src/components/StatusPill/StatusPill.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogFilter.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogHelpTooltip.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogPage.test.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.stories.tsx create mode 100644 site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.tsx create mode 100644 site/src/utils/connection.ts create mode 100644 site/src/utils/http.ts diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7c10188648..013c018d5c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1813,6 +1813,14 @@ class ApiMethods { return response.data; }; + getConnectionLogs = async ( + options: TypesGen.ConnectionLogsRequest, + ): Promise => { + const url = getURLWithSearchParams("/api/v2/connectionlog", options); + const response = await this.axios.get(url); + return response.data; + }; + getTemplateDAUs = async ( templateId: string, ): Promise => { diff --git a/site/src/api/queries/connectionlog.ts b/site/src/api/queries/connectionlog.ts new file mode 100644 index 0000000000..9fbeb3f9e7 --- /dev/null +++ b/site/src/api/queries/connectionlog.ts @@ -0,0 +1,24 @@ +import { API } from "api/api"; +import type { ConnectionLogResponse } from "api/typesGenerated"; +import { useFilterParamsKey } from "components/Filter/Filter"; +import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; + +export function paginatedConnectionLogs( + searchParams: URLSearchParams, +): UsePaginatedQueryOptions { + return { + searchParams, + queryPayload: () => searchParams.get(useFilterParamsKey) ?? "", + queryKey: ({ payload, pageNumber }) => { + return ["connectionLogs", payload, pageNumber] as const; + }, + queryFn: ({ payload, limit, offset }) => { + return API.getConnectionLogs({ + offset, + limit, + q: payload, + }); + }, + prefetch: false, + }; +} diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 3dc591cd4a..0663d3d8d9 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -82,14 +82,15 @@ export type UserFilterMenu = ReturnType; interface UserMenuProps { menu: UserFilterMenu; + placeholder?: string; width?: number; } -export const UserMenu: FC = ({ menu, width }) => { +export const UserMenu: FC = ({ menu, width, placeholder }) => { return ( = ({ + code, + isHttpCode, + label, +}) => { + const pill = ( + + {code.toString()} + + ); + if (!label) { + return pill; + } + return ( + + + {pill} + {label} + + + ); +}; diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 9659a70ea3..f7376d99dd 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -16,6 +16,7 @@ interface DeploymentDropdownProps { canViewDeployment: boolean; canViewOrganizations: boolean; canViewAuditLog: boolean; + canViewConnectionLog: boolean; canViewHealth: boolean; } @@ -23,12 +24,14 @@ export const DeploymentDropdown: FC = ({ canViewDeployment, canViewOrganizations, canViewAuditLog, + canViewConnectionLog, canViewHealth, }) => { const theme = useTheme(); if ( !canViewAuditLog && + !canViewConnectionLog && !canViewOrganizations && !canViewDeployment && !canViewHealth @@ -59,6 +62,7 @@ export const DeploymentDropdown: FC = ({ canViewDeployment={canViewDeployment} canViewOrganizations={canViewOrganizations} canViewAuditLog={canViewAuditLog} + canViewConnectionLog={canViewConnectionLog} canViewHealth={canViewHealth} /> @@ -71,6 +75,7 @@ const DeploymentDropdownContent: FC = ({ canViewOrganizations, canViewAuditLog, canViewHealth, + canViewConnectionLog, }) => { const popover = usePopover(); @@ -108,6 +113,16 @@ const DeploymentDropdownContent: FC = ({ Audit Logs )} + {canViewConnectionLog && ( + + Connection Logs + + )} {canViewHealth && ( = ({ canViewDeployment, canViewOrganizations, canViewAuditLog, + canViewConnectionLog, canViewHealth, }) => { const [open, setOpen] = useState(false); @@ -237,6 +239,14 @@ const AdminSettingsSub: FC = ({ Audit logs )} + {canViewConnectionLog && ( + + Connection logs + + )} {canViewHealth && ( { const canViewHealth = permissions.viewDebugInfo; const canViewAuditLog = featureVisibility.audit_log && permissions.viewAnyAuditLog; + const canViewConnectionLog = + featureVisibility.connection_log && permissions.viewAnyConnectionLog; return ( { canViewOrganizations={canViewOrganizations} canViewHealth={canViewHealth} canViewAuditLog={canViewAuditLog} + canViewConnectionLog={canViewConnectionLog} proxyContextValue={proxyContextValue} /> ); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index 358b717b49..4c43e6a087 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -33,6 +33,7 @@ describe("NavbarView", () => { canViewOrganizations canViewHealth canViewAuditLog + canViewConnectionLog />, ); const workspacesLink = @@ -50,6 +51,7 @@ describe("NavbarView", () => { canViewOrganizations canViewHealth canViewAuditLog + canViewConnectionLog />, ); const templatesLink = @@ -67,6 +69,7 @@ describe("NavbarView", () => { canViewOrganizations canViewHealth canViewAuditLog + canViewConnectionLog />, ); const deploymentMenu = await screen.findByText("Admin settings"); @@ -85,6 +88,7 @@ describe("NavbarView", () => { canViewOrganizations canViewHealth canViewAuditLog + canViewConnectionLog />, ); const deploymentMenu = await screen.findByText("Admin settings"); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index d83b0e8b69..7b1bd9fc53 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -24,6 +24,7 @@ interface NavbarViewProps { canViewDeployment: boolean; canViewOrganizations: boolean; canViewAuditLog: boolean; + canViewConnectionLog: boolean; canViewHealth: boolean; proxyContextValue?: ProxyContextValue; } @@ -44,6 +45,7 @@ export const NavbarView: FC = ({ canViewOrganizations, canViewHealth, canViewAuditLog, + canViewConnectionLog, proxyContextValue, }) => { const webPush = useWebpushNotifications(); @@ -73,6 +75,7 @@ export const NavbarView: FC = ({ canViewOrganizations={canViewOrganizations} canViewDeployment={canViewDeployment} canViewHealth={canViewHealth} + canViewConnectionLog={canViewConnectionLog} /> @@ -124,6 +127,7 @@ export const NavbarView: FC = ({ supportLinks={supportLinks} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} + canViewConnectionLog={canViewConnectionLog} canViewOrganizations={canViewOrganizations} canViewDeployment={canViewDeployment} canViewHealth={canViewHealth} diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index 16d01d113f..db48e61411 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -156,6 +156,13 @@ export const permissionChecks = { }, action: "read", }, + viewAnyConnectionLog: { + object: { + resource_type: "connection_log", + any_org: true, + }, + action: "read", + }, viewDebugInfo: { object: { resource_type: "debug_info", diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index a1c1bc57d8..c625a7d607 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -82,10 +82,17 @@ export const useActionFilterMenu = ({ value, onChange, }: Pick) => { - const actionOptions: SelectFilterOption[] = AuditActions.map((action) => ({ - value: action, - label: capitalize(action), - })); + const actionOptions: SelectFilterOption[] = AuditActions + // TODO(ethanndickson): Logs with these action types are no longer produced. + // Until we remove them from the database and API, we shouldn't suggest them + // in the filter dropdown. + .filter( + (action) => !["connect", "disconnect", "open", "close"].includes(action), + ) + .map((action) => ({ + value: action, + label: capitalize(action), + })); return useFilterMenu({ onChange, value, diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx index a123e83214..73ab52da5c 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx @@ -6,14 +6,13 @@ import Tooltip from "@mui/material/Tooltip"; import type { AuditLog } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; -import { Pill } from "components/Pill/Pill"; import { Stack } from "components/Stack/Stack"; +import { StatusPill } from "components/StatusPill/StatusPill"; import { TimelineEntry } from "components/Timeline/TimelineEntry"; import { InfoIcon } from "lucide-react"; import { NetworkIcon } from "lucide-react"; import { type FC, useState } from "react"; import { Link as RouterLink } from "react-router-dom"; -import type { ThemeRole } from "theme/roles"; import userAgentParser from "ua-parser-js"; import { AuditLogDescription } from "./AuditLogDescription/AuditLogDescription"; import { AuditLogDiff } from "./AuditLogDiff/AuditLogDiff"; @@ -22,21 +21,6 @@ import { determineIdPSyncMappingDiff, } from "./AuditLogDiff/auditUtils"; -const httpStatusColor = (httpStatus: number): ThemeRole => { - // Treat server errors (500) as errors - if (httpStatus >= 500) { - return "error"; - } - - // Treat client errors (400) as warnings - if (httpStatus >= 400) { - return "warning"; - } - - // OK (200) and redirects (300) are successful - return "success"; -}; - interface AuditLogRowProps { auditLog: AuditLog; // Useful for Storybook @@ -139,7 +123,7 @@ export const AuditLogRow: FC = ({ - + {/* With multi-org, there is not enough space so show everything in a tooltip. */} @@ -243,19 +227,6 @@ export const AuditLogRow: FC = ({ ); }; -function StatusPill({ code }: { code: number }) { - const isHttp = code >= 100; - - return ( - - {code.toString()} - - ); -} - const styles = { auditLogCell: { padding: "0 !important", @@ -311,14 +282,6 @@ const styles = { width: "100%", }, - statusCodePill: { - fontSize: 10, - height: 20, - paddingLeft: 10, - paddingRight: 10, - fontWeight: 600, - }, - deletedLabel: (theme) => ({ ...(theme.typography.caption as CSSObject), color: theme.palette.text.secondary, diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogFilter.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogFilter.tsx new file mode 100644 index 0000000000..9d049c4e68 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogFilter.tsx @@ -0,0 +1,157 @@ +import { ConnectionLogStatuses, ConnectionTypes } from "api/typesGenerated"; +import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; +import { + SelectFilter, + type SelectFilterOption, +} from "components/Filter/SelectFilter"; +import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; +import { + type UseFilterMenuOptions, + useFilterMenu, +} from "components/Filter/menu"; +import capitalize from "lodash/capitalize"; +import { + type OrganizationsFilterMenu, + OrganizationsMenu, +} from "modules/tableFiltering/options"; +import type { FC } from "react"; +import { connectionTypeToFriendlyName } from "utils/connection"; +import { docs } from "utils/docs"; + +const PRESET_FILTERS = [ + { + query: "status:connected type:ssh", + name: "Active SSH connections", + }, +]; + +interface ConnectionLogFilterProps { + filter: ReturnType; + error?: unknown; + menus: { + user: UserFilterMenu; + status: StatusFilterMenu; + type: TypeFilterMenu; + // The organization menu is only provided in a multi-org setup. + organization?: OrganizationsFilterMenu; + }; +} + +export const ConnectionLogFilter: FC = ({ + filter, + error, + menus, +}) => { + const width = menus.organization ? 175 : undefined; + + return ( + + + + + {menus.organization && ( + + )} + + } + optionsSkeleton={ + <> + + + + {menus.organization && } + + } + /> + ); +}; + +export const useStatusFilterMenu = ({ + value, + onChange, +}: Pick) => { + const statusOptions: SelectFilterOption[] = ConnectionLogStatuses.map( + (status) => ({ + value: status, + label: capitalize(status), + }), + ); + return useFilterMenu({ + onChange, + value, + id: "status", + getSelectedOption: async () => + statusOptions.find((option) => option.value === value) ?? null, + getOptions: async () => statusOptions, + }); +}; + +type StatusFilterMenu = ReturnType; + +interface StatusMenuProps { + menu: StatusFilterMenu; + width?: number; +} + +const StatusMenu: FC = ({ menu, width }) => { + return ( + + ); +}; + +export const useTypeFilterMenu = ({ + value, + onChange, +}: Pick) => { + const typeOptions: SelectFilterOption[] = ConnectionTypes.map((type) => { + const label: string = connectionTypeToFriendlyName(type); + return { + value: type, + label, + }; + }); + return useFilterMenu({ + onChange, + value, + id: "connection_type", + getSelectedOption: async () => + typeOptions.find((option) => option.value === value) ?? null, + getOptions: async () => typeOptions, + }); +}; + +type TypeFilterMenu = ReturnType; + +interface TypeMenuProps { + menu: TypeFilterMenu; + width?: number; +} + +const TypeMenu: FC = ({ menu, width }) => { + return ( + + ); +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogHelpTooltip.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogHelpTooltip.tsx new file mode 100644 index 0000000000..be87c6e8a8 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogHelpTooltip.tsx @@ -0,0 +1,35 @@ +import { + HelpTooltip, + HelpTooltipContent, + HelpTooltipLink, + HelpTooltipLinksGroup, + HelpTooltipText, + HelpTooltipTitle, + HelpTooltipTrigger, +} from "components/HelpTooltip/HelpTooltip"; +import type { FC } from "react"; +import { docs } from "utils/docs"; + +const Language = { + title: "Why are some events missing?", + body: "The connection log is a best-effort log of workspace access. Some events are reported by workspace agents, and receipt of these events by the server is not guaranteed.", + docs: "Connection log documentation", +}; + +export const ConnectionLogHelpTooltip: FC = () => { + return ( + + + + + {Language.title} + {Language.body} + + + {Language.docs} + + + + + ); +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPage.test.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPage.test.tsx new file mode 100644 index 0000000000..7beea3f033 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPage.test.tsx @@ -0,0 +1,129 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { API } from "api/api"; +import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils"; +import { http, HttpResponse } from "msw"; +import { + MockConnectedSSHConnectionLog, + MockDisconnectedSSHConnectionLog, + MockEntitlementsWithConnectionLog, +} from "testHelpers/entities"; +import { + renderWithAuth, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers"; +import { server } from "testHelpers/server"; +import * as CreateDayString from "utils/createDayString"; +import ConnectionLogPage from "./ConnectionLogPage"; + +interface RenderPageOptions { + filter?: string; + page?: number; +} + +const renderPage = async ({ filter, page }: RenderPageOptions = {}) => { + let route = "/connectionlog"; + const params = new URLSearchParams(); + + if (filter) { + params.set("filter", filter); + } + + if (page) { + params.set("page", page.toString()); + } + + if (Array.from(params).length > 0) { + route += `?${params.toString()}`; + } + + renderWithAuth(, { + route, + path: "/connectionlog", + }); + await waitForLoaderToBeRemoved(); +}; + +describe("ConnectionLogPage", () => { + beforeEach(() => { + // Mocking the dayjs module within the createDayString file + const mock = jest.spyOn(CreateDayString, "createDayString"); + mock.mockImplementation(() => "a minute ago"); + + // Mock the entitlements + server.use( + http.get("/api/v2/entitlements", () => { + return HttpResponse.json(MockEntitlementsWithConnectionLog); + }), + ); + }); + + it("renders page 5", async () => { + // Given + const page = 5; + const getConnectionLogsSpy = jest + .spyOn(API, "getConnectionLogs") + .mockResolvedValue({ + connection_logs: [ + MockConnectedSSHConnectionLog, + MockDisconnectedSSHConnectionLog, + ], + count: 2, + }); + + // When + await renderPage({ page: page }); + + // Then + expect(getConnectionLogsSpy).toHaveBeenCalledWith({ + limit: DEFAULT_RECORDS_PER_PAGE, + offset: DEFAULT_RECORDS_PER_PAGE * (page - 1), + q: "", + }); + screen.getByTestId( + `connection-log-row-${MockConnectedSSHConnectionLog.id}`, + ); + screen.getByTestId( + `connection-log-row-${MockDisconnectedSSHConnectionLog.id}`, + ); + }); + + describe("Filtering", () => { + it("filters by URL", async () => { + const getConnectionLogsSpy = jest + .spyOn(API, "getConnectionLogs") + .mockResolvedValue({ + connection_logs: [MockConnectedSSHConnectionLog], + count: 1, + }); + + const query = "type:ssh status:connected"; + await renderPage({ filter: query }); + + expect(getConnectionLogsSpy).toHaveBeenCalledWith({ + limit: DEFAULT_RECORDS_PER_PAGE, + offset: 0, + q: query, + }); + }); + + it("resets page to 1 when filter is changed", async () => { + await renderPage({ page: 2 }); + + const getConnectionLogsSpy = jest.spyOn(API, "getConnectionLogs"); + getConnectionLogsSpy.mockClear(); + + const filterField = screen.getByLabelText("Filter"); + const query = "type:ssh status:connected"; + await userEvent.type(filterField, query); + + await waitFor(() => + expect(getConnectionLogsSpy).toHaveBeenCalledWith({ + limit: DEFAULT_RECORDS_PER_PAGE, + offset: 0, + q: query, + }), + ); + }); + }); +}); diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx new file mode 100644 index 0000000000..9cd27bac95 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx @@ -0,0 +1,99 @@ +import { paginatedConnectionLogs } from "api/queries/connectionlog"; +import { useFilter } from "components/Filter/Filter"; +import { useUserFilterMenu } from "components/Filter/UserFilter"; +import { isNonInitialPage } from "components/PaginationWidget/utils"; +import { usePaginatedQuery } from "hooks/usePaginatedQuery"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { useOrganizationsFilterMenu } from "modules/tableFiltering/options"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useSearchParams } from "react-router-dom"; +import { pageTitle } from "utils/page"; +import { useStatusFilterMenu, useTypeFilterMenu } from "./ConnectionLogFilter"; +import { ConnectionLogPageView } from "./ConnectionLogPageView"; + +const ConnectionLogPage: FC = () => { + const feats = useFeatureVisibility(); + + // The "else false" is required if connection_log is undefined, which may + // happen if the license is removed. + // + // see: https://github.com/coder/coder/issues/14798 + const isConnectionLogVisible = feats.connection_log || false; + + const { showOrganizations } = useDashboard(); + + const [searchParams, setSearchParams] = useSearchParams(); + const connectionlogsQuery = usePaginatedQuery( + paginatedConnectionLogs(searchParams), + ); + const filter = useFilter({ + searchParamsResult: [searchParams, setSearchParams], + onUpdate: connectionlogsQuery.goToFirstPage, + }); + + const userMenu = useUserFilterMenu({ + value: filter.values.workspace_owner, + onChange: (option) => + filter.update({ + ...filter.values, + workspace_owner: option?.value, + }), + }); + + const statusMenu = useStatusFilterMenu({ + value: filter.values.status, + onChange: (option) => + filter.update({ + ...filter.values, + status: option?.value, + }), + }); + + const typeMenu = useTypeFilterMenu({ + value: filter.values.type, + onChange: (option) => + filter.update({ + ...filter.values, + type: option?.value, + }), + }); + + const organizationsMenu = useOrganizationsFilterMenu({ + value: filter.values.organization, + onChange: (option) => + filter.update({ + ...filter.values, + organization: option?.value, + }), + }); + + return ( + <> + + {pageTitle("Connection Log")} + + + + + ); +}; + +export default ConnectionLogPage; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx new file mode 100644 index 0000000000..3931272804 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockMenu, + getDefaultFilterProps, +} from "components/Filter/storyHelpers"; +import { + mockInitialRenderResult, + mockSuccessResult, +} from "components/PaginationWidget/PaginationContainer.mocks"; +import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; +import type { ComponentProps } from "react"; +import { chromaticWithTablet } from "testHelpers/chromatic"; +import { + MockConnectedSSHConnectionLog, + MockDisconnectedSSHConnectionLog, + MockUserOwner, +} from "testHelpers/entities"; +import { ConnectionLogPageView } from "./ConnectionLogPageView"; + +type FilterProps = ComponentProps["filterProps"]; + +const defaultFilterProps = getDefaultFilterProps({ + query: `username:${MockUserOwner.username}`, + values: { + username: MockUserOwner.username, + status: undefined, + type: undefined, + organization: undefined, + }, + menus: { + user: MockMenu, + status: MockMenu, + type: MockMenu, + }, +}); + +const meta: Meta = { + title: "pages/ConnectionLogPage", + component: ConnectionLogPageView, + args: { + connectionLogs: [ + MockConnectedSSHConnectionLog, + MockDisconnectedSSHConnectionLog, + ], + isConnectionLogVisible: true, + filterProps: defaultFilterProps, + }, +}; + +export default meta; +type Story = StoryObj; + +export const ConnectionLog: Story = { + parameters: { chromatic: chromaticWithTablet }, + args: { + connectionLogsQuery: mockSuccessResult, + }, +}; + +export const Loading: Story = { + args: { + connectionLogs: undefined, + isNonInitialPage: false, + connectionLogsQuery: mockInitialRenderResult, + }, +}; + +export const EmptyPage: Story = { + args: { + connectionLogs: [], + isNonInitialPage: true, + connectionLogsQuery: { + ...mockSuccessResult, + totalRecords: 0, + } as UsePaginatedQueryResult, + }, +}; + +export const NoLogs: Story = { + args: { + connectionLogs: [], + isNonInitialPage: false, + connectionLogsQuery: { + ...mockSuccessResult, + totalRecords: 0, + } as UsePaginatedQueryResult, + }, +}; + +export const NotVisible: Story = { + args: { + isConnectionLogVisible: false, + connectionLogsQuery: mockInitialRenderResult, + }, +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx new file mode 100644 index 0000000000..fe3840d098 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx @@ -0,0 +1,146 @@ +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableRow from "@mui/material/TableRow"; +import type { ConnectionLog } from "api/typesGenerated"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Margins } from "components/Margins/Margins"; +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader"; +import { + PaginationContainer, + type PaginationResult, +} from "components/PaginationWidget/PaginationContainer"; +import { Paywall } from "components/Paywall/Paywall"; +import { Stack } from "components/Stack/Stack"; +import { TableLoader } from "components/TableLoader/TableLoader"; +import { Timeline } from "components/Timeline/Timeline"; +import type { ComponentProps, FC } from "react"; +import { docs } from "utils/docs"; +import { ConnectionLogFilter } from "./ConnectionLogFilter"; +import { ConnectionLogHelpTooltip } from "./ConnectionLogHelpTooltip"; +import { ConnectionLogRow } from "./ConnectionLogRow/ConnectionLogRow"; + +const Language = { + title: "Connection Log", + subtitle: "View workspace connection events.", +}; + +interface ConnectionLogPageViewProps { + connectionLogs?: readonly ConnectionLog[]; + isNonInitialPage: boolean; + isConnectionLogVisible: boolean; + error?: unknown; + filterProps: ComponentProps; + connectionLogsQuery: PaginationResult; +} + +export const ConnectionLogPageView: FC = ({ + connectionLogs, + isNonInitialPage, + isConnectionLogVisible, + error, + filterProps, + connectionLogsQuery: paginationResult, +}) => { + const isLoading = + (connectionLogs === undefined || + paginationResult.totalRecords === undefined) && + !error; + + const isEmpty = !isLoading && connectionLogs?.length === 0; + + return ( + + + + + {Language.title} + + + + {Language.subtitle} + + + + + + + + + + + + {/* Error condition should just show an empty table. */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {connectionLogs && ( + new Date(log.connect_time)} + row={(log) => ( + + )} + /> + )} + + + +
+
+
+
+ + + + +
+
+ ); +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx new file mode 100644 index 0000000000..8c8263e7db --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockConnectedSSHConnectionLog, + MockWebConnectionLog, +} from "testHelpers/entities"; +import { ConnectionLogDescription } from "./ConnectionLogDescription"; + +const meta: Meta = { + title: "pages/ConnectionLogPage/ConnectionLogDescription", + component: ConnectionLogDescription, +}; + +export default meta; +type Story = StoryObj; + +export const SSH: Story = { + args: { + connectionLog: MockConnectedSSHConnectionLog, + }, +}; + +export const App: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + }, + }, +}; + +export const AppUnauthenticated: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + web_info: { + ...MockWebConnectionLog.web_info!, + user: null, + }, + }, + }, +}; + +export const AppAuthenticatedFail: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + web_info: { + ...MockWebConnectionLog.web_info!, + status_code: 404, + }, + }, + }, +}; + +export const PortForwardingAuthenticated: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "port_forwarding", + web_info: { + ...MockWebConnectionLog.web_info!, + slug_or_port: "8080", + }, + }, + }, +}; + +export const AppUnauthenticatedRedirect: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + web_info: { + ...MockWebConnectionLog.web_info!, + user: null, + status_code: 303, + }, + }, + }, +}; + +export const VSCode: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "vscode", + }, + }, +}; + +export const JetBrains: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "jetbrains", + }, + }, +}; + +export const WebTerminal: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "reconnecting_pty", + }, + }, +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx new file mode 100644 index 0000000000..b862134624 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx @@ -0,0 +1,95 @@ +import Link from "@mui/material/Link"; +import type { ConnectionLog } from "api/typesGenerated"; +import type { FC, ReactNode } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { connectionTypeToFriendlyName } from "utils/connection"; + +interface ConnectionLogDescriptionProps { + connectionLog: ConnectionLog; +} + +export const ConnectionLogDescription: FC = ({ + connectionLog, +}) => { + const { type, workspace_owner_username, workspace_name, web_info } = + connectionLog; + + switch (type) { + case "port_forwarding": + case "workspace_app": { + if (!web_info) return null; + + const { user, slug_or_port, status_code } = web_info; + const isPortForward = type === "port_forwarding"; + const presentAction = isPortForward ? "access" : "open"; + const pastAction = isPortForward ? "accessed" : "opened"; + + const target: ReactNode = isPortForward ? ( + <> + port {slug_or_port} + + ) : ( + {slug_or_port} + ); + + const actionText: ReactNode = (() => { + if (status_code === 303) { + return ( + <> + was redirected attempting to {presentAction} {target} + + ); + } + if ((status_code ?? 0) >= 400) { + return ( + <> + unsuccessfully attempted to {presentAction} {target} + + ); + } + return ( + <> + {pastAction} {target} + + ); + })(); + + const isOwnWorkspace = user + ? workspace_owner_username === user.username + : false; + + return ( + + {user ? user.username : "Unauthenticated user"} {actionText} in{" "} + {isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "} + + {workspace_name} + {" "} + workspace + + ); + } + + case "reconnecting_pty": + case "ssh": + case "jetbrains": + case "vscode": { + const friendlyType = connectionTypeToFriendlyName(type); + return ( + + {friendlyType} session to {workspace_owner_username}'s{" "} + + {workspace_name} + {" "} + workspace{" "} + + ); + } + } +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.stories.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.stories.tsx new file mode 100644 index 0000000000..4e9dd49ed3 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.stories.tsx @@ -0,0 +1,74 @@ +import TableContainer from "@mui/material/TableContainer"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Table, TableBody } from "components/Table/Table"; +import { + MockConnectedSSHConnectionLog, + MockDisconnectedSSHConnectionLog, + MockWebConnectionLog, +} from "testHelpers/entities"; +import { ConnectionLogRow } from "./ConnectionLogRow"; + +const meta: Meta = { + title: "pages/ConnectionLogPage/ConnectionLogRow", + component: ConnectionLogRow, + decorators: [ + (Story) => ( + + + + + +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Web: Story = { + args: { + connectionLog: MockWebConnectionLog, + }, +}; + +export const WebUnauthenticatedFail: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + web_info: { + status_code: 404, + user_agent: MockWebConnectionLog.web_info!.user_agent, + user: null, // Unauthenticated connection attempt + slug_or_port: MockWebConnectionLog.web_info!.slug_or_port, + }, + }, + }, +}; + +export const ConnectedSSH: Story = { + args: { + connectionLog: MockConnectedSSHConnectionLog, + }, +}; + +export const DisconnectedSSH: Story = { + args: { + connectionLog: { + ...MockDisconnectedSSHConnectionLog, + }, + }, +}; + +export const DisconnectedSSHError: Story = { + args: { + connectionLog: { + ...MockDisconnectedSSHConnectionLog, + ssh_info: { + ...MockDisconnectedSSHConnectionLog.ssh_info!, + exit_code: 130, // 128 + SIGINT + }, + }, + }, +}; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.tsx new file mode 100644 index 0000000000..ac847cff73 --- /dev/null +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogRow.tsx @@ -0,0 +1,195 @@ +import type { CSSObject, Interpolation, Theme } from "@emotion/react"; +import Link from "@mui/material/Link"; +import TableCell from "@mui/material/TableCell"; +import Tooltip from "@mui/material/Tooltip"; +import type { ConnectionLog } from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { Stack } from "components/Stack/Stack"; +import { StatusPill } from "components/StatusPill/StatusPill"; +import { TimelineEntry } from "components/Timeline/TimelineEntry"; +import { InfoIcon } from "lucide-react"; +import { NetworkIcon } from "lucide-react"; +import type { FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import userAgentParser from "ua-parser-js"; +import { connectionTypeIsWeb } from "utils/connection"; +import { ConnectionLogDescription } from "./ConnectionLogDescription/ConnectionLogDescription"; + +interface ConnectionLogRowProps { + connectionLog: ConnectionLog; +} + +export const ConnectionLogRow: FC = ({ + connectionLog, +}) => { + const userAgent = connectionLog.web_info?.user_agent + ? userAgentParser(connectionLog.web_info?.user_agent) + : undefined; + const isWeb = connectionTypeIsWeb(connectionLog.type); + const code = + connectionLog.web_info?.status_code ?? connectionLog.ssh_info?.exit_code; + + return ( + + + + + {/* Non-web logs don't have an associated user, so we + * display a default network icon instead */} + {connectionLog.web_info?.user ? ( + + ) : ( + + + + )} + + + + + + {new Date(connectionLog.connect_time).toLocaleTimeString()} + {connectionLog.ssh_info?.disconnect_time && + ` → ${new Date(connectionLog.ssh_info.disconnect_time).toLocaleTimeString()}`} + + + + + {code !== undefined && ( + + )} + + {connectionLog.ip && ( +
+

IP:

+
{connectionLog.ip}
+
+ )} + {userAgent?.os.name && ( +
+

OS:

+
{userAgent.os.name}
+
+ )} + {userAgent?.browser.name && ( +
+

Browser:

+
+ {userAgent.browser.name} {userAgent.browser.version} +
+
+ )} + {connectionLog.organization && ( +
+

+ Organization: +

+ + {connectionLog.organization.display_name || + connectionLog.organization.name} + +
+ )} + {connectionLog.ssh_info?.disconnect_reason && ( +
+

+ Close Reason: +

+
{connectionLog.ssh_info?.disconnect_reason}
+
+ )} + + } + > + ({ + color: theme.palette.info.light, + })} + /> +
+
+
+
+
+
+
+ ); +}; + +const styles = { + connectionLogCell: { + padding: "0 !important", + border: 0, + }, + + connectionLogHeader: { + padding: "16px 32px", + }, + + connectionLogHeaderInfo: { + flex: 1, + }, + + connectionLogSummary: (theme) => ({ + ...(theme.typography.body1 as CSSObject), + fontFamily: "inherit", + }), + + connectionLogTime: (theme) => ({ + color: theme.palette.text.secondary, + fontSize: 12, + }), + + connectionLogInfoheader: (theme) => ({ + margin: 0, + color: theme.palette.text.primary, + fontSize: 14, + lineHeight: "150%", + fontWeight: 600, + }), + + connectionLogInfoTooltip: { + display: "flex", + flexDirection: "column", + gap: 8, + }, + + fullWidth: { + width: "100%", + }, +} satisfies Record>; diff --git a/site/src/router.tsx b/site/src/router.tsx index a45b96f1af..90a8bda22c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -12,6 +12,7 @@ import { Loader } from "./components/Loader/Loader"; import { RequireAuth } from "./contexts/auth/RequireAuth"; import { DashboardLayout } from "./modules/dashboard/DashboardLayout"; import AuditPage from "./pages/AuditPage/AuditPage"; +import ConnectionLogPage from "./pages/ConnectionLogPage/ConnectionLogPage"; import { HealthLayout } from "./pages/HealthPage/HealthLayout"; import LoginOAuthDevicePage from "./pages/LoginOAuthDevicePage/LoginOAuthDevicePage"; import LoginPage from "./pages/LoginPage/LoginPage"; @@ -433,6 +434,8 @@ export const router = createBrowserRouter( } /> + } /> + } /> }> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 22dc47ae23..924c4edef7 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2450,6 +2450,21 @@ export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { }), }; +export const MockEntitlementsWithConnectionLog: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + refreshed_at: "2022-05-20T16:45:57.122Z", + features: withDefaultFeatures({ + connection_log: { + enabled: true, + entitlement: "entitled", + }, + }), +}; + export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { errors: [], warnings: [], @@ -2718,6 +2733,79 @@ export const MockAuditLogRequestPasswordReset: TypesGen.AuditLog = { }, }; +export const MockWebConnectionLog: TypesGen.ConnectionLog = { + id: "497dcba3-ecbf-4587-a2dd-5eb0665e6880", + connect_time: "2022-05-19T16:45:57.122Z", + organization: { + id: MockOrganization.id, + name: MockOrganization.name, + display_name: MockOrganization.display_name, + icon: MockOrganization.icon, + }, + workspace_owner_id: MockUserMember.id, + workspace_owner_username: MockUserMember.username, + workspace_id: MockWorkspace.id, + workspace_name: MockWorkspace.name, + agent_name: "dev", + ip: "127.0.0.1", + type: "workspace_app", + web_info: { + user_agent: + '"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"', + user: MockUserMember, + slug_or_port: "code-server", + status_code: 200, + }, +}; + +export const MockConnectedSSHConnectionLog: TypesGen.ConnectionLog = { + id: "7884a866-4ae1-4945-9fba-b2b8d2b7c5a9", + connect_time: "2022-05-19T16:45:57.122Z", + organization: { + id: MockOrganization.id, + name: MockOrganization.name, + display_name: MockOrganization.display_name, + icon: MockOrganization.icon, + }, + workspace_owner_id: MockUserMember.id, + workspace_owner_username: MockUserMember.username, + workspace_id: MockWorkspace.id, + workspace_name: MockWorkspace.name, + agent_name: "dev", + ip: "127.0.0.1", + type: "ssh", + ssh_info: { + connection_id: "026c8c11-fc5c-4df8-a286-5fe6d7f54f98", + disconnect_reason: undefined, + disconnect_time: undefined, + exit_code: undefined, + }, +}; + +export const MockDisconnectedSSHConnectionLog: TypesGen.ConnectionLog = { + id: "893e75e0-1518-4ac8-9629-35923a39533a", + connect_time: "2022-05-19T16:45:57.122Z", + organization: { + id: MockOrganization.id, + name: MockOrganization.name, + display_name: MockOrganization.display_name, + icon: MockOrganization.icon, + }, + workspace_owner_id: MockUserMember.id, + workspace_owner_username: MockUserMember.username, + workspace_id: MockWorkspace.id, + workspace_name: MockWorkspace.name, + agent_name: "dev", + ip: "127.0.0.1", + type: "ssh", + ssh_info: { + connection_id: "026c8c11-fc5c-4df8-a286-5fe6d7f54f98", + disconnect_reason: "server shut down", + disconnect_time: "2022-05-19T16:49:57.122Z", + exit_code: 0, + }, +}; + export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { credits_consumed: 0, budget: 100, @@ -2882,6 +2970,7 @@ export const MockPermissions: Permissions = { viewAllUsers: true, updateUsers: true, viewAnyAuditLog: true, + viewAnyConnectionLog: true, viewDeploymentConfig: true, editDeploymentConfig: true, viewDeploymentStats: true, @@ -2909,6 +2998,7 @@ export const MockNoPermissions: Permissions = { viewAllUsers: false, updateUsers: false, viewAnyAuditLog: false, + viewAnyConnectionLog: false, viewDeploymentConfig: false, editDeploymentConfig: false, viewDeploymentStats: false, diff --git a/site/src/utils/connection.ts b/site/src/utils/connection.ts new file mode 100644 index 0000000000..0150fa333e --- /dev/null +++ b/site/src/utils/connection.ts @@ -0,0 +1,33 @@ +import type { ConnectionType } from "api/typesGenerated"; + +export const connectionTypeToFriendlyName = (type: ConnectionType): string => { + switch (type) { + case "jetbrains": + return "JetBrains"; + case "reconnecting_pty": + return "Web Terminal"; + case "ssh": + return "SSH"; + case "vscode": + return "VS Code"; + case "port_forwarding": + return "Port Forwarding"; + case "workspace_app": + return "Workspace App"; + } +}; + +export const connectionTypeIsWeb = (type: ConnectionType): boolean => { + switch (type) { + case "port_forwarding": + case "workspace_app": { + return true; + } + case "reconnecting_pty": + case "ssh": + case "jetbrains": + case "vscode": { + return false; + } + } +}; diff --git a/site/src/utils/http.ts b/site/src/utils/http.ts new file mode 100644 index 0000000000..5ea00dbd18 --- /dev/null +++ b/site/src/utils/http.ts @@ -0,0 +1,16 @@ +import type { ThemeRole } from "theme/roles"; + +export const httpStatusColor = (httpStatus: number): ThemeRole => { + // Treat server errors (500) as errors + if (httpStatus >= 500) { + return "error"; + } + + // Treat client errors (400) as warnings + if (httpStatus >= 400) { + return "warning"; + } + + // OK (200) and redirects (300) are successful + return "success"; +};