- DeviceDetail: display compliance status with badge + parameter diffs - Home: show compliance badge in device list - ConfigurationDetail: compliance report tab with device list + diffs - ConfigurationEdit: bumpType selector (patch/minor/major), remove version field - ConfigurationsList: tag filter, name search, compliance column - ComplianceBadge: pending/compliant/in_progress/failed/non_compliant states - CriteriaEditor: parameterCriteria editor with autocomplete on parameter names - AutocompleteInput: new reusable autocomplete component - i18n: compliance labels in fr/en Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
439 lines
19 KiB
Vue
439 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'
|
|
|
|
const { t } = useI18n()
|
|
const props = defineProps<{ id: string }>()
|
|
|
|
const configuration = ref<any>(null)
|
|
const versionHistory = ref<any[]>([])
|
|
const activeTab = ref('general')
|
|
|
|
// Dry run state
|
|
const dryRunDeviceId = ref('')
|
|
const dryRunLoading = ref(false)
|
|
const dryRunResult = ref<any>(null)
|
|
const dryRunError = ref('')
|
|
|
|
// 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: 'dryrun', label: t('configDetail.tabDryRun') },
|
|
{ key: 'history', label: t('configDetail.tabHistory') },
|
|
]
|
|
|
|
onMounted(async () => {
|
|
await loadConfiguration()
|
|
loadHistory()
|
|
})
|
|
|
|
async function loadConfiguration() {
|
|
try {
|
|
const res = await fetch(`/api/v1/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(`/api/v1/configurations/${props.id}/history`)
|
|
const data = await res.json()
|
|
versionHistory.value = data.data || []
|
|
} catch {}
|
|
}
|
|
|
|
async function runDryRun() {
|
|
if (!dryRunDeviceId.value.trim()) return
|
|
dryRunLoading.value = true
|
|
dryRunResult.value = null
|
|
dryRunError.value = ''
|
|
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() }),
|
|
})
|
|
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
|
|
}
|
|
}
|
|
|
|
async function loadCompliance() {
|
|
complianceLoading.value = true
|
|
complianceReport.value = null
|
|
complianceError.value = ''
|
|
try {
|
|
const res = await fetch(`/api/v1/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>
|
|
<th>{{ t('configDetail.diffs') }}</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">{{ t('configDetail.nonCompliant') }}</span>
|
|
</td>
|
|
<td class="small text-muted">
|
|
<span v-if="device.parameterDiffs?.length">{{ device.parameterDiffs.length }} diff(s)</span>
|
|
<span v-else-if="device.firmwareMigrationPath?.length">firmware</span>
|
|
<span v-else>—</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>
|
|
|
|
<!-- 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">
|
|
<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>
|
|
</template>
|
|
|