diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/events/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/events/page.tsx new file mode 100644 index 000000000..64cce3cbf --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/events/page.tsx @@ -0,0 +1,5 @@ +import { SandboxEventsView } from '@/features/dashboard/sandbox/events' + +export default function SandboxEventsPage() { + return +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 768833627..8e0ef1e7c 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -32,6 +32,8 @@ export const PROTECTED_URLS = { `/dashboard/${teamSlug}/sandboxes/${sandboxId}/monitoring`, SANDBOX_MONITORING: (teamSlug: string, sandboxId: string) => `/dashboard/${teamSlug}/sandboxes/${sandboxId}/monitoring`, + SANDBOX_EVENTS: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/events`, SANDBOX_LOGS: (teamSlug: string, sandboxId: string) => `/dashboard/${teamSlug}/sandboxes/${sandboxId}/logs`, SANDBOX_FILESYSTEM: (teamSlug: string, sandboxId: string) => diff --git a/src/core/modules/sandboxes/lifecycle-event-types.ts b/src/core/modules/sandboxes/lifecycle-event-types.ts new file mode 100644 index 000000000..220284bce --- /dev/null +++ b/src/core/modules/sandboxes/lifecycle-event-types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +const SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX = 'sandbox.lifecycle.' + +const SandboxLifecycleEventTypeSchema = z.enum([ + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}created`, + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}updated`, + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}paused`, + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}resumed`, + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}killed`, +]) + +type SandboxLifecycleEventType = z.infer + +export { + SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX, + SandboxLifecycleEventTypeSchema, + type SandboxLifecycleEventType, +} diff --git a/src/features/dashboard/build/logs.tsx b/src/features/dashboard/build/logs.tsx index 3d177a474..4176e881e 100644 --- a/src/features/dashboard/build/logs.tsx +++ b/src/features/dashboard/build/logs.tsx @@ -18,10 +18,12 @@ import { LogLevelFilter } from '@/features/dashboard/common/log-level-filter' import { LogStatusCell, LogsEmptyBody, - LogsLoaderBody, LogsTableHeader, - LogVirtualRow, } from '@/features/dashboard/common/log-viewer-ui' +import { + VirtualizedTableLoaderBody, + VirtualizedTableRow, +} from '@/features/dashboard/common/virtualized-table-ui' import { cn } from '@/lib/utils' import { Loader } from '@/ui/primitives/loader' import { Table, TableBody, TableCell } from '@/ui/primitives/table' @@ -70,7 +72,7 @@ export default function Logs({ levelWidth={COLUMN_WIDTHS_PX.level} timestampSortDirection="asc" /> - + @@ -172,7 +174,7 @@ function LogsContent({ timestampSortDirection="asc" /> - {showLoader && } + {showLoader && } {showEmpty && ( )} @@ -514,7 +516,7 @@ function LogRow({ const millisAfterStart = log.timestampUnix - startedAt return ( - - + ) } @@ -567,7 +569,7 @@ function StatusRow({ isFetchingNextPage, }: StatusRowProps) { return ( - - + ) } @@ -603,7 +605,7 @@ function LiveStatusRow({ isBuilding, }: LiveStatusRowProps) { return ( - - + ) } diff --git a/src/features/dashboard/common/log-viewer-ui.tsx b/src/features/dashboard/common/log-viewer-ui.tsx index c62a62209..e40dd5d2d 100644 --- a/src/features/dashboard/common/log-viewer-ui.tsx +++ b/src/features/dashboard/common/log-viewer-ui.tsx @@ -1,8 +1,6 @@ -import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' import type { CSSProperties, ReactNode } from 'react' import { cn } from '@/lib/utils' import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons' -import { Loader } from '@/ui/primitives/loader' import { TableBody, TableCell, @@ -57,20 +55,6 @@ export function LogsTableHeader({ ) } -export function LogsLoaderBody() { - return ( - - - -
- -
-
-
-
- ) -} - interface LogsEmptyBodyProps { description?: ReactNode } @@ -95,47 +79,6 @@ export function LogsEmptyBody({ description }: LogsEmptyBodyProps) { ) } -export function getLogVirtualRowStyle( - virtualRow: VirtualItem, - height: number -): CSSProperties { - return { - display: 'flex', - position: 'absolute', - left: 0, - transform: `translateY(${virtualRow.start}px)`, - minWidth: '100%', - height, - } -} - -interface LogVirtualRowProps { - virtualRow: VirtualItem - virtualizer: Virtualizer - height: number - className?: string - children: ReactNode -} - -export function LogVirtualRow({ - virtualRow, - virtualizer, - height, - className, - children, -}: LogVirtualRowProps) { - return ( - virtualizer.measureElement(node)} - className={className} - style={getLogVirtualRowStyle(virtualRow, height)} - > - {children} - - ) -} - const STATUS_ROW_CELL_STYLE: CSSProperties = { display: 'flex', alignItems: 'center', diff --git a/src/features/dashboard/common/virtualized-table-ui.tsx b/src/features/dashboard/common/virtualized-table-ui.tsx new file mode 100644 index 000000000..2fc24d918 --- /dev/null +++ b/src/features/dashboard/common/virtualized-table-ui.tsx @@ -0,0 +1,53 @@ +import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' +import type { CSSProperties, ReactNode } from 'react' +import { Loader } from '@/ui/primitives/loader' +import { TableBody, TableCell, TableRow } from '@/ui/primitives/table' + +export const getVirtualizedRowStyle = ( + virtualRow: VirtualItem, + height: number +): CSSProperties => ({ + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height, +}) + +interface VirtualizedTableRowProps { + virtualRow: VirtualItem + virtualizer: Virtualizer + height: number + className?: string + children: ReactNode +} + +export const VirtualizedTableRow = ({ + virtualRow, + virtualizer, + height, + className, + children, +}: VirtualizedTableRowProps) => ( + virtualizer.measureElement(node)} + className={className} + style={getVirtualizedRowStyle(virtualRow, height)} + > + {children} + +) + +export const VirtualizedTableLoaderBody = () => ( + + + +
+ +
+
+
+
+) diff --git a/src/features/dashboard/sandbox/events/event-type-badge.tsx b/src/features/dashboard/sandbox/events/event-type-badge.tsx new file mode 100644 index 000000000..7f41f8b68 --- /dev/null +++ b/src/features/dashboard/sandbox/events/event-type-badge.tsx @@ -0,0 +1,24 @@ +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { Badge } from '@/ui/primitives/badge' +import { SANDBOX_EVENT_TYPE_MAP } from './event-type-map' + +export const SandboxEventTypeBadge = ({ type }: { type: string }) => { + const parsed = SandboxLifecycleEventTypeSchema.safeParse(type) + + if (!parsed.success) { + return ( + + {type} + + ) + } + + const { icon: IconComponent, label } = SANDBOX_EVENT_TYPE_MAP[parsed.data] + + return ( + + + {label} + + ) +} diff --git a/src/features/dashboard/sandbox/events/event-type-filter.tsx b/src/features/dashboard/sandbox/events/event-type-filter.tsx new file mode 100644 index 000000000..6d19d4df4 --- /dev/null +++ b/src/features/dashboard/sandbox/events/event-type-filter.tsx @@ -0,0 +1,79 @@ +'use client' + +import { + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import { Button } from '@/ui/primitives/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/ui/primitives/dropdown-menu' +import { SandboxEventTypeBadge } from './event-type-badge' +import { SANDBOX_EVENT_TYPE_MAP } from './event-type-map' + +const getTriggerLabel = (selected: SandboxLifecycleEventType[]) => { + if (selected.length === SandboxLifecycleEventTypeSchema.options.length) + return 'All' + if (selected.length === 0) return 'None' + const [first] = selected + if (selected.length === 1 && first) return SANDBOX_EVENT_TYPE_MAP[first].label + return `${selected.length}/${SandboxLifecycleEventTypeSchema.options.length}` +} + +interface EventTypeFilterProps { + types: SandboxLifecycleEventType[] + onTypesChange: (types: SandboxLifecycleEventType[]) => void +} + +export const EventTypeFilter = ({ + types, + onTypesChange, +}: EventTypeFilterProps) => { + const isAllSelected = + types.length === SandboxLifecycleEventTypeSchema.options.length + + const toggleType = (type: SandboxLifecycleEventType) => { + const next = types.includes(type) + ? types.filter((t) => t !== type) + : [...types, type] + onTypesChange(next) + } + + const toggleAll = (checked: boolean) => { + onTypesChange(checked ? [...SandboxLifecycleEventTypeSchema.options] : []) + } + + return ( + + + + + + e.preventDefault()} + > + All events + + + {SandboxLifecycleEventTypeSchema.options.map((type) => ( + toggleType(type)} + onSelect={(e) => e.preventDefault()} + > + + + ))} + + + ) +} diff --git a/src/features/dashboard/sandbox/events/event-type-map.ts b/src/features/dashboard/sandbox/events/event-type-map.ts new file mode 100644 index 000000000..fb94cc123 --- /dev/null +++ b/src/features/dashboard/sandbox/events/event-type-map.ts @@ -0,0 +1,22 @@ +import type { SandboxLifecycleEventType } from '@/core/modules/sandboxes/lifecycle-event-types' +import { + BlockIcon, + CheckIcon, + type Icon, + PausedIcon, + RefreshIcon, + RunningIcon, +} from '@/ui/primitives/icons' + +const SANDBOX_EVENT_TYPE_MAP: Record< + SandboxLifecycleEventType, + { icon: Icon; label: string } +> = { + 'sandbox.lifecycle.created': { icon: CheckIcon, label: 'Created' }, + 'sandbox.lifecycle.updated': { icon: RefreshIcon, label: 'Updated' }, + 'sandbox.lifecycle.paused': { icon: PausedIcon, label: 'Paused' }, + 'sandbox.lifecycle.resumed': { icon: RunningIcon, label: 'Resumed' }, + 'sandbox.lifecycle.killed': { icon: BlockIcon, label: 'Killed' }, +} + +export { SANDBOX_EVENT_TYPE_MAP } diff --git a/src/features/dashboard/sandbox/events/filter-params.ts b/src/features/dashboard/sandbox/events/filter-params.ts new file mode 100644 index 000000000..5a825130b --- /dev/null +++ b/src/features/dashboard/sandbox/events/filter-params.ts @@ -0,0 +1,29 @@ +import { createParser, parseAsArrayOf, parseAsStringEnum } from 'nuqs/server' +import { + SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX, + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' + +const SANDBOX_EVENTS_ORDER_VALUES: ['asc', 'desc'] = ['asc', 'desc'] + +type SandboxEventsOrder = (typeof SANDBOX_EVENTS_ORDER_VALUES)[number] + +// Maps URL value to lifecycle event type, e.g. "created" -> "sandbox.lifecycle.created" +const eventTypeParser = createParser({ + parse: (value) => { + const result = SandboxLifecycleEventTypeSchema.safeParse( + `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}${value}` + ) + return result.success ? result.data : null + }, + serialize: (value: SandboxLifecycleEventType) => + value.slice(SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX.length), +}) + +const sandboxEventsFilterParams = { + types: parseAsArrayOf(eventTypeParser), + order: parseAsStringEnum(SANDBOX_EVENTS_ORDER_VALUES), +} + +export { sandboxEventsFilterParams, type SandboxEventsOrder } diff --git a/src/features/dashboard/sandbox/events/index.ts b/src/features/dashboard/sandbox/events/index.ts new file mode 100644 index 000000000..828e1cc93 --- /dev/null +++ b/src/features/dashboard/sandbox/events/index.ts @@ -0,0 +1 @@ +export { SandboxEventsView } from './view' diff --git a/src/features/dashboard/sandbox/events/table.tsx b/src/features/dashboard/sandbox/events/table.tsx new file mode 100644 index 000000000..1d8008465 --- /dev/null +++ b/src/features/dashboard/sandbox/events/table.tsx @@ -0,0 +1,223 @@ +'use client' + +import { + useVirtualizer, + type VirtualItem, + type Virtualizer, +} from '@tanstack/react-virtual' +import { useMemo } from 'react' +import type { SandboxEventModel } from '@/core/modules/sandboxes/models' +import { + VirtualizedTableLoaderBody, + VirtualizedTableRow, +} from '@/features/dashboard/common/virtualized-table-ui' +import { IdBadge } from '@/features/dashboard/shared' +import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' +import CopyButtonInline from '@/ui/copy-button-inline' +import { JsonPopover } from '@/ui/json-popover' +import { Button } from '@/ui/primitives/button' +import { ArrowDownIcon, HistoryIcon } from '@/ui/primitives/icons' +import { + Table, + TableBody, + TableCell, + TableEmptyState, + TableHead, + TableHeader, + TableRow, +} from '@/ui/primitives/table' +import { SandboxEventTypeBadge } from './event-type-badge' + +const ROW_HEIGHT_PX = 32 +const VIRTUAL_OVERSCAN = 16 + +interface SandboxEventsTableProps { + events: SandboxEventModel[] + isLoading: boolean + scrollContainer: HTMLDivElement | null + isTimestampDescending: boolean + onToggleTimestampSort: () => void +} + +export const SandboxEventsTable = ({ + events, + isLoading, + scrollContainer, + isTimestampDescending, + onToggleTimestampSort, +}: SandboxEventsTableProps) => { + 'use no memo' + + return ( + + + + + + + + ID + + + Event + + Data + + + + {isLoading ? ( + + ) : events.length > 0 ? ( + + ) : ( + + + + No events found + + + )} +
+ ) +} + +interface VirtualizedEventsBodyProps { + events: SandboxEventModel[] + scrollContainer: HTMLDivElement | null +} + +const VirtualizedEventsBody = ({ + events, + scrollContainer, +}: VirtualizedEventsBodyProps) => { + 'use no memo' + + const initialRect = useMemo(() => { + if (!scrollContainer) return undefined + + return { + height: scrollContainer.clientHeight, + width: scrollContainer.clientWidth, + } + }, [scrollContainer]) + + const virtualizer = useVirtualizer({ + count: events.length, + estimateSize: () => ROW_HEIGHT_PX, + getScrollElement: () => scrollContainer, + initialRect, + overscan: VIRTUAL_OVERSCAN, + paddingStart: 8, + }) + + return ( + + {virtualizer.getVirtualItems().map((virtualRow) => { + const event = events[virtualRow.index] + if (!event) return null + + return ( + + ) + })} + + ) +} + +interface SandboxEventRowProps { + event: SandboxEventModel + virtualRow: VirtualItem + virtualizer: Virtualizer +} + +const SandboxEventRow = ({ + event, + virtualRow, + virtualizer, +}: SandboxEventRowProps) => { + const formattedTimestamp = formatLocalLogStyleTimestamp(event.timestamp, { + includeCentiseconds: true, + }) + const eventDataValue = useMemo( + () => JSON.stringify(event.eventData ?? {}), + [event.eventData] + ) + + return ( + + + {formattedTimestamp ? ( + + + {formattedTimestamp.datePart} + {' '} + + {formattedTimestamp.timePart}.{formattedTimestamp.subsecondPart} + + + ) : ( +
+ -- +
+ )} +
+ + + + + + + + {!event.eventData || eventDataValue.trim() === '{}' ? ( + + n/a + + ) : ( + + + {eventDataValue} + + + )} + +
+ ) +} diff --git a/src/features/dashboard/sandbox/events/use-sandbox-event-filters.ts b/src/features/dashboard/sandbox/events/use-sandbox-event-filters.ts new file mode 100644 index 000000000..c405d57f2 --- /dev/null +++ b/src/features/dashboard/sandbox/events/use-sandbox-event-filters.ts @@ -0,0 +1,52 @@ +'use client' + +import { useQueryStates } from 'nuqs' +import { useCallback, useMemo } from 'react' +import { + type SandboxLifecycleEventType, + SandboxLifecycleEventTypeSchema, +} from '@/core/modules/sandboxes/lifecycle-event-types' +import { + type SandboxEventsOrder, + sandboxEventsFilterParams, +} from './filter-params' + +const DEFAULT_SANDBOX_EVENTS_ORDER: SandboxEventsOrder = 'desc' + +export const useSandboxEventFilters = () => { + const [filters, setFilters] = useQueryStates(sandboxEventsFilterParams, { + shallow: true, + }) + + const types = useMemo( + () => filters.types ?? [...SandboxLifecycleEventTypeSchema.options], + [filters.types] + ) + const order = filters.order ?? DEFAULT_SANDBOX_EVENTS_ORDER + + const setTypes = useCallback( + (next: SandboxLifecycleEventType[]) => { + const isAll = + next.length === SandboxLifecycleEventTypeSchema.options.length + setFilters({ types: isAll ? null : next }) + }, + [setFilters] + ) + + const setOrder = useCallback( + (order: SandboxEventsOrder) => { + setFilters({ + order: order === DEFAULT_SANDBOX_EVENTS_ORDER ? null : order, + }) + }, + [setFilters] + ) + + return { + order, + orderAsc: order === 'asc', + setOrder, + setTypes, + types, + } +} diff --git a/src/features/dashboard/sandbox/events/view.tsx b/src/features/dashboard/sandbox/events/view.tsx new file mode 100644 index 000000000..6b587122c --- /dev/null +++ b/src/features/dashboard/sandbox/events/view.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useSandboxContext } from '../context' +import { EventTypeFilter } from './event-type-filter' +import { SandboxEventsTable } from './table' +import { useSandboxEventFilters } from './use-sandbox-event-filters' + +export const SandboxEventsView = () => { + 'use no memo' + + const { sandboxLifecycle, isSandboxInfoLoading } = useSandboxContext() + const { order, orderAsc, setOrder, setTypes, types } = + useSandboxEventFilters() + const [scrollContainer, setScrollContainer] = useState( + null + ) + + const events = useMemo(() => { + const lifecycleEvents = sandboxLifecycle?.events ?? [] + const typesSet = new Set(types) + const filteredEvents = lifecycleEvents.filter((event) => + typesSet.has(event.type) + ) + // Sandbox lifecycle events are derived in ascending timestamp order. + const orderedEvents = orderAsc + ? filteredEvents + : [...filteredEvents].reverse() + + return orderedEvents + }, [orderAsc, sandboxLifecycle?.events, types]) + + return ( +
+ +
+ + setOrder(order === 'desc' ? 'asc' : 'desc') + } + /> +
+
+ ) +} diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index 89d7523ba..0581b5465 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -5,7 +5,12 @@ import { SANDBOX_INSPECT_MINIMUM_ENVD_VERSION } from '@/configs/versioning' import { useRouteParams } from '@/lib/hooks/use-route-params' import { isVersionCompatible } from '@/lib/utils/version' import { DashboardTab, DashboardTabs } from '@/ui/dashboard-tabs' -import { ListIcon, StorageIcon, TrendIcon } from '@/ui/primitives/icons' +import { + HistoryIcon, + ListIcon, + StorageIcon, + TrendIcon, +} from '@/ui/primitives/icons' import { useSandboxContext } from './context' import SandboxInspectIncompatible from './inspect/incompatible' @@ -63,6 +68,14 @@ export default function SandboxLayout({ > {children} + } + > + {children} + - + @@ -204,7 +206,7 @@ function LogsContent({ timestampSortDirection="asc" /> - {showLoader && } + {showLoader && } {showEmpty && ( - + ) } @@ -682,7 +684,7 @@ function StatusRow({ isFetchingNextPage, }: StatusRowProps) { return ( - - + ) } @@ -718,7 +720,7 @@ function LiveStatusRow({ isRunning, }: LiveStatusRowProps) { return ( - - + ) } diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 4c82af330..9806fa8c8 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -1,25 +1,22 @@ 'use client' import { usePostHog } from 'posthog-js/react' -import type { MouseEvent } from 'react' import { CLI_GENERATED_KEY_NAME } from '@/configs/api' import type { TeamAPIKey } from '@/core/modules/keys/models' -import { UserAvatar } from '@/features/dashboard/shared' -import { useClipboard } from '@/lib/hooks/use-clipboard' +import { IdBadge, UserAvatar } from '@/features/dashboard/shared' import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' import { formatDate, formatUTCTimestamp } from '@/lib/utils/formatting' import { E2BLogo } from '@/ui/brand' -import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' -import { CheckIcon, CopyIcon, KeyIcon, TrashIcon } from '@/ui/primitives/icons' +import { KeyIcon, TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/ui/primitives/tooltip' -import { getApiKeyIdBadgeLabel, getLastUsedLabel } from './api-keys-utils' +import { getLastUsedLabel } from './api-keys-utils' const tableCellClassName = 'py-3 text-left [tr:first-child>&]:pt-1.5' @@ -31,7 +28,6 @@ interface ApiKeysTableRowProps { export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { const posthog = usePostHog() const { toast } = useToast() - const [wasCopied, copy] = useClipboard() const addedDate = apiKey.createdAt ? (formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') ?? '—') @@ -40,14 +36,9 @@ export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { const lastUsedAt = apiKey.lastUsed const lastUsedLabel = getLastUsedLabel(apiKey) const isCliKey = apiKey.name === CLI_GENERATED_KEY_NAME - const displayId = getApiKeyIdBadgeLabel(apiKey.id) const createdByEmail = apiKey.createdBy?.email?.trim() || 'Unknown user' - const handleCopy = async (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - - await copy(apiKey.id) + const handleIdCopied = () => { posthog.capture('copied API key id') toast(defaultSuccessToast('ID copied to clipboard')) } @@ -68,23 +59,11 @@ export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { - - {displayId} - - + {lastUsedAt ? ( diff --git a/src/features/dashboard/settings/keys/api-keys-utils.ts b/src/features/dashboard/settings/keys/api-keys-utils.ts index fa02b864c..d5f188590 100644 --- a/src/features/dashboard/settings/keys/api-keys-utils.ts +++ b/src/features/dashboard/settings/keys/api-keys-utils.ts @@ -3,32 +3,22 @@ import type { TeamAPIKey } from '@/core/modules/keys/models' import { formatRelativeAgo } from '@/lib/utils/formatting' /** Builds a short masked id string for search and display; e.g. input mask fields → `"e2b_…a1b2"` */ -export const getMaskedIdSearchString = (apiKey: TeamAPIKey): string => { +const getMaskedIdSearchString = (apiKey: TeamAPIKey): string => { const { prefix, maskedValuePrefix, maskedValueSuffix } = apiKey.mask return `${prefix}${maskedValuePrefix}...${maskedValueSuffix}`.toLowerCase() } -/** Builds the visible uppercase ID badge label; e.g. `"e2b_c28e178eecf2"` -> `"E2B_...ECF2"` */ -export const getApiKeyIdBadgeLabel = (id: string): string => { - if (id.length <= 8) return id.toUpperCase() - return `${id.slice(0, 4)}...${id.slice(-4)}`.toUpperCase() -} - /** Returns true when the key name or masked id contains the trimmed query (case-insensitive). */ -export const matchesApiKeySearch = ( - apiKey: TeamAPIKey, - query: string -): boolean => { +const matchesApiKeySearch = (apiKey: TeamAPIKey, query: string): boolean => { const q = query.trim().toLowerCase() if (!q) return true if (apiKey.name.toLowerCase().includes(q)) return true if (apiKey.id.toLowerCase().includes(q)) return true - if (getApiKeyIdBadgeLabel(apiKey.id).toLowerCase().includes(q)) return true return getMaskedIdSearchString(apiKey).includes(q) } /** Human line for last-used cell, matching legacy semantics for pre-collection keys. */ -export const getLastUsedLabel = (apiKey: TeamAPIKey): string => { +const getLastUsedLabel = (apiKey: TeamAPIKey): string => { if (apiKey.lastUsed) return formatRelativeAgo(new Date(apiKey.lastUsed)) const createdBefore = @@ -38,3 +28,5 @@ export const getLastUsedLabel = (apiKey: TeamAPIKey): string => { if (createdBefore) return 'N/A' return 'Never' } + +export { getLastUsedLabel, getMaskedIdSearchString, matchesApiKeySearch } diff --git a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx b/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx index a4620b702..0244951bb 100644 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx +++ b/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import type { UseFormReturn } from 'react-hook-form' import ShikiHighlighter from 'react-shiki' import { useShikiTheme } from '@/configs/shiki' +import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' import type { UpsertWebhookSchemaType } from '@/core/server/functions/webhooks/schema' import { Button } from '@/ui/primitives/button' import { Checkbox } from '@/ui/primitives/checkbox' @@ -22,7 +23,6 @@ import { ScrollArea, ScrollBar } from '@/ui/primitives/scroll-area' import { Separator } from '@/ui/primitives/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/primitives/tabs' import { - WEBHOOK_EVENTS, WEBHOOK_EXAMPLE_PAYLOAD, WEBHOOK_SIGNATURE_VALIDATION_DOCS_URL, } from './constants' @@ -176,7 +176,7 @@ export function WebhookAddEditDialogSteps({ {/* Individual event checkboxes */} - {WEBHOOK_EVENTS.map((event) => ( + {SandboxLifecycleEventTypeSchema.options.map((event) => (
selectedEvents.includes(event)) + selectedEvents.length === SandboxLifecycleEventTypeSchema.options.length && + SandboxLifecycleEventTypeSchema.options.every((event) => + selectedEvents.includes(event) + ) const { errors } = form.formState @@ -143,7 +145,7 @@ export default function WebhookAddEditDialog({ if (allEventsSelected) { form.setValue('events', []) } else { - form.setValue('events', [...WEBHOOK_EVENTS]) + form.setValue('events', [...SandboxLifecycleEventTypeSchema.options]) } } @@ -232,39 +234,34 @@ export default function WebhookAddEditDialog({ Confirm + ) : currentStep === 1 ? ( + ) : ( - /* Add mode: show next/back navigation */ <> - {currentStep === 1 ? ( - - ) : ( - <> - - - - )} + + )} diff --git a/src/features/dashboard/settings/webhooks/constants.ts b/src/features/dashboard/settings/webhooks/constants.ts index 07a172cd8..f61731556 100644 --- a/src/features/dashboard/settings/webhooks/constants.ts +++ b/src/features/dashboard/settings/webhooks/constants.ts @@ -1,13 +1,3 @@ -export const WEBHOOK_EVENTS = [ - 'sandbox.lifecycle.created', - 'sandbox.lifecycle.paused', - 'sandbox.lifecycle.resumed', - 'sandbox.lifecycle.updated', - 'sandbox.lifecycle.killed', -] as const - -export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] - export const WEBHOOK_EXAMPLE_PAYLOAD = `{ "version": "v1", "id": "", diff --git a/src/features/dashboard/shared/id-badge.tsx b/src/features/dashboard/shared/id-badge.tsx new file mode 100644 index 000000000..56fff2934 --- /dev/null +++ b/src/features/dashboard/shared/id-badge.tsx @@ -0,0 +1,52 @@ +'use client' + +import type { MouseEvent } from 'react' +import { useClipboard } from '@/lib/hooks/use-clipboard' +import { Badge } from '@/ui/primitives/badge' +import { Button } from '@/ui/primitives/button' +import { CheckIcon, CopyIcon } from '@/ui/primitives/icons' + +/** Builds the visible uppercase ID badge label; e.g. "e2b_c28e178eecf2" -> "E2B_...ECF2". */ +const getIdBadgeLabel = (id: string): string => { + if (id.length <= 8) return id.toUpperCase() + return `${id.slice(0, 4)}...${id.slice(-4)}`.toUpperCase() +} + +interface IdBadgeProps { + id: string + copyAriaLabel?: string + onCopied?: () => void +} + +export const IdBadge = ({ + id, + copyAriaLabel = 'Copy full ID', + onCopied, +}: IdBadgeProps) => { + const [wasCopied, copy] = useClipboard() + const displayId = getIdBadgeLabel(id) + + const handleCopy = async (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + + await copy(id) + onCopied?.() + } + + return ( + + {displayId} + + + ) +} diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index acbd7102a..9722c392c 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1 +1,2 @@ +export { IdBadge } from './id-badge' export { UserAvatar } from './user-avatar' diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index eed13d295..d0c2ece7c 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -47,20 +47,23 @@ const LOCAL_LOG_STYLE_TIMEZONE_FORMATTER = new Intl.DateTimeFormat(undefined, { /** * Format timestamp parts in local timezone, matching logs table style. - * Example: "Jan 05 14:32:09" + * Example: "Jan 05 14:32:09.93" */ export function formatLocalLogStyleTimestamp( timestamp: number | string | Date, { includeSeconds = true, includeYear = false, + includeCentiseconds = false, }: { includeSeconds?: boolean includeYear?: boolean + includeCentiseconds?: boolean } = {} ): { datePart: string timePart: string + subsecondPart: string | null timezonePart: string iso: string } | null { @@ -86,6 +89,11 @@ export function formatLocalLogStyleTimestamp( ? LOCAL_LOG_STYLE_TIME_FORMATTER : LOCAL_LOG_STYLE_TIME_NO_SECONDS_FORMATTER ).format(date), + subsecondPart: includeCentiseconds + ? Math.floor((date.getMilliseconds() / 10) % 100) + .toString() + .padStart(2, '0') + : null, timezonePart, iso: date.toISOString(), } diff --git a/src/ui/copy-button-inline.tsx b/src/ui/copy-button-inline.tsx index 12e1154a6..cb4036402 100644 --- a/src/ui/copy-button-inline.tsx +++ b/src/ui/copy-button-inline.tsx @@ -1,4 +1,3 @@ -import { useRef, useState } from 'react' import { useClipboard } from '@/lib/hooks/use-clipboard' import { cn } from '@/lib/utils/ui' import { CheckIcon, CopyIcon } from '@/ui/primitives/icons' @@ -20,17 +19,19 @@ export default function CopyButtonInline({ } return ( - - {children} + {children} - + ) } diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx index 69f1e499c..6febf4e29 100644 --- a/src/ui/primitives/table.tsx +++ b/src/ui/primitives/table.tsx @@ -151,8 +151,8 @@ const TableEmptyState = ({ children, className, }: TableEmptyStateProps) => ( - - + +