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:
2026-05-22 09:11:32 +02:00
parent 5001ce192d
commit 6b4de6f792
2 changed files with 564 additions and 0 deletions
+231
View File
@@ -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>
+333
View File
@@ -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>