Skip to content

Commit

Permalink
Allow configuring longitude wrapping and ClusterRadius of Leaflet Mar…
Browse files Browse the repository at this point in the history
…kers; tweak edtior styles
  • Loading branch information
Gowee committed May 18, 2020
1 parent 7bc4672 commit 85e79f9
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 69 deletions.
107 changes: 69 additions & 38 deletions src/TracerouteMapEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { Forms } from '@grafana/ui';
import { Forms, Slider } from '@grafana/ui';
import { PanelEditorProps, SelectableValue } from '@grafana/data';

import { TracerouteMapOptions } from './types';
import { GeoIPProviderKind, GeoIPProvider, IPInfo, CustomAPI, IP2Geo, CustomFunction } from './geoip';
import { CodeSnippets, timeout } from './utils';

interface Props extends PanelEditorProps<TracerouteMapOptions> {}
interface Props extends PanelEditorProps<TracerouteMapOptions> { }

interface State {
geoIPProvider: GeoIPProvider;
Expand All @@ -24,9 +24,10 @@ export class TracerouteMapEditor extends PureComponent<PanelEditorProps<Tracerou
this.handleGeoIPProviderChange = this.handleGeoIPProviderChange.bind(this);
this.handleTestAndSave = this.handleTestAndSave.bind(this);
this.handleClearGeoIPCache = this.handleClearGeoIPCache.bind(this);
this.handleLongitude360Switched = this.handleLongitude360Switched.bind(this);
}

onGeoIPProviderSelected = (option: SelectableValue<GeoIPProviderKind>) => {
handleGeoIPProviderSelected = (option: SelectableValue<GeoIPProviderKind>) => {
this.setState({ geoIPProvider: this.props.options.geoIPProviders[option.value ?? 'ipsb'], test: { pending: false } });
};

Expand Down Expand Up @@ -70,47 +71,77 @@ export class TracerouteMapEditor extends PureComponent<PanelEditorProps<Tracerou
}
}

handleLongitude360Switched(value: boolean) {
this.props.onOptionsChange({ ...this.props.options, longitude360: value });
}

handleMapClusterRadius(value: number) {
this.props.onOptionsChange({ ...this.props.options, mapClusterRadius: value });
}

render() {
const options = this.props.options;

return (
<div className="section gf-form-group">
<h5 className="section-header">GeoIP</h5>
<div style={{ width: 500 }}>
<Forms.Field label="Provider">
<Forms.Select options={geoIPOptions} value={this.state.geoIPProvider.kind} onChange={this.onGeoIPProviderSelected} />
</Forms.Field>
{(() => {
switch (this.state.geoIPProvider.kind) {
case 'ipinfo':
return <IPInfoConfig onChange={this.handleGeoIPProviderChange} config={this.state.geoIPProvider} />;
case 'ipsb':
return <IPSBConfig />;
case 'custom-api':
return <CustomAPIConfig onChange={this.handleGeoIPProviderChange} config={this.state.geoIPProvider} />;
case 'custom-function':
return <CustomFunctionConfig onChange={this.handleGeoIPProviderChange} config={this.state.geoIPProvider} />;
}
})()}
<div className="traceroute-map-editor">
<div className="section gf-form-group">
<h5 className="section-header">General</h5>
<div style={{ width: 300 }}>
<Forms.Field label="Wrap longitude to [0°, 360°)" description="So that it won't lay within [-180°, 0°)">
<Forms.Switch checked={options.longitude360} onChange={(event) => this.handleLongitude360Switched(event?.currentTarget.checked ?? false)} />
</Forms.Field>
<Forms.Field label="Cluster Radius" description="Merge close points within a radius into one circle">
<Slider min={5} max={50} value={[this.props.options.mapClusterRadius]} onChange={(value) => this.handleMapClusterRadius(value[0])} />
</Forms.Field>
<Forms.Field label="Note">
<span>Some options won't take effect until the panel/page is refreshed.</span>
</Forms.Field>
</div>
</div>
<Forms.Button icon={this.state.test.pending ? 'fa fa-spinner fa-spin' : undefined} onClick={this.handleTestAndSave}>
Test and Save
</Forms.Button>
<span style={{ marginLeft: '0.5em', marginRight: '0.5em' }}></span>
<Forms.Button variant="secondary" onClick={this.handleClearGeoIPCache}>
Clear Cache
</Forms.Button>

{this.state.test.title ? (
<Forms.Field label="">
<div className="section gf-form-group">
<h5 className="section-header">GeoIP</h5>
<div style={{ width: 400 }}>
<Forms.Field label="Provider">
<Forms.Select options={geoIPOptions} value={this.state.geoIPProvider.kind} onChange={this.handleGeoIPProviderSelected} />
</Forms.Field>
{(() => {
switch (this.state.geoIPProvider.kind) {
case 'ipinfo':
return <IPInfoConfig onChange={this.handleGeoIPProviderChange} config={this.state.geoIPProvider} />;
case 'ipsb':
return <IPSBConfig />;
case 'custom-api':
return <CustomAPIConfig onChange={this.handleGeoIPProviderChange} config={this.state.geoIPProvider} />;
case 'custom-function':
return <CustomFunctionConfig onChange={this.handleGeoIPProviderChange} config={this.state.geoIPProvider} />;
}
})()}
</div>
<Forms.Field>
<>
<span style={{ fontWeight: 'bold' }}>{this.state.test.title}</span>
<pre>
<code>{this.state.test.output}</code>
</pre>
<Forms.Button icon={this.state.test.pending ? 'fa fa-spinner fa-spin' : undefined} onClick={this.handleTestAndSave}>
Test and Save
</Forms.Button>
<span style={{ marginLeft: '0.5em', marginRight: '0.5em' }}></span>
<Forms.Button variant="secondary" onClick={this.handleClearGeoIPCache}>
Clear Cache
</Forms.Button>
</>
</Forms.Field>
) : (
<></>
)}

{this.state.test.title ? (
<Forms.Field label="">
<>
<span style={{ fontWeight: 'bold' }}>{this.state.test.title}</span>
<pre>
<code>{this.state.test.output}</code>
</pre>
</>
</Forms.Field>
) : (
<></>
)}
</div>
</div>
);
}
Expand Down
85 changes: 54 additions & 31 deletions src/TracerouteMapPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,24 @@ import {
Popup,
TileLayer,
Control,
LatLngBounds,
MarkerClusterGroup,
Polyline,
LatLngTuple,
latLngBounds,
LatLngBounds,
} from './react-leaflet-compat';

import { TracerouteMapOptions } from './types';
import { IP2Geo, IPGeo } from './geoip';
import { rainbowPalette, round, HiddenHostsStorage } from './utils';
import 'panel.css';

interface Props extends PanelProps<TracerouteMapOptions> {}
interface Props extends PanelProps<TracerouteMapOptions> { }

interface State {
data: Map<string, PathPoint[]>;
series: any;
mapBounds: Map<string, LatLngBounds>;
mapBounds: Map<string, LatLngTuple[]>;
hiddenHosts: Set<string>;
hostListExpanded: boolean;
}
Expand Down Expand Up @@ -62,6 +62,7 @@ export class TracerouteMapPanel extends Component<Props, State> {
};
this.processData();
this.handleFit = this.handleFit.bind(this);
this.wrapCoord = this.wrapCoord.bind(this);
}

componentDidUpdate(prevProps: Props): void {
Expand All @@ -88,14 +89,13 @@ export class TracerouteMapPanel extends Component<Props, State> {
return;
}
let fields: any = {};
// TODO: use catersian product to handle mesh-like route paths
['host', 'dest', 'hop', 'ip', 'rtt', 'loss'].forEach(item => (fields[item] = null));
for (const field of series[0].fields) {
if (fields.hasOwnProperty(field.name)) {
fields[field.name] = field.values.toArray();
} else {
} /* else {
console.log('Ignoring field: ' + field.name);
}
} */
}
if (Object.values(fields).includes(null)) {
console.log('Invalid query data');
Expand All @@ -109,6 +109,7 @@ export class TracerouteMapPanel extends Component<Props, State> {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const key = `${entry[0]}|${entry[1]}`;
// TODO: parallelize & throttle ip2geo request
const hop = parseInt(entry[2], 10),
ip = entry[3] as string,
rtt = parseFloat(entry[4]),
Expand All @@ -129,38 +130,42 @@ export class TracerouteMapPanel extends Component<Props, State> {
const point_id = `${round(lat, 1)},${round(lon, 1)}`;
let point = group.get(point_id);
if (point === undefined) {
point = { lat, lon: (lon + 360) % 360, region: region ?? 'unknown region', hops: [] };
point = { lat, lon, region: region ?? 'unknown region', hops: [] };
group.set(point_id, point);
}
point.hops.push({ nth: hop, ip, label: label ?? 'unknown network', rtt, loss });

// latLons.push([lat, (lon + 360) % 360]);
}
let mapBounds: Map<string, LatLngBounds> = new Map();
let mapBounds: Map<string, LatLngTuple[]> = new Map();
for (const [key, points] of Array.from(data.entries())) {
mapBounds.set(key, latLngBounds(Array.from(points.values()).map(point => [point.lat, point.lon])));
const bound = latLngBounds(Array.from(points.values()).map(point => [point.lat, point.lon]));
mapBounds.set(key, [[bound.getSouth(), bound.getWest()], [bound.getNorth(), bound.getEast()]]);
}
// debugger;
this.setState({
data: new Map(Array.from(data.entries()).map(([key, value]) => [key, Array.from(value.values())])),
series,
mapBounds,
});

// TODO: use catersian product to handle non-linear route paths
}

toggleHostItem(item: string) {
// event.currentTarget.
this.setState({ hiddenHosts: this.hiddenHostsStorage.toggle(item) });
}

handleFit(event: MouseEvent) {
this.mapRef.current.leafletElement.fitBounds(this.getEffectiveBounds());
}

getEffectiveBounds() {
return Array.from(this.state.mapBounds.entries())
.filter(([key, _value]) => !this.state.hiddenHosts.has(key))
.map(([_key, value]) => value)
.reduce((prev: LatLngBounds | undefined, curr) => prev?.pad(0)?.extend(curr) ?? curr, undefined);
getEffectiveBounds(): LatLngBounds | undefined {
const tuples = Array.from(this.state.mapBounds.entries())
.filter(([key, _value]) => !this.state.hiddenHosts.has(key))
.flatMap(([_key, tuples]) => tuples)
.map(tuple => this.wrapCoord(tuple));
return tuples.length ? latLngBounds(tuples) : undefined;
}

toggleHostList() {
Expand All @@ -169,14 +174,20 @@ export class TracerouteMapPanel extends Component<Props, State> {
});
}

wrapCoord(coord: LatLngTuple): LatLngTuple {
let [lat, lon] = coord;
if (this.props.options.longitude360) {
lon = (lon + 360) % 360;
}
return [lat, lon];
}

render() {
const { /*options,*/ width, height } = this.props;
const { width, height, options } = this.props;
const data = this.state.data;
let palette = rainbowPalette(data.size, 0.618);
const effectiveBounds = this.getEffectiveBounds();
console.log('Map effetive bounds', effectiveBounds);
// data.series
// > select hop, ip, avg, loss from (select mean(avg) as avg, mean(loss) as loss from mtr group by hop, ip)
// console.log('Map effetive bounds', effectiveBounds);
return (
<LMap
key={this.state.series}
Expand All @@ -191,7 +202,7 @@ export class TracerouteMapPanel extends Component<Props, State> {
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
/>
<MarkerClusterGroup maxClusterRadius={15} /*options={{ singleMarkerMode: true }}*/>
<MarkerClusterGroup maxClusterRadius={options.mapClusterRadius} /*options={{ singleMarkerMode: true }}*/>
{Array.from(data.entries()).map(([key, points]) => {
const [host, dest] = key.split('|');
return (
Expand All @@ -202,7 +213,8 @@ export class TracerouteMapPanel extends Component<Props, State> {
points={points}
color={palette()}
visible={!this.state.hiddenHosts.has(key)}
></TraceRouteMarkers>
wrapCoord={this.wrapCoord}
/>
);
})}
</MarkerClusterGroup>
Expand All @@ -229,10 +241,10 @@ export class TracerouteMapPanel extends Component<Props, State> {
</ul>
</>
) : (
<span className="host-list-toggler host-list-expand" onClick={() => this.toggleHostList()}>
<Icon name="expand"></Icon>
</span>
)}
<span className="host-list-toggler host-list-expand" onClick={() => this.toggleHostList()}>
<Icon name="expand"></Icon>
</span>
)}
</Control>
<Control position="topright">
<Button variant="primary" size="md" onClick={this.handleFit}>
Expand All @@ -245,17 +257,28 @@ export class TracerouteMapPanel extends Component<Props, State> {
}
}

const TraceRouteMarkers: React.FC<{ host: string; dest: string; points: PathPoint[]; color: string; visible: boolean }> = ({
// Traceroute for one host->dest pair
const TraceRouteMarkers: React.FC<{ host: string; dest: string; points: PathPoint[]; color: string; visible: boolean; wrapCoord?: (coord: LatLngTuple) => LatLngTuple }> = ({
host,
dest,
points,
color,
visible,
wrapCoord
}) => {
// const wrapCoord = longitude360 ? (lon: number) => (lon + 360) % 360 : (lon: number) => lon;
let wrapCoord_: (coord: LatLngTuple) => LatLngTuple;
if (wrapCoord === undefined) {
wrapCoord_ = (coord: LatLngTuple) => coord;
}
else {
wrapCoord_ = wrapCoord;
}

return visible ? (
<div data-host={host} data-dest={dest} data-points={points.length}>
{points.map(point => (
<Marker key={point.region} position={[point.lat, point.lon]} className="point-marker">
<Marker key={point.region} position={wrapCoord_([point.lat, point.lon])} className="point-marker">
<Popup className="point-popup">
<span className="point-label">{point.region}</span>
<hr />
Expand All @@ -271,14 +294,14 @@ const TraceRouteMarkers: React.FC<{ host: string; dest: string; points: PathPoin
</ul>
<hr />
<span className="host-label">{host}</span>
<span className="host-arrow">&nbsp; ➡️ &nbsp;</span>
<span className="host-arrow" style={{ color }}>&nbsp; ➡️ &nbsp;</span>
<span className="dest-label">{dest}</span>
</Popup>
</Marker>
))}
<Polyline positions={points.map(point => [point.lat, (point.lon + 360) % 360] as LatLngTuple)} color={color}></Polyline>
<Polyline positions={points.map(point => wrapCoord_([point.lat, point.lon]) as LatLngTuple)} color={color}></Polyline>
</div>
) : (
<></>
);
<></>
);
};
4 changes: 4 additions & 0 deletions src/panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,8 @@
right: -1em;
/* aliceblue */
color: rgba(240, 248, 255, 0.618);
}

.traceroute-map-editor a {
color: darkblue;
}
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ export interface TracerouteMapOptions {
'custom-api': CustomAPI;
'custom-function': CustomFunction;
};
longitude360: boolean;
mapClusterRadius: number;
}

export const defaults: TracerouteMapOptions = {
geoIPProviders: {
active: 'ipsb',
...(Object.fromEntries(['ipsb', 'ipinfo', 'custom-api', 'custom-function'].map(p => [p, { kind: p }])) as any),
},
longitude360: false,
mapClusterRadius: 15
};

0 comments on commit 85e79f9

Please sign in to comment.