зеркало из https://github.com/microsoft/nni.git
Improve search parameters on trial detail page (#3651)
Co-authored-by: Lijiao <Lijiaoa@outlook.com>
This commit is contained in:
Родитель
5df75c33cb
Коммит
4ccc94024f
|
@ -41,7 +41,7 @@ if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
|
|||
}
|
||||
|
||||
// Tools like Cloud9 rely on this.
|
||||
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
|
||||
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 8000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
if (process.env.HOST) {
|
||||
|
|
|
@ -48,6 +48,8 @@ export const AppContext = React.createContext({
|
|||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
updateOverviewPage: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
updateDetailPage: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
changeExpandRowIDs: (_val: string, _type?: string): void => {}
|
||||
});
|
||||
|
||||
|
@ -133,6 +135,12 @@ class App extends React.Component<{}, AppState> {
|
|||
}));
|
||||
};
|
||||
|
||||
updateDetailPage = (): void => {
|
||||
this.setState(state => ({
|
||||
trialsUpdateBroadcast: state.trialsUpdateBroadcast + 1
|
||||
}));
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps: any, nextState: AppState): boolean {
|
||||
if (!(nextState.isUpdate || nextState.isUpdate === undefined)) {
|
||||
nextState.isUpdate = true;
|
||||
|
@ -207,6 +215,7 @@ class App extends React.Component<{}, AppState> {
|
|||
bestTrialEntries,
|
||||
changeEntries: this.changeEntries,
|
||||
updateOverviewPage: this.updateOverviewPage,
|
||||
updateDetailPage: this.updateDetailPage,
|
||||
expandRowIDs,
|
||||
changeExpandRowIDs: this.changeExpandRowIDs
|
||||
}}
|
||||
|
|
|
@ -83,10 +83,7 @@ class TrialsDetail extends React.Component<{}, TrialDetailState> {
|
|||
</div>
|
||||
{/* trial table list */}
|
||||
<div className='detailTable' style={{ marginTop: 10 }}>
|
||||
<TableList
|
||||
tableSource={source}
|
||||
trialsUpdateBroadcast={this.context.trialsUpdateBroadcast}
|
||||
/>
|
||||
<TableList tableSource={source} updateDetailPage={this.context.updateDetailPage} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { IStackTokens } from '@fluentui/react';
|
||||
|
||||
const searchConditonsGap: IStackTokens = {
|
||||
childrenGap: 10
|
||||
};
|
||||
|
||||
export { searchConditonsGap };
|
|
@ -1,10 +1,8 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
DefaultButton,
|
||||
Dropdown,
|
||||
IColumn,
|
||||
Icon,
|
||||
IDropdownOption,
|
||||
PrimaryButton,
|
||||
Stack,
|
||||
StackItem,
|
||||
|
@ -14,13 +12,15 @@ import {
|
|||
} from '@fluentui/react';
|
||||
import { EXPERIMENT, TRIALS } from '../../static/datamodel';
|
||||
import { TOOLTIP_BACKGROUND_COLOR } from '../../static/const';
|
||||
import { convertDuration, formatTimestamp, copyAndSort } from '../../static/function';
|
||||
import { TableObj, SortInfo } from '../../static/interface';
|
||||
import { convertDuration, formatTimestamp, copyAndSort, parametersType } from '../../static/function';
|
||||
import { TableObj, SortInfo, SearchItems } from '../../static/interface';
|
||||
import { getTrialsBySearchFilters } from './search/searchFunction';
|
||||
import { blocked, copy, LineChart, tableListIcon } from '../buttons/Icon';
|
||||
import ChangeColumnComponent from '../modals/ChangeColumnComponent';
|
||||
import Compare from '../modals/Compare';
|
||||
import Customize from '../modals/CustomizedTrial';
|
||||
import TensorboardUI from '../modals/tensorboard/TensorboardUI';
|
||||
import Search from './search/Search';
|
||||
import KillJob from '../modals/Killjob';
|
||||
import ExpandableDetails from '../public-child/ExpandableDetails';
|
||||
import PaginationTable from '../public-child/PaginationTable';
|
||||
|
@ -41,12 +41,6 @@ require('echarts/lib/component/tooltip');
|
|||
require('echarts/lib/component/title');
|
||||
|
||||
type SearchOptionType = 'id' | 'trialnum' | 'status' | 'parameters';
|
||||
const searchOptionLiterals = {
|
||||
id: 'ID',
|
||||
trialnum: 'Trial No.',
|
||||
status: 'Status',
|
||||
parameters: 'Parameters'
|
||||
};
|
||||
|
||||
const defaultDisplayedColumns = ['sequenceId', 'id', 'duration', 'status', 'latestAccuracy'];
|
||||
|
||||
|
@ -76,7 +70,7 @@ function _inferColumnTitle(columnKey: string): string {
|
|||
|
||||
interface TableListProps {
|
||||
tableSource: TableObj[];
|
||||
trialsUpdateBroadcast: number;
|
||||
updateDetailPage: () => void;
|
||||
}
|
||||
|
||||
interface TableListState {
|
||||
|
@ -91,6 +85,8 @@ interface TableListState {
|
|||
intermediateDialogTrial: TableObj | undefined;
|
||||
copiedTrialId: string | undefined;
|
||||
sortInfo: SortInfo;
|
||||
searchItems: Array<SearchItems>;
|
||||
relation: Map<string, string>;
|
||||
}
|
||||
|
||||
class TableList extends React.Component<TableListProps, TableListState> {
|
||||
|
@ -114,47 +110,14 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
selectedRowIds: [],
|
||||
intermediateDialogTrial: undefined,
|
||||
copiedTrialId: undefined,
|
||||
sortInfo: { field: '', isDescend: true }
|
||||
sortInfo: { field: '', isDescend: true },
|
||||
searchItems: [],
|
||||
relation: parametersType()
|
||||
};
|
||||
|
||||
this._expandedTrialIds = new Set<string>();
|
||||
}
|
||||
|
||||
/* Search related methods */
|
||||
|
||||
// This functions as the filter for the final trials displayed in the current table
|
||||
private _filterTrials(trials: TableObj[]): TableObj[] {
|
||||
const { searchText, searchType } = this.state;
|
||||
// search a trial by Trial No. | Trial ID | Parameters | Status
|
||||
let searchFilter = (_: TableObj): boolean => true; // eslint-disable-line no-unused-vars
|
||||
if (searchText.trim()) {
|
||||
if (searchType === 'id') {
|
||||
searchFilter = (trial): boolean => trial.id.toUpperCase().includes(searchText.toUpperCase());
|
||||
} else if (searchType === 'trialnum') {
|
||||
searchFilter = (trial): boolean => trial.sequenceId.toString() === searchText;
|
||||
} else if (searchType === 'status') {
|
||||
searchFilter = (trial): boolean => trial.status.toUpperCase().includes(searchText.toUpperCase());
|
||||
} else if (searchType === 'parameters') {
|
||||
// TODO: support filters like `x: 2` (instead of `'x': 2`)
|
||||
searchFilter = (trial): boolean => JSON.stringify(trial.description.parameters).includes(searchText);
|
||||
}
|
||||
}
|
||||
return trials.filter(searchFilter);
|
||||
}
|
||||
|
||||
private _updateSearchFilterType(_event: React.FormEvent<HTMLDivElement>, item: IDropdownOption | undefined): void {
|
||||
if (item !== undefined) {
|
||||
const value = item.key.toString();
|
||||
if (searchOptionLiterals.hasOwnProperty(value)) {
|
||||
this.setState({ searchType: value as SearchOptionType }, this._updateTableSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateSearchText(ev: React.ChangeEvent<HTMLInputElement>): void {
|
||||
this.setState({ searchText: ev.target.value }, this._updateTableSource);
|
||||
}
|
||||
|
||||
/* Table basic function related methods */
|
||||
|
||||
private _onColumnClick(ev: React.MouseEvent<HTMLElement>, column: IColumn): void {
|
||||
|
@ -180,7 +143,7 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
const ret = {
|
||||
sequenceId: trial.sequenceId,
|
||||
id: trial.id,
|
||||
checked: selectedRowIds.includes(trial.id) ? true : false,
|
||||
_checked: selectedRowIds.includes(trial.id) ? true : false,
|
||||
startTime: (trial as Trial).info.startTime, // FIXME: why do we need info here?
|
||||
endTime: (trial as Trial).info.endTime,
|
||||
duration: trial.duration,
|
||||
|
@ -221,7 +184,7 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
}
|
||||
items.forEach(item => {
|
||||
if (item.id === id) {
|
||||
item.checked = !!checked;
|
||||
item._checked = !!checked;
|
||||
}
|
||||
});
|
||||
this.setState(() => ({ displayedItems: items, selectedRowIds: temp }));
|
||||
|
@ -231,7 +194,7 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
const { displayedItems } = this.state;
|
||||
const newDisplayedItems = displayedItems;
|
||||
newDisplayedItems.forEach(item => {
|
||||
item.checked = false;
|
||||
item._checked = false;
|
||||
});
|
||||
this.setState(() => ({
|
||||
selectedRowIds: [],
|
||||
|
@ -253,7 +216,7 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
onRender: (record): React.ReactNode => (
|
||||
<Checkbox
|
||||
label={undefined}
|
||||
checked={record.checked}
|
||||
checked={record._checked}
|
||||
className='detail-check'
|
||||
onChange={this.selectedTrialOnChangeEvent.bind(this, record.id)}
|
||||
/>
|
||||
|
@ -438,7 +401,11 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
|
||||
private _updateTableSource(): void {
|
||||
// call this method when trials or the computation of trial filter has changed
|
||||
const items = this._trialsToTableItems(this._filterTrials(this.props.tableSource));
|
||||
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
|
||||
}
|
||||
if (items.length > 0) {
|
||||
const columns = this._buildColumnsFromTableItems(items);
|
||||
this.setState({
|
||||
|
@ -496,6 +463,12 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
);
|
||||
}
|
||||
|
||||
private changeSearchFilterList = (arr: Array<SearchItems>): void => {
|
||||
this.setState(() => ({
|
||||
searchItems: arr
|
||||
}));
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: TableListProps): void {
|
||||
if (this.props.tableSource !== prevProps.tableSource) {
|
||||
this._updateTableSource();
|
||||
|
@ -510,13 +483,13 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
const {
|
||||
displayedItems,
|
||||
columns,
|
||||
searchType,
|
||||
customizeColumnsDialogVisible,
|
||||
compareDialogVisible,
|
||||
displayedColumns,
|
||||
selectedRowIds,
|
||||
intermediateDialogTrial,
|
||||
copiedTrialId
|
||||
copiedTrialId,
|
||||
searchItems
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
|
@ -526,7 +499,24 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
<span>Trial jobs</span>
|
||||
</Stack>
|
||||
<Stack horizontal className='allList'>
|
||||
<StackItem grow={50}>
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
<DefaultButton
|
||||
text='Compare'
|
||||
className='allList-compare'
|
||||
|
@ -540,37 +530,6 @@ class TableList extends React.Component<TableListProps, TableListState> {
|
|||
changeSelectTrialIds={this.changeSelectTrialIds}
|
||||
/>
|
||||
</StackItem>
|
||||
<StackItem grow={50}>
|
||||
<Stack horizontal horizontalAlign='end' className='allList'>
|
||||
<DefaultButton
|
||||
className='allList-button-gap'
|
||||
text='Add/Remove columns'
|
||||
onClick={(): void => {
|
||||
this.setState({ customizeColumnsDialogVisible: true });
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
selectedKey={searchType}
|
||||
options={Object.entries(searchOptionLiterals).map(([k, v]) => ({
|
||||
key: k,
|
||||
text: v
|
||||
}))}
|
||||
onChange={this._updateSearchFilterType.bind(this)}
|
||||
styles={{ root: { width: 150 } }}
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
className='allList-search-input'
|
||||
placeholder={`Search by ${
|
||||
['id', 'trialnum'].includes(searchType)
|
||||
? searchOptionLiterals[searchType]
|
||||
: searchType
|
||||
}`}
|
||||
onChange={this._updateSearchText.bind(this)}
|
||||
style={{ width: 230 }}
|
||||
/>
|
||||
</Stack>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
{columns && displayedItems && (
|
||||
<PaginationTable
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack, PrimaryButton } from '@fluentui/react';
|
||||
import { searchConditonsGap } from '../../modals/ChildrenGap';
|
||||
import { getSearchInputValueBySearchList } from './searchFunction';
|
||||
|
||||
// This file is for search trial ['Trial id', 'Trial No.']
|
||||
|
||||
function GeneralSearch(props): any {
|
||||
// searchName val: Trial No. | Trial id
|
||||
const { searchName, searchFilter, dismiss, changeSearchFilterList, setSearchInputVal, updatePage } = props;
|
||||
const [firstInputVal, setFirstInputVal] = useState(getSearchNameInit());
|
||||
|
||||
function updateFirstInputVal(ev: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setFirstInputVal(ev.target.value);
|
||||
}
|
||||
|
||||
function getSearchNameInit(): string {
|
||||
let str = ''; // init ''
|
||||
const find = searchFilter.find(item => item.name === searchName);
|
||||
|
||||
if (find !== undefined) {
|
||||
str = find.value1; // init by filter value
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function startFilterTrial(): void {
|
||||
const { searchFilter } = props;
|
||||
const searchFilterConditions = JSON.parse(JSON.stringify(searchFilter));
|
||||
const find = searchFilterConditions.filter(item => item.name === searchName);
|
||||
|
||||
if (firstInputVal === '') {
|
||||
alert('Please input related value!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (find.length > 0) {
|
||||
// change this record
|
||||
// Trial id | Trial No. only need {search name, search value} these message
|
||||
searchFilterConditions.forEach(item => {
|
||||
if (item.name === searchName) {
|
||||
item.value1 = firstInputVal;
|
||||
// item.operator = '';
|
||||
item.isChoice = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
searchFilterConditions.push({
|
||||
name: searchName,
|
||||
// operator: '',
|
||||
value1: firstInputVal,
|
||||
isChoice: false
|
||||
});
|
||||
}
|
||||
setSearchInputVal(getSearchInputValueBySearchList(searchFilterConditions));
|
||||
changeSearchFilterList(searchFilterConditions);
|
||||
updatePage();
|
||||
dismiss(); // close menu
|
||||
}
|
||||
|
||||
return (
|
||||
// Trial id & Trial No.
|
||||
<Stack horizontal className='filterConditions' tokens={searchConditonsGap}>
|
||||
<span>{searchName === 'Trial id' ? 'Includes' : 'Equals to'}</span>
|
||||
<input type='text' className='input input-padding' onChange={updateFirstInputVal} value={firstInputVal} />
|
||||
<PrimaryButton text='Apply' className='btn-vertical-middle' onClick={startFilterTrial} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
GeneralSearch.propTypes = {
|
||||
searchName: PropTypes.string,
|
||||
searchFilter: PropTypes.array,
|
||||
dismiss: PropTypes.func,
|
||||
setSearchInputVal: PropTypes.func,
|
||||
changeSearchFilterList: PropTypes.func,
|
||||
updatePage: PropTypes.func
|
||||
};
|
||||
|
||||
export default GeneralSearch;
|
|
@ -0,0 +1,259 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Stack,
|
||||
DefaultButton,
|
||||
IContextualMenuProps,
|
||||
IContextualMenuItem,
|
||||
DirectionalHint,
|
||||
SearchBox
|
||||
} from '@fluentui/react';
|
||||
import { EXPERIMENT } from '../../../static/datamodel';
|
||||
import { SearchItems } from '../../../static/interface';
|
||||
import SearchParameterConditions from './SearchParameterConditions';
|
||||
import GeneralSearch from './GeneralSearch';
|
||||
import { classNames, isChoiceType } from './searchFunction';
|
||||
|
||||
// TableList search layout
|
||||
|
||||
function Search(props): any {
|
||||
const { searchFilter, changeSearchFilterList, updatePage } = props;
|
||||
const [searchInputVal, setSearchInputVal] = useState('');
|
||||
|
||||
function getSearchMenu(parameterList): IContextualMenuProps {
|
||||
const menu: Array<object> = [];
|
||||
|
||||
parameterList.unshift('StatusNNI');
|
||||
|
||||
['Trial id', 'Trial No.'].forEach(item => {
|
||||
menu.push({
|
||||
key: item,
|
||||
text: item,
|
||||
subMenuProps: {
|
||||
items: [
|
||||
{
|
||||
key: item,
|
||||
text: item,
|
||||
// component: GeneralSearch.tsx
|
||||
onRender: renderIdAndNoComponent.bind(item)
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
parameterList.forEach(item => {
|
||||
menu.push({
|
||||
key: item,
|
||||
text: item === 'StatusNNI' ? 'Status' : item,
|
||||
subMenuProps: {
|
||||
items: [
|
||||
{
|
||||
key: item,
|
||||
text: item,
|
||||
// component: SearchParameterConditions.tsx
|
||||
onRender: renderParametersSearchComponent.bind(item)
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const filterMenu: IContextualMenuProps = {
|
||||
shouldFocusOnMount: true,
|
||||
directionalHint: DirectionalHint.bottomLeftEdge,
|
||||
className: classNames.menu,
|
||||
items: menu as any
|
||||
};
|
||||
|
||||
return filterMenu;
|
||||
}
|
||||
|
||||
// Avoid nested experiments, nested experiments do not support hyperparameter search
|
||||
const searchMenuProps: IContextualMenuProps = getSearchMenu(
|
||||
EXPERIMENT.isNestedExp() ? [] : Object.keys(EXPERIMENT.searchSpace)
|
||||
);
|
||||
|
||||
function renderParametersSearchComponent(item: IContextualMenuItem, dismissMenu: () => void): JSX.Element {
|
||||
return (
|
||||
<SearchParameterConditions
|
||||
parameter={item.text}
|
||||
searchFilter={searchFilter} // search filter list
|
||||
changeSearchFilterList={changeSearchFilterList}
|
||||
updatePage={updatePage}
|
||||
setSearchInputVal={setSearchInputVal}
|
||||
dismiss={dismissMenu} // close menu
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIdAndNoComponent(item: IContextualMenuItem, dismissMenu: () => void): JSX.Element {
|
||||
return (
|
||||
<GeneralSearch
|
||||
searchName={item.text}
|
||||
searchFilter={searchFilter} // search fliter list
|
||||
changeSearchFilterList={changeSearchFilterList}
|
||||
setSearchInputVal={setSearchInputVal}
|
||||
updatePage={updatePage}
|
||||
dismiss={dismissMenu} // after click Apply button to close menu
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function updateSearchText(_, newValue): void {
|
||||
setSearchInputVal(newValue);
|
||||
}
|
||||
|
||||
// update TableList page
|
||||
function changeTableListPage(searchFilterList: Array<SearchItems>): void {
|
||||
changeSearchFilterList(searchFilterList);
|
||||
updatePage();
|
||||
}
|
||||
|
||||
// "[hello, world]", JSON.parse(it) doesn't work so write this function
|
||||
function convertStringArrToList(str: string): string[] {
|
||||
const value = str.slice(1, str.length - 1); // delete []
|
||||
// delete ""
|
||||
const result: string[] = [];
|
||||
|
||||
if (value.includes(',')) {
|
||||
const arr = value.split(',');
|
||||
arr.forEach(item => {
|
||||
if (item !== '') {
|
||||
result.push(item);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
if (value === '') {
|
||||
return result;
|
||||
} else {
|
||||
return [value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SearchBox onSearch event: Filter based on the filter criteria entered by the user
|
||||
function startFilter(): void {
|
||||
const regEn = /`~!@#$%^&*()+?"{}.'/im;
|
||||
const regCn = /·!#¥(——):;“”‘、,|《。》?、【】[\]]/im;
|
||||
if (regEn.test(searchInputVal) || regCn.test(searchInputVal)) {
|
||||
alert('Please delete special characters in the conditions!');
|
||||
return;
|
||||
}
|
||||
// according [input val] to change searchFilter list
|
||||
const allFilterConditions = searchInputVal.trim().split(';');
|
||||
const newSearchFilter: any = [];
|
||||
|
||||
// delete '' in filter list
|
||||
if (allFilterConditions.includes('')) {
|
||||
allFilterConditions.splice(
|
||||
allFilterConditions.findIndex(item => item === ''),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
allFilterConditions.forEach(eachFilterConditionStr => {
|
||||
let eachFilterConditionArr: string[] = [];
|
||||
|
||||
// EXPERIMENT.searchSpace[parameter]._type === 'choice'
|
||||
if (eachFilterConditionStr.includes('>' || '<')) {
|
||||
const operator = eachFilterConditionStr.includes('>') === true ? '>' : '<';
|
||||
eachFilterConditionArr = eachFilterConditionStr.trim().split(operator);
|
||||
newSearchFilter.push({
|
||||
name: eachFilterConditionArr[0],
|
||||
operator: operator,
|
||||
value1: eachFilterConditionArr[1],
|
||||
value2: '',
|
||||
choice: [],
|
||||
isChoice: false
|
||||
});
|
||||
} else if (eachFilterConditionStr.includes('≠')) {
|
||||
// drop_rate≠6; status≠[x,xx,xxx]; conv_size≠[3,7]
|
||||
eachFilterConditionArr = eachFilterConditionStr.trim().split('≠');
|
||||
const filterName = eachFilterConditionArr[0] === 'Status' ? 'StatusNNI' : eachFilterConditionArr[0];
|
||||
const isChoicesType = isChoiceType(filterName);
|
||||
newSearchFilter.push({
|
||||
name: filterName,
|
||||
operator: '≠',
|
||||
value1: isChoicesType ? '' : JSON.parse(eachFilterConditionArr[1]),
|
||||
value2: '',
|
||||
choice: isChoicesType ? convertStringArrToList(eachFilterConditionArr[1]) : [],
|
||||
isChoice: isChoicesType ? true : false
|
||||
});
|
||||
} else {
|
||||
// = : conv_size:[1,2,3,4]; Trial id:3; hidden_size:[1,2], status:[val1,val2,val3]
|
||||
eachFilterConditionArr = eachFilterConditionStr.trim().split(':');
|
||||
const filterName = eachFilterConditionArr[0] === 'Status' ? 'StatusNNI' : eachFilterConditionArr[0];
|
||||
const isChoicesType = isChoiceType(filterName);
|
||||
const isArray =
|
||||
eachFilterConditionArr.length > 1 && eachFilterConditionArr[1].includes('[' || ']') ? true : false;
|
||||
if (isArray === true) {
|
||||
if (isChoicesType === true) {
|
||||
// status:[SUCCEEDED]
|
||||
newSearchFilter.push({
|
||||
name: filterName,
|
||||
operator: '=',
|
||||
value1: '',
|
||||
value2: '',
|
||||
choice: convertStringArrToList(eachFilterConditionArr[1]),
|
||||
isChoice: true
|
||||
});
|
||||
} else {
|
||||
// drop_rate:[1,10]
|
||||
newSearchFilter.push({
|
||||
name: eachFilterConditionArr[0],
|
||||
operator: 'between',
|
||||
value1: JSON.parse(eachFilterConditionArr[1])[0],
|
||||
value2: JSON.parse(eachFilterConditionArr[1])[1],
|
||||
choice: [],
|
||||
isChoice: false
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newSearchFilter.push({
|
||||
name: eachFilterConditionArr[0],
|
||||
operator: '=',
|
||||
value1: eachFilterConditionArr[1],
|
||||
value2: '',
|
||||
choice: [],
|
||||
isChoice: false
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
changeTableListPage(newSearchFilter);
|
||||
}
|
||||
|
||||
// clear search input all value, clear all search filter
|
||||
function clearFliter(): void {
|
||||
changeTableListPage([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack horizontal>
|
||||
<DefaultButton text='Filter' menuProps={searchMenuProps} />
|
||||
{/* search input: store filter conditons, also, user could input filter conditions, could search */}
|
||||
<SearchBox
|
||||
styles={{ root: { width: 530 } }}
|
||||
placeholder='Search'
|
||||
onChange={updateSearchText}
|
||||
value={searchInputVal}
|
||||
onSearch={startFilter}
|
||||
onEscape={clearFliter}
|
||||
onClear={clearFliter}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Search.propTypes = {
|
||||
searchFilter: PropTypes.array,
|
||||
changeSearchFilterList: PropTypes.func,
|
||||
updatePage: PropTypes.func
|
||||
};
|
||||
|
||||
export default Search;
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack, PrimaryButton, Dropdown, IDropdownOption } from '@fluentui/react';
|
||||
import { EXPERIMENT } from '../../../static/datamodel';
|
||||
import { getDropdownOptions, getSearchInputValueBySearchList } from './searchFunction';
|
||||
import { searchConditonsGap } from '../../modals/ChildrenGap';
|
||||
|
||||
// This file is for filtering trial parameters and trial status
|
||||
|
||||
function SearchParameterConditions(props): any {
|
||||
const { parameter, searchFilter, dismiss, changeSearchFilterList, updatePage, setSearchInputVal } = props;
|
||||
const isChoiceTypeSearchFilter = parameter === 'StatusNNI' || EXPERIMENT.searchSpace[parameter]._type === 'choice';
|
||||
const operatorList = isChoiceTypeSearchFilter ? ['=', '≠'] : ['between', '>', '<', '=', '≠'];
|
||||
|
||||
const initValueList = getInitVal();
|
||||
const [operatorVal, setOperatorVal] = useState(initValueList[0]);
|
||||
const [firstInputVal, setFirstInputVal] = useState(initValueList[1] as string);
|
||||
const [secondInputVal, setSecondInputVal] = useState(initValueList[2] as string);
|
||||
// status or choice parameter dropdown selected value list
|
||||
const [choiceList, setChoiceList] = useState(initValueList[3] as string[]);
|
||||
|
||||
function getInitVal(): Array<string | string[]> {
|
||||
// push value: operator, firstInputVal(value1), secondInputVal(value2), choiceValue
|
||||
const str: Array<string | string[]> = [];
|
||||
|
||||
if (searchFilter.length > 0) {
|
||||
const filterElement = searchFilter.find(ele => ele.name === parameter);
|
||||
if (filterElement !== undefined) {
|
||||
str.push(
|
||||
filterElement.operator,
|
||||
filterElement.value1.toString(),
|
||||
filterElement.value2.toString(),
|
||||
filterElement.choice.toString().split(',')
|
||||
);
|
||||
} else {
|
||||
// set init value
|
||||
str.push(`${isChoiceTypeSearchFilter ? '=' : 'between'}`, '', '', [] as string[]);
|
||||
}
|
||||
} else {
|
||||
str.push(`${isChoiceTypeSearchFilter ? '=' : 'between'}`, '', '', [] as string[]);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function updateOperatorDropdown(_event: React.FormEvent<HTMLDivElement>, item: IDropdownOption | undefined): void {
|
||||
if (item !== undefined) {
|
||||
setOperatorVal(item.key.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// get [status | parameters that type is choice] list
|
||||
function updateChoiceDropdown(_event: React.FormEvent<HTMLDivElement>, item: IDropdownOption | undefined): void {
|
||||
if (item !== undefined) {
|
||||
const result = item.selected
|
||||
? [...choiceList, item.key as string]
|
||||
: choiceList.filter(key => key !== item.key);
|
||||
setChoiceList(result);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFirstInputVal(ev: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setFirstInputVal(ev.target.value);
|
||||
}
|
||||
|
||||
function updateSecondInputVal(ev: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSecondInputVal(ev.target.value);
|
||||
}
|
||||
|
||||
function getSecondInputVal(): string {
|
||||
if (secondInputVal === '' && operatorVal === 'between') {
|
||||
// if user uses 'between' operator and doesn't write the second input value,
|
||||
// help to set second value as this parameter max value
|
||||
return EXPERIMENT.searchSpace[parameter]._value[1].toString();
|
||||
}
|
||||
|
||||
return secondInputVal as string;
|
||||
}
|
||||
|
||||
// click Apply button
|
||||
function startFilterTrials(): void {
|
||||
if (isChoiceTypeSearchFilter === false) {
|
||||
if (firstInputVal === '') {
|
||||
alert('Please input related value!');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstInputVal.match(/[a-zA-Z]/) || secondInputVal.match(/[a-zA-Z]/)) {
|
||||
alert('Please input a number!');
|
||||
return;
|
||||
}
|
||||
|
||||
let newSearchFilters = JSON.parse(JSON.stringify(searchFilter));
|
||||
const find = newSearchFilters.filter(ele => ele.name === parameter);
|
||||
|
||||
if (find.length > 0) {
|
||||
// if user clear all selected options, will clear this filter condition on the searchFilter list
|
||||
// eg: conv_size -> choiceList = [], searchFilter will remove (name === 'conv_size')
|
||||
if ((isChoiceTypeSearchFilter && choiceList.length !== 0) || isChoiceTypeSearchFilter === false) {
|
||||
newSearchFilters.forEach(item => {
|
||||
if (item.name === parameter) {
|
||||
item.operator = operatorVal;
|
||||
item.value1 = firstInputVal;
|
||||
item.value2 = getSecondInputVal();
|
||||
item.choice = choiceList;
|
||||
item.isChoice = isChoiceTypeSearchFilter ? true : false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newSearchFilters = newSearchFilters.filter(item => item.name !== parameter);
|
||||
}
|
||||
} else {
|
||||
if ((isChoiceTypeSearchFilter && choiceList.length !== 0) || isChoiceTypeSearchFilter === false) {
|
||||
newSearchFilters.push({
|
||||
name: parameter,
|
||||
operator: operatorVal,
|
||||
value1: firstInputVal,
|
||||
value2: getSecondInputVal(),
|
||||
choice: choiceList,
|
||||
isChoice: isChoiceTypeSearchFilter ? true : false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSearchInputVal(getSearchInputValueBySearchList(newSearchFilters));
|
||||
changeSearchFilterList(newSearchFilters);
|
||||
updatePage();
|
||||
dismiss(); // close menu
|
||||
}
|
||||
|
||||
return (
|
||||
// for trial parameters & Status
|
||||
<Stack horizontal className='filterConditions' tokens={searchConditonsGap}>
|
||||
<Dropdown
|
||||
selectedKey={operatorVal}
|
||||
options={operatorList.map(item => ({
|
||||
key: item,
|
||||
text: item
|
||||
}))}
|
||||
onChange={updateOperatorDropdown}
|
||||
className='btn-vertical-middle'
|
||||
styles={{ root: { width: 100 } }}
|
||||
/>
|
||||
{isChoiceTypeSearchFilter ? (
|
||||
<Dropdown
|
||||
// selectedKeys:[] multiy, selectedKey: string
|
||||
selectedKeys={choiceList}
|
||||
multiSelect
|
||||
options={getDropdownOptions(parameter)}
|
||||
onChange={updateChoiceDropdown}
|
||||
className='btn-vertical-middle'
|
||||
styles={{ root: { width: 190 } }}
|
||||
/>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{operatorVal === 'between' ? (
|
||||
<div>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-padding'
|
||||
onChange={updateFirstInputVal}
|
||||
value={firstInputVal}
|
||||
/>
|
||||
<span className='and'>and</span>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-padding'
|
||||
onChange={updateSecondInputVal}
|
||||
value={secondInputVal}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type='text'
|
||||
className='input input-padding'
|
||||
onChange={updateFirstInputVal}
|
||||
value={firstInputVal}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
<PrimaryButton text='Apply' className='btn-vertical-middle' onClick={startFilterTrials} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
SearchParameterConditions.propTypes = {
|
||||
parameter: PropTypes.string,
|
||||
searchFilter: PropTypes.array,
|
||||
dismiss: PropTypes.func,
|
||||
setSearchInputVal: PropTypes.func,
|
||||
changeSearchFilterList: PropTypes.func,
|
||||
updatePage: PropTypes.func
|
||||
};
|
||||
|
||||
export default SearchParameterConditions;
|
|
@ -0,0 +1,203 @@
|
|||
import { mergeStyleSets } from '@fluentui/react';
|
||||
import { trialJobStatus } from '../../../static/const';
|
||||
import { EXPERIMENT } from '../../../static/datamodel';
|
||||
import { TableObj, SearchItems } from '../../../static/interface';
|
||||
|
||||
const classNames = mergeStyleSets({
|
||||
menu: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 600,
|
||||
selectors: {
|
||||
'.ms-ContextualMenu-item': {
|
||||
height: 'auto'
|
||||
}
|
||||
}
|
||||
},
|
||||
item: {
|
||||
display: 'inline-block',
|
||||
width: 40,
|
||||
height: 40,
|
||||
lineHeight: 40,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
marginBottom: 8,
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: '#eaeaea'
|
||||
}
|
||||
}
|
||||
},
|
||||
categoriesList: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyleType: 'none'
|
||||
},
|
||||
button: {
|
||||
width: '40%',
|
||||
margin: '2%'
|
||||
}
|
||||
});
|
||||
|
||||
function getDropdownOptions(parameter): any {
|
||||
if (parameter === 'StatusNNI') {
|
||||
return trialJobStatus.map(item => ({
|
||||
key: item,
|
||||
text: item
|
||||
}));
|
||||
} else {
|
||||
return EXPERIMENT.searchSpace[parameter]._value.map(item => ({
|
||||
key: item.toString(),
|
||||
text: item.toString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// change origin data according to parameter type, string -> number
|
||||
const convertParametersValue = (searchItems: SearchItems[], relation: Map<string, string>): SearchItems[] => {
|
||||
const choice: any[] = [];
|
||||
|
||||
searchItems.forEach(item => {
|
||||
if (relation.get(item.name) === 'number') {
|
||||
if (item.isChoice === true) {
|
||||
item.choice.forEach(ele => {
|
||||
choice.push(JSON.parse(ele));
|
||||
});
|
||||
item.choice = choice;
|
||||
} else {
|
||||
item.value1 = JSON.parse(item.value1);
|
||||
if (item.value2 !== '') {
|
||||
item.value2 = JSON.parse(item.value2);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return searchItems;
|
||||
};
|
||||
// relation: trial parameter -> type {conv_size -> number}
|
||||
const getTrialsBySearchFilters = (
|
||||
arr: TableObj[],
|
||||
searchItems: SearchItems[],
|
||||
relation: Map<string, string>
|
||||
): TableObj[] => {
|
||||
const que = convertParametersValue(searchItems, relation);
|
||||
// start to filter data by ['Trial id', 'Trial No.', 'Status'] [...parameters]...
|
||||
que.forEach(element => {
|
||||
if (element.name === 'Trial id') {
|
||||
arr = arr.filter(trial => trial.id.toUpperCase().includes(element.value1.toUpperCase()));
|
||||
} else if (element.name === 'Trial No.') {
|
||||
arr = arr.filter(trial => trial.sequenceId.toString() === element.value1);
|
||||
} else if (element.name === 'StatusNNI') {
|
||||
arr = searchChoiceFilter(arr, element, 'status');
|
||||
} else {
|
||||
const parameter = `space/${element.name}`;
|
||||
|
||||
if (element.isChoice === true) {
|
||||
arr = searchChoiceFilter(arr, element, element.name);
|
||||
} else {
|
||||
if (element.operator === '=') {
|
||||
arr = arr.filter(trial => trial[parameter] === element.value1);
|
||||
} else if (element.operator === '>') {
|
||||
arr = arr.filter(trial => trial[parameter] > element.value1);
|
||||
} else if (element.operator === '<') {
|
||||
arr = arr.filter(trial => trial[parameter] < element.value1);
|
||||
} else if (element.operator === 'between') {
|
||||
arr = arr.filter(trial => trial[parameter] > element.value1 && trial[parameter] < element.value2);
|
||||
} else {
|
||||
// operator is '≠'
|
||||
arr = arr.filter(trial => trial[parameter] !== element.value1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return arr;
|
||||
};
|
||||
|
||||
// isChoice = true: status and trial parameters
|
||||
function findTrials(arr: TableObj[], choice: string[], filed: string): TableObj[] {
|
||||
const newResult: TableObj[] = [];
|
||||
const parameter = filed === 'status' ? 'status' : `space/${filed}`;
|
||||
arr.forEach(trial => {
|
||||
choice.forEach(item => {
|
||||
if (trial[parameter] === item) {
|
||||
newResult.push(trial);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return newResult;
|
||||
}
|
||||
|
||||
function searchChoiceFilter(arr: TableObj[], element: SearchItems, field: string): TableObj[] {
|
||||
if (element.operator === '=') {
|
||||
return findTrials(arr, element.choice, field);
|
||||
} else {
|
||||
let choice;
|
||||
if (field === 'status') {
|
||||
choice = trialJobStatus.filter(index => !new Set(element.choice).has(index));
|
||||
} else {
|
||||
choice = EXPERIMENT.searchSpace[field]._value.filter(index => !new Set(element.choice).has(index));
|
||||
}
|
||||
return findTrials(arr, choice, field);
|
||||
}
|
||||
}
|
||||
|
||||
// click Apply btn: set searchBox value now
|
||||
function getSearchInputValueBySearchList(searchFilter): string {
|
||||
let str = ''; // store search input value
|
||||
|
||||
searchFilter.forEach(item => {
|
||||
const filterName = item.name === 'StatusNNI' ? 'Status' : item.name;
|
||||
|
||||
if (item.isChoice === false) {
|
||||
// id, No, !choice parameter
|
||||
if (item.name === 'Trial id' || item.name === 'Trial No.') {
|
||||
str = str + `${item.name}:${item.value1}; `;
|
||||
} else {
|
||||
// !choice parameter
|
||||
if (['=', '≠', '>', '<'].includes(item.operator)) {
|
||||
str = str + `${filterName}${item.operator === '=' ? ':' : item.operator}${item.value1}; `;
|
||||
} else {
|
||||
// between
|
||||
str = str + `${filterName}:[${item.value1},${item.value2}]; `;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// status, choice parameter
|
||||
str = str + `${filterName}${item.operator === '=' ? ':' : '≠'}[${[...item.choice]}]; `;
|
||||
}
|
||||
});
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/***
|
||||
* from experiment search space
|
||||
* "conv_size": {
|
||||
"_type": "choice", // is choice type
|
||||
"_value": [
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
7
|
||||
]
|
||||
},
|
||||
*/
|
||||
function isChoiceType(parameterName): boolean {
|
||||
// 判断是 [choice, status] 还是普通的类型
|
||||
let flag = false; // normal type
|
||||
|
||||
if (parameterName === 'StatusNNI') {
|
||||
flag = true;
|
||||
}
|
||||
|
||||
if (parameterName in EXPERIMENT.searchSpace) {
|
||||
flag = EXPERIMENT.searchSpace[parameterName]._type === 'choice' ? true : false;
|
||||
}
|
||||
|
||||
return flag;
|
||||
}
|
||||
|
||||
export { classNames, getDropdownOptions, getTrialsBySearchFilters, getSearchInputValueBySearchList, isChoiceType };
|
|
@ -2,6 +2,7 @@ import * as JSON5 from 'json5';
|
|||
import axios from 'axios';
|
||||
import { IContextualMenuProps } from '@fluentui/react';
|
||||
import { MANAGER_IP } from './const';
|
||||
import { EXPERIMENT } from './datamodel';
|
||||
import { MetricDataRecord, FinalType, TableObj, Tensorboard } from './interface';
|
||||
|
||||
function getPrefix(): string | undefined {
|
||||
|
@ -356,6 +357,19 @@ function getTensorboardMenu(queryTensorboardList: Tensorboard[], stopFunc, seeDe
|
|||
|
||||
return tensorboardMenu;
|
||||
}
|
||||
|
||||
// search space type map list: now get type from search space
|
||||
const parametersType = (): Map<string, string> => {
|
||||
const parametersTypeMap = new Map();
|
||||
const trialParameterlist = Object.keys(EXPERIMENT.searchSpace);
|
||||
|
||||
trialParameterlist.forEach(item => {
|
||||
parametersTypeMap.set(item, typeof EXPERIMENT.searchSpace[item]._value[0]);
|
||||
});
|
||||
|
||||
return parametersTypeMap;
|
||||
};
|
||||
|
||||
export {
|
||||
getPrefix,
|
||||
convertTime,
|
||||
|
@ -381,5 +395,6 @@ export {
|
|||
caclMonacoEditorHeight,
|
||||
copyAndSort,
|
||||
disableTensorboard,
|
||||
getTensorboardMenu
|
||||
getTensorboardMenu,
|
||||
parametersType
|
||||
};
|
||||
|
|
|
@ -203,6 +203,16 @@ interface Tensorboard {
|
|||
port: string;
|
||||
}
|
||||
|
||||
// for TableList search
|
||||
interface SearchItems {
|
||||
name: string;
|
||||
operator: string;
|
||||
value1: string; // first input value
|
||||
value2: string; // second input value
|
||||
choice: string[]; // use select multiy value list
|
||||
isChoice: boolean; // for parameters: type = choice and status also as choice type
|
||||
}
|
||||
|
||||
export {
|
||||
TableObj,
|
||||
TableRecord,
|
||||
|
@ -226,5 +236,6 @@ export {
|
|||
MultipleAxes,
|
||||
SortInfo,
|
||||
AllExperimentList,
|
||||
Tensorboard
|
||||
Tensorboard,
|
||||
SearchItems
|
||||
};
|
||||
|
|
|
@ -73,3 +73,7 @@ $themeBlue: #0071bc;
|
|||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input-padding {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
|
|
@ -3,23 +3,17 @@
|
|||
width: 96%;
|
||||
margin: 0 auto;
|
||||
margin-top: 15px;
|
||||
position: relative;
|
||||
|
||||
&-compare {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
&-entry {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
/* compare button style */
|
||||
&-button-gap {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&-search-input {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* each row's Intermediate btn -> Modal */
|
||||
|
@ -33,3 +27,32 @@
|
|||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
$filterConditionsHeight: 54px;
|
||||
|
||||
.filterConditions {
|
||||
height: $filterConditionsHeight;
|
||||
line-height: $filterConditionsHeight;
|
||||
padding: 0 15px;
|
||||
|
||||
.btn-vertical-middle {
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100px;
|
||||
height: 24px;
|
||||
margin-top: 13px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 20px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.and {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ms-ContextualMenu-Callout {
|
||||
display: block;
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче