Node search by NetBIOS name. Update documentation.
This commit is contained in:
@@ -5,8 +5,9 @@
|
||||
* [#86] Use Vendor abbrevs to enhance node display in device port view
|
||||
* [#74] Device Name / DNS mismatches report
|
||||
* [#71] Node search by date (but not time)
|
||||
* [#73] NetBIOS Poller (nbtstat and nbtwalk), NetBIOS Node Report,
|
||||
and provide information when available in Node and Port views
|
||||
* [#73] NetBIOS Poller (nbtstat and nbtwalk), Node search by NetBIOS name,
|
||||
NetBIOS Node Report, and provide information when available in Node
|
||||
and Port views
|
||||
* [#56] Support API call to /login
|
||||
|
||||
[ENHANCEMENTS]
|
||||
|
||||
@@ -312,17 +312,13 @@ See L<App::Netdisco::Web::Plugin> for further information.
|
||||
Lots of information about the architecture of this application is contained
|
||||
within the L<Developer|App::Netdisco::Manual::Developing> documentation.
|
||||
|
||||
=head1 Caveats
|
||||
|
||||
NetBIOS Node properies are not yet shown.
|
||||
|
||||
=head1 AUTHOR
|
||||
|
||||
Oliver Gorwits <oliver@cpan.org>
|
||||
|
||||
=head1 COPYRIGHT AND LICENSE
|
||||
|
||||
This software is copyright (c) 2012, 2013 by The Netdisco Developer Team.
|
||||
This software is copyright (c) 2012, 2013, 2014 by The Netdisco Developer Team.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
@@ -98,6 +98,17 @@ See also the C<node_sightings> helper routine, below.
|
||||
__PACKAGE__->has_many( nodes => 'App::Netdisco::DB::Result::Node',
|
||||
{ 'foreign.mac' => 'self.mac' } );
|
||||
|
||||
=head2 netbios
|
||||
|
||||
Returns the set of C<node_nbt> entries associated with the MAC of this IP.
|
||||
That is, all the NetBIOS entries recorded which shared the same MAC with this
|
||||
IP Address.
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->has_many( netbios => 'App::Netdisco::DB::Result::NodeNbt',
|
||||
{ 'foreign.mac' => 'self.mac' } );
|
||||
|
||||
my $search_attr = {
|
||||
order_by => {'-desc' => 'time_last'},
|
||||
'+columns' => {
|
||||
|
||||
@@ -45,6 +45,141 @@ __PACKAGE__->set_primary_key("mac");
|
||||
# Created by DBIx::Class::Schema::Loader v0.07015 @ 2012-01-07 14:20:02
|
||||
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:XFpxaGAWE13iizQIuVOP3g
|
||||
|
||||
=head1 RELATIONSHIPS
|
||||
|
||||
=head2 oui
|
||||
|
||||
Returns the C<oui> table entry matching this Node. You can then join on this
|
||||
relation and retrieve the Company name from the related table.
|
||||
|
||||
The JOIN is of type LEFT, in case the OUI table has not been populated.
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->belongs_to( oui => 'App::Netdisco::DB::Result::Oui',
|
||||
sub {
|
||||
my $args = shift;
|
||||
return {
|
||||
"$args->{foreign_alias}.oui" =>
|
||||
{ '=' => \"substring(cast($args->{self_alias}.mac as varchar) for 8)" }
|
||||
};
|
||||
},
|
||||
{ join_type => 'LEFT' }
|
||||
);
|
||||
|
||||
=head2 nodes
|
||||
|
||||
Returns the set of C<node> entries associated with this IP. That is, all the
|
||||
MAC addresses recorded which have ever hosted this IP Address.
|
||||
|
||||
Remember you can pass a filter to this method to find only active or inactive
|
||||
nodes, but do take into account that both the C<node> and C<node_nbt> tables
|
||||
include independent C<active> fields.
|
||||
|
||||
See also the C<node_sightings> helper routine, below.
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->has_many( nodes => 'App::Netdisco::DB::Result::Node',
|
||||
{ 'foreign.mac' => 'self.mac' } );
|
||||
|
||||
|
||||
=head2 nodeips
|
||||
|
||||
Returns the set of C<node_ip> entries associated with this NetBIOS entry.
|
||||
That is, the IP addresses which the same MAC address at the time of discovery.
|
||||
|
||||
Note that the Active status of the returned IP entries will all be the same
|
||||
as the current NetBIOS entry.
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->has_many( nodeips => 'App::Netdisco::DB::Result::NodeIp',
|
||||
{ 'foreign.mac' => 'self.mac', 'foreign.active' => 'self.active' } );
|
||||
|
||||
|
||||
my $search_attr = {
|
||||
order_by => {'-desc' => 'time_last'},
|
||||
'+columns' => {
|
||||
time_first_stamp => \"to_char(time_first, 'YYYY-MM-DD HH24:MI')",
|
||||
time_last_stamp => \"to_char(time_last, 'YYYY-MM-DD HH24:MI')",
|
||||
},
|
||||
};
|
||||
|
||||
=head2 node_sightings( \%cond, \%attrs? )
|
||||
|
||||
Returns the set of C<node> entries associated with this IP. That is, all the
|
||||
MAC addresses recorded which have ever hosted this IP Address.
|
||||
|
||||
Remember you can pass a filter to this method to find only active or inactive
|
||||
nodes, but do take into account that both the C<node> and C<node_ip> tables
|
||||
include independent C<active> fields.
|
||||
|
||||
=over 4
|
||||
|
||||
=item *
|
||||
|
||||
Results are ordered by time last seen.
|
||||
|
||||
=item *
|
||||
|
||||
Additional columns C<time_first_stamp> and C<time_last_stamp> provide
|
||||
preformatted timestamps of the C<time_first> and C<time_last> fields.
|
||||
|
||||
=item *
|
||||
|
||||
A JOIN is performed on the Device table and the Device DNS column prefetched.
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
sub node_sightings {
|
||||
my ($row, $cond, $attrs) = @_;
|
||||
|
||||
return $row
|
||||
->nodes({}, {
|
||||
'+columns' => [qw/ device.dns /],
|
||||
join => 'device',
|
||||
})
|
||||
->search_rs({}, $search_attr)
|
||||
->search($cond, $attrs);
|
||||
}
|
||||
|
||||
=head1 ADDITIONAL COLUMNS
|
||||
|
||||
=head2 time_first_stamp
|
||||
|
||||
Formatted version of the C<time_first> field, accurate to the minute.
|
||||
|
||||
The format is somewhat like ISO 8601 or RFC3339 but without the middle C<T>
|
||||
between the date stamp and time stamp. That is:
|
||||
|
||||
2012-02-06 12:49
|
||||
|
||||
=cut
|
||||
|
||||
sub time_first_stamp { return (shift)->get_column('time_first_stamp') }
|
||||
|
||||
=head2 time_last_stamp
|
||||
|
||||
Formatted version of the C<time_last> field, accurate to the minute.
|
||||
|
||||
The format is somewhat like ISO 8601 or RFC3339 but without the middle C<T>
|
||||
between the date stamp and time stamp. That is:
|
||||
|
||||
2012-02-06 12:49
|
||||
|
||||
=cut
|
||||
|
||||
sub time_last_stamp { return (shift)->get_column('time_last_stamp') }
|
||||
|
||||
=head2 net_mac
|
||||
|
||||
Returns the C<mac> column instantiated into a L<Net::MAC> object.
|
||||
|
||||
=cut
|
||||
|
||||
sub net_mac { return Net::MAC->new(mac => (shift)->mac) }
|
||||
|
||||
# You can replace this text with custom code or comments, and it will be preserved on regeneration
|
||||
1;
|
||||
|
||||
189
Netdisco/lib/App/Netdisco/DB/ResultSet/NodeNbt.pm
Normal file
189
Netdisco/lib/App/Netdisco/DB/ResultSet/NodeNbt.pm
Normal file
@@ -0,0 +1,189 @@
|
||||
package App::Netdisco::DB::ResultSet::NodeNbt;
|
||||
use base 'App::Netdisco::DB::ResultSet';
|
||||
|
||||
use strict;
|
||||
use warnings FATAL => 'all';
|
||||
|
||||
__PACKAGE__->load_components(qw/
|
||||
+App::Netdisco::DB::ExplicitLocking
|
||||
/);
|
||||
|
||||
my $search_attr = {
|
||||
order_by => {'-desc' => 'time_last'},
|
||||
'+columns' => [
|
||||
'oui.company',
|
||||
{ time_first_stamp => \"to_char(time_first, 'YYYY-MM-DD HH24:MI')" },
|
||||
{ time_last_stamp => \"to_char(time_last, 'YYYY-MM-DD HH24:MI')" },
|
||||
],
|
||||
join => 'oui'
|
||||
};
|
||||
|
||||
=head1 with_times
|
||||
|
||||
This is a modifier for any C<search()> (including the helpers below) which
|
||||
will add the following additional synthesized columns to the result set:
|
||||
|
||||
=over 4
|
||||
|
||||
=item time_first_stamp
|
||||
|
||||
=item time_last_stamp
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
sub with_times {
|
||||
my ($rs, $cond, $attrs) = @_;
|
||||
|
||||
return $rs
|
||||
->search_rs({}, $search_attr)
|
||||
->search($cond, $attrs);
|
||||
}
|
||||
|
||||
=head1 search_by_ip( \%cond, \%attrs? )
|
||||
|
||||
my $set = $rs->search_by_ip({ip => '192.0.2.1', active => 1});
|
||||
|
||||
Like C<search()>, this returns a ResultSet of matching rows from the
|
||||
NodeNbt table.
|
||||
|
||||
=over 4
|
||||
|
||||
=item *
|
||||
|
||||
The C<cond> parameter must be a hashref containing a key C<ip> with the value
|
||||
to search for. Value can either be a simple string of IPv4 or IPv6, or a
|
||||
L<NetAddr::IP::Lite> object in which case all results within the CIDR/Prefix
|
||||
will be retrieved.
|
||||
|
||||
=item *
|
||||
|
||||
Results are ordered by time last seen.
|
||||
|
||||
=item *
|
||||
|
||||
Additional columns C<time_first_stamp> and C<time_last_stamp> provide
|
||||
preformatted timestamps of the C<time_first> and C<time_last> fields.
|
||||
|
||||
=item *
|
||||
|
||||
A JOIN is performed on the OUI table and the OUI C<company> column prefetched.
|
||||
|
||||
=back
|
||||
|
||||
To limit results only to active IPs, set C<< {active => 1} >> in C<cond>.
|
||||
|
||||
=cut
|
||||
|
||||
sub search_by_ip {
|
||||
my ($rs, $cond, $attrs) = @_;
|
||||
|
||||
die "ip address required for search_by_ip\n"
|
||||
if ref {} ne ref $cond or !exists $cond->{ip};
|
||||
|
||||
# handle either plain text IP or NetAddr::IP (/32 or CIDR)
|
||||
my ($op, $ip) = ('=', delete $cond->{ip});
|
||||
|
||||
if ('NetAddr::IP::Lite' eq ref $ip and $ip->num > 1) {
|
||||
$op = '<<=';
|
||||
$ip = $ip->cidr;
|
||||
}
|
||||
$cond->{ip} = { $op => $ip };
|
||||
|
||||
return $rs
|
||||
->search_rs({}, $search_attr)
|
||||
->search($cond, $attrs);
|
||||
}
|
||||
|
||||
=head1 search_by_name( \%cond, \%attrs? )
|
||||
|
||||
my $set = $rs->search_by_name({nbname => 'MYNAME', active => 1});
|
||||
|
||||
Like C<search()>, this returns a ResultSet of matching rows from the
|
||||
NodeNbt table.
|
||||
|
||||
=over 4
|
||||
|
||||
=item *
|
||||
|
||||
The C<cond> parameter must be a hashref containing a key C<nbname> with the
|
||||
value to search for. The value may optionally include SQL wildcard characters.
|
||||
|
||||
=item *
|
||||
|
||||
Results are ordered by time last seen.
|
||||
|
||||
=item *
|
||||
|
||||
Additional columns C<time_first_stamp> and C<time_last_stamp> provide
|
||||
preformatted timestamps of the C<time_first> and C<time_last> fields.
|
||||
|
||||
=item *
|
||||
|
||||
A JOIN is performed on the OUI table and the OUI C<company> column prefetched.
|
||||
|
||||
=back
|
||||
|
||||
To limit results only to active IPs, set C<< {active => 1} >> in C<cond>.
|
||||
|
||||
=cut
|
||||
|
||||
sub search_by_name {
|
||||
my ($rs, $cond, $attrs) = @_;
|
||||
|
||||
die "nbname field required for search_by_name\n"
|
||||
if ref {} ne ref $cond or !exists $cond->{nbname};
|
||||
|
||||
$cond->{nbname} = { '-ilike' => delete $cond->{nbname} };
|
||||
|
||||
return $rs
|
||||
->search_rs({}, $search_attr)
|
||||
->search($cond, $attrs);
|
||||
}
|
||||
|
||||
=head1 search_by_mac( \%cond, \%attrs? )
|
||||
|
||||
my $set = $rs->search_by_mac({mac => '00:11:22:33:44:55', active => 1});
|
||||
|
||||
Like C<search()>, this returns a ResultSet of matching rows from the
|
||||
NodeNbt table.
|
||||
|
||||
=over 4
|
||||
|
||||
=item *
|
||||
|
||||
The C<cond> parameter must be a hashref containing a key C<mac> with the value
|
||||
to search for.
|
||||
|
||||
=item *
|
||||
|
||||
Results are ordered by time last seen.
|
||||
|
||||
=item *
|
||||
|
||||
Additional columns C<time_first_stamp> and C<time_last_stamp> provide
|
||||
preformatted timestamps of the C<time_first> and C<time_last> fields.
|
||||
|
||||
=item *
|
||||
|
||||
A JOIN is performed on the OUI table and the OUI C<company> column prefetched.
|
||||
|
||||
=back
|
||||
|
||||
To limit results only to active IPs, set C<< {active => 1} >> in C<cond>.
|
||||
|
||||
=cut
|
||||
|
||||
sub search_by_mac {
|
||||
my ($rs, $cond, $attrs) = @_;
|
||||
|
||||
die "mac address required for search_by_mac\n"
|
||||
if ref {} ne ref $cond or !exists $cond->{mac};
|
||||
|
||||
return $rs
|
||||
->search_rs({}, $search_attr)
|
||||
->search($cond, $attrs);
|
||||
}
|
||||
|
||||
1;
|
||||
@@ -49,6 +49,9 @@ ajax '/ajax/content/search/node' => require_login sub {
|
||||
my $ips = schema('netdisco')->resultset('NodeIp')
|
||||
->search_by_mac({mac => $mac->as_IEEE, @active, @times});
|
||||
|
||||
my $netbios = schema('netdisco')->resultset('NodeNbt')
|
||||
->search_by_mac({mac => $mac->as_IEEE, @active, @times});
|
||||
|
||||
my $ports = schema('netdisco')->resultset('DevicePort')
|
||||
->search({mac => $mac->as_IEEE});
|
||||
|
||||
@@ -62,17 +65,6 @@ ajax '/ajax/content/search/node' => require_login sub {
|
||||
}
|
||||
);
|
||||
|
||||
my $netbios = schema('netdisco')->resultset('NodeNbt')->search(
|
||||
{ mac => $mac->as_IEEE },
|
||||
{ order_by => { '-desc' => 'time_last' },
|
||||
'+columns' => [
|
||||
{
|
||||
time_first_stamp => \"to_char(time_first, 'YYYY-MM-DD HH24:MI')",
|
||||
time_last_stamp => \"to_char(time_last, 'YYYY-MM-DD HH24:MI')"
|
||||
}]
|
||||
}
|
||||
);
|
||||
|
||||
return unless $sightings->has_rows
|
||||
or $ips->has_rows
|
||||
or $ports->has_rows
|
||||
@@ -89,25 +81,33 @@ ajax '/ajax/content/search/node' => require_login sub {
|
||||
else {
|
||||
my $set;
|
||||
|
||||
my $name = $node;
|
||||
|
||||
if (param('partial')) {
|
||||
$name = "\%$name\%" if $name !~ m/%/;
|
||||
}
|
||||
|
||||
$set = schema('netdisco')->resultset('NodeNbt')
|
||||
->search_by_name({nbname => $name, @active, @times});
|
||||
|
||||
unless ( $set->has_rows ) {
|
||||
if (my $ip = NetAddr::IP::Lite->new($node)) {
|
||||
# search_by_ip() will extract cidr notation if necessary
|
||||
$set = schema('netdisco')->resultset('NodeIp')
|
||||
->search_by_ip({ip => $ip, @active, @times});
|
||||
}
|
||||
else {
|
||||
if (param('partial')) {
|
||||
$node = "\%$node\%" if $node !~ m/%/;
|
||||
}
|
||||
elsif (setting('domain_suffix')) {
|
||||
$node .= setting('domain_suffix')
|
||||
if index($node, setting('domain_suffix')) == -1;
|
||||
|
||||
if ($name !~ m/%/ and setting('domain_suffix')) {
|
||||
$name .= setting('domain_suffix')
|
||||
if index($name, setting('domain_suffix')) == -1;
|
||||
}
|
||||
$set = schema('netdisco')->resultset('NodeIp')
|
||||
->search_by_dns({dns => $node, @active, @times});
|
||||
|
||||
# if the user selects Vendor search opt, then
|
||||
# we'll try the OUI company name as a fallback
|
||||
if (not $set->count and param('show_vendor')) {
|
||||
if (not $set->has_rows and param('show_vendor')) {
|
||||
$node = param('q');
|
||||
$set = schema('netdisco')->resultset('NodeIp')
|
||||
->with_times
|
||||
@@ -117,7 +117,8 @@ ajax '/ajax/content/search/node' => require_login sub {
|
||||
);
|
||||
}
|
||||
}
|
||||
return unless $set and $set->count;
|
||||
return unless $set and $set->has_rows;
|
||||
}
|
||||
$set = $set->search_rs({}, { order_by => 'me.mac' });
|
||||
|
||||
template 'ajax/search/node_by_ip.tt', {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[% USE date(format = '%Y-%m-%d %H:%M') %]
|
||||
[% USE Number.Format %]
|
||||
<table class="table table-bordered table-hover nd_floatinghead">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -16,6 +17,24 @@
|
||||
</thead>
|
||||
</tbody>
|
||||
[% WHILE (row = macs.next) %]
|
||||
[% IF row.nbname %]
|
||||
<tr>
|
||||
<td><a class="nd_linkcell"
|
||||
href="[% search_node %]&q=[% row.net_mac.$mac_format_call | uri %]">
|
||||
[% row.net_mac.$mac_format_call | html_entity %]</a></td>
|
||||
[% IF params.show_vendor %]
|
||||
<td>[% row.oui.company | html_entity %]</td>
|
||||
[% END %]
|
||||
<td>NetBIOS</td>
|
||||
<td class="nd_linkcell">\\<a href="[% uri_for('report/netbios') %]?domain=[% row.domain | uri %]" title="Devices in this Domain">[% row.domain | html %]</a>\<a href="[% search_node %]&q=[% row.nbname | uri %]">[% row.nbname | html_entity %]</a>
|
||||
<br>[% row.nbuser || '[No User]' | html %]@<a href="[% search_node %]&q=[% row.ip | uri %]">[% row.ip | html_entity %]</a>
|
||||
</td>
|
||||
[% IF params.stamps %]
|
||||
<td>[% row.time_first_stamp | html_entity %]</td>
|
||||
<td>[% row.time_last_stamp | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% ELSE %]
|
||||
<tr>
|
||||
<td><a class="nd_linkcell"
|
||||
href="[% search_node %]&q=[% row.net_mac.$mac_format_call | uri %]">
|
||||
@@ -33,6 +52,40 @@
|
||||
<td>[% row.time_last_stamp | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% END %]
|
||||
[% FOREACH nbt IN row.netbios %]
|
||||
<tr>
|
||||
<td> </td>
|
||||
[% IF params.show_vendor %]
|
||||
<td> </td>
|
||||
[% END %]
|
||||
<td>NetBIOS</td>
|
||||
<td class="nd_linkcell">\\<a href="[% uri_for('report/netbios') %]?domain=[% nbt.domain | uri %]" title="Devices in this Domain">[% nbt.domain | html %]</a>\<a href="[% search_node %]&q=[% nbt.nbname | uri %]">[% nbt.nbname | html_entity %]</a>
|
||||
<br>[% nbt.nbuser || '[No User]' | html %]@<a href="[% search_node %]&q=[% nbt.ip | uri %]">[% nbt.ip | html_entity %]</a>
|
||||
</td>
|
||||
[% IF params.stamps %]
|
||||
<td>[% date.format(nbt.time_first) | html_entity %]</td>
|
||||
<td>[% date.format(nbt.time_last) | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% END %]
|
||||
[% FOREACH ni IN row.nodeips %]
|
||||
<tr>
|
||||
<td> </td>
|
||||
[% IF params.show_vendor %]
|
||||
<td> </td>
|
||||
[% END %]
|
||||
<td>IP → MAC</td>
|
||||
<td><a href="[% search_node %]&q=[% ni.ip | uri %]">[% ni.ip | html_entity %]</a>
|
||||
[% ' <i class="icon-book text-warning"></i> ' IF NOT ni.active %]
|
||||
[% ' (' _ ni.dns.remove(settings.domain_suffix) _ ')' IF ni.dns %]
|
||||
</td>
|
||||
[% IF params.stamps %]
|
||||
<td>[% date.format(ni.time_first) | html_entity %]</td>
|
||||
<td>[% date.format(ni.time_last) | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% END %]
|
||||
[% FOREACH node IN row.node_sightings(archive_filter) %]
|
||||
<tr>
|
||||
<td> </td>
|
||||
@@ -53,22 +106,6 @@
|
||||
<td>[% node.time_last_stamp | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% FOREACH nbt IN node.netbios %]
|
||||
<tr>
|
||||
<td> </td>
|
||||
[% IF params.show_vendor %]
|
||||
<td> </td>
|
||||
[% END %]
|
||||
<td>NetBIOS</td>
|
||||
<td class="nd_linkcell">\\<a href="[% uri_for('report/netbios') %]?domain=[% nbt.domain | uri %]" title="Devices in this Domain">[% nbt.domain | html %]</a>\<a href="[% search_node %]&q=[% nbt.nbname | uri %]">[% nbt.nbname | html_entity %]</a>
|
||||
<br>[% nbt.nbuser || '[No User]' | html %]@<a href="[% search_node %]&q=[% nbt.ip | uri %]">[% nbt.ip | html_entity %]</a>
|
||||
</td>
|
||||
[% IF params.stamps %]
|
||||
<td>[% date.format(nbt.time_first) | html_entity %]</td>
|
||||
<td>[% date.format(nbt.time_last) | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% END %]
|
||||
[% FOREACH wlan IN node.wireless %]
|
||||
<tr>
|
||||
<td> </td>
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
<br>[% nbt.nbuser || '[No User]' | html %]@<a href="[% search_node %]&q=[% nbt.ip | uri %]">[% nbt.ip | html_entity %]</a>
|
||||
</td>
|
||||
[% IF params.stamps %]
|
||||
<td>[% nbt.get_column('time_first_stamp') | html_entity %]</td>
|
||||
<td>[% nbt.get_column('time_last_stamp') | html_entity %]</td>
|
||||
<td>[% nbt.time_first_stamp | html_entity %]</td>
|
||||
<td>[% nbt.time_last_stamp | html_entity %]</td>
|
||||
[% END %]
|
||||
</tr>
|
||||
[% SET first_row = 0 %]
|
||||
|
||||
Reference in New Issue
Block a user