This commit is contained in:
2026-02-21 16:55:10 +01:00
commit 1b8bf79d46
49 changed files with 4347 additions and 0 deletions

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HA Entity Scanner</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

16
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}

1535
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "ha-entity-scanner",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"vuetify": "^3.7.0",
"@mdi/font": "^7.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "~5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0"
}
}

128
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,128 @@
<template>
<v-app>
<!-- Barre de navigation -->
<v-app-bar flat density="compact" color="surface">
<v-app-bar-title>
<v-icon start>mdi-home-assistant</v-icon>
HA Entity Scanner
</v-app-bar-title>
<template #append>
<!-- Statut HA -->
<v-chip
:color="health?.ha_connected ? 'success' : 'error'"
size="small"
variant="tonal"
class="mr-3"
>
<v-icon start size="small">
{{ health?.ha_connected ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ health?.ha_connected ? 'HA connecté' : health?.ha_message || 'HA déconnecté' }}
</v-chip>
<!-- Onglets -->
<v-btn-toggle v-model="currentTab" mandatory density="compact" variant="outlined" class="mr-3">
<v-btn value="entities" size="small">
<v-icon start>mdi-format-list-bulleted</v-icon>
Entités
</v-btn>
<v-btn value="audit" size="small">
<v-icon start>mdi-clipboard-text-clock</v-icon>
Journal
</v-btn>
</v-btn-toggle>
</template>
</v-app-bar>
<v-main>
<v-container fluid>
<!-- Page Entités -->
<template v-if="currentTab === 'entities'">
<div class="d-flex align-center justify-space-between mb-4">
<ScanButton :health="health" @scanned="fetchHealth" />
</div>
<FilterBar v-model:filters="filters" @filter="onFilter" />
<EntityTable
:entities="entities"
:total="total"
:loading="entitiesLoading"
:page="page"
:per-page="perPage"
@select="onSelectEntity"
@update:options="onTableOptions"
@refresh="fetchEntities"
/>
</template>
<!-- Page Journal -->
<template v-if="currentTab === 'audit'">
<AuditLog />
</template>
</v-container>
</v-main>
<!-- Panneau détails -->
<EntityDetail
:entity="selectedEntity"
@close="selectedEntity = null"
@refresh="onEntityActionDone"
/>
</v-app>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useHealth } from '@/composables/useHealth'
import { useEntities } from '@/composables/useEntities'
import type { Entity } from '@/api'
import { api } from '@/api'
import ScanButton from '@/components/ScanButton.vue'
import FilterBar from '@/components/FilterBar.vue'
import EntityTable from '@/components/EntityTable.vue'
import EntityDetail from '@/components/EntityDetail.vue'
import AuditLog from '@/components/AuditLog.vue'
const currentTab = ref('entities')
const { health, fetchHealth } = useHealth()
const {
entities, total, loading: entitiesLoading,
page, perPage, sortBy, sortDir,
filters, fetchEntities,
} = useEntities()
const selectedEntity = ref<Entity | null>(null)
function onFilter() {
page.value = 1
fetchEntities()
}
function onTableOptions(opts: any) {
page.value = opts.page
perPage.value = opts.itemsPerPage
if (opts.sortBy?.length) {
sortBy.value = opts.sortBy[0].key
sortDir.value = opts.sortBy[0].order
}
fetchEntities()
}
function onSelectEntity(entity: Entity) {
selectedEntity.value = entity
}
async function onEntityActionDone() {
// Recharger les détails de l'entité sélectionnée + la liste
if (selectedEntity.value) {
try {
selectedEntity.value = await api.entity(selectedEntity.value.entity_id)
} catch { /* ignore */ }
}
fetchEntities()
}
onMounted(fetchEntities)
</script>

111
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,111 @@
const BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json()
}
export interface HealthResponse {
status: string
ha_connected: boolean
ha_message: string
entity_count: number
scan_status: string
last_scan: string | null
progress: number
total: number
error: string
}
export interface Entity {
entity_id: string
domain: string
friendly_name: string
state: string
attrs_json: string
device_class: string | null
unit_of_measurement: string | null
area_id: string | null
device_id: string | null
integration: string | null
is_disabled: boolean
is_hidden: boolean
is_available: boolean
last_changed: string | null
last_updated: string | null
fetched_at: string
favorite: boolean
ignored_local: boolean
notes: string
original_state: string | null
disabled_at: string | null
}
export interface EntitiesResponse {
items: Entity[]
total: number
page: number
per_page: number
pages: number
}
export interface AuditEntry {
id: number
ts: string
action: string
entity_ids_json: string
result: string
error: string
}
export interface AuditResponse {
items: AuditEntry[]
total: number
page: number
per_page: number
}
export interface FilterValues {
domains: string[]
areas: string[]
integrations: string[]
device_classes: string[]
}
export const api = {
health: () => request<HealthResponse>('/health'),
scan: () => request<{ message: string }>('/scan', { method: 'POST' }),
entities: (params: Record<string, string | number | boolean>) => {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== '' && v !== undefined && v !== null) qs.set(k, String(v))
}
return request<EntitiesResponse>(`/entities?${qs}`)
},
entity: (id: string) => request<Entity>(`/entities/${encodeURIComponent(id)}`),
filterValues: () => request<FilterValues>('/entities/filters'),
action: (action: string, entityIds: string[]) =>
request<{ action: string; results: any[] }>('/entities/actions', {
method: 'POST',
body: JSON.stringify({ action, entity_ids: entityIds }),
}),
audit: (params: Record<string, string | number> = {}) => {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== '' && v !== undefined) qs.set(k, String(v))
}
return request<AuditResponse>(`/audit?${qs}`)
},
}

View File

@@ -0,0 +1,98 @@
<template>
<v-card flat>
<v-card-title class="d-flex align-center">
<v-icon start>mdi-clipboard-text-clock</v-icon>
Journal des actions
</v-card-title>
<v-data-table-server
:headers="headers"
:items="items"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="perPage"
density="compact"
@update:options="onOptions"
>
<template #item.ts="{ item }">
<span class="text-caption">{{ formatDate(item.ts) }}</span>
</template>
<template #item.action="{ item }">
<v-chip size="small" :color="actionColor(item.action)">
{{ item.action }}
</v-chip>
</template>
<template #item.entity_ids_json="{ item }">
<span class="text-caption">{{ formatEntities(item.entity_ids_json) }}</span>
</template>
<template #item.result="{ item }">
<v-chip size="x-small" :color="item.error ? 'error' : 'success'" variant="tonal">
{{ item.error || item.result }}
</v-chip>
</template>
</v-data-table-server>
</v-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api, type AuditEntry } from '@/api'
const items = ref<AuditEntry[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const perPage = ref(50)
const headers = [
{ title: 'Date', key: 'ts', sortable: false },
{ title: 'Action', key: 'action', sortable: false },
{ title: 'Entités', key: 'entity_ids_json', sortable: false },
{ title: 'Résultat', key: 'result', sortable: false },
]
async function fetchAudit() {
loading.value = true
try {
const data = await api.audit({ page: page.value, per_page: perPage.value })
items.value = data.items
total.value = data.total
} catch (e) {
console.error('Erreur audit:', e)
} finally {
loading.value = false
}
}
function onOptions(opts: any) {
page.value = opts.page
perPage.value = opts.itemsPerPage
fetchAudit()
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR')
}
function actionColor(action: string): string {
const map: Record<string, string> = {
disable: 'error', enable: 'success',
favorite: 'warning', unfavorite: 'grey',
ignore: 'grey', unignore: 'secondary',
scan: 'info',
}
return map[action] || 'default'
}
function formatEntities(json: string): string {
try {
const ids = JSON.parse(json) as string[]
if (ids.length <= 3) return ids.join(', ')
return `${ids.slice(0, 3).join(', ')} +${ids.length - 3}`
} catch {
return json
}
}
onMounted(fetchAudit)
</script>

View File

@@ -0,0 +1,172 @@
<template>
<v-navigation-drawer
:model-value="!!entity"
location="right"
width="450"
temporary
@update:model-value="!$event && emit('close')"
>
<template v-if="entity">
<v-toolbar flat density="compact" color="surface">
<v-toolbar-title class="text-body-1 font-weight-bold">
{{ entity.friendly_name || entity.entity_id }}
</v-toolbar-title>
<v-btn icon="mdi-close" variant="text" @click="emit('close')" />
</v-toolbar>
<v-card flat>
<v-card-text>
<!-- Infos clés -->
<v-list density="compact" class="pa-0">
<v-list-item>
<template #prepend><v-icon icon="mdi-identifier" size="small" /></template>
<v-list-item-title class="text-caption">Entity ID</v-list-item-title>
<v-list-item-subtitle class="text-body-2">{{ entity.entity_id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend><v-icon icon="mdi-tag" size="small" /></template>
<v-list-item-title class="text-caption">Domaine</v-list-item-title>
<v-list-item-subtitle>{{ entity.domain }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend><v-icon icon="mdi-circle" size="small" :color="entity.is_available ? 'success' : 'error'" /></template>
<v-list-item-title class="text-caption">État</v-list-item-title>
<v-list-item-subtitle>
<v-chip size="small" :color="entity.state === 'on' ? 'success' : 'default'">
{{ entity.state }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.device_class">
<template #prepend><v-icon icon="mdi-devices" size="small" /></template>
<v-list-item-title class="text-caption">Device Class</v-list-item-title>
<v-list-item-subtitle>{{ entity.device_class }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.integration">
<template #prepend><v-icon icon="mdi-puzzle" size="small" /></template>
<v-list-item-title class="text-caption">Intégration</v-list-item-title>
<v-list-item-subtitle>{{ entity.integration }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.area_id">
<template #prepend><v-icon icon="mdi-home-map-marker" size="small" /></template>
<v-list-item-title class="text-caption">Zone</v-list-item-title>
<v-list-item-subtitle>{{ entity.area_id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.unit_of_measurement">
<template #prepend><v-icon icon="mdi-ruler" size="small" /></template>
<v-list-item-title class="text-caption">Unité</v-list-item-title>
<v-list-item-subtitle>{{ entity.unit_of_measurement }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-3" />
<!-- Badges statut -->
<div class="d-flex flex-wrap ga-2 mb-3">
<v-chip v-if="entity.is_disabled" color="error" size="small" prepend-icon="mdi-power-off">
Désactivé (HA)
</v-chip>
<v-chip v-if="entity.ignored_local" color="grey" size="small" prepend-icon="mdi-eye-off">
Ignoré (local)
</v-chip>
<v-chip v-if="entity.favorite" color="warning" size="small" prepend-icon="mdi-star">
Favori
</v-chip>
<v-chip v-if="!entity.is_available" color="error" size="small" variant="outlined" prepend-icon="mdi-alert">
Indisponible
</v-chip>
</div>
<!-- État original (si désactivé) -->
<v-alert
v-if="entity.original_state && (entity.is_disabled || entity.ignored_local)"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
icon="mdi-history"
>
<div class="text-body-2">
<strong>État avant désactivation :</strong> {{ entity.original_state }}
</div>
<div v-if="entity.disabled_at" class="text-caption text-medium-emphasis">
Désactivé le {{ new Date(entity.disabled_at).toLocaleString('fr-FR') }}
</div>
</v-alert>
<!-- Actions -->
<div class="d-flex flex-wrap ga-2 mb-4">
<v-btn
size="small"
:color="entity.favorite ? 'grey' : 'warning'"
variant="outlined"
:prepend-icon="entity.favorite ? 'mdi-star-off' : 'mdi-star'"
@click="doAction(entity.favorite ? 'unfavorite' : 'favorite')"
>
{{ entity.favorite ? 'Retirer favori' : 'Favori' }}
</v-btn>
<v-btn
size="small"
:color="entity.ignored_local ? 'secondary' : 'grey'"
variant="outlined"
:prepend-icon="entity.ignored_local ? 'mdi-eye' : 'mdi-eye-off'"
@click="doAction(entity.ignored_local ? 'unignore' : 'ignore')"
>
{{ entity.ignored_local ? 'Restaurer' : 'Ignorer' }}
</v-btn>
<v-btn
size="small"
:color="entity.is_disabled ? 'success' : 'error'"
variant="outlined"
:prepend-icon="entity.is_disabled ? 'mdi-power' : 'mdi-power-off'"
@click="doAction(entity.is_disabled ? 'enable' : 'disable')"
>
{{ entity.is_disabled ? 'Réactiver' : 'Désactiver' }}
</v-btn>
</div>
<!-- Attributs bruts -->
<v-expansion-panels variant="accordion">
<v-expansion-panel title="Attributs bruts (JSON)">
<v-expansion-panel-text>
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(entity.attrs_json) }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</template>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import type { Entity } from '@/api'
import { api } from '@/api'
const props = defineProps<{
entity: Entity | null
}>()
const emit = defineEmits<{
close: []
refresh: []
}>()
async function doAction(action: string) {
if (!props.entity) return
try {
await api.action(action, [props.entity.entity_id])
emit('refresh')
} catch (e) {
console.error('Erreur action:', e)
}
}
function formatJson(jsonStr: string): string {
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
</script>

View File

@@ -0,0 +1,176 @@
<template>
<v-data-table-server
v-model="selected"
:headers="headers"
:items="entities"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="perPage"
show-select
item-value="entity_id"
density="compact"
class="elevation-1"
@update:options="onOptions"
@click:row="(_e: any, { item }: any) => emit('select', item)"
>
<template #item.entity_id="{ item }">
<span class="text-body-2 font-weight-medium">{{ item.entity_id }}</span>
</template>
<template #item.state="{ item }">
<div class="d-flex align-center ga-1">
<v-chip
:color="stateColor(item.state)"
size="small"
variant="tonal"
>
{{ item.state }}
</v-chip>
<v-chip
v-if="item.original_state && (item.is_disabled || item.ignored_local)"
size="x-small"
variant="outlined"
color="warning"
prepend-icon="mdi-history"
>
était : {{ item.original_state }}
</v-chip>
</div>
</template>
<template #item.is_available="{ item }">
<v-icon
:icon="item.is_available ? 'mdi-check-circle' : 'mdi-alert-circle'"
:color="item.is_available ? 'success' : 'error'"
size="small"
/>
</template>
<template #item.enabled="{ item }">
<div @click.stop>
<v-switch
:model-value="!item.is_disabled && !item.ignored_local"
density="compact"
hide-details
color="success"
@update:model-value="(val: boolean | null) => toggleEntity(item, !!val)"
/>
</div>
</template>
<template #item.favorite="{ item }">
<v-icon
v-if="item.favorite"
icon="mdi-star"
color="warning"
size="small"
/>
</template>
<template #item.area_id="{ item }">
<span class="text-caption">{{ item.area_id || '-' }}</span>
</template>
<template #item.last_changed="{ item }">
<span class="text-caption">{{ formatDate(item.last_changed) }}</span>
</template>
<template #top>
<v-toolbar flat density="compact" v-if="selected.length > 0">
<v-toolbar-title class="text-body-2">
{{ selected.length }} sélectionnée(s)
</v-toolbar-title>
<v-spacer />
<v-btn size="small" variant="outlined" color="warning" class="mr-2" @click="bulkAction('favorite')">
<v-icon start>mdi-star</v-icon> Favori
</v-btn>
<v-btn size="small" variant="outlined" color="grey" class="mr-2" @click="bulkAction('ignore')">
<v-icon start>mdi-eye-off</v-icon> Ignorer
</v-btn>
<v-btn size="small" variant="outlined" color="error" class="mr-2" @click="bulkAction('disable')">
<v-icon start>mdi-power-off</v-icon> Désactiver
</v-btn>
<v-btn size="small" variant="outlined" color="success" @click="bulkAction('enable')">
<v-icon start>mdi-power</v-icon> Activer
</v-btn>
</v-toolbar>
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Entity } from '@/api'
import { api } from '@/api'
defineProps<{
entities: Entity[]
total: number
loading: boolean
page: number
perPage: number
}>()
const emit = defineEmits<{
select: [entity: Entity]
'update:options': [options: { page: number; itemsPerPage: number; sortBy: { key: string; order: string }[] }]
refresh: []
}>()
const selected = ref<string[]>([])
const headers = [
{ title: 'Entity ID', key: 'entity_id', sortable: true },
{ title: 'Nom', key: 'friendly_name', sortable: true },
{ title: 'Domaine', key: 'domain', sortable: true },
{ title: 'Pièce', key: 'area_id', sortable: true },
{ title: 'État', key: 'state', sortable: true },
{ title: 'Dispo', key: 'is_available', sortable: true, width: '70px' },
{ title: 'Actif', key: 'enabled', sortable: false, width: '80px' },
{ title: '', key: 'favorite', sortable: false, width: '50px' },
{ title: 'Modifié', key: 'last_changed', sortable: true },
]
function stateColor(state: string): string {
switch (state) {
case 'on': return 'success'
case 'off': return 'default'
case 'unavailable': return 'error'
case 'unknown': return 'warning'
default: return 'info'
}
}
function formatDate(iso: string | null): string {
if (!iso) return '-'
return new Date(iso).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
})
}
function onOptions(opts: any) {
emit('update:options', opts)
}
async function toggleEntity(entity: Entity, enabled: boolean) {
try {
const action = enabled ? 'enable' : 'disable'
await api.action(action, [entity.entity_id])
emit('refresh')
} catch (e) {
console.error('Erreur toggle:', e)
}
}
async function bulkAction(action: string) {
if (!selected.value.length) return
try {
await api.action(action, selected.value)
selected.value = []
emit('refresh')
} catch (e) {
console.error('Erreur action bulk:', e)
}
}
</script>

View File

@@ -0,0 +1,197 @@
<template>
<v-card flat class="mb-4">
<v-card-text>
<v-row dense align="center">
<v-col cols="12" md="3">
<v-text-field
v-model="filters.search"
label="Rechercher (entity_id, nom)"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.domain"
:items="domainItems"
label="Domaine"
multiple
clearable
density="compact"
hide-details
@update:model-value="onFilter"
>
<template #selection="{ item, index }">
<v-chip v-if="index < 2" size="small" closable @click:close="removeDomain(index)">
{{ item.title }}
</v-chip>
<span v-if="index === 2" class="text-caption text-medium-emphasis">
+{{ filters.domain.length - 2 }}
</span>
</template>
</v-select>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.area_id"
:items="areaItems"
label="Pièce"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="1">
<v-select
v-model="filters.state"
:items="stateItems"
label="État"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.available"
:items="availableItems"
label="Disponibilité"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.favorite"
:items="flagItems"
label="Favoris"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
</v-row>
<!-- Chips actives -->
<div v-if="activeFilters.length" class="mt-2 d-flex flex-wrap ga-1">
<v-chip
v-for="chip in activeFilters"
:key="chip.key"
size="small"
closable
color="primary"
variant="outlined"
@click:close="clearFilter(chip.key)"
>
{{ chip.label }}
</v-chip>
<v-btn
v-if="activeFilters.length > 1"
variant="text"
size="small"
color="error"
@click="clearAll"
>
Tout effacer
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Filters } from '@/composables/useEntities'
import { api } from '@/api'
const filters = defineModel<Filters>('filters', { required: true })
const emit = defineEmits<{
filter: []
}>()
const domainItems = ref<string[]>([])
const areaItems = ref<string[]>([])
onMounted(async () => {
try {
const fv = await api.filterValues()
domainItems.value = fv.domains
areaItems.value = fv.areas
} catch {
// Fallback statique si l'endpoint n'est pas encore dispo
domainItems.value = [
'automation', 'binary_sensor', 'button', 'camera', 'climate',
'cover', 'device_tracker', 'fan', 'input_boolean', 'light',
'lock', 'media_player', 'number', 'person', 'scene', 'script',
'select', 'sensor', 'sun', 'switch', 'timer', 'update', 'weather', 'zone',
]
}
})
const stateItems = [
{ title: 'on', value: 'on' },
{ title: 'off', value: 'off' },
{ title: 'unavailable', value: 'unavailable' },
{ title: 'unknown', value: 'unknown' },
]
const availableItems = [
{ title: 'Disponible', value: 'true' },
{ title: 'Indisponible', value: 'false' },
]
const flagItems = [
{ title: 'Oui', value: 'true' },
{ title: 'Non', value: 'false' },
]
const activeFilters = computed(() => {
const chips: { key: string; label: string }[] = []
if (filters.value.search) chips.push({ key: 'search', label: `Recherche : ${filters.value.search}` })
if (filters.value.domain.length) chips.push({ key: 'domain', label: `Domaine : ${filters.value.domain.join(', ')}` })
if (filters.value.area_id) chips.push({ key: 'area_id', label: `Pièce : ${filters.value.area_id}` })
if (filters.value.state) chips.push({ key: 'state', label: `État : ${filters.value.state}` })
if (filters.value.available) chips.push({ key: 'available', label: filters.value.available === 'true' ? 'Disponible' : 'Indisponible' })
if (filters.value.favorite) chips.push({ key: 'favorite', label: filters.value.favorite === 'true' ? 'Favoris' : 'Non favoris' })
if (filters.value.ignored) chips.push({ key: 'ignored', label: filters.value.ignored === 'true' ? 'Ignorés' : 'Non ignorés' })
return chips
})
function onFilter() {
emit('filter')
}
function removeDomain(index: number) {
filters.value.domain.splice(index, 1)
onFilter()
}
function clearFilter(key: string) {
if (key === 'domain') {
filters.value.domain = []
} else {
(filters.value as any)[key] = ''
}
onFilter()
}
function clearAll() {
filters.value.search = ''
filters.value.domain = []
filters.value.area_id = ''
filters.value.state = ''
filters.value.available = ''
filters.value.favorite = ''
filters.value.ignored = ''
onFilter()
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="d-flex align-center ga-3">
<v-btn
color="primary"
:loading="scanning"
:disabled="scanning"
prepend-icon="mdi-radar"
@click="triggerScan"
>
Scanner
</v-btn>
<div v-if="health" class="text-body-2 text-medium-emphasis">
<template v-if="health.scan_status === 'scanning'">
<v-progress-circular size="16" width="2" indeterminate class="mr-1" />
Scan en cours... {{ health.progress }}/{{ health.total }}
</template>
<template v-else-if="health.last_scan">
Dernier scan : {{ formatDate(health.last_scan) }}
&middot; {{ health.entity_count }} entités
</template>
<template v-else>
Aucun scan effectué
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { api, type HealthResponse } from '@/api'
const props = defineProps<{
health: HealthResponse | null
}>()
const emit = defineEmits<{
scanned: []
}>()
const scanning = computed(() => props.health?.scan_status === 'scanning')
async function triggerScan() {
try {
await api.scan()
emit('scanned')
} catch (e) {
console.error('Erreur scan:', e)
}
}
function formatDate(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = Math.floor((now.getTime() - d.getTime()) / 1000)
if (diff < 60) return 'il y a moins d\'une minute'
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`
return d.toLocaleString('fr-FR')
}
</script>

View File

@@ -0,0 +1,80 @@
import { ref, reactive, watch } from 'vue'
import { api, type Entity, type EntitiesResponse } from '@/api'
export interface Filters {
search: string
domain: string[]
state: string
available: string
device_class: string
integration: string
area_id: string
favorite: string
ignored: string
}
export function useEntities() {
const entities = ref<Entity[]>([])
const total = ref(0)
const pages = ref(0)
const loading = ref(false)
const page = ref(1)
const perPage = ref(50)
const sortBy = ref('entity_id')
const sortDir = ref<'asc' | 'desc'>('asc')
const filters = reactive<Filters>({
search: '',
domain: [],
state: '',
available: '',
device_class: '',
integration: '',
area_id: '',
favorite: '',
ignored: '',
})
async function fetchEntities() {
loading.value = true
try {
const params: Record<string, string | number | boolean> = {
page: page.value,
per_page: perPage.value,
sort_by: sortBy.value,
sort_dir: sortDir.value,
}
if (filters.search) params.search = filters.search
if (filters.domain.length) params.domain = filters.domain.join(',')
if (filters.state) params.state = filters.state
if (filters.available) params.available = filters.available === 'true'
if (filters.device_class) params.device_class = filters.device_class
if (filters.integration) params.integration = filters.integration
if (filters.area_id) params.area_id = filters.area_id
if (filters.favorite) params.favorite = filters.favorite === 'true'
if (filters.ignored) params.ignored = filters.ignored === 'true'
const data = await api.entities(params)
entities.value = data.items
total.value = data.total
pages.value = data.pages
} catch (e) {
console.error('Erreur chargement entités:', e)
} finally {
loading.value = false
}
}
return {
entities,
total,
pages,
loading,
page,
perPage,
sortBy,
sortDir,
filters,
fetchEntities,
}
}

View File

@@ -0,0 +1,30 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { api, type HealthResponse } from '@/api'
export function useHealth() {
const health = ref<HealthResponse | null>(null)
const loading = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
async function fetchHealth() {
loading.value = true
try {
health.value = await api.health()
} catch (e) {
health.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
fetchHealth()
timer = setInterval(fetchHealth, 5000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
return { health, loading, fetchHealth }
}

73
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,73 @@
import { createApp } from 'vue'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as labsComponents from 'vuetify/labs/components'
import * as directives from 'vuetify/directives'
import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css'
import App from './App.vue'
// Gruvbox Dark (Seventies) palette
const vuetify = createVuetify({
components: { ...components, ...labsComponents },
directives,
theme: {
defaultTheme: 'gruvboxDark',
themes: {
gruvboxDark: {
dark: true,
colors: {
background: '#1d2021',
surface: '#282828',
'surface-variant': '#3c3836',
'on-surface': '#ebdbb2',
'on-background': '#ebdbb2',
primary: '#d79921', // Gruvbox yellow
'primary-darken-1': '#b57614',
secondary: '#689d6a', // Gruvbox aqua
'secondary-darken-1': '#427b58',
error: '#cc241d', // Gruvbox red
warning: '#d65d0e', // Gruvbox orange
info: '#458588', // Gruvbox blue
success: '#98971a', // Gruvbox green
'on-primary': '#1d2021',
'on-secondary': '#1d2021',
'on-error': '#1d2021',
'on-warning': '#1d2021',
'on-info': '#ebdbb2',
'on-success': '#1d2021',
},
variables: {
'border-color': '#504945',
'border-opacity': 0.4,
'high-emphasis-opacity': 0.95,
'medium-emphasis-opacity': 0.7,
'disabled-opacity': 0.4,
},
},
},
},
defaults: {
VDataTableServer: {
fixedHeader: true,
hover: true,
},
VCard: {
color: 'surface',
},
VAppBar: {
color: '#32302f',
},
VNavigationDrawer: {
color: '#32302f',
},
VToolbar: {
color: 'surface',
},
VChip: {
variant: 'tonal',
},
},
})
createApp(App).use(vuetify).mount('#app')

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/vite-env.d.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/api.ts","./src/main.ts","./src/vite-env.d.ts","./src/composables/useEntities.ts","./src/composables/useHealth.ts","./src/App.vue","./src/components/AuditLog.vue","./src/components/EntityDetail.vue","./src/components/EntityTable.vue","./src/components/FilterBar.vue","./src/components/ScanButton.vue"],"version":"5.6.3"}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000'
}
}
})