7b32e9b4fd
Add three new device types (21 total) with Lucide icons (Tv2, Printer, Smartphone), colour-coded badges, and translations in fr/en/es. No backend migration needed — type is a free string field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
699 lines
29 KiB
Vue
699 lines
29 KiB
Vue
<template>
|
|
<div class="page">
|
|
<div class="page-header">
|
|
<h1>{{ t('devices') }}</h1>
|
|
<button class="btn-primary" @click="openAdd">{{ t('addDevice') }}</button>
|
|
</div>
|
|
|
|
<div v-if="props.devices.length === 0" class="empty">
|
|
{{ t('noDevicesConfigured') }}
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Barre de filtres -->
|
|
<div class="filter-bar">
|
|
<div class="search-wrap">
|
|
<span class="search-icon">⌕</span>
|
|
<input
|
|
v-model="search"
|
|
class="search-input"
|
|
type="text"
|
|
:placeholder="t('searchPlaceholder')"
|
|
@keydown.escape="search = ''"
|
|
/>
|
|
<button v-if="search" class="search-clear" @click="search = ''">✕</button>
|
|
</div>
|
|
<div v-if="usedTypes.length > 1" class="filter-group" @mousedown.stop>
|
|
<button class="filter-btn" :class="{ active: filterTypes.length }" @click="toggleDrop('types')">
|
|
{{ t('filterType') }}
|
|
<span v-if="filterTypes.length" class="filter-count">{{ filterTypes.length }}</span>
|
|
<span class="filter-chevron">▾</span>
|
|
</button>
|
|
<div v-if="openDrop === 'types'" class="filter-drop">
|
|
<label v-for="t2 in usedTypes" :key="t2.value" class="filter-opt">
|
|
<input type="checkbox" :value="t2.value" v-model="filterTypes" />
|
|
{{ t2.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="usedVlans.length > 0" class="filter-group" @mousedown.stop>
|
|
<button class="filter-btn" :class="{ active: filterVlans.length }" @click="toggleDrop('vlans')">
|
|
{{ t('filterNetwork') }}
|
|
<span v-if="filterVlans.length" class="filter-count">{{ filterVlans.length }}</span>
|
|
<span class="filter-chevron">▾</span>
|
|
</button>
|
|
<div v-if="openDrop === 'vlans'" class="filter-drop">
|
|
<label v-for="v in usedVlans" :key="v.id" class="filter-opt">
|
|
<input type="checkbox" :value="v.id" v-model="filterVlans" />
|
|
<span class="vlan-dot" :style="{ background: v.color }"></span>
|
|
{{ v.vlan_id != null ? 'VLAN ' + v.vlan_id + ' — ' : 'LAN — ' }}{{ v.name }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="availableBrands.length > 0" class="filter-group" @mousedown.stop>
|
|
<button class="filter-btn" :class="{ active: filterBrands.length }" @click="toggleDrop('brands')">
|
|
{{ t('filterBrand') }}
|
|
<span v-if="filterBrands.length" class="filter-count">{{ filterBrands.length }}</span>
|
|
<span class="filter-chevron">▾</span>
|
|
</button>
|
|
<div v-if="openDrop === 'brands'" class="filter-drop">
|
|
<label v-for="b in availableBrands" :key="b.title" class="filter-opt">
|
|
<input type="checkbox" :value="b.title" v-model="filterBrands" />
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path :d="b.path" />
|
|
</svg>
|
|
{{ b.title }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="usedVirts.length > 0" class="filter-group" @mousedown.stop>
|
|
<button class="filter-btn" :class="{ active: filterVirts.length }" @click="toggleDrop('virts')">
|
|
{{ t('filterVirt') }}
|
|
<span v-if="filterVirts.length" class="filter-count">{{ filterVirts.length }}</span>
|
|
<span class="filter-chevron">▾</span>
|
|
</button>
|
|
<div v-if="openDrop === 'virts'" class="filter-drop">
|
|
<label v-for="vt in usedVirts" :key="vt" class="filter-opt">
|
|
<input type="checkbox" :value="vt" v-model="filterVirts" />
|
|
{{ virtLabelFor(vt) }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="hasActiveFilters">
|
|
<button class="filter-reset" @click="resetFilters">{{ t('clearFilters') }}</button>
|
|
<span class="filter-results">{{ sortedDevices.length }} / {{ props.devices.length }}</span>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="sortedDevices.length === 0" class="empty">
|
|
{{ t('noDevicesFiltered') }}
|
|
</div>
|
|
|
|
<div v-else class="devices-grid">
|
|
<div v-for="device in sortedDevices" :key="device.id" class="device-card">
|
|
<div class="device-header">
|
|
<div class="device-icon" :class="iconClass(device)">
|
|
<DeviceIcon
|
|
:device-type="device.type"
|
|
:is-gateway="device.is_gateway"
|
|
:is-livebox="device.is_livebox"
|
|
:name="device.name"
|
|
:description="device.description"
|
|
:size="20"
|
|
type-only
|
|
/>
|
|
</div>
|
|
<div class="device-info">
|
|
<div class="device-name">{{ device.name }}</div>
|
|
<div class="device-meta">
|
|
<span class="badge" :class="'badge-' + device.type">{{ typeLabel(device.type) }}</span>
|
|
<span v-if="device.is_gateway" class="badge badge-gateway">{{ t('badgeGateway') }}</span>
|
|
<span v-if="device.is_livebox" class="badge badge-livebox">{{ t('badgeLivebox') }}</span>
|
|
<span v-if="device.virt_type" class="badge badge-virt" :class="'badge-virt-' + device.virt_type">{{ virtLabelFor(device.virt_type) }}</span>
|
|
<span
|
|
v-for="brand in detectBrands(device.name, device.description)"
|
|
:key="brand.title"
|
|
class="badge-brand"
|
|
:title="brand.title"
|
|
:style="{ background: '#' + brand.hex }"
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path :d="brand.path" />
|
|
</svg>
|
|
{{ brand.title }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="device-actions">
|
|
<button class="btn-icon" @click="openEdit(device)" :title="t('save')">✎</button>
|
|
<button class="btn-icon danger" @click="remove(device)" :title="t('clearFilters')">✕</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="device.description" class="device-desc">{{ device.description }}</div>
|
|
<a v-if="device.url" :href="device.url" target="_blank" rel="noreferrer noopener" class="device-url">{{ device.url }}</a>
|
|
<div v-if="device.interfaces.length" class="interfaces">
|
|
<div v-for="iface in device.interfaces" :key="iface.id" class="iface">
|
|
<span class="iface-name">{{ iface.name }}</span>
|
|
<span v-if="iface.ip_address" class="iface-ip">{{ iface.ip_address }}</span>
|
|
<span v-if="iface.vlan_id" class="iface-vlan" :style="{ background: vlanColor(iface.vlan_id) }">
|
|
{{ vlanLabel(iface.vlan_id) }}
|
|
</span>
|
|
<span v-if="iface.is_upstream" class="iface-upstream">↑ WAN</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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="ex: srv-web-01" />
|
|
</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>WAN</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>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|
import { devicesApi } from '../api.js'
|
|
import DeviceIcon from './DeviceIcon.vue'
|
|
import { detectBrands } from '../brandIcons.js'
|
|
import { t, tFmt } from '../i18n.js'
|
|
|
|
const props = defineProps({ devices: Array, vlans: Array })
|
|
|
|
function ipToInt(ip) {
|
|
if (!ip) return Infinity
|
|
const p = ip.split('.').map(Number)
|
|
return p.length === 4 ? (p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]) >>> 0 : Infinity
|
|
}
|
|
|
|
function deviceFirstIp(device) {
|
|
for (const iface of device.interfaces) {
|
|
if (iface.ip_address) return iface.ip_address
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// ── Recherche + Filtres ───────────────────────────────────────────────────────
|
|
const search = ref('')
|
|
const filterTypes = ref([])
|
|
const filterVlans = ref([])
|
|
const filterBrands = ref([])
|
|
const filterVirts = ref([])
|
|
const openDrop = ref(null)
|
|
|
|
function toggleDrop(name) {
|
|
openDrop.value = openDrop.value === name ? null : name
|
|
}
|
|
|
|
function closeDropdown() { openDrop.value = null }
|
|
onMounted(() => document.addEventListener('mousedown', closeDropdown))
|
|
onUnmounted(() => document.removeEventListener('mousedown', closeDropdown))
|
|
|
|
const hasActiveFilters = computed(() =>
|
|
search.value.trim() ||
|
|
filterTypes.value.length + filterVlans.value.length +
|
|
filterBrands.value.length + filterVirts.value.length > 0
|
|
)
|
|
|
|
function resetFilters() {
|
|
search.value = ''
|
|
filterTypes.value = []
|
|
filterVlans.value = []
|
|
filterBrands.value = []
|
|
filterVirts.value = []
|
|
}
|
|
|
|
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 usedTypes = computed(() => {
|
|
const used = new Set(props.devices.map(d => d.type))
|
|
return deviceTypes.value.filter(t2 => used.has(t2.value))
|
|
})
|
|
|
|
const usedVlans = computed(() => {
|
|
const usedIds = new Set(
|
|
props.devices.flatMap(d => d.interfaces.map(i => i.vlan_id).filter(id => id != null))
|
|
)
|
|
return props.vlans.filter(v => usedIds.has(v.id))
|
|
})
|
|
|
|
const availableBrands = computed(() => {
|
|
const seen = new Map()
|
|
for (const d of props.devices) {
|
|
for (const b of detectBrands(d.name, d.description)) {
|
|
if (!seen.has(b.title)) seen.set(b.title, b)
|
|
}
|
|
}
|
|
return [...seen.values()].sort((a, b) => a.title.localeCompare(b.title))
|
|
})
|
|
|
|
const usedVirts = computed(() => {
|
|
const vts = new Set(props.devices.map(d => d.virt_type).filter(Boolean))
|
|
return ['baremetal', 'lxc', 'qemu'].filter(v => vts.has(v))
|
|
})
|
|
|
|
const filteredDevices = computed(() => {
|
|
const q = search.value.trim().toLowerCase()
|
|
return props.devices.filter(d => {
|
|
if (q) {
|
|
const inName = d.name.toLowerCase().includes(q)
|
|
const inDesc = (d.description || '').toLowerCase().includes(q)
|
|
const inIp = d.interfaces.some(i => (i.ip_address || '').includes(q))
|
|
if (!inName && !inDesc && !inIp) return false
|
|
}
|
|
if (filterTypes.value.length && !filterTypes.value.includes(d.type)) return false
|
|
if (filterVlans.value.length && !d.interfaces.some(i => filterVlans.value.includes(i.vlan_id))) return false
|
|
if (filterBrands.value.length) {
|
|
const dBrands = detectBrands(d.name, d.description).map(b => b.title)
|
|
if (!filterBrands.value.some(b => dBrands.includes(b))) return false
|
|
}
|
|
if (filterVirts.value.length && !filterVirts.value.includes(d.virt_type)) return false
|
|
return true
|
|
})
|
|
})
|
|
|
|
const sortedDevices = computed(() =>
|
|
[...filteredDevices.value].sort((a, b) => ipToInt(deviceFirstIp(a)) - ipToInt(deviceFirstIp(b)))
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
function virtLabelFor(vt) {
|
|
const map = { baremetal: t('virtBaremetal'), lxc: t('virtLxc'), qemu: t('virtQemu') }
|
|
return map[vt] || ''
|
|
}
|
|
|
|
function iconClass(device) {
|
|
if (device.is_livebox) return 'icon-livebox'
|
|
if (device.is_gateway) return 'icon-gateway'
|
|
return 'icon-' + device.type
|
|
}
|
|
|
|
function vlanColor(vlanId) {
|
|
return props.vlans.find(v => v.id === vlanId)?.color || '#94A3B8'
|
|
}
|
|
|
|
function vlanLabel(vlanId) {
|
|
const v = props.vlans.find(v => v.id === vlanId)
|
|
if (!v) return '?'
|
|
return v.vlan_id != null ? `VLAN ${v.vlan_id}` : 'LAN'
|
|
}
|
|
|
|
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(`Supprimer ${tFmt('confirmDeleteDevice', device.name)}`)) return
|
|
try {
|
|
await devicesApi.remove(device.id)
|
|
emit('refresh')
|
|
} catch (e) {
|
|
alert(e.response?.data?.detail || t('deleteError'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page { height: 100vh; overflow-y: auto; background: var(--bg-page); padding: 32px; }
|
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
|
h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
|
|
|
.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); }
|
|
|
|
.empty { padding: 48px; text-align: center; color: var(--text-faint); font-size: 15px; }
|
|
|
|
/* ── Barre de filtres ─────────────────────────────────────────────────────── */
|
|
.filter-bar {
|
|
display: flex; align-items: center; gap: 6px;
|
|
margin-bottom: 16px; flex-wrap: wrap;
|
|
}
|
|
|
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
.search-icon {
|
|
position: absolute; left: 8px; font-size: 15px; color: var(--text-faint);
|
|
pointer-events: none; line-height: 1;
|
|
}
|
|
.search-input {
|
|
height: 28px; padding: 0 28px 0 26px;
|
|
border: 1.5px solid var(--border); border-radius: 8px;
|
|
font-size: 12px; color: var(--text-primary); background: var(--bg-input);
|
|
width: 160px; outline: none;
|
|
}
|
|
.search-input:focus { border-color: #3B82F6; }
|
|
.search-input::placeholder { color: var(--text-faint); }
|
|
.search-clear {
|
|
position: absolute; right: 6px;
|
|
background: none; border: none; font-size: 11px; color: var(--text-faint);
|
|
cursor: pointer; padding: 0; line-height: 1;
|
|
}
|
|
.search-clear:hover { color: var(--text-secondary); }
|
|
|
|
.filter-group { position: relative; }
|
|
|
|
.filter-btn {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 0 10px; height: 28px;
|
|
background: var(--bg-input); border: 1.5px solid var(--border);
|
|
border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
|
cursor: pointer; white-space: nowrap;
|
|
}
|
|
.filter-btn:hover { border-color: var(--border-strong); background: var(--bg-card-hover); }
|
|
.filter-btn.active { background: #EFF6FF; border-color: #93C5FD; color: #1D4ED8; }
|
|
|
|
.filter-count {
|
|
background: #3B82F6; color: #fff;
|
|
border-radius: 10px; padding: 0 5px;
|
|
font-size: 10px; font-weight: 700; line-height: 16px;
|
|
}
|
|
.filter-chevron { font-size: 9px; opacity: 0.5; }
|
|
|
|
.filter-drop {
|
|
position: absolute; top: calc(100% + 4px); left: 0; z-index: 50;
|
|
background: var(--bg-card); border: 1.5px solid var(--border); border-radius: 10px;
|
|
padding: 6px; min-width: 190px; max-height: 260px; overflow-y: auto;
|
|
box-shadow: var(--shadow-drop);
|
|
}
|
|
|
|
.filter-opt {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 5px 8px; border-radius: 6px; cursor: pointer;
|
|
font-size: 12px; color: var(--text-primary); white-space: nowrap; user-select: none;
|
|
}
|
|
.filter-opt:hover { background: var(--bg-page); }
|
|
.filter-opt input[type="checkbox"] { cursor: pointer; width: 13px; height: 13px; flex-shrink: 0; }
|
|
|
|
.vlan-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
|
|
.filter-reset {
|
|
display: inline-flex; align-items: center;
|
|
padding: 0 10px; height: 28px;
|
|
background: #FEE2E2; color: #B91C1C;
|
|
border: none; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
|
}
|
|
.filter-reset:hover { background: #FECACA; }
|
|
|
|
.filter-results { font-size: 12px; color: var(--text-faint); font-weight: 500; }
|
|
|
|
/* ── Grille équipements ───────────────────────────────────────────────────── */
|
|
.devices-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
.device-card {
|
|
background: var(--bg-card); border-radius: 12px; padding: 16px;
|
|
box-shadow: var(--shadow-card); border: 1.5px solid var(--border);
|
|
}
|
|
|
|
.device-header { display: flex; align-items: flex-start; gap: 12px; }
|
|
.device-icon {
|
|
width: 40px; height: 40px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 20px; flex-shrink: 0;
|
|
}
|
|
.icon-livebox { background: #FEE2E2; color: #B91C1C; }
|
|
.icon-gateway { background: #FEF3C7; color: #92400E; }
|
|
.icon-server { background: #DBEAFE; color: #1D4ED8; }
|
|
.icon-switch { background: #D1FAE5; color: #065F46; }
|
|
.icon-router { background: #FFEDD5; color: #C2410C; }
|
|
.icon-nas { background: #EDE9FE; color: #6D28D9; }
|
|
.icon-camera { background: #E0F2FE; color: #0369A1; }
|
|
.icon-temperature { background: #F0F9FF; color: #0284C7; }
|
|
.icon-sensor { background: #ECFCCB; color: #3F6212; }
|
|
.icon-hub { background: #EEF2FF; color: #3730A3; }
|
|
.icon-smart_plug { background: #FFF7ED; color: #C2410C; }
|
|
.icon-alarm { background: #FEF2F2; color: #B91C1C; }
|
|
.icon-light { background: #FEFCE8; color: #854D0E; }
|
|
.icon-doorbell { background: #FDF4FF; color: #7E22CE; }
|
|
.icon-access_point { background: #CCFBF1; color: #0F766E; }
|
|
.icon-desktop { background: #E0E7FF; color: #4338CA; }
|
|
.icon-laptop { background: #DCFCE7; color: #15803D; }
|
|
.icon-smart_tv { background: #FCE7F3; color: #9D174D; }
|
|
.icon-printer { background: #ECFEFF; color: #0E7490; }
|
|
.icon-smartphone { background: #FFFBEB; color: #B45309; }
|
|
.icon-other { background: #F1F5F9; color: #475569; }
|
|
|
|
.device-info { flex: 1; min-width: 0; }
|
|
.device-name { font-weight: 700; font-size: 15px; color: var(--text-primary); }
|
|
.device-meta { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
|
|
|
.badge {
|
|
font-size: 11px; font-weight: 600; padding: 2px 8px;
|
|
border-radius: 20px; color: #fff;
|
|
}
|
|
.badge-server { background: #3B82F6; }
|
|
.badge-switch { background: #10B981; }
|
|
.badge-router { background: #F97316; }
|
|
.badge-nas { background: #8B5CF6; }
|
|
.badge-gateway { background: #6366F1; }
|
|
.badge-livebox { background: #EF4444; }
|
|
.badge-camera { background: #0284C7; }
|
|
.badge-temperature { background: #0EA5E9; }
|
|
.badge-sensor { background: #65A30D; }
|
|
.badge-hub { background: #4F46E5; }
|
|
.badge-smart_plug { background: #EA580C; }
|
|
.badge-alarm { background: #DC2626; }
|
|
.badge-light { background: #CA8A04; }
|
|
.badge-doorbell { background: #A21CAF; }
|
|
.badge-access_point { background: #14B8A6; }
|
|
.badge-desktop { background: #4F46E5; }
|
|
.badge-laptop { background: #16A34A; }
|
|
.badge-smart_tv { background: #DB2777; }
|
|
.badge-printer { background: #0891B2; }
|
|
.badge-smartphone { background: #D97706; }
|
|
.badge-other { background: #94A3B8; }
|
|
.badge-virt { background: #1E293B; }
|
|
.badge-virt-baremetal { background: #475569; }
|
|
.badge-virt-lxc { background: #0369A1; }
|
|
.badge-virt-qemu { background: #7C3AED; }
|
|
|
|
.badge-brand {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-size: 11px; font-weight: 600; padding: 2px 7px;
|
|
border-radius: 20px; color: #fff;
|
|
}
|
|
|
|
.device-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
|
.btn-icon {
|
|
width: 28px; height: 28px; border: none; border-radius: 6px;
|
|
background: var(--bg-chip); color: var(--text-secondary); font-size: 14px; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.btn-icon:hover { background: var(--border-strong); color: var(--text-primary); }
|
|
.btn-icon.danger:hover { background: rgba(239,68,68,0.15); color: #EF4444; }
|
|
|
|
.device-desc { font-size: 13px; color: var(--text-muted); margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
|
|
.device-url {
|
|
display: inline-block; margin-top: 6px;
|
|
font-size: 12px; color: #3B82F6; font-family: monospace;
|
|
text-decoration: none; word-break: break-all;
|
|
}
|
|
.device-url:hover { text-decoration: underline; }
|
|
|
|
.interfaces { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
.iface { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; }
|
|
.iface-name { font-weight: 600; color: var(--text-secondary); font-family: monospace; }
|
|
.iface-ip { color: var(--text-muted); font-family: monospace; }
|
|
.iface-vlan {
|
|
color: #fff; padding: 1px 7px; border-radius: 12px;
|
|
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>
|