Unverified Commit 683c458a authored by Lijiaoa's avatar Lijiaoa Committed by GitHub
Browse files

Multiple Experiments Management UX (#3127)



* first update

* add click event

* add manager exp nav

* first demo, fix some comments

* use api to dev

* refactor code

* fix lint

* fix test met issue and adjust column width

* add /experiments-info  error status

* no declare platform list, get this from api result filter

* fix compare tooltip issue

* fix tooltip location

* fix some comments

* delete datestring

* fix clickable style and clickable column ID rather than name
Co-authored-by: default avatarLijiao <Lijiaoa@outlook.com>
parent af198888
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
.headerCon { .headerCon {
width: 90%; width: 90%;
min-width: 1200px;
max-width: 1490px; max-width: 1490px;
margin: 0 auto; margin: 0 auto;
} }
...@@ -28,6 +29,7 @@ ...@@ -28,6 +29,7 @@
.content { .content {
width: 87%; width: 87%;
min-height: calc(100vh - 56);
margin: 0 auto; margin: 0 auto;
min-width: 1200px; min-width: 1200px;
max-width: 1490px; max-width: 1490px;
......
...@@ -2,6 +2,7 @@ import * as React from 'react'; ...@@ -2,6 +2,7 @@ import * as React from 'react';
import { Stack } from '@fluentui/react'; import { Stack } from '@fluentui/react';
import { COLUMN } from './static/const'; import { COLUMN } from './static/const';
import { EXPERIMENT, TRIALS } from './static/datamodel'; import { EXPERIMENT, TRIALS } from './static/datamodel';
import { isManagerExperimentPage } from './static/function';
import NavCon from './components/NavCon'; import NavCon from './components/NavCon';
import MessageInfo from './components/modals/MessageInfo'; import MessageInfo from './components/modals/MessageInfo';
import { SlideNavBtns } from './components/slideNav/SlideNavBtns'; import { SlideNavBtns } from './components/slideNav/SlideNavBtns';
...@@ -29,15 +30,15 @@ export const AppContext = React.createContext({ ...@@ -29,15 +30,15 @@ export const AppContext = React.createContext({
metricGraphMode: 'max', metricGraphMode: 'max',
bestTrialEntries: '10', bestTrialEntries: '10',
maxDurationUnit: 'm', maxDurationUnit: 'm',
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-empty-function
changeColumn: (val: string[]) => {}, changeColumn: (_val: string[]): void => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-empty-function
changeMetricGraphMode: (val: 'max' | 'min') => {}, changeMetricGraphMode: (_val: 'max' | 'min'): void => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-empty-function
changeMaxDurationUnit: (val: string) => {}, changeMaxDurationUnit: (_val: string): void => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-empty-function
changeEntries: (val: string) => {}, changeEntries: (_val: string): void => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-empty-function
updateOverviewPage: () => {} updateOverviewPage: () => {}
}); });
...@@ -139,69 +140,57 @@ class App extends React.Component<{}, AppState> { ...@@ -139,69 +140,57 @@ class App extends React.Component<{}, AppState> {
{ errorWhere: TRIALS.latestMetricDataError(), errorMessage: TRIALS.getLatestMetricDataErrorMessage() }, { errorWhere: TRIALS.latestMetricDataError(), errorMessage: TRIALS.getLatestMetricDataErrorMessage() },
{ errorWhere: TRIALS.metricDataRangeError(), errorMessage: TRIALS.metricDataRangeErrorMessage() } { errorWhere: TRIALS.metricDataRangeError(), errorMessage: TRIALS.metricDataRangeErrorMessage() }
]; ];
return ( return (
<Stack className='nni' style={{ minHeight: window.innerHeight }}> <React.Fragment>
<div className='header'> {isManagerExperimentPage() ? null : (
<div className='headerCon'> <Stack className='nni' style={{ minHeight: window.innerHeight }}>
<NavCon changeInterval={this.changeInterval} refreshFunction={this.lastRefresh} /> <div className='header'>
</div> <div className='headerCon'>
</div> <NavCon changeInterval={this.changeInterval} refreshFunction={this.lastRefresh} />
<Stack className='contentBox'>
<Stack className='content'>
{/* search space & config */}
<AppContext.Provider
value={{
interval,
columnList,
changeColumn: this.changeColumn,
experimentUpdateBroadcast,
trialsUpdateBroadcast,
metricGraphMode,
maxDurationUnit,
changeMaxDurationUnit: this.changeMaxDurationUnit,
changeMetricGraphMode: this.changeMetricGraphMode,
bestTrialEntries,
changeEntries: this.changeEntries,
updateOverviewPage: this.updateOverviewPage
}}
>
<SlideNavBtns />
</AppContext.Provider>
{/* if api has error field, show error message */}
{errorList.map(
(item, key) =>
item.errorWhere && (
<div key={key} className='warning'>
<MessageInfo info={item.errorMessage} typeInfo='error' />
</div>
)
)}
{isillegalFinal && (
<div className='warning'>
<MessageInfo info={expWarningMessage} typeInfo='warning' />
</div> </div>
)} </div>
<AppContext.Provider <Stack className='contentBox'>
value={{ <Stack className='content'>
interval, {/* search space & config */}
columnList, <SlideNavBtns />
changeColumn: this.changeColumn, {/* if api has error field, show error message */}
experimentUpdateBroadcast, {errorList.map(
trialsUpdateBroadcast, (item, key) =>
metricGraphMode, item.errorWhere && (
maxDurationUnit, <div key={key} className='warning'>
changeMaxDurationUnit: this.changeMaxDurationUnit, <MessageInfo info={item.errorMessage} typeInfo='error' />
changeMetricGraphMode: this.changeMetricGraphMode, </div>
bestTrialEntries, )
changeEntries: this.changeEntries, )}
updateOverviewPage: this.updateOverviewPage {isillegalFinal && (
}} <div className='warning'>
> <MessageInfo info={expWarningMessage} typeInfo='warning' />
{this.props.children} </div>
</AppContext.Provider> )}
<AppContext.Provider
value={{
interval,
columnList,
changeColumn: this.changeColumn,
experimentUpdateBroadcast,
trialsUpdateBroadcast,
metricGraphMode,
maxDurationUnit,
changeMaxDurationUnit: this.changeMaxDurationUnit,
changeMetricGraphMode: this.changeMetricGraphMode,
bestTrialEntries,
changeEntries: this.changeEntries,
updateOverviewPage: this.updateOverviewPage
}}
>
{this.props.children}
</AppContext.Provider>
</Stack>
</Stack>
</Stack> </Stack>
</Stack> )}
</Stack> </React.Fragment>
); );
} }
......
import * as React from 'react'; import * as React from 'react';
import axios from 'axios'; import axios from 'axios';
import { WEBUIDOC, MANAGER_IP } from '../static/const'; import { WEBUIDOC, MANAGER_IP } from '../static/const';
import { import { Stack, StackItem, CommandBarButton, IContextualMenuProps } from '@fluentui/react';
Stack, import { Link } from 'react-router-dom';
initializeIcons, import { infoIconAbout, timeIcon, disableUpdates, requency, closeTimer, ChevronRightMed } from './buttons/Icon';
StackItem,
CommandBarButton,
IContextualMenuProps,
IStackTokens,
IStackStyles
} from '@fluentui/react';
import ExperimentSummaryPanel from './modals/ExperimentSummaryPanel'; import ExperimentSummaryPanel from './modals/ExperimentSummaryPanel';
import { infoIconAbout, timeIcon, disableUpdates, requency, closeTimer } from './buttons/Icon';
import { OVERVIEWTABS, DETAILTABS, NNILOGO } from './stateless-component/NNItabs'; import { OVERVIEWTABS, DETAILTABS, NNILOGO } from './stateless-component/NNItabs';
import { EXPERIMENT } from '../static/datamodel'; import { EXPERIMENT } from '../static/datamodel';
import { stackTokens, stackStyle } from './NavConst';
import '../static/style/nav/nav.scss'; import '../static/style/nav/nav.scss';
import '../static/style/icon.scss'; import '../static/style/icon.scss';
initializeIcons();
const stackTokens: IStackTokens = {
childrenGap: 15
};
const stackStyle: IStackStyles = {
root: {
minWidth: 400,
height: 56,
display: 'flex',
verticalAlign: 'center'
}
};
interface NavState { interface NavState {
version: string; version: string;
menuVisible: boolean; menuVisible: boolean;
...@@ -133,42 +114,50 @@ class NavCon extends React.Component<NavProps, NavState> { ...@@ -133,42 +114,50 @@ class NavCon extends React.Component<NavProps, NavState> {
}; };
return ( return (
<Stack horizontal className='nav'> <Stack horizontal className='nav'>
<StackItem grow={30} styles={{ root: { minWidth: 300, display: 'flex', verticalAlign: 'center' } }}> <React.Fragment>
<span className='desktop-logo'>{NNILOGO}</span> <StackItem grow={30} styles={{ root: { minWidth: 300, display: 'flex', verticalAlign: 'center' } }}>
<span className='left-right-margin'>{OVERVIEWTABS}</span> <span className='desktop-logo'>{NNILOGO}</span>
<span>{DETAILTABS}</span> <span className='left-right-margin'>{OVERVIEWTABS}</span>
</StackItem> <span>{DETAILTABS}</span>
<StackItem grow={70} className='navOptions'> </StackItem>
<Stack horizontal horizontalAlign='end' tokens={stackTokens} styles={stackStyle}> <StackItem grow={70} className='navOptions'>
{/* refresh button danyi*/} <Stack horizontal horizontalAlign='end' tokens={stackTokens} styles={stackStyle}>
{/* TODO: fix bug */} {/* refresh button danyi*/}
{/* <CommandBarButton {/* TODO: fix bug */}
iconProps={{ iconName: 'sync' }} {/* <CommandBarButton
text="Refresh" iconProps={{ iconName: 'sync' }}
onClick={this.props.refreshFunction} text="Refresh"
/> */} onClick={this.props.refreshFunction}
<div className='nav-refresh'> /> */}
<div className='nav-refresh'>
<CommandBarButton
iconProps={refreshFrequency === '' ? disableUpdates : timeIcon}
text={refreshText}
menuProps={this.refreshProps}
/>
<div className='nav-refresh-num'>{refreshFrequency}</div>
</div>
<CommandBarButton <CommandBarButton
iconProps={refreshFrequency === '' ? disableUpdates : timeIcon} iconProps={{ iconName: 'ShowResults' }}
text={refreshText} text='Experiment summary'
menuProps={this.refreshProps} onClick={this.showExpcontent}
/> />
<div className='nav-refresh-num'>{refreshFrequency}</div> <CommandBarButton iconProps={infoIconAbout} text='About' menuProps={aboutProps} />
</div> <Link to='/experiment' className='experiment'>
<CommandBarButton <div className='expNavTitle'>
iconProps={{ iconName: 'ShowResults' }} <span>All experiment</span>
text='Summary' {ChevronRightMed}
onClick={this.showExpcontent} </div>
</Link>
</Stack>
</StackItem>
{isvisibleExperimentDrawer && (
<ExperimentSummaryPanel
closeExpDrawer={this.closeExpDrawer}
experimentProfile={EXPERIMENT.profile}
/> />
<CommandBarButton iconProps={infoIconAbout} text='About' menuProps={aboutProps} /> )}
</Stack> </React.Fragment>
</StackItem>
{isvisibleExperimentDrawer && (
<ExperimentSummaryPanel
closeExpDrawer={this.closeExpDrawer}
experimentProfile={EXPERIMENT.profile}
/>
)}
</Stack> </Stack>
); );
} }
......
import { IStackTokens, IStackStyles } from '@fluentui/react';
const stackTokens: IStackTokens = {
childrenGap: 15
};
const stackStyle: IStackStyles = {
root: {
minWidth: 400,
height: 56,
display: 'flex',
verticalAlign: 'center'
}
};
export { stackTokens, stackStyle };
...@@ -6,7 +6,7 @@ import { AppContext } from '../App'; ...@@ -6,7 +6,7 @@ import { AppContext } from '../App';
import { Title } from './overview/Title'; import { Title } from './overview/Title';
import SuccessTable from './overview/table/SuccessTable'; import SuccessTable from './overview/table/SuccessTable';
import Accuracy from './overview/Accuracy'; import Accuracy from './overview/Accuracy';
import { ReBasicInfo } from './overview/experiment/BasicInfo'; import { BasicInfo } from './overview/params/BasicInfo';
import { ExpDuration } from './overview/count/ExpDuration'; import { ExpDuration } from './overview/count/ExpDuration';
import { ExpDurationContext } from './overview/count/ExpDurationContext'; import { ExpDurationContext } from './overview/count/ExpDurationContext';
import { TrialCount } from './overview/count/TrialCount'; import { TrialCount } from './overview/count/TrialCount';
...@@ -86,7 +86,7 @@ class Overview extends React.Component<{}, OverviewState> { ...@@ -86,7 +86,7 @@ class Overview extends React.Component<{}, OverviewState> {
<Title /> <Title />
</TitleContext.Provider> </TitleContext.Provider>
<BestMetricContext.Provider value={{ bestAccuracy: bestAccuracy }}> <BestMetricContext.Provider value={{ bestAccuracy: bestAccuracy }}>
<ReBasicInfo /> <BasicInfo />
</BestMetricContext.Provider> </BestMetricContext.Provider>
</div> </div>
{/* duration & trial numbers */} {/* duration & trial numbers */}
......
...@@ -19,6 +19,9 @@ const LineChart = <Icon iconName='LineChart' />; ...@@ -19,6 +19,9 @@ const LineChart = <Icon iconName='LineChart' />;
const Edit = <Icon iconName='Edit' />; const Edit = <Icon iconName='Edit' />;
const CheckMark = <Icon iconName='CheckMark' />; const CheckMark = <Icon iconName='CheckMark' />;
const Cancel = <Icon iconName='Cancel' />; const Cancel = <Icon iconName='Cancel' />;
const ReplyAll = { iconName: 'ReplyAll' };
const RevToggleKey = { iconName: 'RevToggleKey' };
const ChevronRightMed = <Icon iconName='ChevronRightMed' />;
export { export {
infoIcon, infoIcon,
...@@ -37,5 +40,8 @@ export { ...@@ -37,5 +40,8 @@ export {
LineChart, LineChart,
Edit, Edit,
CheckMark, CheckMark,
Cancel Cancel,
ReplyAll,
RevToggleKey,
ChevronRightMed
}; };
import * as React from 'react';
import { Stack, DetailsList, DefaultButton, Icon, SearchBox, IColumn } from '@fluentui/react';
import { ExperimentsManager } from '../../static/model/experimentsManager';
import { expformatTimestamp, copyAndSort } from '../../static/function';
import { AllExperimentList, SortInfo } from '../../static/interface';
import MessageInfo from '../modals/MessageInfo';
import { compareDate, filterByStatusOrPlatform, getSortedSource } from './expFunction';
import { MAXSCREENCOLUMNWIDHT, MINSCREENCOLUMNWIDHT } from './experimentConst';
import { Hearder } from './Header';
import NameColumn from './TrialIdColumn';
import FilterBtns from './FilterBtns';
import '../../App.scss';
import '../../static/style/nav/nav.scss';
import '../../static/style/experiment/experiment.scss';
import '../../static/style/overview/probar.scss';
import '../../static/style/tableStatus.css';
interface ExpListState {
columns: IColumn[];
platform: string[];
errorMessage: string;
hideFilter: boolean;
searchInputVal: string;
selectedStatus: string;
selectedPlatform: string;
selectedStartDate?: Date;
selectedEndDate?: Date;
sortInfo: SortInfo;
source: AllExperimentList[];
originExperimentList: AllExperimentList[];
searchSource: AllExperimentList[];
}
class Experiment extends React.Component<{}, ExpListState> {
constructor(props) {
super(props);
this.state = {
platform: [],
columns: this.columns,
errorMessage: '',
hideFilter: true,
searchInputVal: '',
selectedStatus: '',
selectedPlatform: '',
source: [], // data in table
originExperimentList: [], // api /experiments-info
searchSource: [], // search box search result
sortInfo: { field: '', isDescend: false }
};
}
async componentDidMount(): Promise<void> {
const EXPERIMENTMANAGER = new ExperimentsManager();
await EXPERIMENTMANAGER.init();
const result = EXPERIMENTMANAGER.getExperimentList();
this.setState(() => ({
source: result,
originExperimentList: result,
searchSource: result,
platform: EXPERIMENTMANAGER.getPlatformList(),
errorMessage: EXPERIMENTMANAGER.getExpErrorMessage()
}));
}
render(): React.ReactNode {
const {
platform,
hideFilter,
selectedStatus,
source,
selectedPlatform,
selectedStartDate,
selectedEndDate,
errorMessage
} = this.state;
return (
<Stack className='nni' style={{ minHeight: window.innerHeight }}>
<Hearder />
{errorMessage !== undefined ? (
<div className='warning'>
<MessageInfo info={errorMessage} typeInfo='error' />
</div>
) : null}
<Stack className='contentBox expBackground'>
<Stack className='content'>
<Stack className='experimentList'>
<Stack className='box' horizontal>
<div className='search'>
<SearchBox
className='search-input'
placeholder='Search the experiment by name and ID'
onEscape={this.setOriginSource.bind(this)}
onClear={this.setOriginSource.bind(this)}
onChange={this.searchNameAndId.bind(this)}
/>
</div>
<div className='filter'>
<DefaultButton
onClick={this.clickFilter.bind(this)}
className={`${!hideFilter ? 'filter-button-open' : null}`}
>
<Icon iconName='Equalizer' />
<span className='margin'>Filter</span>
</DefaultButton>
</div>
</Stack>
<Stack className={`${hideFilter ? 'hidden' : ''} filter-condition`} horizontal gap={25}>
<FilterBtns
platform={platform}
selectedStatus={selectedStatus}
selectedPlatform={selectedPlatform}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
selectedStartDate={selectedStartDate!}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
selectedEndDate={selectedEndDate!}
selectStatus={this.selectStatus.bind(this)}
selectPlatform={this.selectPlatform.bind(this)}
getSelectedData={this.getSelectedData.bind(this)}
setSearchSource={this.setSearchSource.bind(this)}
/>
</Stack>
<DetailsList
columns={this.columns}
items={source}
setKey='set'
compact={true}
selectionMode={0} // close selector function
className='table'
/>
</Stack>
</Stack>
</Stack>
</Stack>
);
}
private onColumnClick = (_ev: React.MouseEvent<HTMLElement>, getColumn: IColumn): void => {
const { columns, source } = this.state;
const newColumns: IColumn[] = columns.slice();
const currColumn: IColumn = newColumns.filter(item => getColumn.key === item.key)[0];
newColumns.forEach((newCol: IColumn) => {
if (newCol === currColumn) {
currColumn.isSortedDescending = !currColumn.isSortedDescending;
currColumn.isSorted = true;
} else {
newCol.isSorted = false;
newCol.isSortedDescending = true;
}
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newItems = copyAndSort(source, currColumn.fieldName!, currColumn.isSortedDescending);
this.setState(() => ({
columns: newColumns,
source: newItems,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sortInfo: { field: currColumn.fieldName!, isDescend: currColumn.isSortedDescending }
}));
};
private columns: IColumn[] = [
{
name: 'Name',
key: 'experimentName',
fieldName: 'experimentName', // required!
minWidth: MINSCREENCOLUMNWIDHT,
maxWidth: MAXSCREENCOLUMNWIDHT,
isResizable: true,
data: 'number',
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => <div className='succeed-padding'>{item.experimentName}</div>
},
{
name: 'ID',
key: 'id',
fieldName: 'id',
minWidth: MINSCREENCOLUMNWIDHT,
maxWidth: MAXSCREENCOLUMNWIDHT,
isResizable: true,
className: 'tableHead leftTitle',
data: 'string',
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => <NameColumn port={item.port} status={item.status} id={item.id} />
},
{
name: 'Status',
key: 'status',
fieldName: 'status',
minWidth: MINSCREENCOLUMNWIDHT,
maxWidth: MAXSCREENCOLUMNWIDHT,
isResizable: true,
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => (
<div className={`${item.status} commonStyle succeed-padding`}>{item.status}</div>
)
},
{
name: 'Port',
key: 'port',
fieldName: 'port',
minWidth: MINSCREENCOLUMNWIDHT - 15,
maxWidth: MAXSCREENCOLUMNWIDHT - 30,
isResizable: true,
data: 'number',
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => (
<div className='succeed-padding'>
<div>{item.port !== undefined ? item.port : '--'}</div>
</div>
)
},
{
name: 'Platform',
key: 'platform',
fieldName: 'platform',
minWidth: MINSCREENCOLUMNWIDHT - 15,
maxWidth: MAXSCREENCOLUMNWIDHT - 30,
isResizable: true,
data: 'string',
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => <div className='commonStyle succeed-padding'>{item.platform}</div>
},
{
name: 'Start time',
key: 'startTime',
fieldName: 'startTime',
minWidth: MINSCREENCOLUMNWIDHT + 15,
maxWidth: MAXSCREENCOLUMNWIDHT + 30,
isResizable: true,
data: 'number',
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => (
<div className='succeed-padding'>
<div>{expformatTimestamp(item.startTime)}</div>
</div>
)
},
{
name: 'End time',
key: 'endTime',
fieldName: 'endTime',
minWidth: MINSCREENCOLUMNWIDHT + 15,
maxWidth: MAXSCREENCOLUMNWIDHT + 30,
isResizable: true,
data: 'number',
onColumnClick: this.onColumnClick,
onRender: (item: any): React.ReactNode => (
<div className='succeed-padding'>
<div>{expformatTimestamp(item.endTime)}</div>
</div>
)
}
];
private clickFilter(_e: any): void {
const { hideFilter } = this.state;
if (!hideFilter === true) {
this.setSearchSource();
}
this.setState(() => ({ hideFilter: !hideFilter }));
}
private setOriginSource(): void {
let { originExperimentList } = this.state;
const { sortInfo } = this.state;
if (originExperimentList !== undefined) {
originExperimentList = this.commonSelectString(originExperimentList, '');
const sortedData = getSortedSource(originExperimentList, sortInfo);
this.setState(() => ({
source: sortedData
}));
}
}
private searchNameAndId(_event, newValue): void {
const { originExperimentList, sortInfo } = this.state;
if (newValue !== undefined) {
if (newValue === '') {
this.setOriginSource();
} else {
let result = originExperimentList.filter(
item =>
item.experimentName.toLowerCase().includes(newValue.toLowerCase()) ||
item.id.toLowerCase().includes(newValue.toLowerCase())
);
result = this.commonSelectString(result, '');
const sortedResult = getSortedSource(result, sortInfo);
this.setState(() => ({
source: sortedResult,
searchSource: sortedResult
}));
}
this.setState(() => ({
searchInputVal: newValue
}));
}
}
/***
* status, platform
* param
* data: searchSource
* field: no care selected filed
*/
private commonSelectString = (data: AllExperimentList[], field: string): AllExperimentList[] => {
const { selectedStatus, selectedPlatform, selectedStartDate, selectedEndDate } = this.state;
const hasStatus = selectedStatus === '' ? false : true;
const hasPlatform = selectedPlatform === '' ? false : true;
const hasStartDate = selectedStartDate === undefined ? false : true;
const hasEndDate = selectedEndDate === undefined ? false : true;
if (field === 'status') {
if (hasPlatform) {
data = filterByStatusOrPlatform(selectedPlatform, 'platform', data);
}
}
if (field === 'platform') {
if (hasStatus) {
data = filterByStatusOrPlatform(selectedStatus, 'status', data);
}
}
if (field === '') {
if (hasPlatform) {
data = filterByStatusOrPlatform(selectedPlatform, 'platform', data);
}
if (hasStatus) {
data = filterByStatusOrPlatform(selectedStatus, 'status', data);
}
}
if (hasStartDate) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data = data.filter(temp => compareDate(new Date(temp.startTime), selectedStartDate!));
}
if (hasEndDate) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data = data.filter(temp => compareDate(new Date(temp.endTime), selectedEndDate!));
}
return data;
};
// status platform startTime endTime
private selectStatus = (_event: React.FormEvent<HTMLDivElement>, item: any): void => {
if (item !== undefined) {
const { searchSource, sortInfo } = this.state;
let result = filterByStatusOrPlatform(item.key, 'status', searchSource);
result = this.commonSelectString(result, 'status');
this.setState({ selectedStatus: item.key, source: getSortedSource(result, sortInfo) });
}
};
private selectPlatform = (_event: React.FormEvent<HTMLDivElement>, item: any): void => {
if (item !== undefined) {
const { searchSource, sortInfo } = this.state;
let result = filterByStatusOrPlatform(item.key, 'platform', searchSource);
result = this.commonSelectString(result, 'platform');
this.setState({ selectedPlatform: item.key, source: getSortedSource(result, sortInfo) });
}
};
private getSelectedData(type: string, date: Date | null | undefined): void {
if (date !== null && date !== undefined) {
const {
selectedStatus,
selectedPlatform,
selectedStartDate,
selectedEndDate,
searchSource,
sortInfo
} = this.state;
const hasStatus = selectedStatus === '' ? false : true;
const hasPlatform = selectedPlatform === '' ? false : true;
const hasStartDate = selectedStartDate === undefined ? false : true;
const hasEndDate = selectedEndDate === undefined ? false : true;
let result;
if (type === 'start') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result = searchSource.filter(item => compareDate(new Date(item.startTime), date));
if (hasStatus) {
result = result.filter(temp => temp.status === selectedStatus);
}
if (hasPlatform) {
result = result.filter(temp => temp.platform === selectedPlatform);
}
if (hasEndDate) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result = result.filter(temp => compareDate(new Date(temp.endTime), selectedEndDate!));
}
this.setState(() => ({
source: getSortedSource(result, sortInfo),
selectedStartDate: date
}));
} else {
result = searchSource.filter(item => compareDate(new Date(item.endTime), date));
if (hasStatus) {
result = result.filter(temp => temp.status === selectedStatus);
}
if (hasPlatform) {
result = result.filter(temp => temp.platform === selectedPlatform);
}
if (hasStartDate) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result = result.filter(temp => compareDate(new Date(temp.startTime), selectedStartDate!));
}
this.setState(() => ({
source: getSortedSource(result, sortInfo),
selectedEndDate: date
}));
}
}
}
// reset
private setSearchSource(): void {
const { sortInfo, searchInputVal, originExperimentList } = this.state;
// hert re-search data for fix this status: filter first -> searchBox search result null -> close filter
const result = originExperimentList.filter(
item =>
item.experimentName.toLowerCase().includes(searchInputVal.toLowerCase()) ||
item.id.toLowerCase().includes(searchInputVal.toLowerCase())
);
this.setState(() => ({
source: getSortedSource(result, sortInfo),
selectedStatus: '',
selectedPlatform: '',
selectedStartDate: undefined,
selectedEndDate: undefined
}));
}
}
export default Experiment;
import * as React from 'react';
import { DefaultButton, Icon, Dropdown, DatePicker, DayOfWeek } from '@fluentui/react';
import { EXPERIMENTSTATUS } from '../../static/const';
import { fillOptions } from './expFunction';
interface FilterBtnsProps {
platform: string[];
selectedStatus: string;
selectedPlatform: string;
selectedStartDate: Date;
selectedEndDate: Date;
selectStatus: (_event: React.FormEvent<HTMLDivElement>, item: any) => void;
selectPlatform: (_event: React.FormEvent<HTMLDivElement>, item: any) => void;
getSelectedData: (type: string, date: Date | null | undefined) => void;
setSearchSource: () => void;
}
class FilterBtns extends React.Component<FilterBtnsProps, {}> {
constructor(props: FilterBtnsProps) {
super(props);
}
render(): React.ReactNode {
const {
platform,
selectedStatus,
selectedPlatform,
selectedStartDate,
selectedEndDate,
selectStatus,
selectPlatform,
getSelectedData,
setSearchSource
} = this.props;
return (
<React.Fragment>
<Dropdown
label='Status'
selectedKey={selectedStatus}
onChange={selectStatus.bind(this)}
placeholder='Select an option'
options={fillOptions(EXPERIMENTSTATUS)}
className='filter-condition-status'
/>
<Dropdown
label='Platform'
selectedKey={selectedPlatform}
onChange={selectPlatform.bind(this)}
placeholder='Select an option'
options={fillOptions(platform)}
className='filter-condition-platform'
/>
<DatePicker
label='Start time'
firstDayOfWeek={DayOfWeek.Sunday}
showMonthPickerAsOverlay={true}
placeholder='Select a date...'
ariaLabel='Select a date'
value={selectedStartDate}
onSelectDate={getSelectedData.bind(this, 'start')}
/>
<DatePicker
label='End time'
firstDayOfWeek={DayOfWeek.Sunday}
showMonthPickerAsOverlay={true}
placeholder='Select a date...'
ariaLabel='Select a date'
value={selectedEndDate}
onSelectDate={getSelectedData.bind(this, 'end')}
/>
<DefaultButton onClick={setSearchSource.bind(this)} className='reset'>
<Icon iconName='Refresh' />
<span className='margin'>Reset</span>
</DefaultButton>
</React.Fragment>
);
}
}
export default FilterBtns;
import React from 'react';
import { Link } from 'react-router-dom';
import { Stack, StackItem, CommandBarButton } from '@fluentui/react';
import { RevToggleKey } from '../buttons/Icon';
import { NNILOGO } from '../stateless-component/NNItabs';
import { stackTokens, stackStyle } from '../NavConst';
export const Hearder = (): any => (
<div className='header'>
<div className='headerCon'>
<Stack className='nav' horizontal>
<StackItem grow={30} styles={{ root: { minWidth: 300, display: 'flex', verticalAlign: 'center' } }}>
<span className='desktop-logo'>{NNILOGO}</span>
<span className='logoTitle'>Neural Network Intelligence</span>
</StackItem>
<StackItem grow={70} className='navOptions'>
<Stack horizontal horizontalAlign='end' tokens={stackTokens} styles={stackStyle}>
<Link to='/oview' className='experiment'>
<CommandBarButton iconProps={RevToggleKey} text='Back to the experiment' />
</Link>
</Stack>
</StackItem>
</Stack>
</div>
</div>
);
import * as React from 'react';
interface TrialIdColumnProps {
port: number;
id: string;
status: string;
}
class TrialIdColumn extends React.Component<TrialIdColumnProps, {}> {
constructor(props: TrialIdColumnProps) {
super(props);
}
render(): React.ReactNode {
const { port, id, status } = this.props;
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const webuiPortal = `${protocol}//${hostname}:${port}/oview`;
return (
<div className='succeed-padding ellipsis'>
{status === 'STOPPED' ? (
<div>{id}</div>
) : (
<a href={webuiPortal} className='link' target='_blank' rel='noopener noreferrer'>
{id}
</a>
)}
</div>
);
}
}
export default TrialIdColumn;
import { AllExperimentList, SortInfo } from '../../static/interface';
import { copyAndSort } from '../../static/function';
function compareDate(date1: Date, date2: Date): boolean {
if (date1 !== undefined && date2 !== undefined) {
if (date1.getFullYear() === date2.getFullYear()) {
if (date1.getMonth() === date2.getMonth()) {
if (date1.getDate() === date2.getDate()) {
return true;
}
}
}
}
return false;
}
const filterByStatusOrPlatform = (val: string, type: string, data: AllExperimentList[]): AllExperimentList[] => {
return data.filter(temp => temp[type] === val);
};
function fillOptions(arr: string[]): any {
const list: Array<object> = [];
arr.map(item => {
list.push({ key: item, text: item });
});
return list;
}
function getSortedSource(source: AllExperimentList[], sortInfo: SortInfo): AllExperimentList[] {
const keepSortedSource = copyAndSort(source, sortInfo.field, sortInfo.isDescend);
return keepSortedSource;
}
export { compareDate, filterByStatusOrPlatform, fillOptions, getSortedSource };
const MAXSCREENCOLUMNWIDHT = 180;
const MINSCREENCOLUMNWIDHT = 139;
export { MAXSCREENCOLUMNWIDHT, MINSCREENCOLUMNWIDHT };
...@@ -58,16 +58,16 @@ class Compare extends React.Component<CompareProps, {}> { ...@@ -58,16 +58,16 @@ class Compare extends React.Component<CompareProps, {}> {
super(props); super(props);
} }
private _generateTooltipSummary(row: Item, metricKey: string): string { private _generateTooltipSummary = (row: Item, value: string): string =>
return renderToString( renderToString(
<div className='tooldetailAccuracy'> <div className='tooldetailAccuracy'>
<div>Trial No.: {row.sequenceId}</div>
<div>Trial ID: {row.id}</div> <div>Trial ID: {row.id}</div>
<div>Default metric: {row.metrics.get(metricKey) || 'N/A'}</div> <div>Intermediate metric: {value}</div>
</div> </div>
); );
}
private _intermediates(items: Item[], metricKey: string): React.ReactNode { private _intermediates(items: Item[]): React.ReactNode {
// Precondition: make sure `items` is not empty // Precondition: make sure `items` is not empty
const xAxisMax = Math.max(...items.map(item => item.intermediates.length)); const xAxisMax = Math.max(...items.map(item => item.intermediates.length));
const xAxis = Array(xAxisMax) const xAxis = Array(xAxisMax)
...@@ -84,7 +84,7 @@ class Compare extends React.Component<CompareProps, {}> { ...@@ -84,7 +84,7 @@ class Compare extends React.Component<CompareProps, {}> {
trigger: 'item', trigger: 'item',
enterable: true, enterable: true,
position: (point: number[], data: TooltipForIntermediate): [number, number] => { position: (point: number[], data: TooltipForIntermediate): [number, number] => {
if (data.dataIndex < length / 2) { if (data.dataIndex < xAxisMax / 2) {
return [point[0], 80]; return [point[0], 80];
} else { } else {
return [point[0] - 300, 80]; return [point[0] - 300, 80];
...@@ -92,7 +92,7 @@ class Compare extends React.Component<CompareProps, {}> { ...@@ -92,7 +92,7 @@ class Compare extends React.Component<CompareProps, {}> {
}, },
formatter: (data: TooltipForIntermediate): string => { formatter: (data: TooltipForIntermediate): string => {
const item = items.find(k => k.id === data.seriesName) as Item; const item = items.find(k => k.id === data.seriesName) as Item;
return this._generateTooltipSummary(item, metricKey); return this._generateTooltipSummary(item, data.data);
} }
}, },
grid: { grid: {
...@@ -187,9 +187,13 @@ class Compare extends React.Component<CompareProps, {}> { ...@@ -187,9 +187,13 @@ class Compare extends React.Component<CompareProps, {}> {
{parameterKeys.map(k => {parameterKeys.map(k =>
this._renderRow(`space_${k}`, k, 'value', items, item => item.parameters.get(k)) this._renderRow(`space_${k}`, k, 'value', items, item => item.parameters.get(k))
)} )}
{metricKeys.map(k => {metricKeys !== undefined
this._renderRow(`metrics_${k}`, `Metric: ${k}`, 'value', items, item => item.metrics.get(k)) ? metricKeys.map(k =>
)} this._renderRow(`metrics_${k}`, `Metric: ${k}`, 'value', items, item =>
item.metrics.get(k)
)
)
: null}
</tbody> </tbody>
</table> </table>
); );
...@@ -209,8 +213,6 @@ class Compare extends React.Component<CompareProps, {}> { ...@@ -209,8 +213,6 @@ class Compare extends React.Component<CompareProps, {}> {
metrics: flatten(trial.metrics(TRIALS.inferredMetricSpace())), metrics: flatten(trial.metrics(TRIALS.inferredMetricSpace())),
intermediates: _parseIntermediates(trial) intermediates: _parseIntermediates(trial)
})); }));
const metricKeys = this._overlapKeys(items.map(item => item.metrics));
const defaultMetricKey = !metricKeys || metricKeys.includes('default') ? 'default' : metricKeys[0];
return ( return (
<Modal <Modal
...@@ -232,7 +234,7 @@ class Compare extends React.Component<CompareProps, {}> { ...@@ -232,7 +234,7 @@ class Compare extends React.Component<CompareProps, {}> {
/> />
</div> </div>
<Stack className='compare-modal-intermediate'> <Stack className='compare-modal-intermediate'>
{this._intermediates(items, defaultMetricKey)} {this._intermediates(items)}
<Stack className='compare-yAxis'># Intermediate result</Stack> <Stack className='compare-yAxis'># Intermediate result</Stack>
</Stack> </Stack>
{showDetails && <Stack>{this._columns(items)}</Stack>} {showDetails && <Stack>{this._columns(items)}</Stack>}
......
...@@ -37,9 +37,9 @@ export const Command1 = (): any => { ...@@ -37,9 +37,9 @@ export const Command1 = (): any => {
<div className='basic' style={rightEidtParam}> <div className='basic' style={rightEidtParam}>
<div> <div>
<p className='command'>Training platform</p> <p className='command'>Training platform</p>
<div className='nowrap'>{EXPERIMENT.profile.params.trainingServicePlatform}</div> <div className='ellipsis'>{EXPERIMENT.profile.params.trainingServicePlatform}</div>
<p className='lineMargin'>{title.join('/')}</p> <p className='lineMargin'>{title.join('/')}</p>
<div className='nowrap'>{builtinName.join('/')}</div> <div className='ellipsis'>{builtinName.join('/')}</div>
</div> </div>
</div> </div>
); );
......
...@@ -24,10 +24,10 @@ export const Command2 = (): any => { ...@@ -24,10 +24,10 @@ export const Command2 = (): any => {
return ( return (
<div className='basic' style={leftProgress}> <div className='basic' style={leftProgress}>
<p className='command'>Log directory</p> <p className='command'>Log directory</p>
<div className='nowrap'> <div className='ellipsis'>
<TooltipHost <TooltipHost
content={EXPERIMENT.profile.logDir || 'unknown'} content={EXPERIMENT.profile.logDir || 'unknown'}
className='nowrap' className='ellipsis'
directionalHint={DirectionalHint.bottomCenter} directionalHint={DirectionalHint.bottomCenter}
tooltipProps={{ tooltipProps={{
calloutProps: { calloutProps: {
...@@ -43,10 +43,10 @@ export const Command2 = (): any => { ...@@ -43,10 +43,10 @@ export const Command2 = (): any => {
</TooltipHost> </TooltipHost>
</div> </div>
<p className='lineMargin'>Trial command</p> <p className='lineMargin'>Trial command</p>
<div className='nowrap'> <div className='ellipsis'>
<TooltipHost <TooltipHost
content={trialCommand || 'unknown'} content={trialCommand || 'unknown'}
className='nowrap' className='ellipsis'
directionalHint={DirectionalHint.bottomCenter} directionalHint={DirectionalHint.bottomCenter}
tooltipProps={{ tooltipProps={{
calloutProps: { calloutProps: {
......
...@@ -6,10 +6,10 @@ import { formatTimestamp } from '../../../static/function'; ...@@ -6,10 +6,10 @@ import { formatTimestamp } from '../../../static/function';
import { useId } from '@uifabric/react-hooks'; import { useId } from '@uifabric/react-hooks';
import { BestMetricContext } from '../../Overview'; import { BestMetricContext } from '../../Overview';
import { styles } from './basicInfoStyles'; import { styles } from './basicInfoStyles';
import '../../../static/style/progress/progress.scss'; import '../../../static/style/overview/probar.scss';
import '../../../static/style/progress/probar.scss'; import '../../../static/style/overview/basic.scss';
export const ReBasicInfo = (): any => { export const BasicInfo = (): any => {
const labelId: string = useId('callout-label'); const labelId: string = useId('callout-label');
const descriptionId: string = useId('callout-description'); const descriptionId: string = useId('callout-description');
const ref = React.createRef<HTMLDivElement>(); const ref = React.createRef<HTMLDivElement>();
...@@ -26,9 +26,9 @@ export const ReBasicInfo = (): any => { ...@@ -26,9 +26,9 @@ export const ReBasicInfo = (): any => {
<Stack horizontal horizontalAlign='space-between' className='marginTop'> <Stack horizontal horizontalAlign='space-between' className='marginTop'>
<div className='basic'> <div className='basic'>
<p>Name</p> <p>Name</p>
<div className='nowrap'>{EXPERIMENT.profile.params.experimentName}</div> <div className='ellipsis'>{EXPERIMENT.profile.params.experimentName}</div>
<p className='marginTop'>ID</p> <p className='marginTop'>ID</p>
<div className='nowrap'>{EXPERIMENT.profile.id}</div> <div className='ellipsis'>{EXPERIMENT.profile.id}</div>
</div> </div>
<div className='basic'> <div className='basic'>
<p>Status</p> <p>Status</p>
...@@ -86,9 +86,9 @@ export const ReBasicInfo = (): any => { ...@@ -86,9 +86,9 @@ export const ReBasicInfo = (): any => {
</div> </div>
<div className='basic'> <div className='basic'>
<p>Start time</p> <p>Start time</p>
<div className='nowrap'>{formatTimestamp(EXPERIMENT.profile.startTime)}</div> <div className='ellipsis'>{formatTimestamp(EXPERIMENT.profile.startTime)}</div>
<p className='marginTop'>End time</p> <p className='marginTop'>End time</p>
<div className='nowrap'>{formatTimestamp(EXPERIMENT.profile.endTime)}</div> <div className='ellipsis'>{formatTimestamp(EXPERIMENT.profile.endTime)}</div>
</div> </div>
</Stack> </Stack>
{/* learn about click -> default active key is dispatcher. */} {/* learn about click -> default active key is dispatcher. */}
......
...@@ -106,7 +106,7 @@ class SuccessTable extends React.Component<SuccessTableProps, SuccessTableState> ...@@ -106,7 +106,7 @@ class SuccessTable extends React.Component<SuccessTableProps, SuccessTableState>
</React.Fragment> </React.Fragment>
); );
private columns = [ private columns: IColumn[] = [
{ {
key: '_expand', key: '_expand',
name: '', name: '',
......
...@@ -73,18 +73,24 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState> ...@@ -73,18 +73,24 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState>
data.data[0] < maxSequenceId ? point[0] : point[0] - 300, data.data[0] < maxSequenceId ? point[0] : point[0] - 300,
80 80
], ],
formatter: (data: TooltipForAccuracy): React.ReactNode => formatter: (data: TooltipForAccuracy): React.ReactNode => {
'<div class="tooldetailAccuracy">' + return (
'<div>Trial No.: ' + '<div class="tooldetailAccuracy">' +
data.data[0] + '<div>Trial No.: ' +
'</div>' + data.data[0] +
'<div>Default metric: ' + '</div>' +
data.data[1] + '<div>Trial ID: ' +
'</div>' + data.data[2] +
'<div>Parameters: <pre>' + '</div>' +
JSON.stringify(data.data[2], null, 4) + '<div>Default metric: ' +
'</pre></div>' + data.data[1] +
'</div>' '</div>' +
'<div>Parameters: <pre>' +
JSON.stringify(data.data[3], null, 4) +
'</pre></div>' +
'</div>'
);
}
}, },
dataZoom: [ dataZoom: [
{ {
...@@ -110,7 +116,7 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState> ...@@ -110,7 +116,7 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState>
} }
generateScatterSeries(trials: Trial[]): any { generateScatterSeries(trials: Trial[]): any {
const data = trials.map(trial => [trial.sequenceId, trial.accuracy, trial.description.parameters]); const data = trials.map(trial => [trial.sequenceId, trial.accuracy, trial.id, trial.description.parameters]);
return { return {
symbolSize: 6, symbolSize: 6,
type: 'scatter', type: 'scatter',
...@@ -120,7 +126,7 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState> ...@@ -120,7 +126,7 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState>
generateBestCurveSeries(trials: Trial[]): any { generateBestCurveSeries(trials: Trial[]): any {
let best = trials[0]; let best = trials[0];
const data = [[best.sequenceId, best.accuracy, best.description.parameters]]; const data = [[best.sequenceId, best.accuracy, best.id, best.description.parameters]];
for (let i = 1; i < trials.length; i++) { for (let i = 1; i < trials.length; i++) {
const trial = trials[i]; const trial = trials[i];
...@@ -128,10 +134,10 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState> ...@@ -128,10 +134,10 @@ class DefaultPoint extends React.Component<DefaultPointProps, DefaultPointState>
const delta = trial.accuracy! - best.accuracy!; const delta = trial.accuracy! - best.accuracy!;
const better = EXPERIMENT.optimizeMode === 'minimize' ? delta < 0 : delta > 0; const better = EXPERIMENT.optimizeMode === 'minimize' ? delta < 0 : delta > 0;
if (better) { if (better) {
data.push([trial.sequenceId, trial.accuracy, trial.description.parameters]); data.push([trial.sequenceId, trial.accuracy, best.id, trial.description.parameters]);
best = trial; best = trial;
} else { } else {
data.push([trial.sequenceId, best.accuracy, trial.description.parameters]); data.push([trial.sequenceId, best.accuracy, best.id, trial.description.parameters]);
} }
} }
......
...@@ -61,6 +61,7 @@ class Intermediate extends React.Component<IntermediateProps, IntermediateState> ...@@ -61,6 +61,7 @@ class Intermediate extends React.Component<IntermediateProps, IntermediateState>
const temp = source[item]; const temp = source[item];
trialIntermediate.push({ trialIntermediate.push({
name: temp.id, name: temp.id,
trialNum: temp.sequenceId,
data: temp.description.intermediate, data: temp.description.intermediate,
type: 'line', type: 'line',
hyperPara: temp.description.parameters hyperPara: temp.description.parameters
...@@ -94,13 +95,18 @@ class Intermediate extends React.Component<IntermediateProps, IntermediateState> ...@@ -94,13 +95,18 @@ class Intermediate extends React.Component<IntermediateProps, IntermediateState>
}, },
formatter: function(data: TooltipForIntermediate): React.ReactNode { formatter: function(data: TooltipForIntermediate): React.ReactNode {
const trialId = data.seriesName; const trialId = data.seriesName;
let obj = {}; let parameters = {};
let trialNum = 0;
const temp = trialIntermediate.find(key => key.name === trialId); const temp = trialIntermediate.find(key => key.name === trialId);
if (temp !== undefined) { if (temp !== undefined) {
obj = temp.hyperPara; parameters = temp.hyperPara;
trialNum = temp.trialNum;
} }
return ( return (
'<div class="tooldetailAccuracy">' + '<div class="tooldetailAccuracy">' +
'<div>Trial No.: ' +
trialNum +
'</div>' +
'<div>Trial ID: ' + '<div>Trial ID: ' +
trialId + trialId +
'</div>' + '</div>' +
...@@ -109,7 +115,7 @@ class Intermediate extends React.Component<IntermediateProps, IntermediateState> ...@@ -109,7 +115,7 @@ class Intermediate extends React.Component<IntermediateProps, IntermediateState>
'</div>' + '</div>' +
'<div>Parameters: ' + '<div>Parameters: ' +
'<pre>' + '<pre>' +
JSON.stringify(obj, null, 4) + JSON.stringify(parameters, null, 4) +
'</pre>' + '</pre>' +
'</div>' + '</div>' +
'</div>' '</div>'
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment