From 397ecabe03d3f5da537be44125c36d386e730f86 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 2 Jul 2025 05:52:20 +0000 Subject: [PATCH] feat(site): add connection log page --- 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 7c10188648121..013c018d5c656 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 0000000000000..9fbeb3f9e783d --- /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 3dc591cd4a284..0663d3d8d97d0 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 9659a70ea32b3..f7376d99dd387 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 358b717b492a4..4c43e6a0877f9 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 d83b0e8b694a4..7b1bd9fc535ed 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 16d01d113f8ee..db48e61411d18 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 a1c1bc57d8549..c625a7d60797e 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 a123e83214775..73ab52da5cd1a 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 0000000000000..9d049c4e6865b --- /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 0000000000000..be87c6e8a8b17 --- /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 0000000000000..7beea3f033e30 --- /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 0000000000000..9cd27bac95bf4 --- /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 0000000000000..393127280409b --- /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 0000000000000..fe3840d098aaa --- /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 0000000000000..8c8263e7dbc68 --- /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 0000000000000..b862134624189 --- /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 0000000000000..4e9dd49ed3edf --- /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 0000000000000..ac847cff73b39 --- /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 a45b96f1af01e..90a8bda22c1f3 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 22dc47ae2390f..924c4edef730f 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 0000000000000..0150fa333e158 --- /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 0000000000000..5ea00dbd18e01 --- /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"; +};