Network: consolidate into one tabbed hub; subnet devices span all asset types
Replaces the two flat "Network Devices" + "View Networks" nav entries with a single "Network" entry opening a tabbed hub: Devices | Networks | VLANs (NetworkHub renders the existing device list, the subnet browse, and the VLAN list; VLANs is now reachable outside Settings). /network -> hub; /networks redirects to the Networks tab; subnet detail stays at /networks/:id. Subnet "Devices on this network" now matches ANY asset whose primary IP falls in the CIDR (PCs, printers, machines, measuring tools - not just network devices), computed on the core Communication + Asset tables; each row links to its typed detail (extension id resolved lazily/guarded per plugin). Fixes the empty list - printers and PCs carry IPs and now appear (e.g. 35 devices on 10.80.92.0/24). Also: subnet-browse search uses the standard form-control styling; dropped the redundant per-tab page header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ export default [
|
|||||||
{
|
{
|
||||||
path: 'network',
|
path: 'network',
|
||||||
name: 'network',
|
name: 'network',
|
||||||
component: () => import('../../views/network/NetworkDevicesList.vue'),
|
component: () => import('../../views/network/NetworkHub.vue'),
|
||||||
meta: { plugin: 'network' }
|
meta: { plugin: 'network' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -27,10 +27,9 @@ export default [
|
|||||||
meta: { requiresAuth: true, plugin: 'network' }
|
meta: { requiresAuth: true, plugin: 'network' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Legacy path -> the Networks tab of the hub.
|
||||||
path: 'networks',
|
path: 'networks',
|
||||||
name: 'networks',
|
redirect: { path: '/network', query: { tab: 'networks' } }
|
||||||
component: () => import('../../views/network/SubnetsBrowse.vue'),
|
|
||||||
meta: { plugin: 'network' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'networks/:id',
|
path: 'networks/:id',
|
||||||
|
|||||||
@@ -156,8 +156,7 @@ const defaultNav = [
|
|||||||
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
||||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
||||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
||||||
{ name: 'Network Devices', icon: 'network-wired', route: '/network', position: 18 },
|
{ name: 'Network', 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: 'Printers', icon: 'printer', route: '/printers', position: 20 },
|
||||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
|
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
|
||||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
|
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
|
||||||
|
|||||||
72
frontend/src/views/network/NetworkHub.vue
Normal file
72
frontend/src/views/network/NetworkHub.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Network</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hub-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="hub-tab"
|
||||||
|
:class="{ active: active === tab.key }"
|
||||||
|
@click="setTab(tab.key)"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
<component :is="current" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import NetworkDevicesList from './NetworkDevicesList.vue'
|
||||||
|
import SubnetsBrowse from './SubnetsBrowse.vue'
|
||||||
|
import VLANsList from '../settings/VLANsList.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'devices', label: 'Devices', comp: NetworkDevicesList },
|
||||||
|
{ key: 'networks', label: 'Networks', comp: SubnetsBrowse },
|
||||||
|
{ key: 'vlans', label: 'VLANs', comp: VLANsList },
|
||||||
|
]
|
||||||
|
|
||||||
|
const active = ref(tabs.some(t => t.key === route.query.tab) ? route.query.tab : 'devices')
|
||||||
|
const current = computed(() => (tabs.find(t => t.key === active.value) || tabs[0]).comp)
|
||||||
|
|
||||||
|
function setTab(key) {
|
||||||
|
active.value = key
|
||||||
|
router.replace({ query: { ...route.query, tab: key } })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.query.tab, (value) => {
|
||||||
|
if (value && value !== active.value && tabs.some(t => t.key === value)) {
|
||||||
|
active.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hub-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.hub-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.hub-tab:hover { color: var(--text); }
|
||||||
|
.hub-tab.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -37,14 +37,13 @@
|
|||||||
<tr><th>Device</th><th>IP</th><th>Type</th></tr>
|
<tr><th>Device</th><th>IP</th><th>Type</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="dev in devices" :key="dev.networkdeviceid">
|
<tr v-for="dev in devices" :key="dev.assetid">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/network/${dev.networkdeviceid}`">
|
<router-link v-if="dev.url" :to="dev.url">{{ dev.name || dev.assetnumber }}</router-link>
|
||||||
{{ dev.name || dev.hostname || dev.assetnumber }}
|
<span v-else>{{ dev.name || dev.assetnumber }}</span>
|
||||||
</router-link>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="mono">{{ dev.ipaddress }}</td>
|
<td class="mono">{{ dev.ipaddress }}</td>
|
||||||
<td>{{ dev.networkdevicetypename || '-' }}</td>
|
<td>{{ typeLabel(dev.assettype) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -67,6 +66,14 @@ const loading = ref(true)
|
|||||||
|
|
||||||
const devices = computed(() => subnet.value?.devices || [])
|
const devices = computed(() => subnet.value?.devices || [])
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
computer: 'PC', network_device: 'Network Device', printer: 'Printer',
|
||||||
|
machine: 'Machine', measuring_tool: 'Measuring Tool',
|
||||||
|
}
|
||||||
|
function typeLabel(type) {
|
||||||
|
return TYPE_LABELS[type] || type || '-'
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
|
||||||
<h1>Networks</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input v-model="search" type="text" placeholder="Search networks..." class="search-input" />
|
<input v-model="search" type="text" placeholder="Search networks..." class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -831,31 +831,34 @@ def get_subnet(subnet_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def _devices_in_subnet(cidr):
|
def _devices_in_subnet(cidr):
|
||||||
"""Network devices (with their primary IP) whose IP is inside cidr. Matching
|
"""ANY asset (PC, printer, network device, ...) whose primary IP falls in
|
||||||
is done in Python because a device's IP lives in a Communication row, not a
|
cidr. A subnet is cross-type, so this matches on the core Communication +
|
||||||
column, so it cannot be a simple SQL join."""
|
Asset tables, not just network devices. Matching is in Python because the IP
|
||||||
|
lives in a Communication row, not a column."""
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
from shopdb.api import Asset, AssetType, Communication
|
||||||
try:
|
try:
|
||||||
net = ipaddress.ip_network(cidr, strict=False)
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return []
|
return []
|
||||||
|
rows = (Communication.query
|
||||||
|
.join(Asset, Communication.assetid == Asset.assetid)
|
||||||
|
.join(AssetType, Asset.assettypeid == AssetType.assettypeid)
|
||||||
|
.filter(Communication.isprimary == True,
|
||||||
|
Communication.ipaddress.isnot(None))
|
||||||
|
.with_entities(Asset.assetid, Asset.assetnumber, Asset.name,
|
||||||
|
AssetType.assettype, Communication.ipaddress).all())
|
||||||
result = []
|
result = []
|
||||||
for netdev in NetworkDevice.query.all():
|
for assetid, assetnumber, name, assettype, ip in rows:
|
||||||
ip = _primary_ip(netdev.assetid)
|
ip = (ip or '').strip()
|
||||||
if not ip:
|
if not ip:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
if ipaddress.ip_address(ip.strip()) in net:
|
if ipaddress.ip_address(ip) in net:
|
||||||
asset = netdev.asset
|
|
||||||
result.append({
|
result.append({
|
||||||
'networkdeviceid': netdev.networkdeviceid,
|
'assetid': assetid, 'assetnumber': assetnumber, 'name': name,
|
||||||
'assetid': netdev.assetid,
|
'assettype': assettype, 'ipaddress': ip,
|
||||||
'assetnumber': asset.assetnumber if asset else None,
|
'url': _asset_detail_url(assettype, assetid),
|
||||||
'name': asset.name if asset else None,
|
|
||||||
'hostname': netdev.hostname,
|
|
||||||
'ipaddress': ip,
|
|
||||||
'networkdevicetypename': (netdev.networkdevicetype.networkdevicetype
|
|
||||||
if netdev.networkdevicetype else None),
|
|
||||||
})
|
})
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
@@ -863,6 +866,27 @@ def _devices_in_subnet(cidr):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_detail_url(assettype, assetid):
|
||||||
|
"""Best-effort front-end detail path for an asset by type. Resolves the
|
||||||
|
per-type extension id lazily/guarded (subnet listings span plugins); returns
|
||||||
|
None when the plugin is absent so the frontend just shows the row."""
|
||||||
|
try:
|
||||||
|
if assettype == 'network_device':
|
||||||
|
row = NetworkDevice.query.filter_by(assetid=assetid).first()
|
||||||
|
return f'/network/{row.networkdeviceid}' if row else None
|
||||||
|
if assettype == 'printer':
|
||||||
|
from plugins.printers.models import Printer
|
||||||
|
row = Printer.query.filter_by(assetid=assetid).first()
|
||||||
|
return f'/printers/{row.printerid}' if row else None
|
||||||
|
if assettype == 'computer':
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
row = Computer.query.filter_by(assetid=assetid).first()
|
||||||
|
return f'/pcs/{row.computerid}' if row else None
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@network_bp.route('/subnets', methods=['POST'])
|
@network_bp.route('/subnets', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('network.create')
|
@require_permission('network.create')
|
||||||
|
|||||||
@@ -208,17 +208,11 @@ class NetworkPlugin(BasePlugin):
|
|||||||
"""Return navigation menu items."""
|
"""Return navigation menu items."""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'name': 'Network Devices',
|
'name': 'Network',
|
||||||
'icon': 'network-wired',
|
'icon': 'network-wired',
|
||||||
'route': '/network',
|
'route': '/network',
|
||||||
'position': 18,
|
'position': 18,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'name': 'View Networks',
|
|
||||||
'icon': 'globe',
|
|
||||||
'route': '/networks',
|
|
||||||
'position': 19,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_permissions(self) -> List:
|
def get_permissions(self) -> List:
|
||||||
|
|||||||
Reference in New Issue
Block a user