mirror of https://github.com/apache/druid.git
Add lookups view to allow adding/editing/deleting of druid lookups (#7259)
* Add lookups view to allow adding/editing/deleting of druid lookups * Remove unused bp3 class * Make lookup editor dialog wider
This commit is contained in:
parent
5406aaa49d
commit
1b6b40e511
|
@ -49,6 +49,8 @@ export const IconNames = {
|
|||
CARET_DOWN: "caret-down" as "caret-down",
|
||||
ARROW_UP: "arrow-up" as "arrow-up",
|
||||
ARROW_DOWN: "arrow-down" as "arrow-down",
|
||||
PROPERTIES: "properties" as "properties",
|
||||
BUILD: "build" as "build"
|
||||
};
|
||||
export type IconNames = typeof IconNames[keyof typeof IconNames];
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ import {
|
|||
LEGACY_OVERLORD_CONSOLE
|
||||
} from '../variables';
|
||||
|
||||
export type HeaderActiveTab = null | 'datasources' | 'segments' | 'tasks' | 'servers' | 'sql';
|
||||
export type HeaderActiveTab = null | 'datasources' | 'segments' | 'tasks' | 'servers' | 'sql' | 'lookups';
|
||||
|
||||
export interface HeaderBarProps extends React.Props<any> {
|
||||
active: HeaderActiveTab;
|
||||
|
@ -100,6 +100,7 @@ export class HeaderBar extends React.Component<HeaderBarProps, HeaderBarState> {
|
|||
|
||||
const configMenu = <Menu>
|
||||
<MenuItem iconName={IconNames.COG} text="Coordinator dynamic config" onClick={() => this.setState({ coordinatorDynamicConfigDialogOpen: true })}/>
|
||||
<MenuItem iconName={IconNames.PROPERTIES} className={classNames(Classes.MINIMAL, { 'pt-active': active === 'lookups' })} text="Lookups" href="#lookups"/>
|
||||
</Menu>;
|
||||
|
||||
return <Navbar className="header-bar">
|
||||
|
|
|
@ -31,6 +31,7 @@ import { SegmentsView } from './views/segments-view';
|
|||
import { ServersView } from './views/servers-view';
|
||||
import { TasksView } from './views/tasks-view';
|
||||
import { SqlView } from './views/sql-view';
|
||||
import { LookupsView } from "./views/lookups-view";
|
||||
import "./console-application.scss";
|
||||
|
||||
export interface ConsoleApplicationProps extends React.Props<any> {
|
||||
|
@ -156,6 +157,9 @@ export class ConsoleApplication extends React.Component<ConsoleApplicationProps,
|
|||
<Route path="/sql" component={() => {
|
||||
return wrapInViewContainer('sql', <SqlView initSql={this.initSql}/>);
|
||||
}} />
|
||||
<Route path="/lookups" component={() => {
|
||||
return wrapInViewContainer('lookups', <LookupsView />);
|
||||
}} />
|
||||
<Route component={() => {
|
||||
return wrapInViewContainer(null, <HomeView/>)
|
||||
}} />
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.lookup-edit-dialog {
|
||||
top: 10vh;
|
||||
|
||||
width: 600px;
|
||||
|
||||
.ace_editor{
|
||||
margin: 0px 20px 10px;
|
||||
}
|
||||
|
||||
.lookup-label {
|
||||
padding: 0 20px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ace_scroller {
|
||||
background-color: #232C35;
|
||||
}
|
||||
|
||||
.ace_gutter-layer {
|
||||
background-color: #27313c;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {Button, Classes, Dialog, Intent, InputGroup } from "@blueprintjs/core";
|
||||
import "./lookup-edit-dialog.scss"
|
||||
import {validJson} from "../utils";
|
||||
import AceEditor from "react-ace";
|
||||
import {FormGroup} from "../components/filler";
|
||||
|
||||
export interface LookupEditDialogProps extends React.Props<any> {
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
onSubmit: () => void,
|
||||
onChange: (field: string, value: string) => void
|
||||
lookupName: string,
|
||||
lookupTier: string,
|
||||
lookupVersion: string,
|
||||
lookupSpec: string,
|
||||
isEdit: boolean,
|
||||
allLookupTiers: string[]
|
||||
}
|
||||
|
||||
export interface LookupEditDialogState {
|
||||
}
|
||||
|
||||
export class LookupEditDialog extends React.Component<LookupEditDialogProps, LookupEditDialogState> {
|
||||
|
||||
constructor(props: LookupEditDialogProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private addISOVersion = () => {
|
||||
const {onChange} = this.props;
|
||||
const currentDate = new Date();
|
||||
const ISOString = currentDate.toISOString();
|
||||
onChange("lookupEditVersion", ISOString);
|
||||
}
|
||||
|
||||
private renderTierInput() {
|
||||
const { isEdit, lookupTier, allLookupTiers, onChange } = this.props;
|
||||
if (isEdit) {
|
||||
return <FormGroup className={"lookup-label"} label={"Tier: "}>
|
||||
<InputGroup
|
||||
value={lookupTier}
|
||||
onChange={(e: any) => onChange("lookupEditTier", e.target.value)}
|
||||
disabled={true}
|
||||
/>
|
||||
</FormGroup>
|
||||
} else {
|
||||
return <FormGroup className={"lookup-label"} label={"Tier:"}>
|
||||
<div className="pt-select">
|
||||
<select disabled={isEdit} value={lookupTier} onChange={(e:any) => onChange("lookupEditTier", e.target.value)}>
|
||||
{
|
||||
allLookupTiers.map(tier => {
|
||||
return <option key={tier} value={tier}>{tier}</option>
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</FormGroup>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, onClose, onSubmit, lookupSpec, lookupTier, lookupName, lookupVersion, onChange, isEdit, allLookupTiers } = this.props;
|
||||
|
||||
const disableSubmit = lookupName === "" || lookupVersion === "" ||
|
||||
lookupTier === "" || !validJson(lookupSpec);
|
||||
|
||||
return <Dialog
|
||||
className={"lookup-edit-dialog"}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Edit lookup" : "Add lookup"}
|
||||
>
|
||||
<FormGroup className={"lookup-label"} label={"Name: "}>
|
||||
<InputGroup
|
||||
value={lookupName}
|
||||
onChange={(e: any) => onChange("lookupEditName", e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder={"Enter the lookup name"}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{ this.renderTierInput() }
|
||||
|
||||
<FormGroup className={"lookup-label"} label={"Version:"}>
|
||||
<InputGroup
|
||||
value={lookupVersion}
|
||||
onChange={(e: any) => onChange("lookupEditVersion", e.target.value)}
|
||||
placeholder={"Enter the lookup version"}
|
||||
rightElement={<Button className={"pt-minimal"} text={"Use ISO as version"} onClick={() => this.addISOVersion()} />}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className={"lookup-label"} label={"Spec:"}/>
|
||||
|
||||
<AceEditor
|
||||
className={"lookup-edit-dialog-textarea"}
|
||||
mode="sql"
|
||||
theme="solarized_dark"
|
||||
onChange={
|
||||
(e: any) => onChange("lookupEditSpec", e)
|
||||
}
|
||||
fontSize={12}
|
||||
height={"40vh"}
|
||||
width={"auto"}
|
||||
showPrintMargin={false}
|
||||
showGutter={false}
|
||||
value={lookupSpec}
|
||||
editorProps={{$blockScrolling: Infinity}}
|
||||
setOptions={{
|
||||
enableBasicAutocompletion: false,
|
||||
enableLiveAutocompletion: false,
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button
|
||||
text="Close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Button
|
||||
text="Submit"
|
||||
intent={Intent.PRIMARY}
|
||||
onClick={() => onSubmit()}
|
||||
disabled={disableSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
}
|
|
@ -137,3 +137,14 @@ export function localStorageGet(key: string): string | null {
|
|||
if (typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
|
||||
export function validJson(json: string): boolean {
|
||||
try {
|
||||
JSON.parse(json);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.lookups-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.ReactTable {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.init-div {
|
||||
text-align: center;
|
||||
margin-top: 35vh;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,306 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import * as React from 'react';
|
||||
import * as classNames from 'classnames';
|
||||
import ReactTable from "react-table";
|
||||
import { Filter } from "react-table";
|
||||
import { Button, Intent } from "@blueprintjs/core";
|
||||
import {getDruidErrorMessage, QueryManager} from "../utils";
|
||||
import {LookupEditDialog} from "../dialogs/lookup-edit-dialog";
|
||||
import { AppToaster } from "../singletons/toaster";
|
||||
import "./lookups-view.scss";
|
||||
|
||||
export interface LookupsViewProps extends React.Props<any> {
|
||||
|
||||
}
|
||||
|
||||
export interface LookupsViewState {
|
||||
lookups: {}[],
|
||||
loadingLookups: boolean,
|
||||
lookupsError: string | null,
|
||||
lookupEditDialogOpen: boolean,
|
||||
lookupEditName: string,
|
||||
lookupEditTier: string,
|
||||
lookupEditVersion: string,
|
||||
lookupEditSpec: string,
|
||||
isEdit: boolean,
|
||||
allLookupTiers: string[]
|
||||
}
|
||||
|
||||
export class LookupsView extends React.Component<LookupsViewProps, LookupsViewState> {
|
||||
private lookupsGetQueryManager: QueryManager<string, {lookupEntries: any[], tiers: string[]}>;
|
||||
private lookupDeleteQueryManager: QueryManager<string, any[]>;
|
||||
|
||||
constructor(props: LookupsViewProps, context: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
lookups: [],
|
||||
loadingLookups: true,
|
||||
lookupsError: null,
|
||||
lookupEditDialogOpen: false,
|
||||
lookupEditTier: "",
|
||||
lookupEditName: "",
|
||||
lookupEditVersion: "",
|
||||
lookupEditSpec: "",
|
||||
isEdit: false,
|
||||
allLookupTiers: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.lookupsGetQueryManager = new QueryManager({
|
||||
processQuery: async (query: string) => {
|
||||
const tiersResp = await axios.get('/druid/coordinator/v1/tiers');
|
||||
const tiers = tiersResp.data;
|
||||
|
||||
let lookupEntries: {}[] = [];
|
||||
const lookupResp = await axios.get("/druid/coordinator/v1/lookups/config/all");
|
||||
const lookupData = lookupResp.data;
|
||||
Object.keys(lookupData).map((tier: string) => {
|
||||
const lookupIds = lookupData[tier];
|
||||
Object.keys(lookupIds).map((id: string) => {
|
||||
lookupEntries.push({tier: tier, id: id, version:lookupData[tier][id].version, spec: lookupData[tier][id].lookupExtractorFactory},);
|
||||
})
|
||||
})
|
||||
return {
|
||||
lookupEntries,
|
||||
tiers
|
||||
};
|
||||
},
|
||||
onStateChange: ({ result, loading, error }) => {
|
||||
this.setState({
|
||||
lookups: result === null ? [] : result.lookupEntries,
|
||||
loadingLookups: loading,
|
||||
lookupsError: error,
|
||||
allLookupTiers: result === null ? [] : result.tiers
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.lookupsGetQueryManager.runQuery("dummy");
|
||||
|
||||
this.lookupDeleteQueryManager = new QueryManager({
|
||||
processQuery: async (url: string) => {
|
||||
const lookupDeleteResp = await axios.delete(url);
|
||||
return lookupDeleteResp.data;
|
||||
},
|
||||
onStateChange: ({}) => {
|
||||
this.lookupsGetQueryManager.rerunLastQuery();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.lookupsGetQueryManager.terminate();
|
||||
this.lookupDeleteQueryManager.terminate();
|
||||
}
|
||||
|
||||
private async initializeLookup() {
|
||||
try {
|
||||
await axios.post(`/druid/coordinator/v1/lookups/config`, {});
|
||||
this.lookupsGetQueryManager.rerunLastQuery();
|
||||
} catch (e) {
|
||||
AppToaster.show(
|
||||
{
|
||||
iconName: 'error',
|
||||
intent: Intent.DANGER,
|
||||
message: getDruidErrorMessage(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async openLookupEditDialog(tier:string, id: string) {
|
||||
const { lookups, allLookupTiers } = this.state;
|
||||
const target: any = lookups.find((lookupEntry: any) => {
|
||||
return lookupEntry.tier === tier && lookupEntry.id === id;
|
||||
});
|
||||
if (id === "") {
|
||||
this.setState({
|
||||
lookupEditName: "",
|
||||
lookupEditTier: allLookupTiers[0],
|
||||
lookupEditDialogOpen: true,
|
||||
lookupEditSpec: "",
|
||||
lookupEditVersion: (new Date()).toISOString(),
|
||||
isEdit: false
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
lookupEditName: id,
|
||||
lookupEditTier: tier,
|
||||
lookupEditDialogOpen: true,
|
||||
lookupEditSpec: JSON.stringify(target.spec, null, 2),
|
||||
lookupEditVersion: target.version,
|
||||
isEdit: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private changeLookup(field: string, value: string) {
|
||||
this.setState({
|
||||
[field]: value
|
||||
} as any)
|
||||
}
|
||||
|
||||
private async submitLookupEdit() {
|
||||
const { lookupEditTier, lookupEditName, lookupEditSpec, lookupEditVersion, isEdit } = this.state;
|
||||
let endpoint = "/druid/coordinator/v1/lookups/config";
|
||||
const specJSON: any = JSON.parse(lookupEditSpec);
|
||||
let dataJSON: any;
|
||||
if (isEdit) {
|
||||
endpoint = `${endpoint}/${lookupEditTier}/${lookupEditName}`;
|
||||
dataJSON = {
|
||||
version: lookupEditVersion,
|
||||
lookupExtractorFactory: specJSON
|
||||
};
|
||||
} else {
|
||||
dataJSON = {
|
||||
[lookupEditTier]: {
|
||||
[lookupEditName]: {
|
||||
version: lookupEditVersion,
|
||||
lookupExtractorFactory: specJSON
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
try {
|
||||
await axios.post(endpoint, dataJSON);
|
||||
this.setState({
|
||||
lookupEditDialogOpen: false
|
||||
})
|
||||
this.lookupsGetQueryManager.rerunLastQuery();
|
||||
} catch(e) {
|
||||
AppToaster.show(
|
||||
{
|
||||
iconName: 'error',
|
||||
intent: Intent.DANGER,
|
||||
message: getDruidErrorMessage(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private deleteLookup(tier:string, name: string): void {
|
||||
const url = `/druid/coordinator/v1/lookups/config/${tier}/${name}`;
|
||||
this.lookupDeleteQueryManager.runQuery(url);
|
||||
}
|
||||
|
||||
renderLookupsTable() {
|
||||
const { lookups, loadingLookups, lookupsError} = this.state;
|
||||
if (lookupsError) {
|
||||
return <div className={"init-div"}>
|
||||
<Button
|
||||
iconName="build"
|
||||
text="Initialize Lookup"
|
||||
onClick={() => this.initializeLookup()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
return <>
|
||||
<ReactTable
|
||||
data={lookups}
|
||||
loading={loadingLookups}
|
||||
noDataText={!loadingLookups && lookups && !lookups.length ? 'No lookups' : (lookupsError || '')}
|
||||
filterable={true}
|
||||
columns={[
|
||||
{
|
||||
Header: "Lookup Name",
|
||||
id: "lookup_name",
|
||||
accessor: (row: any) => row.id,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
Header: "Tier",
|
||||
id: "tier",
|
||||
accessor: (row: any) => row.tier,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
Header: "Type",
|
||||
id: "type",
|
||||
accessor: (row: any) => row.spec.type,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
Header: "Version",
|
||||
id: "version",
|
||||
accessor: (row: any) => row.version,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
Header: "Config",
|
||||
id: "config",
|
||||
accessor: row => {return {id: row.id, tier: row.tier};},
|
||||
filterable: false,
|
||||
Cell: (row: any) => {
|
||||
const lookupId = row.value.id;
|
||||
const lookupTier = row.value.tier;
|
||||
return <div>
|
||||
<a onClick={() => this.openLookupEditDialog(lookupTier,lookupId)}>Edit</a>
|
||||
|
||||
<a onClick={() => this.deleteLookup(lookupTier,lookupId)}>Delete</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
]}
|
||||
defaultPageSize={50}
|
||||
className="-striped -highlight"
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
renderLookupEditDialog () {
|
||||
const { lookupEditDialogOpen, allLookupTiers, lookupEditSpec, lookupEditTier, lookupEditName, lookupEditVersion, isEdit } = this.state
|
||||
|
||||
return <LookupEditDialog
|
||||
isOpen={lookupEditDialogOpen}
|
||||
onClose={() => this.setState({ lookupEditDialogOpen: false })}
|
||||
onSubmit={() => this.submitLookupEdit()}
|
||||
onChange={(field: string, value: string) => this.changeLookup(field, value)}
|
||||
lookupSpec= {lookupEditSpec}
|
||||
lookupName={lookupEditName}
|
||||
lookupTier={lookupEditTier}
|
||||
lookupVersion = {lookupEditVersion}
|
||||
isEdit={isEdit}
|
||||
allLookupTiers={allLookupTiers}
|
||||
/>
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="lookups-view app-view">
|
||||
<div className="control-bar">
|
||||
<div className="control-label">Lookups</div>
|
||||
<Button
|
||||
iconName="refresh"
|
||||
text="Refresh"
|
||||
onClick={() => this.lookupsGetQueryManager.rerunLastQuery()}
|
||||
/>
|
||||
<Button
|
||||
iconName="plus"
|
||||
text="Add"
|
||||
style={{display: this.state.lookupsError !== null ? 'none' : 'inline'}}
|
||||
onClick={() => this.openLookupEditDialog("", "")}
|
||||
/>
|
||||
</div>
|
||||
{this.renderLookupsTable()}
|
||||
{this.renderLookupEditDialog()}
|
||||
</div>
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue