TableList.tsx 26 KB
Newer Older
Lijiaoa's avatar
Lijiaoa committed
1
import React from 'react';
2
import {
3
    DefaultButton,
4
    IColumn,
5
6
7
8
    Icon,
    PrimaryButton,
    Stack,
    StackItem,
9
    TooltipHost,
10
11
    DirectionalHint,
    Checkbox
12
} from '@fluentui/react';
13
import { EXPERIMENT, TRIALS } from '../../static/datamodel';
14
import { TOOLTIP_BACKGROUND_COLOR } from '../../static/const';
15
import { convertDuration, formatTimestamp, copyAndSort, parametersType, parseMetrics } from '../../static/function';
16
17
import { TableObj, SortInfo, SearchItems } from '../../static/interface';
import { getTrialsBySearchFilters } from './search/searchFunction';
18
19
20
21
import { blocked, copy, LineChart, tableListIcon } from '../buttons/Icon';
import ChangeColumnComponent from '../modals/ChangeColumnComponent';
import Compare from '../modals/Compare';
import Customize from '../modals/CustomizedTrial';
Lijiaoa's avatar
Lijiaoa committed
22
import TensorboardUI from '../modals/tensorboard/TensorboardUI';
23
import Search from './search/Search';
24
25
26
import KillJob from '../modals/Killjob';
import ExpandableDetails from '../public-child/ExpandableDetails';
import PaginationTable from '../public-child/PaginationTable';
27
import CopyButton from '../public-child/CopyButton';
28
import { Trial } from '../../static/model/trial';
Lijiaoa's avatar
Lijiaoa committed
29
30
31
32
33
34
35
36
37
import '../../static/style/button.scss';
import '../../static/style/logPath.scss';
import '../../static/style/openRow.scss';
import '../../static/style/pagination.scss';
import '../../static/style/search.scss';
import '../../static/style/table.scss';
import '../../static/style/tableStatus.css';
import '../../static/style/tensorboard.scss';
import '../../static/style/overview/overviewTitle.scss';
38

Lijiao's avatar
Lijiao committed
39
40
41
42
require('echarts/lib/chart/line');
require('echarts/lib/component/tooltip');
require('echarts/lib/component/title');

43
type SearchOptionType = 'id' | 'trialnum' | 'status' | 'parameters';
44

45
const defaultDisplayedColumns = ['sequenceId', 'id', 'duration', 'status', 'latestAccuracy'];
Lijiao's avatar
Lijiao committed
46

47
48
49
50
51
52
53
function _inferColumnTitle(columnKey: string): string {
    if (columnKey === 'sequenceId') {
        return 'Trial No.';
    } else if (columnKey === 'id') {
        return 'ID';
    } else if (columnKey === 'intermediateCount') {
        return 'Intermediate results (#)';
54
55
    } else if (columnKey === 'message') {
        return 'Message';
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
    } else if (columnKey.startsWith('space/')) {
        return columnKey.split('/', 2)[1] + ' (space)';
    } else if (columnKey === 'latestAccuracy') {
        return 'Default metric'; // to align with the original design
    } else if (columnKey.startsWith('metric/')) {
        return columnKey.split('/', 2)[1] + ' (metric)';
    } else if (columnKey.startsWith('_')) {
        return columnKey;
    } else {
        // camel case to verbose form
        const withSpace = columnKey.replace(/[A-Z]/g, letter => ` ${letter.toLowerCase()}`);
        return withSpace.charAt(0).toUpperCase() + withSpace.slice(1);
    }
}

interface TableListProps {
    tableSource: TableObj[];
73
    updateDetailPage: () => void;
74
75
}

Lijiao's avatar
Lijiao committed
76
interface TableListState {
77
78
79
80
81
82
83
84
85
86
87
    displayedItems: any[];
    displayedColumns: string[];
    columns: IColumn[];
    searchType: SearchOptionType;
    searchText: string;
    selectedRowIds: string[];
    customizeColumnsDialogVisible: boolean;
    compareDialogVisible: boolean;
    intermediateDialogTrial: TableObj | undefined;
    copiedTrialId: string | undefined;
    sortInfo: SortInfo;
88
89
    searchItems: Array<SearchItems>;
    relation: Map<string, string>;
90
    intermediateKeyList: string[];
91
92
}

Lijiao's avatar
Lijiao committed
93
class TableList extends React.Component<TableListProps, TableListState> {
94
    private _expandedTrialIds: Set<string>;
95

Lijiao's avatar
Lijiao committed
96
97
98
99
    constructor(props: TableListProps) {
        super(props);

        this.state = {
100
            displayedItems: [],
101
102
103
104
105
            displayedColumns:
                localStorage.getItem('columns') !== null
                    ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                      JSON.parse(localStorage.getItem('columns')!)
                    : defaultDisplayedColumns,
106
107
108
109
110
111
112
113
            columns: [],
            searchType: 'id',
            searchText: '',
            customizeColumnsDialogVisible: false,
            compareDialogVisible: false,
            selectedRowIds: [],
            intermediateDialogTrial: undefined,
            copiedTrialId: undefined,
114
115
            sortInfo: { field: '', isDescend: true },
            searchItems: [],
116
117
            relation: parametersType(),
            intermediateKeyList: []
Lijiao's avatar
Lijiao committed
118
119
        };

120
121
        this._expandedTrialIds = new Set<string>();
    }
122

123
    /* Table basic function related methods */
Lijiao's avatar
Lijiao committed
124

125
126
127
128
129
130
131
132
133
134
135
136
137
    private _onColumnClick(ev: React.MouseEvent<HTMLElement>, column: IColumn): void {
        // handle the click events on table header (do sorting)
        const { columns } = this.state;
        const newColumns: IColumn[] = columns.slice();
        const currColumn: IColumn = newColumns.filter(currCol => column.key === currCol.key)[0];
        const isSortedDescending = !currColumn.isSortedDescending;
        this.setState(
            {
                sortInfo: { field: column.key, isDescend: isSortedDescending }
            },
            this._updateTableSource
        );
    }
138

139
140
141
142
    private _trialsToTableItems(trials: TableObj[]): any[] {
        // TODO: use search space and metrics space from TRIALS will cause update issues.
        const searchSpace = TRIALS.inferredSearchSpace(EXPERIMENT.searchSpaceNew);
        const metricSpace = TRIALS.inferredMetricSpace();
143
        const { selectedRowIds } = this.state;
144
145
146
147
        const items = trials.map(trial => {
            const ret = {
                sequenceId: trial.sequenceId,
                id: trial.id,
148
                _checked: selectedRowIds.includes(trial.id) ? true : false,
149
150
151
152
                startTime: (trial as Trial).info.startTime, // FIXME: why do we need info here?
                endTime: (trial as Trial).info.endTime,
                duration: trial.duration,
                status: trial.status,
153
                message: (trial as Trial).info.message || '--',
154
155
156
157
158
159
160
161
162
163
164
165
                intermediateCount: trial.intermediates.length,
                _expandDetails: this._expandedTrialIds.has(trial.id) // hidden field names should start with `_`
            };
            for (const [k, v] of trial.parameters(searchSpace)) {
                ret[`space/${k.baseName}`] = v;
            }
            for (const [k, v] of trial.metrics(metricSpace)) {
                ret[`metric/${k.baseName}`] = v;
            }
            ret['latestAccuracy'] = (trial as Trial).latestAccuracy;
            ret['_formattedLatestAccuracy'] = (trial as Trial).formatLatestAccuracy();
            return ret;
166
        });
167

168
169
        const { sortInfo } = this.state;
        if (sortInfo.field !== '') {
170
            return copyAndSort(items, sortInfo.field, sortInfo.isDescend);
171
172
        } else {
            return items;
173
        }
174
    }
175

176
177
178
179
180
181
    private selectedTrialOnChangeEvent = (
        id: string,
        _ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
        checked?: boolean
    ): void => {
        const { displayedItems, selectedRowIds } = this.state;
Lijiaoa's avatar
Lijiaoa committed
182
183
184
185
186
187
188
        const latestDisplayedItems = JSON.parse(JSON.stringify(displayedItems));
        let latestSelectedRowIds = selectedRowIds;

        if (checked === false) {
            latestSelectedRowIds = latestSelectedRowIds.filter(item => item !== id);
        } else {
            latestSelectedRowIds.push(id);
189
        }
Lijiaoa's avatar
Lijiaoa committed
190
191

        latestDisplayedItems.forEach(item => {
192
            if (item.id === id) {
193
                item._checked = !!checked;
194
195
            }
        });
Lijiaoa's avatar
Lijiaoa committed
196
        this.setState(() => ({ displayedItems: latestDisplayedItems, selectedRowIds: latestSelectedRowIds }));
197
198
199
200
201
202
    };

    private changeSelectTrialIds = (): void => {
        const { displayedItems } = this.state;
        const newDisplayedItems = displayedItems;
        newDisplayedItems.forEach(item => {
203
            item._checked = false;
204
205
206
207
208
209
210
        });
        this.setState(() => ({
            selectedRowIds: [],
            displayedItems: newDisplayedItems
        }));
    };

211
212
    private _buildColumnsFromTableItems(tableItems: any[]): IColumn[] {
        const columns: IColumn[] = [
213
214
215
216
217
218
219
220
221
222
223
224
            // select trial function
            {
                name: '',
                key: '_selected',
                fieldName: 'selected',
                minWidth: 20,
                maxWidth: 20,
                isResizable: true,
                className: 'detail-table',
                onRender: (record): React.ReactNode => (
                    <Checkbox
                        label={undefined}
225
                        checked={record._checked}
226
227
228
229
230
231
                        className='detail-check'
                        onChange={this.selectedTrialOnChangeEvent.bind(this, record.id)}
                    />
                )
            },
            // extra column, for a icon to expand the trial details panel
232
233
234
            {
                key: '_expand',
                name: '',
235
                onRender: (item): any => {
236
237
238
239
                    return (
                        <Icon
                            aria-hidden={true}
                            iconName='ChevronRight'
240
                            className='cursor'
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
                            styles={{
                                root: {
                                    transition: 'all 0.2s',
                                    transform: `rotate(${item._expandDetails ? 90 : 0}deg)`
                                }
                            }}
                            onClick={(event): void => {
                                event.stopPropagation();
                                const newItem: any = { ...item, _expandDetails: !item._expandDetails };
                                if (newItem._expandDetails) {
                                    // preserve to be restored when refreshed
                                    this._expandedTrialIds.add(newItem.id);
                                } else {
                                    this._expandedTrialIds.delete(newItem.id);
                                }
256
257
258
                                const newItems = this.state.displayedItems.map(item =>
                                    item.id === newItem.id ? newItem : item
                                );
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
                                this.setState({
                                    displayedItems: newItems
                                });
                            }}
                            onMouseDown={(e): void => {
                                e.stopPropagation();
                            }}
                            onMouseUp={(e): void => {
                                e.stopPropagation();
                            }}
                        />
                    );
                },
                fieldName: 'expand',
                isResizable: false,
                minWidth: 20,
                maxWidth: 20
276
            }
277
        ];
278

279
280
281
282
283
        // looking at the first row only for now
        for (const k of Object.keys(tableItems[0])) {
            if (k === 'metric/default') {
                // FIXME: default metric is hacked as latestAccuracy currently
                continue;
Lijiao's avatar
Lijiao committed
284
            }
285
286
            const columnTitle = _inferColumnTitle(k);
            // TODO: add blacklist
Lijiaoa's avatar
Lijiaoa committed
287
288
            // 0.85: tableWidth / screen
            const widths = window.innerWidth * 0.85;
289
290
291
292
            columns.push({
                name: columnTitle,
                key: k,
                fieldName: k,
Lijiaoa's avatar
Lijiaoa committed
293
294
                minWidth: widths * 0.12,
                maxWidth: widths * 0.19,
295
296
297
298
299
300
301
302
                isResizable: true,
                onColumnClick: this._onColumnClick.bind(this),
                ...(k === 'status' && {
                    // color status
                    onRender: (record): React.ReactNode => (
                        <span className={`${record.status} commonStyle`}>{record.status}</span>
                    )
                }),
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
                ...(k === 'message' && {
                    onRender: (record): React.ReactNode =>
                        record.message.length > 15 ? (
                            <TooltipHost
                                content={record.message}
                                directionalHint={DirectionalHint.bottomCenter}
                                tooltipProps={{
                                    calloutProps: {
                                        styles: {
                                            beak: { background: TOOLTIP_BACKGROUND_COLOR },
                                            beakCurtain: { background: TOOLTIP_BACKGROUND_COLOR },
                                            calloutMain: { background: TOOLTIP_BACKGROUND_COLOR }
                                        }
                                    }
                                }}
                            >
                                <div>{record.message}</div>
                            </TooltipHost>
                        ) : (
                            <div>{record.message}</div>
                        )
                }),
325
326
327
                ...((k.startsWith('metric/') || k.startsWith('space/')) && {
                    // show tooltip
                    onRender: (record): React.ReactNode => (
328
329
330
331
332
333
334
335
336
337
338
339
340
                        <TooltipHost
                            content={record[k]}
                            directionalHint={DirectionalHint.bottomCenter}
                            tooltipProps={{
                                calloutProps: {
                                    styles: {
                                        beak: { background: TOOLTIP_BACKGROUND_COLOR },
                                        beakCurtain: { background: TOOLTIP_BACKGROUND_COLOR },
                                        calloutMain: { background: TOOLTIP_BACKGROUND_COLOR }
                                    }
                                }
                            }}
                        >
341
342
343
344
345
346
347
                            <div className='ellipsis'>{record[k]}</div>
                        </TooltipHost>
                    )
                }),
                ...(k === 'latestAccuracy' && {
                    // FIXME: this is ad-hoc
                    onRender: (record): React.ReactNode => (
348
349
350
351
352
353
354
355
356
357
358
359
360
                        <TooltipHost
                            content={record._formattedLatestAccuracy}
                            directionalHint={DirectionalHint.bottomCenter}
                            tooltipProps={{
                                calloutProps: {
                                    styles: {
                                        beak: { background: TOOLTIP_BACKGROUND_COLOR },
                                        beakCurtain: { background: TOOLTIP_BACKGROUND_COLOR },
                                        calloutMain: { background: TOOLTIP_BACKGROUND_COLOR }
                                    }
                                }
                            }}
                        >
361
362
363
364
365
366
367
368
369
370
371
                            <div className='ellipsis'>{record._formattedLatestAccuracy}</div>
                        </TooltipHost>
                    )
                }),
                ...(['startTime', 'endTime'].includes(k) && {
                    onRender: (record): React.ReactNode => <span>{formatTimestamp(record[k], '--')}</span>
                }),
                ...(k === 'duration' && {
                    onRender: (record): React.ReactNode => (
                        <span className='durationsty'>{convertDuration(record[k])}</span>
                    )
372
373
374
375
376
377
378
379
                }),
                ...(k === 'id' && {
                    onRender: (record): React.ReactNode => (
                        <Stack horizontal className='idCopy'>
                            <div>{record.id}</div>
                            <CopyButton value={record.id} />
                        </Stack>
                    )
380
381
                })
            });
382
        }
383
384
385
386
387
        // operations column
        columns.push({
            name: 'Operation',
            key: '_operation',
            fieldName: 'operation',
Lijiaoa's avatar
Lijiaoa committed
388
389
            minWidth: 150,
            maxWidth: 160,
390
391
392
393
            isResizable: true,
            className: 'detail-table',
            onRender: this._renderOperationColumn.bind(this)
        });
394

395
396
397
398
399
400
401
402
403
        const { sortInfo } = this.state;
        for (const column of columns) {
            if (column.key === sortInfo.field) {
                column.isSorted = true;
                column.isSortedDescending = sortInfo.isDescend;
            } else {
                column.isSorted = false;
                column.isSortedDescending = true;
            }
404
        }
405
        return columns;
406
    }
407

408
409
    private _updateTableSource(): void {
        // call this method when trials or the computation of trial filter has changed
410
411
412
413
414
        const { searchItems, relation } = this.state;
        let items = this._trialsToTableItems(this.props.tableSource);
        if (searchItems.length > 0) {
            items = getTrialsBySearchFilters(items, searchItems, relation); // use search filter to filter data
        }
415
416
417
418
419
420
421
422
423
424
        if (items.length > 0) {
            const columns = this._buildColumnsFromTableItems(items);
            this.setState({
                displayedItems: items,
                columns: columns
            });
        } else {
            this.setState({
                displayedItems: [],
                columns: []
425
            });
426
        }
427
    }
428

429
    private _updateDisplayedColumns(displayedColumns: string[]): void {
430
        this.setState({
431
            displayedColumns: displayedColumns
432
433
        });
    }
434

435
436
    private _renderOperationColumn(record: any): React.ReactNode {
        const runningTrial: boolean = ['RUNNING', 'UNKNOWN'].includes(record.status) ? false : true;
437
        const disabledAddCustomizedTrial = ['DONE', 'ERROR', 'STOPPED', 'VIEWED'].includes(EXPERIMENT.status);
438
439
440
441
442
443
444
445
        return (
            <Stack className='detail-button' horizontal>
                <PrimaryButton
                    className='detail-button-operation'
                    title='Intermediate'
                    onClick={(): void => {
                        const { tableSource } = this.props;
                        const trial = tableSource.find(trial => trial.id === record.id) as TableObj;
446
447
448
449
450
                        const intermediateKeyListResult = this.getIntermediateAllKeys(trial);
                        this.setState({
                            intermediateDialogTrial: trial,
                            intermediateKeyList: intermediateKeyListResult
                        });
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
                    }}
                >
                    {LineChart}
                </PrimaryButton>
                {runningTrial ? (
                    <PrimaryButton className='detail-button-operation' disabled={true} title='kill'>
                        {blocked}
                    </PrimaryButton>
                ) : (
                    <KillJob trial={record} />
                )}
                <PrimaryButton
                    className='detail-button-operation'
                    title='Customized trial'
                    onClick={(): void => {
                        this.setState({ copiedTrialId: record.id });
                    }}
                    disabled={disabledAddCustomizedTrial}
                >
                    {copy}
                </PrimaryButton>
            </Stack>
473
        );
474
    }
475

476
477
478
479
480
481
    private changeSearchFilterList = (arr: Array<SearchItems>): void => {
        this.setState(() => ({
            searchItems: arr
        }));
    };

482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
    private getIntermediateAllKeys = (intermediateDialogTrial: any): string[] => {
        let intermediateAllKeysList: string[] = [];
        if (
            intermediateDialogTrial!.intermediateMetrics !== undefined &&
            intermediateDialogTrial!.intermediateMetrics[0]
        ) {
            const parsedMetric = parseMetrics(intermediateDialogTrial!.intermediateMetrics[0].data);
            if (parsedMetric !== undefined && typeof parsedMetric === 'object') {
                const allIntermediateKeys: string[] = [];
                // just add type=number keys
                for (const key in parsedMetric) {
                    if (typeof parsedMetric[key] === 'number') {
                        allIntermediateKeys.push(key);
                    }
                }
                intermediateAllKeysList = allIntermediateKeys;
            }
        }

        if (intermediateAllKeysList.includes('default') && intermediateAllKeysList[0] !== 'default') {
            intermediateAllKeysList = intermediateAllKeysList.filter(item => item !== 'default');
            intermediateAllKeysList.unshift('default');
        }

        return intermediateAllKeysList;
    };

509
510
511
    componentDidUpdate(prevProps: TableListProps): void {
        if (this.props.tableSource !== prevProps.tableSource) {
            this._updateTableSource();
512
        }
513
514
515
516
517
    }

    componentDidMount(): void {
        this._updateTableSource();
    }
518

519
    render(): React.ReactNode {
520
        const {
521
522
523
524
525
526
527
            displayedItems,
            columns,
            customizeColumnsDialogVisible,
            compareDialogVisible,
            displayedColumns,
            selectedRowIds,
            intermediateDialogTrial,
528
            copiedTrialId,
529
530
            searchItems,
            intermediateKeyList
531
        } = this.state;
532

Lijiao's avatar
Lijiao committed
533
        return (
534
535
536
537
538
539
            <div id='tableList'>
                <Stack horizontal className='panelTitle' style={{ marginTop: 10 }}>
                    <span style={{ marginRight: 12 }}>{tableListIcon}</span>
                    <span>Trial jobs</span>
                </Stack>
                <Stack horizontal className='allList'>
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
                    <StackItem>
                        <Stack horizontal horizontalAlign='end' className='allList'>
                            <Search
                                searchFilter={searchItems} // search filter list
                                changeSearchFilterList={this.changeSearchFilterList}
                                updatePage={this.props.updateDetailPage}
                            />
                        </Stack>
                    </StackItem>

                    <StackItem styles={{ root: { position: 'absolute', right: '0' } }}>
                        <DefaultButton
                            className='allList-button-gap'
                            text='Add/Remove columns'
                            onClick={(): void => {
                                this.setState({ customizeColumnsDialogVisible: true });
                            }}
                        />
558
559
560
561
562
563
564
                        <DefaultButton
                            text='Compare'
                            className='allList-compare'
                            onClick={(): void => {
                                this.setState({ compareDialogVisible: true });
                            }}
                            disabled={selectedRowIds.length === 0}
565
                        />
566
567
568
569
                        <TensorboardUI
                            selectedRowIds={selectedRowIds}
                            changeSelectTrialIds={this.changeSelectTrialIds}
                        />
570
571
572
573
574
575
                    </StackItem>
                </Stack>
                {columns && displayedItems && (
                    <PaginationTable
                        columns={columns.filter(
                            column =>
576
577
                                displayedColumns.includes(column.key) ||
                                ['_expand', '_operation', '_selected'].includes(column.key)
578
579
580
                        )}
                        items={displayedItems}
                        compact={true}
581
                        selectionMode={0}
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
                        selectionPreservedOnEmptyClick={true}
                        onRenderRow={(props): any => {
                            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                            return <ExpandableDetails detailsProps={props!} isExpand={props!.item._expandDetails} />;
                        }}
                    />
                )}
                {compareDialogVisible && (
                    <Compare
                        title='Compare trials'
                        showDetails={true}
                        trials={this.props.tableSource.filter(trial => selectedRowIds.includes(trial.id))}
                        onHideDialog={(): void => {
                            this.setState({ compareDialogVisible: false });
                        }}
597
                        changeSelectTrialIds={this.changeSelectTrialIds}
598
599
600
601
602
603
604
                    />
                )}
                {intermediateDialogTrial !== undefined && (
                    <Compare
                        title='Intermediate results'
                        showDetails={false}
                        trials={[intermediateDialogTrial]}
605
                        intermediateKeyList={intermediateKeyList}
606
607
608
609
610
611
                        onHideDialog={(): void => {
                            this.setState({ intermediateDialogTrial: undefined });
                        }}
                    />
                )}
                {customizeColumnsDialogVisible && (
612
                    <ChangeColumnComponent
613
614
615
616
617
618
619
620
                        selectedColumns={displayedColumns}
                        allColumns={columns
                            .filter(column => !column.key.startsWith('_'))
                            .map(column => ({ key: column.key, name: column.name }))}
                        onSelectedChange={this._updateDisplayedColumns.bind(this)}
                        onHideDialog={(): void => {
                            this.setState({ customizeColumnsDialogVisible: false });
                        }}
621
                        whichComponent='table'
622
                    />
623
                )}
624
625
                {/* Clone a trial and customize a set of new parameters */}
                {/* visible is done inside because prompt is needed even when the dialog is closed */}
626
                <Customize
627
628
629
630
631
                    visible={copiedTrialId !== undefined}
                    copyTrialId={copiedTrialId || ''}
                    closeCustomizeModal={(): void => {
                        this.setState({ copiedTrialId: undefined });
                    }}
632
                />
633
            </div>
Lijiao's avatar
Lijiao committed
634
635
636
637
        );
    }
}

638
export default TableList;