Files
adam-acs/inertia/pages/configurations/ConfigurationDetail.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

455 lines
19 KiB
Vue

<script setup lang="ts">
import { ref, onMounted } 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 ComplianceBadge from '~/components/configuration/ComplianceBadge.vue'
import { fetchWithCsrf } from '~/helpers/fetch_with_csrf'
const { t } = useI18n()
const props = defineProps<{ id: string }>()
const configuration = ref<any>(null)
const versionHistory = ref<any[]>([])
const activeTab = ref('general')
// Dry run state (modal)
const dryRunDeviceId = ref('')
const dryRunLoading = ref(false)
const dryRunResult = ref<any>(null)
const dryRunError = ref('')
const showDryRunModal = ref(false)
// Compliance state
const complianceLoading = ref(false)
const complianceReport = ref<any>(null)
const complianceError = ref('')
const tabs = [
{ key: 'general', label: t('configDetail.tabGeneral') },
{ key: 'criteria', label: t('configDetail.tabCriteria') },
{ key: 'inventory', label: t('configDetail.tabInventory') },
{ key: 'parameters', label: t('configDetail.tabParameters') },
{ key: 'compliance', label: t('configDetail.tabCompliance') },
{ key: 'history', label: t('configDetail.tabHistory') },
]
onMounted(async () => {
await loadConfiguration()
loadHistory()
})
async function loadConfiguration() {
try {
const res = await fetch(`/internal/configurations/${props.id}`)
const data = await res.json()
configuration.value = data.data ?? data
} catch {
router.get('/configurations')
}
}
async function loadHistory() {
try {
const res = await fetch(`/internal/configurations/${props.id}/history`)
const data = await res.json()
versionHistory.value = data.data || []
} catch {}
}
async function runDryRun(deviceIdentifier?: string) {
const targetDevice = deviceIdentifier || dryRunDeviceId.value.trim()
if (!targetDevice) return
dryRunDeviceId.value = targetDevice
dryRunLoading.value = true
dryRunResult.value = null
dryRunError.value = ''
showDryRunModal.value = true
try {
const res = await fetchWithCsrf(`/internal/configurations/${props.id}/dry-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIdentifier: targetDevice }),
})
const data = await res.json()
if (!res.ok) {
dryRunError.value = data.message || t('configDetail.dryRunError')
} else {
dryRunResult.value = data
}
} catch {
dryRunError.value = t('configDetail.dryRunError')
} finally {
dryRunLoading.value = false
}
}
function closeDryRunModal() {
showDryRunModal.value = false
dryRunResult.value = null
dryRunError.value = ''
}
async function loadCompliance() {
complianceLoading.value = true
complianceReport.value = null
complianceError.value = ''
try {
const res = await fetch(`/internal/configurations/${props.id}/compliance`)
const data = await res.json()
if (!res.ok) {
complianceError.value = data.message || t('configDetail.complianceError')
} else {
complianceReport.value = data
}
} catch {
complianceError.value = t('configDetail.complianceError')
} finally {
complianceLoading.value = false
}
}
function switchTab(key: string) {
activeTab.value = key
if (key === 'compliance' && !complianceReport.value && !complianceLoading.value) {
loadCompliance()
}
}
function goToEdit() { router.get(`/configurations/${props.id}/edit`) }
function goToList() { router.get('/configurations') }
function goToDevice(identifier: string) { router.get(`/devices/${identifier}`) }
</script>
<template>
<TitleBar section="configurations" :subtitle="configuration?.name ?? ''" />
<div class="container-xxl py-4">
<div v-if="!configuration" class="text-center py-5 text-muted">Loading…</div>
<template v-else>
<!-- Header row -->
<div class="d-flex justify-content-between align-items-start mb-4">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary font-monospace">{{ configuration.identifier }}</span>
<span class="text-muted small">v{{ configuration.version }}</span>
<PolicyBadge :policy="configuration.policy" />
<ComplianceBadge v-if="configuration.compliance?.status" :status="configuration.compliance.status" />
<span class="badge" :class="configuration.enabled ? 'bg-success' : 'bg-light text-dark border'">
{{ configuration.enabled ? t('labels.enabled') : t('labels.disabled') }}
</span>
<span v-if="configuration.priority" class="badge bg-secondary">{{ t('labels.priority') }} {{ configuration.priority }}</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" @click="goToList">← {{ t('button.cancel') }}</button>
<button class="btn btn-primary btn-sm" @click="goToEdit">✏ {{ t('button.edit') }}</button>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3">
<li v-for="tab in tabs" :key="tab.key" class="nav-item">
<button class="nav-link" :class="{ active: activeTab === tab.key }" @click="switchTab(tab.key)">
{{ tab.label }}
</button>
</li>
</ul>
<!-- General -->
<div v-show="activeTab === 'general'" class="card border-0 shadow-sm">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3 text-muted small">{{ t('labels.identifier') }}</dt>
<dd class="col-sm-9 font-monospace small">{{ configuration.identifier }}</dd>
<dt class="col-sm-3 text-muted small">{{ t('labels.name') }}</dt>
<dd class="col-sm-9 small">{{ configuration.name }}</dd>
<dt class="col-sm-3 text-muted small">{{ t('labels.version') }}</dt>
<dd class="col-sm-9 small">{{ configuration.version }}</dd>
<dt class="col-sm-3 text-muted small">{{ t('labels.priority') }}</dt>
<dd class="col-sm-9 small">{{ configuration.priority }}</dd>
<dt class="col-sm-3 text-muted small">{{ t('labels.policy') }}</dt>
<dd class="col-sm-9"><PolicyBadge :policy="configuration.policy" /></dd>
<dt class="col-sm-3 text-muted small">{{ t('labels.tags') }}</dt>
<dd class="col-sm-9">
<div class="d-flex flex-wrap gap-1">
<span v-for="tag in configuration.tags" :key="tag" class="badge bg-light text-dark border small">{{ tag }}</span>
<span v-if="!configuration.tags?.length" class="text-muted small">—</span>
</div>
</dd>
<dt class="col-sm-3 text-muted small">{{ t('labels.updatedAt') }}</dt>
<dd class="col-sm-9 small">{{ configuration.updatedAt ? new Date(configuration.updatedAt).toLocaleString() : '—' }}</dd>
</dl>
</div>
</div>
<!-- Criteria -->
<div v-show="activeTab === 'criteria'" class="card border-0 shadow-sm">
<div class="card-body">
<!-- DeviceId criteria -->
<div v-if="Object.keys(configuration.criteria || {}).filter(k => k !== 'parameterCriteria').length" class="row g-2 mb-3">
<div v-for="(val, key) in configuration.criteria" :key="key" class="col-md-6">
<template v-if="key !== 'parameterCriteria'">
<div class="bg-light rounded p-2">
<div class="text-muted small text-capitalize">{{ key }}</div>
<div class="font-monospace small fw-semibold">{{ val }}</div>
</div>
</template>
</div>
</div>
<p v-else-if="!(configuration.criteria?.parameterCriteria?.length)" class="text-muted text-center py-3 mb-0">No criteria defined — applies to all devices.</p>
<!-- Parameter criteria -->
<template v-if="configuration.criteria?.parameterCriteria?.length">
<div class="fw-semibold small mb-2 mt-2">Parameter Conditions</div>
<table class="table table-sm table-bordered font-monospace small mb-0">
<thead>
<tr>
<th>Parameter</th>
<th>Expected value</th>
</tr>
</thead>
<tbody>
<tr v-for="(pc, i) in configuration.criteria.parameterCriteria" :key="i">
<td>{{ pc.name }}</td>
<td>{{ pc.value }}</td>
</tr>
</tbody>
</table>
</template>
</div>
</div>
<!-- Inventory -->
<div v-show="activeTab === 'inventory'" class="card border-0 shadow-sm">
<div class="card-body p-0">
<div v-if="configuration.inventory?.length">
<ul class="list-group list-group-flush">
<li v-for="(item, i) in configuration.inventory" :key="i" class="list-group-item font-monospace small">
{{ item.path }}
</li>
</ul>
</div>
<p v-else class="text-muted text-center py-3 mb-0">No inventory paths defined.</p>
</div>
</div>
<!-- Parameters -->
<div v-show="activeTab === 'parameters'" class="card border-0 shadow-sm">
<div class="card-body p-0">
<div v-if="configuration.desiredParameters?.length">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr v-for="param in configuration.desiredParameters" :key="param.name">
<td class="font-monospace small">{{ param.name }}</td>
<td class="small">{{ param.value }}</td>
<td><span class="badge bg-light text-dark border small">{{ param.type }}</span></td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-muted text-center py-3 mb-0">No parameters defined.</p>
</div>
</div>
<!-- Compliance -->
<div v-show="activeTab === 'compliance'" class="card border-0 shadow-sm">
<div class="card-body">
<div v-if="complianceLoading" class="text-center py-4 text-muted">{{ t('labels.loading') }}</div>
<div v-else-if="complianceError" class="alert alert-danger">{{ complianceError }}</div>
<template v-else-if="complianceReport">
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-4">
<div class="border rounded p-3 text-center">
<div class="fs-4 fw-bold">{{ complianceReport.totalDevices }}</div>
<div class="text-muted small">{{ t('configDetail.totalDevices') }}</div>
</div>
</div>
<div class="col-4">
<div class="border rounded p-3 text-center border-success">
<div class="fs-4 fw-bold text-success">{{ complianceReport.compliantCount }}</div>
<div class="text-muted small">{{ t('configDetail.compliant') }}</div>
</div>
</div>
<div class="col-4">
<div class="border rounded p-3 text-center border-danger">
<div class="fs-4 fw-bold text-danger">{{ complianceReport.nonCompliantCount }}</div>
<div class="text-muted small">{{ t('configDetail.nonCompliant') }}</div>
</div>
</div>
</div>
<!-- Device list -->
<div v-if="complianceReport.devices.length === 0" class="text-muted text-center py-3">
{{ t('configDetail.noMatchingDevices') }}
</div>
<table v-else class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>{{ t('labels.identifier') }}</th>
<th>{{ t('labels.manufacturer') }}</th>
<th>{{ t('labels.productClass') }}</th>
<th>{{ t('labels.serialNumber') }}</th>
<th>{{ t('labels.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="device in complianceReport.devices" :key="device.identifier"
class="cursor-pointer" @click="goToDevice(device.identifier)">
<td class="font-monospace small">{{ device.identifier }}</td>
<td class="small">{{ device.deviceId?.manufacturer ?? '—' }}</td>
<td class="small">{{ device.deviceId?.productClass ?? '—' }}</td>
<td class="small font-monospace">{{ device.deviceId?.serialNumber ?? '—' }}</td>
<td>
<span v-if="device.compliant" class="badge bg-success">{{ t('configDetail.compliant') }}</span>
<span v-else class="badge bg-danger" style="cursor:pointer" @click.stop="runDryRun(device.identifier)">{{ t('configDetail.nonCompliant') }}</span>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="text-center py-4">
<button class="btn btn-outline-primary btn-sm" @click="loadCompliance">
{{ t('configDetail.loadCompliance') }}
</button>
</div>
</div>
</div>
<!-- History -->
<div v-show="activeTab === 'history'" class="card border-0 shadow-sm"> <div class="card-body p-0">
<div v-if="configuration || versionHistory.length">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>{{ t('labels.version') }}</th>
<th>{{ t('labels.name') }}</th>
<th>{{ t('labels.policy') }}</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<!-- Current version always first -->
<tr v-if="configuration" class="table-active fw-semibold">
<td class="text-muted small">—</td>
<td class="small font-monospace">{{ configuration.version }} <span class="badge bg-dark ms-1">current</span></td>
<td class="small">{{ configuration.name }}</td>
<td class="small"><PolicyBadge v-if="configuration.policy" :policy="configuration.policy" /></td>
<td class="text-muted small">{{ configuration.updatedAt ? new Date(configuration.updatedAt).toLocaleString() : '—' }}</td>
</tr>
<tr v-for="entry in versionHistory" :key="entry._id">
<td class="text-muted small">{{ entry.versionNumber }}</td>
<td class="small font-monospace">{{ entry.snapshot?.version ?? '—' }}</td>
<td class="small">{{ entry.snapshot?.name ?? '—' }}</td>
<td class="small"><PolicyBadge v-if="entry.snapshot?.policy" :policy="entry.snapshot.policy" /></td>
<td class="text-muted small">{{ entry.modifiedAt ? new Date(entry.modifiedAt).toLocaleString() : '—' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-muted text-center py-3 mb-0">No history available.</p>
</div>
</div>
</template>
</div>
<!-- Dry Run Modal -->
<Teleport to="body">
<div v-if="showDryRunModal" class="modal-backdrop fade show" @click="closeDryRunModal"></div>
<div
v-if="showDryRunModal"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-labelledby="dryRunModalLabel"
aria-modal="true"
>
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 id="dryRunModalLabel" class="modal-title">
Dry Run — <span class="font-monospace">{{ dryRunDeviceId }}</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeDryRunModal"></button>
</div>
<div class="modal-body">
<div v-if="dryRunLoading" class="text-center py-4 text-muted">
<span class="spinner-border spinner-border-sm me-2"></span>Evaluating…
</div>
<div v-else-if="dryRunError" class="alert alert-danger py-2 small">{{ dryRunError }}</div>
<template v-else-if="dryRunResult">
<!-- Compliance status -->
<div class="alert py-2 small mb-3"
:class="dryRunResult.evaluation.isAlreadyCompliant ? 'alert-success' : 'alert-warning'">
<strong>{{ dryRunResult.evaluation.isAlreadyCompliant ? t('configDetail.compliant') : t('configDetail.nonCompliant') }}</strong>
<span v-if="dryRunResult.evaluation.skipFirmware" class="ms-2 text-muted">({{ t('configDetail.firmwareSkipped') }})</span>
</div>
<!-- Parameter diffs -->
<div v-if="dryRunResult.evaluation.parameterDiffs?.length" class="mb-3">
<div class="fw-semibold small mb-2">{{ t('configDetail.paramDiffs') }}</div>
<table class="table table-sm table-bordered font-monospace small mb-0">
<thead class="table-light">
<tr>
<th>{{ t('labels.parameterName') }}</th>
<th>{{ t('configDetail.currentValue') }}</th>
<th>{{ t('configDetail.targetValue') }}</th>
<th>{{ t('labels.type') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="diff in dryRunResult.evaluation.parameterDiffs" :key="diff.name" class="table-warning">
<td>{{ diff.name }}</td>
<td class="text-danger">{{ diff.currentValue ?? t('configDetail.notSet') }}</td>
<td class="text-success">{{ diff.targetValue }}</td>
<td>{{ diff.type }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-muted small">{{ t('configDetail.noParamDiffs') }}</p>
<!-- Firmware migration -->
<div v-if="dryRunResult.evaluation.firmwareMigrationPath?.length" class="mb-3">
<div class="fw-semibold small mb-2">{{ t('configDetail.firmwarePath') }}</div>
<ol class="small ps-3 mb-0">
<li v-for="(step, i) in dryRunResult.evaluation.firmwareMigrationPath" :key="i">
{{ step.from }} → {{ step.to }}
</li>
</ol>
</div>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" @click="goToDevice(dryRunDeviceId)">
Voir le device
</button>
<button type="button" class="btn btn-sm btn-secondary" @click="closeDryRunModal">Fermer</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>