first
This commit is contained in:
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
16
frontend/nginx.conf
Normal 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
1535
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
128
frontend/src/App.vue
Normal 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
111
frontend/src/api.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
98
frontend/src/components/AuditLog.vue
Normal file
98
frontend/src/components/AuditLog.vue
Normal 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>
|
||||
172
frontend/src/components/EntityDetail.vue
Normal file
172
frontend/src/components/EntityDetail.vue
Normal 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>
|
||||
176
frontend/src/components/EntityTable.vue
Normal file
176
frontend/src/components/EntityTable.vue
Normal 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>
|
||||
197
frontend/src/components/FilterBar.vue
Normal file
197
frontend/src/components/FilterBar.vue
Normal 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>
|
||||
60
frontend/src/components/ScanButton.vue
Normal file
60
frontend/src/components/ScanButton.vue
Normal 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) }}
|
||||
· {{ 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>
|
||||
80
frontend/src/composables/useEntities.ts
Normal file
80
frontend/src/composables/useEntities.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
30
frontend/src/composables/useHealth.ts
Normal file
30
frontend/src/composables/useHealth.ts
Normal 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
73
frontend/src/main.ts
Normal 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
7
frontend/src/vite-env.d.ts
vendored
Normal 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
19
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal 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
18
frontend/vite.config.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user