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:
2026-05-22 08:52:53 +02:00
parent 954b5cefa6
commit fd289cc00f
8 changed files with 139 additions and 175 deletions
+14
View File
@@ -146,6 +146,19 @@ def _migrate_users():
conn.commit() 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(): def _migrate_drop_links_table():
"""Supprime la table links (fonctionnalité retirée en phase 3). Idempotent.""" """Supprime la table links (fonctionnalité retirée en phase 3). Idempotent."""
with engine.connect() as conn: with engine.connect() as conn:
@@ -162,6 +175,7 @@ def _migrate_drop_links_table():
_migrate_vlan_nullable() _migrate_vlan_nullable()
_migrate_device_virt_type() _migrate_device_virt_type()
_migrate_device_url() _migrate_device_url()
_migrate_device_os()
_migrate_users_must_change_password() _migrate_users_must_change_password()
_migrate_users_token_version() _migrate_users_token_version()
_migrate_force_admin_password_change() _migrate_force_admin_password_change()
+1
View File
@@ -36,6 +36,7 @@ class Device(Base):
is_livebox = Column(Boolean, default=False) is_livebox = Column(Boolean, default=False)
virt_type = Column(String, nullable=True) virt_type = Column(String, nullable=True)
url = Column(String, nullable=True) url = Column(String, nullable=True)
os = Column(String, nullable=True)
interfaces = relationship( interfaces = relationship(
"DeviceInterface", back_populates="device", cascade="all, delete-orphan" "DeviceInterface", back_populates="device", cascade="all, delete-orphan"
+2 -1
View File
@@ -91,6 +91,8 @@ class DeviceCreate(BaseModel):
@field_validator("virt_type") @field_validator("virt_type")
@classmethod @classmethod
def _virt_type(cls, v: Optional[str]) -> Optional[str]: def _virt_type(cls, v: Optional[str]) -> Optional[str]:
if not v:
return None
if v not in _VALID_VIRT_TYPES: if v not in _VALID_VIRT_TYPES:
raise ValueError(f"Invalid virt_type: {v!r}. Must be one of: baremetal, lxc, qemu") raise ValueError(f"Invalid virt_type: {v!r}. Must be one of: baremetal, lxc, qemu")
return v return v
@@ -104,7 +106,6 @@ class DeviceCreate(BaseModel):
raise ValueError("url must be a valid http or https URL") raise ValueError("url must be a valid http or https URL")
return v return v
class DeviceOut(BaseModel): class DeviceOut(BaseModel):
id: int id: int
name: str name: str
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "network-topology", "name": "network-topology",
"version": "1.0.1", "version": "1.1.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
+3
View File
@@ -110,6 +110,7 @@
/> />
<VlanManager v-if="view === 'vlans'" :vlans="vlans" :devices="devices" @refresh="loadAll" /> <VlanManager v-if="view === 'vlans'" :vlans="vlans" :devices="devices" @refresh="loadAll" />
<DeviceManager v-if="view === 'devices'" :devices="devices" :vlans="vlans" @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> </main>
</div> </div>
</div> </div>
@@ -124,6 +125,7 @@ import { isAuthenticated, currentUsername, mustChangePassword, setAuth, clearAut
import TopologyGraph from './components/TopologyGraph.vue' import TopologyGraph from './components/TopologyGraph.vue'
import VlanManager from './components/VlanManager.vue' import VlanManager from './components/VlanManager.vue'
import DeviceManager from './components/DeviceManager.vue' import DeviceManager from './components/DeviceManager.vue'
import IpAddressing from './components/IpAddressing.vue'
import DiscoveryModal from './components/DiscoveryModal.vue' import DiscoveryModal from './components/DiscoveryModal.vue'
import LoginPage from './components/LoginPage.vue' import LoginPage from './components/LoginPage.vue'
import AccountModal from './components/AccountModal.vue' import AccountModal from './components/AccountModal.vue'
@@ -142,6 +144,7 @@ const tabs = computed(() => [
{ id: 'topology', label: t('tabTopology'), icon: '■' }, { id: 'topology', label: t('tabTopology'), icon: '■' },
{ id: 'vlans', label: t('tabNetworks'), icon: '◆' }, { id: 'vlans', label: t('tabNetworks'), icon: '◆' },
{ id: 'devices', label: t('tabDevices'), icon: '▣' }, { id: 'devices', label: t('tabDevices'), icon: '▣' },
{ id: 'addressing', label: t('tabAddressing'), icon: '⊞' },
]) ])
function onLogin({ token, username, mustChangePassword: mcp }) { function onLogin({ token, username, mustChangePassword: mcp }) {
+88 -1
View File
@@ -15,6 +15,15 @@ import {
siElastic, siKibana, siLogstash, siSplunk, siGraylog, siJaeger, siOpentelemetry, siElastic, siKibana, siLogstash, siSplunk, siGraylog, siJaeger, siOpentelemetry,
siApple, siApple,
siDebian, siUbuntu, siFirefox, 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, siAnsible,
siDell, siHp, siDell, siHp,
siRaspberrypi, siArduino, siRaspberrypi, siArduino,
@@ -22,7 +31,7 @@ import {
siWordpress, siGhost, siGrav, siJekyll, siHugo, siHexo, siDrupal, siJoomla, siTypo3, siOctobercms, siTextpattern, siWordpress, siGhost, siGrav, siJekyll, siHugo, siHexo, siDrupal, siJoomla, siTypo3, siOctobercms, siTextpattern,
siMatomo, siPlausibleanalytics, siMatomo, siPlausibleanalytics,
siSamsung, siLg, siSony, siPanasonic, siSharp, siToshiba, siVestel, siSamsung, siLg, siSony, siPanasonic, siSharp, siToshiba, siVestel,
siChromecast, siAndroid, siAppletv, siAmazonfiretv, siRoku, siKodi, siChromecast, siAppletv, siAmazonfiretv, siRoku, siKodi,
siJellyfin, siHomeassistant, siPhilipshue, siXiaomi, siJellyfin, siHomeassistant, siPhilipshue, siXiaomi,
siRadarr, siSonarr, siTransmission, siRadarr, siSonarr, siTransmission,
siExcalidraw, siExcalidraw,
@@ -295,6 +304,84 @@ const BRANDS = [
{ kw: ['xiaomi', 'mi home', 'yeelight'], icon: siXiaomi }, { 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) { export function detectBrands(name, description) {
const text = ((name || '') + ' ' + (description || '')).toLowerCase() const text = ((name || '') + ' ' + (description || '')).toLowerCase()
if (!text.trim()) return [] if (!text.trim()) return []
+9 -169
View File
@@ -149,90 +149,21 @@
</div> </div>
</template> </template>
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false"> <DeviceFormModal
<div class="modal"> v-if="showForm"
<h2>{{ editing ? t('editDevice') : t('newDevice') }}</h2> :device="editing"
<form @submit.prevent="save"> :vlans="props.vlans"
<div class="fields-row"> @close="showForm = false"
<div class="field"> @saved="emit('refresh')"
<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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { devicesApi } from '../api.js' import { devicesApi } from '../api.js'
import DeviceIcon from './DeviceIcon.vue' import DeviceIcon from './DeviceIcon.vue'
import DeviceFormModal from './DeviceFormModal.vue'
import { detectBrands } from '../brandIcons.js' import { detectBrands } from '../brandIcons.js'
import { t, tFmt } from '../i18n.js' import { t, tFmt } from '../i18n.js'
@@ -360,10 +291,6 @@ const emit = defineEmits(['refresh'])
const showForm = ref(false) const showForm = ref(false)
const editing = ref(null) 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) { function typeLabel(type) {
return deviceTypes.value.find(d => d.value === type)?.label || type return deviceTypes.value.find(d => d.value === type)?.label || type
@@ -392,47 +319,14 @@ function vlanLabel(vlanId) {
function openAdd() { function openAdd() {
editing.value = null 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 showForm.value = true
} }
function openEdit(device) { function openEdit(device) {
editing.value = 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 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) { async function remove(device) {
if (!confirm(tFmt('confirmDeleteDevice', device.name))) return if (!confirm(tFmt('confirmDeleteDevice', device.name))) return
try { 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; } .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> </style>
+18
View File
@@ -13,6 +13,12 @@ const LANGS = {
tabTopology: 'Topologie', tabTopology: 'Topologie',
tabNetworks: 'Réseaux', tabNetworks: 'Réseaux',
tabDevices: 'Équipements', 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', discovery: '🔍 Découverte auto',
statsNetwork: 'Réseau', statsNetwork: 'Réseau',
statsNetworks: 'Réseaux', statsNetworks: 'Réseaux',
@@ -187,6 +193,12 @@ const LANGS = {
tabTopology: 'Topology', tabTopology: 'Topology',
tabNetworks: 'Networks', tabNetworks: 'Networks',
tabDevices: 'Devices', tabDevices: 'Devices',
tabAddressing: 'IP Addressing',
ipAddressing: 'IP Addressing',
colHostname: 'Hostname',
colOs: 'OS',
fieldOs: 'Operating system',
noIpConfigured: 'No IP address configured.',
discovery: '🔍 Auto discovery', discovery: '🔍 Auto discovery',
statsNetwork: 'Network', statsNetwork: 'Network',
statsNetworks: 'Networks', statsNetworks: 'Networks',
@@ -355,6 +367,12 @@ const LANGS = {
tabTopology: 'Topología', tabTopology: 'Topología',
tabNetworks: 'Redes', tabNetworks: 'Redes',
tabDevices: 'Equipos', 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', discovery: '🔍 Descubrimiento auto',
statsNetwork: 'Red', statsNetwork: 'Red',
statsNetworks: 'Redes', statsNetworks: 'Redes',