feat: dry-run modal on non-compliant badge click

Replace the separate Dry Run tab with a modal triggered by clicking
a non-compliant badge in the Compliance tab. Shows parameter diffs,
firmware path, and a link to the device page.
This commit is contained in:
Kevin L'Hereec committed 2026-06-02 10:30:23 +02:00
1 parent 0109bca236
commit bf98c9f405
1 file changed
+97 -76
@@ -13,11 +13,12 @@ const configuration = ref<any>(null)
const versionHistory = ref<any[]>([])
const activeTab = ref('general')
// Dry run state
// 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)
@@ -30,7 +31,6 @@ const tabs = [
{ key: 'inventory', label: t('configDetail.tabInventory') },
{ key: 'parameters', label: t('configDetail.tabParameters') },
{ key: 'compliance', label: t('configDetail.tabCompliance') },
{ key: 'dryrun', label: t('configDetail.tabDryRun') },
{ key: 'history', label: t('configDetail.tabHistory') },
]
@@ -57,16 +57,21 @@ async function loadHistory() {
} catch {}
}
async function runDryRun() {
if (!dryRunDeviceId.value.trim()) return
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 fetch(`/api/v1/configurations/${props.id}/dry-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIdentifier: dryRunDeviceId.value.trim() }),
body: JSON.stringify({ deviceIdentifier: targetDevice }),
})
const data = await res.json()
if (!res.ok) {
@@ -81,6 +86,12 @@ async function runDryRun() {
}
}
function closeDryRunModal() {
showDryRunModal.value = false
dryRunResult.value = null
dryRunError.value = ''
}
async function loadCompliance() {
complianceLoading.value = true
complianceReport.value = null
@@ -307,7 +318,7 @@ function goToDevice(identifier: string) { router.get(`/devices/${identifier}`) }
<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">{{ t('configDetail.nonCompliant') }}</span>
<span v-else class="badge bg-danger" style="cursor:pointer" @click.stop="runDryRun(device.identifier)">{{ t('configDetail.nonCompliant') }}</span>
</td>
<td class="small text-muted">
<span v-if="device.parameterDiffs?.length">{{ device.parameterDiffs.length }} diff(s)</span>
@@ -326,76 +337,6 @@ function goToDevice(identifier: string) { router.get(`/devices/${identifier}`) }
</div>
</div>
<!-- Dry Run -->
<div v-show="activeTab === 'dryrun'" class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-3">{{ t('configDetail.dryRunDesc') }}</p>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{{ t('labels.deviceIdentifier') }}</label>
<input
v-model="dryRunDeviceId"
type="text"
class="form-control form-control-sm font-monospace"
:placeholder="t('configDetail.dryRunPlaceholder')"
@keyup.enter="runDryRun"
/>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary btn-sm w-100" :disabled="dryRunLoading || !dryRunDeviceId.trim()" @click="runDryRun">
<span v-if="dryRunLoading" class="spinner-border spinner-border-sm me-1" />
{{ t('configDetail.runDryRun') }}
</button>
</div>
</div>
<div v-if="dryRunError" class="alert alert-danger py-2 small">{{ dryRunError }}</div>
<template v-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>
<!-- History -->
<div v-show="activeTab === 'history'" class="card border-0 shadow-sm"> <div class="card-body p-0">
<div v-if="configuration || versionHistory.length">
@@ -434,5 +375,85 @@ function goToDevice(identifier: string) { router.get(`/devices/${identifier}`) }
</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>