From 21015216feb49b97c95545eaa48b2b7262829d81 Mon Sep 17 00:00:00 2001 From: Gerardo Torres Date: Thu, 3 Feb 2022 12:54:21 -0500 Subject: [PATCH] ui: Add column selector and filter including session details to the session overview page Closes [#73463](https://cockroachlabs.atlassian.net/browse/CRDB-11600) Previously, the sessions page was missing the column selector and filter components that the stmts and txns page have. This change adds both components in addition to new columns in the sessions overview page and edits to the sessions details page. Release note (ui change): added column selector, filters, new columns to the sessions page and sessions details. --- .../cluster-ui/src/icon/circleFilled.tsx | 13 +- .../cluster-ui/src/queryFilter/filter.tsx | 17 +- .../src/sessions/sessionDetails.module.scss | 21 +- .../src/sessions/sessionDetails.tsx | 88 +++++-- .../src/sessions/sessionPage.module.scss | 9 + .../src/sessions/sessionsPage.fixture.ts | 15 ++ .../cluster-ui/src/sessions/sessionsPage.tsx | 238 ++++++++++++++++-- .../sessionsPageConnected.stories.tsx | 4 +- .../src/sessions/sessionsPageConnected.tsx | 69 ++++- .../src/sessions/sessionsTable.module.scss | 25 ++ .../cluster-ui/src/sessions/sessionsTable.tsx | 190 ++++++++------ .../src/sessions/sessionsTableContent.tsx | 115 --------- .../src/statsTableUtil/statsTableUtil.tsx | 184 +++++++++++++- .../localStorage/localStorage.reducer.ts | 6 + .../src/views/sessions/sessionsPage.tsx | 53 +++- 15 files changed, 795 insertions(+), 252 deletions(-) delete mode 100644 pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTableContent.tsx diff --git a/pkg/ui/workspaces/cluster-ui/src/icon/circleFilled.tsx b/pkg/ui/workspaces/cluster-ui/src/icon/circleFilled.tsx index 770f2974e995..6993a159539d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/icon/circleFilled.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/icon/circleFilled.tsx @@ -14,8 +14,11 @@ interface IconProps { className: string; } -export const CircleFilled = ({ className, ...props }: IconProps) => ( - - - -); +export function CircleFilled(props: IconProps) { + const { className } = props; + return ( + + + + ); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx b/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx index 25bfa067fddd..d6237b7ec717 100644 --- a/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx @@ -43,6 +43,7 @@ interface QueryFilter { showScan?: boolean; showRegions?: boolean; showNodes?: boolean; + timeLabel?: string; } interface FilterState { hide: boolean; @@ -69,6 +70,7 @@ export interface Filters { const timeUnit = [ { label: "seconds", value: "seconds" }, { label: "milliseconds", value: "milliseconds" }, + { label: "minutes", value: "minutes" }, ]; export const defaultFilters: Filters = { @@ -239,9 +241,15 @@ export const calculateActiveFilters = (filters: Filters): number => { export const getTimeValueInSeconds = (filters: Filters): number | "empty" => { if (filters.timeNumber === "0") return "empty"; - return filters.timeUnit === "seconds" - ? Number(filters.timeNumber) - : Number(filters.timeNumber) / 1000; + switch (filters.timeUnit) { + case "seconds": + return Number(filters.timeNumber); + case "minutes": + return Number(filters.timeNumber) * 60; + default: + // Milliseconds + return Number(filters.timeNumber) / 1000; + } }; export class Filter extends React.Component { @@ -355,6 +363,7 @@ export class Filter extends React.Component { showScan, showRegions, showNodes, + timeLabel, } = this.props; const dropdownArea = hide ? hidden : dropdown; const customStyles = { @@ -549,7 +558,7 @@ export class Filter extends React.Component { {showRegions ? regionsFilter : ""} {showNodes ? nodesFilter : ""}
- Statement fingerprint runs longer than + {timeLabel ? timeLabel : "Statement fingerprint runs longer than"}
{ - @@ -293,6 +289,11 @@ export class SessionDetails extends React.Component { value={yesOrNo(txn.is_historical)} className={cx("details-item")} /> + { value={TimestampToMoment(stmt.start).format(DATE_FORMAT)} className={cx("details-item")} /> - - this.props.onStatementClick && this.props.onStatementClick() + + + this.props.onStatementClick && + this.props.onStatementClick() + } + > + View Statement Details + + } - > - View Statement Details - + value={""} + className={cx("details-item")} + /> @@ -363,10 +373,12 @@ export class SessionDetails extends React.Component { label={"Gateway Node"} value={ this.props.uiConfig.showGatewayNodeLink ? ( - +
+ +
) : ( session.node_id.toString() ) @@ -374,11 +386,34 @@ export class SessionDetails extends React.Component { className={cx("details-item")} /> )} + + + 0 + ? "session-status-icon__active" + : "session-status-icon__idle", + )} + /> + + {session.active_queries.length > 0 ? "Active" : "Idle"} + + + } + className={cx("details-item")} + /> @@ -386,6 +421,11 @@ export class SessionDetails extends React.Component { alloc_bytes={session.alloc_bytes} max_alloc_bytes={session.max_alloc_bytes} /> + @@ -394,7 +434,7 @@ export class SessionDetails extends React.Component { {txnInfo} - Statement + Most Recent Statement {curStmtInfo} diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionPage.module.scss b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionPage.module.scss index 6fe0b635cc73..3fd710c522ed 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionPage.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionPage.module.scss @@ -15,3 +15,12 @@ all: initial; font-family: $font-family--base; } + +.sessions-filter { + font-size: $font-size--medium; + margin-bottom: $spacing-smaller; +} + +.session-column-selector { + margin-bottom: $spacing-smaller; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.fixture.ts b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.fixture.ts index 96591fdf23f2..c9f3c9b3010e 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.fixture.ts +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.fixture.ts @@ -15,6 +15,7 @@ import Long from "long"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; const Phase = cockroach.server.serverpb.ActiveQuery.Phase; import { util } from "protobufjs"; +import { defaultFilters, Filters } from "../queryFilter"; import { CancelQueryRequestMessage, CancelSessionRequestMessage, @@ -142,7 +143,16 @@ const sessionsList: SessionInfo[] = [ activeSession, ]; +export const filters: Filters = { + app: "", + timeNumber: "0", + timeUnit: "seconds", + regions: "", + nodes: "", +}; + export const sessionsPagePropsFixture: SessionsPageProps = { + filters: defaultFilters, history, location: { pathname: "/sessions", @@ -162,6 +172,8 @@ export const sessionsPagePropsFixture: SessionsPageProps = { ascending: false, columnTitle: "statementAge", }, + columns: null, + internalAppNamePrefix: "$ internal", refreshSessions: () => {}, cancelSession: (req: CancelSessionRequestMessage) => {}, cancelQuery: (req: CancelQueryRequestMessage) => {}, @@ -169,6 +181,7 @@ export const sessionsPagePropsFixture: SessionsPageProps = { }; export const sessionsPagePropsEmptyFixture: SessionsPageProps = { + filters: defaultFilters, history, location: { pathname: "/sessions", @@ -188,6 +201,8 @@ export const sessionsPagePropsEmptyFixture: SessionsPageProps = { ascending: false, columnTitle: "statementAge", }, + columns: null, + internalAppNamePrefix: "$ internal", refreshSessions: () => {}, cancelSession: (req: CancelSessionRequestMessage) => {}, cancelQuery: (req: CancelQueryRequestMessage) => {}, diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.tsx b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.tsx index 18707c4f4600..3850d1a20687 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPage.tsx @@ -9,10 +9,9 @@ // licenses/APL.txt. import React from "react"; -import { isNil } from "lodash"; +import { isNil, merge } from "lodash"; import { syncHistory } from "src/util/query"; -import { appAttr } from "src/util/constants"; import { makeSessionsColumns, SessionInfo, @@ -24,16 +23,24 @@ import { sessionsTable } from "src/util/docs"; import emptyTableResultsIcon from "../assets/emptyState/empty-table-results.svg"; import SQLActivityError from "../sqlActivity/errorComponent"; - -import { Pagination, ResultsPerPageLabel } from "src/pagination"; +import { Pagination } from "src/pagination"; import { SortSetting, ISortedTablePagination, updateSortSettingQueryParamsOnTab, + ColumnDescriptor, } from "src/sortedtable"; import { Loading } from "src/loading"; import { Anchor } from "src/anchor"; import { EmptyTable } from "src/empty"; +import { + calculateActiveFilters, + defaultFilters, + Filter, + Filters, + getTimeValueInSeconds, + handleFiltersFromQueryString, +} from "../queryFilter"; import TerminateQueryModal, { TerminateQueryModalRef, @@ -49,12 +56,26 @@ import { import statementsPageStyles from "src/statementsPage/statementsPage.module.scss"; import sessionPageStyles from "./sessionPage.module.scss"; +import ColumnsSelector, { + SelectOption, +} from "../columnsSelector/columnsSelector"; +import { TimestampToMoment } from "src/util"; +import moment from "moment"; +import { + getLabel, + StatisticTableColumnKeys, +} from "../statsTableUtil/statsTableUtil"; +import { TableStatistics } from "../tableStatistics"; +import * as protos from "@cockroachlabs/crdb-protobuf-client"; + +type ISessionsResponse = protos.cockroach.server.serverpb.IListSessionsResponse; const statementsPageCx = classNames.bind(statementsPageStyles); const sessionsPageCx = classNames.bind(sessionPageStyles); export interface OwnProps { sessions: SessionInfo[]; + internalAppNamePrefix: string; sessionsError: Error | Error[]; sortSetting: SortSetting; refreshSessions: () => void; @@ -69,13 +90,30 @@ export interface OwnProps { onSessionClick?: () => void; onTerminateSessionClick?: () => void; onTerminateStatementClick?: () => void; + onColumnsChange?: (selectedColumns: string[]) => void; + onFilterChange?: (value: Filters) => void; + columns: string[]; + filters: Filters; } export interface SessionsPageState { + apps: string[]; pagination: ISortedTablePagination; + filters: Filters; + activeFilters?: number; } -export type SessionsPageProps = OwnProps & RouteComponentProps; +export type SessionsPageProps = OwnProps & RouteComponentProps; + +function getSessionAppFilterOptions(sessions: SessionInfo[]): string[] { + const uniqueAppNames = new Set( + sessions.map(s => + s.session.application_name ? s.session.application_name : "(unset)", + ), + ); + + return Array.from(uniqueAppNames); +} export class SessionsPage extends React.Component< SessionsPageProps, @@ -87,11 +125,16 @@ export class SessionsPage extends React.Component< constructor(props: SessionsPageProps) { super(props); this.state = { + filters: defaultFilters, + apps: [], pagination: { pageSize: 20, current: 1, }, }; + + const stateFromHistory = this.getStateFromHistory(); + this.state = merge(this.state, stateFromHistory); this.terminateSessionRef = React.createRef(); this.terminateQueryRef = React.createRef(); @@ -111,6 +154,21 @@ export class SessionsPage extends React.Component< } } + getStateFromHistory = (): Partial => { + const { history, filters, onFilterChange } = this.props; + + // Filters. + const latestFilter = handleFiltersFromQueryString( + history, + filters, + onFilterChange, + ); + + return { + filters: latestFilter, + }; + }; + changeSortSetting = (ss: SortSetting): void => { if (this.props.onSortingChange) { this.props.onSortingChange("Sessions", ss.columnTitle, ss.ascending); @@ -161,35 +219,169 @@ export class SessionsPage extends React.Component< this.props.onPageChanged(current); }; + onSubmitFilters = (filters: Filters): void => { + if (this.props.onFilterChange) { + this.props.onFilterChange(filters); + } + + this.setState({ + filters: filters, + }); + this.resetPagination(); + syncHistory( + { + app: filters.app, + timeNumber: filters.timeNumber, + timeUnit: filters.timeUnit, + }, + this.props.history, + ); + }; + + onClearFilters = (): void => { + if (this.props.onFilterChange) { + this.props.onFilterChange(defaultFilters); + } + + this.setState({ + filters: { + ...defaultFilters, + }, + }); + this.resetPagination(); + syncHistory( + { + app: undefined, + timeNumber: undefined, + timeUnit: undefined, + }, + this.props.history, + ); + }; + + getFilteredSessionsData = (): { + sessions: SessionInfo[]; + activeFilters: number; + } => { + const { filters } = this.state; + const { sessions, internalAppNamePrefix } = this.props; + if (!filters) { + return { + sessions: sessions, + activeFilters: 0, + }; + } + const activeFilters = calculateActiveFilters(filters); + const timeValue = getTimeValueInSeconds(filters); + const filteredSessions = sessions + .filter((s: SessionInfo) => { + const isInternal = (s: SessionInfo) => + s.session.application_name.startsWith(internalAppNamePrefix); + if (filters.app && filters.app != "All") { + const apps = filters.app.split(","); + let showInternal = false; + if (apps.includes(internalAppNamePrefix)) { + showInternal = true; + } + if (apps.includes("(unset)")) { + apps.push(""); + } + + return ( + (showInternal && isInternal(s)) || + apps.includes(s.session.application_name) + ); + } else { + return !isInternal(s); + } + }) + .filter((s: SessionInfo) => { + const sessionTime = moment().diff( + TimestampToMoment(s.session.start), + "seconds", + ); + return sessionTime >= timeValue || timeValue === "empty"; + }); + + return { + sessions: filteredSessions, + activeFilters, + }; + }; + renderSessions = (): React.ReactElement => { const sessionsData = this.props.sessions; - const { pagination } = this.state; + const { pagination, filters } = this.state; + const { columns: userSelectedColumnsToShow, onColumnsChange } = this.props; + + const { + sessions: sessionsToDisplay, + activeFilters, + } = this.getFilteredSessionsData(); + + const appNames = getSessionAppFilterOptions(sessionsData); + const columns = makeSessionsColumns( + "session", + this.terminateSessionRef, + this.terminateQueryRef, + this.props.onSessionClick, + this.props.onTerminateStatementClick, + this.props.onTerminateSessionClick, + ); + + const isColumnSelected = (c: ColumnDescriptor) => { + return ( + (!userSelectedColumnsToShow && c.showByDefault) || + (userSelectedColumnsToShow && + userSelectedColumnsToShow.includes(c.name)) || + c.alwaysShow + ); + }; + + const tableColumns = columns + .filter(c => !c.alwaysShow) + .map( + (c): SelectOption => ({ + label: getLabel(c.name as StatisticTableColumnKeys), + value: c.name, + isSelected: isColumnSelected(c), + }), + ); + + const timeLabel = "Session duration runs longer than"; + const displayColumns = columns.filter(c => isColumnSelected(c)); return ( <> +
+ +
-

- + + -

+
(
{storyFn()}
- )) - .add("with data", () => ); + )); diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx index e0e7ef5205e1..0a2404f04bef 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx @@ -14,7 +14,7 @@ import { analyticsActions, AppState } from "src/store"; import { SessionsState } from "src/store/sessions"; import { createSelector } from "reselect"; -import { SessionsPage } from "./index"; +import { OwnProps, SessionsPage } from "./index"; import { actions as sessionsActions } from "src/store/sessions"; import { actions as localStorageActions } from "src/store/localStorage"; @@ -24,6 +24,23 @@ import { ICancelSessionRequest, } from "src/store/terminateQuery"; import { Dispatch } from "redux"; +import { Filters } from "../queryFilter"; +import { sqlStatsSelector } from "../store/sqlStats/sqlStats.selector"; + +export const selectSessionsData = createSelector( + sqlStatsSelector, + sessionsState => (sessionsState.valid ? sessionsState.data : null), +); + +export const adminUISelector = createSelector( + (state: AppState) => state.adminUI, + adminUiState => adminUiState, +); + +export const localStorageSelector = createSelector( + adminUISelector, + adminUiState => adminUiState.localStorage, +); export const selectSessions = createSelector( (state: AppState) => state.adminUI.sessions, @@ -37,17 +54,43 @@ export const selectSessions = createSelector( }, ); +export const selectAppName = createSelector( + (state: AppState) => state.adminUI.sessions, + (state: SessionsState) => { + if (!state.data) { + return null; + } + return state.data.internal_app_name_prefix; + }, +); + export const selectSortSetting = createSelector( (state: AppState) => state.adminUI.localStorage, localStorage => localStorage["sortSetting/SessionsPage"], ); +export const selectColumns = createSelector( + localStorageSelector, + localStorage => + localStorage["showColumns/SessionsPage"] + ? localStorage["showColumns/SessionsPage"].split(",") + : null, +); + +export const selectFilters = createSelector( + localStorageSelector, + localStorage => localStorage["filters/SessionsPage"], +); + export const SessionsPageConnected = withRouter( connect( (state: AppState, props: RouteComponentProps) => ({ sessions: selectSessions(state), + internalAppNamePrefix: selectAppName(state), sessionsError: state.adminUI.sessions.lastError, sortSetting: selectSortSetting(state), + columns: selectColumns(state), + filters: selectFilters(state), }), (dispatch: Dispatch) => ({ refreshSessions: () => dispatch(sessionsActions.refresh()), @@ -95,6 +138,30 @@ export const SessionsPageConnected = withRouter( page: "Sessions", action: "Terminate Statement", }), + onFilterChange: (value: Filters) => { + dispatch( + analyticsActions.track({ + name: "Filter Clicked", + page: "Sessions", + filterName: "app", + value: value.toString(), + }), + ); + dispatch( + localStorageActions.update({ + key: "filters/SessionsPage", + value: value, + }), + ); + }, + onColumnsChange: (selectedColumns: string[]) => + dispatch( + localStorageActions.update({ + key: "showColumns/SessionsPage", + value: + selectedColumns.length === 0 ? " " : selectedColumns.join(","), + }), + ), }), )(SessionsPage), ); diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.module.scss b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.module.scss index 5cedecf0c1ea..acfd426160c3 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.module.scss @@ -23,6 +23,17 @@ color: $colors--link; } +.cl-table__col-query-text { + font-family: $font-family--monospace; + font-size: 12px; + display: inline-block; + max-width: 400px; + div { + font-size: $font-size--small; + @include line-clamp(2); + } +} + .cl-table__col-session { color: $colors--neutral-8; font-family: $font-family--base; @@ -43,6 +54,20 @@ } } +.session-status-icon { + &__active { + height: 10px; + width: 20px; + fill: $colors--primary-green-3; + } + + &__idle { + height: 10px; + width: 20px; + fill: $colors--functional-orange-3; + } +} + .code { font-family: $font-family--monospace; font-size: $font-size--small; diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.tsx b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.tsx index 00b20f595af9..1f814e462b42 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTable.tsx @@ -11,13 +11,12 @@ import classNames from "classnames/bind"; import styles from "./sessionsTable.module.scss"; -import { SessionTableTitle } from "./sessionsTableContent"; import { TimestampToMoment } from "src/util/convert"; -import { BytesWithPrecision, DATE_FORMAT } from "src/util/format"; +import { BytesWithPrecision } from "src/util/format"; import { Link } from "react-router-dom"; import React from "react"; -import { Moment } from "moment"; +import moment from "moment"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; type ISession = cockroach.server.serverpb.Session; @@ -26,8 +25,8 @@ import { TerminateSessionModalRef } from "./terminateSessionModal"; import { TerminateQueryModalRef } from "./terminateQueryModal"; import { ColumnDescriptor, SortedTable } from "src/sortedtable/sortedtable"; -import { Icon } from "antd"; -import { Ellipsis } from "@cockroachlabs/icons"; +import { Icon } from "@cockroachlabs/ui-components"; +import { CircleFilled } from "src/icon/circleFilled"; import { Dropdown, @@ -36,6 +35,11 @@ import { import { Button } from "src/button/button"; import { Tooltip } from "@cockroachlabs/ui-components"; import { computeOrUseStmtSummary } from "../util"; +import { StatementLinkTarget } from "../statementsTable"; +import { + statisticsTableTitles, + StatisticType, +} from "../statsTableUtil/statsTableUtil"; const cx = classNames.bind(styles); @@ -61,59 +65,82 @@ const SessionLink = (props: { session: ISession; onClick?: () => void }) => { const { session, onClick } = props; const base = `/session`; - const start = TimestampToMoment(session.start); const sessionID = byteArrayToUuid(session.id); return (
- Session started at {start.format(DATE_FORMAT)}} - > - -
{start.fromNow(true)}
- -
+ +
{formatSessionStart(session)}
+
); }; -const AgeLabel = (props: { start: Moment; thingName: string }) => { +const StatementTableCell = (props: { session: ISession }) => { + const { session } = props; + + if (!(session.active_queries?.length > 0)) { + if (session.last_active_query == "") { + return
{"N/A"}
; + } + return
{session.last_active_query}
; + } + const stmt = session.active_queries[0]; + const sql = stmt.sql; + const stmtSummary = session.active_queries[0].sql_summary; + const stmtCellText = computeOrUseStmtSummary(sql, stmtSummary); return ( - - {props.thingName} started at {props.start.format(DATE_FORMAT)} - - } + - {props.start.fromNow(true)} - + {sql}}> +
{stmtCellText}
+
+ ); }; -const StatementTableCell = (props: { session: ISession }) => { - const { session } = props; +function formatSessionStart(session: ISession): string { + const formatStr = "MMM DD, YYYY [at] h:mm A"; + const start = moment.unix(Number(session.start.seconds)).utc(); - if (!(session.active_queries?.length > 0)) { + return start.format(formatStr); +} + +function formatStatementStart(session: ISession): string { + if (session.active_queries.length == 0) { return "N/A"; } - const stmt = session.active_queries[0].sql; - const stmtSummary = session.active_queries[0].sql_summary; - const stmtCellText = computeOrUseStmtSummary(stmt, stmtSummary); + const formatStr = "MMM DD, YYYY [at] h:mm A"; + const start = moment + .unix(Number(session.active_queries[0].start.seconds)) + .utc(); + + return start.format(formatStr); +} + +const SessionStatus = (props: { session: ISession }) => { + const { session } = props; + const status = session.active_queries.length > 0 ? "Active" : "Idle"; + const classname = + session.active_queries.length > 0 + ? "session-status-icon__active" + : "session-status-icon__idle"; return (
- {stmt}}> - {stmtCellText} - + + {status}
); }; export function makeSessionsColumns( + statType: StatisticType, terminateSessionRef?: React.RefObject, terminateQueryRef?: React.RefObject, onSessionClick?: () => void, @@ -122,51 +149,48 @@ export function makeSessionsColumns( ): ColumnDescriptor[] { const columns: ColumnDescriptor[] = [ { - name: "sessionAge", - title: SessionTableTitle.sessionAge, + name: "sessionStart", + title: statisticsTableTitles.sessionStart(statType), className: cx("cl-table__col-session"), cell: session => SessionLink({ session: session.session, onClick: onSessionClick }), + sort: session => session.session.start.seconds, + alwaysShow: true, + }, + { + name: "sessionDuration", + title: statisticsTableTitles.sessionDuration(statType), + className: cx("cl-table__col-session"), + cell: session => TimestampToMoment(session.session.start).fromNow(true), sort: session => TimestampToMoment(session.session.start).valueOf(), }, { - name: "txnAge", - title: SessionTableTitle.txnAge, + name: "status", + title: statisticsTableTitles.status(statType), className: cx("cl-table__col-session"), - cell: function(session: SessionInfo) { - if (session.session.active_txn) { - return AgeLabel({ - start: TimestampToMoment(session.session.active_txn.start), - thingName: "Transaction", - }); - } - return "N/A"; - }, - sort: session => session.session.active_txn?.start.seconds || 0, + cell: session => SessionStatus(session), + sort: session => session.session.active_queries.length, + }, + { + name: "mostRecentStatement", + title: statisticsTableTitles.mostRecentStatement(statType), + className: cx("cl-table__col-query-text"), + cell: session => StatementTableCell(session), + sort: session => session.session.last_active_query, }, { - name: "statementAge", - title: SessionTableTitle.statementAge, + name: "statementStartTime", + title: statisticsTableTitles.statementStartTime(statType), className: cx("cl-table__col-session"), - cell: function(session: SessionInfo) { - if (session.session.active_queries?.length > 0) { - return AgeLabel({ - start: TimestampToMoment(session.session.active_queries[0].start), - thingName: "Statement", - }); - } - return "N/A"; - }, - sort: function(session: SessionInfo): number { - if (session.session.active_queries?.length > 0) { - return session.session.active_queries[0].start.seconds.toNumber(); - } - return 0; - }, + cell: session => formatStatementStart(session.session), + sort: session => + session.session.active_queries.length > 0 + ? session.session.active_queries[0].start.seconds + : 0, }, { name: "memUsage", - title: SessionTableTitle.memUsage, + title: statisticsTableTitles.memUsage(statType), className: cx("cl-table__col-session"), cell: session => BytesWithPrecision(session.session.alloc_bytes?.toNumber(), 0) + @@ -175,14 +199,29 @@ export function makeSessionsColumns( sort: session => session.session.alloc_bytes?.toNumber(), }, { - name: "statement", - title: SessionTableTitle.statement, - className: cx("cl-table__col-session", "code"), - cell: session => StatementTableCell({ session: session.session }), + name: "clientAddress", + title: statisticsTableTitles.clientAddress(statType), + className: cx("cl-table__col-session"), + cell: session => session.session.client_address, + sort: session => session.session.client_address, + }, + { + name: "username", + title: statisticsTableTitles.username(statType), + className: cx("cl-table__col-session"), + cell: session => session.session.username, + sort: session => session.session.username, + }, + { + name: "applicationName", + title: statisticsTableTitles.applicationName(statType), + className: cx("cl-table__col-session"), + cell: session => session.session.application_name, + sort: session => session.session.application_name, }, { name: "actions", - title: SessionTableTitle.actions, + title: statisticsTableTitles.actions(statType), className: cx("cl-table__col-session-actions"), titleAlign: "right", cell: ({ session }) => { @@ -226,7 +265,11 @@ export function makeSessionsColumns( const renderDropdownToggleButton: JSX.Element = ( <> ); @@ -241,6 +284,7 @@ export function makeSessionsColumns( /> ); }, + alwaysShow: true, }, ]; diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTableContent.tsx b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTableContent.tsx deleted file mode 100644 index 1f52b02ba34f..000000000000 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsTableContent.tsx +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2020 The Cockroach Authors. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0, included in the file -// licenses/APL.txt. - -import React from "react"; -import { Tooltip } from "@cockroachlabs/ui-components"; - -export const SessionTableTitle = { - id: ( - - Session ID - - ), - statement: ( - - Statement - - ), - actions: ( - - Actions - - ), - sessionAge: ( - - Session Duration - - ), - txnAge: ( - - Transaction Duration - - ), - statementAge: ( - - Statement Duration - - ), - memUsage: ( - - Memory Usage - - ), - maxMemUsed: ( - - Maximum Memory Usage - - ), - numRetries: ( - - Retries - - ), - lastActiveStatement: ( - - Last Statement - - ), - numStmts: ( - - Statements Run - - ), -}; diff --git a/pkg/ui/workspaces/cluster-ui/src/statsTableUtil/statsTableUtil.tsx b/pkg/ui/workspaces/cluster-ui/src/statsTableUtil/statsTableUtil.tsx index 335563d019b3..7553fe1348fa 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statsTableUtil/statsTableUtil.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statsTableUtil/statsTableUtil.tsx @@ -30,6 +30,20 @@ export type NodeNames = { [nodeId: string]: string }; // Single place for column names. Used in table columns and in columns selector. export const statisticsColumnLabels = { + sessionStart: "Session Start Time (UTC)", + sessionDuration: "Session Duration", + mostRecentStatement: "Most Recent Statement", + status: "Status", + statementStartTime: "Statement Start Time (UTC)", + txnDuration: "Transaction Duration", + actions: "Actions", + memUsage: "Memory Usage", + maxMemUsed: "Maximum Memory Usage", + numRetries: "Retries", + numStatements: "Statements Run", + clientAddress: "Client IP Address", + username: "User Name", + applicationName: "Application Name", bytesRead: "Bytes Read", contention: "Contention", database: "Database", @@ -58,7 +72,11 @@ export const contentModifiers = { statements: "statements", }; -export type StatisticType = "statement" | "transaction" | "transactionDetails"; +export type StatisticType = + | "statement" + | "session" + | "transaction" + | "transactionDetails"; export type StatisticTableColumnKeys = keyof typeof statisticsColumnLabels; type StatisticTableTitleType = { @@ -95,6 +113,170 @@ export function getLabel( // of data the statistics are based on (e.g. statements, transactions, or transactionDetails). The // StatisticType is used to modify the content of the tooltip. export const statisticsTableTitles: StatisticTableTitleType = { + sessionStart: () => { + return ( + + {getLabel("sessionStart")} + + ); + }, + sessionDuration: () => { + return ( + + {getLabel("sessionDuration")} + + ); + }, + status: () => { + return ( + + {getLabel("status")} + + ); + }, + mostRecentStatement: () => { + return ( + + {getLabel("mostRecentStatement")} + + ); + }, + statementStartTime: () => { + return ( + + {getLabel("statementStartTime")} + + ); + }, + memUsage: () => { + return ( + + {getLabel("memUsage")} + + ); + }, + clientAddress: () => { + return ( + + {getLabel("clientAddress")} + + ); + }, + username: () => { + return ( + + {getLabel("username")} + + ); + }, + applicationName: () => { + return ( + + {getLabel("applicationName")} + + ); + }, + actions: () => { + return ( + + {getLabel("actions")} + + ); + }, + maxMemUsed: () => { + return ( + + {getLabel("maxMemUsage")} + + ); + }, + numRetries: () => { + return ( + + {getLabel("retries")} + + ); + }, + numStatements: () => { + return ( + + {getLabel("numStatements")} + + ); + }, + txnDuration: () => { + return ( + + {getLabel("txnDuration")} + + ); + }, statements: () => { return ( ; +export const selectData = createSelector( + (state: AdminUIState) => state.cachedData.statements, + (state: CachedDataReducerState) => { + if (!state.data || state.inFlight || !state.valid) return null; + return state.data; + }, +); + export const selectSessions = createSelector( (state: SessionsState) => state.cachedData.sessions, (_state: SessionsState, props: RouteComponentProps) => props, @@ -42,15 +57,44 @@ export const selectSessions = createSelector( }, ); +export const selectAppName = createSelector( + (state: SessionsState) => state.cachedData.sessions, + (_state: SessionsState, props: RouteComponentProps) => props, + ( + state: CachedDataReducerState, + _: RouteComponentProps, + ) => { + if (!state.data) { + return null; + } + return state.data.internal_app_name_prefix; + }, +); + export const sortSettingLocalSetting = new LocalSetting( "sortSetting/SessionsPage", (state: AdminUIState) => state.localSettings, { ascending: false, columnTitle: "statementAge" }, ); +export const sessionColumnsLocalSetting = new LocalSetting( + "showColumns/SessionsPage", + (state: AdminUIState) => state.localSettings, + null, +); + +export const filtersLocalSetting = new LocalSetting( + "filters/SessionsPage", + (state: AdminUIState) => state.localSettings, + defaultFilters, +); + const SessionsPageConnected = withRouter( connect( (state: AdminUIState, props: RouteComponentProps) => ({ + columns: sessionColumnsLocalSetting.selectorToArray(state), + internalAppNamePrefix: selectAppName(state, props), + filters: filtersLocalSetting.selector(state), sessions: selectSessions(state, props), sessionsError: state.cachedData.sessions.lastError, sortSetting: sortSettingLocalSetting.selector(state), @@ -68,6 +112,11 @@ const SessionsPageConnected = withRouter( ascending: ascending, columnTitle: columnName, }), + onColumnsChange: (value: string[]) => + sessionColumnsLocalSetting.set( + value.length === 0 ? " " : value.join(","), + ), + onFilterChange: (filters: Filters) => filtersLocalSetting.set(filters), }, )(SessionsPage), );