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,12 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY rate_limit.conf /etc/nginx/conf.d/00_rate_limit.conf
|
||||
EXPOSE 8080
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Stupid Simple Network Inventory</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ── Security headers ─────────────────────────────────────────────────────
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# CSP for a Vue 3 / Vite SPA:
|
||||
# - script-src 'self' + hash : Vite bundles (self) + one Vite-generated inline
|
||||
# script (modulepreload fallback in index.html, stable hash across builds)
|
||||
# - style-src 'self' 'unsafe-inline' : inline style="" attributes used by Vue
|
||||
# - img-src 'self' data: : SVG/PNG assets + possible data URIs
|
||||
# - connect-src 'self' : API calls to /api/ (same origin via proxy)
|
||||
# - object-src 'none' : no plugins
|
||||
# - frame-ancestors 'none' : prevents embedding in iframes (replaces X-Frame-Options for CSP-aware browsers)
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-ZswfTY7H35rbv8WC7NXBoiC7WNu86vSzCDChNWwZZDM='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self';" always;
|
||||
|
||||
location = /api/auth/login {
|
||||
limit_req zone=login burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://backend:8000/api/auth/login;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "network-topology",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.8",
|
||||
"axios": "^1.6.2",
|
||||
"lucide-vue-next": "^0.460.0",
|
||||
"simple-icons": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<circle cx="4" cy="6" r="2"/>
|
||||
<circle cx="20" cy="6" r="2"/>
|
||||
<circle cx="4" cy="18" r="2"/>
|
||||
<circle cx="20" cy="18" r="2"/>
|
||||
<line x1="6" y1="6" x2="10" y2="11"/>
|
||||
<line x1="18" y1="6" x2="14" y2="11"/>
|
||||
<line x1="6" y1="18" x2="10" y2="13"/>
|
||||
<line x1="18" y1="18" x2="14" y2="13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1 @@
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
|
||||
@@ -0,0 +1,477 @@
|
||||
<template>
|
||||
<div class="app-root">
|
||||
<LoginPage v-if="!isAuthenticated" @login="onLogin" />
|
||||
|
||||
<AccountModal
|
||||
v-else-if="mustChangePassword"
|
||||
:forced="true"
|
||||
@updated="onAccountUpdated"
|
||||
/>
|
||||
|
||||
<div v-else class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/>
|
||||
<circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/>
|
||||
<line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/>
|
||||
<line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/>
|
||||
</svg>
|
||||
<span>Stupid Simple Network Inventory</span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['nav-btn', { active: view === tab.id }]"
|
||||
@click="view = tab.id"
|
||||
>
|
||||
<span class="nav-icon">{{ tab.icon }}</span>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="discovery-btn-wrap">
|
||||
<button class="btn-discovery" @click="showDiscovery = true">
|
||||
{{ t('discovery') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-val">{{ vlans.length }}</span>
|
||||
<span class="stat-lbl">{{ t('statsNetworks') }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-val">{{ devices.length }}</span>
|
||||
<span class="stat-lbl">{{ t('statsDevices') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-export" @click="exportJson">{{ t('exportJson') }}</button>
|
||||
<label class="btn-export">
|
||||
{{ t('importJson') }}
|
||||
<input type="file" accept=".json" @change="importJson" style="display:none" />
|
||||
</label>
|
||||
|
||||
<!-- Contrôles thème + langue -->
|
||||
<div class="settings-row">
|
||||
<button class="settings-btn" @click="toggleTheme" :title="theme === 'dark' ? t('lightTheme') : t('darkTheme')">
|
||||
{{ theme === 'dark' ? '☀' : '☾' }}
|
||||
</button>
|
||||
<div class="lang-switcher">
|
||||
<button
|
||||
v-for="lang in langs"
|
||||
:key="lang"
|
||||
class="lang-btn"
|
||||
:class="{ active: locale === lang }"
|
||||
@click="setLocale(lang)"
|
||||
>{{ lang }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compte utilisateur -->
|
||||
<div class="user-row">
|
||||
<button class="user-btn" @click="showAccount = true" :title="t('accountSettings')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
<span class="user-name">{{ currentUsername }}</span>
|
||||
</button>
|
||||
<button class="logout-btn" @click="logout" :title="t('logout')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<div v-if="error" class="toast-error">{{ error }}</div>
|
||||
<DiscoveryModal
|
||||
v-if="showDiscovery"
|
||||
:vlans="vlans"
|
||||
:devices="devices"
|
||||
@close="showDiscovery = false"
|
||||
@refresh="loadAll"
|
||||
/>
|
||||
<AccountModal
|
||||
v-if="showAccount"
|
||||
@close="showAccount = false"
|
||||
@updated="onAccountUpdated"
|
||||
/>
|
||||
<TopologyGraph
|
||||
v-if="view === 'topology'"
|
||||
:devices="devices"
|
||||
:vlans="vlans"
|
||||
/>
|
||||
<VlanManager v-if="view === 'vlans'" :vlans="vlans" @refresh="loadAll" />
|
||||
<DeviceManager v-if="view === 'devices'" :devices="devices" :vlans="vlans" @refresh="loadAll" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { vlansApi, devicesApi } from './api.js'
|
||||
import { t, locale, setLocale } from './i18n.js'
|
||||
import { theme, toggleTheme } from './theme.js'
|
||||
import { isAuthenticated, currentUsername, mustChangePassword, setAuth, clearAuth } from './auth.js'
|
||||
import TopologyGraph from './components/TopologyGraph.vue'
|
||||
import VlanManager from './components/VlanManager.vue'
|
||||
import DeviceManager from './components/DeviceManager.vue'
|
||||
import DiscoveryModal from './components/DiscoveryModal.vue'
|
||||
import LoginPage from './components/LoginPage.vue'
|
||||
import AccountModal from './components/AccountModal.vue'
|
||||
|
||||
const view = ref('topology')
|
||||
const vlans = ref([])
|
||||
const devices = ref([])
|
||||
const error = ref('')
|
||||
const showDiscovery = ref(false)
|
||||
const showAccount = ref(false)
|
||||
const langs = ['fr', 'en', 'es']
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'topology', label: t('tabTopology'), icon: '■' },
|
||||
{ id: 'vlans', label: t('tabNetworks'), icon: '◆' },
|
||||
{ id: 'devices', label: t('tabDevices'), icon: '▣' },
|
||||
])
|
||||
|
||||
function onLogin({ token, username, mustChangePassword: mcp }) {
|
||||
setAuth(token, username, mcp)
|
||||
if (!mcp) loadAll()
|
||||
}
|
||||
|
||||
function onAccountUpdated({ token, username, mustChangePassword: mcp }) {
|
||||
setAuth(token, username, mcp || false)
|
||||
if (!mcp) loadAll()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [v, d] = await Promise.all([
|
||||
vlansApi.list(),
|
||||
devicesApi.list(),
|
||||
])
|
||||
vlans.value = v.data
|
||||
devices.value = d.data
|
||||
} catch (e) {
|
||||
if (e.response?.status !== 401) {
|
||||
showError(t('loadError') + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
error.value = msg
|
||||
setTimeout(() => { error.value = '' }, 4000)
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
const data = JSON.stringify({ vlans: vlans.value, devices: devices.value }, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `topology-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function importJson(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
showError(t('importTooLarge'))
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
if (typeof data !== 'object' || Array.isArray(data) || data === null)
|
||||
throw new Error('Invalid format: expected a JSON object')
|
||||
if (data.vlans !== undefined && !Array.isArray(data.vlans))
|
||||
throw new Error('Invalid format: vlans must be an array')
|
||||
if (data.devices !== undefined && !Array.isArray(data.devices))
|
||||
throw new Error('Invalid format: devices must be an array')
|
||||
for (const vlan of (data.vlans || [])) {
|
||||
await vlansApi.create({ vlan_id: vlan.vlan_id, name: vlan.name, cidr: vlan.cidr, color: vlan.color }).catch(() => {})
|
||||
}
|
||||
await loadAll()
|
||||
const refreshedVlans = vlans.value
|
||||
for (const device of (data.devices || [])) {
|
||||
const ifaces = (device.interfaces || []).map(i => {
|
||||
const matchedVlan = refreshedVlans.find(v => v.vlan_id === (data.vlans || []).find(ov => ov.id === i.vlan_id)?.vlan_id)
|
||||
return { ...i, vlan_id: matchedVlan?.id || null }
|
||||
})
|
||||
await devicesApi.create({ ...device, interfaces: ifaces }).catch(() => {})
|
||||
}
|
||||
await loadAll()
|
||||
e.target.value = ''
|
||||
} catch (err) {
|
||||
showError(t('importFailed') + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value && !mustChangePassword.value) loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
input, select, textarea { font-family: inherit; }
|
||||
|
||||
/* ── CSS variables — light (default) ───────────────────────────────────── */
|
||||
:root {
|
||||
--bg-page: #F1F5F9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #F8FAFC;
|
||||
--bg-input: #ffffff;
|
||||
--bg-thead: #F8FAFC;
|
||||
--bg-chip: #F8FAFC;
|
||||
--bg-chip-hover: #F1F5F9;
|
||||
--border: #E2E8F0;
|
||||
--border-strong: #CBD5E1;
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748B;
|
||||
--text-faint: #94A3B8;
|
||||
--chip-ip-bg: #E2E8F0;
|
||||
--chip-ip-color: #475569;
|
||||
--modal-overlay: rgba(0,0,0,0.4);
|
||||
--shadow-card: 0 1px 4px rgba(0,0,0,0.07);
|
||||
--shadow-modal: 0 20px 60px rgba(0,0,0,0.2);
|
||||
--shadow-drop: 0 8px 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* ── CSS variables — dark ───────────────────────────────────────────────── */
|
||||
html.dark {
|
||||
--bg-page: #0F172A;
|
||||
--bg-card: #1E293B;
|
||||
--bg-card-hover: #263548;
|
||||
--bg-input: #1E293B;
|
||||
--bg-thead: #162032;
|
||||
--bg-chip: #263548;
|
||||
--bg-chip-hover: #2D3F55;
|
||||
--border: #334155;
|
||||
--border-strong: #475569;
|
||||
--text-primary: #F1F5F9;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
--text-faint: #475569;
|
||||
--chip-ip-bg: #334155;
|
||||
--chip-ip-color: #94A3B8;
|
||||
--modal-overlay: rgba(0,0,0,0.65);
|
||||
--shadow-card: 0 1px 4px rgba(0,0,0,0.3);
|
||||
--shadow-modal: 0 20px 60px rgba(0,0,0,0.5);
|
||||
--shadow-drop: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-page); }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
background: #1E293B;
|
||||
color: #CBD5E1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.dark .sidebar { background: #020617; }
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
color: #F8FAFC;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
nav {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
color: #94A3B8;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: #334155; color: #F1F5F9; }
|
||||
.nav-btn.active { background: #3B82F6; color: #fff; font-weight: 600; }
|
||||
.nav-icon { font-size: 12px; }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #334155;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.stat-val { font-size: 18px; font-weight: 700; color: #F8FAFC; }
|
||||
.stat-lbl { font-size: 10px; color: #64748B; }
|
||||
|
||||
.discovery-btn-wrap { padding: 8px; }
|
||||
.btn-discovery {
|
||||
display: block; width: 100%;
|
||||
padding: 10px 8px; background: #1D4ED8; color: #fff;
|
||||
border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; transition: background 0.15s; text-align: center;
|
||||
}
|
||||
.btn-discovery:hover { background: #2563EB; }
|
||||
|
||||
.btn-export {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: #334155;
|
||||
color: #CBD5E1;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-export:hover { background: #475569; color: #F8FAFC; }
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 28px; height: 28px;
|
||||
background: #334155; color: #CBD5E1;
|
||||
border: none; border-radius: 6px;
|
||||
font-size: 14px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.settings-btn:hover { background: #475569; color: #F8FAFC; }
|
||||
|
||||
.lang-switcher {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
.lang-btn {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
background: #334155; color: #94A3B8;
|
||||
border: none; border-radius: 5px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.lang-btn:hover { background: #475569; color: #F8FAFC; }
|
||||
.lang-btn.active { background: #3B82F6; color: #fff; }
|
||||
|
||||
/* ── User row ───────────────────────────────────────────────────────────── */
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-top: 1px solid #334155;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.user-btn {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #334155; color: #94A3B8;
|
||||
border: none; border-radius: 6px;
|
||||
font-size: 12px; text-align: left;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.user-btn:hover { background: #475569; color: #F8FAFC; }
|
||||
.user-name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 28px; height: 28px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #334155; color: #94A3B8;
|
||||
border: none; border-radius: 6px;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.logout-btn:hover { background: #7F1D1D; color: #FCA5A5; }
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #EF4444;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios'
|
||||
import { getToken, clearAuth } from './auth.js'
|
||||
|
||||
const http = axios.create({ baseURL: '/api' })
|
||||
|
||||
http.interceptors.request.use(config => {
|
||||
const token = getToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401 && getToken()) {
|
||||
clearAuth()
|
||||
window.location.reload()
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
login: (username, password) => {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
return http.post('/auth/login', form)
|
||||
},
|
||||
updateAccount: (data) => http.put('/auth/account', data),
|
||||
me: () => http.get('/auth/me'),
|
||||
}
|
||||
|
||||
export const vlansApi = {
|
||||
list: () => http.get('/vlans/'),
|
||||
create: (data) => http.post('/vlans/', data),
|
||||
update: (id, data) => http.put(`/vlans/${id}`, data),
|
||||
remove: (id) => http.delete(`/vlans/${id}`),
|
||||
}
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => http.get('/devices/'),
|
||||
create: (data) => http.post('/devices/', data),
|
||||
update: (id, data) => http.put(`/devices/${id}`, data),
|
||||
remove: (id) => http.delete(`/devices/${id}`),
|
||||
}
|
||||
|
||||
export const discoveryApi = {
|
||||
scan: (data) => http.post('/discovery/scan', data),
|
||||
ping: (ips) => http.post('/discovery/ping', { ips }),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const _token = ref(localStorage.getItem('auth_token') || null)
|
||||
const _username = ref(localStorage.getItem('auth_username') || null)
|
||||
const _mustChange = ref(localStorage.getItem('auth_mustchange') === 'true')
|
||||
|
||||
export const isAuthenticated = computed(() => !!_token.value)
|
||||
export const currentUsername = computed(() => _username.value)
|
||||
export const mustChangePassword = computed(() => _mustChange.value)
|
||||
|
||||
export function setAuth(token, username, mustChange = false) {
|
||||
_token.value = token
|
||||
_username.value = username
|
||||
_mustChange.value = mustChange
|
||||
localStorage.setItem('auth_token', token)
|
||||
localStorage.setItem('auth_username', username)
|
||||
localStorage.setItem('auth_mustchange', String(mustChange))
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
_token.value = null
|
||||
_username.value = null
|
||||
_mustChange.value = false
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_username')
|
||||
localStorage.removeItem('auth_mustchange')
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return _token.value
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
siProxmox, siDocker,
|
||||
siSynology, siTruenas,
|
||||
siUbiquiti, siMikrotik, siCisco, siTplink, siAsus, siNetgear, siPfsense, siOpnsense, siOpenwrt,
|
||||
siApache, siTraefikproxy,
|
||||
siMariadb,
|
||||
siKubernetes,
|
||||
siDebian, siUbuntu,
|
||||
siAnsible,
|
||||
siDell, siHp,
|
||||
siRaspberrypi, siArduino,
|
||||
siNextcloud, siPaperlessngx, siUptimekuma, siMaterialformkdocs,
|
||||
siJellyfin, siHomeassistant, siPhilipshue, siXiaomi,
|
||||
siExcalidraw,
|
||||
siKde,
|
||||
} from 'simple-icons'
|
||||
|
||||
// Ordre : du plus spécifique au plus générique pour éviter les faux positifs.
|
||||
const BRANDS = [
|
||||
// Hyperviseurs / virtualisation
|
||||
{ kw: ['proxmox', 'pve'], icon: siProxmox },
|
||||
{ kw: ['docker'], icon: siDocker },
|
||||
|
||||
// NAS
|
||||
{ kw: ['synology', 'dsm'], icon: siSynology },
|
||||
{ kw: ['truenas', 'freenas'], icon: siTruenas },
|
||||
|
||||
// Réseau
|
||||
{ kw: ['ubiquiti', 'unifi', 'usg', 'udm'], icon: siUbiquiti },
|
||||
{ kw: ['mikrotik', 'routeros'], icon: siMikrotik },
|
||||
{ kw: ['cisco'], icon: siCisco },
|
||||
{ kw: ['tp-link', 'tplink', 'tp link'], icon: siTplink },
|
||||
{ kw: ['asus'], icon: siAsus },
|
||||
{ kw: ['netgear'], icon: siNetgear },
|
||||
{ kw: ['pfsense'], icon: siPfsense },
|
||||
{ kw: ['opnsense'], icon: siOpnsense },
|
||||
{ kw: ['openwrt'], icon: siOpenwrt },
|
||||
|
||||
// Serveurs web / proxy
|
||||
{ kw: ['apache', 'apache2', 'httpd'], icon: siApache },
|
||||
{ kw: ['traefik'], icon: siTraefikproxy },
|
||||
|
||||
// Bases de données
|
||||
{ kw: ['mariadb', 'maria db'], icon: siMariadb },
|
||||
|
||||
// Orchestration
|
||||
{ kw: ['kubernetes', 'k8s', 'kubectl', 'k3s'], icon: siKubernetes },
|
||||
|
||||
// OS / distros
|
||||
{ kw: ['debian'], icon: siDebian },
|
||||
{ kw: ['ubuntu'], icon: siUbuntu },
|
||||
|
||||
// Automatisation
|
||||
{ kw: ['ansible'], icon: siAnsible },
|
||||
|
||||
// Serveurs
|
||||
{ kw: ['dell', 'idrac', 'poweredge'], icon: siDell },
|
||||
{ kw: ['proliant', 'ilo', 'hewlett'], icon: siHp },
|
||||
|
||||
// SBC / DIY
|
||||
{ kw: ['raspberry', 'raspberrypi', 'rpi', 'raspi'], icon: siRaspberrypi },
|
||||
{ kw: ['arduino'], icon: siArduino },
|
||||
|
||||
// Environnements de bureau
|
||||
{ kw: ['kde', 'plasma', 'kde desktop'], icon: siKde },
|
||||
|
||||
// Outils
|
||||
{ kw: ['excalidraw'], icon: siExcalidraw },
|
||||
|
||||
// Productivité / self-hosted
|
||||
{ kw: ['nextcloud'], icon: siNextcloud },
|
||||
{ kw: ['paperless', 'paperless-ng', 'paperless-ngx'], icon: siPaperlessngx },
|
||||
{ kw: ['uptime-kuma', 'uptimekuma', 'uptime kuma'], icon: siUptimekuma },
|
||||
{ kw: ['mkdocs', 'material for mkdocs'], icon: siMaterialformkdocs },
|
||||
|
||||
// Médias / domotique
|
||||
{ kw: ['jellyfin'], icon: siJellyfin },
|
||||
{ kw: ['homeassistant', 'home assistant', 'hassio', 'hass'], icon: siHomeassistant },
|
||||
{ kw: ['philips hue', 'hue bridge', 'hue hub'], icon: siPhilipshue },
|
||||
{ kw: ['xiaomi', 'mi home', 'yeelight'], icon: siXiaomi },
|
||||
]
|
||||
|
||||
export function detectBrands(name, description) {
|
||||
const text = ((name || '') + ' ' + (description || '')).toLowerCase()
|
||||
if (!text.trim()) return []
|
||||
return BRANDS.filter(b => b.kw.some(kw => text.includes(kw))).map(b => b.icon)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="forced ? null : $emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ t('accountSettings') }}</h2>
|
||||
<button v-if="!forced" class="close-btn" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div v-if="forced" class="forced-warning">
|
||||
{{ t('mustChangePasswordWarning') }}
|
||||
</div>
|
||||
|
||||
<p class="current-user">{{ t('loginUsername') }} : <strong>{{ currentUsername }}</strong></p>
|
||||
|
||||
<div class="section-title">{{ t('newUsername') }}</div>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="newUsername"
|
||||
type="text"
|
||||
:placeholder="currentUsername"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
{{ t('newPassword') }}
|
||||
<span v-if="forced" class="required-star">*</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
:placeholder="forced ? '' : t('leaveBlankToKeep')"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="newPassword || forced">
|
||||
<div class="section-title">{{ t('confirmPassword') }}</div>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="section-title">{{ t('currentPassword') }} *</div>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
<p v-if="successMsg" class="success-msg">{{ successMsg }}</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button v-if="!forced" class="btn-cancel" @click="$emit('close')">{{ t('cancel') }}</button>
|
||||
<button class="btn-save" :disabled="saving" @click="save">
|
||||
{{ saving ? '…' : t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { authApi } from '../api.js'
|
||||
import { t } from '../i18n.js'
|
||||
import { currentUsername } from '../auth.js'
|
||||
|
||||
const props = defineProps({ forced: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
|
||||
const newUsername = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const currentPassword = ref('')
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const _USERNAME_RE = /^[a-zA-Z0-9._-]{1,64}$/
|
||||
|
||||
function _validateClient() {
|
||||
if (props.forced && !newPassword.value) {
|
||||
errorMsg.value = t('newPasswordRequired')
|
||||
return false
|
||||
}
|
||||
if (newUsername.value && !_USERNAME_RE.test(newUsername.value)) {
|
||||
errorMsg.value = t('usernameInvalid')
|
||||
return false
|
||||
}
|
||||
if (newPassword.value) {
|
||||
if (newPassword.value.length < 8) {
|
||||
errorMsg.value = t('passwordTooShort')
|
||||
return false
|
||||
}
|
||||
if (!/[a-zA-Z]/.test(newPassword.value) || !/[0-9]/.test(newPassword.value)) {
|
||||
errorMsg.value = t('passwordTooWeak')
|
||||
return false
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
errorMsg.value = t('passwordMismatch')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
|
||||
if (!currentPassword.value) {
|
||||
errorMsg.value = t('currentPassword') + ' ' + t('loginPassword').toLowerCase()
|
||||
return
|
||||
}
|
||||
if (!_validateClient()) return
|
||||
if (!newUsername.value && !newPassword.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { current_password: currentPassword.value }
|
||||
if (newUsername.value) payload.new_username = newUsername.value
|
||||
if (newPassword.value) payload.new_password = newPassword.value
|
||||
const { data } = await authApi.updateAccount(payload)
|
||||
successMsg.value = t('accountUpdated')
|
||||
emit('updated', {
|
||||
token: data.access_token,
|
||||
username: data.username,
|
||||
mustChangePassword: data.must_change_password || false,
|
||||
})
|
||||
newUsername.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
currentPassword.value = ''
|
||||
} catch (e) {
|
||||
const detail = e.response?.data?.detail || ''
|
||||
const MAP = {
|
||||
'Current password is incorrect': 'wrongPassword',
|
||||
'password_too_short': 'passwordTooShort',
|
||||
'password_too_weak': 'passwordTooWeak',
|
||||
'username_invalid': 'usernameInvalid',
|
||||
}
|
||||
errorMsg.value = t(MAP[detail] || 'saveError')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--modal-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 20px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
||||
|
||||
.close-btn {
|
||||
background: none; border: none; font-size: 16px;
|
||||
color: var(--text-muted); cursor: pointer; line-height: 1;
|
||||
}
|
||||
.close-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forced-warning {
|
||||
font-size: 13px;
|
||||
color: #92400E;
|
||||
background: #FEF3C7;
|
||||
border: 1px solid #FCD34D;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
:global(html.dark .forced-warning) {
|
||||
background: #2D2000;
|
||||
border-color: #5C4200;
|
||||
color: #FCD34D;
|
||||
}
|
||||
|
||||
.required-star { color: #EF4444; margin-left: 2px; }
|
||||
|
||||
.current-user {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.current-user strong { color: var(--text-primary); }
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.field input:focus { border-color: #3B82F6; }
|
||||
|
||||
.error-msg {
|
||||
font-size: 12px; color: #EF4444;
|
||||
background: #FEF2F2; border: 1px solid #FECACA;
|
||||
border-radius: 6px; padding: 7px 10px;
|
||||
}
|
||||
.success-msg {
|
||||
font-size: 12px; color: #15803D;
|
||||
background: #F0FDF4; border: 1px solid #BBF7D0;
|
||||
border-radius: 6px; padding: 7px 10px;
|
||||
}
|
||||
|
||||
:global(html.dark .error-msg) { background: #2A1515; border-color: #3D1F1F; }
|
||||
:global(html.dark .success-msg) { background: #0F2D1A; border-color: #1A4D2A; color: #4ADE80; }
|
||||
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 7px 16px; background: var(--bg-chip);
|
||||
color: var(--text-secondary); border: 1.5px solid var(--border);
|
||||
border-radius: 7px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--bg-chip-hover); }
|
||||
|
||||
.btn-save {
|
||||
padding: 7px 18px; background: #3B82F6;
|
||||
color: #fff; border: none; border-radius: 7px;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: #2563EB; }
|
||||
.btn-save:disabled { opacity: 0.6; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<!-- Simple Icons : logos des marques détectées (chips de topologie) -->
|
||||
<span v-if="!typeOnly && brandIcons.length > 0" class="brand-icons">
|
||||
<svg
|
||||
v-for="icon in brandIcons"
|
||||
:key="icon.title"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path :d="icon.path" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- Lucide : icône générique par type -->
|
||||
<component
|
||||
v-else
|
||||
:is="lucideIcon"
|
||||
:size="size"
|
||||
:stroke-width="1.75"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Server, Network, Wifi, Database, Globe, GitFork,
|
||||
Camera, Thermometer, Gauge, House, PlugZap, ShieldAlert,
|
||||
Lightbulb, BellRing, Antenna, Monitor, Laptop, Box,
|
||||
} from 'lucide-vue-next'
|
||||
import { detectBrands } from '../brandIcons.js'
|
||||
|
||||
const props = defineProps({
|
||||
deviceType: { type: String, default: 'other' },
|
||||
isGateway: { type: Boolean, default: false },
|
||||
isLivebox: { type: Boolean, default: false },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
size: { type: Number, default: 18 },
|
||||
typeOnly: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const brandIcons = computed(() => detectBrands(props.name, props.description))
|
||||
|
||||
const LUCIDE_MAP = {
|
||||
server: Server,
|
||||
switch: Network,
|
||||
router: Wifi,
|
||||
nas: Database,
|
||||
gateway: GitFork,
|
||||
livebox: Globe,
|
||||
camera: Camera,
|
||||
temperature: Thermometer,
|
||||
sensor: Gauge,
|
||||
hub: House,
|
||||
smart_plug: PlugZap,
|
||||
alarm: ShieldAlert,
|
||||
light: Lightbulb,
|
||||
doorbell: BellRing,
|
||||
access_point: Antenna,
|
||||
desktop: Monitor,
|
||||
laptop: Laptop,
|
||||
other: Box,
|
||||
}
|
||||
|
||||
const lucideIcon = computed(() => {
|
||||
if (props.isLivebox) return Globe
|
||||
if (props.isGateway) return GitFork
|
||||
return LUCIDE_MAP[props.deviceType] || Box
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.brand-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,689 @@
|
||||
<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: '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-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-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>
|
||||
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="header-title">
|
||||
<span class="header-icon">🔍</span>
|
||||
<h2>{{ t('autoDiscovery') }}</h2>
|
||||
</div>
|
||||
<button class="close-btn" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 1 : Configuration ── -->
|
||||
<template v-if="step === 'config'">
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('dnsServer') }}</label>
|
||||
<div class="input-hint">{{ t('dnsHint') }}</div>
|
||||
<input v-model="dnsServer" type="text" placeholder="192.168.1.16" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('vlansToScan') }}</label>
|
||||
<div class="input-hint">{{ t('vlansHint') }}</div>
|
||||
|
||||
<div v-if="scanableVlans.length === 0" class="warn-box">
|
||||
{{ t('noCidrWarning') }}
|
||||
</div>
|
||||
|
||||
<div class="vlan-checklist">
|
||||
<label
|
||||
v-for="vlan in props.vlans"
|
||||
:key="vlan.id"
|
||||
class="vlan-check"
|
||||
:class="{ disabled: !vlan.cidr }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="vlan.id"
|
||||
v-model="selectedVlanIds"
|
||||
:disabled="!vlan.cidr"
|
||||
/>
|
||||
<div class="vlan-check-body">
|
||||
<span class="vlan-badge" :style="{ background: vlan.color }">VLAN {{ vlan.vlan_id }}</span>
|
||||
<span class="vlan-check-name">{{ vlan.name }}</span>
|
||||
<code v-if="vlan.cidr" class="vlan-check-cidr">{{ vlan.cidr }}</code>
|
||||
<span v-else class="no-cidr">{{ t('noCidr') }}</span>
|
||||
<span v-if="vlan.cidr" class="host-count">
|
||||
(~{{ hostCount(vlan.cidr) }} hôtes)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="configError" class="error-box">{{ configError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="$emit('close')">{{ t('cancel') }}</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="selectedVlanIds.length === 0"
|
||||
@click="startScan"
|
||||
>
|
||||
{{ t('startDiscovery') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Step 2 : Scan en cours ── -->
|
||||
<template v-if="step === 'scanning'">
|
||||
<div class="modal-body scanning-body">
|
||||
<div class="spinner"></div>
|
||||
<p class="scan-msg">{{ t('scanning') }}</p>
|
||||
<p class="scan-detail">
|
||||
{{ totalToScan }} {{ t('scanAddresses') }} {{ selectedVlanIds.length }} {{ t('scanVlans') }}
|
||||
</p>
|
||||
<p class="scan-note">{{ t('scanNote') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Step 3 : Résultats ── -->
|
||||
<template v-if="step === 'results'">
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="results-summary">
|
||||
<span class="summary-found">{{ results.length }} {{ t('hostsFound') }}</span>
|
||||
<span class="summary-meta">
|
||||
{{ t('scanAddresses') }} {{ scanMeta.total_scanned }} {{ t('addressesScanned') }}
|
||||
— {{ scanMeta.duration_s }}s
|
||||
</span>
|
||||
<span v-if="newCount > 0" class="summary-new">{{ newCount }} {{ t('newHosts') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="results.length === 0" class="empty-results">
|
||||
{{ t('noHosts') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="results-table-wrap">
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" @change="toggleAll" :checked="allNewSelected" />
|
||||
</th>
|
||||
<th>{{ t('colIp') }}</th>
|
||||
<th>{{ t('colDns') }}</th>
|
||||
<th>VLAN</th>
|
||||
<th>{{ t('colStatus') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="host in results"
|
||||
:key="host.ip"
|
||||
:class="{ 'row-existing': isExisting(host.ip) }"
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="host.ip"
|
||||
v-model="selectedIps"
|
||||
:disabled="isExisting(host.ip)"
|
||||
/>
|
||||
</td>
|
||||
<td><code class="ip">{{ host.ip }}</code></td>
|
||||
<td class="hostname">
|
||||
<span v-if="host.hostname">{{ shortHostname(host.hostname) }}</span>
|
||||
<span v-else class="no-dns">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="vlan-badge sm" :style="{ background: vlanColor(host.vlan_id) }">
|
||||
VLAN {{ vlanNum(host.vlan_id) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="isExisting(host.ip)" class="status existing">{{ t('statusExisting') }}</span>
|
||||
<span v-else class="status new">{{ t('statusNew') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="importError" class="error-box">{{ importError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="step = 'config'">{{ t('newScan') }}</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="selectedIps.length === 0 || importing"
|
||||
@click="doImport"
|
||||
>
|
||||
{{ importing ? t('importingBtn') : `${t('importBtn')} (${selectedIps.length})` }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { devicesApi, discoveryApi } from '../api.js'
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
const props = defineProps({
|
||||
vlans: { type: Array, default: () => [] },
|
||||
devices: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'refresh'])
|
||||
|
||||
const step = ref('config')
|
||||
const dnsServer = ref('192.168.1.16')
|
||||
const selectedVlanIds = ref([])
|
||||
const results = ref([])
|
||||
const selectedIps = ref([])
|
||||
const scanMeta = ref({ total_scanned: 0, duration_s: 0 })
|
||||
const configError = ref('')
|
||||
const importError = ref('')
|
||||
const importing = ref(false)
|
||||
|
||||
const scanableVlans = computed(() => props.vlans.filter(v => v.cidr))
|
||||
|
||||
const existingIps = computed(() => {
|
||||
const ips = new Set()
|
||||
for (const d of props.devices)
|
||||
for (const i of d.interfaces)
|
||||
if (i.ip_address) ips.add(i.ip_address.trim())
|
||||
return ips
|
||||
})
|
||||
|
||||
const totalToScan = computed(() => {
|
||||
let n = 0
|
||||
for (const id of selectedVlanIds.value) {
|
||||
const vlan = props.vlans.find(v => v.id === id)
|
||||
if (vlan?.cidr) n += hostCount(vlan.cidr)
|
||||
}
|
||||
return n
|
||||
})
|
||||
|
||||
const newCount = computed(() =>
|
||||
results.value.filter(h => !isExisting(h.ip)).length
|
||||
)
|
||||
|
||||
const allNewSelected = computed(() => {
|
||||
const newHosts = results.value.filter(h => !isExisting(h.ip))
|
||||
return newHosts.length > 0 && newHosts.every(h => selectedIps.value.includes(h.ip))
|
||||
})
|
||||
|
||||
function hostCount(cidr) {
|
||||
try {
|
||||
const parts = cidr.split('/')
|
||||
const prefix = parseInt(parts[1])
|
||||
if (prefix >= 31) return 0
|
||||
return Math.pow(2, 32 - prefix) - 2
|
||||
} catch { return 0 }
|
||||
}
|
||||
|
||||
function isExisting(ip) { return existingIps.value.has(ip) }
|
||||
function vlanColor(vlanId) { return props.vlans.find(v => v.id === vlanId)?.color || '#94A3B8' }
|
||||
function vlanNum(vlanId) { return props.vlans.find(v => v.id === vlanId)?.vlan_id || '?' }
|
||||
function shortHostname(fqdn) { return fqdn.split('.')[0] }
|
||||
|
||||
function toggleAll(e) {
|
||||
if (e.target.checked) {
|
||||
selectedIps.value = results.value.filter(h => !isExisting(h.ip)).map(h => h.ip)
|
||||
} else {
|
||||
selectedIps.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
configError.value = ''
|
||||
if (!dnsServer.value.trim()) {
|
||||
configError.value = t('dnsRequired')
|
||||
return
|
||||
}
|
||||
if (selectedVlanIds.value.length === 0) {
|
||||
configError.value = t('selectVlan')
|
||||
return
|
||||
}
|
||||
|
||||
const targets = selectedVlanIds.value.map(id => {
|
||||
const vlan = props.vlans.find(v => v.id === id)
|
||||
return { vlan_id: id, cidr: vlan.cidr }
|
||||
})
|
||||
|
||||
step.value = 'scanning'
|
||||
results.value = []
|
||||
selectedIps.value = []
|
||||
|
||||
try {
|
||||
const resp = await discoveryApi.scan({
|
||||
dns_server: dnsServer.value.trim(),
|
||||
targets,
|
||||
})
|
||||
results.value = resp.data.hosts
|
||||
scanMeta.value = { total_scanned: resp.data.total_scanned, duration_s: resp.data.duration_s }
|
||||
selectedIps.value = results.value.filter(h => !isExisting(h.ip)).map(h => h.ip)
|
||||
step.value = 'results'
|
||||
} catch (e) {
|
||||
configError.value = e.response?.data?.detail || t('scanError')
|
||||
step.value = 'config'
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
const toImport = results.value.filter(h => selectedIps.value.includes(h.ip) && !isExisting(h.ip))
|
||||
|
||||
try {
|
||||
for (const host of toImport) {
|
||||
const name = host.hostname ? shortHostname(host.hostname) : host.ip
|
||||
await devicesApi.create({
|
||||
name,
|
||||
type: 'server',
|
||||
description: host.hostname || '',
|
||||
is_gateway: false,
|
||||
is_livebox: false,
|
||||
interfaces: [{
|
||||
name: 'eth0',
|
||||
ip_address: host.ip,
|
||||
vlan_id: host.vlan_id,
|
||||
is_upstream: false,
|
||||
}],
|
||||
})
|
||||
}
|
||||
emit('refresh')
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
importError.value = e.response?.data?.detail || t('importError')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--modal-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card); border-radius: 18px;
|
||||
width: 680px; max-width: 96vw; max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: var(--shadow-modal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.header-title { display: flex; align-items: center; gap: 10px; }
|
||||
.header-icon { font-size: 22px; }
|
||||
h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||
.close-btn {
|
||||
width: 30px; height: 30px; border: none; border-radius: 8px;
|
||||
background: var(--bg-page); color: var(--text-muted); font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.close-btn:hover { background: var(--border); }
|
||||
|
||||
.modal-body { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
|
||||
.field { margin-bottom: 20px; }
|
||||
.field label { display: block; font-size: 13px; font-weight: 700; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.input-hint { font-size: 12px; color: var(--text-faint); margin-bottom: 8px; }
|
||||
.field input[type="text"] {
|
||||
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);
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #3B82F6; }
|
||||
|
||||
.vlan-checklist { display: flex; flex-direction: column; gap: 8px; }
|
||||
.vlan-check {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border: 1.5px solid var(--border); border-radius: 10px;
|
||||
cursor: pointer; transition: border-color 0.15s;
|
||||
}
|
||||
.vlan-check:hover:not(.disabled) { border-color: #93C5FD; background: var(--bg-card-hover); }
|
||||
.vlan-check.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.vlan-check input[type="checkbox"] { width: 16px; height: 16px; flex-shrink: 0; cursor: pointer; }
|
||||
.vlan-check-body { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.vlan-badge {
|
||||
color: #fff; font-size: 11px; font-weight: 800;
|
||||
padding: 2px 9px; border-radius: 20px;
|
||||
}
|
||||
.vlan-badge.sm { font-size: 10px; padding: 1px 7px; }
|
||||
.vlan-check-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.vlan-check-cidr { font-size: 12px; color: var(--text-muted); font-family: monospace; background: var(--bg-page); padding: 1px 5px; border-radius: 4px; }
|
||||
.no-cidr { font-size: 12px; color: var(--text-faint); font-style: italic; }
|
||||
.host-count { font-size: 12px; color: var(--text-faint); }
|
||||
|
||||
.scanning-body {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 60px 24px; gap: 16px;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
border: 4px solid var(--border); border-top-color: #3B82F6;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.scan-msg { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||
.scan-detail { font-size: 14px; color: var(--text-muted); }
|
||||
.scan-note { font-size: 12px; color: var(--text-faint); }
|
||||
|
||||
.results-summary {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.summary-found { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
||||
.summary-meta { font-size: 13px; color: var(--text-muted); }
|
||||
.summary-new {
|
||||
background: #D1FAE5; color: #065F46;
|
||||
font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 20px;
|
||||
}
|
||||
|
||||
.empty-results { text-align: center; padding: 32px; color: var(--text-faint); font-size: 15px; }
|
||||
|
||||
.results-table-wrap { overflow-x: auto; border: 1.5px solid var(--border); border-radius: 10px; }
|
||||
.results-table { width: 100%; border-collapse: collapse; }
|
||||
.results-table thead { background: var(--bg-thead); }
|
||||
.results-table th {
|
||||
padding: 10px 12px; text-align: left;
|
||||
font-size: 11px; font-weight: 700; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.results-table td { padding: 10px 12px; font-size: 13px; border-top: 1px solid var(--border); color: var(--text-primary); }
|
||||
.results-table tr:hover td { background: var(--bg-card-hover); }
|
||||
.row-existing td { opacity: 0.5; }
|
||||
|
||||
code.ip { font-family: monospace; font-size: 13px; color: var(--text-primary); }
|
||||
.hostname { color: #1D4ED8; font-weight: 600; }
|
||||
.no-dns { color: var(--text-faint); }
|
||||
|
||||
.status {
|
||||
font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 20px;
|
||||
}
|
||||
.status.new { background: #DBEAFE; color: #1D4ED8; }
|
||||
.status.existing { background: var(--bg-page); color: var(--text-faint); }
|
||||
|
||||
.warn-box {
|
||||
background: #FFFBEB; border: 1.5px solid #FCD34D; border-radius: 8px;
|
||||
padding: 12px; font-size: 13px; color: #92400E;
|
||||
}
|
||||
.error-box {
|
||||
background: #FEF2F2; border: 1.5px solid #FCA5A5; border-radius: 8px;
|
||||
padding: 12px; font-size: 13px; color: #B91C1C; margin-top: 12px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 9px 20px; background: #3B82F6; color: #fff;
|
||||
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: #2563EB; }
|
||||
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-secondary {
|
||||
padding: 9px 20px; background: var(--bg-page); color: var(--text-secondary);
|
||||
border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border); }
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/>
|
||||
<circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/>
|
||||
<line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/>
|
||||
<line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/>
|
||||
</svg>
|
||||
<span>Stupid Simple Network Inventory</span>
|
||||
</div>
|
||||
|
||||
<h2 class="login-title">{{ t('loginTitle') }}</h2>
|
||||
|
||||
<form @submit.prevent="submit" class="login-form">
|
||||
<div class="field">
|
||||
<label>{{ t('loginUsername') }}</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('loginPassword') }}</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
{{ loading ? '…' : t('loginBtn') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { authApi } from '../api.js'
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
const emit = defineEmits(['login'])
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const errorMsg = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await authApi.login(username.value, password.value)
|
||||
emit('login', {
|
||||
token: data.access_token,
|
||||
username: data.username,
|
||||
mustChangePassword: data.must_change_password || false,
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.response?.status === 429) {
|
||||
errorMsg.value = t('tooManyAttempts')
|
||||
} else {
|
||||
errorMsg.value = t('loginError')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: var(--shadow-modal);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #3B82F6;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 9px 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.field input:focus { border-color: #3B82F6; }
|
||||
|
||||
.login-error {
|
||||
font-size: 13px;
|
||||
color: #EF4444;
|
||||
background: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
:global(html.dark) .login-error {
|
||||
background: #2A1515;
|
||||
border-color: #3D1F1F;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 4px;
|
||||
padding: 10px;
|
||||
background: #3B82F6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.login-btn:hover:not(:disabled) { background: #2563EB; }
|
||||
.login-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1>{{ t('networks') }}</h1>
|
||||
<button class="btn-primary" @click="openAdd">{{ t('addNetwork') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.vlans.length === 0" class="empty">
|
||||
{{ t('noNetworksConfigured') }}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" v-else>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('colType') }}</th>
|
||||
<th>{{ t('colName') }}</th>
|
||||
<th>{{ t('colSubnet') }}</th>
|
||||
<th>{{ t('colColor') }}</th>
|
||||
<th>{{ t('colActions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="vlan in props.vlans" :key="vlan.id">
|
||||
<td>
|
||||
<span class="badge-vlan" :style="{ background: vlan.color }">
|
||||
{{ vlan.vlan_id != null ? vlan.vlan_id : 'LAN' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ vlan.name }}</td>
|
||||
<td><code>{{ vlan.cidr || '—' }}</code></td>
|
||||
<td>
|
||||
<div class="color-preview" :style="{ background: vlan.color }"></div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon" @click="openEdit(vlan)" title="✎">✎</button>
|
||||
<button class="btn-icon danger" @click="remove(vlan)" title="✕">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false">
|
||||
<div class="modal">
|
||||
<h2>{{ editing ? t('editNetwork') : t('newNetwork') }}</h2>
|
||||
<form @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label>{{ t('vlanId') }} <span class="optional">{{ t('vlanIdHint') }}</span></label>
|
||||
<input v-model="form.vlan_id" type="number" placeholder="ex: 10" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('fieldName') }}</label>
|
||||
<input v-model="form.name" type="text" required placeholder="ex: Serveurs" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('subnet') }}</label>
|
||||
<input v-model="form.cidr" type="text" :placeholder="t('subnetPlaceholder')" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('color') }}</label>
|
||||
<div class="color-row">
|
||||
<input v-model="form.color" type="color" />
|
||||
<span>{{ form.color }}</span>
|
||||
<div class="color-presets">
|
||||
<div
|
||||
v-for="c in presetColors"
|
||||
:key="c"
|
||||
class="preset"
|
||||
:style="{ background: c }"
|
||||
@click="form.color = c"
|
||||
></div>
|
||||
</div>
|
||||
</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 } from 'vue'
|
||||
import { vlansApi } from '../api.js'
|
||||
import { t, tFmt } from '../i18n.js'
|
||||
|
||||
const props = defineProps({ vlans: Array })
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const showForm = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = reactive({ vlan_id: '', name: '', cidr: '', color: '#4A90D9' })
|
||||
|
||||
const presetColors = [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
|
||||
]
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
Object.assign(form, { vlan_id: '', name: '', cidr: '', color: '#4A90D9' })
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openEdit(vlan) {
|
||||
editing.value = vlan
|
||||
Object.assign(form, {
|
||||
vlan_id: vlan.vlan_id != null ? String(vlan.vlan_id) : '',
|
||||
name: vlan.name,
|
||||
cidr: vlan.cidr || '',
|
||||
color: vlan.color
|
||||
})
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
...form,
|
||||
vlan_id: form.vlan_id !== '' ? Number(form.vlan_id) : null
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await vlansApi.update(editing.value.id, payload)
|
||||
} else {
|
||||
await vlansApi.create(payload)
|
||||
}
|
||||
showForm.value = false
|
||||
emit('refresh')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || t('saveError'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(vlan) {
|
||||
const label = vlan.vlan_id != null ? `VLAN ${vlan.vlan_id} — ${vlan.name}` : `LAN ${vlan.name}`
|
||||
if (!confirm(`Supprimer ${tFmt('confirmDeleteNetwork', label)}`)) return
|
||||
try {
|
||||
await vlansApi.remove(vlan.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: 24px; }
|
||||
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; transition: background 0.15s;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border-strong); }
|
||||
|
||||
.empty { padding: 48px; text-align: center; color: var(--text-faint); font-size: 15px; }
|
||||
|
||||
.table-wrap { background: var(--bg-card); border-radius: 12px; box-shadow: var(--shadow-card); overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead { background: var(--bg-thead); }
|
||||
th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
td { padding: 14px 16px; font-size: 14px; color: var(--text-primary); border-top: 1px solid var(--border); }
|
||||
tr:hover td { background: var(--bg-card-hover); }
|
||||
|
||||
.badge-vlan {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 36px; padding: 2px 8px; border-radius: 20px;
|
||||
color: #fff; font-weight: 700; font-size: 13px;
|
||||
}
|
||||
code { background: var(--bg-page); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--text-secondary); }
|
||||
.color-preview { width: 24px; height: 24px; border-radius: 6px; border: 2px solid rgba(0,0,0,0.1); }
|
||||
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.btn-icon {
|
||||
width: 30px; height: 30px; border: none; border-radius: 6px;
|
||||
background: var(--bg-chip); color: var(--text-secondary); font-size: 15px;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--border-strong); color: var(--text-primary); }
|
||||
.btn-icon.danger:hover { background: rgba(239,68,68,0.15); color: #EF4444; }
|
||||
|
||||
.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: 440px; max-width: 95vw; box-shadow: var(--shadow-modal);
|
||||
}
|
||||
.modal h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 20px; }
|
||||
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.optional { font-weight: 400; color: var(--text-faint); font-size: 12px; }
|
||||
.field input[type="text"],
|
||||
.field input[type="number"] {
|
||||
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);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #3B82F6; }
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 12px; }
|
||||
.color-row input[type="color"] { width: 40px; height: 36px; border: none; border-radius: 6px; cursor: pointer; padding: 0; }
|
||||
.color-row span { font-size: 13px; color: var(--text-muted); font-family: monospace; }
|
||||
.color-presets { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.preset { width: 20px; height: 20px; border-radius: 4px; cursor: pointer; transition: transform 0.1s; }
|
||||
.preset:hover { transform: scale(1.2); }
|
||||
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,477 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const locale = ref(localStorage.getItem('locale') || 'fr')
|
||||
|
||||
export function setLocale(lang) {
|
||||
locale.value = lang
|
||||
localStorage.setItem('locale', lang)
|
||||
}
|
||||
|
||||
const LANGS = {
|
||||
fr: {
|
||||
// Sidebar / App
|
||||
tabTopology: 'Topologie',
|
||||
tabNetworks: 'Réseaux',
|
||||
tabDevices: 'Équipements',
|
||||
discovery: '🔍 Découverte auto',
|
||||
statsNetworks: 'Réseaux',
|
||||
statsDevices: 'Équip.',
|
||||
exportJson: '⬇ Export JSON',
|
||||
importJson: '⬆ Import JSON',
|
||||
loadError: 'Erreur de chargement : ',
|
||||
importFailed: 'Import échoué : ',
|
||||
importTooLarge: 'Fichier trop volumineux (max 5 Mo).',
|
||||
// TopologyGraph
|
||||
ping: 'Ping',
|
||||
pinging: 'Ping en cours…',
|
||||
refreshPing: 'Rafraîchir le ping',
|
||||
wan: 'Internet / WAN',
|
||||
gateway: 'Passerelle inter-VLAN',
|
||||
unassigned: 'Non assigné',
|
||||
noDevice: 'Aucun équipement',
|
||||
noDevices: 'Aucun équipement à afficher.',
|
||||
noDevicesHint: 'Commencez par créer des réseaux et des équipements.',
|
||||
reachable: 'Joignable',
|
||||
unreachable: 'Injoignable',
|
||||
openWebUI: "Ouvrir l'interface web",
|
||||
// DeviceManager
|
||||
devices: 'Équipements',
|
||||
addDevice: '+ Ajouter un équipement',
|
||||
noDevicesConfigured: 'Aucun équipement configuré. Commencez par en ajouter un.',
|
||||
searchPlaceholder: 'Nom, IP…',
|
||||
filterType: 'Type',
|
||||
filterNetwork: 'Réseau',
|
||||
filterBrand: 'Marque',
|
||||
filterVirt: 'Virt',
|
||||
clearFilters: '✕ Effacer',
|
||||
noDevicesFiltered: 'Aucun équipement ne correspond aux filtres sélectionnés.',
|
||||
editDevice: "Modifier l'équipement",
|
||||
newDevice: 'Nouvel équipement',
|
||||
fieldName: 'Nom *',
|
||||
fieldType: 'Type *',
|
||||
fieldDescription: 'Description',
|
||||
isGateway: 'Passerelle inter-VLAN',
|
||||
isLivebox: 'Livebox / Box FAI',
|
||||
accessUrl: "URL d'accès",
|
||||
runtimeType: "Type d'environnement d'exécution",
|
||||
notSpecified: '— Non précisé',
|
||||
baremetal: 'Bare-metal',
|
||||
lxcContainer: 'Conteneur LXC',
|
||||
vmQemu: 'VM QEMU/KVM',
|
||||
networkInterfaces: 'Interfaces réseau',
|
||||
addInterface: '+ Interface',
|
||||
noInterface: 'Aucune interface configurée',
|
||||
cancel: 'Annuler',
|
||||
save: 'Enregistrer',
|
||||
create: 'Créer',
|
||||
badgeGateway: 'Passerelle',
|
||||
badgeLivebox: 'Livebox',
|
||||
confirmDeleteDevice: '{0} et tous ses liens ?',
|
||||
confirmDeleteNetwork: '{0} ?',
|
||||
saveError: 'Erreur lors de la sauvegarde',
|
||||
deleteError: 'Erreur lors de la suppression',
|
||||
descPlaceholder: 'Rôle, OS, notes…',
|
||||
// Device types
|
||||
typeServer: 'Serveur',
|
||||
typeSwitch: 'Switch',
|
||||
typeRouter: 'Routeur',
|
||||
typeNas: 'NAS',
|
||||
typeGateway: 'Passerelle',
|
||||
typeLivebox: 'Livebox',
|
||||
typeCamera: 'Caméra IP',
|
||||
typeTemperature: 'Sonde température/humidité',
|
||||
typeSensor: 'Capteur (mouvement, ouverture…)',
|
||||
typeHub: 'Hub domotique',
|
||||
typeSmartPlug: 'Prise connectée',
|
||||
typeAlarm: 'Alarme / Détecteur',
|
||||
typeLight: 'Éclairage connecté',
|
||||
typeDoorbell: 'Sonnette / Interphone',
|
||||
typeAccessPoint: 'Borne WiFi / Access Point',
|
||||
typeDesktop: 'Ordinateur fixe',
|
||||
typeLaptop: 'Ordinateur portable',
|
||||
typeOther: 'Autre',
|
||||
virtBaremetal: 'Bare-metal',
|
||||
virtLxc: 'LXC',
|
||||
virtQemu: 'VM QEMU',
|
||||
// VlanManager
|
||||
networks: 'Réseaux',
|
||||
addNetwork: '+ Ajouter un réseau',
|
||||
noNetworksConfigured: 'Aucun réseau configuré. Commencez par en créer un.',
|
||||
colType: 'Type',
|
||||
colName: 'Nom',
|
||||
colSubnet: 'Sous-réseau',
|
||||
colColor: 'Couleur',
|
||||
colActions: 'Actions',
|
||||
editNetwork: 'Modifier le réseau',
|
||||
newNetwork: 'Nouveau réseau',
|
||||
vlanId: 'ID VLAN',
|
||||
vlanIdHint: '(laisser vide pour LAN classique)',
|
||||
subnet: 'Sous-réseau CIDR',
|
||||
color: 'Couleur',
|
||||
subnetPlaceholder: 'ex: 192.168.10.0/24',
|
||||
// DiscoveryModal
|
||||
autoDiscovery: 'Découverte automatique',
|
||||
dnsServer: 'Serveur DNS',
|
||||
dnsHint: 'Le reverse DNS sera interrogé sur ce serveur pour résoudre les noms.',
|
||||
vlansToScan: 'VLANs à scanner',
|
||||
vlansHint: 'Seuls les VLANs avec un sous-réseau CIDR configuré peuvent être scannés.',
|
||||
noCidrWarning: "Aucun VLAN n'a de CIDR configuré. Renseignez-les dans l'onglet VLANs.",
|
||||
noCidr: 'pas de CIDR',
|
||||
startDiscovery: 'Lancer la découverte',
|
||||
scanning: 'Scan en cours…',
|
||||
scanAddresses: 'adresses sur',
|
||||
scanVlans: 'VLAN(s)',
|
||||
scanNote: 'Chaque hôte est pingé puis interrogé en DNS.',
|
||||
hostsFound: 'hôte(s) découvert(s)',
|
||||
addressesScanned: 'adresses scannées',
|
||||
newHosts: 'nouveaux',
|
||||
noHosts: 'Aucun hôte actif trouvé sur les plages sélectionnées.',
|
||||
colIp: 'IP',
|
||||
colDns: 'Nom (DNS)',
|
||||
colStatus: 'Statut',
|
||||
statusExisting: 'Déjà présent',
|
||||
statusNew: 'Nouveau',
|
||||
newScan: 'Nouveau scan',
|
||||
importingBtn: 'Import…',
|
||||
importBtn: 'Importer',
|
||||
dnsRequired: 'Veuillez renseigner un serveur DNS.',
|
||||
selectVlan: 'Sélectionnez au moins un VLAN.',
|
||||
importError: "Erreur lors de l'import.",
|
||||
scanError: 'Erreur lors du scan.',
|
||||
// Theme / Lang
|
||||
lightTheme: 'Thème clair',
|
||||
darkTheme: 'Thème sombre',
|
||||
// Auth
|
||||
loginTitle: 'Connexion',
|
||||
loginUsername: 'Nom d\'utilisateur',
|
||||
loginPassword: 'Mot de passe',
|
||||
loginBtn: 'Se connecter',
|
||||
loginError: 'Identifiants incorrects.',
|
||||
logout: 'Déconnexion',
|
||||
accountSettings: 'Paramètres du compte',
|
||||
currentPassword: 'Mot de passe actuel',
|
||||
newUsername: 'Nouveau nom d\'utilisateur',
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
leaveBlankToKeep: 'Laisser vide pour ne pas modifier',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
accountUpdated: 'Compte mis à jour.',
|
||||
wrongPassword: 'Mot de passe actuel incorrect.',
|
||||
mustChangePasswordWarning: 'Pour des raisons de sécurité, vous devez changer votre mot de passe avant de continuer.',
|
||||
newPasswordRequired: 'Un nouveau mot de passe est requis.',
|
||||
passwordTooShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
passwordTooWeak: 'Le mot de passe doit contenir au moins une lettre et un chiffre.',
|
||||
usernameInvalid: "Le nom d'utilisateur ne peut contenir que des lettres, chiffres, . _ - (1 à 64 caractères).",
|
||||
tooManyAttempts: 'Trop de tentatives, réessayez plus tard.',
|
||||
},
|
||||
|
||||
en: {
|
||||
tabTopology: 'Topology',
|
||||
tabNetworks: 'Networks',
|
||||
tabDevices: 'Devices',
|
||||
discovery: '🔍 Auto discovery',
|
||||
statsNetworks: 'Networks',
|
||||
statsDevices: 'Devices',
|
||||
exportJson: '⬇ Export JSON',
|
||||
importJson: '⬆ Import JSON',
|
||||
loadError: 'Loading error: ',
|
||||
importFailed: 'Import failed: ',
|
||||
importTooLarge: 'File too large (max 5 MB).',
|
||||
ping: 'Ping',
|
||||
pinging: 'Pinging…',
|
||||
refreshPing: 'Refresh ping',
|
||||
wan: 'Internet / WAN',
|
||||
gateway: 'Inter-VLAN Gateway',
|
||||
unassigned: 'Unassigned',
|
||||
noDevice: 'No devices',
|
||||
noDevices: 'No devices to display.',
|
||||
noDevicesHint: 'Start by creating networks and devices.',
|
||||
reachable: 'Reachable',
|
||||
unreachable: 'Unreachable',
|
||||
openWebUI: 'Open web interface',
|
||||
devices: 'Devices',
|
||||
addDevice: '+ Add device',
|
||||
noDevicesConfigured: 'No devices configured. Start by adding one.',
|
||||
searchPlaceholder: 'Name, IP…',
|
||||
filterType: 'Type',
|
||||
filterNetwork: 'Network',
|
||||
filterBrand: 'Brand',
|
||||
filterVirt: 'Virt',
|
||||
clearFilters: '✕ Clear',
|
||||
noDevicesFiltered: 'No devices match the selected filters.',
|
||||
editDevice: 'Edit device',
|
||||
newDevice: 'New device',
|
||||
fieldName: 'Name *',
|
||||
fieldType: 'Type *',
|
||||
fieldDescription: 'Description',
|
||||
isGateway: 'Inter-VLAN gateway',
|
||||
isLivebox: 'ISP Box / Router',
|
||||
accessUrl: 'Access URL',
|
||||
runtimeType: 'Runtime environment',
|
||||
notSpecified: '— Not specified',
|
||||
baremetal: 'Bare-metal',
|
||||
lxcContainer: 'LXC container',
|
||||
vmQemu: 'VM QEMU/KVM',
|
||||
networkInterfaces: 'Network interfaces',
|
||||
addInterface: '+ Interface',
|
||||
noInterface: 'No interface configured',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
create: 'Create',
|
||||
badgeGateway: 'Gateway',
|
||||
badgeLivebox: 'ISP Box',
|
||||
confirmDeleteDevice: '{0} and all its links?',
|
||||
confirmDeleteNetwork: '{0}?',
|
||||
saveError: 'Error while saving',
|
||||
deleteError: 'Error while deleting',
|
||||
descPlaceholder: 'Role, OS, notes…',
|
||||
typeServer: 'Server',
|
||||
typeSwitch: 'Switch',
|
||||
typeRouter: 'Router',
|
||||
typeNas: 'NAS',
|
||||
typeGateway: 'Gateway',
|
||||
typeLivebox: 'ISP Box',
|
||||
typeCamera: 'IP Camera',
|
||||
typeTemperature: 'Temperature/humidity sensor',
|
||||
typeSensor: 'Sensor (motion, door…)',
|
||||
typeHub: 'Home automation hub',
|
||||
typeSmartPlug: 'Smart plug',
|
||||
typeAlarm: 'Alarm / Detector',
|
||||
typeLight: 'Smart light',
|
||||
typeDoorbell: 'Doorbell / Intercom',
|
||||
typeAccessPoint: 'WiFi Access Point',
|
||||
typeDesktop: 'Desktop computer',
|
||||
typeLaptop: 'Laptop',
|
||||
typeOther: 'Other',
|
||||
virtBaremetal: 'Bare-metal',
|
||||
virtLxc: 'LXC',
|
||||
virtQemu: 'VM QEMU',
|
||||
networks: 'Networks',
|
||||
addNetwork: '+ Add network',
|
||||
noNetworksConfigured: 'No networks configured. Start by creating one.',
|
||||
colType: 'Type',
|
||||
colName: 'Name',
|
||||
colSubnet: 'Subnet',
|
||||
colColor: 'Color',
|
||||
colActions: 'Actions',
|
||||
editNetwork: 'Edit network',
|
||||
newNetwork: 'New network',
|
||||
vlanId: 'VLAN ID',
|
||||
vlanIdHint: '(leave empty for plain LAN)',
|
||||
subnet: 'CIDR subnet',
|
||||
color: 'Color',
|
||||
subnetPlaceholder: 'e.g. 192.168.10.0/24',
|
||||
autoDiscovery: 'Auto discovery',
|
||||
dnsServer: 'DNS server',
|
||||
dnsHint: 'Reverse DNS will be queried on this server to resolve hostnames.',
|
||||
vlansToScan: 'VLANs to scan',
|
||||
vlansHint: 'Only VLANs with a configured CIDR subnet can be scanned.',
|
||||
noCidrWarning: 'No VLAN has a CIDR configured. Set them in the Networks tab.',
|
||||
noCidr: 'no CIDR',
|
||||
startDiscovery: 'Start discovery',
|
||||
scanning: 'Scanning…',
|
||||
scanAddresses: 'addresses on',
|
||||
scanVlans: 'VLAN(s)',
|
||||
scanNote: 'Each host is pinged then queried via DNS.',
|
||||
hostsFound: 'host(s) found',
|
||||
addressesScanned: 'addresses scanned',
|
||||
newHosts: 'new',
|
||||
noHosts: 'No active hosts found on the selected ranges.',
|
||||
colIp: 'IP',
|
||||
colDns: 'Name (DNS)',
|
||||
colStatus: 'Status',
|
||||
statusExisting: 'Already present',
|
||||
statusNew: 'New',
|
||||
newScan: 'New scan',
|
||||
importingBtn: 'Importing…',
|
||||
importBtn: 'Import',
|
||||
dnsRequired: 'Please enter a DNS server.',
|
||||
selectVlan: 'Select at least one VLAN.',
|
||||
importError: 'Error during import.',
|
||||
scanError: 'Error during scan.',
|
||||
lightTheme: 'Light theme',
|
||||
darkTheme: 'Dark theme',
|
||||
// Auth
|
||||
loginTitle: 'Sign in',
|
||||
loginUsername: 'Username',
|
||||
loginPassword: 'Password',
|
||||
loginBtn: 'Sign in',
|
||||
loginError: 'Incorrect credentials.',
|
||||
logout: 'Logout',
|
||||
accountSettings: 'Account settings',
|
||||
currentPassword: 'Current password',
|
||||
newUsername: 'New username',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
leaveBlankToKeep: 'Leave blank to keep unchanged',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
accountUpdated: 'Account updated.',
|
||||
wrongPassword: 'Current password is incorrect.',
|
||||
mustChangePasswordWarning: 'For security reasons, you must change your password before continuing.',
|
||||
newPasswordRequired: 'A new password is required.',
|
||||
passwordTooShort: 'Password must be at least 8 characters.',
|
||||
passwordTooWeak: 'Password must contain at least one letter and one digit.',
|
||||
usernameInvalid: 'Username may only contain letters, digits, . _ - (1 to 64 characters).',
|
||||
tooManyAttempts: 'Too many attempts, please try again later.',
|
||||
},
|
||||
|
||||
es: {
|
||||
tabTopology: 'Topología',
|
||||
tabNetworks: 'Redes',
|
||||
tabDevices: 'Equipos',
|
||||
discovery: '🔍 Descubrimiento auto',
|
||||
statsNetworks: 'Redes',
|
||||
statsDevices: 'Equipos',
|
||||
exportJson: '⬇ Exportar JSON',
|
||||
importJson: '⬆ Importar JSON',
|
||||
loadError: 'Error de carga: ',
|
||||
importFailed: 'Importación fallida: ',
|
||||
importTooLarge: 'Archivo demasiado grande (máx 5 MB).',
|
||||
ping: 'Ping',
|
||||
pinging: 'Ping en curso…',
|
||||
refreshPing: 'Actualizar ping',
|
||||
wan: 'Internet / WAN',
|
||||
gateway: 'Pasarela inter-VLAN',
|
||||
unassigned: 'Sin asignar',
|
||||
noDevice: 'Sin equipos',
|
||||
noDevices: 'No hay equipos que mostrar.',
|
||||
noDevicesHint: 'Empiece creando redes y equipos.',
|
||||
reachable: 'Alcanzable',
|
||||
unreachable: 'No alcanzable',
|
||||
openWebUI: 'Abrir interfaz web',
|
||||
devices: 'Equipos',
|
||||
addDevice: '+ Añadir equipo',
|
||||
noDevicesConfigured: 'No hay equipos configurados. Empiece añadiendo uno.',
|
||||
searchPlaceholder: 'Nombre, IP…',
|
||||
filterType: 'Tipo',
|
||||
filterNetwork: 'Red',
|
||||
filterBrand: 'Marca',
|
||||
filterVirt: 'Virt',
|
||||
clearFilters: '✕ Borrar',
|
||||
noDevicesFiltered: 'Ningún equipo coincide con los filtros seleccionados.',
|
||||
editDevice: 'Editar equipo',
|
||||
newDevice: 'Nuevo equipo',
|
||||
fieldName: 'Nombre *',
|
||||
fieldType: 'Tipo *',
|
||||
fieldDescription: 'Descripción',
|
||||
isGateway: 'Pasarela inter-VLAN',
|
||||
isLivebox: 'Router / Box ISP',
|
||||
accessUrl: 'URL de acceso',
|
||||
runtimeType: 'Entorno de ejecución',
|
||||
notSpecified: '— No especificado',
|
||||
baremetal: 'Bare-metal',
|
||||
lxcContainer: 'Contenedor LXC',
|
||||
vmQemu: 'VM QEMU/KVM',
|
||||
networkInterfaces: 'Interfaces de red',
|
||||
addInterface: '+ Interfaz',
|
||||
noInterface: 'Sin interfaces configuradas',
|
||||
cancel: 'Cancelar',
|
||||
save: 'Guardar',
|
||||
create: 'Crear',
|
||||
badgeGateway: 'Pasarela',
|
||||
badgeLivebox: 'Router ISP',
|
||||
confirmDeleteDevice: '{0} y todos sus enlaces?',
|
||||
confirmDeleteNetwork: '{0}?',
|
||||
saveError: 'Error al guardar',
|
||||
deleteError: 'Error al eliminar',
|
||||
descPlaceholder: 'Rol, SO, notas…',
|
||||
typeServer: 'Servidor',
|
||||
typeSwitch: 'Switch',
|
||||
typeRouter: 'Router',
|
||||
typeNas: 'NAS',
|
||||
typeGateway: 'Pasarela',
|
||||
typeLivebox: 'Router ISP',
|
||||
typeCamera: 'Cámara IP',
|
||||
typeTemperature: 'Sonda de temperatura/humedad',
|
||||
typeSensor: 'Sensor (movimiento, apertura…)',
|
||||
typeHub: 'Hub domótico',
|
||||
typeSmartPlug: 'Enchufe inteligente',
|
||||
typeAlarm: 'Alarma / Detector',
|
||||
typeLight: 'Iluminación inteligente',
|
||||
typeDoorbell: 'Timbre / Portero',
|
||||
typeAccessPoint: 'Punto de acceso WiFi',
|
||||
typeDesktop: 'Ordenador de sobremesa',
|
||||
typeLaptop: 'Portátil',
|
||||
typeOther: 'Otro',
|
||||
virtBaremetal: 'Bare-metal',
|
||||
virtLxc: 'LXC',
|
||||
virtQemu: 'VM QEMU',
|
||||
networks: 'Redes',
|
||||
addNetwork: '+ Añadir red',
|
||||
noNetworksConfigured: 'No hay redes configuradas. Empiece creando una.',
|
||||
colType: 'Tipo',
|
||||
colName: 'Nombre',
|
||||
colSubnet: 'Subred',
|
||||
colColor: 'Color',
|
||||
colActions: 'Acciones',
|
||||
editNetwork: 'Editar red',
|
||||
newNetwork: 'Nueva red',
|
||||
vlanId: 'ID de VLAN',
|
||||
vlanIdHint: '(dejar vacío para LAN simple)',
|
||||
subnet: 'Subred CIDR',
|
||||
color: 'Color',
|
||||
subnetPlaceholder: 'ej: 192.168.10.0/24',
|
||||
autoDiscovery: 'Descubrimiento automático',
|
||||
dnsServer: 'Servidor DNS',
|
||||
dnsHint: 'El DNS inverso será consultado en este servidor para resolver nombres.',
|
||||
vlansToScan: 'VLANs a escanear',
|
||||
vlansHint: 'Solo los VLANs con subred CIDR configurada pueden escanearse.',
|
||||
noCidrWarning: 'Ninguna VLAN tiene CIDR configurado. Configúrelo en la pestaña Redes.',
|
||||
noCidr: 'sin CIDR',
|
||||
startDiscovery: 'Iniciar descubrimiento',
|
||||
scanning: 'Escaneo en curso…',
|
||||
scanAddresses: 'direcciones en',
|
||||
scanVlans: 'VLAN(s)',
|
||||
scanNote: 'Cada host es pingado y luego consultado en DNS.',
|
||||
hostsFound: 'host(s) descubierto(s)',
|
||||
addressesScanned: 'direcciones escaneadas',
|
||||
newHosts: 'nuevos',
|
||||
noHosts: 'No se encontraron hosts activos en los rangos seleccionados.',
|
||||
colIp: 'IP',
|
||||
colDns: 'Nombre (DNS)',
|
||||
colStatus: 'Estado',
|
||||
statusExisting: 'Ya presente',
|
||||
statusNew: 'Nuevo',
|
||||
newScan: 'Nuevo escaneo',
|
||||
importingBtn: 'Importando…',
|
||||
importBtn: 'Importar',
|
||||
dnsRequired: 'Por favor ingrese un servidor DNS.',
|
||||
selectVlan: 'Seleccione al menos una VLAN.',
|
||||
importError: 'Error durante la importación.',
|
||||
scanError: 'Error durante el escaneo.',
|
||||
lightTheme: 'Tema claro',
|
||||
darkTheme: 'Tema oscuro',
|
||||
// Auth
|
||||
loginTitle: 'Iniciar sesión',
|
||||
loginUsername: 'Nombre de usuario',
|
||||
loginPassword: 'Contraseña',
|
||||
loginBtn: 'Iniciar sesión',
|
||||
loginError: 'Credenciales incorrectas.',
|
||||
logout: 'Cerrar sesión',
|
||||
accountSettings: 'Configuración de cuenta',
|
||||
currentPassword: 'Contraseña actual',
|
||||
newUsername: 'Nuevo nombre de usuario',
|
||||
newPassword: 'Nueva contraseña',
|
||||
confirmPassword: 'Confirmar contraseña',
|
||||
leaveBlankToKeep: 'Dejar en blanco para no cambiar',
|
||||
passwordMismatch: 'Las contraseñas no coinciden.',
|
||||
accountUpdated: 'Cuenta actualizada.',
|
||||
wrongPassword: 'La contraseña actual es incorrecta.',
|
||||
mustChangePasswordWarning: 'Por razones de seguridad, debe cambiar su contraseña antes de continuar.',
|
||||
newPasswordRequired: 'Se requiere una nueva contraseña.',
|
||||
passwordTooShort: 'La contraseña debe tener al menos 8 caracteres.',
|
||||
passwordTooWeak: 'La contraseña debe contener al menos una letra y un número.',
|
||||
usernameInvalid: 'El nombre de usuario solo puede contener letras, dígitos, . _ - (1 a 64 caracteres).',
|
||||
tooManyAttempts: 'Demasiados intentos, inténtelo de nuevo más tarde.',
|
||||
},
|
||||
}
|
||||
|
||||
export function t(key) {
|
||||
return LANGS[locale.value]?.[key] ?? LANGS['fr'][key] ?? key
|
||||
}
|
||||
|
||||
export function tFmt(key, ...args) {
|
||||
let str = LANGS[locale.value]?.[key] ?? LANGS['fr'][key] ?? key
|
||||
args.forEach((arg, i) => { str = str.replace(`{${i}}`, arg) })
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const theme = ref(localStorage.getItem('theme') || 'light')
|
||||
|
||||
function apply(t) {
|
||||
document.documentElement.classList.toggle('dark', t === 'dark')
|
||||
}
|
||||
|
||||
apply(theme.value)
|
||||
|
||||
watch(theme, (t) => {
|
||||
localStorage.setItem('theme', t)
|
||||
apply(t)
|
||||
})
|
||||
|
||||
export function toggleTheme() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user