Implemented graphical call-flow view.

3.4
Andreas Granig 13 years ago
parent 7ee08beb56
commit 8e03d11958

2
debian/control vendored

@ -26,7 +26,7 @@ Description: Configuration templates for the web-based admin interface
Package: ngcp-www-admin
Architecture: all
Depends: ${shlibs:Depends}, ${misc:Depends}, ngcp-ossbss-billing (>= 2.1.1), ngcp-ossbss-voip (>= 3.1.0), libcatalyst-perl, libcatalyst-modules-perl, libcatalyst-engine-apache-perl, ngcp-templates-www-admin
Depends: ${shlibs:Depends}, ${misc:Depends}, ngcp-ossbss-billing (>= 2.1.1), ngcp-ossbss-voip (>= 3.1.0), libcatalyst-perl, libcatalyst-modules-perl, libcatalyst-engine-apache-perl, ngcp-templates-www-admin, libgd-gd2-perl
Description: administration framework to configure the Sipwise NGCP
This package provides an administration framework which is used to
configure the Sipwise NGCP.

@ -32,6 +32,10 @@
fax2mail_domain="[% ossbss.provisioning.routing.fax2mail_domain %]"
conference_domain="[% ossbss.provisioning.routing.conference_domain %]"
>
<sipstats lb_int="[% networking.iaddress %]:[% kamailio.lb.port %]"
lb_ext="[% networking.eaddress %]:[% kamailio.lb.port %]"
proxy="[% networking.iaddress %]:[% kamailio.proxy.port %]"
sbc="[% networking.iaddress %]:[% sems.bindport %]" />
<dashboard enabled="[% www_admin.dashboard.enabled %]" />
<subscriber extension_features="[% www_admin.subscriber.extension_features %]"
audiofile_features="[% www_admin.subscriber.audiofile_features %]"

@ -1400,6 +1400,101 @@ sub sipstats_pcap : Local {
return;
}
sub sipstats_callmap_png : Local {
my ( $self, $c ) = @_;
$c->stash->{template} = 'tt/subscriber_sipstats_call.tt';
my $subscriber_id = $c->request->params->{subscriber_id};
my $callid = $c->request->params->{callid};
my $subscriber;
return unless $c->model('Provisioning')->call_prov( $c, 'billing', 'get_voip_account_subscriber_by_id',
{ subscriber_id => $subscriber_id },
\$subscriber
);
$c->stash->{callid} = $callid;
$c->stash->{subscriber} = $subscriber;
$c->stash->{subscriber}{subscriber_id} = $subscriber_id;
my $calls;
return unless $c->model('Provisioning')->call_prov( $c, 'voip', 'get_subscriber_sipstat_packets',
{ username => $$subscriber{username},
domain => $$subscriber{domain},
callid => $callid,
},
\$calls
);
my $png = admin::Utils::generate_callmap_png($c, $calls);
my $filename = $callid . '.png';
$c->stash->{current_view} = 'Plain';
$c->stash->{content_type} = 'image/png';
$c->stash->{content_disposition} = qq[attachment; filename="$filename"];
$c->stash->{content} = eval { $png };
return;
}
sub sipstats_callmap : Local {
my ( $self, $c ) = @_;
$c->stash->{template} = 'tt/subscriber_sipstats_call.tt';
my $subscriber_id = $c->request->params->{subscriber_id};
my $callid = $c->request->params->{callid};
my $subscriber;
return unless $c->model('Provisioning')->call_prov( $c, 'billing', 'get_voip_account_subscriber_by_id',
{ subscriber_id => $subscriber_id },
\$subscriber
);
$c->stash->{callid} = $callid;
$c->stash->{subscriber} = $subscriber;
$c->stash->{subscriber}{subscriber_id} = $subscriber_id;
my $calls;
return unless $c->model('Provisioning')->call_prov( $c, 'voip', 'get_subscriber_sipstat_packets',
{ username => $$subscriber{username},
domain => $$subscriber{domain},
callid => $callid,
},
\$calls
);
$c->stash->{canvas} = admin::Utils::generate_callmap($c, $calls);
return;
}
sub sipstats_packet : Local {
my ( $self, $c ) = @_;
#$c->stash->{template} = 'tt/subscriber_sipstats_call.tt';
my $subscriber_id = $c->request->params->{subscriber_id};
my $pkgid = $c->request->params->{pkgid};
my $subscriber;
return unless $c->model('Provisioning')->call_prov( $c, 'billing', 'get_voip_account_subscriber_by_id',
{ subscriber_id => $subscriber_id },
\$subscriber
);
$c->stash->{subscriber} = $subscriber;
$c->stash->{subscriber}{subscriber_id} = $subscriber_id;
my $pkg;
return unless $c->model('Provisioning')->call_prov( $c, 'voip', 'get_subscriber_sipstat_packet',
{ username => $$subscriber{username},
domain => $$subscriber{domain},
packetid => $pkgid,
},
\$pkg
);
$pkg->{payload} = encode_entities($pkg->{payload});
$pkg->{payload} =~ s/^([^\n]+)\n/<b>$1<\/b>\n/;
$pkg->{payload} =~ s/\n([a-zA-Z0-9\-_]+\:)/\n<b>$1<\/b>/g;
$pkg->{payload} =~ s/\r?\n/<br\/>/g;
$c->stash->{current_view} = 'Plain';
$c->stash->{content_type} = 'text/html';
$c->stash->{content} = eval { $pkg->{payload} };
return;
}
sub edit_cf : Local {
my ( $self, $c ) = @_;
$c->stash->{template} = 'tt/subscriber_callforward.tt';

@ -6,6 +6,7 @@ use Time::Local;
use HTML::Entities;
use POSIX;
use DateTime::TimeZone::OffsetOnly;
use GD::Simple;
# Takes a search result total count, an offset and a limit and returns
# an array containing offset values for a pagination link list
@ -593,4 +594,183 @@ sub generate_pcap {
return $pcap;
}
sub draw_line {
my ($c, $from_x, $from_y, $to_x, $to_y, $width, $color) = @_;
$c->fgcolor($color);
$c->moveTo($from_x, $from_y);
$c->penSize($width, $width);
$c->lineTo($to_x, $to_y);
}
sub draw_arrow {
my ($c, $from_x, $from_y, $to_x, $to_y, $width, $color) = @_;
$c->fgcolor($color);
$c->moveTo($from_x, $from_y);
$c->penSize($width, $width);
$c->lineTo($to_x, $to_y);
my $poly = new GD::Polygon;
$poly->addPt($to_x, $to_y);
my $dir = ($to_x > $from_x) ? -1 : 1;
$poly->addPt($to_x + 4*$width*$dir, $to_y - 2*$width-(($width%2)?0:1));
$poly->addPt($to_x + 4*$width*$dir, $to_y + 2*$width);
my $oldbgcolor = $c->bgcolor();
$c->bgcolor($color);
$c->penSize(1,1);
$c->polygon($poly);
$c->bgcolor($oldbgcolor);
}
sub draw_text {
my ($c, $x, $y, $ftype, $fsize, $fcolor, $txt) = @_;
$c->font($ftype);
$c->fontsize($fsize);
$c->fgcolor($fcolor);
$c->moveTo($x, $y);
$c->string($txt);
my @b = $c->stringBounds($txt);
my %bounds = ('x', $x, 'y', $y, 'dx', $b[0], 'dy', $b[1]);
return %bounds;
}
sub process_callmap {
my $c = shift;
my $packets = shift;
my $r_png = shift;
my $r_info = shift;
my %int_uas = (
$c->config->{sipstats}->{lb_int}, 'lb',
$c->config->{sipstats}->{lb_ext}, 'lb',
$c->config->{sipstats}->{proxy}, 'proxy',
$c->config->{sipstats}->{sbc}, 'sbc',
);
my $canvas_margin = 100; # enough free space around diagram for text etc
my $canvas_elem_distance = 150; # horizontal distance between element lines
my $canvas_pkg_distance = 30; # vertical distance between packet arrows
my $canvas_elem_line_width = 2;
my $canvas_elem_line_color = 'darkgray';
my $canvas_elem_font = gdMediumBoldFont;
my $canvas_elem_font_size = 12;
my $canvas_elem_font_color = 'darkgray';
my $canvas_pkg_line_width = 2;
my $canvas_pkg_line_color = 'green';
my $canvas_pkg_font = gdMediumBoldFont;
my $canvas_pkg_font_size = 0;
my $canvas_pkg_font_color = 'dimgray';
my $html_padding = 5;
my %ext_uas = ();
my @uas = ();
### gather all involved elements
foreach my $packet(@{$packets}) {
if(exists($int_uas{$packet->{src_ip}.':'.$packet->{src_port}})) {
#print "skipping internal elem ".$packet->{src_ip}.':'.$packet->{src_port}." (".$int_uas{$packet->{src_ip}.':'.$packet->{src_port}}.")\n";
}
elsif(exists($ext_uas{$packet->{src_ip}.':'.$packet->{src_port}})) {
#print "skipping known external elem ".$packet->{src_ip}.':'.$packet->{src_port}."\n";
}
else {
#print "adding new src elem ".$packet->{src_ip}.':'.$packet->{src_port}."\n";
$ext_uas{$packet->{src_ip}.':'.$packet->{src_port}} = 1;
# TODO: prefix "proto:" as well
push @uas, $packet->{src_ip}.':'.$packet->{src_port};
}
if(exists($int_uas{$packet->{dst_ip}.':'.$packet->{dst_port}})) {
#print "skipping internal elem ".$packet->{dst_ip}.':'.$packet->{dst_port}." (".$int_uas{$packet->{dst_ip}.':'.$packet->{dst_port}}.")\n";
}
elsif(exists($ext_uas{$packet->{dst_ip}.':'.$packet->{dst_port}})) {
#print "skipping known external elem ".$packet->{dst_ip}.':'.$packet->{dst_port}."\n";
}
else {
#print "adding new dst elem ".$packet->{dst_ip}.':'.$packet->{dst_port}."\n";
$ext_uas{$packet->{dst_ip}.':'.$packet->{dst_port}} = 1;
# TODO: prefix "proto:" as well
push @uas, $packet->{src_ip}.':'.$packet->{src_port};
}
}
push @uas, ('lb', 'sbc', 'proxy');
### calculate x position of all uas
my %uas_pos_x = ();
my $i = 0;
foreach my $ua(@uas) {
my $name = $ua;
foreach my $k(keys %int_uas) {
if($ua eq $int_uas{$k}) {
$uas_pos_x{$k} = $canvas_margin + $canvas_elem_distance*$i;
}
}
$uas_pos_x{$ua} = $canvas_margin + $canvas_elem_distance*$i;
++$i;
}
### calculate canvas size
# TODO: take into account length of "proto:[ipv6]:port"
my $canvas_width = 2*$canvas_margin + $canvas_elem_distance*(@uas - 1);
my $canvas_height = 2*$canvas_margin + $canvas_pkg_distance*(@{$packets} + 1); # leave one pkg_distance free at begin and end
my $canvas = GD::Simple->new($canvas_width, $canvas_height);
$canvas->bgcolor('white');
### prepare html
$r_info->{width} = $canvas_width;
$r_info->{height} = $canvas_height;
$r_info->{areas} = ();
### draw vertical lines
my $offset = $canvas_margin;
foreach my $ua(@uas) {
draw_line($canvas, $offset, $canvas_margin, $offset, $canvas_height-$canvas_margin, $canvas_elem_line_width, $canvas_elem_line_color);
my @bounds = $canvas->stringBounds($ua); # get bounds for text centering
draw_text($canvas, $offset-int(abs($bounds[0])/2), $canvas_margin-abs($bounds[1]), $canvas_elem_font, $canvas_elem_font_size, $canvas_elem_font_color, $ua);
$offset += $canvas_elem_distance;
}
### draw arrows
my $y_offset = $canvas_margin + $canvas_pkg_distance;
foreach my $packet(@{$packets}) {
my $from_x = $uas_pos_x{$packet->{src_ip}.':'.$packet->{src_port}};
my $to_x = $uas_pos_x{$packet->{dst_ip}.':'.$packet->{dst_port}};
#print "arrow from ".$packet->{src_ip}.':'.$packet->{src_port}." to ".$packet->{dst_ip}.':'.$packet->{dst_port}.": $from_x - $to_x\n";
draw_arrow($canvas, $from_x, $y_offset, $to_x, $y_offset, $canvas_pkg_line_width, $canvas_pkg_line_color);
my $txt = $packet->{method}; # TODO: also append cseq
my @bounds = $canvas->stringBounds($txt); # get bounds for text centering
if($from_x < $to_x) {
$from_x = $from_x+int($canvas_elem_distance/2)-int($bounds[0]/2);
} elsif($from_x > $to_x) {
$from_x = $from_x-int($canvas_elem_distance/2)-int($bounds[0]/2);
} else {
$from_x += 10; # call to itself, e.g. in cf loop
}
draw_text($canvas, $from_x, $y_offset-int(abs($bounds[1])/2), $canvas_pkg_font, $canvas_pkg_font_size, $canvas_pkg_font_color, $txt);
push @{$r_info->{areas}}, {"id", $packet->{id}, "coords", ($from_x-$html_padding).','.($y_offset-abs($bounds[1])-$html_padding).','.($from_x+abs($bounds[0])+$html_padding).','.($y_offset)};
$y_offset += $canvas_pkg_distance;
}
$$r_png = $canvas->png;
}
sub generate_callmap {
my $c = shift;
my $packets = shift;
my $png; my %info;
process_callmap($c, $packets, \$png, \%info);
return \%info;
}
sub generate_callmap_png {
my $c = shift;
my $packets = shift;
my $png; my %info;
process_callmap($c, $packets, \$png, \%info);
return $png;
}
1;

@ -0,0 +1,222 @@
function modalPopup(align, top, width, padding, disableColor, disableOpacity, backgroundColor, borderColor, borderWeight, borderRadius, fadeOutTime, url, loadingImage){
var containerid = "innerModalPopupDiv";
var popupDiv = document.createElement('div');
var popupMessage = document.createElement('div');
var blockDiv = document.createElement('div');
popupDiv.setAttribute('id', 'outerModalPopupDiv');
popupDiv.setAttribute('class', 'outerModalPopupDiv');
popupMessage.setAttribute('id', 'innerModalPopupDiv');
popupMessage.setAttribute('class', 'innerModalPopupDiv');
blockDiv.setAttribute('id', 'blockModalPopupDiv');
blockDiv.setAttribute('class', 'blockModalPopupDiv');
blockDiv.setAttribute('onClick', 'closePopup(' + fadeOutTime + ')');
document.body.appendChild(popupDiv);
popupDiv.appendChild(popupMessage);
document.body.appendChild(blockDiv);
if (/MSIE (\d+\.\d+);/.test(navigator.userAgent)){ //test for MSIE x.x;
var ieversion=new Number(RegExp.$1) // capture x.x portion and store as a number
if(ieversion>6) {
getScrollHeight(top);
}
} else {
getScrollHeight(top);
}
document.getElementById('outerModalPopupDiv').style.display='block';
document.getElementById('outerModalPopupDiv').style.width = width + 'px';
document.getElementById('outerModalPopupDiv').style.padding = borderWeight + 'px';
document.getElementById('outerModalPopupDiv').style.background = borderColor;
document.getElementById('outerModalPopupDiv').style.borderRadius = borderRadius + 'px';
document.getElementById('outerModalPopupDiv').style.MozBorderRadius = borderRadius + 'px';
document.getElementById('outerModalPopupDiv').style.WebkitBorderRadius = borderRadius + 'px';
document.getElementById('outerModalPopupDiv').style.borderWidth = 0 + 'px';
document.getElementById('outerModalPopupDiv').style.position = 'absolute';
document.getElementById('outerModalPopupDiv').style.zIndex = 100;
document.getElementById('innerModalPopupDiv').style.padding = padding + 'px';
document.getElementById('innerModalPopupDiv').style.background = backgroundColor;
document.getElementById('innerModalPopupDiv').style.borderRadius = (borderRadius - 3) + 'px';
document.getElementById('innerModalPopupDiv').style.MozBorderRadius = (borderRadius - 3) + 'px';
document.getElementById('innerModalPopupDiv').style.WebkitBorderRadius = (borderRadius - 3) + 'px';
document.getElementById('blockModalPopupDiv').style.width = 100 + '%';
document.getElementById('blockModalPopupDiv').style.border = 0 + 'px';
document.getElementById('blockModalPopupDiv').style.padding = 0 + 'px';
document.getElementById('blockModalPopupDiv').style.margin = 0 + 'px';
document.getElementById('blockModalPopupDiv').style.background = disableColor;
document.getElementById('blockModalPopupDiv').style.opacity = (disableOpacity / 100);
document.getElementById('blockModalPopupDiv').style.filter = 'alpha(Opacity=' + disableOpacity + ')';
document.getElementById('blockModalPopupDiv').style.zIndex = 99;
document.getElementById('blockModalPopupDiv').style.position = 'fixed';
document.getElementById('blockModalPopupDiv').style.top = 0 + 'px';
document.getElementById('blockModalPopupDiv').style.left = 0 + 'px';
if(align=="center") {
document.getElementById('outerModalPopupDiv').style.marginLeft = (-1 * (width / 2)) + 'px';
document.getElementById('outerModalPopupDiv').style.left = 50 + '%';
} else if(align=="left") {
document.getElementById('outerModalPopupDiv').style.marginLeft = 0 + 'px';
document.getElementById('outerModalPopupDiv').style.left = 10 + 'px';
} else if(align=="right") {
document.getElementById('outerModalPopupDiv').style.marginRight = 0 + 'px';
document.getElementById('outerModalPopupDiv').style.right = 10 + 'px';
} else {
document.getElementById('outerModalPopupDiv').style.marginLeft = (-1 * (width / 2)) + 'px';
document.getElementById('outerModalPopupDiv').style.left = 50 + '%';
}
blockPage();
var page_request = false;
if (window.XMLHttpRequest) {
page_request = new XMLHttpRequest();
} else if (window.ActiveXObject) {
try {
page_request = new ActiveXObject("Msxml2.XMLHTTP");
} catch (e) {
try {
page_request = new ActiveXObject("Microsoft.XMLHTTP");
} catch (e) { }
}
} else {
return false;
}
page_request.onreadystatechange=function(){
if((url.search(/.jpg/i)==-1) && (url.search(/.jpeg/i)==-1) && (url.search(/.gif/i)==-1) && (url.search(/.png/i)==-1) && (url.search(/.bmp/i)==-1)) {
pageloader(page_request, containerid, loadingImage);
} else {
imageloader(url, containerid, loadingImage);
}
}
page_request.open('GET', url, true);
page_request.send(null);
}
function pageloader(page_request, containerid, loadingImage){
document.getElementById(containerid).innerHTML = '<div align="center"><img src="' + loadingImage + '" border="0" /></div>';
if (page_request.readyState == 4 && (page_request.status==200 || window.location.href.indexOf("http")==-1)) {
document.getElementById(containerid).innerHTML=page_request.responseText;
}
}
function imageloader(url, containerid, loadingImage) {
document.getElementById(containerid).innerHTML = '<div align="center"><img src="' + loadingImage + '" border="0" /></div>';
document.getElementById(containerid).innerHTML='<div align="center"><img src="' + url + '" border="0" /></div>';
}
function blockPage() {
var blockdiv = document.getElementById('blockModalPopupDiv');
var height = screen.height;
blockdiv.style.height = height + 'px';
blockdiv.style.display = 'block';
}
function getScrollHeight(top) {
var h = window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop;
if (/MSIE (\d+\.\d+);/.test(navigator.userAgent)) {
var ieversion=new Number(RegExp.$1);
if(ieversion>6) {
document.getElementById('outerModalPopupDiv').style.top = h + top + 'px';
} else {
document.getElementById('outerModalPopupDiv').style.top = top + 'px';
}
} else {
document.getElementById('outerModalPopupDiv').style.top = h + top + 'px';
}
}
function closePopup(fadeOutTime) {
fade('outerModalPopupDiv', fadeOutTime);
document.getElementById('blockModalPopupDiv').style.display='none';
}
function fade(id, fadeOutTime) {
var el = document.getElementById(id);
if(el == null) {
return;
}
if(el.FadeState == null) {
if(el.style.opacity == null || el.style.opacity == '' || el.style.opacity == '1') {
el.FadeState = 2;
} else {
el.FadeState = -2;
}
}
if(el.FadeState == 1 || el.FadeState == -1) {
el.FadeState = el.FadeState == 1 ? -1 : 1;
el.fadeTimeLeft = fadeOutTime - el.fadeTimeLeft;
} else {
el.FadeState = el.FadeState == 2 ? -1 : 1;
el.fadeTimeLeft = fadeOutTime;
setTimeout("animateFade(" + new Date().getTime() + ",'" + id + "','" + fadeOutTime + "')", 33);
}
}
function animateFade(lastTick, id, fadeOutTime) {
var currentTick = new Date().getTime();
var totalTicks = currentTick - lastTick;
var el = document.getElementById(id);
if(el.fadeTimeLeft <= totalTicks) {
el.style.opacity = el.FadeState == 1 ? '1' : '0';
el.style.filter = 'alpha(opacity = ' + (el.FadeState == 1 ? '100' : '0') + ')';
el.FadeState = el.FadeState == 1 ? 2 : -2;
document.body.removeChild(el);
return;
}
el.fadeTimeLeft -= totalTicks;
var newOpVal = el.fadeTimeLeft / fadeOutTime;
if(el.FadeState == 1) {
newOpVal = 1 - newOpVal;
}
el.style.opacity = newOpVal;
el.style.filter = 'alpha(opacity = ' + (newOpVal*100) + ')';
setTimeout("animateFade(" + currentTick + ",'" + id + "','" + fadeOutTime + "')", 33);
}

@ -86,6 +86,7 @@
{ name = "transfer", icon="transferthick-e-w" },
{ name = "cart", icon="cart" },
{ name = "browse", icon="folder-open" },
{ name = "download", icon="disk" },
]
%]
[% FOREACH b = buttons %]

@ -34,7 +34,7 @@
[% FOREACH call IN calls %]
<li class="ui-state-default">
<div class="span-4">[% call.timestamp %]</div>
<div class="span-10"><a href="sipstats_pcap?subscriber_id=[% subscriber.subscriber_id %]&callid=[% call.call_id %]">[% IF call.call_id.length > 50 %][% call.call_id.substr(0, 50) %]...[% ELSE %][% call.call_id %][% END %]</a></div>
<div class="span-10"><a href="sipstats_callmap?subscriber_id=[% subscriber.subscriber_id %]&callid=[% call.call_id %]">[% IF call.call_id.length > 50 %][% call.call_id.substr(0, 50) %]...[% ELSE %][% call.call_id %][% END %]</a></div>
<div class="span-2 last">[% call.method %]</div>
</li>
[% END %]

@ -0,0 +1,41 @@
<h3> Subscriber
<a class="noarrow" href="detail?subscriber_id=[% subscriber.subscriber_id %]">
[% subscriber.username %]@[% subscriber.domain %]</a>
</h3>
<div class="topsubmenu">
<ul>
<li><a href="detail?subscriber_id=[% subscriber.subscriber_id %]"><span>User</span></a></li>
<li><a href="preferences?subscriber_id=[% subscriber.subscriber_id %]"><span>Preferences</span></a></li>
[% IF Catalyst.session.admin.call_data %]
<li><a href="call_data?subscriber_id=[% subscriber.subscriber_id %]"><span>CDRs</span></a></li>
[% END %]
[% IF Catalyst.config.voisniff_features %]
<li class="selected"><a href="sipstats?subscriber_id=[% subscriber.subscriber_id %]"><span>SIP Stats</span></a></li>
[% END %]
</ul>
</div>
<div class="topsubmenudivider"> </div>
<script type="text/javascript" src="/js/modal.popup.js"></script>
<script type="text/javascript">
function pkgPopup(pkgid) {
// http://www.jquerypopup.com/documentation.php
modalPopup("center", 100, 640, 10, "#666666", 40, "#FFFFFF", "#000000", 4, 5, 300,
"sipstats_packet?subscriber_id=[% subscriber.subscriber_id %]&pkgid="+pkgid,
"/static/images/loader.gif");
}
</script>
<h3 id="calldata">Call Details for Call-ID [% callid %]</h3>
<a href="sipstats?subscriber_id=[% subscriber.subscriber_id %]"><span class="button-back">Back</span></a>
<a href="sipstats_pcap?subscriber_id=[% subscriber.subscriber_id %]&callid=[% callid %]"><span class="button-download">Download PCAP</span></a>
<image src="sipstats_callmap_png?subscriber_id=[% subscriber.subscriber_id %]&callid=[% callid %]" width="[% canvas.width %]" height="[% canvas.height %]" usemap="#diamap" />
<map name="diamap">
[% FOREACH area IN canvas.areas %]
<area shape="rect" coords="[% area.coords %]" href="javascript:pkgPopup([% area.id %])" />
[% END %]
</map>
Loading…
Cancel
Save