Files
adam-acs/inertia/pages/configurations/ConfigurationsList.vue
T
kevin.lhereec 8cddf98093
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 1m6s
Build & Deploy / deploy (push) Failing after 33s
fix: CSRF token sur les requetes mutantes /internal + IPv6 Docker + tests Connection Request
- Ajout de fetchWithCsrf() helper qui lit le cookie XSRF-TOKEN et injecte
  X-XSRF-TOKEN sur toutes les requetes POST/PUT/PATCH/DELETE vers /internal/*
- Remplacement de fetch() par fetchWithCsrf() dans tous les composants Vue
  concernes (Tr069Trace, DeviceDetail, SequenceViewer, Home, configurations,
  firmware_catalog)
- Activation IPv6 sur les reseaux Docker (enable_ipv6: true, subnet fd00:adam::/64)
  dans docker-compose.yml et docker-compose.prod.yml
- Ajout tests unitaires DeviceService.sendWakeOnWlan : IPv4, IPv6, ENETUNREACH,
  credentials fallback chain (11 tests)
2026-07-17 10:16:29 +02:00

253 lines
9.6 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '~/components/TitleBar.vue'
import PolicyBadge from '~/components/configuration/PolicyBadge.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
const configurations = ref<any[]>([])
const availableTags = ref<string[]>([])
const page = ref(1)
const lastPage = ref(1)
const total = ref(0)
const loading = ref(false)
const filters = ref({
search: '',
tag: '',
policy: '',
enabled: '',
})
const hasActiveFilters = computed(() =>
Object.values(filters.value).some((v) => v !== '')
)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
await Promise.all([loadConfigurations(), loadTags()])
})
watch([page], loadConfigurations)
watch(
() => ({ ...filters.value }),
() => {
page.value = 1
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(loadConfigurations, 300)
},
{ deep: true }
)
async function loadTags() {
try {
const res = await fetch('/internal/configurations/tags')
if (res.ok) availableTags.value = await res.json()
} catch { /* ignore */ }
}
async function loadConfigurations() {
loading.value = true
try {
const params = new URLSearchParams({ page: page.value.toString(), perPage: '20' })
if (filters.value.search) params.append('name', filters.value.search)
if (filters.value.tag) params.append('tag', filters.value.tag)
if (filters.value.policy) params.append('policy', filters.value.policy)
if (filters.value.enabled !== '') params.append('enabled', filters.value.enabled)
const response = await fetch(`/internal/configurations?${params}`)
const data = await response.json()
configurations.value = data.data || []
lastPage.value = data.lastPage || 1
total.value = data.total || configurations.value.length
} catch (e) {
console.error('Failed to load configurations', e)
} finally {
loading.value = false
}
}
function clearFilters() {
filters.value = { search: '', tag: '', policy: '', enabled: '' }
page.value = 1
}
function removeFilter(key: keyof typeof filters.value) {
filters.value[key] = ''
page.value = 1
}
async function toggleEnabled(id: string, enabled: boolean) {
await fetchWithCsrf(`/internal/configurations/${id}/toggle`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
await loadConfigurations()
}
async function deleteConfig(id: string, name: string) {
if (!confirm(`Delete configuration "${name}\"?`)) return
await fetchWithCsrf(`/internal/configurations/${id}`, { method: 'DELETE' })
await loadConfigurations()
}
function goToCreate() { router.get('/configurations/create') }
function goToDetail(id: string) { router.get(`/configurations/${id}/detail`) }
function goToEdit(id: string) { router.get(`/configurations/${id}/edit`) }
</script>
<template>
<TitleBar section="configurations" />
<div class="container-xxl py-4">
<!-- Toolbar -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small text-muted mb-1">{{ t('labels.search') }}</label>
<input
type="text"
class="form-control form-control-sm"
v-model="filters.search"
:placeholder="t('placeholder.search')"
/>
</div>
<div class="col-md-2">
<label class="form-label small text-muted mb-1">{{ t('labels.tags') }}</label>
<select class="form-select form-select-sm" v-model="filters.tag" @change="page = 1">
<option value="">—</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted mb-1">{{ t('labels.policy') }}</label>
<select class="form-select form-select-sm" v-model="filters.policy" @change="page = 1">
<option value="">—</option>
<option value="once">{{ t('labels.once') }}</option>
<option value="maintain">{{ t('labels.maintain') }}</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted mb-1">{{ t('labels.status') }}</label>
<select class="form-select form-select-sm" v-model="filters.enabled" @change="page = 1">
<option value="">—</option>
<option value="true">{{ t('labels.enabled') }}</option>
<option value="false">{{ t('labels.disabled') }}</option>
</select>
</div>
<div class="col-md-1 d-flex gap-1">
<button v-if="hasActiveFilters" class="btn btn-outline-secondary btn-sm" @click="clearFilters">✕</button>
</div>
<div class="col text-end">
<button class="btn btn-primary btn-sm" @click="goToCreate">
+ {{ t('labels.newConfiguration') }}
</button>
</div>
</div>
</div>
</div>
<!-- Active filter badges -->
<div v-if="hasActiveFilters" class="mb-3 d-flex gap-2 flex-wrap">
<span v-if="filters.tag" class="badge bg-light text-dark border d-flex align-items-center gap-1">
{{ t('labels.tags') }}: <strong>{{ filters.tag }}</strong>
<button class="btn-close ms-1" style="font-size:0.55rem" @click="removeFilter('tag')" />
</span>
<span v-if="filters.policy" class="badge bg-light text-dark border d-flex align-items-center gap-1">
{{ t('labels.policy') }}: <strong>{{ filters.policy }}</strong>
<button class="btn-close ms-1" style="font-size:0.55rem" @click="removeFilter('policy')" />
</span>
<span v-if="filters.enabled !== ''" class="badge bg-light text-dark border d-flex align-items-center gap-1">
{{ t('labels.status') }}: <strong>{{ filters.enabled === 'true' ? t('labels.enabled') : t('labels.disabled') }}</strong>
<button class="btn-close ms-1" style="font-size:0.55rem" @click="removeFilter('enabled')" />
</span>
</div>
<!-- Results count -->
<p class="text-muted small mb-2">
<span v-if="loading">{{ t('labels.loading') }}</span>
<span v-else>{{ total }} {{ t('labels.results') }}</span>
</p>
<!-- Table -->
<div class="table-responsive">
<table class="table table-sm table-hover table-striped">
<thead class="table-light">
<tr>
<th>{{ t('labels.identifier') }}</th>
<th>{{ t('labels.name') }}</th>
<th class="text-center">{{ t('labels.priority') }}</th>
<th>{{ t('labels.policy') }}</th>
<th class="text-center">{{ t('labels.enabled') }}</th>
<th>{{ t('labels.tags') }}</th>
<th>{{ t('labels.updatedAt') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="config in configurations"
:key="config.identifier"
class="align-middle"
style="cursor: pointer"
@click="goToDetail(config.identifier)"
>
<td class="font-monospace small fw-semibold">{{ config.identifier }}</td>
<td class="small">{{ config.name }}</td>
<td class="text-center small">
<span class="badge bg-secondary">{{ config.priority }}</span>
</td>
<td><PolicyBadge :policy="config.policy" /></td>
<td class="text-center" @click.stop>
<div class="form-check form-switch d-flex justify-content-center mb-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
:checked="config.enabled"
@change="toggleEnabled(config.identifier, !config.enabled)"
/>
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
<span
v-for="tag in config.tags"
:key="tag"
class="badge bg-light text-dark border small"
>{{ tag }}</span>
</div>
</td>
<td class="text-muted small">
{{ config.updatedAt ? new Date(config.updatedAt).toLocaleDateString() : '—' }}
</td>
<td class="text-end" @click.stop>
<div class="d-flex gap-1 justify-content-end">
<button class="btn btn-outline-primary btn-sm" @click="goToEdit(config.identifier)">{{ t('button.edit') }}</button>
<button class="btn btn-outline-danger btn-sm" @click="deleteConfig(config.identifier, config.name)">{{ t('button.delete') }}</button>
</div>
</td>
</tr>
<tr v-if="!loading && configurations.length === 0">
<td colspan="8" class="text-center text-muted py-4">{{ t('labels.noResults') }}</td> </tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="lastPage > 1" class="d-flex justify-content-center align-items-center gap-3 mt-3">
<button class="btn btn-outline-secondary btn-sm" :disabled="page <= 1" @click="page--">← {{ t('button.previous') }}</button>
<span class="text-muted small">Page {{ page }} / {{ lastPage }}</span>
<button class="btn btn-outline-secondary btn-sm" :disabled="page >= lastPage" @click="page++">{{ t('button.next') }} →</button>
</div>
</div>
</template>