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"; +}; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy