Add "View Networks": front-facing subnet browse + detail with attached devices
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:
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
81
frontend/src/views/network/SubnetDetail.vue
Normal file
81
frontend/src/views/network/SubnetDetail.vue
Normal 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>
|
||||
79
frontend/src/views/network/SubnetsBrowse.vue
Normal file
79
frontend/src/views/network/SubnetsBrowse.vue
Normal 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>
|
||||
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user