diff --git a/src/components/common/duration-pill.tsx b/src/components/common/duration-pill.tsx index 92c946c1..a4e50fec 100644 --- a/src/components/common/duration-pill.tsx +++ b/src/components/common/duration-pill.tsx @@ -1,46 +1,12 @@ import * as React from 'react'; import { observer } from 'mobx-react'; -import { TimingEvents } from '../../types'; -import { observableClock } from '../../util/observable'; - import { Pill } from './pill'; -import { formatDuration } from '../../util/text'; - -function sigFig(num: number, figs: number): string { - return num.toFixed(figs); -} - -type DurationPillProps = { className?: string } & ( - | { durationMs: number } - | { timingEvents: Partial } -); - -const calculateDuration = (timingEvents: Partial) => { - const doneTimestamp = timingEvents.responseSentTimestamp ?? timingEvents.abortedTimestamp; - if (timingEvents.startTimestamp !== undefined && doneTimestamp !== undefined) { - return doneTimestamp - timingEvents.startTimestamp; - } +import { calculateAndFormatDuration, FormattedDurationProps } from "../../util/utils"; - if (timingEvents.startTime !== undefined) { - // This may not be perfect - note that startTime comes from the server so we might be - // mildly out of sync (ehhhh, in theory) but this is only for pending requests where - // that's unlikely to be an issue - the final time will be correct regardless. - return observableClock.getTime() - timingEvents.startTime; - } -} +type DurationPillProps = { className?: string } & FormattedDurationProps; export const DurationPill = observer((p: DurationPillProps) => { - let duration: number | undefined; - - if ('durationMs' in p) { - duration = p.durationMs; - } else if (p.timingEvents) { - duration = calculateDuration(p.timingEvents); - } - - if (duration === undefined) return null; - - return {formatDuration(duration)}; + return {calculateAndFormatDuration(p)}; }); \ No newline at end of file diff --git a/src/components/view/view-context-menu-builder.ts b/src/components/view/view-context-menu-builder.ts index fb95aaf6..e165ce1d 100644 --- a/src/components/view/view-context-menu-builder.ts +++ b/src/components/view/view-context-menu-builder.ts @@ -27,8 +27,10 @@ export class ViewEventContextMenuBuilder { private onPin: (event: CollectedEvent) => void, private onDelete: (event: CollectedEvent) => void, private onBuildRuleFromExchange: (exchange: HttpExchangeView) => void, - private onPrepareToResendRequest?: (exchange: HttpExchangeView) => void - ) {} + private onPrepareToResendRequest?: (exchange: HttpExchangeView) => void, + private onHeaderColumnOptionChange?: (visibleViewColumns: Map, columnName: string, show: boolean) => void, + ) { + } private readonly BaseOptions = { Pin: { @@ -142,4 +144,28 @@ export class ViewEventContextMenuBuilder { }; } + getHeaderToggleContextMenu(enabledColumns: Map) { + let menuOptions: ContextMenuItem[] = []; + + enabledColumns.forEach((enabled, columnName) => { + menuOptions.push({ + type: 'option', + label: (!enabled ? "Show " : "Hide ") + columnName, + callback: () => { + this.onHeaderColumnOptionChange ? this.onHeaderColumnOptionChange(enabledColumns, columnName, !enabled) : console.log('onHeaderColumnOptionChange callback not set'); + } + }); + }); + + return (mouseEvent: React.MouseEvent) => { + const sortedOptions = _.sortBy(menuOptions, (o: ContextMenuItem) => + o.type === 'separator' || !(o.enabled ?? true) + ) as Array>; + + this.uiStore.handleContextMenuEvent( + mouseEvent, + sortedOptions + ) + }; + } } \ No newline at end of file diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index 88409c96..9c4bef52 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -7,6 +7,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { styled } from '../../styles' +import { css } from "styled-components"; import { ArrowIcon, Icon, PhosphorIcon, WarningIcon } from '../../icons'; import { @@ -27,6 +28,7 @@ import { } from '../../model/events/categorization'; import { nameStepClass } from '../../model/rules/rule-descriptions'; import { getReadableSize } from '../../util/buffer'; +import { calculateAndFormatDuration } from "../../util/utils"; import { UnreachableCheck } from '../../util/error'; import { filterProps } from '../component-utils'; @@ -87,7 +89,7 @@ const ListContainer = styled.div<{ role: 'table' }>` } `; -const Column = styled.div<{ role: 'cell' | 'columnheader' }>` +const columnStyles = css` display: block; overflow: hidden; text-overflow: ellipsis; @@ -95,6 +97,30 @@ const Column = styled.div<{ role: 'cell' | 'columnheader' }>` padding: 3px 0; `; +const Column = styled.div<{ role: 'cell' | 'columnheader' }>` + ${columnStyles} +`; + +const ColumnVisibilityToggle = inject('uiStore')(observer(({ columnName, uiStore, children }: { + columnName: string, + uiStore?: UiStore, + children?: React.ReactNode +}) => { + //Render by default + let renderComponent = true; + + if (uiStore) { + const { visibleViewColumns } = uiStore; + renderComponent = visibleViewColumns.get(columnName) === true + } + + return ( + <> + {renderComponent && (children ?? children)} + + ) +})); + const RowPin = styled( filterProps(Icon, 'pinned') ).attrs((p: { pinned: boolean }) => ({ @@ -111,12 +137,12 @@ const RowPin = styled( ${(p: { pinned: boolean }) => p.pinned - ? ` + ? ` width: auto; padding: 8px 7px; && { margin-right: -3px; } ` - : ` + : ` padding: 8px 0; width: 0 !important; margin: 0 !important; @@ -148,12 +174,34 @@ const MarkerHeader = styled.div<{ role: 'columnheader' }>` flex-shrink: 0; `; +const BaseTimestamp = ({ timestamp, role = 'cell', className, children }: { + timestamp?: number, + role?: 'columnheader' | 'cell', + className?: string, + children?: React.ReactNode +}) => { + return ( + +
+ {timestamp != null ? (new Date(timestamp).toLocaleTimeString()) : (children ?? '-')} +
+
+ ); +}; + +const Timestamp = styled(BaseTimestamp)` + ${columnStyles}; + transition: flex-basis 0.1s; + flex-shrink: 0; + flex-grow: 0; +`; + const Method = styled(Column)` transition: flex-basis 0.1s; ${(p: { pinned?: boolean }) => p.pinned - ? 'flex-basis: 50px;' - : 'flex-basis: 71px;' + ? 'flex-basis: 50px;' + : 'flex-basis: 71px;' } flex-shrink: 0; @@ -188,14 +236,43 @@ const PathAndQuery = styled(Column)` flex-basis: 1000px; `; +const BaseDuration = observer(({ exchange, role = 'cell', className, children }: { + exchange?: HttpExchange, role?: 'columnheader' | 'cell', + className?: string, + children?: React.ReactNode +}) => { + let duration: string | null | undefined; + if (exchange != null) { + duration = calculateAndFormatDuration({ timingEvents: exchange.timingEvents }); + } + return ( + +
+ {duration ? + duration : + (children ?? 'Duration') + } +
+
+ ); +}); + +const Duration = styled(BaseDuration)` + ${columnStyles}; + flex-basis: 71px; + flex-shrink: 0; + flex-grow: 0; +`; + + // Match Method + Status, but shrink right margin slightly so that // spinner + "WebRTC Media" fits OK. const EventTypeColumn = styled(Column)` transition: flex-basis 0.1s; ${(p: { pinned?: boolean }) => p.pinned - ? 'flex-basis: 109px;' - : 'flex-basis: 130px;' + ? 'flex-basis: 109px;' + : 'flex-basis: 130px;' } margin-right: 6px !important; @@ -247,6 +324,7 @@ const EventListRow = styled.div<{ role: 'row' }>` color: ${p => p.theme.highlightColor}; fill: ${p => p.theme.highlightColor}; + * { /* Override status etc colours to ensure contrast & give row max visibility */ color: ${p => p.theme.highlightColor}; @@ -279,13 +357,7 @@ const TrafficEventListRow = styled(EventListRow)` const TlsListRow = styled(EventListRow)` height: 28px !important; /* Important required to override react-window's style attr */ - margin: 2px 0; - - font-style: italic; - justify-content: center; - text-align: center; - - opacity: 0.7; + margin: 2px 0 2px 20px; &:hover { opacity: 1; @@ -298,6 +370,13 @@ const TlsListRow = styled(EventListRow)` } `; +const TlsRowDescription = styled(Column)` + flex-grow: 1; + font-style: italic; + justify-content: center; + text-align: center; + opacity: 0.7; +`; export const TableHeaderRow = styled.div<{ role: 'row' }>` height: 38px; overflow: hidden; @@ -442,30 +521,31 @@ const ExchangeRow = inject('uiStore')(observer(({ className={isSelected ? 'selected' : ''} style={style} > - + - { request.method } + + {request.method} { response === 'aborted' ? - : exchange.downstream.isBreakpointed - ? - : exchange.isWebSocket() && response?.statusCode === 101 - ? - : + : exchange.downstream.isBreakpointed + ? + : exchange.isWebSocket() && response?.statusCode === 101 + ? + : } @@ -481,22 +561,23 @@ const ExchangeRow = inject('uiStore')(observer(({ ) && } - - { request.parsedUrl.host } + + {request.parsedUrl.host} - - { request.parsedUrl.pathname + request.parsedUrl.search } + + {request.parsedUrl.pathname + request.parsedUrl.search} + ; })); @@ -541,8 +622,9 @@ const RTCConnectionRow = observer(({ className={isSelected ? 'selected' : ''} style={style} > - + + //TODO: Expose timingEvents { !event.closeState && } WebRTC @@ -609,8 +691,9 @@ const RTCStreamRow = observer(({ className={isSelected ? 'selected' : ''} style={style} > - + + //TODO: Expose timingEvents { !event.closeState && } WebRTC { event.isRTCDataChannel() @@ -699,10 +782,11 @@ const BuiltInApiRow = observer((p: { className={p.isSelected ? 'selected' : ''} style={p.style} > - + + - { api.service.shortName }: { apiOperationName } + {api.service.shortName}: {apiOperationName} - { apiRequestDescription } + {apiRequestDescription} + }); @@ -765,24 +850,29 @@ const TlsRow = observer((p: { const connectionTarget = tlsEvent.upstreamHostname || 'unknown domain'; - return - { - tlsEvent.isTlsTunnel() && - tlsEvent.isOpen() && - - } { - description - } connection to { connectionTarget } - + return ( + + + + { + tlsEvent.isTlsTunnel() && + tlsEvent.isOpen() && + + } { + description + } connection to {connectionTarget} + + + ); }); @observer @@ -836,52 +926,57 @@ export class ViewEventList extends React.Component { const { events, filteredEvents, isPaused } = this.props; return - + + Timestamp Method Status Source Host Path and query + Duration { events.length === 0 - ? (isPaused - ? - Interception is paused, resume it to collect intercepted requests - - : - Connect a client and intercept some requests, and they'll appear here - - ) - - : filteredEvents.length === 0 - ? - No requests match this search filter{ - isPaused ? ' and interception is paused' : '' - } - - - : {({ height, width }) => - {() => - - { EventRow } - - } - } + ? (isPaused + ? + Interception is paused, resume it to collect intercepted requests + + : + Connect a client and intercept some requests, and they'll appear here + + ) + + : filteredEvents.length === 0 + ? + No requests match this search filter{ + isPaused ? ' and interception is paused' : '' + } + + + : {({ height, width }) => + {() => + + {EventRow} + + } + } } ; } diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index 2db52793..2ba72439 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -239,13 +239,13 @@ class ViewPage extends React.Component { return contentPerspective === 'client' ? this.selectedEvent.downstream - : contentPerspective === 'server' - ? (this.selectedEvent.upstream ?? this.selectedEvent.downstream) - : contentPerspective === 'original' - ? this.selectedEvent.original - : contentPerspective === 'transformed' - ? this.selectedEvent.transformed - : unreachableCheck(contentPerspective) + : contentPerspective === 'server' + ? (this.selectedEvent.upstream ?? this.selectedEvent.downstream) + : contentPerspective === 'original' + ? this.selectedEvent.original + : contentPerspective === 'transformed' + ? this.selectedEvent.transformed + : unreachableCheck(contentPerspective) } private readonly contextMenuBuilder = new ViewEventContextMenuBuilder( @@ -254,7 +254,8 @@ class ViewPage extends React.Component { this.onPin, this.onDelete, this.onBuildRuleFromExchange, - this.onPrepareToResendRequest + this.onPrepareToResendRequest, + this.onHeaderColumnOptionChange ); componentDidMount() { @@ -559,6 +560,11 @@ class ViewPage extends React.Component { navigate(`/send`); } + @action.bound + async onHeaderColumnOptionChange(visibleViewColumns: Map, columnName: string, show: boolean) { + visibleViewColumns.set(columnName, show); + } + @action.bound onDelete(event: CollectedEvent) { const { filteredEvents } = this.filteredEventState; diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 6fc02d2e..9ba60247 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -183,10 +183,20 @@ export class UiStore { @persist @observable private _themeName: ThemeName | 'automatic' | 'custom' = 'automatic'; + @persist('map') @observable + private _visibleViewColumns = new Map([ + ['Timestamp', false], + ['Duration', true] + ]); //TODO: implement a strict type + get themeName() { return this._themeName; } + get visibleViewColumns() { + return this._visibleViewColumns + }; + /** * Stores if user prefers a dark color theme (for example when set in system settings). * Used if automatic theme is enabled. diff --git a/src/util/text.ts b/src/util/text.ts index e5768b0d..442c493f 100644 --- a/src/util/text.ts +++ b/src/util/text.ts @@ -34,7 +34,7 @@ const sigFig = (num: number, figs: number): string => export const formatDuration = (duration: number) => duration < 100 ? sigFig(duration, 1) + 'ms' : // 22.3ms - duration < 1000 ? sigFig(duration, 0) + 'ms' : // 999ms - duration < 5000 ? sigFig(duration / 1000, 2) + ' seconds' : // 3.04 seconds - duration < 9900 ? sigFig(duration / 1000, 1) + ' seconds' : // 8.2 seconds - sigFig(duration / 1000, 0) + ' seconds'; // 30 seconds \ No newline at end of file + duration < 1000 ? sigFig(duration, 0) + 'ms' : // 999ms + duration < 5000 ? sigFig(duration / 1000, 2) + 's' : // 3.04s + duration < 59000 ? sigFig(duration / 1000, 1) + 's' : // 30.2s + sigFig(duration / 60000, 1) + 'm' // 1.1m \ No newline at end of file diff --git a/src/util/utils.ts b/src/util/utils.ts new file mode 100644 index 00000000..0b06bf5d --- /dev/null +++ b/src/util/utils.ts @@ -0,0 +1,39 @@ +import { TimingEvents } from "../types"; +import { observableClock } from "./observable"; +import {formatDuration} from "./text"; + +export type FormattedDurationProps = + { + durationMs?: number, + timingEvents: Partial + }; + +export const calculateDuration = (timingEvents: Partial): number | undefined => { + const doneTimestamp = timingEvents.responseSentTimestamp ?? timingEvents.abortedTimestamp ?? timingEvents.wsClosedTimestamp; + + if (timingEvents.startTimestamp !== undefined && doneTimestamp !== undefined) { + return doneTimestamp - timingEvents.startTimestamp; + } + + if (timingEvents.startTime !== undefined) { + // This may not be perfect - note that startTime comes from the server so we might be + // mildly out of sync (ehhhh, in theory) but this is only for pending requests where + // that's unlikely to be an issue - the final time will be correct regardless. + return observableClock.getTime() - timingEvents.startTime; + } +} + +export const calculateAndFormatDuration = (props: FormattedDurationProps): string | null | undefined => { + let duration: number | undefined; + + if (props.durationMs !== undefined) { + duration = props.durationMs; + } else if (props.timingEvents) { + duration = calculateDuration(props.timingEvents); + } + + if (duration === undefined) + return null; + + return formatDuration(duration); +}; \ No newline at end of file