mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+105
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
+95
@@ -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>>;
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
};
|
||||
Reference in New Issue
Block a user