// Copyright 2022 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, { useContext } from "react"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { ArrowLeft } from "@cockroachlabs/icons"; import { Col, Row, Tabs } from "antd"; import "antd/lib/col/style"; import "antd/lib/row/style"; import "antd/lib/tabs/style"; import Long from "long"; import Helmet from "react-helmet"; import { RouteComponentProps } from "react-router-dom"; import { JobRequest, JobResponse } from "src/api/jobsApi"; import { Button } from "src/button"; import { Loading } from "src/loading"; import { SqlBox, SqlBoxSize } from "src/sql"; import { SummaryCard, SummaryCardItem } from "src/summaryCard"; import { TimestampToMoment, idAttr, getMatchParamByName, DATE_WITH_SECONDS_AND_MILLISECONDS_FORMAT_24_TZ, } from "src/util"; import { HighwaterTimestamp } from "src/jobs/util/highwaterTimestamp"; import { JobStatusCell } from "src/jobs/util/jobStatusCell"; import { isTerminalState } from "../util/jobOptions"; import { commonStyles } from "src/common"; import summaryCardStyles from "src/summaryCard/summaryCard.module.scss"; import jobStyles from "src/jobs/jobs.module.scss"; import classNames from "classnames/bind"; import { Timestamp } from "../../timestamp"; import { RequestState } from "../../api"; import moment from "moment-timezone"; import { CockroachCloudContext } from "src/contexts"; import { InlineAlert } from "@cockroachlabs/ui-components"; const { TabPane } = Tabs; const cardCx = classNames.bind(summaryCardStyles); const jobCx = classNames.bind(jobStyles); enum TabKeysEnum { OVERVIEW = "Overview", PROFILER = "Profiler", } export interface JobDetailsStateProps { jobRequest: RequestState<JobResponse>; } export interface JobDetailsDispatchProps { refreshJob: (req: JobRequest) => void; } export interface JobDetailsState { currentTab?: string; } export type JobDetailsProps = JobDetailsStateProps & JobDetailsDispatchProps & RouteComponentProps<unknown>; export class JobDetails extends React.Component< JobDetailsProps, JobDetailsState > { refreshDataInterval: NodeJS.Timeout; constructor(props: JobDetailsProps) { super(props); const searchParams = new URLSearchParams(props.history.location.search); this.state = { currentTab: searchParams.get("tab") || "overview", }; } private refresh(): void { if (isTerminalState(this.props.jobRequest.data?.status)) { clearInterval(this.refreshDataInterval); return; } this.props.refreshJob( new cockroach.server.serverpb.JobRequest({ job_id: Long.fromString(getMatchParamByName(this.props.match, idAttr)), }), ); } componentDidMount(): void { if (!this.props.jobRequest.data) { this.refresh(); } // Refresh every 10s. this.refreshDataInterval = setInterval(() => this.refresh(), 10 * 1000); } componentWillUnmount(): void { if (this.refreshDataInterval) { clearInterval(this.refreshDataInterval); } } prevPage = (): void => this.props.history.goBack(); renderProfilerTabContent = ( job: cockroach.server.serverpb.JobResponse, ): React.ReactElement => { const id = job?.id; // This URL results in a cluster-wide CPU profile to be collected for 5 // seconds. We set `tagfocus` (tf) to only view the samples corresponding to // this job's execution. const url = `debug/pprof/ui/cpu?node=all&seconds=5&labels=true&tf=job.*${id}`; return ( <Row gutter={24}> <Col className="gutter-row" span={24}> <SummaryCard className={cardCx("summary-card")}> <SummaryCardItem label="Cluster-wide CPU Profile" value={<a href={url}>Profile</a>} /> <InlineAlert intent="warning" title="This profiles all nodes in the cluster. This operation buffers profiles in memory and should only be run if there is sufficient overhead." /> </SummaryCard> </Col> </Row> ); }; renderOverviewTabContent = ( hasNextRun: boolean, nextRun: moment.Moment, job: JobResponse, ): React.ReactElement => { if (!job) { return null; } return ( <Row gutter={24}> <Col className="gutter-row" span={24}> <SummaryCard className={cardCx("summary-card")}> <SummaryCardItem label="Status" value={ <JobStatusCell job={job} lineWidth={1.5} hideDuration={true} /> } /> {hasNextRun && ( <> <SummaryCardItem label="Next Planned Execution Time" value={ <Timestamp time={nextRun} format={DATE_WITH_SECONDS_AND_MILLISECONDS_FORMAT_24_TZ} /> } /> </> )} <SummaryCardItem label="Creation Time" value={ <Timestamp time={TimestampToMoment(job.created)} format={DATE_WITH_SECONDS_AND_MILLISECONDS_FORMAT_24_TZ} /> } /> {job.modified && ( <SummaryCardItem label="Last Modified Time" value={ <Timestamp time={TimestampToMoment(job.modified)} format={DATE_WITH_SECONDS_AND_MILLISECONDS_FORMAT_24_TZ} /> } /> )} {job.finished && ( <SummaryCardItem label="Completed Time" value={ <Timestamp time={TimestampToMoment(job.finished)} format={DATE_WITH_SECONDS_AND_MILLISECONDS_FORMAT_24_TZ} /> } /> )} <SummaryCardItem label="Last Execution Time" value={ <Timestamp time={TimestampToMoment(job.last_run)} format={DATE_WITH_SECONDS_AND_MILLISECONDS_FORMAT_24_TZ} /> } /> <SummaryCardItem label="Execution Count" value={String(job.num_runs)} /> <SummaryCardItem label="User Name" value={job.username} /> {job.highwater_timestamp && ( <SummaryCardItem label="High-water Timestamp" value={ <HighwaterTimestamp timestamp={job.highwater_timestamp} decimalString={job.highwater_decimal} /> } /> )} </SummaryCard> </Col> </Row> ); }; onTabChange = (tabId: string): void => { const { history } = this.props; const searchParams = new URLSearchParams(history.location.search); searchParams.set("tab", tabId); history.replace({ ...history.location, search: searchParams.toString(), }); this.setState({ currentTab: tabId, }); }; render(): React.ReactElement { const isLoading = this.props.jobRequest.inFlight; const error = this.props.jobRequest.error; const job = this.props.jobRequest.data; const nextRun = TimestampToMoment(job?.next_run); const hasNextRun = nextRun?.isAfter(); const { currentTab } = this.state; return ( <div className={jobCx("job-details")}> <Helmet title={"Details | Job"} /> <div className={jobCx("section page--header")}> <Button onClick={this.prevPage} type="unstyled-link" size="small" icon={<ArrowLeft fontSize={"10px"} />} iconPosition="left" className={commonStyles("small-margin")} > Jobs </Button> <h3 className={jobCx("page--header__title")}>{`Job ID: ${String( getMatchParamByName(this.props.match, idAttr), )}`}</h3> </div> <section className={jobCx("section section--container")}> <Loading loading={isLoading} page={"job details"} error={error} render={() => ( <> <section className={cardCx("summary-card")}> <Row gutter={24}> <Col className="gutter-row" span={24}> <SqlBox value={job?.description ?? "Job not found."} size={SqlBoxSize.custom} format={true} /> </Col> </Row> </section> <Tabs className={commonStyles("cockroach--tabs")} defaultActiveKey={TabKeysEnum.OVERVIEW} onChange={this.onTabChange} activeKey={currentTab} > <TabPane tab={TabKeysEnum.OVERVIEW} key="overview"> {this.renderOverviewTabContent(hasNextRun, nextRun, job)} </TabPane> {!useContext(CockroachCloudContext) && ( <TabPane tab={TabKeysEnum.PROFILER} key="profiler"> {this.renderProfilerTabContent(job)} </TabPane> )} </Tabs> </> )} /> </section> </div> ); } }