feat(site): convert ConnectionLog page to global sessions view
Replace the flat per-connection ConnectionLog page with a session-grouped view using the new GET /api/v2/connectionlog/sessions endpoint. Changes: - Add getGlobalWorkspaceSessions API client method - Add paginatedGlobalWorkspaceSessions react-query hook - Create GlobalSessionRow with expandable connections list - Create ConnectionDetailDialog for viewing connection details - Update ConnectionLogFilter to remove status/type menus (session-level) - Rewrite ConnectionLogPageView to use sessions timeline - Update ConnectionLogPage to use sessions query - Update storybook stories for new data shape
This commit is contained in:
committed by
Seth Shelnutt
parent
85e3e19673
commit
3e84596fc2
@@ -1917,6 +1917,17 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getGlobalWorkspaceSessions = async (
|
||||
options: TypesGen.GlobalWorkspaceSessionsRequest,
|
||||
): Promise<TypesGen.GlobalWorkspaceSessionsResponse> => {
|
||||
const url = getURLWithSearchParams(
|
||||
"/api/v2/connectionlog/sessions",
|
||||
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 { GlobalWorkspaceSessionsResponse } from "api/typesGenerated";
|
||||
import { useFilterParamsKey } from "components/Filter/Filter";
|
||||
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||
|
||||
export function paginatedGlobalWorkspaceSessions(
|
||||
searchParams: URLSearchParams,
|
||||
): UsePaginatedQueryOptions<GlobalWorkspaceSessionsResponse, string> {
|
||||
return {
|
||||
searchParams,
|
||||
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
|
||||
queryKey: ({ payload, pageNumber }) => {
|
||||
return ["globalWorkspaceSessions", payload, pageNumber] as const;
|
||||
},
|
||||
queryFn: ({ payload, limit, offset }) => {
|
||||
return API.getGlobalWorkspaceSessions({
|
||||
offset,
|
||||
limit,
|
||||
q: payload,
|
||||
});
|
||||
},
|
||||
prefetch: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { WorkspaceConnection } from "api/typesGenerated";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "components/Dialog/Dialog";
|
||||
import { X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { connectionTypeToFriendlyName } from "utils/connection";
|
||||
|
||||
interface ConnectionDetailDialogProps {
|
||||
connection: WorkspaceConnection | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ConnectionDetailDialog: FC<ConnectionDetailDialogProps> = ({
|
||||
connection,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={connection !== null} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connection Details</DialogTitle>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
{connection && <ConnectionDetails connection={connection} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionDetails: FC<{ connection: WorkspaceConnection }> = ({
|
||||
connection,
|
||||
}) => {
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return "—";
|
||||
return new Date(time).toLocaleString();
|
||||
};
|
||||
|
||||
const duration = () => {
|
||||
if (!connection.connected_at) return "—";
|
||||
const start = new Date(connection.connected_at).getTime();
|
||||
const end = connection.ended_at
|
||||
? new Date(connection.ended_at).getTime()
|
||||
: Date.now();
|
||||
const diffMs = end - start;
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
};
|
||||
|
||||
const rows: [string, string | undefined][] = [
|
||||
["Type", connectionTypeToFriendlyName(connection.type)],
|
||||
["Status", connection.status],
|
||||
["IP", connection.ip],
|
||||
["Client Hostname", connection.client_hostname],
|
||||
["Description", connection.short_description],
|
||||
["Connected At", formatTime(connection.connected_at)],
|
||||
["Disconnected At", formatTime(connection.ended_at)],
|
||||
["Duration", duration()],
|
||||
["Detail", connection.detail],
|
||||
["Disconnect Reason", connection.disconnect_reason],
|
||||
[
|
||||
"Exit Code",
|
||||
connection.exit_code !== undefined
|
||||
? String(connection.exit_code)
|
||||
: undefined,
|
||||
],
|
||||
["User Agent", connection.user_agent],
|
||||
["P2P", connection.p2p !== undefined ? String(connection.p2p) : undefined],
|
||||
[
|
||||
"Latency",
|
||||
connection.latency_ms !== undefined
|
||||
? `${connection.latency_ms}ms`
|
||||
: undefined,
|
||||
],
|
||||
["Home DERP", connection.home_derp?.display_name],
|
||||
];
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
{rows.map(
|
||||
([label, value]) =>
|
||||
value !== undefined && (
|
||||
<div key={label} className="contents">
|
||||
<dt className="text-content-secondary font-medium">{label}</dt>
|
||||
<dd className="text-content-primary break-all">{value}</dd>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
@@ -1,35 +1,17 @@
|
||||
import {
|
||||
type ConnectionLogStatus,
|
||||
ConnectionLogStatuses,
|
||||
type ConnectionType,
|
||||
ConnectionTypes,
|
||||
} from "api/typesGenerated";
|
||||
import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter";
|
||||
import {
|
||||
type UseFilterMenuOptions,
|
||||
useFilterMenu,
|
||||
} from "components/Filter/menu";
|
||||
import {
|
||||
SelectFilter,
|
||||
type SelectFilterOption,
|
||||
} from "components/Filter/SelectFilter";
|
||||
import {
|
||||
DEFAULT_USER_FILTER_WIDTH,
|
||||
type UserFilterMenu,
|
||||
UserMenu,
|
||||
} from "components/Filter/UserFilter";
|
||||
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";
|
||||
|
||||
type ConnectionLogFilterValues = {
|
||||
status?: ConnectionLogStatus;
|
||||
type?: ConnectionType;
|
||||
workspace_owner?: string;
|
||||
organization?: string;
|
||||
};
|
||||
@@ -38,8 +20,6 @@ const buildConnectionLogFilterQuery = (
|
||||
v: ConnectionLogFilterValues,
|
||||
): string => {
|
||||
const parts: string[] = [];
|
||||
if (v.status) parts.push(`status:${v.status}`);
|
||||
if (v.type) parts.push(`type:${v.type}`);
|
||||
if (v.workspace_owner) parts.push(`workspace_owner:${v.workspace_owner}`);
|
||||
if (v.organization) parts.push(`organization:${v.organization}`);
|
||||
return parts.join(" ");
|
||||
@@ -47,8 +27,8 @@ const buildConnectionLogFilterQuery = (
|
||||
|
||||
const CONNECTION_LOG_PRESET_FILTERS = [
|
||||
{
|
||||
query: buildConnectionLogFilterQuery({ status: "ongoing", type: "ssh" }),
|
||||
name: "Active SSH connections",
|
||||
query: buildConnectionLogFilterQuery({ workspace_owner: "me" }),
|
||||
name: "My sessions",
|
||||
},
|
||||
] satisfies { name: string; query: string }[];
|
||||
|
||||
@@ -57,8 +37,6 @@ interface ConnectionLogFilterProps {
|
||||
error?: unknown;
|
||||
menus: {
|
||||
user: UserFilterMenu;
|
||||
status: StatusFilterMenu;
|
||||
type: TypeFilterMenu;
|
||||
// The organization menu is only provided in a multi-org setup.
|
||||
organization?: OrganizationsFilterMenu;
|
||||
};
|
||||
@@ -82,8 +60,6 @@ export const ConnectionLogFilter: FC<ConnectionLogFilterProps> = ({
|
||||
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} />
|
||||
)}
|
||||
@@ -91,8 +67,6 @@ export const ConnectionLogFilter: FC<ConnectionLogFilterProps> = ({
|
||||
}
|
||||
optionsSkeleton={
|
||||
<>
|
||||
<MenuSkeleton />
|
||||
<MenuSkeleton />
|
||||
<MenuSkeleton />
|
||||
{menus.organization && <MenuSkeleton />}
|
||||
</>
|
||||
@@ -100,86 +74,3 @@ export const ConnectionLogFilter: FC<ConnectionLogFilterProps> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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.filter(
|
||||
(type) => type !== "system",
|
||||
).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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { paginatedConnectionLogs } from "api/queries/connectionlog";
|
||||
import { paginatedGlobalWorkspaceSessions } from "api/queries/globalWorkspaceSessions";
|
||||
import { useFilter } from "components/Filter/Filter";
|
||||
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
||||
import { isNonInitialPage } from "components/PaginationWidget/utils";
|
||||
@@ -9,7 +9,6 @@ import { useOrganizationsFilterMenu } from "modules/tableFiltering/options";
|
||||
import type { FC } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useStatusFilterMenu, useTypeFilterMenu } from "./ConnectionLogFilter";
|
||||
import { ConnectionLogPageView } from "./ConnectionLogPageView";
|
||||
|
||||
const ConnectionLogPage: FC = () => {
|
||||
@@ -24,13 +23,13 @@ const ConnectionLogPage: FC = () => {
|
||||
const { showOrganizations } = useDashboard();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const connectionlogsQuery = usePaginatedQuery(
|
||||
paginatedConnectionLogs(searchParams),
|
||||
const sessionsQuery = usePaginatedQuery(
|
||||
paginatedGlobalWorkspaceSessions(searchParams),
|
||||
);
|
||||
const filter = useFilter({
|
||||
searchParams,
|
||||
onSearchParamsChange: setSearchParams,
|
||||
onUpdate: connectionlogsQuery.goToFirstPage,
|
||||
onUpdate: sessionsQuery.goToFirstPage,
|
||||
});
|
||||
|
||||
const userMenu = useUserFilterMenu({
|
||||
@@ -42,24 +41,6 @@ const ConnectionLogPage: FC = () => {
|
||||
}),
|
||||
});
|
||||
|
||||
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) =>
|
||||
@@ -74,18 +55,16 @@ const ConnectionLogPage: FC = () => {
|
||||
<title>{pageTitle("Connection Log")}</title>
|
||||
|
||||
<ConnectionLogPageView
|
||||
connectionLogs={connectionlogsQuery.data?.connection_logs}
|
||||
sessions={sessionsQuery.data?.sessions}
|
||||
isNonInitialPage={isNonInitialPage(searchParams)}
|
||||
isConnectionLogVisible={isConnectionLogVisible}
|
||||
connectionLogsQuery={connectionlogsQuery}
|
||||
error={connectionlogsQuery.error}
|
||||
sessionsQuery={sessionsQuery}
|
||||
error={sessionsQuery.error}
|
||||
filterProps={{
|
||||
filter,
|
||||
error: connectionlogsQuery.error,
|
||||
error: sessionsQuery.error,
|
||||
menus: {
|
||||
user: userMenu,
|
||||
status: statusMenu,
|
||||
type: typeMenu,
|
||||
organization: showOrganizations ? organizationsMenu : undefined,
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { GlobalWorkspaceSession } from "api/typesGenerated";
|
||||
import { chromaticWithTablet } from "testHelpers/chromatic";
|
||||
import {
|
||||
MockConnectedSSHConnectionLog,
|
||||
MockDisconnectedSSHConnectionLog,
|
||||
MockUserOwner,
|
||||
} from "testHelpers/entities";
|
||||
import { MockUserOwner } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
getDefaultFilterProps,
|
||||
@@ -20,28 +17,63 @@ import { ConnectionLogPageView } from "./ConnectionLogPageView";
|
||||
type FilterProps = ComponentProps<typeof ConnectionLogPageView>["filterProps"];
|
||||
|
||||
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
|
||||
query: `username:${MockUserOwner.username}`,
|
||||
query: `workspace_owner:${MockUserOwner.username}`,
|
||||
values: {
|
||||
username: MockUserOwner.username,
|
||||
status: undefined,
|
||||
type: undefined,
|
||||
workspace_owner: MockUserOwner.username,
|
||||
organization: undefined,
|
||||
},
|
||||
menus: {
|
||||
user: MockMenu,
|
||||
status: MockMenu,
|
||||
type: MockMenu,
|
||||
},
|
||||
});
|
||||
|
||||
const MockGlobalSession: GlobalWorkspaceSession = {
|
||||
id: "session-1",
|
||||
workspace_id: "workspace-1",
|
||||
workspace_name: "my-workspace",
|
||||
workspace_owner_username: "testuser",
|
||||
ip: "192.168.1.100",
|
||||
client_hostname: "dev-laptop",
|
||||
status: "ongoing",
|
||||
started_at: "2024-01-15T10:00:00Z",
|
||||
connections: [
|
||||
{
|
||||
ip: "192.168.1.100",
|
||||
status: "ongoing",
|
||||
created_at: "2024-01-15T10:00:00Z",
|
||||
connected_at: "2024-01-15T10:00:01Z",
|
||||
type: "ssh",
|
||||
client_hostname: "dev-laptop",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MockEndedSession: GlobalWorkspaceSession = {
|
||||
id: "session-2",
|
||||
workspace_id: "workspace-2",
|
||||
workspace_name: "staging-env",
|
||||
workspace_owner_username: "admin",
|
||||
ip: "10.0.0.5",
|
||||
status: "clean_disconnected",
|
||||
started_at: "2024-01-15T08:00:00Z",
|
||||
ended_at: "2024-01-15T09:30:00Z",
|
||||
connections: [
|
||||
{
|
||||
ip: "10.0.0.5",
|
||||
status: "clean_disconnected",
|
||||
created_at: "2024-01-15T08:00:00Z",
|
||||
connected_at: "2024-01-15T08:00:01Z",
|
||||
ended_at: "2024-01-15T09:30:00Z",
|
||||
type: "vscode",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ConnectionLogPageView> = {
|
||||
title: "pages/ConnectionLogPage",
|
||||
component: ConnectionLogPageView,
|
||||
args: {
|
||||
connectionLogs: [
|
||||
MockConnectedSSHConnectionLog,
|
||||
MockDisconnectedSSHConnectionLog,
|
||||
],
|
||||
sessions: [MockGlobalSession, MockEndedSession],
|
||||
isConnectionLogVisible: true,
|
||||
filterProps: defaultFilterProps,
|
||||
},
|
||||
@@ -50,37 +82,37 @@ const meta: Meta<typeof ConnectionLogPageView> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectionLogPageView>;
|
||||
|
||||
export const ConnectionLog: Story = {
|
||||
export const Sessions: Story = {
|
||||
parameters: { chromatic: chromaticWithTablet },
|
||||
args: {
|
||||
connectionLogsQuery: mockSuccessResult,
|
||||
sessionsQuery: mockSuccessResult,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
connectionLogs: undefined,
|
||||
sessions: undefined,
|
||||
isNonInitialPage: false,
|
||||
connectionLogsQuery: mockInitialRenderResult,
|
||||
sessionsQuery: mockInitialRenderResult,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyPage: Story = {
|
||||
args: {
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
isNonInitialPage: true,
|
||||
connectionLogsQuery: {
|
||||
sessionsQuery: {
|
||||
...mockSuccessResult,
|
||||
totalRecords: 0,
|
||||
} as UsePaginatedQueryResult,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoLogs: Story = {
|
||||
export const NoSessions: Story = {
|
||||
args: {
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
isNonInitialPage: false,
|
||||
connectionLogsQuery: {
|
||||
sessionsQuery: {
|
||||
...mockSuccessResult,
|
||||
totalRecords: 0,
|
||||
} as UsePaginatedQueryResult,
|
||||
@@ -90,6 +122,6 @@ export const NoLogs: Story = {
|
||||
export const NotVisible: Story = {
|
||||
args: {
|
||||
isConnectionLogVisible: false,
|
||||
connectionLogsQuery: mockInitialRenderResult,
|
||||
sessionsQuery: mockInitialRenderResult,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConnectionLog } from "api/typesGenerated";
|
||||
import type { GlobalWorkspaceSession } from "api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
@@ -20,36 +20,36 @@ import type { ComponentProps, FC } from "react";
|
||||
import { docs } from "utils/docs";
|
||||
import { ConnectionLogFilter } from "./ConnectionLogFilter";
|
||||
import { ConnectionLogHelpTooltip } from "./ConnectionLogHelpTooltip";
|
||||
import { ConnectionLogRow } from "./ConnectionLogRow/ConnectionLogRow";
|
||||
import { GlobalSessionRow } from "./GlobalSessionRow";
|
||||
|
||||
const Language = {
|
||||
title: "Connection Log",
|
||||
subtitle: "View workspace connection events.",
|
||||
subtitle: "View workspace connection sessions.",
|
||||
};
|
||||
|
||||
interface ConnectionLogPageViewProps {
|
||||
connectionLogs?: readonly ConnectionLog[];
|
||||
sessions?: readonly GlobalWorkspaceSession[];
|
||||
isNonInitialPage: boolean;
|
||||
isConnectionLogVisible: boolean;
|
||||
error?: unknown;
|
||||
filterProps: ComponentProps<typeof ConnectionLogFilter>;
|
||||
connectionLogsQuery: PaginationResult;
|
||||
sessionsQuery: PaginationResult;
|
||||
}
|
||||
|
||||
export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
connectionLogs,
|
||||
sessions,
|
||||
isNonInitialPage,
|
||||
isConnectionLogVisible,
|
||||
error,
|
||||
filterProps,
|
||||
connectionLogsQuery: paginationResult,
|
||||
sessionsQuery: paginationResult,
|
||||
}) => {
|
||||
const isLoading =
|
||||
(connectionLogs === undefined ||
|
||||
(sessions === undefined ||
|
||||
paginationResult.totalRecords === undefined) &&
|
||||
!error;
|
||||
|
||||
const isEmpty = !isLoading && connectionLogs?.length === 0;
|
||||
const isEmpty = !isLoading && sessions?.length === 0;
|
||||
|
||||
return (
|
||||
<Margins className="pb-12">
|
||||
@@ -69,7 +69,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
|
||||
<PaginationContainer
|
||||
query={paginationResult}
|
||||
paginationUnitLabel="logs"
|
||||
paginationUnitLabel="sessions"
|
||||
>
|
||||
<Table>
|
||||
<TableBody>
|
||||
@@ -78,7 +78,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
<Cond condition={Boolean(error)}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="An error occurred while loading connection logs" />
|
||||
<EmptyState message="An error occurred while loading sessions" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
@@ -92,7 +92,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
<Cond condition={isNonInitialPage}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No connection logs available on this page" />
|
||||
<EmptyState message="No sessions available on this page" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
@@ -100,7 +100,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
<Cond>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No connection logs available" />
|
||||
<EmptyState message="No sessions available" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
@@ -108,12 +108,15 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{connectionLogs && (
|
||||
{sessions && (
|
||||
<Timeline
|
||||
items={connectionLogs}
|
||||
getDate={(log) => new Date(log.connect_time)}
|
||||
row={(log) => (
|
||||
<ConnectionLogRow key={log.id} connectionLog={log} />
|
||||
items={sessions}
|
||||
getDate={(session) => new Date(session.started_at)}
|
||||
row={(session) => (
|
||||
<GlobalSessionRow
|
||||
key={session.id ?? session.started_at}
|
||||
session={session}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import type {
|
||||
ConnectionType,
|
||||
GlobalWorkspaceSession,
|
||||
WorkspaceConnection,
|
||||
WorkspaceConnectionStatus,
|
||||
} from "api/typesGenerated";
|
||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { TableCell } from "components/Table/Table";
|
||||
import { TimelineEntry } from "components/Timeline/TimelineEntry";
|
||||
import { type FC, useState } from "react";
|
||||
import { ConnectionDetailDialog } from "./ConnectionDetailDialog";
|
||||
|
||||
interface GlobalSessionRowProps {
|
||||
session: GlobalWorkspaceSession;
|
||||
}
|
||||
|
||||
export const GlobalSessionRow: FC<GlobalSessionRowProps> = ({ session }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedConnection, setSelectedConnection] =
|
||||
useState<WorkspaceConnection | null>(null);
|
||||
|
||||
const hasConnections = session.connections.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimelineEntry
|
||||
data-testid={`session-row-${session.id ?? session.started_at}`}
|
||||
clickable={hasConnections}
|
||||
>
|
||||
<TableCell className="py-4 px-8">
|
||||
{/* Summary row */}
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
className="cursor-pointer"
|
||||
tabIndex={0}
|
||||
onClick={() => hasConnections && setIsOpen((v) => !v)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && hasConnections) {
|
||||
setIsOpen((v) => !v);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasConnections && <DropdownArrow close={isOpen} />}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Workspace info */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium text-content-primary truncate">
|
||||
{session.workspace_name}
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary truncate">
|
||||
{session.workspace_owner_username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Client info */}
|
||||
<span className="text-xs text-content-secondary font-mono">
|
||||
{session.short_description ||
|
||||
session.client_hostname ||
|
||||
session.ip ||
|
||||
"Unknown"}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${connectionStatusDot(session.status)}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${connectionStatusColor(session.status)}`}
|
||||
>
|
||||
{connectionStatusLabel(session.status)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Connection count */}
|
||||
<span className="text-xs text-content-secondary shrink-0">
|
||||
{session.connections.length}{" "}
|
||||
{session.connections.length === 1
|
||||
? "connection"
|
||||
: "connections"}
|
||||
</span>
|
||||
|
||||
{/* Time range */}
|
||||
<span className="text-xs text-content-secondary shrink-0">
|
||||
{formatTimeRange(session.started_at, session.ended_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Expanded connections list */}
|
||||
<Collapse in={isOpen}>
|
||||
<div className="mt-3 ml-8 space-y-1">
|
||||
{session.connections.map((conn, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
// Connections don't have guaranteed unique IDs, so we
|
||||
// use the index combined with created_at as a key.
|
||||
key={`${conn.created_at}-${idx}`}
|
||||
className="flex items-center gap-3 py-2 px-3 rounded cursor-pointer hover:bg-surface-secondary w-full text-left"
|
||||
onClick={() => setSelectedConnection(conn)}
|
||||
>
|
||||
<span className="text-xs font-mono text-content-primary">
|
||||
{connectionTypeLabel(conn.type, conn.detail)}
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{conn.connected_at
|
||||
? new Date(conn.connected_at).toLocaleTimeString()
|
||||
: new Date(conn.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${connectionStatusDot(conn.status)}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${connectionStatusColor(conn.status)}`}
|
||||
>
|
||||
{connectionStatusLabel(conn.status)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TimelineEntry>
|
||||
|
||||
<ConnectionDetailDialog
|
||||
connection={selectedConnection}
|
||||
onClose={() => setSelectedConnection(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function connectionStatusLabel(status: WorkspaceConnectionStatus): string {
|
||||
switch (status) {
|
||||
case "ongoing":
|
||||
return "Connected";
|
||||
case "control_lost":
|
||||
return "Control Lost";
|
||||
case "client_disconnected":
|
||||
return "Disconnected";
|
||||
case "clean_disconnected":
|
||||
return "Disconnected";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionStatusColor(status: WorkspaceConnectionStatus): string {
|
||||
switch (status) {
|
||||
case "ongoing":
|
||||
return "text-content-success";
|
||||
case "control_lost":
|
||||
return "text-content-warning";
|
||||
case "client_disconnected":
|
||||
case "clean_disconnected":
|
||||
return "text-content-secondary";
|
||||
default:
|
||||
return "text-content-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function connectionStatusDot(status: WorkspaceConnectionStatus): string {
|
||||
switch (status) {
|
||||
case "ongoing":
|
||||
return "bg-content-success";
|
||||
case "control_lost":
|
||||
return "bg-content-warning";
|
||||
case "client_disconnected":
|
||||
case "clean_disconnected":
|
||||
return "bg-content-secondary";
|
||||
default:
|
||||
return "bg-content-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function connectionTypeLabel(type_: ConnectionType, detail?: string): string {
|
||||
switch (type_) {
|
||||
case "ssh":
|
||||
return "SSH";
|
||||
case "reconnecting_pty":
|
||||
return "ReconnectingPTY";
|
||||
case "vscode":
|
||||
return "VSCode";
|
||||
case "jetbrains":
|
||||
return "JetBrains";
|
||||
case "workspace_app":
|
||||
return detail ? `App: ${detail}` : "Workspace App";
|
||||
case "port_forwarding":
|
||||
return detail ? `Port ${detail}` : "Port Forwarding";
|
||||
case "system":
|
||||
return "System";
|
||||
default:
|
||||
return type_;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeRange(startedAt: string, endedAt?: string): string {
|
||||
const start = new Date(startedAt);
|
||||
const startStr = start.toLocaleTimeString();
|
||||
if (!endedAt) {
|
||||
return `${startStr} — ongoing`;
|
||||
}
|
||||
const end = new Date(endedAt);
|
||||
return `${startStr} — ${end.toLocaleTimeString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user