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



<img width="346" alt="image" src="https://github.com/user-attachments/assets/6de29945-55c2-4fe5-9a4f-d42e476ded25" />
<img width="184" alt="image" src="https://github.com/user-attachments/assets/e83234bc-4d9d-4f71-b668-9256a600659c" />


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
This commit is contained in:
Ethan
2025-07-15 15:11:31 +10:00
committed by GitHub
parent 1ee6b8d5b1
commit b5260d5699
26 changed files with 1406 additions and 45 deletions
+8
View File
@@ -1813,6 +1813,14 @@ class ApiMethods {
return response.data;
};
getConnectionLogs = async (
options: TypesGen.ConnectionLogsRequest,
): Promise<TypesGen.ConnectionLogResponse> => {
const url = getURLWithSearchParams("/api/v2/connectionlog", options);
const response = await this.axios.get(url);
return response.data;
};
getTemplateDAUs = async (
templateId: string,
): Promise<TypesGen.DAUsResponse> => {
+24
View File
@@ -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<ConnectionLogResponse, string> {
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,
};
}
+3 -2
View File
@@ -82,14 +82,15 @@ export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>;
interface UserMenuProps {
menu: UserFilterMenu;
placeholder?: string;
width?: number;
}
export const UserMenu: FC<UserMenuProps> = ({ menu, width }) => {
export const UserMenu: FC<UserMenuProps> = ({ menu, width, placeholder }) => {
return (
<SelectFilter
label="Select user"
placeholder="All users"
placeholder={placeholder ?? "All users"}
emptyText="No users found"
options={menu.searchOptions}
onSelect={menu.selectOption}
@@ -0,0 +1,43 @@
import { Pill } from "components/Pill/Pill";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
import { httpStatusColor } from "utils/http";
interface StatusPillProps {
code: number;
isHttpCode: boolean;
label?: string;
}
export const StatusPill: FC<StatusPillProps> = ({
code,
isHttpCode,
label,
}) => {
const pill = (
<Pill
className="text-[10px] h-5 px-2.5 font-semibold"
type={
isHttpCode ? httpStatusColor(code) : code === 0 ? "success" : "error"
}
>
{code.toString()}
</Pill>
);
if (!label) {
return pill;
}
return (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -16,6 +16,7 @@ interface DeploymentDropdownProps {
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAuditLog: boolean;
canViewConnectionLog: boolean;
canViewHealth: boolean;
}
@@ -23,12 +24,14 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewDeployment,
canViewOrganizations,
canViewAuditLog,
canViewConnectionLog,
canViewHealth,
}) => {
const theme = useTheme();
if (
!canViewAuditLog &&
!canViewConnectionLog &&
!canViewOrganizations &&
!canViewDeployment &&
!canViewHealth
@@ -59,6 +62,7 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewDeployment={canViewDeployment}
canViewOrganizations={canViewOrganizations}
canViewAuditLog={canViewAuditLog}
canViewConnectionLog={canViewConnectionLog}
canViewHealth={canViewHealth}
/>
</PopoverContent>
@@ -71,6 +75,7 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
canViewOrganizations,
canViewAuditLog,
canViewHealth,
canViewConnectionLog,
}) => {
const popover = usePopover();
@@ -108,6 +113,16 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
Audit Logs
</MenuItem>
)}
{canViewConnectionLog && (
<MenuItem
component={NavLink}
to="/connectionlog"
css={styles.menuItem}
onClick={onPopoverClose}
>
Connection Logs
</MenuItem>
)}
{canViewHealth && (
<MenuItem
component={NavLink}
@@ -37,6 +37,7 @@ type MobileMenuPermissions = {
canViewDeployment: boolean;
canViewOrganizations: boolean;
canViewAuditLog: boolean;
canViewConnectionLog: boolean;
canViewHealth: boolean;
};
@@ -192,6 +193,7 @@ const AdminSettingsSub: FC<MobileMenuPermissions> = ({
canViewDeployment,
canViewOrganizations,
canViewAuditLog,
canViewConnectionLog,
canViewHealth,
}) => {
const [open, setOpen] = useState(false);
@@ -237,6 +239,14 @@ const AdminSettingsSub: FC<MobileMenuPermissions> = ({
<Link to="/audit">Audit logs</Link>
</DropdownMenuItem>
)}
{canViewConnectionLog && (
<DropdownMenuItem
asChild
className={cn(itemStyles.default, itemStyles.sub)}
>
<Link to="/connectionlog">Connection logs</Link>
</DropdownMenuItem>
)}
{canViewHealth && (
<DropdownMenuItem
asChild
@@ -22,6 +22,8 @@ export const Navbar: FC = () => {
const canViewHealth = permissions.viewDebugInfo;
const canViewAuditLog =
featureVisibility.audit_log && permissions.viewAnyAuditLog;
const canViewConnectionLog =
featureVisibility.connection_log && permissions.viewAnyConnectionLog;
return (
<NavbarView
@@ -34,6 +36,7 @@ export const Navbar: FC = () => {
canViewOrganizations={canViewOrganizations}
canViewHealth={canViewHealth}
canViewAuditLog={canViewAuditLog}
canViewConnectionLog={canViewConnectionLog}
proxyContextValue={proxyContextValue}
/>
);
@@ -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");
@@ -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<NavbarViewProps> = ({
canViewOrganizations,
canViewHealth,
canViewAuditLog,
canViewConnectionLog,
proxyContextValue,
}) => {
const webPush = useWebpushNotifications();
@@ -73,6 +75,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewHealth={canViewHealth}
canViewConnectionLog={canViewConnectionLog}
/>
</div>
@@ -124,6 +127,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
supportLinks={supportLinks}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewConnectionLog={canViewConnectionLog}
canViewOrganizations={canViewOrganizations}
canViewDeployment={canViewDeployment}
canViewHealth={canViewHealth}
+7
View File
@@ -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",
+11 -4
View File
@@ -82,10 +82,17 @@ export const useActionFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions, "value" | "onChange">) => {
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,
@@ -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<AuditLogRowProps> = ({
</Stack>
<Stack direction="row" alignItems="center">
<StatusPill code={auditLog.status_code} />
<StatusPill isHttpCode={true} code={auditLog.status_code} />
{/* With multi-org, there is not enough space so show
everything in a tooltip. */}
@@ -243,19 +227,6 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
);
};
function StatusPill({ code }: { code: number }) {
const isHttp = code >= 100;
return (
<Pill
css={styles.statusCodePill}
type={isHttp ? httpStatusColor(code) : code === 0 ? "success" : "error"}
>
{code.toString()}
</Pill>
);
}
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,
@@ -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<typeof useFilter>;
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<ConnectionLogFilterProps> = ({
filter,
error,
menus,
}) => {
const width = menus.organization ? 175 : undefined;
return (
<Filter
learnMoreLink={docs(
"/admin/monitoring/connection-logs#how-to-filter-connection-logs",
)}
presets={PRESET_FILTERS}
isLoading={menus.user.isInitializing}
filter={filter}
error={error}
options={
<>
<UserMenu placeholder="All owners" menu={menus.user} width={width} />
<StatusMenu menu={menus.status} width={width} />
<TypeMenu menu={menus.type} width={width} />
{menus.organization && (
<OrganizationsMenu menu={menus.organization} width={width} />
)}
</>
}
optionsSkeleton={
<>
<MenuSkeleton />
<MenuSkeleton />
<MenuSkeleton />
{menus.organization && <MenuSkeleton />}
</>
}
/>
);
};
export const useStatusFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions, "value" | "onChange">) => {
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<typeof useStatusFilterMenu>;
interface StatusMenuProps {
menu: StatusFilterMenu;
width?: number;
}
const StatusMenu: FC<StatusMenuProps> = ({ menu, width }) => {
return (
<SelectFilter
label="Filter by session status"
placeholder="All sessions"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
width={width}
/>
);
};
export const useTypeFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions, "value" | "onChange">) => {
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<typeof useTypeFilterMenu>;
interface TypeMenuProps {
menu: TypeFilterMenu;
width?: number;
}
const TypeMenu: FC<TypeMenuProps> = ({ menu, width }) => {
return (
<SelectFilter
label="Filter by connection type"
placeholder="All types"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
width={width}
/>
);
};
@@ -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 (
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipTitle>{Language.title}</HelpTooltipTitle>
<HelpTooltipText>{Language.body}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href={docs("/admin/monitoring/connection-logs")}>
{Language.docs}
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltipContent>
</HelpTooltip>
);
};
@@ -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(<ConnectionLogPage />, {
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,
}),
);
});
});
});
@@ -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 (
<>
<Helmet>
<title>{pageTitle("Connection Log")}</title>
</Helmet>
<ConnectionLogPageView
connectionLogs={connectionlogsQuery.data?.connection_logs}
isNonInitialPage={isNonInitialPage(searchParams)}
isConnectionLogVisible={isConnectionLogVisible}
connectionLogsQuery={connectionlogsQuery}
error={connectionlogsQuery.error}
filterProps={{
filter,
error: connectionlogsQuery.error,
menus: {
user: userMenu,
status: statusMenu,
type: typeMenu,
organization: showOrganizations ? organizationsMenu : undefined,
},
}}
/>
</>
);
};
export default ConnectionLogPage;
@@ -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<typeof ConnectionLogPageView>["filterProps"];
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
query: `username:${MockUserOwner.username}`,
values: {
username: MockUserOwner.username,
status: undefined,
type: undefined,
organization: undefined,
},
menus: {
user: MockMenu,
status: MockMenu,
type: MockMenu,
},
});
const meta: Meta<typeof ConnectionLogPageView> = {
title: "pages/ConnectionLogPage",
component: ConnectionLogPageView,
args: {
connectionLogs: [
MockConnectedSSHConnectionLog,
MockDisconnectedSSHConnectionLog,
],
isConnectionLogVisible: true,
filterProps: defaultFilterProps,
},
};
export default meta;
type Story = StoryObj<typeof ConnectionLogPageView>;
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,
},
};
@@ -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<typeof ConnectionLogFilter>;
connectionLogsQuery: PaginationResult;
}
export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
connectionLogs,
isNonInitialPage,
isConnectionLogVisible,
error,
filterProps,
connectionLogsQuery: paginationResult,
}) => {
const isLoading =
(connectionLogs === undefined ||
paginationResult.totalRecords === undefined) &&
!error;
const isEmpty = !isLoading && connectionLogs?.length === 0;
return (
<Margins>
<PageHeader>
<PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.title}</span>
<ConnectionLogHelpTooltip />
</Stack>
</PageHeaderTitle>
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle>
</PageHeader>
<ChooseOne>
<Cond condition={isConnectionLogVisible}>
<ConnectionLogFilter {...filterProps} />
<PaginationContainer
query={paginationResult}
paginationUnitLabel="logs"
>
<TableContainer>
<Table>
<TableBody>
<ChooseOne>
{/* Error condition should just show an empty table. */}
<Cond condition={Boolean(error)}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading connection logs" />
</TableCell>
</TableRow>
</Cond>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No connection logs available on this page" />
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No connection logs available" />
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
{connectionLogs && (
<Timeline
items={connectionLogs}
getDate={(log) => new Date(log.connect_time)}
row={(log) => (
<ConnectionLogRow
key={log.id}
connectionLog={log}
/>
)}
/>
)}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</PaginationContainer>
</Cond>
<Cond>
<Paywall
message="Connection logs"
description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature."
documentationLink={docs("/admin/monitoring/connection-logs")}
/>
</Cond>
</ChooseOne>
</Margins>
);
};
@@ -0,0 +1,105 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockConnectedSSHConnectionLog,
MockWebConnectionLog,
} from "testHelpers/entities";
import { ConnectionLogDescription } from "./ConnectionLogDescription";
const meta: Meta<typeof ConnectionLogDescription> = {
title: "pages/ConnectionLogPage/ConnectionLogDescription",
component: ConnectionLogDescription,
};
export default meta;
type Story = StoryObj<typeof ConnectionLogDescription>;
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",
},
},
};
@@ -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<ConnectionLogDescriptionProps> = ({
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 <strong>{slug_or_port}</strong>
</>
) : (
<strong>{slug_or_port}</strong>
);
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 (
<span>
{user ? user.username : "Unauthenticated user"} {actionText} in{" "}
{isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "}
<Link
component={RouterLink}
to={`/@${workspace_owner_username}/${workspace_name}`}
>
<strong>{workspace_name}</strong>
</Link>{" "}
workspace
</span>
);
}
case "reconnecting_pty":
case "ssh":
case "jetbrains":
case "vscode": {
const friendlyType = connectionTypeToFriendlyName(type);
return (
<span>
{friendlyType} session to {workspace_owner_username}'s{" "}
<Link
component={RouterLink}
to={`/@${workspace_owner_username}/${workspace_name}`}
>
<strong>{workspace_name}</strong>
</Link>{" "}
workspace{" "}
</span>
);
}
}
};
@@ -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<typeof ConnectionLogRow> = {
title: "pages/ConnectionLogPage/ConnectionLogRow",
component: ConnectionLogRow,
decorators: [
(Story) => (
<TableContainer>
<Table>
<TableBody>
<Story />
</TableBody>
</Table>
</TableContainer>
),
],
};
export default meta;
type Story = StoryObj<typeof ConnectionLogRow>;
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
},
},
},
};
@@ -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<ConnectionLogRowProps> = ({
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 (
<TimelineEntry
key={connectionLog.id}
data-testid={`connection-log-row-${connectionLog.id}`}
clickable={false}
>
<TableCell css={styles.connectionLogCell}>
<Stack
direction="row"
alignItems="center"
css={styles.connectionLogHeader}
tabIndex={0}
>
<Stack
direction="row"
alignItems="center"
css={styles.connectionLogHeaderInfo}
>
{/* Non-web logs don't have an associated user, so we
* display a default network icon instead */}
{connectionLog.web_info?.user ? (
<Avatar
fallback={connectionLog.web_info.user.username}
src={connectionLog.web_info.user.avatar_url}
/>
) : (
<Avatar>
<NetworkIcon className="h-full w-full p-1" />
</Avatar>
)}
<Stack
alignItems="center"
css={styles.fullWidth}
justifyContent="space-between"
direction="row"
>
<Stack
css={styles.connectionLogSummary}
direction="row"
alignItems="baseline"
spacing={1}
>
<ConnectionLogDescription connectionLog={connectionLog} />
<span css={styles.connectionLogTime}>
{new Date(connectionLog.connect_time).toLocaleTimeString()}
{connectionLog.ssh_info?.disconnect_time &&
` → ${new Date(connectionLog.ssh_info.disconnect_time).toLocaleTimeString()}`}
</span>
</Stack>
<Stack direction="row" alignItems="center">
{code !== undefined && (
<StatusPill
code={code}
isHttpCode={isWeb}
label={isWeb ? "HTTP Status Code" : "SSH Exit Code"}
/>
)}
<Tooltip
title={
<div css={styles.connectionLogInfoTooltip}>
{connectionLog.ip && (
<div>
<h4 css={styles.connectionLogInfoheader}>IP:</h4>
<div>{connectionLog.ip}</div>
</div>
)}
{userAgent?.os.name && (
<div>
<h4 css={styles.connectionLogInfoheader}>OS:</h4>
<div>{userAgent.os.name}</div>
</div>
)}
{userAgent?.browser.name && (
<div>
<h4 css={styles.connectionLogInfoheader}>Browser:</h4>
<div>
{userAgent.browser.name} {userAgent.browser.version}
</div>
</div>
)}
{connectionLog.organization && (
<div>
<h4 css={styles.connectionLogInfoheader}>
Organization:
</h4>
<Link
component={RouterLink}
to={`/organizations/${connectionLog.organization.name}`}
>
{connectionLog.organization.display_name ||
connectionLog.organization.name}
</Link>
</div>
)}
{connectionLog.ssh_info?.disconnect_reason && (
<div>
<h4 css={styles.connectionLogInfoheader}>
Close Reason:
</h4>
<div>{connectionLog.ssh_info?.disconnect_reason}</div>
</div>
)}
</div>
}
>
<InfoIcon
css={(theme) => ({
color: theme.palette.info.light,
})}
/>
</Tooltip>
</Stack>
</Stack>
</Stack>
</Stack>
</TableCell>
</TimelineEntry>
);
};
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<string, Interpolation<Theme>>;
+3
View File
@@ -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(
<Route path="/audit" element={<AuditPage />} />
<Route path="/connectionlog" element={<ConnectionLogPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/organizations" element={<OrganizationSettingsLayout />}>
+90
View File
@@ -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,
+33
View File
@@ -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;
}
}
};
+16
View File
@@ -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";
};