diff --git a/Netdisco/Changes b/Netdisco/Changes
index 09a064cc..d52a9fc3 100644
--- a/Netdisco/Changes
+++ b/Netdisco/Changes
@@ -3,6 +3,7 @@
[NEW FEATURES]
* Support for Link Aggregation (port-channel, etherchannel, "trunking", etc)
+ * [#68] Devices orphaned by missing topology info report
[ENHANCEMENTS]
diff --git a/Netdisco/lib/App/Netdisco/DB/Result/Virtual/OrphanedDevices.pm b/Netdisco/lib/App/Netdisco/DB/Result/Virtual/OrphanedDevices.pm
new file mode 100644
index 00000000..d77bf90f
--- /dev/null
+++ b/Netdisco/lib/App/Netdisco/DB/Result/Virtual/OrphanedDevices.pm
@@ -0,0 +1,32 @@
+package App::Netdisco::DB::Result::Virtual::OrphanedDevices;
+
+use strict;
+use warnings;
+
+use utf8;
+use base 'App::Netdisco::DB::Result::Device';
+
+__PACKAGE__->load_components('Helper::Row::SubClass');
+__PACKAGE__->subclass;
+
+__PACKAGE__->table_class('DBIx::Class::ResultSource::View');
+__PACKAGE__->table('orphaned_devices');
+__PACKAGE__->result_source_instance->is_virtual(1);
+__PACKAGE__->result_source_instance->view_definition(<<'ENDSQL');
+SELECT *
+FROM device
+WHERE ip NOT IN
+ ( SELECT DISTINCT dp.ip AS ip
+ FROM
+ (SELECT device_port.ip,
+ device_port.remote_ip
+ FROM device_port
+ WHERE device_port.remote_port IS NOT NULL
+ GROUP BY device_port.ip,
+ device_port.remote_ip
+ ORDER BY device_port.ip) dp
+ LEFT JOIN device_ip di ON dp.remote_ip = di.alias
+ WHERE di.ip IS NOT NULL)
+ENDSQL
+
+1;
diff --git a/Netdisco/lib/App/Netdisco/DB/Result/Virtual/UnDirEdgesAgg.pm b/Netdisco/lib/App/Netdisco/DB/Result/Virtual/UnDirEdgesAgg.pm
new file mode 100644
index 00000000..8a304758
--- /dev/null
+++ b/Netdisco/lib/App/Netdisco/DB/Result/Virtual/UnDirEdgesAgg.pm
@@ -0,0 +1,54 @@
+package App::Netdisco::DB::Result::Virtual::UnDirEdgesAgg;
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+
+__PACKAGE__->table_class('DBIx::Class::ResultSource::View');
+
+__PACKAGE__->table('undir_edges_agg');
+__PACKAGE__->result_source_instance->is_virtual(1);
+__PACKAGE__->result_source_instance->view_definition(<<'ENDSQL');
+ SELECT left_ip,
+ array_agg(right_ip) AS links
+ FROM
+ ( SELECT dp.ip AS left_ip,
+ di.ip AS right_ip
+ FROM
+ (SELECT device_port.ip,
+ device_port.remote_ip
+ FROM device_port
+ WHERE device_port.remote_port IS NOT NULL
+ GROUP BY device_port.ip,
+ device_port.remote_ip) dp
+ LEFT JOIN device_ip di ON dp.remote_ip = di.alias
+ WHERE di.ip IS NOT NULL
+ UNION SELECT di.ip AS left_ip,
+ dp.ip AS right_ip
+ FROM
+ (SELECT device_port.ip,
+ device_port.remote_ip
+ FROM device_port
+ WHERE device_port.remote_port IS NOT NULL
+ GROUP BY device_port.ip,
+ device_port.remote_ip) dp
+ LEFT JOIN device_ip di ON dp.remote_ip = di.alias
+ WHERE di.ip IS NOT NULL ) AS foo
+ GROUP BY left_ip
+ ORDER BY left_ip
+ENDSQL
+
+__PACKAGE__->add_columns(
+ 'left_ip' => {
+ data_type => 'inet',
+ },
+ 'links' => {
+ data_type => 'inet[]',
+ }
+);
+
+__PACKAGE__->belongs_to('device', 'App::Netdisco::DB::Result::Device',
+ { 'foreign.ip' => 'self.left_ip' });
+
+1;
diff --git a/Netdisco/lib/App/Netdisco/Web/Plugin/AdminTask/OrphanedDevices.pm b/Netdisco/lib/App/Netdisco/Web/Plugin/AdminTask/OrphanedDevices.pm
new file mode 100644
index 00000000..949e7a38
--- /dev/null
+++ b/Netdisco/lib/App/Netdisco/Web/Plugin/AdminTask/OrphanedDevices.pm
@@ -0,0 +1,82 @@
+package App::Netdisco::Web::Plugin::AdminTask::OrphanedDevices;
+
+use strict;
+use warnings;
+use Dancer ':syntax';
+use Dancer::Plugin::DBIC;
+use Dancer::Plugin::Auth::Extensible;
+
+use App::Netdisco::Web::Plugin;
+
+register_admin_task(
+ { tag => 'orphaned',
+ label => 'Orphaned Devices / Networks',
+ provides_csv => 1,
+ }
+);
+
+get '/ajax/content/admin/orphaned' => require_role admin => sub {
+
+ my @tree = schema('netdisco')->resultset('Virtual::UnDirEdgesAgg')
+ ->search( undef, { prefetch => 'device' } )->hri->all;
+
+ my @orphans
+ = schema('netdisco')->resultset('Virtual::OrphanedDevices')->search()
+ ->order_by('ip')->hri->all;
+
+ return unless ( scalar @tree || scalar @orphans );
+
+ my @ordered;
+
+ if ( scalar @tree ) {
+ my %tree = map { $_->{'left_ip'} => $_ } @tree;
+
+ my $current_graph = 0;
+ my %visited = ();
+ my @to_visit = ();
+ foreach my $node ( keys %tree ) {
+ next if exists $visited{$node};
+
+ $current_graph++;
+ @to_visit = ($node);
+ while (@to_visit) {
+ my $node_to_visit = shift @to_visit;
+
+ $visited{$node_to_visit} = $current_graph;
+
+ push @to_visit,
+ grep { !exists $visited{$_} }
+ @{ $tree{$node_to_visit}->{'links'} };
+ }
+ }
+
+ my @graphs = ();
+ foreach my $key ( keys %visited ) {
+ push @{ $graphs[ $visited{$key} - 1 ] }, $tree{$key}->{'device'};
+ }
+
+ @ordered = sort { scalar @{$b} <=> scalar @{$a} } @graphs;
+ }
+
+ return if ( scalar @ordered < 2 && !scalar @tree );
+
+ if ( request->is_ajax ) {
+ template 'ajax/admintask/orphaned.tt',
+ {
+ orphans => \@orphans,
+ graphs => \@ordered,
+ },
+ { layout => undef };
+ }
+ else {
+ header( 'Content-Type' => 'text/comma-separated-values' );
+ template 'ajax/admintask/orphaned_csv.tt',
+ {
+ orphans => \@orphans,
+ graphs => \@ordered,
+ },
+ { layout => undef };
+ }
+};
+
+1;
diff --git a/Netdisco/share/config.yml b/Netdisco/share/config.yml
index 3f1f8dcf..877dd1d0 100644
--- a/Netdisco/share/config.yml
+++ b/Netdisco/share/config.yml
@@ -58,6 +58,7 @@ web_plugins:
- AdminTask::PseudoDevice
- AdminTask::SlowDevices
- AdminTask::UndiscoveredNeighbors
+ - AdminTask::OrphanedDevices
- AdminTask::UserLog
- AdminTask::Users
- Search::Device
diff --git a/Netdisco/share/views/ajax/admintask/orphaned.tt b/Netdisco/share/views/ajax/admintask/orphaned.tt
new file mode 100644
index 00000000..b87ebeec
--- /dev/null
+++ b/Netdisco/share/views/ajax/admintask/orphaned.tt
@@ -0,0 +1,105 @@
+[% IF orphans.size > 0 %]
+
+[% END %]
+[%# The largest graph is considered the main network, all others are
+ considered orphaned, so we need two to generate div %]
+[% IF graphs.size > 1 %]
+
+[% count = 0 %]
+[% FOREACH network IN graphs %]
+ [% count = count + 1 %]
+ [%# The largest is not an orphan, so skip %]
+ [% NEXT IF count == 1 %]
+
+[% END %]
+
+[% END %]
+
diff --git a/Netdisco/share/views/ajax/admintask/orphaned_csv.tt b/Netdisco/share/views/ajax/admintask/orphaned_csv.tt
new file mode 100644
index 00000000..a87372f0
--- /dev/null
+++ b/Netdisco/share/views/ajax/admintask/orphaned_csv.tt
@@ -0,0 +1,51 @@
+[% USE CSV -%]
+[% CSV.dump(['Orphaned Devices']) %]
+
+[% CSV.dump([ 'Device' 'IP' 'Device Location' 'Contact' ' Vendor'
+ 'Model' ]) %]
+
+[% FOREACH row IN orphans %]
+ [% mydlist = [] %]
+ [% mydevice = row.dns || row.name %]
+ [% mydlist.push(mydevice) %]
+ [% mydlist.push(row.ip) %]
+ [% mydlist.push(row.location) %]
+ [% mydlist.push(row.contact) %]
+ [% mydlist.push(row.vendor) %]
+ [% mydlist.push(row.model) %]
+ [% CSV.dump(mydlist) %]
+
+[% END %]
+
+[% IF graphs.size > 1 %]
+[% count = 0 %]
+[% FOREACH network IN graphs %]
+ [% count = count + 1 %]
+ [%# The largest is not an orphan, so skip %]
+ [% NEXT IF count == 1 %]
+
+ [% CSV.dump([' ']) %]
+
+ [% ntwk_header = [] %]
+ [% ntwk_header.push('Orphaned Network') %]
+ [% ntwk_header.push(count - 1) %]
+
+ [% CSV.dump(ntwk_header) %]
+
+ [% CSV.dump([ 'Device' 'IP' 'Device Location' 'Contact' ' Vendor'
+ 'Model' ]) %]
+
+ [% FOREACH row IN network %]
+ [% mydlist = [] %]
+ [% mydevice = row.dns || row.name %]
+ [% mydlist.push(mydevice) %]
+ [% mydlist.push(row.ip) %]
+ [% mydlist.push(row.location) %]
+ [% mydlist.push(row.contact) %]
+ [% mydlist.push(row.vendor) %]
+ [% mydlist.push(row.model) %]
+ [% CSV.dump(mydlist) %]
+
+ [% END %]
+[% END %]
+[% END %]
\ No newline at end of file