Add "View Networks": front-facing subnet browse + detail with attached devices
Some checks failed
CI / backend (push) Successful in 1m38s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s

Subnets previously lived only under Settings, easily confused with the Network
Devices asset list. Add a front-facing browse + detail:

- Nav: rename "Network" -> "Network Devices"; add "View Networks" (subnets), both
  under Assets (network plugin get_navigation_items; frontend fallback matched).
- /networks (SubnetsBrowse): all subnets with name / CIDR / type / VLAN / notes,
  searchable, row-click to detail.
- /networks/:id (SubnetDetail): the subnet (CIDR, network address, type, VLAN,
  gateway, notes) plus the network devices whose primary IP falls inside its
  CIDR - get_subnet now computes that membership in Python (a device's IP lives
  in a Communication row, so it is not a plain SQL join).

Verified on the import DB: 37 networks list (real WJ subnets), detail renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 14:06:47 -04:00
parent 5f51aa2383
commit dd541fba0a
6 changed files with 219 additions and 4 deletions

View File

@@ -26,6 +26,18 @@ export default [
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true, plugin: 'network' }
},
{
path: 'networks',
name: 'networks',
component: () => import('../../views/network/SubnetsBrowse.vue'),
meta: { plugin: 'network' }
},
{
path: 'networks/:id',
name: 'network-subnet-detail',
component: () => import('../../views/network/SubnetDetail.vue'),
meta: { plugin: 'network' }
},
// Network-specific settings
{
path: 'settings/vlans',

View File

@@ -156,7 +156,8 @@ const defaultNav = [
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
{ name: 'Network Devices', icon: 'network-wired', route: '/network', position: 18 },
{ name: 'View Networks', icon: 'globe', route: '/networks', position: 19 },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },

View File

@@ -0,0 +1,81 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="!subnet" class="empty-state"><p>Network not found.</p></div>
<template v-else>
<div class="page-header">
<h1>{{ subnet.name || subnet.cidr }}</h1>
<router-link to="/networks" class="btn btn-secondary">Back to Networks</router-link>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Network</h3>
<div class="info-list">
<div class="info-row"><span class="info-label">CIDR</span><span class="info-value mono">{{ subnet.cidr }}</span></div>
<div class="info-row"><span class="info-label">Network Address</span><span class="info-value mono">{{ subnet.networkaddress || '-' }}</span></div>
<div class="info-row"><span class="info-label">Type</span><span class="info-value">{{ subnet.subnettype || '-' }}</span></div>
<div class="info-row"><span class="info-label">VLAN</span><span class="info-value">{{ subnet.vlannumber || subnet.vlanid || '-' }}</span></div>
<div class="info-row" v-if="subnet.gatewayip"><span class="info-label">Gateway</span><span class="info-value mono">{{ subnet.gatewayip }}</span></div>
</div>
</div>
<div class="section-card" v-if="subnet.description">
<h3 class="section-title">Notes</h3>
<div class="notes-content">{{ subnet.description }}</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Devices on this network ({{ devices.length }})</h3>
<div v-if="devices.length === 0" class="empty-state"><p>No devices found in this range.</p></div>
<div v-else class="table-container">
<table>
<thead>
<tr><th>Device</th><th>IP</th><th>Type</th></tr>
</thead>
<tbody>
<tr v-for="dev in devices" :key="dev.networkdeviceid">
<td>
<router-link :to="`/network/${dev.networkdeviceid}`">
{{ dev.name || dev.hostname || dev.assetnumber }}
</router-link>
</td>
<td class="mono">{{ dev.ipaddress }}</td>
<td>{{ dev.networkdevicetypename || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi } from '@/api'
const route = useRoute()
const subnet = ref(null)
const loading = ref(true)
const devices = computed(() => subnet.value?.devices || [])
async function load() {
loading.value = true
try {
const response = await networkApi.subnets.get(route.params.id)
subnet.value = response.data?.data || response.data
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div>
<div class="page-header">
<h1>Networks</h1>
</div>
<div class="filters">
<input v-model="search" type="text" placeholder="Search networks..." class="search-input" />
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="filtered.length === 0" class="empty-state"><p>No networks found.</p></div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>CIDR</th>
<th>Type</th>
<th>VLAN</th>
<th>Notes</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="subnet in filtered" :key="subnet.subnetid" class="clickable-row"
@click="$router.push(`/networks/${subnet.subnetid}`)">
<td>{{ subnet.name || '-' }}</td>
<td class="mono">{{ subnet.cidr }}</td>
<td>{{ subnet.subnettype || '-' }}</td>
<td>{{ subnet.vlannumber || subnet.vlanid || '-' }}</td>
<td class="cell-truncate" :title="subnet.description">{{ subnet.description || '-' }}</td>
<td class="actions">
<router-link :to="`/networks/${subnet.subnetid}`" class="btn btn-sm btn-secondary" @click.stop>View</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { networkApi } from '@/api'
const subnets = ref([])
const loading = ref(true)
const search = ref('')
const filtered = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return subnets.value
return subnets.value.filter(s =>
(s.name || '').toLowerCase().includes(term) ||
(s.cidr || '').toLowerCase().includes(term) ||
(s.subnettype || '').toLowerCase().includes(term) ||
(s.description || '').toLowerCase().includes(term))
})
async function load() {
loading.value = true
try {
const response = await networkApi.subnets.list({ per_page: 1000 })
subnets.value = response.data?.data || response.data || []
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.clickable-row { cursor: pointer; }
.clickable-row:hover { background: var(--bg); }
</style>

View File

@@ -814,7 +814,8 @@ def list_subnets():
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
@jwt_required(optional=True)
def get_subnet(subnet_id: int):
"""Get a single subnet."""
"""Get a single subnet plus the network devices whose primary IP falls in
its CIDR range."""
subnet = db.session.get(Subnet, subnet_id)
if not subnet:
@@ -824,7 +825,42 @@ def get_subnet(subnet_id: int):
http_code=404
)
return success_response(subnet.to_dict())
data = subnet.to_dict()
data['devices'] = _devices_in_subnet(subnet.cidr)
return success_response(data)
def _devices_in_subnet(cidr):
"""Network devices (with their primary IP) whose IP is inside cidr. Matching
is done in Python because a device's IP lives in a Communication row, not a
column, so it cannot be a simple SQL join."""
import ipaddress
try:
net = ipaddress.ip_network(cidr, strict=False)
except (ValueError, TypeError):
return []
result = []
for netdev in NetworkDevice.query.all():
ip = _primary_ip(netdev.assetid)
if not ip:
continue
try:
if ipaddress.ip_address(ip.strip()) in net:
asset = netdev.asset
result.append({
'networkdeviceid': netdev.networkdeviceid,
'assetid': netdev.assetid,
'assetnumber': asset.assetnumber if asset else None,
'name': asset.name if asset else None,
'hostname': netdev.hostname,
'ipaddress': ip,
'networkdevicetypename': (netdev.networkdevicetype.networkdevicetype
if netdev.networkdevicetype else None),
})
except (ValueError, TypeError):
continue
result.sort(key=lambda d: d['ipaddress'])
return result
@network_bp.route('/subnets', methods=['POST'])

View File

@@ -208,11 +208,17 @@ class NetworkPlugin(BasePlugin):
"""Return navigation menu items."""
return [
{
'name': 'Network',
'name': 'Network Devices',
'icon': 'network-wired',
'route': '/network',
'position': 18,
},
{
'name': 'View Networks',
'icon': 'globe',
'route': '/networks',
'position': 19,
},
]
def get_permissions(self) -> List: