App-Netdisco

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

    clicked, because the first node in the list was drawn on top of it
  * the Ports tab SSID column showed one SSID on a port that has several
  * the Ports CSV export omitted the SSID value, shifting every later column
  * GET /api/v1/object/device/{ip}/port/{port}/ssid returned only one SSID
    for a port that has several, and now answers with a list
  * checksitelocal named the wrong release for the do_search deprecation
  * #1667 the Show all Ports tooltip stayed on screen, at the top left of the
    window, after clicking the bin to clear the ports filter
  * a tooltip could be left on screen the same way when the sidebar, the CSV
    download link or a port control icon was hidden
  * a page fragment could 404 for the rest of a web worker's life after one
    request to its URL from outside the application
  * behind_proxy, which no deployment needs to set, made the client address
    read as the whole X-Forwarded-For chain where more than one proxy stood in
    front of the web server, so an API token restricted by an ACL was refused
    and every login failed to log

2.107000 - 2026-09-04

  [NEW FEATURES]

Changes  view on Meta::CPAN


  * HTML tables now use DataTables, table plug-in for jQuery, to support
    pagination, results filtering, and multi-column ordering

  [ENHANCEMENTS]

  * Web daemon will drop root status always if --port is not given
  * Ignore devices with "phone" in their CDP/LLDP platform description
  * Change log format to contain UTC timestamp
  * Link to VLAN search by name from VLAN Inventory report
  * Neighbor device links to both device and port (fragfutter)
  * Optimization of multiple SQL queries to improve report performance
  * VendorMap and Delimiter enhancements to RANCID generator (LesB)

  [BUG FIXES]

  * Subnets report date range fixed, & (unnecessary?) "not" option removed
  * Track how many times the device uptime wrapped (F. Schiavarelli)
  * Fix for device counts in VLAN Inventory report
  * Forward port housekeeping/schedule and expiry/expire legacy config
  * Store started timestamp on job completion

bin/netdisco-web-fg  view on Meta::CPAN

}

use App::Netdisco;
use Dancer;
BEGIN {
  warning sprintf "App::Netdisco %s web", ($App::Netdisco::VERSION || 'HEAD');
}

# Exact strings, not patterns: Deflater compares with `eq`, where the Expires
# filter below takes regexes. text/xml is what Dancer::Plugin::Ajax gives the
# ajax fragments. Both JavaScript spellings appear because Plack::MIME has
# served .js as each.
my @compressible = qw(
  text/html
  text/xml
  text/css
  text/javascript
  application/javascript
  text/plain
  application/json
  image/svg+xml

lib/App/Netdisco/Web/Plugin/Device/Ports.pm  view on Meta::CPAN


    my @neighbor_ports = map { $_->port } grep { $_->remote_ip } @$rows;
    return undef if not scalar @neighbor_ports;
    return \@neighbor_ports;
}

# n_archived swaps $nodes_name to nodes, which is the whole table rather than
# the archived half, so a shadow built with active = 0 there would drop the
# active nodes back out and a MAC visible on screen would stop being findable.
# Derived from $nodes_name so this and %node_result_class cannot drift apart.
sub _shadow_active_fragment {
    my ($nodes_name) = @_;
    return $nodes_name =~ m/^active/ ? 'AND n.active' : '';
}

# Builds the DataTables search shadow: one string per port carrying every
# node's mac, ip, dns and vlan text, plus ssid and netbios when asked for, so
# the filter can find a node whose markup this response does not render.
# Built in one statement rather than by concatenating the fetched rows in
# Perl, which costs more than the render it exists to save.
#
# The node_ip lateral's "active = n.active" mirrors Node's ips relationship,
# which joins foreign.active => self.active, so an archived node gets archived
# IPs. Get it wrong and the filter matches archived IPs that are not on
# screen. node_wireless and node_nbt are keyed on mac alone, with no active
# condition, so their laterals match that instead.
#
# vlan has no n_* guard in the template and is always included. ssid, netbios
# and vendor are joined only under their own guards: a field the cell does not
# render need not be findable, and each join has a real cost.
sub _attach_search_shadow {
    my ($schema, $device_ip, $rows, $active_fragment, $want_ssid, $want_netbios,
        $mac_format, $want_vendor) = @_;

    my ($ssid_select, $ssid_join) = ('', '');
    if ($want_ssid) {
        $ssid_select = q{ || ' ' || COALESCE(w.txt, '')};
        $ssid_join = q{
          LEFT JOIN LATERAL (
            SELECT string_agg(ssid, ' ') AS txt
              FROM node_wireless WHERE mac = n.mac) w ON true};
    }

lib/App/Netdisco/Web/Plugin/Device/Ports.pm  view on Meta::CPAN

               string_agg(n.mac::text || ' ' || COALESCE(n.vlan, '') || ' '
                 || COALESCE(i.txt, '')%s%s%s%s, ' ') AS s
          FROM node n
          LEFT JOIN LATERAL (
            SELECT string_agg(ip::text || ' ' || COALESCE(dns, ''), ' ') AS txt
              FROM node_ip WHERE mac = n.mac AND active = n.active) i ON true
          %s%s%s
         WHERE n.switch = ? %s
         GROUP BY n.port
    }, $ssid_select, $netbios_select, $vendor_select, $mac_select,
       $ssid_join, $netbios_join, $vendor_join, $active_fragment);

    my $shadow = $schema->storage->dbh_do(sub {
      my (undef, $dbh) = @_;
      $dbh->selectall_arrayref($sql, undef, $device_ip);
    });

    my %shadow_by_port = map {; $_->[0] => ($_->[1] || '') } @$shadow;
    $_->{nodes_search} = ($shadow_by_port{ $_->port } || '') for @$rows;
    return;
}

lib/App/Netdisco/Web/Plugin/Device/Ports.pm  view on Meta::CPAN


    my @extra_prefetch = ();
    push @extra_prefetch, 'wireless' if param('n_ssid');
    push @extra_prefetch, 'netbios' if param('n_netbios');
    push @extra_prefetch, 'manufacturer' if param('n_vendor');

    return ($nodes_name, $ips_name, \@node_order, \@extra_prefetch);
}

# One query, run once per c_nodes request rather than once per row: reads the
# same table _attach_search_shadow does, under the same active fragment, so a
# port cannot say "N nodes" here and expand to a different number there.
sub _count_nodes_by_port {
    my ($schema, $device_ip, $rows, $active_fragment) = @_;

    my $sql = sprintf(q{
        SELECT n.port, count(*) AS n
          FROM node n
         WHERE n.switch = ? %s
         GROUP BY n.port
    }, $active_fragment);

    my $counts = $schema->storage->dbh_do(sub {
      my (undef, $dbh) = @_;
      $dbh->selectall_arrayref($sql, undef, $device_ip);
    });

    my %count_by_port = map {; $_->[0] => $_->[1] } @$counts;
    $_->{node_count} = ($count_by_port{ $_->port } || 0) for @$rows;
    return \%count_by_port;
}

lib/App/Netdisco/Web/Plugin/Device/Ports.pm  view on Meta::CPAN

      scalar param('c_nodes'), scalar param('c_neighbors'), \@results);

    my $deferred_node_params = '';

    if (defined $node_fetch_scope) {
        my $only_ports = $node_fetch_scope;
        my $skip_stitch = 0;

        # ports_csv.tt has no collapse threshold and reads every port's
        # stitched_nodes unconditionally, so this scoping only applies to
        # the HTML fragment: a CSV export still gets the whole device.
        if (param('c_nodes') and request->is_ajax) {
            my $count_by_port = _count_nodes_by_port(schema(vars->{'tenant'}),
              $device->ip, \@results, _shadow_active_fragment($nodes_name));

            # Ports over the threshold render a count and an empty div that
            # fetches its own rows; fetching them here would build markup
            # that netdisco.css hides on arrival.
            $only_ports = _threshold_scope(
              setting('devport_nodes_collapse_threshold'),
              scalar param('c_neighbors'), $count_by_port, \@results);
            $skip_stitch = 1 unless scalar @$only_ports;

            $deferred_node_params = _deferred_node_params(

lib/App/Netdisco/Web/Plugin/Device/Ports.pm  view on Meta::CPAN

        _stitch_nodes(schema(vars->{'tenant'}), $device->ip, \@results,
          $nodes_name, $ips_name, $node_order, $extra_prefetch, $only_ports)
          unless $skip_stitch;
    }

    # Gated on c_nodes alone, not c_neighbors like the stitch above: the
    # template emits data-search only under c_nodes, so the neighbors-only
    # view would build this for nothing.
    if (param('c_nodes')) {
        _attach_search_shadow(schema(vars->{'tenant'}), $device->ip, \@results,
          _shadow_active_fragment($nodes_name),
          scalar param('n_ssid'), scalar param('n_netbios'),
          scalar param('mac_format'), scalar param('n_vendor'));
        _augment_neighbor_search(\@results, scalar param('c_neighbors'),
          scalar param('n_inventory'));
    }

    # filter for tagged vlan using existing agg query,
    # which is better than join inflation
    if (($prefer eq 'vlan') or (not $prefer and $f =~ m/^\d+$/)) {
      if (param('invert')) {

share/public/css/font-awesome.min.css  view on Meta::CPAN

/*!
 * Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
 * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
 * Copyright 2026 Fonticons, Inc.
 */
.fa,.fa-brands,.fa-classic,.fa-regular,.fa-solid,.fab,.far,.fas{--_fa-family:var(--fa-family,var(--fa-style-family,"Font Awesome 7 Free"));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:var(--fa-display,inline-block);fon...

.fa-0{--fa:"\30 "}.fa-1{--fa:"\31 "}.fa-2{--fa:"\32 "}.fa-3{--fa:"\33 "}.fa-4{--fa:"\34 "}.fa-5{--fa:"\35 "}.fa-6{--fa:"\36 "}.fa-7{--fa:"\37 "}.fa-8{--fa:"\38 "}.fa-9{--fa:"\39 "}.fa-exclamation{--fa:"\!"}.fa-hashtag{--fa:"\#"}.fa-dollar,.fa-dollar-...
:host,:root{--fa-family-brands:"Font Awesome 7 Brands";--fa-font-brands:normal 400 1em/1 var(--fa-family-brands)}@font-face{font-family:"Font Awesome 7 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.wof...

share/public/javascripts/htmx.min.js  view on Meta::CPAN

var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,to...

share/public/javascripts/jquery-ui.min.js  view on Meta::CPAN

/*! jQuery UI - v1.14.2 - 2026-01-28
* https://jqueryui.com
* Includes: widget.js, position.js, data.js, disable-selection.js, effect.js, effects/effect-blind.js, effects/effect-bounce.js, effects/effect-clip.js, effects/effect-drop.js, effects/effect-explode.js, effects/effect-fade.js, effects/effect-fold.js...
* Copyright OpenJS Foundation and other contributors; Licensed MIT */

!function(t){"use strict";"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)}(function(t){"use strict";t.ui=t.ui||{},t.ui.version="1.14.2";var e,i,s,n,o,a=0,r=Array.prototype.hasOwnProperty,l=Array.prototype.slice;h=t.cleanData,t.cl...

share/public/javascripts/netdisco.js  view on Meta::CPAN

  if (event.key !== 'Escape') { return }
  if (!event.target.closest('li.dropend > .dropdown-menu')) { return }
  event.stopPropagation();
  var toggle = event.target.closest('.nav-item.dropdown');
  toggle = toggle && toggle.querySelector(':scope > .dropdown-toggle');
  if (!toggle) { return }
  toggle.focus();
  bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
}, true);

// htmx takes the indicator down when the response arrives, but the fragment's
// own script builds its table from a ready callback afterwards, so the raw
// full-length table would paint with no indicator until that finishes.
//
// Quiet DOM rather than a table library's own event, so this outlives the move
// off jQuery. Quiet is not enough on its own: the build has gaps of several
// hundred milliseconds where nothing changes because the thread is busy
// computing, and revealing in one of those shows a table that is still moving.
// A frame that took far longer than a frame should is the evidence of that, so
// both conditions have to hold, twice running.
//

share/public/javascripts/netdisco.js  view on Meta::CPAN

  // table that no longer answers the search being run.
  //
  // jobqueue is excluded for the reason it carries no indicator: it refreshes
  // on a timer and would blank on every tick.
  document.body.addEventListener('htmx:beforeRequest', function (evt) {
    var target = evt.detail.target;
    if (!target.id.match(/_pane$/) || target.id === 'jobqueue_pane') return;

    // force-graph renders every frame until destroyed, and emptying the pane
    // only detaches its canvas. netmap.js destroys the previous instance as
    // well, but not until the new fragment's script runs.
    if (target.id === 'netmap_pane' && window.graph && window.graph.fg
        && typeof window.graph.fg._destructor === 'function') {
      window.graph.fg._destructor();
    }

    destroyAutocompletesIn(target);
    target.innerHTML = '';
  });
  document.body.addEventListener('htmx:responseError', function (evt) {
    var target = evt.detail.target;

share/public/swagger-ui/swagger-ui-bundle.js  view on Meta::CPAN

/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(...

share/public/swagger-ui/swagger-ui-bundle.js.map  view on Meta::CPAN

{"version":3,"file":"swagger-ui-bundle.js","mappings":";CAAA,SAAUA,iCAAiCC,EAAMC,GAC1B,iBAAZC,SAA0C,iBAAXC,OACxCA,OAAOD,QAAUD,IACQ,mBAAXG,QAAyBA,OAAOC,IAC9CD,OAAO,GAAIH,GACe,iBAAZC,QACdA,QAAyB,gBAAID,IAE7BD,EAAsB,gBAAIC,GAC3B,CATD,CASGK,MAAM,cCRLC,EA...

share/public/swagger-ui/swagger-ui-standalone-preset.js  view on Meta::CPAN

/*! For license information please see swagger-ui-standalone-preset.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SwaggerUIStandalonePreset=e():t.SwaggerUIStandalo...

share/views/ajax/admintask/users.tt  view on Meta::CPAN


  $('#nd_token-copy').on('click', function() {
    navigator.clipboard.writeText($('#nd_token-value').val());
    $(this).html('<i class="fas fa-check"></i> Copied');
  });

  // Called by admintask.js when the server returns a data-nd-api-key span
  window.nd_show_api_token = function(apiKey) {
    $('#nd_token-value').val(apiKey);
    $('#nd_token-copy').html('<i class="fas fa-copy"></i> Copy');
    // Name the form rather than building its id from task.tag. This fragment
    // is only ever rendered by the ajax route in Users.pm, which passes no task
    // in its stash, so task.tag was always empty here and the selector was
    // always '#_form', which matches nothing. The tag is 'users' either way:
    // this template is registered for that one admin task.
    $('#nd_token-reveal').one('hidden.bs.modal', function() {
      nd_submit('#users_form');
    });
    $('#nd_token-reveal').modal('show');
  };

share/views/ajax/device/netmap.tt  view on Meta::CPAN

<div id="nd2_netmap-wrap">
[%# do_search's "Waiting for results..." is already gone by the time this
    fragment lands; the wait the user sees is the getJSON below. Same markup as
    netdisco.js so the netmap does not announce a slow load differently. %]
  <div id="nd2_netmap-loading" class="col-md-2 alert"><i class="fas fa-spinner fa-spin"></i> Waiting for results...</div>
  <div id="nd2_netmap-container"></div>
  <div id="nd2_netmap-fullscreen" title="Full Screen"><i class="fas fa-expand fa-lg"></i></div>
  <div id="nd2_netmap-spinner" class="nd_netmap-running"></div>
  [% IF params.colorby == 'hgroup' OR params.colorby == 'lgroup' %]<div id="nd2_netmap-legend"></div>[% END %]
</div>
<script>
[%+ INCLUDE 'js/netmap.js' -%]
</script>

share/views/js/netmap.js  view on Meta::CPAN

// The netmap: force-graph on canvas, fed by the same payload and posting the
// same positions as the d3 renderer it replaced.

var graph;             // accessor object; the harness and device.js use window.graph
var saveMapPositions;  // device.js binds the sidebar Save button to this

$.getJSON('[% uri_for("/ajax/data/device/netmap") | none %]?[% my_query | none %]', function (mapdata) {

  // the netmap fragment reloads in place (do_search's $(target).html()), so
  // this callback runs again while the previous ForceGraph instance's rAF
  // loop is still running; without tearing it down first, its stale
  // onEngineStop fires against the new, still-settling graph through the
  // reassigned global saveMapPositions and can autosave half-settled positions
  if (window.graph && window.graph.fg && typeof window.graph.fg._destructor === 'function') {
    window.graph.fg._destructor();
  }

  // from here the bottom-right spinner carries the signal through layout to
  // settle, so the two never show at once

share/views/js/netmap.js  view on Meta::CPAN

  }

  // force-graph reheats the simulation on every drag event, and once the first
  // layout has finished alpha is already below d3AlphaMin, so the engine stops
  // again at once having run no ticks. Counting ticks is what tells a real
  // settle apart from those, and without it the save below fires once per
  // mousemove, posting the whole map each time.
  var ticksSinceStop = 0;

  // The pane can be replaced while this instance is still running: nothing
  // destroys it until the next fragment's callback reaches the teardown above,
  // and its tick and engine-stop handlers keep firing until then, against a
  // spinner that is no longer in the document.
  function setSpinnerState(state) {
    var el = document.getElementById('nd2_netmap-spinner');
    if (el && el.className !== state) { el.className = state }
  }

  var fg = ForceGraph()(container)
    .width(parseInt(jQuery('#netmap_pane').parent().css('width')))
    .height(window.innerHeight - 100)

share/views/js/netmap.js  view on Meta::CPAN

    box.el.remove();
    fg.enablePanInteraction(true).enableZoomInteraction(true);
    var r = container.querySelector('canvas').getBoundingClientRect();
    var a = fg.screen2GraphCoords(Math.min(box.x0, ev.clientX) - r.left, Math.min(box.y0, ev.clientY) - r.top);
    var b = fg.screen2GraphCoords(Math.max(box.x0, ev.clientX) - r.left, Math.max(box.y0, ev.clientY) - r.top);
    fg.graphData().nodes.forEach(function (n) {
      n.selected = (n.x >= a.x && n.x <= b.x && n.y >= a.y && n.y <= b.y);
    });
    fg.nodeRelSize(fg.nodeRelSize());
  }
  // raw listeners cannot be namespaced like jQuery's; on a fragment reload,
  // remove the previous render's pair by reference before adding this one,
  // or they accumulate on window forever
  if (window.__ndNetmapPointerHandlers) {
    window.removeEventListener('pointermove', window.__ndNetmapPointerHandlers.move);
    window.removeEventListener('pointerup', window.__ndNetmapPointerHandlers.up);
  }
  window.__ndNetmapPointerHandlers = { move: onBoxPointerMove, up: onBoxPointerUp };
  window.addEventListener('pointermove', onBoxPointerMove);
  window.addEventListener('pointerup', onBoxPointerUp);

share/views/js/netmap.js  view on Meta::CPAN

      entry.node.fx = entry.node.x = entry.x;
      entry.node.fy = entry.node.y = entry.y;
    });
  });

  // fullscreen: same API dance the old template used, on the pane so the
  // sidebar stays outside it
  document.getElementById('nd2_netmap-fullscreen').addEventListener('click', function () {
    requestFullScreen(document.getElementById('netmap_pane'));
  });
  // namespaced so a fragment reload's .off() removes only this render's
  // handler instead of every handler ever bound to these shared elements
  $(document).off('.ndnetmap').on('webkitfullscreenchange.ndnetmap mozfullscreenchange.ndnetmap fullscreenchange.ndnetmap', function () {
    resizeGraphContainer();
    $('#nd2_netmap-fullscreen i').attr('class',
      isFullScreen() ? 'fas fa-compress fa-lg' : 'fas fa-expand fa-lg');
  });

  function resizeGraphContainer() {
    setTimeout(function () {
      fg.width(parseInt(jQuery('#netmap_pane').parent().css('width')))

share/views/sidebar/device/netmap.tt  view on Meta::CPAN

                <input type="checkbox" name="showspeed" id="nd_showspeed"
                    [% ' checked="checked"' IF vars.sidebar_defaults.device_netmap.showspeed %]
                    data-toggle="toggle" data-size="small" data-width="30"
                    data-on="Show" data-off="&nbsp;" data-onstyle="success">
                  <label for="nd_showspeed">&nbsp;Link Speed</label>
              </div>
              <div class="form-check float-start">
                <input type="checkbox" name="autosave" id="nd_autosave"
                    [%# the raw URL param lands here, and TT treats the string 'off' as
                        true, so autosave=off used to render the box checked and the next
                        fragment fetch serialized it straight back to autosave=on %]
                    [% ' checked="checked"' IF vars.sidebar_defaults.device_netmap.autosave
                         AND vars.sidebar_defaults.device_netmap.autosave != 'off' %]
                    data-toggle="toggle" data-size="small" data-width="30"
                    data-on="On" data-off="Off" data-onstyle="success">
                  <label for="nd_autosave">&nbsp;Auto Save</label>
              </div>
            </div>

            <label><span id="">Positions: </span>
                  <button id="nd_netmap-save" class="nd_sidebar-btn-netmap btn btn-sm btn-info">

xt/11-port-sortkey.t  view on Meta::CPAN

    open my $fh, '<', $MIGRATION or die "$MIGRATION: $!";
    my @lines = split /\n/, join '', <$fh>;
    close $fh;
    @lines = grep { $_ && $_ !~ /^--/ && $_ !~ /^(BEGIN|BEGIN TRANSACTION|COMMIT)/m } @lines;
    return grep { /\S/ } split /;/, join '', @lines;
}

my @statements = statements_as_deployed();

is(scalar @statements, 1, 'the migration survives the upgrade reader as one statement')
    or diag("got $#{[@statements]} + 1 fragments; a semicolon inside the \$\$ body splits them");

like($statements[0], qr/CREATE OR REPLACE FUNCTION\s+port_sortkey\(raw text\)/,
    'that statement is the port_sortkey definition');

# An unindented continuation line fuses its first token to the previous line's
# last one once the newlines are gone. The result is still one statement, so the
# count above cannot catch it, and Postgres reports it only at deploy time.
unlike($statements[0], qr/\)RETURNS|textLANGUAGE|\$\$WITH/,
    'no two lines fused together for want of leading whitespace');

xt/43-response-compression.t  view on Meta::CPAN


  like $out, qr/^ENCODING=none$/m,
    'with compress_responses false the asset is served uncompressed';
};

# The static assets above cannot produce a text/xml response, and driving one
# needs a populated database, so this is a source assertion. It is here because
# the type was missing from the first version of the list and nothing caught it:
# 87 routes across 18 files are declared with Dancer::Plugin::Ajax's `ajax`
# keyword, which defaults the content type to text/xml rather than text/html,
# and those fragments are the largest responses Netdisco sends. Measured on a
# 7300 port device, one of them is 2,184,560 bytes, compressing to 18,875.
subtest 'compressibleTypes__ajaxFragments__areCoveredByTheList' => sub {
  open my $fh, '<', $psgi or BAIL_OUT("cannot read $psgi: $!");
  my $src = do { local $/; <$fh> };
  my ($list) = $src =~ m/my \@compressible = qw\((.*?)\)/s;
  ok $list, 'the compressible list is where this test expects it';
  like $list, qr{\btext/xml\b},
    'text/xml is listed, or every ajax fragment silently goes uncompressed';
};

done_testing;

xt/55-ports-search-shadow.t  view on Meta::CPAN

    });

    my $device = $schema->resultset('Device')->find($switch);
    my @results = $device->ports->with_properties->order_by_port_name->all;

    App::Netdisco::Web::Plugin::Device::Ports::_stitch_nodes(
      $schema, $device->ip, \@results, 'active_nodes', 'ips', [], []);

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $device->ip, \@results,
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'));

    my ($row) = grep { $_->port eq $port } @results;

    like $row->{nodes_search}, qr/\Q$a_mac\E/,
      'the shadow carries a MAC from the port';
    like $row->{nodes_search}, qr/\Q$an_ip\E/,
      'the shadow carries an IP from the port';
    is scalar(grep { !exists $_->{nodes_search} } @results), 0,
      'every port has a shadow, including ports with no nodes';
}

xt/55-ports-search-shadow.t  view on Meta::CPAN


    my $vlan_device = $schema->resultset('Device')->find($vlan_switch);
    my ($vlan_row) = grep { $_->port eq $vlan_port }
      $vlan_device->ports->with_properties->order_by_port_name->all;

    skip 'port lookup for the vlan fixture came back empty', 1
      if not $vlan_row;

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $vlan_device->ip, [$vlan_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0);

    like $vlan_row->{nodes_search}, qr/(?:^|\s)\Q$a_vlan\E(?:\s|$)/,
      'the shadow carries a node VLAN as its own token, which has no n_* guard';
}

# SSID comes from node_wireless, keyed on mac alone, and its lateral is joined
# only when want_ssid is true, mirroring the template's n_ssid guard.
SKIP: {
    skip "no usable netdisco database: $why", 2 if not $schema;

xt/55-ports-search-shadow.t  view on Meta::CPAN


    my $ssid_device = $schema->resultset('Device')->find($ssid_switch);
    my ($ssid_row) = grep { $_->port eq $ssid_port }
      $ssid_device->ports->with_properties->order_by_port_name->all;

    skip 'port lookup for the ssid fixture came back empty', 2
      if not $ssid_row;

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $ssid_device->ip, [$ssid_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      1, 0);
    like $ssid_row->{nodes_search}, qr/(?:^|\s)\Q$an_ssid\E(?:\s|$)/,
      'the shadow carries an SSID when n_ssid is on';

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $ssid_device->ip, [$ssid_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0);
    unlike $ssid_row->{nodes_search}, qr/(?:^|\s)\Q$an_ssid\E(?:\s|$)/,
      'the shadow does not carry the SSID when n_ssid is off, the conditional join is worth having';
}

# NetBIOS comes from node_nbt, also keyed on mac alone, joined only when
# want_netbios is true, mirroring the template's n_netbios guard.
SKIP: {
    skip "no usable netdisco database: $why", 7 if not $schema;

xt/55-ports-search-shadow.t  view on Meta::CPAN


    my $nbt_device = $schema->resultset('Device')->find($nbt_switch);
    my ($nbt_row) = grep { $_->port eq $nbt_port }
      $nbt_device->ports->with_properties->order_by_port_name->all;

    skip 'port lookup for the netbios fixture came back empty', 7
      if not $nbt_row;

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $nbt_device->ip, [$nbt_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 1);
    like $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$an_nbname\E(?:\s|$)/,
      'the shadow carries a NetBIOS name when n_netbios is on';
    like $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$a_domain\E(?:\s|$)/,
      'the shadow carries a NetBIOS domain when n_netbios is on';
    like $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$a_nbuser\E(?:\s|$)/,
      'the shadow carries a NetBIOS user when n_netbios is on';
    like $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$an_ip\E(?:\s|$)/,
      'the shadow carries a NetBIOS-reported IP when n_netbios is on';

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $nbt_device->ip, [$nbt_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0);
    unlike $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$an_nbname\E(?:\s|$)/,
      'the shadow does not carry the NetBIOS name when n_netbios is off, the conditional join is worth having';
    unlike $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$a_nbuser\E(?:\s|$)/,
      'the shadow does not carry the NetBIOS user when n_netbios is off';
    unlike $nbt_row->{nodes_search}, qr/(?:^|\s)\Q$an_ip\E(?:\s|$)/,
      'the shadow does not carry the NetBIOS-reported IP when n_netbios is off';
}

# Outside the SKIP block deliberately: inside it, CI would skip this and still
# report PASS.
require App::Netdisco::DB::Result::DevicePort;
ok !App::Netdisco::DB::Result::DevicePort->can('nodes_search'),
  'nodes_search is not a method, so Template Toolkit reaches the hash key';

# _shadow_active_fragment reads $nodes_name alone, so it needs no database.
require App::Netdisco::Web::Plugin::Device::Ports;

is App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
  'AND n.active', 'active_nodes gets the active-only fragment';
is App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes_with_age'),
  'AND n.active', 'active_nodes_with_age gets the active-only fragment too';
is App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('nodes'),
  '', 'nodes (the archived view) gets no fragment, the whole table';
is App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('nodes_with_age'),
  '', 'nodes_with_age gets no fragment either';

# _augment_neighbor_search keeps a port's own neighbor identity findable once
# c_nodes is on, the two cells being one <td> whose search text data-search
# replaces wholesale. A fake row only needs the accessors it reads.
{
    package Fake::NeighborRow;
    sub new { my ($class, %args) = @_; return bless { %args }, $class }
    sub remote_ip        { return $_[0]->{remote_ip} }
    sub remote_port      { return $_[0]->{remote_port} }
    sub remote_dns       { return $_[0]->{remote_dns} }

xt/55-ports-search-shadow.t  view on Meta::CPAN

      if not defined $v_switch;

    my $v_device = $schema->resultset('Device')->find($v_switch);
    my ($v_row) = grep { $_->port eq $v_port }
      $v_device->ports->with_properties->order_by_port_name->all;

    skip 'port lookup for the vendor fixture came back empty', 2 if not $v_row;

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $v_device->ip, [$v_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0, '', 1);
    like $v_row->{nodes_search}, qr/(?:^|\s)\Q$an_abbrev\E(?:\s|$)/,
      'the shadow carries the manufacturer abbreviation when n_vendor is on';

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $v_device->ip, [$v_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0, '', 0);
    unlike $v_row->{nodes_search}, qr/(?:^|\s)\Q$an_abbrev\E(?:\s|$)/,
      'the shadow leaves the vendor out when n_vendor is off, the join is worth having';
}

# The cell renders the MAC through mac_format_call, so on any setting but IEEE
# the string on screen is not the one Postgres stores. The shadow has to carry
# the displayed form or a user who has picked Cisco cannot filter on the MAC
# they can see. IEEE needs nothing extra: it is what mac::text already gives.
SKIP: {

xt/55-ports-search-shadow.t  view on Meta::CPAN

    my ($m_row) = grep { $_->port eq $m_port }
      $m_device->ports->with_properties->order_by_port_name->all;

    skip 'port lookup for the mac_format fixture came back empty', 3 if not $m_row;

    my $cisco = NetAddr::MAC->new(mac => $a_mac)->as_cisco;
    my $sun   = NetAddr::MAC->new(mac => $a_mac)->as_sun;

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $m_device->ip, [$m_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0, 'Cisco', 0);
    like $m_row->{nodes_search}, qr/(?:^|\s)\Q$cisco\E(?:\s|$)/,
      'the shadow carries the Cisco form when that is the chosen mac_format';
    like $m_row->{nodes_search}, qr/(?:^|\s)\Q$a_mac\E(?:\s|$)/,
      'and still carries the canonical form beside it';

    App::Netdisco::Web::Plugin::Device::Ports::_attach_search_shadow(
      $schema, $m_device->ip, [$m_row],
      App::Netdisco::Web::Plugin::Device::Ports::_shadow_active_fragment('active_nodes'),
      0, 0, 'Sun', 0);
    like $m_row->{nodes_search}, qr/(?:^|\s)\Q$sun\E(?:\s|$)/,
      'and the Sun form, whose octets lose their leading zeros';
}

# The shadow is only reachable if DataTables sources the column's filter text
# from data-search, and it decides that from tbody tr:first-child alone, for the
# whole column. Emitting the attribute on the collapsed rows only, which is what
# this cell used to do, left the column reading rendered text and every shadow
# ignored, while every Perl assertion above still passed. The fixture puts one

xt/js/netmap-autosave.test.js  view on Meta::CPAN

      body,
      /saveMapPositions/,
      'the engine stop after a drag runs no ticks, so without a save here a node the ' +
      'user moved by hand is never persisted, which the renderer this replaced did ' +
      'do'
    );
  });
});

describe('netmap autosave toggle', () => {
  // Nothing re-fetches the netmap fragment when the toggle is clicked, so a
  // value baked in at render time cannot follow it.
  test('autosaveOn__after_the_sidebar_toggle__is_read_live_not_baked_at_render', () => {
    const src = netmapJs();
    assert.doesNotMatch(
      src,
      /autosaveOn\s*=\s*\('\[%/,
      'autosave must not be baked from a template param: the toggle changes the ' +
      'checkbox without re-rendering this fragment, so a baked value cannot follow it'
    );
    assert.match(
      src,
      /getElementById\(\s*'nd_autosave'\s*\)/,
      'the autosave state must be read from the sidebar checkbox, the same way ' +
      'showips and showspeed already are in this file'
    );
  });
});

xt/js/netmap-loading.test.js  view on Meta::CPAN

// The netmap pane must say something while it waits for its first paint.
//
// netdisco.js's do_search shows "Waiting for results..." only while it fetches
// /ajax/content/device/netmap. That fragment is about 500 bytes and arrives in
// a few milliseconds, and the moment it does, $(target).html(content) replaces
// the message. The wait the user actually experiences is the one after that:
// the fragment's own $.getJSON to /ajax/data/device/netmap, then parsing the
// payload, then the first force layout. Measured against a 6481 node network
// that is 4.8MB over about a second, plus layout, with nothing on screen.
//
// So the fragment carries its own indicator, and the data callback removes it.
// The wrap also needs a height of its own: until ForceGraph sizes the
// container, the wrap's only in-flow child is empty, so it collapses to zero
// height and the absolutely positioned spinner and fullscreen control resolve
// against a box with no room, landing above the pane instead of inside it.

'use strict';

const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');

const repoRoot = path.join(__dirname, '..', '..');
const read = (...parts) => fs.readFileSync(path.join(repoRoot, ...parts), 'utf8');

const LOADING_ID = 'nd2_netmap-loading';

describe('netmap first paint indicator', () => {
  test('fragment__before_the_data_arrives__ships_a_loading_indicator', () => {
    const fragment = read('share', 'views', 'ajax', 'device', 'netmap.tt');
    assert.match(
      fragment,
      new RegExp(`id="${LOADING_ID}"`),
      `share/views/ajax/device/netmap.tt must carry an element with id "${LOADING_ID}", ` +
      'or the pane is blank for the whole data request and first layout'
    );
    assert.match(
      fragment,
      /fa-spinner fa-spin/,
      'the indicator must use the same spinner glyph as netdisco.js\'s "Waiting for results..." ' +
      'so the netmap pane does not announce a slow load differently from every other tab'
    );
  });

  test('dataCallback__once_the_graph_is_built__removes_the_loading_indicator', () => {
    const js = read('share', 'views', 'js', 'netmap.js');
    assert.match(
      js,

xt/js/netmap-loading.test.js  view on Meta::CPAN

    );
    assert.match(
      js,
      new RegExp(`${LOADING_ID}[\\s\\S]{0,200}?\\.remove\\(\\)`),
      `share/views/js/netmap.js must remove "${LOADING_ID}", or it stays on top of the map`
    );
  });

  // Re-submitting the netmap sidebar form replaces the pane while the previous
  // instance is still running, and nothing destroys it until the next
  // fragment's data callback, so its handlers fire against a spinner that has
  // left the document.
  test('spinnerHandlers__pane_replaced_mid_run__do_not_dereference_a_missing_element', () => {
    const js = read('share', 'views', 'js', 'netmap.js');
    assert.doesNotMatch(
      js,
      /getElementById\(['"]nd2_netmap-spinner['"]\)\s*\./,
      'share/views/js/netmap.js must not read a property straight off the spinner lookup: ' +
      'onEngineTick and onEngineStop both outlive the element on a reload in place, ' +
      'and an unguarded read throws a TypeError into the console on every re-submit'
    );



( run in 1.728 second using v1.01-cache-2.11-cpan-b16cb0d3907 )