feat: auto-detect OS from description; expand OS_LIST to 51 distros
- Remove os field from Device API and form — OS is now detected automatically from name/description via detectOs() in brandIcons.js - Expand OS_LIST from 20 to 51 entries covering all major distros (Debian/Ubuntu flavours, Red Hat, SUSE, Arch, BSD, security distros, Windows/macOS/iOS/Android, generic Linux/BSD catch-alls) - Display detected OS icon in IpAddressing.vue description column - Fix virt_type validator to normalise empty string to null Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,19 @@ def _migrate_users():
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_device_os():
|
||||
"""Ajoute la colonne os sur devices si absente."""
|
||||
with engine.connect() as conn:
|
||||
if not conn.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
|
||||
)).fetchone():
|
||||
return
|
||||
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
|
||||
if 'os' not in cols:
|
||||
conn.execute(text("ALTER TABLE devices ADD COLUMN os VARCHAR"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_drop_links_table():
|
||||
"""Supprime la table links (fonctionnalité retirée en phase 3). Idempotent."""
|
||||
with engine.connect() as conn:
|
||||
@@ -162,6 +175,7 @@ def _migrate_drop_links_table():
|
||||
_migrate_vlan_nullable()
|
||||
_migrate_device_virt_type()
|
||||
_migrate_device_url()
|
||||
_migrate_device_os()
|
||||
_migrate_users_must_change_password()
|
||||
_migrate_users_token_version()
|
||||
_migrate_force_admin_password_change()
|
||||
|
||||
@@ -36,6 +36,7 @@ class Device(Base):
|
||||
is_livebox = Column(Boolean, default=False)
|
||||
virt_type = Column(String, nullable=True)
|
||||
url = Column(String, nullable=True)
|
||||
os = Column(String, nullable=True)
|
||||
|
||||
interfaces = relationship(
|
||||
"DeviceInterface", back_populates="device", cascade="all, delete-orphan"
|
||||
|
||||
@@ -91,6 +91,8 @@ class DeviceCreate(BaseModel):
|
||||
@field_validator("virt_type")
|
||||
@classmethod
|
||||
def _virt_type(cls, v: Optional[str]) -> Optional[str]:
|
||||
if not v:
|
||||
return None
|
||||
if v not in _VALID_VIRT_TYPES:
|
||||
raise ValueError(f"Invalid virt_type: {v!r}. Must be one of: baremetal, lxc, qemu")
|
||||
return v
|
||||
@@ -104,7 +106,6 @@ class DeviceCreate(BaseModel):
|
||||
raise ValueError("url must be a valid http or https URL")
|
||||
return v
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "network-topology",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
/>
|
||||
<VlanManager v-if="view === 'vlans'" :vlans="vlans" :devices="devices" @refresh="loadAll" />
|
||||
<DeviceManager v-if="view === 'devices'" :devices="devices" :vlans="vlans" @refresh="loadAll" />
|
||||
<IpAddressing v-if="view === 'addressing'" :devices="devices" :vlans="vlans" @refresh="loadAll" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +125,7 @@ import { isAuthenticated, currentUsername, mustChangePassword, setAuth, clearAut
|
||||
import TopologyGraph from './components/TopologyGraph.vue'
|
||||
import VlanManager from './components/VlanManager.vue'
|
||||
import DeviceManager from './components/DeviceManager.vue'
|
||||
import IpAddressing from './components/IpAddressing.vue'
|
||||
import DiscoveryModal from './components/DiscoveryModal.vue'
|
||||
import LoginPage from './components/LoginPage.vue'
|
||||
import AccountModal from './components/AccountModal.vue'
|
||||
@@ -142,6 +144,7 @@ const tabs = computed(() => [
|
||||
{ id: 'topology', label: t('tabTopology'), icon: '■' },
|
||||
{ id: 'vlans', label: t('tabNetworks'), icon: '◆' },
|
||||
{ id: 'devices', label: t('tabDevices'), icon: '▣' },
|
||||
{ id: 'addressing', label: t('tabAddressing'), icon: '⊞' },
|
||||
])
|
||||
|
||||
function onLogin({ token, username, mustChangePassword: mcp }) {
|
||||
|
||||
@@ -15,6 +15,15 @@ import {
|
||||
siElastic, siKibana, siLogstash, siSplunk, siGraylog, siJaeger, siOpentelemetry,
|
||||
siApple,
|
||||
siDebian, siUbuntu, siFirefox,
|
||||
siCentos, siRedhat, siFedora, siAlpinelinux, siArchlinux, siOpensuse, siFreebsd,
|
||||
siAlmalinux, siRockylinux, siNixos, siGentoo, siVoidlinux, siSlackware,
|
||||
siSuse, siManjaro, siLinuxmint, siZorin, siPopos, siDeepin, siElementary,
|
||||
siMxlinux, siSolus, siEndeavouros, siArtixlinux, siAsahilinux,
|
||||
siKubuntu, siLubuntu, siXubuntu, siUbuntumate,
|
||||
siBsd, siOpenbsd, siNetbsd,
|
||||
siKalilinux, siParrotsecurity, siTails, siQubesos, siReactos,
|
||||
siMacos, siIos, siLinux,
|
||||
siAndroid,
|
||||
siAnsible,
|
||||
siDell, siHp,
|
||||
siRaspberrypi, siArduino,
|
||||
@@ -22,7 +31,7 @@ import {
|
||||
siWordpress, siGhost, siGrav, siJekyll, siHugo, siHexo, siDrupal, siJoomla, siTypo3, siOctobercms, siTextpattern,
|
||||
siMatomo, siPlausibleanalytics,
|
||||
siSamsung, siLg, siSony, siPanasonic, siSharp, siToshiba, siVestel,
|
||||
siChromecast, siAndroid, siAppletv, siAmazonfiretv, siRoku, siKodi,
|
||||
siChromecast, siAppletv, siAmazonfiretv, siRoku, siKodi,
|
||||
siJellyfin, siHomeassistant, siPhilipshue, siXiaomi,
|
||||
siRadarr, siSonarr, siTransmission,
|
||||
siExcalidraw,
|
||||
@@ -295,6 +304,84 @@ const BRANDS = [
|
||||
{ kw: ['xiaomi', 'mi home', 'yeelight'], icon: siXiaomi },
|
||||
]
|
||||
|
||||
export const OS_LIST = [
|
||||
// Infrastructure
|
||||
{ value: 'proxmox', label: 'Proxmox VE', kw: ['proxmox', 'pve'], icon: siProxmox },
|
||||
{ value: 'truenas', label: 'TrueNAS', kw: ['truenas', 'freenas'], icon: siTruenas },
|
||||
{ value: 'synology', label: 'Synology DSM', kw: ['synology', 'dsm'], icon: siSynology },
|
||||
// Réseau / pare-feu
|
||||
{ value: 'openwrt', label: 'OpenWrt', kw: ['openwrt'], icon: siOpenwrt },
|
||||
{ value: 'pfsense', label: 'pfSense', kw: ['pfsense'], icon: siPfsense },
|
||||
{ value: 'opnsense', label: 'OPNsense', kw: ['opnsense'], icon: siOpnsense },
|
||||
// SBC
|
||||
{ value: 'raspbian', label: 'Raspberry Pi OS', kw: ['raspbian', 'raspberry pi os', 'pi os'], icon: siRaspberrypi },
|
||||
// Debian / Ubuntu
|
||||
{ value: 'ubuntu', label: 'Ubuntu', kw: ['ubuntu'], icon: siUbuntu },
|
||||
{ value: 'kubuntu', label: 'Kubuntu', kw: ['kubuntu'], icon: siKubuntu },
|
||||
{ value: 'lubuntu', label: 'Lubuntu', kw: ['lubuntu'], icon: siLubuntu },
|
||||
{ value: 'xubuntu', label: 'Xubuntu', kw: ['xubuntu'], icon: siXubuntu },
|
||||
{ value: 'ubuntumate', label: 'Ubuntu MATE', kw: ['ubuntu mate', 'ubuntumate'], icon: siUbuntumate },
|
||||
{ value: 'debian', label: 'Debian', kw: ['debian'], icon: siDebian },
|
||||
// Red Hat / CentOS
|
||||
{ value: 'redhat', label: 'Red Hat', kw: ['red hat', 'redhat', 'rhel'], icon: siRedhat },
|
||||
{ value: 'centos', label: 'CentOS', kw: ['centos'], icon: siCentos },
|
||||
{ value: 'almalinux', label: 'AlmaLinux', kw: ['almalinux', 'alma linux'], icon: { ...siAlmalinux, hex: '4d4d4d' } },
|
||||
{ value: 'rockylinux', label: 'Rocky Linux', kw: ['rockylinux', 'rocky linux', 'rocky'], icon: siRockylinux },
|
||||
{ value: 'fedora', label: 'Fedora', kw: ['fedora'], icon: siFedora },
|
||||
// SUSE
|
||||
{ value: 'opensuse', label: 'openSUSE', kw: ['opensuse', 'open suse'], icon: siOpensuse },
|
||||
{ value: 'suse', label: 'SUSE Linux Enterprise', kw: ['sles', 'suse linux enterprise'], icon: { ...siSuse, hex: '73BA25' } },
|
||||
// Arch
|
||||
{ value: 'arch', label: 'Arch Linux', kw: ['arch linux', 'archlinux'], icon: siArchlinux },
|
||||
{ value: 'manjaro', label: 'Manjaro', kw: ['manjaro'], icon: siManjaro },
|
||||
{ value: 'endeavouros', label: 'EndeavourOS', kw: ['endeavouros', 'endeavour os'], icon: siEndeavouros },
|
||||
{ value: 'artixlinux', label: 'Artix Linux', kw: ['artixlinux', 'artix linux', 'artix'], icon: siArtixlinux },
|
||||
// Alpine
|
||||
{ value: 'alpine', label: 'Alpine Linux', kw: ['alpine linux', 'alpine'], icon: siAlpinelinux },
|
||||
// Divers
|
||||
{ value: 'nixos', label: 'NixOS', kw: ['nixos', 'nix os'], icon: siNixos },
|
||||
{ value: 'gentoo', label: 'Gentoo', kw: ['gentoo'], icon: siGentoo },
|
||||
{ value: 'voidlinux', label: 'Void Linux', kw: ['voidlinux', 'void linux'], icon: siVoidlinux },
|
||||
{ value: 'slackware', label: 'Slackware', kw: ['slackware'], icon: { ...siSlackware, hex: '4d4d4d' } },
|
||||
{ value: 'asahilinux', label: 'Asahi Linux', kw: ['asahi linux', 'asahi'], icon: siAsahilinux },
|
||||
// Bureau
|
||||
{ value: 'linuxmint', label: 'Linux Mint', kw: ['linux mint', 'linuxmint'], icon: siLinuxmint },
|
||||
{ value: 'popos', label: 'Pop!_OS', kw: ["pop!_os", 'popos', 'pop os'], icon: siPopos },
|
||||
{ value: 'zorin', label: 'Zorin OS', kw: ['zorin'], icon: siZorin },
|
||||
{ value: 'elementary', label: 'elementary OS', kw: ['elementary os', 'elementaryos'], icon: siElementary },
|
||||
{ value: 'deepin', label: 'deepin', kw: ['deepin'], icon: siDeepin },
|
||||
{ value: 'mxlinux', label: 'MX Linux', kw: ['mx linux', 'mxlinux'], icon: { ...siMxlinux, hex: '4d4d4d' } },
|
||||
{ value: 'solus', label: 'Solus', kw: ['solus'], icon: siSolus },
|
||||
// BSD
|
||||
{ value: 'freebsd', label: 'FreeBSD', kw: ['freebsd', 'free bsd'], icon: siFreebsd },
|
||||
{ value: 'openbsd', label: 'OpenBSD', kw: ['openbsd'], icon: siOpenbsd },
|
||||
{ value: 'netbsd', label: 'NetBSD', kw: ['netbsd'], icon: siNetbsd },
|
||||
// Sécurité
|
||||
{ value: 'kali', label: 'Kali Linux', kw: ['kali linux', 'kalilinux'], icon: siKalilinux },
|
||||
{ value: 'parrot', label: 'Parrot Security', kw: ['parrot security', 'parrotsec', 'parrot os'], icon: siParrotsecurity },
|
||||
{ value: 'tails', label: 'Tails', kw: ['tails os', 'tails linux'], icon: siTails },
|
||||
{ value: 'qubes', label: 'Qubes OS', kw: ['qubes os', 'qubesos'], icon: siQubesos },
|
||||
// Windows / macOS / mobile
|
||||
{ value: 'windows', label: 'Windows', kw: ['windows', 'win10', 'win11', 'winserver', 'windows server'], icon: ICON_WINDOWS },
|
||||
{ value: 'macos', label: 'macOS', kw: ['macos', 'mac os'], icon: { ...siMacos, hex: '555555' } },
|
||||
{ value: 'ios', label: 'iOS / iPadOS', kw: ['ios', 'ipados'], icon: { ...siIos, hex: '555555' } },
|
||||
{ value: 'android', label: 'Android', kw: ['android'], icon: siAndroid },
|
||||
// Autres
|
||||
{ value: 'reactos', label: 'ReactOS', kw: ['reactos'], icon: siReactos },
|
||||
{ value: 'linux', label: 'Linux (générique)', kw: ['linux'], icon: siLinux },
|
||||
{ value: 'bsd', label: 'BSD (générique)', kw: ['bsd'], icon: siBsd },
|
||||
]
|
||||
|
||||
export function osIcon(value) {
|
||||
return OS_LIST.find(o => o.value === value) ?? null
|
||||
}
|
||||
|
||||
export function detectOs(name, description) {
|
||||
const text = ((name || '') + ' ' + (description || '')).toLowerCase()
|
||||
if (!text.trim()) return null
|
||||
return OS_LIST.find(o => o.kw.some(kw => text.includes(kw))) ?? null
|
||||
}
|
||||
|
||||
export function detectBrands(name, description) {
|
||||
const text = ((name || '') + ' ' + (description || '')).toLowerCase()
|
||||
if (!text.trim()) return []
|
||||
|
||||
@@ -149,90 +149,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false">
|
||||
<div class="modal">
|
||||
<h2>{{ editing ? 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 props.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="showForm = false">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn-primary">{{ editing ? t('save') : t('create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<DeviceFormModal
|
||||
v-if="showForm"
|
||||
:device="editing"
|
||||
:vlans="props.vlans"
|
||||
@close="showForm = false"
|
||||
@saved="emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { devicesApi } from '../api.js'
|
||||
import DeviceIcon from './DeviceIcon.vue'
|
||||
import DeviceFormModal from './DeviceFormModal.vue'
|
||||
import { detectBrands } from '../brandIcons.js'
|
||||
import { t, tFmt } from '../i18n.js'
|
||||
|
||||
@@ -360,10 +291,6 @@ const emit = defineEmits(['refresh'])
|
||||
|
||||
const showForm = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = reactive({
|
||||
name: '', type: 'server', description: '',
|
||||
is_gateway: false, is_livebox: false, virt_type: null, url: null, interfaces: []
|
||||
})
|
||||
|
||||
function typeLabel(type) {
|
||||
return deviceTypes.value.find(d => d.value === type)?.label || type
|
||||
@@ -392,47 +319,14 @@ function vlanLabel(vlanId) {
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', type: 'server', description: '', is_gateway: false, is_livebox: false, virt_type: null, url: null, interfaces: [] })
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openEdit(device) {
|
||||
editing.value = device
|
||||
Object.assign(form, {
|
||||
name: device.name,
|
||||
type: device.type,
|
||||
description: device.description,
|
||||
is_gateway: device.is_gateway,
|
||||
is_livebox: device.is_livebox,
|
||||
virt_type: device.virt_type || null,
|
||||
url: device.url || null,
|
||||
interfaces: device.interfaces.map(i => ({ ...i }))
|
||||
})
|
||||
showForm.value = 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 {
|
||||
if (editing.value) {
|
||||
await devicesApi.update(editing.value.id, form)
|
||||
} else {
|
||||
await devicesApi.create(form)
|
||||
}
|
||||
showForm.value = false
|
||||
emit('refresh')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || t('saveError'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(device) {
|
||||
if (!confirm(tFmt('confirmDeleteDevice', device.name))) return
|
||||
try {
|
||||
@@ -641,58 +535,4 @@ h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||
}
|
||||
.iface-upstream { color: #F97316; font-weight: 600; font-size: 11px; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────────────────── */
|
||||
.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; }
|
||||
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,12 @@ const LANGS = {
|
||||
tabTopology: 'Topologie',
|
||||
tabNetworks: 'Réseaux',
|
||||
tabDevices: 'Équipements',
|
||||
tabAddressing: 'Adressage IP',
|
||||
ipAddressing: 'Adressage IP',
|
||||
colHostname: "Nom d'hôte",
|
||||
colOs: 'OS',
|
||||
fieldOs: "Système d'exploitation",
|
||||
noIpConfigured: 'Aucune adresse IP configurée.',
|
||||
discovery: '🔍 Découverte auto',
|
||||
statsNetwork: 'Réseau',
|
||||
statsNetworks: 'Réseaux',
|
||||
@@ -187,6 +193,12 @@ const LANGS = {
|
||||
tabTopology: 'Topology',
|
||||
tabNetworks: 'Networks',
|
||||
tabDevices: 'Devices',
|
||||
tabAddressing: 'IP Addressing',
|
||||
ipAddressing: 'IP Addressing',
|
||||
colHostname: 'Hostname',
|
||||
colOs: 'OS',
|
||||
fieldOs: 'Operating system',
|
||||
noIpConfigured: 'No IP address configured.',
|
||||
discovery: '🔍 Auto discovery',
|
||||
statsNetwork: 'Network',
|
||||
statsNetworks: 'Networks',
|
||||
@@ -355,6 +367,12 @@ const LANGS = {
|
||||
tabTopology: 'Topología',
|
||||
tabNetworks: 'Redes',
|
||||
tabDevices: 'Equipos',
|
||||
tabAddressing: 'Direccionamiento IP',
|
||||
ipAddressing: 'Direccionamiento IP',
|
||||
colHostname: 'Nombre de host',
|
||||
colOs: 'SO',
|
||||
fieldOs: 'Sistema operativo',
|
||||
noIpConfigured: 'No hay ninguna dirección IP configurada.',
|
||||
discovery: '🔍 Descubrimiento auto',
|
||||
statsNetwork: 'Red',
|
||||
statsNetworks: 'Redes',
|
||||
|
||||
Reference in New Issue
Block a user