feat: add DeviceFormModal and IpAddressing components
- DeviceFormModal: device create/edit form with interfaces, virt_type, url, type selector — no OS field (auto-detected from description) - IpAddressing: IP address view grouped by VLAN with inline OS icon detected via detectOs() from name/description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<h2>{{ device ? t('editDevice') : t('newDevice') }}</h2>
|
||||
<form @submit.prevent="save">
|
||||
<div class="fields-row">
|
||||
<div class="field">
|
||||
<label>{{ t('fieldName') }}</label>
|
||||
<input v-model="form.name" type="text" required :placeholder="t('deviceNamePlaceholder')" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('fieldType') }}</label>
|
||||
<select v-model="form.type">
|
||||
<option v-for="dtype in deviceTypes" :key="dtype.value" :value="dtype.value">{{ dtype.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('fieldDescription') }}</label>
|
||||
<textarea v-model="form.description" rows="2" :placeholder="t('descPlaceholder')"></textarea>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="form.is_gateway" />
|
||||
<span>{{ t('isGateway') }}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="form.is_livebox" />
|
||||
<span>{{ t('isLivebox') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!['desktop','laptop'].includes(form.type)" class="field">
|
||||
<label>{{ t('accessUrl') }}</label>
|
||||
<input v-model="form.url" type="url" placeholder="https://192.168.1.1" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('runtimeType') }}</label>
|
||||
<select v-model="form.virt_type">
|
||||
<option :value="null">{{ t('notSpecified') }}</option>
|
||||
<option value="baremetal">{{ t('baremetal') }}</option>
|
||||
<option value="lxc">{{ t('lxcContainer') }}</option>
|
||||
<option value="qemu">{{ t('vmQemu') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="interfaces-section">
|
||||
<div class="interfaces-header">
|
||||
<strong>{{ t('networkInterfaces') }}</strong>
|
||||
<button type="button" class="btn-add-iface" @click="addInterface">{{ t('addInterface') }}</button>
|
||||
</div>
|
||||
<div v-if="form.interfaces.length === 0" class="iface-empty">
|
||||
{{ t('noInterface') }}
|
||||
</div>
|
||||
<div v-for="(iface, idx) in form.interfaces" :key="idx" class="iface-row">
|
||||
<input v-model="iface.name" type="text" placeholder="eth0" class="iface-input" />
|
||||
<input v-model="iface.ip_address" type="text" placeholder="IP" class="iface-input wide" />
|
||||
<select v-model="iface.vlan_id" class="iface-select">
|
||||
<option :value="null">— VLAN</option>
|
||||
<option v-for="v in vlans" :key="v.id" :value="v.id">
|
||||
{{ v.vlan_id != null ? 'VLAN ' + v.vlan_id : 'LAN' }} — {{ v.name }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="iface-upstream">
|
||||
<input type="checkbox" v-model="iface.is_upstream" />
|
||||
<span>{{ t('wanLabel') }}</span>
|
||||
</label>
|
||||
<button type="button" class="btn-rm-iface" @click="removeInterface(idx)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" @click="$emit('close')">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ device ? t('save') : t('create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { devicesApi } from '../api.js'
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, default: null },
|
||||
vlans: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const deviceTypes = computed(() => [
|
||||
{ value: 'server', label: t('typeServer') },
|
||||
{ value: 'switch', label: t('typeSwitch') },
|
||||
{ value: 'router', label: t('typeRouter') },
|
||||
{ value: 'nas', label: t('typeNas') },
|
||||
{ value: 'gateway', label: t('typeGateway') },
|
||||
{ value: 'livebox', label: t('typeLivebox') },
|
||||
{ value: 'camera', label: t('typeCamera') },
|
||||
{ value: 'temperature', label: t('typeTemperature') },
|
||||
{ value: 'sensor', label: t('typeSensor') },
|
||||
{ value: 'hub', label: t('typeHub') },
|
||||
{ value: 'smart_plug', label: t('typeSmartPlug') },
|
||||
{ value: 'alarm', label: t('typeAlarm') },
|
||||
{ value: 'light', label: t('typeLight') },
|
||||
{ value: 'doorbell', label: t('typeDoorbell') },
|
||||
{ value: 'access_point', label: t('typeAccessPoint') },
|
||||
{ value: 'desktop', label: t('typeDesktop') },
|
||||
{ value: 'laptop', label: t('typeLaptop') },
|
||||
{ value: 'smart_tv', label: t('typeSmartTv') },
|
||||
{ value: 'printer', label: t('typePrinter') },
|
||||
{ value: 'smartphone', label: t('typeSmartphone') },
|
||||
{ value: 'other', label: t('typeOther') },
|
||||
])
|
||||
|
||||
const form = reactive({
|
||||
name: '', type: 'server', description: '',
|
||||
is_gateway: false, is_livebox: false, virt_type: null, url: null, interfaces: [],
|
||||
})
|
||||
|
||||
watch(() => props.device, (d) => {
|
||||
if (d) {
|
||||
Object.assign(form, {
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
description: d.description,
|
||||
is_gateway: d.is_gateway,
|
||||
is_livebox: d.is_livebox,
|
||||
virt_type: d.virt_type || null,
|
||||
url: d.url || null,
|
||||
interfaces: d.interfaces.map(i => ({ ...i })),
|
||||
})
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
name: '', type: 'server', description: '',
|
||||
is_gateway: false, is_livebox: false, virt_type: null, url: null, interfaces: [],
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function addInterface() {
|
||||
form.interfaces.push({ name: 'eth' + form.interfaces.length, ip_address: '', vlan_id: null, is_upstream: false })
|
||||
}
|
||||
|
||||
function removeInterface(idx) {
|
||||
form.interfaces.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
virt_type: form.virt_type || null,
|
||||
url: form.url || null,
|
||||
}
|
||||
if (props.device) {
|
||||
await devicesApi.update(props.device.id, payload)
|
||||
} else {
|
||||
await devicesApi.create(payload)
|
||||
}
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || t('saveError'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: var(--modal-overlay);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card); border-radius: 16px; padding: 28px;
|
||||
width: 600px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: var(--shadow-modal);
|
||||
}
|
||||
.modal h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 20px; }
|
||||
|
||||
.fields-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 5px; }
|
||||
.field input[type="text"],
|
||||
.field input[type="url"],
|
||||
.field input[type="number"],
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%; padding: 9px 12px; border: 1.5px solid var(--border);
|
||||
border-radius: 8px; font-size: 14px; color: var(--text-primary);
|
||||
background: var(--bg-input); box-sizing: border-box;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
outline: none; border-color: #3B82F6;
|
||||
}
|
||||
|
||||
.checkboxes { display: flex; gap: 20px; margin-bottom: 16px; }
|
||||
.checkbox { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: var(--text-secondary); }
|
||||
.checkbox input { width: 16px; height: 16px; cursor: pointer; }
|
||||
|
||||
.interfaces-section { border: 1.5px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 14px; }
|
||||
.interfaces-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.interfaces-header strong { font-size: 13px; color: var(--text-secondary); }
|
||||
.btn-add-iface {
|
||||
padding: 5px 12px; background: #EFF6FF; color: #3B82F6;
|
||||
border: 1.5px solid #BFDBFE; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-add-iface:hover { background: #DBEAFE; }
|
||||
.iface-empty { font-size: 13px; color: var(--text-faint); text-align: center; padding: 8px 0; }
|
||||
|
||||
.iface-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.iface-input { padding: 7px 10px; border: 1.5px solid var(--border); border-radius: 6px; font-size: 13px; width: 80px; background: var(--bg-input); color: var(--text-primary); }
|
||||
.iface-input.wide { width: 130px; }
|
||||
.iface-select { padding: 7px 10px; border: 1.5px solid var(--border); border-radius: 6px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
|
||||
.iface-upstream { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-muted); cursor: pointer; }
|
||||
.btn-rm-iface {
|
||||
width: 26px; height: 26px; border: none; border-radius: 6px;
|
||||
background: #FEE2E2; color: #EF4444; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.btn-rm-iface:hover { background: #FECACA; }
|
||||
|
||||
.btn-primary {
|
||||
padding: 9px 18px; background: #3B82F6; color: #fff;
|
||||
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { background: #2563EB; }
|
||||
.btn-secondary {
|
||||
padding: 9px 18px; background: var(--border); color: var(--text-secondary);
|
||||
border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border-strong); }
|
||||
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<DeviceFormModal
|
||||
v-if="editingDevice"
|
||||
:device="editingDevice"
|
||||
:vlans="props.vlans"
|
||||
@close="editingDevice = null"
|
||||
@saved="emit('refresh')"
|
||||
/>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ t('ipAddressing') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="allHaveNoIp" class="empty">
|
||||
{{ t('noIpConfigured') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="groups">
|
||||
<div v-for="group in groups" :key="group.key" class="group">
|
||||
<div class="group-header" :style="{ borderLeftColor: group.color }">
|
||||
<span class="badge-vlan" :style="{ background: group.color }">
|
||||
{{ group.badge }}
|
||||
</span>
|
||||
<span class="group-name">{{ group.name }}</span>
|
||||
<code v-if="group.cidr" class="group-cidr">{{ group.cidr }}</code>
|
||||
<span class="group-count">{{ group.rows.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ip">{{ t('colIp') }}</th>
|
||||
<th class="col-name">{{ t('colHostname') }}</th>
|
||||
<th class="col-desc">{{ t('fieldDescription') }}</th>
|
||||
<th class="col-action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in group.rows" :key="row.ip + '-' + row.deviceId">
|
||||
<td><code class="ip-cell">{{ row.ip }}</code></td>
|
||||
<td class="name-cell">{{ row.name }}</td>
|
||||
<td class="desc-cell">
|
||||
<div class="desc-inner">
|
||||
<span v-if="row.osEntry" class="os-inline" :title="row.osEntry.label">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
|
||||
:style="{ color: '#' + row.osEntry.icon.hex }">
|
||||
<path :d="row.osEntry.icon.path" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ row.description || '—' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn-edit" @click="editingDevice = row.device" :title="t('editDevice')">✎</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { t } from '../i18n.js'
|
||||
import { detectOs } from '../brandIcons.js'
|
||||
import DeviceFormModal from './DeviceFormModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
devices: { type: Array, default: () => [] },
|
||||
vlans: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const editingDevice = ref(null)
|
||||
|
||||
function ipToNum(ip) {
|
||||
if (!ip) return 0
|
||||
return ip.split('.').reduce((acc, part) => acc * 256 + parseInt(part, 10), 0)
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
const vlanMap = Object.fromEntries(props.vlans.map(v => [v.id, v]))
|
||||
|
||||
const byVlan = {}
|
||||
const unassigned = []
|
||||
|
||||
for (const device of props.devices) {
|
||||
for (const iface of device.interfaces || []) {
|
||||
if (!iface.ip_address) continue
|
||||
const row = {
|
||||
ip: iface.ip_address,
|
||||
name: device.name,
|
||||
description: device.description,
|
||||
deviceId: device.id,
|
||||
osEntry: detectOs(device.name, device.description),
|
||||
device,
|
||||
}
|
||||
if (iface.vlan_id != null) {
|
||||
if (!byVlan[iface.vlan_id]) byVlan[iface.vlan_id] = []
|
||||
byVlan[iface.vlan_id].push(row)
|
||||
} else {
|
||||
unassigned.push(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedVlans = [...props.vlans].sort((a, b) => {
|
||||
if (a.vlan_id == null && b.vlan_id == null) return 0
|
||||
if (a.vlan_id == null) return -1
|
||||
if (b.vlan_id == null) return 1
|
||||
return a.vlan_id - b.vlan_id
|
||||
})
|
||||
|
||||
const result = []
|
||||
|
||||
for (const vlan of sortedVlans) {
|
||||
const rows = byVlan[vlan.id]
|
||||
if (!rows || rows.length === 0) continue
|
||||
rows.sort((a, b) => ipToNum(a.ip) - ipToNum(b.ip))
|
||||
result.push({
|
||||
key: `vlan-${vlan.id}`,
|
||||
badge: vlan.vlan_id != null ? `VLAN ${vlan.vlan_id}` : 'LAN',
|
||||
name: vlan.name,
|
||||
cidr: vlan.cidr,
|
||||
color: vlan.color,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
|
||||
if (unassigned.length > 0) {
|
||||
unassigned.sort((a, b) => ipToNum(a.ip) - ipToNum(b.ip))
|
||||
result.push({
|
||||
key: 'unassigned',
|
||||
badge: '?',
|
||||
name: t('unassigned'),
|
||||
cidr: null,
|
||||
color: '#94A3B8',
|
||||
rows: unassigned,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const allHaveNoIp = computed(() => groups.value.length === 0)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.group {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 4px solid transparent;
|
||||
background: var(--bg-thead);
|
||||
}
|
||||
|
||||
.badge-vlan {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.group-cidr {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-chip);
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-chip);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
background: var(--bg-thead);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.col-ip { width: 150px; }
|
||||
.col-name { width: 220px; }
|
||||
.col-desc { }
|
||||
.col-action { width: 40px; }
|
||||
|
||||
.ip-cell {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: var(--chip-ip-bg);
|
||||
color: var(--chip-ip-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.desc-cell {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desc-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.os-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
width: 26px; height: 26px; border: none; border-radius: 6px;
|
||||
background: var(--bg-chip); color: var(--text-secondary);
|
||||
font-size: 13px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
tr:hover .btn-edit { opacity: 1; }
|
||||
.btn-edit:hover { background: var(--border-strong); color: var(--text-primary); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user