diff --git a/src/rest-server/index.js b/src/rest-server/index.js index f4b864229d..0645afafc2 100644 --- a/src/rest-server/index.js +++ b/src/rest-server/index.js @@ -15,5 +15,4 @@ // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - module.exports = require('./src/server'); diff --git a/src/webportal/config/webpack.common.js b/src/webportal/config/webpack.common.js index 78622f0ae1..78b8ab3c4a 100644 --- a/src/webportal/config/webpack.common.js +++ b/src/webportal/config/webpack.common.js @@ -70,7 +70,7 @@ const config = (env, argv) => ({ submit_v1: './src/app/job/job-submit-v1/job-submit.component.js', jobList: './src/app/job/job-view/fabric/job-list.jsx', jobDetail: './src/app/job/job-view/fabric/job-detail.jsx', - jobRetry: './src/app/job/job-view/fabric/job-retry.jsx', + taskAttempt: './src/app/job/job-view/fabric/task-attempt.jsx', jobEvent: './src/app/job/job-view/fabric/job-event.jsx', virtualClusters: './src/app/vc/vc.component.js', services: './src/app/cluster-view/services/services.component.js', @@ -336,8 +336,8 @@ const config = (env, argv) => ({ chunks: ['layout', 'jobDetail'], }), generateHtml({ - filename: 'job-retry.html', - chunks: ['layout', 'jobRetry'], + filename: 'task-attempt.html', + chunks: ['layout', 'taskAttempt'], }), generateHtml({ filename: 'job-event.html', diff --git a/src/webportal/src/app/components/horizontal-line.jsx b/src/webportal/src/app/components/horizontal-line.jsx new file mode 100644 index 0000000000..43d9edee49 --- /dev/null +++ b/src/webportal/src/app/components/horizontal-line.jsx @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import styled from 'styled-components'; + +const HorizontalLine = styled.hr` + display: block; + height: 1px; + border: 0; + border-top: 1px solid #f3f2f1; + margin: 1px 0; + padding: 0; +`; + +export default HorizontalLine; diff --git a/src/webportal/src/app/env.js.template b/src/webportal/src/app/env.js.template index 987374f190..b9260f3617 100644 --- a/src/webportal/src/app/env.js.template +++ b/src/webportal/src/app/env.js.template @@ -13,7 +13,6 @@ window.ENV = { alertManagerUri: '${ALERT_MANAGER_URI}/alert-manager', launcherType: '${LAUNCHER_TYPE}', launcherScheduler: '${LAUNCHER_SCHEDULER}', - jobHistory: '${JOB_HISTORY}', }; window.PAI_PLUGINS = [${WEBPORTAL_PLUGINS}][0] || []; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail.jsx index eb8df72225..67139b79b3 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail.jsx @@ -15,23 +15,26 @@ // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -import classNames from 'classnames'; -import { get, isEmpty, isNil } from 'lodash'; +import { capitalize, isEmpty, isNil } from 'lodash'; +import { DateTime, Interval } from 'luxon'; import { - FontClassNames, MessageBar, MessageBarType, + Stack, + Dropdown, + Text, + Toggle, } from 'office-ui-fabric-react'; import React from 'react'; import ReactDOM from 'react-dom'; import t from '../../../components/tachyons.scss'; +import { getDurationString } from '../../../components/util/job'; import Context from './job-detail/components/context'; import Top from './job-detail/components/top'; import Summary from './job-detail/components/summary'; import { SpinnerLoading } from '../../../components/loading'; -import TaskRole from './job-detail/components/task-role'; import { fetchJobConfig, fetchJobInfo, @@ -40,7 +43,11 @@ import { NotFoundError, fetchRawJobConfig, } from './job-detail/conn'; -import { getHumanizedJobStateString } from '../../../components/util/job'; +import Card from './job-detail/components/card'; +import HorizontalLine from '../../../components/horizontal-line'; +import StatusBadge from '../../../components/status-badge'; +import TaskRoleContainerList from './job-detail/components/task-role-container-list'; +import TaskRoleCount from './job-detail/components/task-role-count'; class JobDetail extends React.Component { constructor(props) { @@ -55,9 +62,16 @@ class JobDetail extends React.Component { rawJobConfig: null, jobConfig: null, sshInfo: null, + showMoreDiagnostics: false, + selectedAttemptIndex: null, + loadingAttempt: false, }; this.stop = this.stop.bind(this); this.reload = this.reload.bind(this); + this.onChangeJobAttempt = this.onChangeJobAttempt.bind(this); + this.onChangeShowMoreDiagnostics = this.onChangeShowMoreDiagnostics.bind( + this, + ); } componentDidMount() { @@ -76,7 +90,7 @@ class JobDetail extends React.Component { }; const loadJobInfo = async () => { try { - nextState.jobInfo = await fetchJobInfo(); + nextState.jobInfo = await fetchJobInfo(this.state.selectedAttemptIndex); } catch (err) { nextState.error = `fetch job status failed: ${err.message}`; } @@ -132,6 +146,9 @@ class JobDetail extends React.Component { if (alertFlag === true && !isNil(nextState.error)) { alert(nextState.error); } + if (isNil(this.state.selectedAttemptIndex)) { + nextState.selectedAttemptIndex = nextState.jobInfo.jobStatus.retries; + } this.setState(nextState); } @@ -140,45 +157,32 @@ class JobDetail extends React.Component { await this.reload(); } - renderTaskRoles() { - const { jobConfig, jobInfo } = this.state; - if (!isEmpty(jobInfo.taskRoles)) { - const failedTaskRole = - getHumanizedJobStateString(jobInfo.jobStatus) === 'Failed' && - get(jobInfo, 'jobStatus.appExitTriggerTaskRoleName'); - return Object.keys(jobInfo.taskRoles).map(name => ( - - )); - } else if (jobConfig && jobConfig.taskRoles) { - return Object.entries(jobConfig.taskRoles).map(([name, taskConfig]) => { - // dummy tasks - let dummyTaskInfo = null; - if (taskConfig) { - const instances = isNil(taskConfig.instances) - ? 1 - : taskConfig.instances; - dummyTaskInfo = { - taskStatuses: Array.from({ length: instances }, (v, idx) => ({ - taskState: 'WAITING', - })), - }; - } - - return ( - - ); + onChangeJobAttempt(event, item) { + this.setState({ loadingAttempt: true, selectedAttemptIndex: item.key }); + fetchJobInfo(item.key).then(data => { + this.setState({ + jobInfo: data, + loadingAttempt: false, }); + }); + } + + onChangeShowMoreDiagnostics(event, checked) { + this.setState({ + showMoreDiagnostics: checked, + }); + } + + getTimeDuration(startMs, endMs) { + const start = startMs && DateTime.fromMillis(startMs); + const end = endMs && DateTime.fromMillis(endMs); + if (start) { + return Interval.fromDateTimes(start, end || DateTime.utc()).toDuration([ + 'days', + 'hours', + 'minutes', + 'seconds', + ]); } else { return null; } @@ -193,13 +197,26 @@ class JobDetail extends React.Component { jobConfig, rawJobConfig, sshInfo, + selectedAttemptIndex, + loadingAttempt, } = this.state; + + const attemptIndexOptions = []; + if (!isNil(jobInfo)) { + for (let index = jobInfo.jobStatus.retries; index >= 0; index -= 1) { + if (index === jobInfo.jobStatus.retries) { + attemptIndexOptions.push({ key: index, text: `${index} (latest)` }); + } else { + attemptIndexOptions.push({ key: index, text: index }); + } + } + } if (loading) { return ; } else { return ( -
+ {!isEmpty(error) && (
@@ -215,8 +232,112 @@ class JobDetail extends React.Component { onStopJob={this.stop} onReload={this.reload} /> - {this.renderTaskRoles()} -
+ + + + Job Attempt Index + + + + {loadingAttempt ? ( + + ) : ( + + + + + Attempt State + + + + Attempt Creation Time + + {isNil(jobInfo.jobStatus.appCreatedTime) + ? 'N/A' + : DateTime.fromMillis( + jobInfo.jobStatus.appCreatedTime, + ).toLocaleString( + DateTime.DATETIME_MED_WITH_SECONDS, + )} + + + + Attempt Duration + + {getDurationString( + this.getTimeDuration( + jobInfo.jobStatus.appCreatedTime, + jobInfo.jobStatus.appCompletedTime, + ), + )} + + + + Attempt Running Start Time + + {isNil(jobInfo.jobStatus.appLaunchedTime) + ? 'N/A' + : DateTime.fromMillis( + jobInfo.jobStatus.appLaunchedTime, + ).toLocaleString( + DateTime.DATETIME_MED_WITH_SECONDS, + )} + + + + Attempt Running Duration + + {getDurationString( + this.getTimeDuration( + jobInfo.jobStatus.appLaunchedTime, + jobInfo.jobStatus.appCompletedTime, + ), + )} + + + + + + {!isEmpty(jobInfo.taskRoles) && + Object.keys(jobInfo.taskRoles).map(name => ( + + + + {`Task Role: ${name}`} + + + + + + ))} + + )} + + +
); } diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx index bb20380af4..ad2ab56438 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx @@ -45,13 +45,8 @@ import t from '../../../../../components/tachyons.scss'; import Card from './card'; import Context from './context'; import Timer from './timer'; -import { getTensorBoardUrl, getJobMetricsUrl, checkAttemptAPI } from '../conn'; -import { - printDateTime, - isJobV2, - HISTORY_API_ERROR_MESSAGE, - HISTORY_DISABLE_MESSAGE, -} from '../util'; +import { getTensorBoardUrl, getJobMetricsUrl } from '../conn'; +import { printDateTime, isJobV2 } from '../util'; import MonacoPanel from '../../../../../components/monaco-panel'; import StatusBadge from '../../../../../components/status-badge'; import { @@ -88,14 +83,20 @@ HintItem.propTypes = { export default class Summary extends React.Component { constructor(props) { super(props); + const { jobInfo } = props; + let defaultInterval = 10 * 1000; + if ( + jobInfo.jobStatus.state === 'FAILED' || + jobInfo.jobStatus.state === 'SUCCEEDED' + ) { + defaultInterval = 0; + } this.state = { monacoProps: null, modalTitle: '', - autoReloadInterval: 10 * 1000, + autoReloadInterval: defaultInterval, hideDialog: true, - isRetryHealthy: false, }; - this.onChangeInterval = this.onChangeInterval.bind(this); this.onDismiss = this.onDismiss.bind(this); this.showExitDiagnostics = this.showExitDiagnostics.bind(this); @@ -103,16 +104,18 @@ export default class Summary extends React.Component { this.showJobConfig = this.showJobConfig.bind(this); this.showStopJobConfirm = this.showStopJobConfirm.bind(this); this.setHideDialog = this.setHideDialog.bind(this); - this.checkRetryHealthy = this.checkRetryHealthy.bind(this); - this.checkRetryLink = this.checkRetryLink.bind(this); - this.hasRetries = this.hasRetries.bind(this); } - async componentDidMount() { - if (await this.checkRetryHealthy()) { - this.setState({ isRetryHealthy: true }); - } else { - this.setState({ isRetryHealthy: false }); + componentDidUpdate(prevProps) { + if ( + this.props.jobInfo.jobStatus.state !== prevProps.jobInfo.jobStatus.state + ) { + if ( + this.props.jobInfo.jobStatus.attemptState === 'FAILED' || + this.props.jobInfo.jobStatus.attemptState === 'SUCCEEDED' + ) { + this.setState({ autoReloadInterval: 0 }); + } } } @@ -269,17 +272,6 @@ export default class Summary extends React.Component { return result; } - async checkRetryHealthy() { - if (config.launcherType !== 'k8s') { - return false; - } - - if (!(await checkAttemptAPI())) { - return false; - } - return true; - } - renderHintMessage() { const { jobInfo } = this.props; if (!jobInfo) { @@ -346,39 +338,12 @@ export default class Summary extends React.Component { } } - checkRetryLink() { - const { jobInfo } = this.props; - const { isRetryHealthy } = this.state; - - if ( - config.jobHistory !== 'true' || - !isRetryHealthy || - isNil(jobInfo.jobStatus.retries) || - jobInfo.jobStatus.retries === 0 - ) { - return false; - } else { - return true; - } - } - - hasRetries() { - const { jobInfo } = this.props; - - if (isNil(jobInfo.jobStatus.retries) || jobInfo.jobStatus.retries === 0) { - return false; - } else { - return true; - } - } - render() { const { autoReloadInterval, modalTitle, monacoProps, hideDialog, - isRetryHealthy, } = this.state; const { className, jobInfo, reloading, onStopJob, onReload } = this.props; const { rawJobConfig } = this.context; @@ -474,7 +439,7 @@ export default class Summary extends React.Component { {/* summary-row-2 */}
-
Status
+
Job State
Retries
- {this.checkRetryLink() ? ( - -
- {jobInfo.jobStatus.retries} -
- - ) : ( -
- {jobInfo.jobStatus.retries} -
- )} +
+ {jobInfo.jobStatus.retries} +
{/* summary-row-2.5 error info */} @@ -594,77 +549,6 @@ export default class Summary extends React.Component {
)} - {this.hasRetries() && ( -
-
- - Go to Retry History Page - - {config.jobHistory !== 'true' && ( -
- ( -
- {HISTORY_DISABLE_MESSAGE} -
- ), - }} - directionalHint={DirectionalHint.topLeftEdge} - > -
- -
-
-
- )} - {config.jobHistory === 'true' && !isRetryHealthy && ( -
- ( -
- {HISTORY_API_ERROR_MESSAGE} -
- ), - }} - directionalHint={DirectionalHint.topLeftEdge} - > -
- -
-
-
- )} -
- )}
diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx index 54d030df5d..9a97167d28 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx @@ -20,19 +20,18 @@ import { createTheme, ColorClassNames, FontClassNames, - FontSizes, getTheme, } from '@uifabric/styling'; import c from 'classnames'; import { capitalize, isEmpty, isNil, flatten } from 'lodash'; -import { DateTime } from 'luxon'; +import { DateTime, Interval } from 'luxon'; import { CommandBarButton, PrimaryButton, TooltipHost, DirectionalHint, - Icon, Stack, + Link, } from 'office-ui-fabric-react'; import { DetailsList, @@ -50,12 +49,16 @@ import t from '../../../../../components/tachyons.scss'; import Context from './context'; import Timer from './timer'; import { getContainerLog } from '../conn'; -import { parseGpuAttr, printDateTime } from '../util'; import config from '../../../../../config/webportal.config'; import MonacoPanel from '../../../../../components/monaco-panel'; import StatusBadge from '../../../../../components/status-badge'; +import { getDurationString } from '../../../../../components/util/job'; import CopyButton from '../../../../../components/copy-button'; +const params = new URLSearchParams(window.location.search); +const userName = params.get('username'); +const jobName = params.get('jobName'); + const theme = createTheme({ palette: { themePrimary: '#0078d4', @@ -135,6 +138,9 @@ export default class TaskRoleContainerList extends React.Component { monacoTitle: '', monacoFooterButton: null, logUrl: null, + items: props.tasks, + ordering: { field: null, descending: false }, + hideDialog: true, }; this.showSshInfo = this.showSshInfo.bind(this); @@ -142,6 +148,14 @@ export default class TaskRoleContainerList extends React.Component { this.showContainerLog = this.showContainerLog.bind(this); this.onRenderRow = this.onRenderRow.bind(this); this.logAutoRefresh = this.logAutoRefresh.bind(this); + this.onColumnClick = this.onColumnClick.bind(this); + this.applySortProps = this.applySortProps.bind(this); + } + + componentDidUpdate(prevProps) { + if (prevProps.tasks !== this.props.tasks) { + this.setState({ items: this.props.tasks }); + } } logAutoRefresh() { @@ -184,6 +198,21 @@ export default class TaskRoleContainerList extends React.Component { }); } + getTimeDuration(startMs, endMs) { + const start = startMs && DateTime.fromMillis(startMs); + const end = endMs && DateTime.fromMillis(endMs); + if (start) { + return Interval.fromDateTimes(start, end || DateTime.utc()).toDuration([ + 'days', + 'hours', + 'minutes', + 'seconds', + ]); + } else { + return null; + } + } + showContainerLog(logUrl, logType) { let title; let logHint; @@ -323,203 +352,167 @@ export default class TaskRoleContainerList extends React.Component { } } - getColumns(showDebugInfo) { - const optionalColumns = [ - { - key: 'accountableRetries', - name: 'Accountable Retries', - headerClassName: FontClassNames.medium, - minWidth: 150, - maxWidth: 200, - isResizable: true, - onRender: (item, idx) => { - return ( -
- {item.accountableRetries} -
- ); - }, - }, - { - key: 'startTime', - name: 'Start Time', - headerClassName: FontClassNames.medium, - minWidth: 150, - maxWidth: 200, - isResizable: true, - onRender: item => { - return ( -
- {isNil(item.createdTime) - ? 'N/A' - : printDateTime(DateTime.fromMillis(item.createdTime))} -
- ); - }, - }, - { - key: 'currentAttemptLaunchedTime', - name: 'Current Attempt Launched Time', - headerClassName: FontClassNames.medium, - minWidth: 200, - maxWidth: 250, - isResizable: true, - onRender: item => { - return ( -
- {isNil(item.currentAttemptLaunchedTime) - ? 'N/A' - : printDateTime( - DateTime.fromMillis(item.currentAttemptLaunchedTime), - )} -
- ); - }, - }, - { - key: 'currentAttemptCompletedTime', - name: 'Current Attempt Completion Time', - headerClassName: FontClassNames.medium, - minWidth: 250, - maxWidth: 250, - isResizable: true, - onRender: item => { - return ( -
- {isNil(item.currentAttemptCompletedTime) - ? 'N/A' - : printDateTime( - DateTime.fromMillis(item.currentAttemptCompletedTime), - )} -
- ); - }, - }, - { - key: 'completionTime', - name: 'Completion Time', - headerClassName: FontClassNames.medium, - minWidth: 150, - maxWidth: 200, - isResizable: true, - onRender: item => { - return ( -
- {isNil(item.completedTime) - ? 'N/A' - : printDateTime(DateTime.fromMillis(item.completedTime))} -
- ); - }, - }, - { - key: 'nodeName', - name: 'Node Name', - headerClassName: FontClassNames.medium, - minWidth: 100, - isResizable: true, - onRender: item => { - return ( -
- {item.containerNodeName} -
- ); - }, - }, - { - key: 'exitDiagonostic', - name: 'Exit Diagnostics', - headerClassName: FontClassNames.medium, - minWidth: 200, - isResizable: true, - onRender: item => { - return ( - { - const result = []; - // exit spec - const spec = item.containerExitSpec; - if (!isNil(spec)) { - // divider - result.push(Array.from({ length: 80 }, () => '-').join('')); - result.push(''); - // content - result.push('[Exit Spec]'); - result.push(''); - result.push(yaml.safeDump(spec)); - result.push(''); - } + getTaskPropertyFromColumnKey(item, key) { + if (key === 'exitType') { + return !isNil(item.containerExitSpec) && + !isNil(item.containerExitSpec.type) + ? item.containerExitSpec.type + : null; + } + return item[key]; + } - // diagnostics - const diag = item.containerExitDiagnostics; - if (!isNil(diag)) { - // divider - result.push(Array.from({ length: 80 }, () => '-').join('')); - result.push(''); - // content - result.push('[Exit Diagnostics]'); - result.push(''); - result.push(diag); - result.push(''); - } + orderItems(items, ordering) { + const key = ordering.field; + return items + .slice(0) + .sort((a, b) => + (ordering.descending + ? this.getTaskPropertyFromColumnKey(a, key) < + this.getTaskPropertyFromColumnKey(b, key) + : this.getTaskPropertyFromColumnKey(a, key) > + this.getTaskPropertyFromColumnKey(b, key)) + ? 1 + : -1, + ); + } - this.setState({ - monacoProps: { - language: 'text', - value: result.join('\n'), - options: { - wordWrap: 'off', - readOnly: true, - }, - }, - monacoTitle: `Task Exit Diagonostics`, - }); - }} - /> - ); - }, + onColumnClick(event, column) { + const { field, descending } = this.state.ordering; + const items = this.state.items; + let newOrdering = null; + let newItems = []; + if (field === column.key) { + if (descending) { + newOrdering = { field: null, descending: false }; + newItems = this.props.tasks; + this.setState({ ordering: newOrdering, items: newItems }); + } else { + newOrdering = { field: field, descending: true }; + newItems = this.orderItems(items, newOrdering); + this.setState({ ordering: newOrdering, items: newItems }); + } + } else { + newOrdering = { field: column.key, descending: false }; + newItems = this.orderItems(items, newOrdering); + this.setState({ ordering: newOrdering, items: newItems }); + } + } + + applySortProps(column) { + column.isSorted = this.state.ordering.field === column.key; + column.isSortedDescending = this.state.ordering.descending; + column.onColumnClick = this.onColumnClick; + return column; + } + + onRenderRow(props) { + return ( + + ); + } + + render() { + const { + monacoTitle, + monacoProps, + monacoFooterButton, + logUrl, + items, + } = this.state; + const { showMoreDiagnostics } = this.props; + return ( +
+ + + + {/* Timer */} + + {/* Monaco Editor Panel */} + +
+ ); + } + + getColumns(showMoreDiagnostics) { + const taskStateColumn = this.applySortProps({ + key: 'taskState', + name: 'Task State', + headerClassName: FontClassNames.medium, + minWidth: 100, + maxWidth: 150, + isResizable: true, + onRender: item => , + }); + const exitTypeColumn = this.applySortProps({ + key: 'exitType', + name: 'Exit Type', + headerClassName: FontClassNames.medium, + minWidth: 150, + maxWidth: 200, + isResizable: true, + onRender: item => { + return ( +
+ {!isNil(item.containerExitSpec) && + !isNil(item.containerExitSpec.type) + ? item.containerExitSpec.type + : null} +
+ ); }, + }); + const defaultColumns = [ { - key: 'containerId', - name: 'Container ID', + key: 'taskIndex', + name: 'Task Index', headerClassName: FontClassNames.medium, - minWidth: 300, + maxWidth: 50, isResizable: true, - onRender: item => { - const id = item.containerId; + onRender: (item, idx) => { return ( - !isNil(id) && ( -
- {id} -
- ) +
{item.taskIndex}
); }, }, - ]; - const defaultColumns = [ + taskStateColumn, { - key: 'number', - name: 'No.', + key: 'retries', + name: 'Task Retries', headerClassName: FontClassNames.medium, - minWidth: 30, - maxWidth: 50, + maxWidth: 150, isResizable: true, onRender: (item, idx) => { return ( - !isNil(idx) && ( -
{idx}
- ) + +
{item.retries}
+ ); }, }, @@ -591,118 +584,6 @@ export default class TaskRoleContainerList extends React.Component { ); }, }, - { - key: 'gpus', - name: 'GPUs', - className: FontClassNames.mediumPlus, - headerClassName: FontClassNames.medium, - minWidth: 35, - maxWidth: 60, - isResizable: true, - onRender: item => { - const gpuAttr = isNil(item.containerGpus) - ? null - : parseGpuAttr(item.containerGpus); - if (isNil(gpuAttr)) { - return null; - } else if (gpuAttr.length === 0) { - return
0
; - } else { - return ( -
- ( -
- {gpuAttr.map(x => ( - {`#${x}`} - ))} -
- ), - }} - directionalHint={DirectionalHint.topLeftEdge} - > - -
{gpuAttr.length}
-
- -
-
-
-
- ); - } - }, - }, - { - key: 'status', - name: 'Status', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 150, - isResizable: true, - onRender: item => , - }, - { - key: 'retries', - name: 'Retries', - headerClassName: FontClassNames.medium, - minWidth: 50, - maxWidth: 100, - isResizable: true, - onRender: (item, idx) => { - return ( -
{item.retries}
- ); - }, - }, - { - key: 'exitCode', - name: 'Exit Code', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 150, - isResizable: true, - onRender: item => { - return ( -
- {item.containerExitCode} -
- ); - }, - }, - { - key: 'exitType', - name: 'Exit Type', - headerClassName: FontClassNames.medium, - minWidth: 150, - maxWidth: 200, - isResizable: true, - onRender: item => { - return ( -
- {!isNil(item.containerExitSpec) && - !isNil(item.containerExitSpec.type) - ? item.containerExitSpec.type - : null} -
- ); - }, - }, { key: 'info', name: 'Info & Logs', @@ -806,72 +687,172 @@ export default class TaskRoleContainerList extends React.Component {
), }, + exitTypeColumn, + { + key: 'exitCode', + name: 'Exit Code', + minWidth: 260, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender: item => { + return isNil(item.containerExitSpec) ? ( +
+ {item.containerExitCode} +
+ ) : ( +
+ {`${item.containerExitCode} (${item.containerExitSpec.phrase})`} +
+ ); + }, + }, + ]; + const optionalColumns = [ + { + key: 'runningStartTime', + name: 'Running Start Time', + headerClassName: FontClassNames.medium, + minWidth: 180, + maxWidth: 200, + isResizable: true, + onRender: item => { + return ( +
+ {isNil(item.launchedTime) + ? 'N/A' + : DateTime.fromMillis(item.launchedTime).toLocaleString( + DateTime.DATETIME_MED_WITH_SECONDS, + )} +
+ ); + }, + }, + { + key: 'Duration', + name: 'Running Duration', + minWidth: 150, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender: (item, idx) => { + return ( +
+ {getDurationString( + this.getTimeDuration(item.launchedTime, item.completedTime), + )} +
+ ); + }, + }, + { + key: 'nodeName', + name: 'Node Name', + headerClassName: FontClassNames.medium, + minWidth: 100, + isResizable: true, + onRender: item => { + return ( +
+ {item.containerNodeName} +
+ ); + }, + }, + { + key: 'exitDiagonostic', + name: 'Exit Diagnostics', + headerClassName: FontClassNames.medium, + minWidth: 200, + isResizable: true, + onRender: item => { + return ( + { + const result = []; + // exit spec + const spec = item.containerExitSpec; + if (!isNil(spec)) { + // divider + result.push(Array.from({ length: 80 }, () => '-').join('')); + result.push(''); + // content + result.push('[Exit Spec]'); + result.push(''); + result.push(yaml.safeDump(spec)); + result.push(''); + } + + // diagnostics + const diag = item.containerExitDiagnostics; + if (!isNil(diag)) { + // divider + result.push(Array.from({ length: 80 }, () => '-').join('')); + result.push(''); + // content + result.push('[Exit Diagnostics]'); + result.push(''); + result.push(diag); + result.push(''); + } + + this.setState({ + monacoProps: { + language: 'text', + value: result.join('\n'), + options: { + wordWrap: 'off', + readOnly: true, + }, + }, + monacoTitle: `Task Exit Diagonostics`, + }); + }} + /> + ); + }, + }, + { + key: 'containerId', + name: 'Container ID', + headerClassName: FontClassNames.medium, + minWidth: 300, + isResizable: true, + onRender: item => { + const id = item.containerId; + return ( + !isNil(id) && ( +
+ {id} +
+ ) + ); + }, + }, ]; let columns = defaultColumns; - if (showDebugInfo) { - columns = defaultColumns.concat(optionalColumns); + if (showMoreDiagnostics) { + columns = columns.concat(optionalColumns); } return columns; } - - onRenderRow(props) { - return ( - - ); - } - - render() { - const { monacoTitle, monacoProps, monacoFooterButton, logUrl } = this.state; - const { className, style, taskInfo, showDebugInfo } = this.props; - const status = taskInfo.taskStatuses; - return ( -
- - - - {/* Timer */} - - {/* Monaco Editor Panel */} - -
- ); - } } TaskRoleContainerList.contextType = Context; TaskRoleContainerList.propTypes = { - className: PropTypes.string, - style: PropTypes.object, - taskInfo: PropTypes.object, - showDebugInfo: PropTypes.bool, + taskRoleName: PropTypes.string, + tasks: PropTypes.arrayOf(PropTypes.object), + showMoreDiagnostics: PropTypes.bool, + jobAttemptIndex: PropTypes.number, }; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-count.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-count.jsx new file mode 100644 index 0000000000..dbfdc6fe4e --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-count.jsx @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import c from 'classnames'; +import { capitalize } from 'lodash'; +import { TooltipHost, Text } from 'office-ui-fabric-react'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import t from '../../../../../components/tachyons.scss'; + +import { statusColor } from '../../../../../components/theme'; + +const TaskRoleCount = ({ taskInfo }) => { + const count = { + running: 0, + waiting: 0, + succeeded: 0, + failed: 0, + stopped: 0, + unknown: 0, + }; + if (taskInfo && taskInfo.taskStatuses) { + for (const item of taskInfo.taskStatuses) { + switch (item.taskState) { + case 'RUNNING': + count.running += 1; + break; + case 'WAITING': + case 'STOPPING': + count.waiting += 1; + break; + case 'SUCCEEDED': + count.succeeded += 1; + break; + case 'FAILED': + count.failed += 1; + break; + case 'STOPPED': + count.stopped += 1; + break; + default: + count.unknown += 1; + break; + } + } + } else { + // task status info not available + return; + } + + return ( +
+ {Object.keys(count) + .filter(x => count[x] > 0) + .map(x => ( +
+ +
+
+ {count[x]} +
+ ))} +
+ ); +}; + +TaskRoleCount.propTypes = { + taskInfo: PropTypes.object.isRequired, +}; + +export default TaskRoleCount; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx deleted file mode 100644 index 90d13d0682..0000000000 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) Microsoft Corporation -// All rights reserved. -// -// MIT License -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -// documentation files (the "Software"), to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and -// to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import { FontClassNames, ColorClassNames, getTheme } from '@uifabric/styling'; -import c from 'classnames'; -import { capitalize } from 'lodash'; -import { - Icon, - IconButton, - TooltipHost, - Toggle, - Stack, -} from 'office-ui-fabric-react'; -import PropTypes from 'prop-types'; -import React from 'react'; -import yaml from 'js-yaml'; - -import t from '../../../../../components/tachyons.scss'; - -import Card from './card'; -import Context from './context'; -import TaskRoleContainerList from './task-role-container-list'; -import { getTaskConfig } from '../util'; -import MonacoCallout from '../../../../../components/monaco-callout'; -import { statusColor } from '../../../../../components/theme'; - -const TaskRoleCount = ({ taskInfo }) => { - const count = { - running: 0, - waiting: 0, - succeeded: 0, - failed: 0, - stopped: 0, - unknown: 0, - }; - if (taskInfo && taskInfo.taskStatuses) { - for (const item of taskInfo.taskStatuses) { - switch (item.taskState) { - case 'RUNNING': - count.running += 1; - break; - case 'WAITING': - case 'STOPPING': - count.waiting += 1; - break; - case 'SUCCEEDED': - count.succeeded += 1; - break; - case 'FAILED': - count.failed += 1; - break; - case 'STOPPED': - count.stopped += 1; - break; - default: - count.unknown += 1; - break; - } - } - } else { - // task status info not available - return; - } - - return ( -
- {Object.keys(count) - .filter(x => count[x] > 0) - .map(x => ( -
- -
-
-
{count[x]}
-
- ))} -
- ); -}; - -TaskRoleCount.propTypes = { - taskInfo: PropTypes.object.isRequired, -}; - -export default class TaskRole extends React.Component { - constructor(props) { - super(props); - this.state = { - containerListExpanded: true, - showDebugInfo: false, - }; - this.taskConfigButtonRef = React.createRef(); - this.expandContainerList = this.expandContainerList.bind(this); - this.collapseContainerList = this.collapseContainerList.bind(this); - } - - expandContainerList() { - this.setState({ containerListExpanded: true }); - } - - collapseContainerList() { - this.setState({ containerListExpanded: false }); - } - - render() { - const { className, name, taskInfo, isFailed } = this.props; - const { containerListExpanded } = this.state; - const { semanticColors } = getTheme(); - const { rawJobConfig } = this.context; - const taskConfig = getTaskConfig(rawJobConfig, name); - return ( -
- {/* summary */} - -
- {/* left */} -
- {isFailed && ( -
- -
- )} -
- Task Role: - {name} -
- {taskConfig && ( - - - - )} - {/* status */} -
-
Status:
-
- -
-
-
- {/* right */} - - { - this.setState({ - showDebugInfo: checked, - }); - }} - /> - {containerListExpanded ? ( - - ) : ( - - )} - -
- {containerListExpanded && ( - - )} -
-
- ); - } -} - -TaskRole.contextType = Context; - -TaskRole.propTypes = { - className: PropTypes.string, - name: PropTypes.string.isRequired, - taskInfo: PropTypes.object.isRequired, - isFailed: PropTypes.bool, -}; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx index 0a1c5eeb38..867d607c2c 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx @@ -27,7 +27,7 @@ const Top = () => ( iconProps={{ iconName: 'revToggleKey' }} href='/job-list.html' > - Back to Jobs + Go to Jobs List
diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/conn.js b/src/webportal/src/app/job/job-view/fabric/job-detail/conn.js index 55a1c23d91..dc0561807d 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/conn.js +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/conn.js @@ -43,55 +43,34 @@ const wrapper = async func => { } }; -export async function checkAttemptAPI() { +export async function fetchJobInfo(attemptIndex) { return wrapper(async () => { - try { - await client.jobHistory.getJobAttemptsHealthz(userName, jobName); - return true; - } catch { - return false; - } + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = isNil(attemptIndex) + ? `${restServerUri}/api/v2/jobs/${userName}~${jobName}` + : `${restServerUri}/api/v2/jobs/${userName}~${jobName}/attempts/${attemptIndex}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + return result; }); } -export async function fetchJobRetries() { - if (!(await checkAttemptAPI())) { - return { - isSucceeded: false, - errorMessage: 'Attempts API is not working!', - jobRetries: null, - }; - } - - try { - const jobAttempts = await client.jobHistory.getJobAttempts( - userName, - jobName, - ); - return { - isSucceeded: true, - errorMessage: null, - jobRetries: jobAttempts.filter(attempt => !attempt.isLatest), - }; - } catch (err) { - if (err.status === 404) { - return { - isSucceeded: false, - errorMessage: 'Could not find any attempts of this job!', - jobRetries: null, - }; - } else { - return { - isSucceeded: false, - errorMessage: 'Some errors occurred!', - jobRetries: null, - }; - } - } -} - -export async function fetchJobInfo() { - return wrapper(() => client.job.getJob(userName, jobName)); +export async function fetchTaskStatus(attemptIndex, taskRoleName, taskIndex) { + return wrapper(async () => { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = `${restServerUri}/api/v2/jobs/${userName}~${jobName}/attempts/${attemptIndex}/taskRoles/${taskRoleName}/taskIndex/${taskIndex}/attempts`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + return result; + }); } export async function fetchRawJobConfig() { diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/util.js b/src/webportal/src/app/job/job-view/fabric/job-detail/util.js index 9f25e6225c..7c2d0d6d60 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/util.js +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/util.js @@ -23,27 +23,14 @@ export function printDateTime(dt) { dt > DateTime.utc().minus({ week: 1 }) && dt < DateTime.utc().minus({ minute: 1 }) ) { - return `${dt.toRelative()}, ${dt.toLocaleString({ - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', - })}`; + return `${dt.toRelative()}, ${dt.toLocaleString( + DateTime.TIME_WITH_SECONDS, + )}`; } else { - return dt.toLocaleString(DateTime.DATETIME_MED); + return dt.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS); } } -export function parseGpuAttr(attr) { - const res = []; - for (let i = 0; attr !== 0; i++, attr >>= 1) { - if ((attr & 1) === 1) { - res.push(i); - } - } - - return res; -} - export function isJobV2(rawJobConfig) { return ( !isNil(rawJobConfig.protocol_version) || @@ -74,8 +61,3 @@ export function getTaskConfig(rawJobConfig, name) { } return null; } - -export const HISTORY_DISABLE_MESSAGE = - 'The job history was not enabled when deploying.'; -export const HISTORY_API_ERROR_MESSAGE = - 'The job history API is not healthy right now.'; diff --git a/src/webportal/src/app/job/job-view/fabric/job-retry.jsx b/src/webportal/src/app/job/job-view/fabric/job-retry.jsx deleted file mode 100644 index 8eacb614dd..0000000000 --- a/src/webportal/src/app/job/job-view/fabric/job-retry.jsx +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Microsoft Corporation -// All rights reserved. -// -// MIT License -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -// documentation files (the "Software"), to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and -// to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import { isNil } from 'lodash'; -import { Fabric, Stack, getTheme } from 'office-ui-fabric-react'; -import React, { useEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; - -import Top from './job-retry/top'; -import { SpinnerLoading } from '../../../components/loading'; -import { JobRetryCard } from './job-retry/job-retry-card'; -import { fetchJobRetries } from './job-detail/conn'; -const { spacing } = getTheme(); - -const JobRetryPage = () => { - const [loading, setLoading] = useState(true); - const [jobRetries, setJobRetries] = useState(null); - - useEffect(() => { - reload(true); - }, []); - - const reload = async alertFlag => { - let errorMessage; - try { - const result = await fetchJobRetries(); - if (result.isSucceeded) { - setJobRetries(result.jobRetries); - } else { - errorMessage = result.errorMessage; - } - } catch (err) { - errorMessage = `fetch job status failed: ${err.message}`; - } - if (alertFlag === true && !isNil(errorMessage)) { - alert(errorMessage); - } - setLoading(false); - }; - - return ( - - {loading && } - {!loading && ( - - - - {jobRetries.map(jobRetry => { - return ( - - ); - })} - - - )} - - ); -}; - -ReactDOM.render(, document.getElementById('content-wrapper')); diff --git a/src/webportal/src/app/job/job-view/fabric/job-retry/container-list.jsx b/src/webportal/src/app/job/job-view/fabric/job-retry/container-list.jsx deleted file mode 100644 index fb3a9188f8..0000000000 --- a/src/webportal/src/app/job/job-view/fabric/job-retry/container-list.jsx +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) Microsoft Corporation -// All rights reserved. -// -// MIT License -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -// documentation files (the "Software"), to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and -// to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import { FontClassNames, getTheme } from '@uifabric/styling'; -import c from 'classnames'; -import { capitalize, isNil } from 'lodash'; -import { Link } from 'office-ui-fabric-react'; -import { - DetailsList, - SelectionMode, - DetailsListLayoutMode, -} from 'office-ui-fabric-react/lib/DetailsList'; -import PropTypes from 'prop-types'; -import React from 'react'; - -import t from '../../../../components/tachyons.scss'; - -import StatusBadge from '../../../../components/status-badge'; - -const { palette } = getTheme(); - -export const ContainerList = ({ taskStatuses }) => { - const columns = [ - { - key: 'number', - name: 'No.', - headerClassName: FontClassNames.medium, - minWidth: 50, - maxWidth: 50, - isResizable: true, - onRender: (item, idx) => { - return ( - !isNil(idx) &&
{idx}
- ); - }, - }, - { - key: 'name', - name: 'Container ID', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 500, - isResizable: true, - onRender: item => { - const id = item.containerId; - return ( - !isNil(id) && ( -
{id}
- ) - ); - }, - }, - { - key: 'containerIP', - name: 'Container IP', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 100, - isResizable: true, - onRender: (item, idx) => { - return ( -
{item.containerIp}
- ); - }, - }, - { - key: 'status', - name: 'Status', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 100, - isResizable: true, - onRender: item => , - }, - { - key: 'userLog', - name: 'User Log', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 100, - onRender: item => { - const logUrl = isNil(item.containerLog) - ? item.containerLog - : item.containerLog.replace('/tail/', '/'); - const allLogUrl = `${logUrl}user.pai.all`; - return ( - !isNil(logUrl) && ( - - User Log - - ) - ); - }, - }, - { - key: 'logFolder', - name: 'Log Folder', - headerClassName: FontClassNames.medium, - minWidth: 100, - maxWidth: 100, - onRender: item => { - const logUrl = isNil(item.containerLog) - ? item.containerLog - : item.containerLog.replace('/tail/', '/'); - return ( - !isNil(logUrl) && ( - - Log Folder - - ) - ); - }, - }, - ]; - - return ( -
- -
- ); -}; - -ContainerList.propTypes = { - taskStatuses: PropTypes.arrayOf(PropTypes.object), -}; diff --git a/src/webportal/src/app/job/job-view/fabric/job-retry/job-retry-card.jsx b/src/webportal/src/app/job/job-view/fabric/job-retry/job-retry-card.jsx deleted file mode 100644 index 0b0760cae9..0000000000 --- a/src/webportal/src/app/job/job-view/fabric/job-retry/job-retry-card.jsx +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (c) Microsoft Corporation -// All rights reserved. -// -// MIT License -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -// documentation files (the "Software"), to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and -// to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import { FontClassNames, ColorClassNames, getTheme } from '@uifabric/styling'; -import c from 'classnames'; -import { Stack, IconButton, Link } from 'office-ui-fabric-react'; -import PropTypes from 'prop-types'; -import React, { useState } from 'react'; -import { Interval, DateTime } from 'luxon'; -import { capitalize, isNil, get } from 'lodash'; -import styled from 'styled-components'; -import yaml from 'js-yaml'; - -import { getDurationString } from '../../../../components/util/job'; -import StatusBadge from '../../../../components/status-badge'; -import { ContainerList } from './container-list'; -import { printDateTime } from '../job-detail/util'; -import MonacoPanel from '../../../../components/monaco-panel'; - -const { spacing, palette } = getTheme(); - -function getAttemptDurationString(attempt) { - const start = - attempt.attemptStartedTime && - DateTime.fromMillis(attempt.attemptStartedTime); - const end = attempt.attemptCompletedTime - ? DateTime.fromMillis(attempt.attemptCompletedTime) - : DateTime.utc(); - if (start && end) { - return getDurationString( - Interval.fromDateTimes(start, end || DateTime.utc()).toDuration([ - 'days', - 'hours', - 'minutes', - 'seconds', - ]), - ); - } else { - return 'N/A'; - } -} - -const RetryCard = styled.div` - background: #f8f8f8; - box-shadow: rgba(0, 0, 0, 0.06) 0px 2px 4px, rgba(0, 0, 0, 0.05) 0px 0.5px 1px; -`; - -const TaskRoleCard = styled.div` - padding: ${spacing.l1}; - background: ${palette.white}; - box-shadow: rgba(0, 0, 0, 0.06) 0px 2px 4px, rgba(0, 0, 0, 0.05) 0px 0.5px 1px; -`; - -const TaskRole = ({ name, taskrole }) => { - const [isExpanded, setIsExpanded] = useState(false); - return ( - - - -
- TaslRole Name: - {`${name} (${taskrole.taskStatuses.length})`} -
-
- {isExpanded ? ( - setIsExpanded(false)} - /> - ) : ( - setIsExpanded(true)} - /> - )} -
-
- {isExpanded && } -
-
- ); -}; - -TaskRole.propTypes = { - name: PropTypes.string, - taskrole: PropTypes.object, -}; - -export const JobRetryCard = ({ jobRetry }) => { - const [monacoProps, setMonacoProps] = useState(null); - const [modalTitle, setModalTile] = useState(''); - - const showEditor = (title, props) => { - setMonacoProps(props); - setModalTile(title); - }; - - const dismissEditor = () => { - setMonacoProps(null); - setModalTile(''); - }; - - const showExitDiagnostics = () => { - const result = []; - // trigger info - result.push('[Exit Trigger Info]'); - result.push(''); - result.push( - `ExitTriggerMessage: ${get(jobRetry, 'appExitTriggerMessage')}`, - ); - result.push( - `ExitTriggerTaskRole: ${get(jobRetry, 'appExitTriggerTaskRoleName')}`, - ); - result.push( - `ExitTriggerTaskIndex: ${get(jobRetry, 'appExitTriggerTaskIndex')}`, - ); - const userExitCode = get( - jobRetry, - 'appExitMessages.runtime.originalUserExitCode', - ); - if (userExitCode) { - // user exit code - result.push(`UserExitCode: ${userExitCode}`); - } - result.push(''); - - // exit spec - const spec = jobRetry.appExitSpec; - if (spec) { - // divider - result.push(Array.from({ length: 80 }, () => '-').join('')); - result.push(''); - // content - result.push('[Exit Spec]'); - result.push(''); - result.push(yaml.safeDump(spec)); - result.push(''); - } - - // diagnostics - const diag = jobRetry.appExitDiagnostics; - if (diag) { - // divider - result.push(Array.from({ length: 80 }, () => '-').join('')); - result.push(''); - // content - result.push('[Exit Diagnostics]'); - result.push(''); - result.push(diag); - result.push(''); - } - - showEditor('Exit Diagnostics', { - language: 'text', - value: result.join('\n'), - }); - }; - - return ( - - - -
- Retry Index: - {jobRetry.attemptIndex} -
-
- -
-
- Status: -
- -
-
-
- Start Time: -
-
- {printDateTime(DateTime.fromMillis(jobRetry.attemptStartedTime))} -
-
-
-
- Duration: -
-
- {getAttemptDurationString(jobRetry)} -
-
-
-
- Exit Code: -
-
- {`${jobRetry.exitCode}`} -
-
-
-
- Exit Phrase: -
-
- {`${jobRetry.exitPhrase}`} -
-
-
-
- Exit Type: -
-
- {`${jobRetry.exitType}`} -
-
-
-
- Exit Diagnostics: -
- - View Exit Diagnostics - -
-
- - {Object.keys(jobRetry.taskRoles).map(name => ( - - ))} - -
- -
- ); -}; - -JobRetryCard.propTypes = { - jobRetry: PropTypes.object, -}; diff --git a/src/webportal/src/app/job/job-view/fabric/job-retry/top.jsx b/src/webportal/src/app/job/job-view/fabric/job-retry/top.jsx deleted file mode 100644 index 237969d62c..0000000000 --- a/src/webportal/src/app/job/job-view/fabric/job-retry/top.jsx +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation -// All rights reserved. -// -// MIT License -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -// documentation files (the "Software"), to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and -// to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import React from 'react'; -import { Stack, ActionButton } from 'office-ui-fabric-react'; - -const params = new URLSearchParams(window.location.search); -const username = params.get('username'); -const jobName = params.get('jobName'); - -const Top = () => ( - -
- - Back to Job Detail - -
-
-); - -export default Top; diff --git a/src/webportal/src/app/job/job-view/fabric/task-attempt.jsx b/src/webportal/src/app/job/job-view/fabric/task-attempt.jsx new file mode 100644 index 0000000000..fab668a1c1 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/task-attempt.jsx @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import React, { useEffect, useState } from 'react'; +import { Stack, ActionButton, Text } from 'office-ui-fabric-react'; +import ReactDOM from 'react-dom'; +import { isNil, capitalize } from 'lodash'; +import { DateTime, Interval } from 'luxon'; + +import { SpinnerLoading } from '../../../components/loading'; +import TaskAttemptList from './task-attempt/task-attempt-list'; +import { fetchTaskStatus } from './task-attempt/conn'; +import StatusBadge from '../../../components/status-badge'; +import { getDurationString } from '../../../components/util/job'; +import Card from './job-detail/components/card'; +import HorizontalLine from '../../../components/horizontal-line'; + +const params = new URLSearchParams(window.location.search); +const userName = params.get('username'); +const jobName = params.get('jobName'); +const jobAttemptIndex = params.get('jobAttemptIndex'); +const taskRoleName = params.get('taskRoleName'); +const taskIndex = params.get('taskIndex'); + +const TaskAttemptPage = () => { + const [loading, setLoading] = useState(true); + const [taskStatus, setTaskStatus] = useState(null); + + const getTimeDuration = (startMs, endMs) => { + const start = startMs && DateTime.fromMillis(startMs); + const end = endMs && DateTime.fromMillis(endMs); + if (start) { + return Interval.fromDateTimes(start, end || DateTime.utc()).toDuration([ + 'days', + 'hours', + 'minutes', + 'seconds', + ]); + } else { + return null; + } + }; + + useEffect(() => { + fetchTaskStatus( + userName, + jobName, + jobAttemptIndex, + taskRoleName, + taskIndex, + ).then(data => { + setTaskStatus(data); + setLoading(false); + }); + }, []); + + return ( +
+ {loading && } + {!loading && ( + +
+ + Go to Job Detail + +
+ + + + Job Name: + {jobName} + + + + + Job Attempt Index + {jobAttemptIndex} + + + Task Role + {taskRoleName} + + + Task Index + {taskIndex} + + + Task Uid + {taskStatus.taskUid} + + + + + + Task State + + + + Task Retries + {taskStatus.retries} + + + Task Creation Time + + {isNil(taskStatus.createdTime) + ? 'N/A' + : DateTime.fromMillis( + taskStatus.createdTime, + ).toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS)} + + + + Task Duration + + {getDurationString( + getTimeDuration( + taskStatus.createdTime, + taskStatus.completedTime, + ), + )} + + + + Task Running Start Time + + {isNil(taskStatus.launchedTime) + ? 'N/A' + : DateTime.fromMillis( + taskStatus.launchedTime, + ).toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS)} + + + + Task Running Duration + + {getDurationString( + getTimeDuration( + taskStatus.launchedTime, + taskStatus.completedTime, + ), + )} + + + + + + +
+ )} +
+ ); +}; + +ReactDOM.render( + , + document.getElementById('content-wrapper'), +); diff --git a/src/webportal/src/app/job/job-view/fabric/task-attempt/conn.js b/src/webportal/src/app/job/job-view/fabric/task-attempt/conn.js new file mode 100644 index 0000000000..72909ca78b --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/task-attempt/conn.js @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { clearToken } from '../../../../user/user-logout/user-logout.component'; +import config from '../../../../config/webportal.config'; +const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:/; + +const token = cookies.get('token'); + +export class NotFoundError extends Error { + constructor(msg) { + super(msg); + this.name = 'NotFoundError'; + } +} + +const wrapper = async func => { + try { + return await func(); + } catch (err) { + if (err.data.code === 'UnauthorizedUserError') { + alert(err.data.message); + clearToken(); + } else if (err.data.code === 'NoJobConfigError') { + throw new NotFoundError(err.data.message); + } else { + throw new Error(err.data.message); + } + } +}; + +export async function fetchTaskStatus( + userName, + jobName, + attemptIndex, + taskRoleName, + taskIndex, +) { + return wrapper(async () => { + const restServerUri = new URL(config.restServerUri, window.location.href); + const url = `${restServerUri}/api/v2/jobs/${userName}~${jobName}/attempts/${attemptIndex}/taskRoles/${taskRoleName}/taskIndex/${taskIndex}/attempts`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + return result; + }); +} + +export async function getContainerLog(logUrl) { + const ret = { + fullLogLink: logUrl, + text: null, + }; + const res = await fetch(logUrl); + var text = await res.text(); + if (!res.ok) { + throw new Error(res.statusText); + } + + const contentType = res.headers.get('content-type'); + if (!contentType) { + throw new Error(`Log not available`); + } + + // Check log type. The log type is in LOG_TYPE and should be yarn|log-manager. + if (config.logType === 'yarn') { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'text/html'); + const content = doc.getElementsByClassName('content')[0]; + const pre = content.getElementsByTagName('pre')[0]; + ret.text = pre.innerText; + // fetch full log link + if (pre.previousElementSibling) { + const link = pre.previousElementSibling.getElementsByTagName('a'); + if (link.length === 1) { + ret.fullLogLink = link[0].getAttribute('href'); + // relative link + if (ret.fullLogLink && !absoluteUrlRegExp.test(ret.fullLogLink)) { + let baseUrl = res.url; + // check base tag + const baseTags = doc.getElementsByTagName('base'); + // There can be only one element in a document. + if (baseTags.length > 0 && baseTags[0].hasAttribute('href')) { + baseUrl = baseTags[0].getAttribute('href'); + // relative base tag url + if (!absoluteUrlRegExp.test(baseUrl)) { + baseUrl = new URL(baseUrl, res.url); + } + } + const url = new URL(ret.fullLogLink, baseUrl); + ret.fullLogLink = url.href; + } + } + } + return ret; + } catch (e) { + throw new Error(`Log not available`); + } + } else if (config.logType === 'log-manager') { + // Try to get roated log if currently log content is less than 15KB + if (text.length <= 15 * 1024) { + const fullLogUrl = logUrl.replace('/tail/', '/full/'); + const rotatedLogUrl = logUrl + '.1'; + const rotatedLogRes = await fetch(rotatedLogUrl); + const fullLogRes = await fetch(fullLogUrl); + const rotatedText = await rotatedLogRes.text(); + const fullLog = await fullLogRes.text(); + if (rotatedLogRes.ok && rotatedText.trim() !== 'No such file!') { + text = rotatedText + .concat('\n--------log is rotated, may be lost during this--------\n') + .concat(fullLog); + } + // get last 16KB + text = text.slice(-16 * 1024); + } + ret.text = text; + ret.fullLogLink = logUrl.replace('/tail/', '/full/'); + return ret; + } else { + throw new Error(`Log not available`); + } +} diff --git a/src/webportal/src/app/job/job-view/fabric/task-attempt/task-attempt-dialog.jsx b/src/webportal/src/app/job/job-view/fabric/task-attempt/task-attempt-dialog.jsx new file mode 100644 index 0000000000..ea7fa75b6b --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/task-attempt/task-attempt-dialog.jsx @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { isNil } from 'lodash'; +import { + PrimaryButton, + DefaultButton, + Dialog, + DialogFooter, +} from 'office-ui-fabric-react'; +import TaskAttemptList from './task-attempt-list'; +import { fetchTaskStatus } from '../fabric/job-detail/conn'; + +const TaskAttemptDialog = props => { + const { + hideDialog, + toggleHideDialog, + jobAttemptIndex, + taskRoleName, + taskIndex, + } = props; + const [taskAttempts, setTaskAttempts] = useState([]); + + useEffect(() => { + if (!isNil(jobAttemptIndex) && !isNil(taskRoleName) && !isNil(taskIndex)) { + fetchTaskStatus(jobAttemptIndex, taskRoleName, taskIndex).then( + taskStatus => { + const attempts = taskStatus.attempts; + setTaskAttempts(attempts); + }, + ); + } + }, [jobAttemptIndex, taskRoleName, taskIndex]); + + return ( + + ); +}; + +TaskAttemptDialog.propTypes = { + hideDialog: PropTypes.bool, + toggleHideDialog: PropTypes.func, + jobAttemptIndex: PropTypes.number, + taskRoleName: PropTypes.string, + taskIndex: PropTypes.number, +}; + +export default TaskAttemptDialog; diff --git a/src/webportal/src/app/job/job-view/fabric/task-attempt/task-attempt-list.jsx b/src/webportal/src/app/job/job-view/fabric/task-attempt/task-attempt-list.jsx new file mode 100644 index 0000000000..d0a154cd93 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/task-attempt/task-attempt-list.jsx @@ -0,0 +1,642 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { ColorClassNames, FontClassNames, getTheme } from '@uifabric/styling'; +import c from 'classnames'; +import { capitalize, isEmpty, isNil, flatten } from 'lodash'; +import { DateTime, Interval } from 'luxon'; +import { CommandBarButton, PrimaryButton, Stack } from 'office-ui-fabric-react'; +import { + DetailsList, + SelectionMode, + DetailsRow, + DetailsListLayoutMode, +} from 'office-ui-fabric-react/lib/DetailsList'; +import PropTypes from 'prop-types'; +import React from 'react'; +import yaml from 'js-yaml'; + +import localCss from './task-role-container-list.scss'; +import t from '../../../../components/tachyons.scss'; + +import { getContainerLog } from './conn'; +import config from '../../../../config/webportal.config'; +import MonacoPanel from '../../../../components/monaco-panel'; +import StatusBadge from '../../../../components/status-badge'; +import CopyButton from '../../../../components/copy-button'; +import { getDurationString } from '../../../../components/util/job'; + +const theme = getTheme(); + +const IPTooltipContent = ({ ip }) => { + return ( +
+ +
{`Container IP: ${ip}`}
+
+ +
+
+
+ ); +}; + +IPTooltipContent.propTypes = { + ip: PropTypes.string, +}; + +const PortTooltipContent = ({ ports }) => { + const { spacing } = getTheme(); + return ( +
+ + + {Object.entries(ports).map(([key, val]) => ( + + + + + + ))} + +
{`${key}:`}{val} + +
+
+ ); +}; + +PortTooltipContent.propTypes = { + ports: PropTypes.object, +}; + +export default class TaskAttemptList extends React.Component { + constructor(props) { + super(props); + this.state = { + monacoProps: null, + monacoTitle: '', + monacoFooterButton: null, + logUrl: null, + items: props.taskAttempts, + ordering: { field: null, descending: false }, + hideDialog: true, + }; + + this.showSshInfo = this.showSshInfo.bind(this); + this.onDismiss = this.onDismiss.bind(this); + this.showContainerLog = this.showContainerLog.bind(this); + this.onRenderRow = this.onRenderRow.bind(this); + } + + componentDidUpdate(prevProps) { + if (prevProps.taskAttempts !== this.props.taskAttempts) { + this.setState({ items: this.props.taskAttempts }); + } + } + + logAutoRefresh() { + const { logUrl } = this.state; + getContainerLog(logUrl) + .then(({ text, fullLogLink }) => + this.setState( + prevState => + prevState.logUrl === logUrl && { + monacoProps: { value: text }, + monacoFooterButton: ( + + ), + }, + ), + ) + .catch(err => + this.setState( + prevState => + prevState.logUrl === logUrl && { + monacoProps: { value: err.message }, + }, + ), + ); + } + + onDismiss() { + this.setState({ + monacoProps: null, + monacoTitle: '', + monacoFooterButton: null, + logUrl: null, + }); + } + + getTimeDuration(startMs, endMs) { + const start = startMs && DateTime.fromMillis(startMs); + const end = endMs && DateTime.fromMillis(endMs); + if (start) { + return Interval.fromDateTimes(start, end || DateTime.utc()).toDuration([ + 'days', + 'hours', + 'minutes', + 'seconds', + ]); + } else { + return null; + } + } + + showContainerLog(logUrl, logType) { + let title; + let logHint; + + if (config.logType === 'yarn') { + logHint = 'Last 4096 bytes'; + } else if (config.logType === 'log-manager') { + logHint = 'Last 16384 bytes'; + } else { + logHint = ''; + } + switch (logType) { + case 'stdout': + title = `Standard Output (${logHint})`; + break; + case 'stderr': + title = `Standard Error (${logHint})`; + break; + case 'stdall': + title = `User logs (${logHint}. Notice: The logs may out of order when merging stdout & stderr streams)`; + break; + default: + throw new Error(`Unsupported log type`); + } + this.setState( + { + monacoProps: { value: 'Loading...' }, + monacoTitle: title, + logUrl, + }, + () => { + this.logAutoRefresh(); // start immediately + }, + ); + } + + showSshInfo(id, containerPorts, containerIp) { + const { sshInfo, jobConfig } = this.context; + const containerSshInfo = + sshInfo && sshInfo.containers.find(x => x.id === id); + if (config.launcherType !== 'k8s') { + if (!containerSshInfo) { + const res = []; + res.push('This job does not contain SSH info.'); + res.push( + 'Please note that if your docker image does not have openssh-server and curl packages, SSH will not be enabled.\n', + ); + res.push( + 'Solution 1: Use one of the recommended docker images on the submission page.', + ); + res.push( + 'Solution 2: Use your own image, but enable SSH for it. Please follow the instructions on https://aka.ms/AA5u4sq to do such work.', + ); + this.setState({ + monacoProps: { value: res.join('\n') }, + monacoTitle: `SSH to ${id}`, + }); + } else { + const res = []; + res.push('# Step 1. Open a Bash shell terminal.'); + res.push('# Step 2: Download the private key:'); + res.push( + `wget '${sshInfo.keyPair.privateKeyDirectDownloadLink}' -O ${sshInfo.keyPair.privateKeyFileName}`, + ); + res.push('# Step 3: Set correct permission for the key file:'); + res.push(`chmod 400 ${sshInfo.keyPair.privateKeyFileName}`); + res.push('# Step 4: Connect to the container:'); + res.push( + `ssh -i ${sshInfo.keyPair.privateKeyFileName} -p ${containerSshInfo.sshPort} root@${containerSshInfo.sshIp}`, + ); + res.push(''); + this.setState({ + monacoProps: { + value: res.join('\n'), + options: { + wordWrap: 'off', + readOnly: true, + }, + }, + monacoTitle: `SSH to ${id}`, + }); + } + } else { + const res = []; + let hasUserSsh = false; + if ( + 'extras' in jobConfig && + 'com.microsoft.pai.runtimeplugin' in jobConfig.extras + ) { + for (const pluginSetting of jobConfig.extras[ + 'com.microsoft.pai.runtimeplugin' + ]) { + if (pluginSetting.plugin === 'ssh') { + if ( + 'parameters' in pluginSetting && + 'userssh' in pluginSetting.parameters && + !isEmpty(pluginSetting.parameters.userssh) + ) { + hasUserSsh = true; + break; + } + } + } + } + if (hasUserSsh) { + res.push( + 'You can connect to this container by one of the following commands if SSH is set up properly: \n', + ); + res.push(`1. Use your default SSH private key:\n`); + res.push(`ssh -p ${containerPorts.ssh} root@${containerIp}\n`); + res.push(`2. Use a pre-downloaded SSH private key:\n`); + res.push( + `On Windows:\nssh -p ${containerPorts.ssh} -i root@${containerIp}\n`, + ); + res.push( + `On Unix-like System:\nchmod 400 && ssh -p ${containerPorts.ssh} -i root@${containerIp}\n\n`, + ); + res.push( + `If you are using a different username in your docker, please change "root" to your pre-defined username.`, + ); + } else { + res.push('This job does not contain SSH info.'); + res.push( + 'If you want to use SSH, please enable it in the "Tools -> SSH" Section on the Job Submission Page.', + ); + } + this.setState({ + monacoProps: { + value: res.join('\n'), + options: { + wordWrap: 'off', + readOnly: true, + }, + }, + monacoTitle: `SSH to ${id}`, + }); + } + } + + getTaskPropertyFromColumnKey(item, key) { + if (key === 'exitType') { + return !isNil(item.containerExitSpec) && + !isNil(item.containerExitSpec.type) + ? item.containerExitSpec.type + : null; + } + return item[key]; + } + + onRenderRow(props) { + return ( + + ); + } + + render() { + const { monacoTitle, monacoProps, monacoFooterButton, items } = this.state; + return ( +
+ + {/* Monaco Editor Panel */} + +
+ ); + } + + getColumns() { + const defaultColumns = [ + { + key: 'taskAttemptIndex', + name: 'Task Attempt Index', + minWidth: 120, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender: (item, idx) => { + return ( +
{item.attemptId}
+ ); + }, + }, + { + key: 'taskAttemtState', + name: 'Task Attempt State', + minWidth: 120, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender: item => ( + + ), + }, + { + key: 'ip', + name: 'IP', + className: FontClassNames.mediumPlus, + headerClassName: FontClassNames.medium, + minWidth: 90, + maxWidth: 140, + isResizable: true, + fieldName: 'containerIp', + onRender: item => { + const ip = item.containerIp; + return !isNil(ip) &&
{ip}
; + }, + }, + { + key: 'ports', + name: 'Ports', + className: FontClassNames.mediumPlus, + headerClassName: FontClassNames.medium, + minWidth: 150, + maxWidth: 300, + isResizable: true, + onRender: item => { + const ports = item.containerPorts; + return ( + !isNil(ports) && ( +
+ {flatten( + Object.entries(ports).map(([key, val], idx) => [ + idx !== 0 && ( + + ), + `${key}: ${val}`, + ]), + )} +
+ ) + ); + }, + }, + { + key: 'info', + name: 'Info & Logs', + className: localCss.pa0I, + headerClassName: FontClassNames.medium, + minWidth: 300, + maxWidth: 500, + onRender: item => ( +
+
+ + this.showContainerLog( + `${item.containerLog}user.pai.stdout`, + 'stdout', + ) + } + disabled={isNil(item.containerId) || isNil(item.containerIp)} + /> + + this.showContainerLog( + `${item.containerLog}user.pai.stderr`, + 'stderr', + ) + } + disabled={isNil(item.containerId) || isNil(item.containerIp)} + /> +
+
+ ), + }, + { + key: 'exitType', + name: 'Exit Type', + headerClassName: FontClassNames.medium, + minWidth: 150, + maxWidth: 200, + isResizable: true, + onRender: item => { + return ( +
+ {!isNil(item.containerExitSpec) && + !isNil(item.containerExitSpec.type) + ? item.containerExitSpec.type + : null} +
+ ); + }, + }, + { + key: 'taskAttemptExitCode', + name: 'Exit Code', + minWidth: 230, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender: (item, idx) => { + return isNil(item.containerExitSpec) ? ( +
+ {item.containerExitCode} +
+ ) : ( +
+ {`${item.containerExitCode} (${item.containerExitSpec.phrase})`} +
+ ); + }, + }, + { + key: 'runningStartTime', + name: 'Running Start Time', + headerClassName: FontClassNames.medium, + minWidth: 180, + maxWidth: 200, + isResizable: true, + onRender: item => { + return ( +
+ {isNil(item.currentAttemptLaunchedTime) + ? 'N/A' + : DateTime.fromMillis( + item.currentAttemptLaunchedTime, + ).toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS)} +
+ ); + }, + }, + { + key: 'taskAttemptDuration', + name: 'Running Duration', + minWidth: 150, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender: (item, idx) => { + return ( +
+ {getDurationString( + this.getTimeDuration( + item.currentAttemptLaunchedTime, + item.currentAttemptCompletedTime, + ), + )} +
+ ); + }, + }, + { + key: 'nodeName', + name: 'Node Name', + headerClassName: FontClassNames.medium, + minWidth: 100, + isResizable: true, + onRender: item => { + return ( +
+ {item.containerNodeName} +
+ ); + }, + }, + { + key: 'exitDiagonostic', + name: 'Exit Diagnostics', + headerClassName: FontClassNames.medium, + minWidth: 200, + isResizable: true, + onRender: item => { + return ( + { + const result = []; + // exit spec + const spec = item.containerExitSpec; + if (!isNil(spec)) { + // divider + result.push(Array.from({ length: 80 }, () => '-').join('')); + result.push(''); + // content + result.push('[Exit Spec]'); + result.push(''); + result.push(yaml.safeDump(spec)); + result.push(''); + } + + // diagnostics + const diag = item.containerExitDiagnostics; + if (!isNil(diag)) { + // divider + result.push(Array.from({ length: 80 }, () => '-').join('')); + result.push(''); + // content + result.push('[Exit Diagnostics]'); + result.push(''); + result.push(diag); + result.push(''); + } + + this.setState({ + monacoProps: { + language: 'text', + value: result.join('\n'), + options: { + wordWrap: 'off', + readOnly: true, + }, + }, + monacoTitle: `Task Exit Diagonostics`, + }); + }} + /> + ); + }, + }, + { + key: 'containerId', + name: 'Container ID', + headerClassName: FontClassNames.medium, + minWidth: 300, + isResizable: true, + onRender: item => { + const id = item.containerId; + return ( + !isNil(id) && ( +
+ {id} +
+ ) + ); + }, + }, + ]; + const columns = defaultColumns; + return columns; + } +} + +TaskAttemptList.propTypes = { + taskAttempts: PropTypes.arrayOf(PropTypes.object), +}; diff --git a/src/webportal/src/app/job/job-view/fabric/task-attempt/task-role-container-list.scss b/src/webportal/src/app/job/job-view/fabric/task-attempt/task-role-container-list.scss new file mode 100644 index 0000000000..7564004ff0 --- /dev/null +++ b/src/webportal/src/app/job/job-view/fabric/task-attempt/task-role-container-list.scss @@ -0,0 +1,3 @@ +.pa-0-i { + padding: 0 !important +}