Initial commit — Stupid Simple Network Inventory
Application web d'inventaire réseau manuel avec FastAPI, Vue 3 et Docker. Inclut l'authentification JWT, la découverte ICMP, et la topologie en cards CSS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="topology-page">
|
||||
|
||||
<!-- Barre d'outils -->
|
||||
<div class="topo-toolbar">
|
||||
<button class="ping-btn" :class="{ pinging }" :disabled="pinging" @click="pingAll">
|
||||
<span class="ping-btn-dot" :class="pingBtnDotClass"></span>
|
||||
{{ pinging ? t('pinging') : pingDone ? t('refreshPing') : t('ping') }}
|
||||
</button>
|
||||
<span v-if="pingDone && !pinging" class="ping-summary">
|
||||
{{ pingUpCount }} ↑ {{ pingDownCount }} ↓
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cartes spéciales en haut : WAN et Passerelle -->
|
||||
<div v-if="liveboxDevices.length || gatewayDevices.length" class="special-row">
|
||||
|
||||
<div v-if="liveboxDevices.length" class="special-card special-wan">
|
||||
<div class="special-header">
|
||||
<span class="special-icon">🌐</span>
|
||||
<span class="special-title">{{ t('wan') }}</span>
|
||||
</div>
|
||||
<div class="special-body">
|
||||
<div v-for="d in liveboxDevices" :key="d.id" class="device-chip chip-livebox">
|
||||
<span class="chip-icon"><DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only /></span>
|
||||
<div class="chip-body">
|
||||
<span class="chip-name">{{ d.name }}</span>
|
||||
<div class="chip-sub">
|
||||
<span v-for="iface in d.interfaces" :key="iface.id">
|
||||
<code v-if="iface.ip_address" class="chip-ip">{{ iface.ip_address }}</code>
|
||||
</span>
|
||||
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="gatewayDevices.length" class="special-card special-gw">
|
||||
<div class="special-header">
|
||||
<span class="special-icon">⬡</span>
|
||||
<span class="special-title">{{ t('gateway') }}</span>
|
||||
</div>
|
||||
<div class="special-body">
|
||||
<div v-for="d in gatewayDevices" :key="d.id" class="device-chip chip-gateway">
|
||||
<span class="chip-icon"><DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only /></span>
|
||||
<div class="chip-body">
|
||||
<span class="chip-name">{{ d.name }}</span>
|
||||
<div class="chip-sub">
|
||||
<span v-if="d.description" class="chip-iface">{{ d.description }}</span>
|
||||
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Grille des réseaux -->
|
||||
<div class="vlan-grid">
|
||||
<div
|
||||
v-for="vlan in props.vlans"
|
||||
:key="vlan.id"
|
||||
class="vlan-card"
|
||||
:style="{ '--vlan-color': vlan.color }"
|
||||
>
|
||||
<div class="vlan-header">
|
||||
<div class="vlan-id-badge">{{ vlan.vlan_id != null ? 'VLAN ' + vlan.vlan_id : 'LAN' }}</div>
|
||||
<div class="vlan-meta">
|
||||
<span class="vlan-name">{{ vlan.name }}</span>
|
||||
<code v-if="vlan.cidr" class="vlan-cidr">{{ vlan.cidr }}</code>
|
||||
</div>
|
||||
<span class="vlan-count">{{ vlanMembers(vlan.id).length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="vlan-body">
|
||||
<div v-if="vlanMembers(vlan.id).length === 0" class="empty-vlan">
|
||||
{{ t('noDevice') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in vlanMembers(vlan.id)"
|
||||
:key="item.device.id"
|
||||
class="device-chip"
|
||||
:class="'chip-' + chipRole(item.device)"
|
||||
>
|
||||
<span class="chip-icon">
|
||||
<DeviceIcon :device-type="item.device.type" :is-livebox="item.device.is_livebox" :is-gateway="item.device.is_gateway" :name="item.device.name" :description="item.device.description" :size="16" type-only />
|
||||
</span>
|
||||
<div class="chip-body">
|
||||
<span class="chip-name">{{ item.device.name }}</span>
|
||||
<div class="chip-sub">
|
||||
<code v-if="item.ip" class="chip-ip">{{ item.ip }}</code>
|
||||
<span v-if="item.ifaceName" class="chip-iface">{{ item.ifaceName }}</span>
|
||||
<svg v-for="b in detectBrands(item.device.name, item.device.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip-tags">
|
||||
<span v-if="pingStatus[item.device.id]" class="ping-dot" :class="'ping-' + pingStatus[item.device.id]" :title="pingStatus[item.device.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||
<a v-if="item.device.url" :href="item.device.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||
<span v-if="item.device.is_gateway" class="chip-tag tag-gw">GW</span>
|
||||
<span v-if="item.device.virt_type === 'lxc'" class="chip-tag tag-lxc">LXC</span>
|
||||
<span v-if="item.device.virt_type === 'qemu'" class="chip-tag tag-vm">VM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone non assigné -->
|
||||
<div
|
||||
v-if="untaggedDevices.length"
|
||||
class="vlan-card untagged"
|
||||
>
|
||||
<div class="vlan-header">
|
||||
<div class="vlan-id-badge untagged-badge">—</div>
|
||||
<div class="vlan-meta">
|
||||
<span class="vlan-name">{{ t('unassigned') }}</span>
|
||||
</div>
|
||||
<span class="vlan-count">{{ untaggedDevices.length }}</span>
|
||||
</div>
|
||||
<div class="vlan-body">
|
||||
<div
|
||||
v-for="d in untaggedDevices"
|
||||
:key="d.id"
|
||||
class="device-chip"
|
||||
:class="'chip-' + chipRole(d)"
|
||||
>
|
||||
<span class="chip-icon">
|
||||
<DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only />
|
||||
</span>
|
||||
<div class="chip-body">
|
||||
<span class="chip-name">{{ d.name }}</span>
|
||||
<div class="chip-sub">
|
||||
<span v-if="d.description" class="chip-iface">{{ d.description }}</span>
|
||||
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
|
||||
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.vlans.length === 0 && props.devices.length === 0" class="empty-state">
|
||||
<div class="empty-icon">⬡</div>
|
||||
<p>{{ t('noDevices') }}</p>
|
||||
<p class="empty-hint">{{ t('noDevicesHint') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import DeviceIcon from './DeviceIcon.vue'
|
||||
import { detectBrands } from '../brandIcons.js'
|
||||
import { discoveryApi } from '../api.js'
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
devices: { type: Array, default: () => [] },
|
||||
vlans: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const liveboxDevices = computed(() => props.devices.filter(d => d.is_livebox))
|
||||
const gatewayDevices = computed(() => props.devices.filter(d => d.is_gateway))
|
||||
|
||||
function vlanMembers(vlanId) {
|
||||
const result = []
|
||||
for (const device of props.devices) {
|
||||
const iface = device.interfaces.find(i => i.vlan_id === vlanId)
|
||||
if (iface) {
|
||||
result.push({
|
||||
device,
|
||||
ip: iface.ip_address || '',
|
||||
ifaceName: iface.name || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => ipToInt(a.ip) - ipToInt(b.ip))
|
||||
}
|
||||
|
||||
const untaggedDevices = computed(() =>
|
||||
props.devices.filter(d => {
|
||||
if (d.is_livebox || d.is_gateway) return false
|
||||
return !d.interfaces.some(i => i.vlan_id)
|
||||
}).sort((a, b) => ipToInt(a.interfaces[0]?.ip_address) - ipToInt(b.interfaces[0]?.ip_address))
|
||||
)
|
||||
|
||||
function chipRole(device) {
|
||||
if (device.is_livebox) return 'livebox'
|
||||
if (device.is_gateway) return 'gateway'
|
||||
return device.type || 'other'
|
||||
}
|
||||
|
||||
// ── Ping ──────────────────────────────────────────────────────────────────────
|
||||
const pingStatus = ref({})
|
||||
const pinging = ref(false)
|
||||
const pingDone = ref(false)
|
||||
|
||||
const pingUpCount = computed(() => Object.values(pingStatus.value).filter(s => s === 'up').length)
|
||||
const pingDownCount = computed(() => Object.values(pingStatus.value).filter(s => s === 'down').length)
|
||||
const pingBtnDotClass = computed(() => {
|
||||
if (pinging.value) return 'dot-pinging'
|
||||
if (!pingDone.value) return 'dot-idle'
|
||||
return pingDownCount.value > 0 ? 'dot-warn' : 'dot-ok'
|
||||
})
|
||||
|
||||
async function pingAll() {
|
||||
const ipToDevices = {}
|
||||
for (const d of props.devices) {
|
||||
for (const i of d.interfaces) {
|
||||
if (i.ip_address) {
|
||||
if (!ipToDevices[i.ip_address]) ipToDevices[i.ip_address] = []
|
||||
ipToDevices[i.ip_address].push(d.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const ips = Object.keys(ipToDevices)
|
||||
if (!ips.length) return
|
||||
|
||||
pinging.value = true
|
||||
try {
|
||||
const { data } = await discoveryApi.ping(ips)
|
||||
const next = {}
|
||||
for (const { ip, alive } of data) {
|
||||
for (const id of (ipToDevices[ip] || [])) {
|
||||
if (alive || next[id] !== 'up') next[id] = alive ? 'up' : 'down'
|
||||
}
|
||||
}
|
||||
pingStatus.value = next
|
||||
pingDone.value = true
|
||||
} finally {
|
||||
pinging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topology-page {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-page);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
|
||||
.topo-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
|
||||
.ping-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 5px 14px; height: 30px;
|
||||
background: var(--bg-card); border: 1.5px solid var(--border);
|
||||
border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ping-btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-card-hover); }
|
||||
.ping-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.ping-btn.pinging { border-color: #93C5FD; color: #1D4ED8; }
|
||||
|
||||
.ping-btn-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.dot-idle { background: #CBD5E1; }
|
||||
.dot-pinging { background: #60A5FA; animation: pulse 1s infinite; }
|
||||
.dot-ok { background: #22C55E; }
|
||||
.dot-warn { background: #F97316; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.ping-summary { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* ── Dot ping ────────────────────────────────────────────────────────────── */
|
||||
.ping-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.ping-up { background: #22C55E; }
|
||||
.ping-down { background: #EF4444; }
|
||||
|
||||
/* ── Cartes spéciales (WAN + Passerelle) ─────────────────────────────────── */
|
||||
.special-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.special-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
min-width: 220px;
|
||||
flex: 1;
|
||||
}
|
||||
.special-wan { border-color: #FCA5A5; }
|
||||
.special-gw { border-color: #FCD34D; }
|
||||
|
||||
.special-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.special-wan .special-header { background: #FEF2F2; color: #B91C1C; border-bottom: 1px solid #FECACA; }
|
||||
.special-gw .special-header { background: #FFFBEB; color: #92400E; border-bottom: 1px solid #FDE68A; }
|
||||
|
||||
:global(html.dark .special-wan) { border-color: #7F1D1D; }
|
||||
:global(html.dark .special-gw) { border-color: #78350F; }
|
||||
:global(html.dark .special-wan .special-header) { background: #2A1515; color: #FCA5A5; border-bottom-color: #3D1F1F; }
|
||||
:global(html.dark .special-gw .special-header) { background: #2A200A; color: #FCD34D; border-bottom-color: #3D3010; }
|
||||
|
||||
.special-body {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Grille des réseaux ──────────────────────────────────────────────────── */
|
||||
.vlan-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Card réseau ─────────────────────────────────────────────────────────── */
|
||||
.vlan-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-top: 3px solid var(--vlan-color, #CBD5E1);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.vlan-card.untagged {
|
||||
--vlan-color: #CBD5E1;
|
||||
border-style: dashed;
|
||||
border-top-style: solid;
|
||||
}
|
||||
|
||||
.vlan-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-thead);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.vlan-id-badge {
|
||||
background: var(--vlan-color, #CBD5E1);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 2px 9px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.untagged-badge { background: #94A3B8; }
|
||||
|
||||
.vlan-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.vlan-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.vlan-cidr {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vlan-count {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--vlan-color, #CBD5E1);
|
||||
background: color-mix(in srgb, var(--vlan-color) 10%, transparent);
|
||||
border-radius: 20px;
|
||||
padding: 1px 8px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vlan-body {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vlan-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.empty-vlan {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
font-style: italic;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* ── Device chip ─────────────────────────────────────────────────────────── */
|
||||
.device-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-chip);
|
||||
border: 1px solid var(--border);
|
||||
transition: background 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.device-chip:hover {
|
||||
background: var(--bg-chip-hover);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 7px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.chip-server .chip-icon { background: #DBEAFE; color: #1D4ED8; }
|
||||
.chip-nas .chip-icon { background: #EDE9FE; color: #6D28D9; }
|
||||
.chip-switch .chip-icon { background: #D1FAE5; color: #065F46; }
|
||||
.chip-router .chip-icon { background: #FFEDD5; color: #C2410C; }
|
||||
.chip-access_point .chip-icon { background: #CCFBF1; color: #0F766E; }
|
||||
.chip-gateway .chip-icon { background: #FEF3C7; color: #B45309; }
|
||||
.chip-livebox .chip-icon { background: #FEE2E2; color: #B91C1C; }
|
||||
.chip-camera .chip-icon { background: #E0F2FE; color: #0369A1; }
|
||||
.chip-temperature .chip-icon { background: #F0F9FF; color: #0284C7; }
|
||||
.chip-sensor .chip-icon { background: #ECFCCB; color: #3F6212; }
|
||||
.chip-hub .chip-icon { background: #EEF2FF; color: #3730A3; }
|
||||
.chip-smart_plug .chip-icon { background: #FFF7ED; color: #C2410C; }
|
||||
.chip-alarm .chip-icon { background: #FEF2F2; color: #B91C1C; }
|
||||
.chip-light .chip-icon { background: #FEFCE8; color: #854D0E; }
|
||||
.chip-doorbell .chip-icon { background: #FDF4FF; color: #7E22CE; }
|
||||
.chip-desktop .chip-icon { background: #E0E7FF; color: #4338CA; }
|
||||
.chip-laptop .chip-icon { background: #DCFCE7; color: #15803D; }
|
||||
.chip-other .chip-icon { background: #F1F5F9; color: #64748B; }
|
||||
|
||||
.chip-body { flex: 1; min-width: 0; }
|
||||
.chip-name {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
|
||||
}
|
||||
.chip-sub {
|
||||
display: flex; align-items: center; gap: 5px; margin-top: 1px; flex-wrap: wrap;
|
||||
}
|
||||
.chip-brand-icon { display: inline-block; flex-shrink: 0; opacity: 0.85; }
|
||||
.chip-ip {
|
||||
font-size: 11px; color: var(--chip-ip-color);
|
||||
font-family: monospace; background: var(--chip-ip-bg);
|
||||
padding: 0px 4px; border-radius: 3px;
|
||||
}
|
||||
.chip-iface {
|
||||
font-size: 10px; color: var(--text-faint);
|
||||
font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip-tags { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; }
|
||||
.chip-tag {
|
||||
font-size: 9px; font-weight: 800; padding: 1px 5px;
|
||||
border-radius: 4px; text-align: center; letter-spacing: 0.03em;
|
||||
}
|
||||
.tag-gw { background: #FEF3C7; color: #B45309; }
|
||||
.tag-lxc { background: #DBEAFE; color: #1D4ED8; }
|
||||
.tag-vm { background: #EDE9FE; color: #6D28D9; }
|
||||
.tag-link { background: #F0FDF4; color: #15803D; text-decoration: none; }
|
||||
.tag-link:hover { background: #DCFCE7; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; color: var(--text-faint);
|
||||
}
|
||||
.empty-icon { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 16px; margin-bottom: 6px; }
|
||||
.empty-hint { font-size: 13px; color: var(--text-faint); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user