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:
Seth Shelnutt
2026-02-11 16:41:49 -05:00
committed by Seth Shelnutt
parent 85e3e19673
commit 3e84596fc2
8 changed files with 438 additions and 183 deletions
+11
View File
@@ -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()}`;
}