fix(trace viewer): follow up with recent instrumentation changes (#5488)

- List all actions we are interested in
- Fix timeline hover flicker
- Extract tabbed pane component
- Preview snapshots without clicking on the action
This commit is contained in:
Dmitry Gozman 2021-02-17 17:51:57 -08:00 committed by GitHub
parent 3248c2449c
commit da135c2abb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 252 additions and 226 deletions

View File

@ -64,6 +64,8 @@ export type VideoMetaInfo = {
endTime: number; endTime: number;
}; };
const kInterestingActions = ['click', 'dblclick', 'hover', 'check', 'uncheck', 'tap', 'fill', 'press', 'type', 'selectOption', 'setInputFiles', 'goto', 'setContent', 'goBack', 'goForward', 'reload'];
export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel, filePath: string) { export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel, filePath: string) {
const contextEntries = new Map<string, ContextEntry>(); const contextEntries = new Map<string, ContextEntry>();
const pageEntries = new Map<string, PageEntry>(); const pageEntries = new Map<string, PageEntry>();
@ -108,6 +110,8 @@ export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel
break; break;
} }
case 'action': { case 'action': {
if (!kInterestingActions.includes(event.method))
break;
const pageEntry = pageEntries.get(event.pageId!)!; const pageEntry = pageEntries.get(event.pageId!)!;
const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length; const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length;
const action: ActionEntry = { const action: ActionEntry = {

View File

@ -1,135 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed 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 { ActionEntry } from '../../../cli/traceViewer/traceModel';
import { Boundaries, Size } from '../geometry';
import { NetworkTab } from './networkTab';
import { SourceTab } from './sourceTab';
import './propertiesTabbedPane.css';
import * as React from 'react';
import { msToString, useMeasure } from './helpers';
import { LogsTab } from './logsTab';
export const PropertiesTabbedPane: React.FunctionComponent<{
actionEntry: ActionEntry | undefined,
snapshotSize: Size,
selectedTime: number | undefined,
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
const [selected, setSelected] = React.useState<'snapshot' | 'source' | 'network' | 'logs'>('snapshot');
return <div className='properties-tabbed-pane'>
<div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='properties-tab-strip'>
<div className={'properties-tab-element ' + (selected === 'snapshot' ? 'selected' : '')}
onClick={() => setSelected('snapshot')}>
<div className='properties-tab-label'>Snapshot</div>
</div>
<div className={'properties-tab-element ' + (selected === 'source' ? 'selected' : '')}
onClick={() => setSelected('source')}>
<div className='properties-tab-label'>Source</div>
</div>
<div className={'properties-tab-element ' + (selected === 'network' ? 'selected' : '')}
onClick={() => setSelected('network')}>
<div className='properties-tab-label'>Network</div>
</div>
<div className={'properties-tab-element ' + (selected === 'logs' ? 'selected' : '')}
onClick={() => setSelected('logs')}>
<div className='properties-tab-label'>Logs</div>
</div>
</div>
</div>
{ selected === 'snapshot' && <div className='properties-tab-content'>
<SnapshotTab actionEntry={actionEntry} snapshotSize={snapshotSize} selectedTime={selectedTime} boundaries={boundaries} />
</div> }
{ selected === 'source' && <div className='properties-tab-content'>
<SourceTab actionEntry={actionEntry} />
</div> }
{ selected === 'network' && <div className='properties-tab-content'>
<NetworkTab actionEntry={actionEntry} />
</div> }
{ selected === 'logs' && <div className='properties-tab-content'>
<LogsTab actionEntry={actionEntry} />
</div> }
</div>
</div>;
};
const SnapshotTab: React.FunctionComponent<{
actionEntry: ActionEntry | undefined,
snapshotSize: Size,
selectedTime: number | undefined,
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = [];
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
if (!snapshots.length || snapshots[0].name !== 'before')
snapshots.unshift({ name: 'before', snapshotTime: actionEntry ? actionEntry.action.startTime : 0 });
if (snapshots[snapshots.length - 1].name !== 'after')
snapshots.push({ name: 'after', snapshotTime: actionEntry ? actionEntry.action.endTime : 0 });
const iframeRef = React.createRef<HTMLIFrameElement>();
React.useEffect(() => {
if (!actionEntry || !iframeRef.current)
return;
// TODO: this logic is copied from SnapshotServer. Find a way to share.
let snapshotUrl = 'data:text/html,Snapshot is not available';
if (selectedTime) {
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${selectedTime}/main`;
} else {
const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotTime)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`;
else if (snapshot && snapshot.snapshotId)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`;
}
try {
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl);
} catch (e) {
}
}, [actionEntry, snapshotIndex, selectedTime]);
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
return <div className='snapshot-tab'>
<div className='snapshot-controls'>{
selectedTime && <div key='selectedTime' className='snapshot-toggle'>
{msToString(selectedTime - boundaries.minimum)}
</div>
}{!selectedTime && snapshots.map((snapshot, index) => {
return <div
key={snapshot.name}
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
onClick={() => setSnapshotIndex(index)}>
{snapshot.name}
</div>
})
}</div>
<div ref={ref} className='snapshot-wrapper'>
<div className='snapshot-container' style={{
width: snapshotSize.width + 'px',
height: snapshotSize.height + 'px',
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
}}>
<iframe ref={iframeRef} id='snapshot' name='snapshot' src='/snapshot/'></iframe>
</div>
</div>
</div>;
};

View File

@ -0,0 +1,54 @@
/*
Copyright (c) Microsoft Corporation.
Licensed 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.
*/
.snapshot-tab {
display: flex;
flex-direction: column;
align-items: stretch;
}
.snapshot-controls {
flex: 0 0 24px;
display: flex;
flex-direction: row;
align-items: center;
}
.snapshot-toggle {
padding: 5px 10px;
cursor: pointer;
}
.snapshot-toggle.toggled {
background: var(--inactive-focus-ring);
}
.snapshot-wrapper {
flex: auto;
margin: 1px;
}
.snapshot-container {
display: block;
background: white;
outline: 1px solid #aaa;
}
iframe#snapshot {
width: 100%;
height: 100%;
border: none;
}

View File

@ -0,0 +1,90 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed 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 { ActionEntry } from '../../../cli/traceViewer/traceModel';
import { Boundaries, Size } from '../geometry';
import './snapshotTab.css';
import * as React from 'react';
import { msToString, useMeasure } from './helpers';
export const SnapshotTab: React.FunctionComponent<{
actionEntry: ActionEntry | undefined,
snapshotSize: Size,
selection: { pageId: string, time: number } | undefined,
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selection, boundaries }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = [];
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
if (actionEntry) {
if (!snapshots.length || snapshots[0].name !== 'before')
snapshots.unshift({ name: 'before', snapshotTime: actionEntry ? actionEntry.action.startTime : 0 });
if (snapshots[snapshots.length - 1].name !== 'after')
snapshots.push({ name: 'after', snapshotTime: actionEntry ? actionEntry.action.endTime : 0 });
}
const { pageId, time } = selection || { pageId: undefined, time: 0 };
const iframeRef = React.createRef<HTMLIFrameElement>();
React.useEffect(() => {
if (!iframeRef.current)
return;
// TODO: this logic is copied from SnapshotServer. Find a way to share.
let snapshotUrl = 'data:text/html,Snapshot is not available';
if (pageId) {
snapshotUrl = `/snapshot/pageId/${pageId}/timestamp/${time}/main`;
} else if (actionEntry) {
const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotTime)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`;
else if (snapshot && snapshot.snapshotId)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`;
}
try {
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl);
} catch (e) {
}
}, [actionEntry, snapshotIndex, pageId, time]);
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
return <div className='snapshot-tab'>
<div className='snapshot-controls'>{
selection && <div key='selectedTime' className='snapshot-toggle'>
{msToString(selection.time - boundaries.minimum)}
</div>
}{!selection && snapshots.map((snapshot, index) => {
return <div
key={snapshot.name}
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
onClick={() => setSnapshotIndex(index)}>
{snapshot.name}
</div>
})
}</div>
<div ref={ref} className='snapshot-wrapper'>
<div className='snapshot-container' style={{
width: snapshotSize.width + 'px',
height: snapshotSize.height + 'px',
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
}}>
<iframe ref={iframeRef} id='snapshot' name='snapshot' src='/snapshot/'></iframe>
</div>
</div>
</div>;
};

View File

@ -14,19 +14,19 @@
limitations under the License. limitations under the License.
*/ */
.properties-tabbed-pane { .tabbed-pane {
display: flex; display: flex;
flex: auto; flex: auto;
overflow: hidden; overflow: hidden;
} }
.properties-tab-content { .tab-content {
display: flex; display: flex;
flex: auto; flex: auto;
overflow: hidden; overflow: hidden;
} }
.properties-tab-strip { .tab-strip {
flex: auto; flex: auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -34,11 +34,11 @@
height: 34px; height: 34px;
} }
.properties-tab-strip:focus { .tab-strip:focus {
outline: none; outline: none;
} }
.properties-tab-element { .tab-element {
padding: 2px 6px 0 6px; padding: 2px 6px 0 6px;
margin-right: 4px; margin-right: 4px;
cursor: pointer; cursor: pointer;
@ -52,7 +52,7 @@
outline: none; outline: none;
} }
.properties-tab-label { .tab-label {
max-width: 250px; max-width: 250px;
white-space: pre; white-space: pre;
overflow: hidden; overflow: hidden;
@ -60,49 +60,10 @@
display: inline-block; display: inline-block;
} }
.properties-tab-element.selected { .tab-element.selected {
border-bottom-color: var(--color); border-bottom-color: var(--color);
} }
.properties-tab-element:hover { .tab-element:hover {
font-weight: 600; font-weight: 600;
} }
.snapshot-tab {
display: flex;
flex-direction: column;
align-items: stretch;
}
.snapshot-controls {
flex: 0 0 24px;
display: flex;
flex-direction: row;
align-items: center;
}
.snapshot-toggle {
padding: 5px 10px;
cursor: pointer;
}
.snapshot-toggle.toggled {
background: var(--inactive-focus-ring);
}
.snapshot-wrapper {
flex: auto;
margin: 1px;
}
.snapshot-container {
display: block;
background: white;
outline: 1px solid #aaa;
}
iframe#snapshot {
width: 100%;
height: 100%;
border: none;
}

View File

@ -0,0 +1,51 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed 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 './tabbedPane.css';
import * as React from 'react';
export interface TabbedPaneTab {
id: string;
title: string;
render: () => React.ReactElement;
}
export const TabbedPane: React.FunctionComponent<{
tabs: TabbedPaneTab[],
}> = ({ tabs }) => {
const [selected, setSelected] = React.useState<string>(tabs.length ? tabs[0].id : '');
return <div className='tabbed-pane'>
<div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='tab-strip'>{
tabs.map(tab => {
return <div className={'tab-element ' + (selected === tab.id ? 'selected' : '')}
onClick={() => setSelected(tab.id)}
key={tab.id}>
<div className='tab-label'>{tab.title}</div>
</div>
})
}</div>
</div>
{
tabs.map(tab => {
if (selected === tab.id)
return <div key={tab.id} className='tab-content'>{tab.render()}</div>;
})
}
</div>
</div>;
};

View File

@ -43,13 +43,12 @@ export const Timeline: React.FunctionComponent<{
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onTimeSelected }) => { }> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onTimeSelected }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
const [previewX, setPreviewX] = React.useState<number | undefined>(); const [previewX, setPreviewX] = React.useState<number | undefined>();
const [hoveredBar, setHoveredBar] = React.useState<TimelineBar | undefined>(); const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
const offsets = React.useMemo(() => { const offsets = React.useMemo(() => {
return calculateDividerOffsets(measure.width, boundaries); return calculateDividerOffsets(measure.width, boundaries);
}, [measure.width, boundaries]); }, [measure.width, boundaries]);
let targetBar: TimelineBar | undefined = hoveredBar;
const bars = React.useMemo(() => { const bars = React.useMemo(() => {
const bars: TimelineBar[] = []; const bars: TimelineBar[] = [];
for (const page of context.pages) { for (const page of context.pages) {
@ -67,8 +66,6 @@ export const Timeline: React.FunctionComponent<{
type: entry.action.method, type: entry.action.method,
priority: 0, priority: 0,
}); });
if (entry === (highlightedAction || selectedAction))
targetBar = bars[bars.length - 1];
} }
let lastDialogOpened: trace.DialogOpenedEvent | undefined; let lastDialogOpened: trace.DialogOpenedEvent | undefined;
for (const event of page.interestingEvents) { for (const event of page.interestingEvents) {
@ -116,56 +113,55 @@ export const Timeline: React.FunctionComponent<{
return bars; return bars;
}, [context, boundaries, measure.width]); }, [context, boundaries, measure.width]);
const findHoveredBar = (x: number) => { const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined;
let targetBar: TimelineBar | undefined = bars.find(bar => bar.entry === (highlightedAction || selectedAction));
targetBar = hoveredBar || targetBar;
const findHoveredBarIndex = (x: number) => {
const time = positionToTime(measure.width, boundaries, x); const time = positionToTime(measure.width, boundaries, x);
const time1 = positionToTime(measure.width, boundaries, x - 5); const time1 = positionToTime(measure.width, boundaries, x - 5);
const time2 = positionToTime(measure.width, boundaries, x + 5); const time2 = positionToTime(measure.width, boundaries, x + 5);
let bar: TimelineBar | undefined; let index: number | undefined;
let distance: number | undefined; let distance: number | undefined;
for (const b of bars) { for (let i = 0; i < bars.length; i++) {
const left = Math.max(b.leftTime, time1); const bar = bars[i];
const right = Math.min(b.rightTime, time2); const left = Math.max(bar.leftTime, time1);
const middle = (b.leftTime + b.rightTime) / 2; const right = Math.min(bar.rightTime, time2);
const middle = (bar.leftTime + bar.rightTime) / 2;
const d = Math.abs(time - middle); const d = Math.abs(time - middle);
if (left <= right && (!bar || d < distance!)) { if (left <= right && (index === undefined || d < distance!)) {
bar = b; index = i;
distance = d; distance = d;
} }
} }
return bar; return index;
}; };
const onMouseMove = (event: React.MouseEvent) => { const onMouseMove = (event: React.MouseEvent) => {
if (ref.current) { if (!ref.current)
const x = event.clientX - ref.current.getBoundingClientRect().left; return;
setPreviewX(x); const x = event.clientX - ref.current.getBoundingClientRect().left;
onTimeSelected(positionToTime(measure.width, boundaries, x)); setPreviewX(x);
setHoveredBar(findHoveredBar(x)); onTimeSelected(positionToTime(measure.width, boundaries, x));
} setHoveredBarIndex(findHoveredBarIndex(x));
}; };
const onMouseLeave = () => { const onMouseLeave = () => {
setPreviewX(undefined); setPreviewX(undefined);
onTimeSelected(undefined); onTimeSelected(undefined);
}; };
const onActionClick = (event: React.MouseEvent) => { const onClick = (event: React.MouseEvent) => {
if (ref.current) { if (!ref.current)
const x = event.clientX - ref.current.getBoundingClientRect().left; return;
const bar = findHoveredBar(x); const x = event.clientX - ref.current.getBoundingClientRect().left;
if (bar && bar.entry) const index = findHoveredBarIndex(x);
onSelected(bar.entry); if (index === undefined)
event.stopPropagation(); return;
} const entry = bars[index].entry;
}; if (entry)
const onTimeClick = (event: React.MouseEvent) => { onSelected(entry);
if (ref.current) {
const x = event.clientX - ref.current.getBoundingClientRect().left;
const time = positionToTime(measure.width, boundaries, x);
onTimeSelected(time);
event.stopPropagation();
}
}; };
return <div ref={ref} className='timeline-view' onMouseMove={onMouseMove} onMouseOver={onMouseMove} onMouseLeave={onMouseLeave} onClick={onTimeClick}> return <div ref={ref} className='timeline-view' onMouseMove={onMouseMove} onMouseOver={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick}>
<div className='timeline-grid'>{ <div className='timeline-grid'>{
offsets.map((offset, index) => { offsets.map((offset, index) => {
return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}> return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}>
@ -186,7 +182,7 @@ export const Timeline: React.FunctionComponent<{
</div>; </div>;
}) })
}</div> }</div>
<div className='timeline-lane timeline-bars' onClick={onActionClick}>{ <div className='timeline-lane timeline-bars'>{
bars.map((bar, index) => { bars.map((bar, index) => {
return <div key={index} return <div key={index}
className={'timeline-bar ' + bar.type + (targetBar === bar ? ' selected' : '')} className={'timeline-bar ' + bar.type + (targetBar === bar ? ' selected' : '')}

View File

@ -16,11 +16,15 @@
import { ActionEntry, TraceModel } from '../../../cli/traceViewer/traceModel'; import { ActionEntry, TraceModel } from '../../../cli/traceViewer/traceModel';
import { ActionList } from './actionList'; import { ActionList } from './actionList';
import { PropertiesTabbedPane } from './propertiesTabbedPane'; import { TabbedPane } from './tabbedPane';
import { Timeline } from './timeline'; import { Timeline } from './timeline';
import './workbench.css'; import './workbench.css';
import * as React from 'react'; import * as React from 'react';
import { ContextSelector } from './contextSelector'; import { ContextSelector } from './contextSelector';
import { NetworkTab } from './networkTab';
import { SourceTab } from './sourceTab';
import { SnapshotTab } from './snapshotTab';
import { LogsTab } from './logsTab';
export const Workbench: React.FunctionComponent<{ export const Workbench: React.FunctionComponent<{
traceModel: TraceModel, traceModel: TraceModel,
@ -39,6 +43,7 @@ export const Workbench: React.FunctionComponent<{
const snapshotSize = context.created.viewportSize || { width: 1280, height: 720 }; const snapshotSize = context.created.viewportSize || { width: 1280, height: 720 };
const boundaries = { minimum: context.startTime, maximum: context.endTime }; const boundaries = { minimum: context.startTime, maximum: context.endTime };
const snapshotSelection = context.pages.length && selectedTime !== undefined ? { pageId: context.pages[0].created.pageId, time: selectedTime } : undefined;
return <div className='vbox workbench'> return <div className='vbox workbench'>
<div className='hbox header'> <div className='hbox header'>
@ -78,12 +83,12 @@ export const Workbench: React.FunctionComponent<{
onHighlighted={action => setHighlightedAction(action)} onHighlighted={action => setHighlightedAction(action)}
/> />
</div> </div>
<PropertiesTabbedPane <TabbedPane tabs={[
actionEntry={selectedAction} { id: 'snapshot', title: 'Snapshot', render: () => <SnapshotTab actionEntry={selectedAction} snapshotSize={snapshotSize} selection={snapshotSelection} boundaries={boundaries} /> },
snapshotSize={snapshotSize} { id: 'source', title: 'Source', render: () => <SourceTab actionEntry={selectedAction} /> },
selectedTime={selectedTime} { id: 'network', title: 'Network', render: () => <NetworkTab actionEntry={selectedAction} /> },
boundaries={boundaries} { id: 'logs', title: 'Logs', render: () => <LogsTab actionEntry={selectedAction} /> },
/> ]}/>
</div> </div>
</div>; </div>;
}; };