List pages kept the current page in local state, so clicking into an asset and hitting Back remounted the list at page 1. A shared useListQuery composable now mirrors the page (and search term) into the URL query via router.replace across all 18 list pages, so Back restores the page you were on and lists are deep-linkable. Page 1 with no search stays a bare path; changing a filter resets to page 1; unrelated query keys are preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
378 lines
9.9 KiB
Vue
378 lines
9.9 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>VLANs</h2>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
|
|
</div>
|
|
|
|
<!-- Search -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search VLANs..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
|
|
<option value="">All Types</option>
|
|
<option value="data">Data</option>
|
|
<option value="voice">Voice</option>
|
|
<option value="management">Management</option>
|
|
<option value="guest">Guest</option>
|
|
<option value="iot">IoT</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>VLAN #</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Description</th>
|
|
<th>Subnets</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="vlan in vlans" :key="vlan.vlanid">
|
|
<td class="mono">{{ vlan.vlannumber }}</td>
|
|
<td>{{ vlan.name }}</td>
|
|
<td>
|
|
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
|
|
{{ vlan.vlantype }}
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
|
|
<td>
|
|
<router-link
|
|
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
|
|
class="subnet-link"
|
|
>
|
|
View Subnets
|
|
</router-link>
|
|
</td>
|
|
<td class="actions">
|
|
<button
|
|
class="btn btn-secondary btn-sm"
|
|
@click="openModal(vlan)"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
class="btn btn-danger btn-sm"
|
|
@click="confirmDelete(vlan)"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="vlans.length === 0">
|
|
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
|
No VLANs found
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Add/Edit Modal -->
|
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
|
|
</div>
|
|
<form @submit.prevent="saveVLAN">
|
|
<div class="modal-body">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="vlannumber">VLAN Number *</label>
|
|
<input
|
|
id="vlannumber"
|
|
v-model.number="form.vlannumber"
|
|
type="number"
|
|
class="form-control"
|
|
min="1"
|
|
max="4094"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="name">Name *</label>
|
|
<input
|
|
id="name"
|
|
v-model="form.name"
|
|
type="text"
|
|
class="form-control"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="vlantype">VLAN Type</label>
|
|
<select id="vlantype" v-model="form.vlantype" class="form-control">
|
|
<option value="">Select Type</option>
|
|
<option value="data">Data</option>
|
|
<option value="voice">Voice</option>
|
|
<option value="management">Management</option>
|
|
<option value="guest">Guest</option>
|
|
<option value="iot">IoT</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="description">Description</label>
|
|
<textarea
|
|
id="description"
|
|
v-model="form.description"
|
|
class="form-control"
|
|
rows="3"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Delete VLAN</h3>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
|
|
<p style="color: var(--text-light); font-size: 0.875rem;">
|
|
VLANs with associated subnets cannot be deleted.
|
|
</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
|
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { networkApi } from '../../api'
|
|
import PaginationBar from '../../components/PaginationBar.vue'
|
|
import { useToast } from '../../composables/toast'
|
|
import { apiError } from '../../utils/apiError'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
const toast = useToast()
|
|
|
|
const vlans = ref([])
|
|
const loading = ref(true)
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVLANs })
|
|
const typeFilter = ref('')
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
const showModal = ref(false)
|
|
const editingVLAN = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
|
|
const showDeleteModal = ref(false)
|
|
const vlanToDelete = ref(null)
|
|
|
|
const form = ref({
|
|
vlannumber: null,
|
|
name: '',
|
|
vlantype: '',
|
|
description: ''
|
|
})
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadVLANs()
|
|
})
|
|
|
|
async function loadVLANs() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value
|
|
}
|
|
if (search.value) params.search = search.value
|
|
if (typeFilter.value) params.type = typeFilter.value
|
|
|
|
const response = await networkApi.vlans.list(params)
|
|
vlans.value = response.data.data || []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (err) {
|
|
console.error('Error loading VLANs:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadVLANs()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadVLANs()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadVLANs()
|
|
}
|
|
|
|
function getTypeClass(type) {
|
|
switch (type) {
|
|
case 'data': return 'badge-info'
|
|
case 'voice': return 'badge-success'
|
|
case 'management': return 'badge-warning'
|
|
case 'guest': return 'badge-secondary'
|
|
case 'iot': return 'badge-primary'
|
|
default: return 'badge-info'
|
|
}
|
|
}
|
|
|
|
function openModal(vlan = null) {
|
|
editingVLAN.value = vlan
|
|
if (vlan) {
|
|
form.value = {
|
|
vlannumber: vlan.vlannumber,
|
|
name: vlan.name || '',
|
|
vlantype: vlan.vlantype || '',
|
|
description: vlan.description || ''
|
|
}
|
|
} else {
|
|
form.value = {
|
|
vlannumber: null,
|
|
name: '',
|
|
vlantype: '',
|
|
description: ''
|
|
}
|
|
}
|
|
error.value = ''
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editingVLAN.value = null
|
|
}
|
|
|
|
async function saveVLAN() {
|
|
error.value = ''
|
|
saving.value = true
|
|
|
|
try {
|
|
if (editingVLAN.value) {
|
|
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
|
|
} else {
|
|
await networkApi.vlans.create(form.value)
|
|
}
|
|
closeModal()
|
|
loadVLANs()
|
|
} catch (err) {
|
|
console.error('Error saving VLAN:', err)
|
|
error.value = apiError(err, 'Failed to save VLAN')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function confirmDelete(vlan) {
|
|
vlanToDelete.value = vlan
|
|
showDeleteModal.value = true
|
|
}
|
|
|
|
async function deleteVLAN() {
|
|
try {
|
|
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
|
|
showDeleteModal.value = false
|
|
vlanToDelete.value = null
|
|
loadVLANs()
|
|
} catch (err) {
|
|
console.error('Error deleting VLAN:', err)
|
|
toast.error(apiError(err, 'Failed to delete VLAN'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mono {
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.filters select {
|
|
width: auto;
|
|
min-width: 150px;
|
|
}
|
|
|
|
.subnet-link {
|
|
color: var(--link);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.subnet-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.badge-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.badge-secondary {
|
|
background: var(--secondary);
|
|
color: var(--text);
|
|
}
|
|
</style>
|