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), );