Squashed commit of the following:

commit b054119d9c
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 17 14:29:58 2013 +0000

    hide Reports menu if there are no reports

commit d86e670600
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 17 14:23:59 2013 +0000

    add Report docs for developers

commit ee8351eb30
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 17 14:06:39 2013 +0000

    split Plugin docs into user and developer pages

commit 5e4b8f3063
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 17 12:59:47 2013 +0000

    add duplex report into default config

commit 8fd622f50c
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 17 12:56:51 2013 +0000

    update query for duplex mismatch to check left and right are both not DOWN

commit 6d9170598c
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Mon Mar 11 23:10:38 2013 +0000

    use the new duplex mismatch query in a template

commit 786977354b
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Mon Mar 11 22:54:21 2013 +0000

    add VIEW for duplex mismatches

commit f37ae8568e
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Mon Mar 11 22:54:01 2013 +0000

    remove unecessary assign

commit 13af853582
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 23:40:53 2013 +0000

    fixes to main app to support reports

commit 55a0f3d8dc
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 22:18:48 2013 +0000

    also update fontawesome to match bootstrap version

commit 83a2c74242
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 22:07:23 2013 +0000

    update bootstrap again, to include glyphicons with correct path

commit 25be8bfa92
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 19:27:37 2013 +0000

    implement first report - duplex mismatch - as a placeholder only

commit 00265a9323
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 18:41:40 2013 +0000

    report error on failure to load a plugin

commit af8f124bb2
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 18:37:19 2013 +0000

    change id for tag in device and search tab plugins

commit b818d4156f
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 18:29:57 2013 +0000

    change id for tag in navbar plugins

commit f513000f08
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 17:21:23 2013 +0000

    implement register_report() and replace More menu with Reports menu

commit 4a16e3fde3
Author: Oliver Gorwits <oliver@cpan.org>
Date:   Sun Mar 10 17:00:44 2013 +0000

    Upgraded to Twitter Bootstrap 2.3.1 - customized for 13px font 18px line
This commit is contained in:
Oliver Gorwits
2013-03-17 14:37:21 +00:00
parent a67478dd5c
commit 65d01be38c
36 changed files with 1244 additions and 1062 deletions

View File

@@ -0,0 +1,61 @@
package App::Netdisco::DB::Result::Virtual::DuplexMismatch;
use strict;
use warnings;
use base 'DBIx::Class::Core';
__PACKAGE__->table_class('DBIx::Class::ResultSource::View');
__PACKAGE__->table('duplex_mismatch');
__PACKAGE__->result_source_instance->is_virtual(1);
__PACKAGE__->result_source_instance->view_definition(<<ENDSQL
SELECT dp.ip AS left_ip, d1.dns AS left_dns, dp.port AS left_port, dp.duplex AS left_duplex,
di.ip AS right_ip, d2.dns AS right_dns, dp.remote_port AS right_port, dp2.duplex AS right_duplex
FROM ( SELECT device_port.ip, device_port.remote_ip, device_port.port, device_port.duplex, device_port.remote_port
FROM device_port
WHERE
device_port.remote_port IS NOT NULL
AND device_port.up NOT ILIKE '%down%'
GROUP BY device_port.ip, device_port.remote_ip, device_port.port, device_port.duplex, device_port.remote_port
ORDER BY device_port.ip) dp
LEFT JOIN device_ip di ON dp.remote_ip = di.alias
LEFT JOIN device d1 ON dp.ip = d1.ip
LEFT JOIN device d2 ON di.ip = d2.ip
LEFT JOIN device_port dp2 ON (di.ip = dp2.ip AND dp.remote_port = dp2.port)
WHERE di.ip IS NOT NULL
AND dp.duplex <> dp2.duplex
AND dp.ip <= di.ip
AND dp2.up NOT ILIKE '%down%'
ORDER BY dp.ip
ENDSQL
);
__PACKAGE__->add_columns(
'left_ip' => {
data_type => 'inet',
},
'left_dns' => {
data_type => 'text',
},
'left_port' => {
data_type => 'text',
},
'left_duplex' => {
data_type => 'text',
},
'right_ip' => {
data_type => 'inet',
},
'right_dns' => {
data_type => 'text',
},
'right_port' => {
data_type => 'text',
},
'right_duplex' => {
data_type => 'text',
},
);
1;

View File

@@ -0,0 +1,246 @@
=head1 NAME
App::Netdisco::Manual::WritingPlugins - Documentation on Plugins for Developers
=head1 Introduction
L<App::Netdisco>'s plugin subsystem allows developers to write and test web
user interface (UI) components without needing to patch the main Netdisco
application. It also allows the end-user more control over the UI components
displayed in their browser.
See L<App::Netdisco::Web::Plugin> for more general information about plugins.
=head1 Developing Plugins
A plugin is simply a Perl module which is loaded. Therefore it can do anything
you like, but most usefully for the App::Netdisco web application, the module
will install a L<Dancer> route handler subroutine, and link this to a web user
interface (UI) component.
Explaining how to write Dancer route handlers is beyond the scope of this
document, but by examining the source to the plugins in App::Netdisco you'll
probably get enough of an idea to begin on your own.
App::Netdisco plugins should load the L<App::Netdisco::Web::Plugin> module.
This exports a set of helper subroutines to register the new UI components.
Here's the boilerplate code for our example plugin module:
package App::Netdisco::Web::Plugin::MyNewFeature
use Dancer ':syntax';
use Dancer::Plugin::Ajax;
use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
# plugin registration code goes here, ** see below **
# your Dancer route handler, for example:
get '/mynewfeature' => sub {
# ...lorem ipsum...
};
true;
=head1 Navigation Bar items
These components appear in the black navigation bar at the top of each page,
as individual items (i.e. not in a menu). The canonical example of this is the
Inventory link.
To register an item for display in the navigation bar, use the following code:
register_navbar_item({
tag => 'newfeature',
path => '/mynewfeature',
label => 'My New Feature',
});
This causes an item to appear in the Navigation Bar with a visible text of "My
New Feature" which when clicked sends the user to the C</mynewfeature> page.
Note that this won't work for any target link - the path must be an
App::Netdisco Dancer route handler. Please bug the App::Netdisco devs if you
want arbitrary links supported.
=head1 Search and Device page Tabs
These components appear as tabs in the interface when the user reaches the
Search page or Device details page. Note that Tab plugins usually live in
the C<App::Netdisco::Web::Plugin::Device> or
C<App::Netdisco::Web::Plugin::Search> namespace.
To register a handler for display as a Search page Tab, use the following
code:
register_search_tab({tag => 'newfeature', label => 'My New Feature'});
This causes a tab to appear with the label "My New Feature". So how does
App::Netdisco know what the link should be? Well, as the
L<App::Netdisco::Developing> documentation says, tab content is retrieved by
an AJAX call back to the web server. This uses a predictable URL path format:
/ajax/content/<search or device>/<feature tag>
For example:
/ajax/content/search/newfeature
Therefore your plugin module should look like the following:
package App::Netdisco::Web::Plugin::Search::MyNewFeature
use Dancer ':syntax';
use Dancer::Plugin::Ajax;
use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_search_tab({tag => 'newfeature', label => 'My New Feature'});
ajax '/ajax/content/search/newfeature' => sub {
# ...lorem ipsum...
content_type('text/html');
# return some HTML content here, probably using a template
};
true;
If this all sounds a bit daunting, take a look at the
L<App::Netdisco::Web::Plugin::Search::Port> module which is fairly
straightforward.
To register a handler for display as a Device page Tab, the only difference is
the name of the registration helper sub:
register_device_tab({tag => 'newfeature', label => 'My New Feature'});
=head1 Reports
Report components contain pre-canned searches which the user community have
found to be useful. The implementation is very similar to one of the Search
and Device page Tabs, so please read that documentation above, first.
Report plugins usually live in the C<App::Netdisco::Web::Plugin::Report>
namespace. To register a handler for display as a Report, you need to pick the
I<category> of the report. Here are the pre-defined categories:
=over 4
=item *
Device
=item *
Port
=item *
Node
=item *
VLAN
=item *
Network
=item *
Wireless
=back
Once your category is selected, use the following registration code:
register_report({
category => 'Port', # pick one from the list
tag => 'newreport',
label => 'My New Report',
});
You will note that like Device and Search page Tabs, there's no path
specified in the registration. The reports engine will make an AJAX request to
the following URL:
/ajax/content/report/<report tag>
Therefore you should implement in your plugin an AJAX handler for this path.
The handler must return the HTML content for the report. It can also process
any query parameters which might customize the report search.
See the L<App::Netdisco::Web::Plugin::Report::DuplexMismatch> module for a
simple example of how to implement the handler.
=head1 Templates
All of Netdisco's web page templates are stashed away in its distribution,
probably installed in your system's or user's Perl directory. It's not
recommended that you mess about with those files.
So in order to replace a template with your own version, or to reference a
template file of your own in your plugin, you need a new path:
package App::Netdisco::Web::Plugin::Search::MyNewFeature
use File::ShareDir 'dist_dir';
register_template_path(
dist_dir( 'App-Netdisco-Web-Plugin-Search-MyNewFeature' ));
The registered path will be searched before the built-in C<App::Netdisco>
path. We recommend use of the L<File::ShareDir> module to package and ship
templates along with your plugin, as shown.
Each path added using C<register_template_path> is searched I<before> any
existing paths in the template config.
=head3 Template Variables
Some useful variables are made available in your templates automatically by
App::Netdisco:
=over 4
=item C<search_node>
A base url which links to the Node tab of the Search page, together with the
correct default search options set.
=item C<search_device>
A base url which links to the Device tab of the Search page, together with the
correct default search options set.
=item C<device_ports>
A base url which links to the Ports tab of the Device page, together with
the correct default column view options set.
=item C<uri_base>
Used for linking to static content within App::Netdisco safely if the base of
the app is relocated, for example:
<link rel="stylesheet" href="[% uri_base %]/css/toastr.css"/>
=item C<uri_for>
Simply the Dancer C<uri_for> method. Allows you to do things like this in the
template safely if the base of the app is relocated:
<a href="[% uri_for('/search') %]" ...>
=item C<self_options>
Available in the Device tabs, use this if you need to refer back to the
current page with some additional parameters, for example:
<a href="[% uri_for('/device', self_options) %]&foo=bar" ...>
=back
=cut

View File

@@ -12,6 +12,7 @@ use URI::QueryParam (); # part of URI, to add helper methods
use App::Netdisco::Web::AuthN;
use App::Netdisco::Web::Search;
use App::Netdisco::Web::Device;
use App::Netdisco::Web::Report;
use App::Netdisco::Web::TypeAhead;
use App::Netdisco::Web::PortControl;
@@ -25,6 +26,7 @@ sub _load_web_plugins {
debug "loading Netdisco plugin $plugin";
eval "require $plugin";
error $@ if $@;
}
}

View File

@@ -7,6 +7,9 @@ set(
'navbar_items' => [],
'search_tabs' => [],
'device_tabs' => [],
'reports_menu' => {},
'reports' => {},
'report_order' => [qw/Device Port Node VLAN Network Wireless/],
);
# this is what Dancer::Template::TemplateToolkit does by default
@@ -28,7 +31,7 @@ register 'register_template_path' => sub {
register 'register_navbar_item' => sub {
my ($self, $config) = plugin_args(@_);
if (!length $config->{id}
if (!length $config->{tag}
or !length $config->{path}
or !length $config->{label}) {
@@ -37,7 +40,7 @@ register 'register_navbar_item' => sub {
}
foreach my $item (@{ setting('navbar_items') }) {
if ($item->{id} eq $config->{id}) {
if ($item->{tag} eq $config->{tag}) {
$item = $config;
return;
}
@@ -50,7 +53,7 @@ sub _register_tab {
my ($nav, $config) = @_;
my $stash = setting("${nav}_tabs");
if (!length $config->{id}
if (!length $config->{tag}
or !length $config->{label}) {
error "bad config to register_${nav}_item";
@@ -58,7 +61,7 @@ sub _register_tab {
}
foreach my $item (@{ $stash }) {
if ($item->{id} eq $config->{id}) {
if ($item->{tag} eq $config->{tag}) {
$item = $config;
return;
}
@@ -77,6 +80,30 @@ register 'register_device_tab' => sub {
_register_tab('device', $config);
};
register 'register_report' => sub {
my ($self, $config) = plugin_args(@_);
my @categories = @{ setting('report_order') };
if (!length $config->{category}
or !length $config->{tag}
or !length $config->{label}
or 0 == scalar grep {$config->{category} eq $_} @categories) {
error "bad config to register_report";
return;
}
foreach my $item (@{setting('reports_menu')->{ $config->{category} }}) {
if ($item eq $config->{tag}) {
setting('reports')->{$config->{tag}} = $config;
return;
}
}
push @{setting('reports_menu')->{ $config->{category} }}, $config->{tag};
setting('reports')->{$config->{tag}} = $config;
};
register_plugin;
true;
@@ -86,12 +113,12 @@ App::Netdisco::Web::Plugin - Plugin subsystem for App::Netdisco Web UI component
=head1 Introduction
L<App::Netdisco>'s plugin subsystem allows developers to write and test web
user interface (UI) components without needing to patch the main Netdisco
application. It also allows the end-user more control over the UI components
displayed in their browser.
L<App::Netdisco>'s plugin subsystem allows the user more control of Netdisco
UI components displayed in the web browser. Plugins can be distributed
independently from Netdisco and are a better alternative to source code
patches.
So far, the following UI compoents are implemented as plugins:
The following UI components are implemented as plugins:
=over 4
@@ -103,12 +130,14 @@ Navigation Bar items (e.g. Inventory link)
Tabs for Search and Device pages
=item *
Reports (pre-canned searches)
=back
In the future there will be other components supported, such as Reports.
This document explains first how to configure which plugins are loaded (useful
for the end-user) and then also how to write and install your own plugins.
This document explains how to configure which plugins are loaded. See
L<App::Netdisco::Manual::WritingPlugins> if you want to develop new plugins.
=head1 Application Configuration
@@ -119,6 +148,7 @@ be loaded. For example:
web_plugins:
- Inventory
- Report::DuplexMismatch
- Search::Device
- Search::Node
- Search::Port
@@ -142,9 +172,7 @@ namespaces:
web_plugins:
- Inventory
- Search::Device
- Search::Node
- Device::Details
- Device::Ports
- +My::Other::Netdisco::Web::Component
The order of the entries in C<web_plugins> is significant. Unsurprisingly, the
@@ -163,174 +191,5 @@ C<web_plugins> setting, use the C<extra_web_plugins> setting instead in your
Environment configuration. Any Navigation Bar items or Page Tabs are added
after those in C<web_plugins>.
=head1 Developing Plugins
A plugin is simply a Perl module which is loaded. Therefore it can do anything
you like, but most usefully for the App::Netdisco web application, the module
will install a L<Dancer> route handler subroutine, and link this to a web user
interface (UI) component.
Explaining how to write Dancer route handlers is beyond the scope of this
document, but by examining the source to the plugins in App::Netdisco you'll
probably get enough of an idea to begin on your own.
App::Netdisco plugins should load the L<App::Netdisco::Web::Plugin> module.
This exports a set of helper subroutines to register the new UI components.
Here's the boilerplate code for our example plugin module:
package App::Netdisco::Web::Plugin::MyNewFeature
use Dancer ':syntax';
use Dancer::Plugin::Ajax;
use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
# plugin registration code goes here, ** see below **
# your Dancer route handler, for example:
get '/mynewfeature' => sub {
# ...lorem ipsum...
};
true;
=head2 Navigation Bar items
These components appear in the black navigation bar at the top of each page,
as individual items (i.e. not in a menu). The canonical example of this is the
Inventory link.
To register an item for display in the navigation bar, use the following code:
register_navbar_item({
id => 'newfeature',
path => '/mynewfeature',
label => 'My New Feature',
});
This causes an item to appear in the Navigation Bar with a visible text of "My
New Feature" which when clicked sends the user to the C</mynewfeature> page.
Note that this won't work for any target link - the path must be an
App::Netdisco Dancer route handler. Please bug the App::Netdisco devs if you
want arbitrary links supported.
=head2 Search and Device page Tabs
These components appear as tabs in the interface when the user reaches the
Search page or Device details page. Note that Tab plugins usually live in
the C<App::Netdisco::Web::Plugin::Device> or
C<App::Netdisco::Web::Plugin::Search> namespace.
To register a handler for display as a Search page Tab, use the following
code:
register_search_tab({id => 'newfeature', label => 'My New Feature'});
This causes a tab to appear with the label "My New Feature". So how does
App::Netdisco know what the link should be? Well, as the
L<App::Netdisco::Developing> documentation says, tab content is retrieved by
an AJAX call back to the web server. This uses a predictable URL path format:
/ajax/content/<search or device>/<feature ID>
For example:
/ajax/content/search/newfeature
Therefore your plugin module should look like the following:
package App::Netdisco::Web::Plugin::Search::MyNewFeature
use Dancer ':syntax';
use Dancer::Plugin::Ajax;
use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_search_tab({id => 'newfeature', label => 'My New Feature'});
ajax '/ajax/content/search/newfeature' => sub {
# ...lorem ipsum...
content_type('text/html');
# return some HTML content here, probably using a template
};
true;
If this all sounds a bit daunting, take a look at the
L<App::Netdisco::Web::Plugin::Search::Port> module which is fairly
straightforward.
To register a handler for display as a Device page Tab, the only difference is
the name of the registration helper sub:
register_device_tab({id => 'newfeature', label => 'My New Feature'});
=head2 Templates
All of Netdisco's web page templates are stashed away in its distribution,
probably installed in your system's or user's Perl directory. It's not
recommended that you mess about with those files.
So in order to replace a template with your own version, or to reference a
template file of your own in your plugin, you need a new path:
package App::Netdisco::Web::Plugin::Search::MyNewFeature
use File::ShareDir 'dist_dir';
register_template_path(
dist_dir( 'App-Netdisco-Web-Plugin-Search-MyNewFeature' ));
The registered path will be searched before the built-in C<App::Netdisco>
path. We recommend use of the L<File::ShareDir> module to package and ship
templates along with your plugin, as shown.
=head3 Template Variables
Some useful variables are made available in your templates automatically by
App::Netdisco:
=over 4
=item C<search_node>
A base url which links to the Node tab of the Search page, together with the
correct default search options set.
=item C<search_device>
A base url which links to the Device tab of the Search page, together with the
correct default search options set.
=item C<device_ports>
A base url which links to the Ports tab of the Device page, together with
the correct default column view options set.
=item C<uri_base>
Used for linking to static content within App::Netdisco safely if the base of
the app is relocated, for example:
<link rel="stylesheet" href="[% uri_base %]/css/toastr.css"/>
=item C<uri_for>
Simply the Dancer C<uri_for> method. Allows you to do things like this in the
template safely if the base of the app is relocated:
<a href="[% uri_for('/search') %]" ...>
=item C<self_options>
Available in the Device tabs, use this if you need to refer back to the
current page with some additional parameters, for example:
<a href="[% uri_for('/device', self_options) %]&foo=bar" ...>
=back
=cut

View File

@@ -6,7 +6,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_device_tab({ id => 'addresses', label => 'Addresses' });
register_device_tab({ tag => 'addresses', label => 'Addresses' });
# device interface addresses
ajax '/ajax/content/device/addresses' => sub {

View File

@@ -6,7 +6,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_device_tab({ id => 'details', label => 'Details' });
register_device_tab({ tag => 'details', label => 'Details' });
# device details table
ajax '/ajax/content/device/details' => sub {

View File

@@ -5,7 +5,7 @@ use Dancer::Plugin::Ajax;
use App::Netdisco::Web::Plugin;
register_device_tab({ id => 'modules', label => 'Modules' });
register_device_tab({ tag => 'modules', label => 'Modules' });
ajax '/ajax/content/device/:thing' => sub {
return "<p>Hello, this is where the ". param('thing') ." content goes.</p>";

View File

@@ -6,7 +6,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_device_tab({ id => 'netmap', label => 'Neighbors' });
register_device_tab({ tag => 'netmap', label => 'Neighbors' });
ajax '/ajax/content/device/netmap' => sub {
content_type('text/html');

View File

@@ -7,7 +7,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Util::Web (); # for sort_port
use App::Netdisco::Web::Plugin;
register_device_tab({ id => 'ports', label => 'Ports' });
register_device_tab({ tag => 'ports', label => 'Ports' });
# device ports with a description (er, name) matching
ajax '/ajax/content/device/ports' => sub {

View File

@@ -6,7 +6,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_navbar_item({
id => 'inventory',
tag => 'inventory',
path => '/inventory',
label => 'Inventory',
});

View File

@@ -0,0 +1,25 @@
package App::Netdisco::Web::Plugin::Report::DuplexMismatch;
use Dancer ':syntax';
use Dancer::Plugin::Ajax;
use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_report({
category => 'Port',
tag => 'duplexmismatch',
label => 'Duplex Mismatches Between Devices',
});
ajax '/ajax/content/report/duplexmismatch' => sub {
my $set = schema('netdisco')->resultset('Virtual::DuplexMismatch');
return unless $set->count;
content_type('text/html');
template 'ajax/report/duplexmismatch.tt', {
results => $set,
}, { layout => undef };
};
true;

View File

@@ -8,7 +8,7 @@ use List::MoreUtils ();
use App::Netdisco::Web::Plugin;
register_search_tab({id => 'device', label => 'Device'});
register_search_tab({ tag => 'device', label => 'Device' });
# device with various properties or a default match-all
ajax '/ajax/content/search/device' => sub {

View File

@@ -9,7 +9,7 @@ use Net::MAC ();
use App::Netdisco::Web::Plugin;
register_search_tab({ id => 'node', label => 'Node' });
register_search_tab({ tag => 'node', label => 'Node' });
# nodes matching the param as an IP or DNS hostname or MAC
ajax '/ajax/content/search/node' => sub {

View File

@@ -6,7 +6,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_search_tab({ id => 'port', label => 'Port' });
register_search_tab({ tag => 'port', label => 'Port' });
# device ports with a description (er, name) matching
ajax '/ajax/content/search/port' => sub {

View File

@@ -6,7 +6,7 @@ use Dancer::Plugin::DBIC;
use App::Netdisco::Web::Plugin;
register_search_tab({ id => 'vlan', label => 'VLAN' });
register_search_tab({ tag => 'vlan', label => 'VLAN' });
# devices carrying vlan xxx
ajax '/ajax/content/search/vlan' => sub {

View File

@@ -0,0 +1,17 @@
package App::Netdisco::Web::Report;
use Dancer ':syntax';
get '/report/*' => sub {
my ($tag) = splat;
# trick the ajax into working as if this were a tabbed page
params->{tab} = $tag;
var(nav => 'reports');
template 'report', {
report => setting('reports')->{ $tag },
};
};
true;