feat(ui): compliance report, configuration list improvements
- 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>
This commit is contained in:
1 parent
aff8e14a63
commit
3394abd5ec
10 files changed
+797
-182
No files matched your search
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
suggestions?: string[]
|
||||
placeholder?: string
|
||||
id?: string
|
||||
monospace?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!props.suggestions?.length) return []
|
||||
const q = props.modelValue.toLowerCase()
|
||||
return props.suggestions.filter((s) => s.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function select(val: string) {
|
||||
emit('update:modelValue', val)
|
||||
isOpen.value = false
|
||||
input.value?.blur()
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
setTimeout(() => { isOpen.value = false }, 150)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="autocomplete-wrapper position-relative">
|
||||
<input
|
||||
:id="id"
|
||||
ref="input"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
:class="{ 'font-monospace': monospace }"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
@input="onInput"
|
||||
@focus="isOpen = true"
|
||||
@blur="onBlur"
|
||||
@keydown.escape="isOpen = false"
|
||||
/>
|
||||
<ul
|
||||
v-if="isOpen && filtered.length > 0"
|
||||
class="autocomplete-dropdown list-unstyled mb-0 position-absolute w-100 border rounded shadow-sm bg-white"
|
||||
style="top: 100%; left: 0; z-index: 1000; max-height: 200px; overflow-y: auto;"
|
||||
>
|
||||
<li
|
||||
v-for="val in filtered"
|
||||
:key="val"
|
||||
class="autocomplete-item px-3 py-1 small font-monospace"
|
||||
style="cursor: pointer;"
|
||||
@mousedown.prevent="select(val)"
|
||||
>
|
||||
{{ val }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.autocomplete-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
@@ -9,15 +9,16 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status: 'compliant' | 'in_progress' | 'failed'
|
||||
status: 'pending' | 'compliant' | 'in_progress' | 'failed'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
compliant: 'Compliant',
|
||||
in_progress: 'In progress',
|
||||
failed: 'Failed',
|
||||
@@ -27,6 +28,7 @@ const statusLabel = computed(() => {
|
||||
|
||||
const iconName = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
pending: 'clock',
|
||||
compliant: 'check-circle',
|
||||
in_progress: 'loader',
|
||||
failed: 'alert-circle',
|
||||
@@ -47,6 +49,11 @@ const iconName = computed(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.status-compliant {
|
||||
background-color: #e6f4ea;
|
||||
color: #0d652d;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AutocompleteInput from '~/components/AutocompleteInput.vue'
|
||||
import type { ConfigurationCriteria, ParameterCriteria } from '#domain/configuration/configuration'
|
||||
|
||||
interface Props {
|
||||
@@ -11,49 +13,28 @@ interface Emits {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const allowedFields = ['manufacturer', 'oui', 'productClass', 'serialNumber'] as const
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
manufacturer: 'Manufacturer',
|
||||
oui: 'OUI',
|
||||
productClass: 'Product Class',
|
||||
serialNumber: 'Serial Number',
|
||||
}
|
||||
|
||||
const fieldPlaceholders: Record<string, string> = {
|
||||
manufacturer: 'e.g. Sagemcom or Sagemcom*',
|
||||
oui: 'e.g. 00E0FC or 00*',
|
||||
productClass: 'e.g. BM632w or BM*',
|
||||
serialNumber: 'e.g. ABC12345 or ABC*',
|
||||
}
|
||||
|
||||
const fieldHelp: Record<string, string> = {
|
||||
manufacturer: 'Exact match or glob pattern (* ?)',
|
||||
oui: 'Organizationally Unique Identifier',
|
||||
productClass: 'Supports glob patterns',
|
||||
serialNumber: 'Supports glob patterns',
|
||||
}
|
||||
|
||||
const suggestions = ref<Record<string, string[]>>({
|
||||
manufacturer: [],
|
||||
oui: [],
|
||||
productClass: [],
|
||||
serialNumber: [],
|
||||
modelName: [],
|
||||
hardwareVersion: [],
|
||||
softwareVersion: [],
|
||||
})
|
||||
const loadingSuggestions = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loadingSuggestions.value = true
|
||||
const res = await fetch('/api/v1/devices/criteria-values')
|
||||
if (res.ok) {
|
||||
suggestions.value = await res.json()
|
||||
}
|
||||
} catch {
|
||||
// silently fail — suggestions are optional
|
||||
} finally {
|
||||
loadingSuggestions.value = false
|
||||
// silently fail
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,6 +50,42 @@ function updateCriteria(field: string, value: string) {
|
||||
|
||||
// ── Parameter Criteria ──────────────────────────────────────────────────────
|
||||
|
||||
const presetParams: { labelKey: string; tr181: string; tr098: string; placeholderKey: string; suggestionsKey: string }[] = [
|
||||
{ labelKey: 'criteriaEditor.presets.modelName', tr181: 'Device.DeviceInfo.ModelName', tr098: 'InternetGatewayDevice.DeviceInfo.ModelName', placeholderKey: 'criteriaEditor.presets.modelNamePlaceholder', suggestionsKey: 'modelName' },
|
||||
{ labelKey: 'criteriaEditor.presets.hardwareVersion', tr181: 'Device.DeviceInfo.HardwareVersion', tr098: 'InternetGatewayDevice.DeviceInfo.HardwareVersion', placeholderKey: 'criteriaEditor.presets.hardwareVersionPlaceholder', suggestionsKey: 'hardwareVersion' },
|
||||
{ labelKey: 'criteriaEditor.presets.softwareVersion', tr181: 'Device.DeviceInfo.SoftwareVersion', tr098: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', placeholderKey: 'criteriaEditor.presets.softwareVersionPlaceholder', suggestionsKey: 'softwareVersion' },
|
||||
]
|
||||
|
||||
// Paths already covered by preset fields — excluded from custom conditions suggestions
|
||||
const presetPaths = new Set(presetParams.flatMap((p) => [p.tr181, p.tr098]))
|
||||
|
||||
const customSuggestions = computed(() =>
|
||||
(suggestions.value.parameterNames || []).filter((name) => !presetPaths.has(name))
|
||||
)
|
||||
|
||||
function getPresetValue(preset: typeof presetParams[0]): string {
|
||||
const list = props.modelValue.parameterCriteria || []
|
||||
const entry = list.find((p) => p.name === preset.tr181 || p.name === preset.tr098)
|
||||
return entry ? String(entry.value) : ''
|
||||
}
|
||||
|
||||
function updatePresetParam(preset: typeof presetParams[0], value: string) {
|
||||
const updated = { ...props.modelValue }
|
||||
const list = [...(updated.parameterCriteria || [])]
|
||||
const idx = list.findIndex((p) => p.name === preset.tr181 || p.name === preset.tr098)
|
||||
if (value) {
|
||||
if (idx >= 0) {
|
||||
list[idx] = { name: preset.tr181, value }
|
||||
} else {
|
||||
list.push({ name: preset.tr181, value })
|
||||
}
|
||||
} else {
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
updated.parameterCriteria = list.length > 0 ? list : undefined
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
const paramCriteria = computed<ParameterCriteria[]>(() => props.modelValue.parameterCriteria || [])
|
||||
|
||||
function addParamCriteria() {
|
||||
@@ -92,62 +109,84 @@ function updateParamCriteria(index: number, field: 'name' | 'value', value: stri
|
||||
updated.parameterCriteria = list
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
function knownValuesLabel(count: number): string {
|
||||
return count > 1 ? t('criteriaEditor.knownValues') : t('criteriaEditor.knownValue')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- DeviceId criteria -->
|
||||
<p class="fw-semibold small mb-2">{{ t('criteriaEditor.deviceId') }}</p>
|
||||
<div class="row g-3">
|
||||
<div v-for="field in allowedFields" :key="field" class="col-md-6">
|
||||
<label :for="`criteria-${field}`" class="form-label">{{ fieldLabels[field] }}</label>
|
||||
<input
|
||||
<label :for="`criteria-${field}`" class="form-label">{{ t(`criteriaEditor.fields.${field}.label`) }}</label>
|
||||
<AutocompleteInput
|
||||
:id="`criteria-${field}`"
|
||||
:list="`datalist-${field}`"
|
||||
type="text"
|
||||
class="form-control form-control-sm font-monospace"
|
||||
:value="modelValue[field] || ''"
|
||||
:placeholder="fieldPlaceholders[field]"
|
||||
autocomplete="off"
|
||||
@input="updateCriteria(field, ($event.target as HTMLInputElement).value)"
|
||||
:model-value="modelValue[field] || ''"
|
||||
:suggestions="suggestions[field]"
|
||||
:placeholder="t(`criteriaEditor.fields.${field}.placeholder`)"
|
||||
monospace
|
||||
@update:model-value="updateCriteria(field, $event)"
|
||||
/>
|
||||
<datalist :id="`datalist-${field}`">
|
||||
<option v-for="val in suggestions[field]" :key="val" :value="val" />
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
{{ fieldHelp[field] }}
|
||||
{{ t(`criteriaEditor.fields.${field}.help`) }}
|
||||
<span v-if="suggestions[field]?.length" class="text-muted">
|
||||
· {{ suggestions[field].length }} known value{{ suggestions[field].length > 1 ? 's' : '' }}
|
||||
· {{ suggestions[field].length }} {{ knownValuesLabel(suggestions[field].length) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameter Criteria -->
|
||||
<!-- Parameter Criteria — Preset fields -->
|
||||
<div class="mt-4">
|
||||
<p class="fw-semibold small mb-2">{{ t('criteriaEditor.standardParams') }}</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div v-for="preset in presetParams" :key="preset.tr181" class="col-md-6">
|
||||
<label class="form-label">{{ t(preset.labelKey) }}</label>
|
||||
<AutocompleteInput
|
||||
:model-value="getPresetValue(preset)"
|
||||
:suggestions="suggestions[preset.suggestionsKey]"
|
||||
:placeholder="t(preset.placeholderKey)"
|
||||
monospace
|
||||
@update:model-value="updatePresetParam(preset, $event)"
|
||||
/>
|
||||
<div class="form-text text-muted" style="font-size:0.7rem">
|
||||
{{ preset.tr181 }}
|
||||
<span v-if="suggestions[preset.suggestionsKey]?.length" class="ms-1">
|
||||
· {{ suggestions[preset.suggestionsKey].length }} {{ knownValuesLabel(suggestions[preset.suggestionsKey].length) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameter Criteria — conditions libres -->
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<span class="fw-semibold small">Parameter Criteria</span>
|
||||
<span class="form-text ms-2">Match devices where known parameter values equal the expected values</span>
|
||||
<span class="fw-semibold small">{{ t('criteriaEditor.customCriteria') }}</span>
|
||||
<span class="form-text ms-2">{{ t('criteriaEditor.customCriteriaHelp') }}</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="addParamCriteria">
|
||||
+ Add condition
|
||||
{{ t('criteriaEditor.addCondition') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="paramCriteria.length === 0" class="text-muted small fst-italic">
|
||||
No parameter conditions — matches all devices passing DeviceId criteria above.
|
||||
{{ t('criteriaEditor.noConditions') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(pc, idx) in paramCriteria"
|
||||
:key="idx"
|
||||
class="d-flex align-items-center gap-2 mb-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm font-monospace flex-grow-1"
|
||||
:value="pc.name"
|
||||
placeholder="e.g. InternetGatewayDevice.Time.Enable"
|
||||
@input="updateParamCriteria(idx, 'name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-for="(pc, idx) in paramCriteria" :key="idx" class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<AutocompleteInput
|
||||
:model-value="pc.name"
|
||||
:suggestions="customSuggestions"
|
||||
placeholder="e.g. Device.DeviceInfo.Manufacturer"
|
||||
monospace
|
||||
@update:model-value="updateParamCriteria(idx, 'name', $event)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-muted small">=</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -157,21 +196,13 @@ function updateParamCriteria(index: number, field: 'name' | 'value', value: stri
|
||||
placeholder="e.g. true"
|
||||
@input="updateParamCriteria(idx, 'value', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="Remove"
|
||||
@click="removeParamCriteria(idx)"
|
||||
>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" :title="t('button.delete')" @click="removeParamCriteria(idx)">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="paramCriteria.length > 0" class="form-text mt-1">
|
||||
ℹ️ If a parameter is not yet known on the device, this configuration will not match.
|
||||
A lower-priority configuration without this condition should run first to discover the parameter via its inventory.
|
||||
Avoid using the same path in both criteria and desired parameters — this risks a deadlock.
|
||||
ℹ️ {{ t('criteriaEditor.paramCriteriaWarning') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+71
-2
@@ -77,7 +77,8 @@
|
||||
"firmwareUrl": "Firmware URL",
|
||||
"checksum": "Checksum",
|
||||
"filterByProductClass": "Filter by product class…",
|
||||
"saveChanges": "Save changes"
|
||||
"saveChanges": "Save changes",
|
||||
"hardwareVersion": "Hardware version"
|
||||
},
|
||||
"button": {
|
||||
"refresh": "Refresh",
|
||||
@@ -121,5 +122,73 @@
|
||||
"language": {
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"criteriaEditor": {
|
||||
"deviceId": "Device Identity",
|
||||
"standardParams": "Standard Parameters",
|
||||
"customCriteria": "Custom Conditions",
|
||||
"customCriteriaHelp": "Match devices where known parameter values equal the expected values",
|
||||
"addCondition": "+ Add condition",
|
||||
"noConditions": "No parameter conditions — matches all devices passing DeviceId criteria above.",
|
||||
"paramCriteriaWarning": "If a parameter is not yet known on the device, this configuration will not match. A lower-priority configuration without this condition should run first to discover the parameter via its inventory. Avoid using the same path in both criteria and desired parameters — this risks a deadlock.",
|
||||
"knownValue": "known value",
|
||||
"knownValues": "known values",
|
||||
"fields": {
|
||||
"manufacturer": {
|
||||
"label": "Manufacturer",
|
||||
"placeholder": "e.g. Sagemcom or Sagemcom*",
|
||||
"help": "Exact match or glob pattern (* ?)"
|
||||
},
|
||||
"oui": {
|
||||
"label": "OUI",
|
||||
"placeholder": "e.g. 00E0FC or 00*",
|
||||
"help": "Organizationally Unique Identifier"
|
||||
},
|
||||
"productClass": {
|
||||
"label": "Product Class",
|
||||
"placeholder": "e.g. BM632w or BM*",
|
||||
"help": "Supports glob patterns"
|
||||
},
|
||||
"serialNumber": {
|
||||
"label": "Serial Number",
|
||||
"placeholder": "e.g. ABC12345 or ABC*",
|
||||
"help": "Supports glob patterns"
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"modelName": "Model Name",
|
||||
"hardwareVersion": "Hardware Version",
|
||||
"softwareVersion": "Software Version",
|
||||
"modelNamePlaceholder": "e.g. BM632w",
|
||||
"hardwareVersionPlaceholder": "e.g. V3R1",
|
||||
"softwareVersionPlaceholder": "e.g. 1.2.3"
|
||||
}
|
||||
},
|
||||
"configDetail": {
|
||||
"tabGeneral": "General",
|
||||
"tabCriteria": "Criteria",
|
||||
"tabInventory": "Inventory",
|
||||
"tabParameters": "Parameters",
|
||||
"tabCompliance": "Compliance",
|
||||
"tabDryRun": "Dry Run",
|
||||
"tabHistory": "History",
|
||||
"totalDevices": "Matching devices",
|
||||
"compliant": "Compliant",
|
||||
"nonCompliant": "Non-compliant",
|
||||
"noMatchingDevices": "No devices match this configuration criteria.",
|
||||
"loadCompliance": "Load compliance report",
|
||||
"complianceError": "Error loading compliance report.",
|
||||
"diffs": "Diffs",
|
||||
"dryRunDesc": "Simulate applying this configuration to a device without making any real changes.",
|
||||
"dryRunPlaceholder": "e.g. AABBCC-Device-123",
|
||||
"runDryRun": "Run",
|
||||
"dryRunError": "Error during dry run.",
|
||||
"firmwareSkipped": "firmware outside schedule window",
|
||||
"paramDiffs": "Parameter differences",
|
||||
"currentValue": "Current value",
|
||||
"targetValue": "Target value",
|
||||
"notSet": "(not set)",
|
||||
"noParamDiffs": "No parameter differences.",
|
||||
"firmwarePath": "Firmware migration path"
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-2
@@ -77,7 +77,8 @@
|
||||
"firmwareUrl": "URL du firmware",
|
||||
"checksum": "Checksum",
|
||||
"filterByProductClass": "Filtrer par classe produit…",
|
||||
"saveChanges": "Enregistrer"
|
||||
"saveChanges": "Enregistrer",
|
||||
"hardwareVersion": "Version matérielle"
|
||||
},
|
||||
"button": {
|
||||
"refresh": "Rafraîchir",
|
||||
@@ -121,5 +122,73 @@
|
||||
"language": {
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"criteriaEditor": {
|
||||
"deviceId": "Identifiant équipement",
|
||||
"standardParams": "Paramètres standards",
|
||||
"customCriteria": "Conditions personnalisées",
|
||||
"customCriteriaHelp": "Correspond aux équipements dont les valeurs de paramètres connus sont égales aux valeurs attendues",
|
||||
"addCondition": "+ Ajouter une condition",
|
||||
"noConditions": "Aucune condition — correspond à tous les équipements passant les critères d'identifiant ci-dessus.",
|
||||
"paramCriteriaWarning": "Si un paramètre n'est pas encore connu sur l'équipement, cette configuration ne correspondra pas. Une configuration de priorité inférieure sans cette condition doit s'exécuter en premier pour découvrir le paramètre via son inventaire. Évitez d'utiliser le même chemin dans les critères et les paramètres souhaités — cela risque un blocage.",
|
||||
"knownValue": "valeur connue",
|
||||
"knownValues": "valeurs connues",
|
||||
"fields": {
|
||||
"manufacturer": {
|
||||
"label": "Fabricant",
|
||||
"placeholder": "ex. Sagemcom ou Sagemcom*",
|
||||
"help": "Correspondance exacte ou motif glob (* ?)"
|
||||
},
|
||||
"oui": {
|
||||
"label": "OUI",
|
||||
"placeholder": "ex. 00E0FC ou 00*",
|
||||
"help": "Identifiant Unique Organisationnel"
|
||||
},
|
||||
"productClass": {
|
||||
"label": "Classe produit",
|
||||
"placeholder": "ex. BM632w ou BM*",
|
||||
"help": "Supporte les motifs glob"
|
||||
},
|
||||
"serialNumber": {
|
||||
"label": "Numéro de série",
|
||||
"placeholder": "ex. ABC12345 ou ABC*",
|
||||
"help": "Supporte les motifs glob"
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"modelName": "Nom du modèle",
|
||||
"hardwareVersion": "Version matérielle",
|
||||
"softwareVersion": "Version logicielle",
|
||||
"modelNamePlaceholder": "ex. BM632w",
|
||||
"hardwareVersionPlaceholder": "ex. V3R1",
|
||||
"softwareVersionPlaceholder": "ex. 1.2.3"
|
||||
}
|
||||
},
|
||||
"configDetail": {
|
||||
"tabGeneral": "Général",
|
||||
"tabCriteria": "Critères",
|
||||
"tabInventory": "Inventaire",
|
||||
"tabParameters": "Paramètres",
|
||||
"tabCompliance": "Conformité",
|
||||
"tabDryRun": "Dry Run",
|
||||
"tabHistory": "Historique",
|
||||
"totalDevices": "Équipements correspondants",
|
||||
"compliant": "Conforme",
|
||||
"nonCompliant": "Non conforme",
|
||||
"noMatchingDevices": "Aucun équipement ne correspond aux critères.",
|
||||
"loadCompliance": "Charger le rapport de conformité",
|
||||
"complianceError": "Erreur lors du chargement de la conformité.",
|
||||
"diffs": "Différences",
|
||||
"dryRunDesc": "Simulez l'application de cette configuration sur un équipement sans effectuer de modification réelle.",
|
||||
"dryRunPlaceholder": "ex: AABBCC-Device-123",
|
||||
"runDryRun": "Lancer",
|
||||
"dryRunError": "Erreur lors du dry run.",
|
||||
"firmwareSkipped": "firmware hors plage horaire",
|
||||
"paramDiffs": "Différences de paramètres",
|
||||
"currentValue": "Valeur actuelle",
|
||||
"targetValue": "Valeur cible",
|
||||
"notSet": "(non défini)",
|
||||
"noParamDiffs": "Aucune différence de paramètres.",
|
||||
"firmwarePath": "Chemin de migration firmware"
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-51
@@ -13,12 +13,19 @@ const { device, modelPaths, commands } = defineProps<{
|
||||
}>()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
||||
function deleteDevice() {
|
||||
router.delete(`/devices/${encodeURIComponent(device.identifier)}`, {
|
||||
onSuccess: () => router.visit('/devices'),
|
||||
})
|
||||
}
|
||||
const showGetModelPopup = ref(false)
|
||||
const getModelInput = ref('')
|
||||
const activeSection = ref('identifiers')
|
||||
|
||||
// Collapsible sections
|
||||
const openSections = ref<Set<string>>(new Set(['identifiers', 'properties', 'parameters', 'commands']))
|
||||
const openSections = ref<Set<string>>(new Set(['identifiers', 'commands']))
|
||||
|
||||
function toggleSection(section: string) {
|
||||
if (openSections.value.has(section)) {
|
||||
@@ -82,7 +89,7 @@ function formatName(name: unknown): string {
|
||||
|
||||
function getPropertyValue(name: string): string {
|
||||
const prop = device.properties?.find((p: any) => p.name === name)
|
||||
return prop?.value ?? '—'
|
||||
return prop?.value ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -100,7 +107,7 @@ function getPropertyValue(name: string): string {
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeSection === 'identifiers' }" href="javascript:void(0);" @click="scrollTo('identifiers')">
|
||||
Identifiants
|
||||
Identité
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
@@ -130,58 +137,126 @@ function getPropertyValue(name: string): string {
|
||||
<!-- Main Content -->
|
||||
<div class="col-lg-11">
|
||||
|
||||
<!-- Section: Identifiers -->
|
||||
<div class="card border-0 shadow-sm mb-3" id="identifiers">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="h6 fw-bold mb-3">Identifiants</h2>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.manufacturer') }}</div>
|
||||
<div class="fw-medium">{{ device.deviceId?.manufacturer || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">OUI</div>
|
||||
<div class="fw-medium font-monospace">{{ device.deviceId?.oui || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.productClass') }}</div>
|
||||
<div class="fw-medium">{{ device.deviceId?.productClass || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.serialNumber') }}</div>
|
||||
<div class="fw-medium font-monospace">{{ device.deviceId?.serialNumber || '—' }}</div>
|
||||
<!-- Section: Identité — une seule carte englobante -->
|
||||
<div id="identifiers" class="mb-3">
|
||||
<div class="card border-0 shadow-sm" style="background:#fff;border-radius:10px!important">
|
||||
|
||||
<!-- En-tête de la carte -->
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
<span class="text-uppercase fw-bold" style="font-size:0.65rem;letter-spacing:.1em;color:#888">Identité</span>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pb-3 d-flex flex-column gap-2">
|
||||
|
||||
<!-- Zone 1 : Identifiant -->
|
||||
<div class="rounded-3 px-3 py-3" style="background:#f6f7f9">
|
||||
<div class="text-muted fw-bold text-uppercase mb-3" style="font-size:0.62rem;letter-spacing:.08em">Identifiant</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.manufacturer') }}</div>
|
||||
<div class="fw-semibold fs-6">{{ device.deviceId?.manufacturer || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">OUI</div>
|
||||
<div class="fw-semibold fs-6 font-monospace">{{ device.deviceId?.oui || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.productClass') }}</div>
|
||||
<div class="fw-semibold fs-6">{{ device.deviceId?.productClass || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.serialNumber') }}</div>
|
||||
<div class="fw-semibold fs-6 font-monospace">{{ device.deviceId?.serialNumber || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.identifier') }}</div>
|
||||
<div class="fw-medium font-monospace small">{{ device.identifier }}</div>
|
||||
|
||||
<!-- Zones 2+3 : Versions + Système côte à côte -->
|
||||
<div class="row g-2">
|
||||
|
||||
<!-- Zone 2 : Versions -->
|
||||
<div class="col-lg-5">
|
||||
<div class="rounded-3 px-3 py-3 h-100" style="background:#f6f7f9">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted flex-shrink-0">
|
||||
<path d="M500 75.5c-234.72 0-425 190.28-425 425s190.28 425 425 425 425-190.28 425-425-190.28-425-425-425m0 100a75 75 0 1 1-75 75 75 75 0 0 1 75-75m125 600H400v-25.737l5.287-.167c15.555-.5 27.115-4.919 34.368-13.136 2.576-2.972 6.956-13.1 6.956-47.153v-227.2c0-32.392-4.523-44.6-8.311-49.141-5.359-6.391-16.516-10.147-33.17-11.156l-5.13-.31v-26h175v313.807c0 32.415 8.457 44.618 12.24 49.142 5.34 6.389 15.68 10.142 32.625 11.152l5.135.3v25.6Z" style="fill-rule:evenodd"/>
|
||||
</svg>
|
||||
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">Versions</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="text-muted fw-bold text-uppercase mb-2" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.softwareVersion') }}</div>
|
||||
<span class="font-monospace fw-medium small">{{ getPropertyValue('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getPropertyValue('Device.DeviceInfo.SoftwareVersion') || '—' }}</span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted fw-bold text-uppercase mb-2" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.hardwareVersion') }}</div>
|
||||
<span class="font-monospace fw-medium small">{{ getPropertyValue('InternetGatewayDevice.DeviceInfo.HardwareVersion') || getPropertyValue('Device.DeviceInfo.HardwareVersion') || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone 3 : Système -->
|
||||
<div class="col-lg-7">
|
||||
<div class="rounded-3 px-3 py-3 h-100" style="background:#f6f7f9">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted flex-shrink-0">
|
||||
<path d="M827.568 500.5c0-110.61 120.715-26.921 64.516-162.6-56.231-135.681-82.391 8.823-160.606-69.384s66.333-104.365-69.36-160.562c-135.718-56.2-52.024 64.481-162.61 64.481-110.619 0-26.921-120.68-162.612-64.481s8.827 82.349-69.391 160.562-104.372-66.3-160.575 69.384c-56.23 135.679 64.486 51.99 64.486 162.6S50.7 527.419 106.93 663.1c56.2 135.681 82.359-8.823 160.575 69.352 78.218 78.214-66.3 104.4 69.391 160.6s51.993-64.483 162.612-64.483c110.586 0 26.892 120.678 162.61 64.483 135.693-56.2-8.854-82.384 69.36-160.6 78.215-78.175 104.375 66.329 160.606-69.352 56.199-135.681-64.516-51.99-64.516-162.6M500 301.015c110.457 0 200 89.537 200 199.985s-89.543 199.985-200 199.985S300 611.448 300 501s89.543-199.985 200-199.985" style="fill-rule:evenodd"/>
|
||||
</svg>
|
||||
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">Système</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.identifier') }} (ACS)</div>
|
||||
<div class="font-monospace small text-break">{{ device.identifier || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">Provisioning Code</div>
|
||||
<div class="font-monospace small">{{ device.provisioningCode || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.softwareVersion') }}</div>
|
||||
<div class="fw-medium font-monospace">{{ getPropertyValue('InternetGatewayDevice.DeviceInfo.SoftwareVersion') }}</div>
|
||||
|
||||
<!-- Zones 4+5 : dates côte à côte -->
|
||||
<div class="row g-2">
|
||||
|
||||
<!-- Zone 4 : Créé le -->
|
||||
<div class="col-sm-6">
|
||||
<div class="rounded-3 px-3 py-3 d-flex align-items-center gap-3" style="background:#f6f7f9">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="width:34px;height:34px;background:#e4e6ea">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted">
|
||||
<path d="M800 175H700v-38c0-20.71-16.789-37-37.5-37a37.5 37.5 0 0 0-37.5 37.5V175H350.011L350 137.5a37.5 37.5 0 0 0-75 0V175H100v625a75 75 0 0 0 75 75h700V250a75 75 0 0 0-75-75m25 650H187.5a37.5 37.5 0 0 1-37.5-37.5V400h637.5a37.5 37.5 0 0 1 37.5 37.5zM250 262.5a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0V250h.011v-37.493A62.5 62.5 0 1 1 250 262.5m350 0a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0l-.011-12.5H700v-37a61.47 61.47 0 0 1 25 49.5 62.5 62.5 0 0 1-125 0" style="fill-rule:evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.createdAt') }}</div>
|
||||
<div class="font-monospace small fw-medium">{{ device.createdAt ? new Date(device.createdAt).toLocaleString() : '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone 5 : Dernière connexion -->
|
||||
<div class="col-sm-6">
|
||||
<div class="rounded-3 px-3 py-3 d-flex align-items-center gap-3" style="background:#f6f7f9">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="width:34px;height:34px;background:#e4e6ea">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted">
|
||||
<path d="M800 175H700v-38c0-20.71-16.789-37-37.5-37a37.5 37.5 0 0 0-37.5 37.5V175H350.011L350 137.5a37.5 37.5 0 0 0-75 0V175H100v625a75 75 0 0 0 75 75h700V250a75 75 0 0 0-75-75M300 825H187.5a37.5 37.5 0 0 1-37.5-37.5V400h150zm-50-562.5a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0V250h.011v-37.493A62.5 62.5 0 1 1 250 262.5M475 825H325V400h150zm175 0H500V400h150zm-50-562.5a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0l-.011-12.5H700v-37a61.47 61.47 0 0 1 25 49.5 62.5 62.5 0 0 1-125 0M825 825H675V400h112.5a37.5 37.5 0 0 1 37.5 37.5z" style="fill-rule:evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.lastSeen') }}</div>
|
||||
<div class="font-monospace small fw-medium">{{ device.updatedAt ? new Date(device.updatedAt).toLocaleString() : '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.createdAt') }}</div>
|
||||
<div class="fw-medium small">{{ device.createdAt ? new Date(device.createdAt).toLocaleString() : '—' }}</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<div class="text-muted small">{{ t('labels.lastSeen') }}</div>
|
||||
<div class="fw-medium small">{{ device.updatedAt ? new Date(device.updatedAt).toLocaleString() : '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Actions -->
|
||||
<div class="card border-0 shadow-sm mb-3" id="actions">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="h6 fw-bold mb-3">Actions</h2>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-dark btn-sm" @click="refreshDevice" :disabled="isLoading">
|
||||
{{ t('button.refresh') }}
|
||||
</button>
|
||||
<button class="btn btn-outline-dark btn-sm" @click="openGetModelPopup" :disabled="isLoading">
|
||||
{{ t('button.getModel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,6 +328,16 @@ function getPropertyValue(name: string): string {
|
||||
<button class="btn btn-outline-dark btn-sm" @click="openGetModelPopup" :disabled="isLoading">
|
||||
{{ t('button.getModel') }}
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm ms-auto" @click="showDeleteConfirm = true">
|
||||
Supprimer l'équipement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation suppression -->
|
||||
<div v-if="showDeleteConfirm" class="alert alert-danger d-flex align-items-center gap-3 mt-3 mb-0">
|
||||
<span>Confirmer la suppression de <strong>{{ device.identifier }}</strong> ?</span>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteDevice">Confirmer</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="showDeleteConfirm = false">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-4
@@ -69,6 +69,23 @@ function goToFleetManagement() {
|
||||
if (selectedIdentifiers.value.length === 0) return
|
||||
router.post('/commands/fleet', { devices: selectedIdentifiers.value })
|
||||
}
|
||||
|
||||
const isDeleting = ref(false)
|
||||
|
||||
async function deleteSelected() {
|
||||
if (selectedIdentifiers.value.length === 0 || isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedIdentifiers.value.map((id) =>
|
||||
fetch(`/api/v1/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
)
|
||||
)
|
||||
router.reload()
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,7 +103,6 @@ function goToFleetManagement() {
|
||||
type="checkbox"
|
||||
id="selectAll"
|
||||
v-model="allSelected"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
<label class="form-check-label" for="selectAll">
|
||||
<span class="visually-hidden">{{ t('checkbox.selectAll') }}</span>
|
||||
@@ -124,14 +140,14 @@ function goToFleetManagement() {
|
||||
<td>{{ device.deviceId?.manufacturer }}</td>
|
||||
<td>{{ device.deviceId?.productClass }}</td>
|
||||
<td>{{ device.deviceId?.serialNumber }}</td>
|
||||
<td>{{ getParameterValue(device.parameters, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') }}</td>
|
||||
<td>{{ getPropertyValue(device.properties, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion') }}</td>
|
||||
<td>{{ getParameterValue(device.parameters, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getPropertyValue(device.properties, 'Device.IP.Interface.1.IPv4Address.1.IPAddress') || '—' }}</td>
|
||||
<td>{{ getPropertyValue(device.properties, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getPropertyValue(device.properties, 'Device.DeviceInfo.SoftwareVersion') || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="goToFleetManagement"
|
||||
@@ -139,6 +155,13 @@ function goToFleetManagement() {
|
||||
>
|
||||
{{ t('button.fleetManagement', { count: selectedIdentifiers.length }) }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
@click="deleteSelected"
|
||||
:disabled="selectedIdentifiers.length === 0 || isDeleting"
|
||||
>
|
||||
{{ isDeleting ? 'Suppression…' : `Supprimer (${selectedIdentifiers.length})` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,12 +13,25 @@ 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: 'General' },
|
||||
{ key: 'criteria', label: 'Criteria' },
|
||||
{ key: 'inventory', label: 'Inventory' },
|
||||
{ key: 'parameters', label: 'Parameters' },
|
||||
{ key: 'history', label: 'History' },
|
||||
{ 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 () => {
|
||||
@@ -44,8 +57,59 @@ async function loadHistory() {
|
||||
} 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>
|
||||
@@ -77,7 +141,7 @@ function goToList() { router.get('/configurations') }
|
||||
<!-- 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="activeTab = tab.key">
|
||||
<button class="nav-link" :class="{ active: activeTab === tab.key }" @click="switchTab(tab.key)">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</li>
|
||||
@@ -192,10 +256,149 @@ function goToList() { router.get('/configurations') }
|
||||
</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="versionHistory.length">
|
||||
<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>
|
||||
@@ -207,6 +410,14 @@ function goToList() { router.get('/configurations') }
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TitleBar from '~/components/TitleBar.vue'
|
||||
@@ -13,7 +13,7 @@ const props = defineProps<{ id: string }>()
|
||||
const configuration = ref<any>(null)
|
||||
const errorMsg = ref('')
|
||||
const form = ref<any>({
|
||||
name: '', version: '1.0.0', priority: 1, policy: 'maintain',
|
||||
name: '', bumpType: 'patch', priority: 1, policy: 'maintain',
|
||||
enabled: true, tags: [], criteria: {}, inventory: [], desiredParameters: [],
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ async function loadConfiguration() {
|
||||
const d = data.data ?? data
|
||||
configuration.value = d
|
||||
form.value = {
|
||||
name: d.name, version: d.version, priority: d.priority, policy: d.policy,
|
||||
name: d.name, bumpType: 'patch', priority: d.priority, policy: d.policy,
|
||||
enabled: d.enabled, tags: d.tags || [], criteria: d.criteria || {},
|
||||
inventory: d.inventory || [], desiredParameters: d.desiredParameters || [],
|
||||
}
|
||||
@@ -36,6 +36,14 @@ async function loadConfiguration() {
|
||||
}
|
||||
|
||||
function addInventoryPath() { form.value.inventory.push({ path: '' }) }
|
||||
|
||||
const nextVersion = computed(() => {
|
||||
if (!configuration.value?.version) return ''
|
||||
const [maj, min, pat] = configuration.value.version.split('.').map(Number)
|
||||
if (form.value.bumpType === 'major') return `→ ${maj + 1}.0.0`
|
||||
if (form.value.bumpType === 'minor') return `→ ${maj}.${min + 1}.0`
|
||||
return `→ ${maj}.${min}.${pat + 1}`
|
||||
})
|
||||
function removeInventoryPath(i: number) { form.value.inventory.splice(i, 1) }
|
||||
function addParameter() { form.value.desiredParameters.push({ name: '', value: '', type: 'string' }) }
|
||||
function removeParameter(i: number) { form.value.desiredParameters.splice(i, 1) }
|
||||
@@ -81,8 +89,32 @@ function goBack() { router.get('/configurations') }
|
||||
<input v-model="form.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ t('labels.version') }} <span class="text-danger">*</span></label>
|
||||
<input v-model="form.version" type="text" class="form-control" placeholder="1.0.0" pattern="\d+\.\d+\.\d+" required />
|
||||
<label class="form-label">{{ t('labels.version') }}</label>
|
||||
<input :value="configuration.version" type="text" class="form-control font-monospace" disabled />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type de changement <span class="text-danger">*</span></label>
|
||||
<div class="d-flex gap-2 flex-wrap mt-1">
|
||||
<div class="form-check">
|
||||
<input v-model="form.bumpType" class="form-check-input" type="radio" value="patch" id="bump-patch" />
|
||||
<label class="form-check-label small" for="bump-patch">
|
||||
<span class="badge bg-secondary">patch</span> fix
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input v-model="form.bumpType" class="form-check-input" type="radio" value="minor" id="bump-minor" />
|
||||
<label class="form-check-label small" for="bump-minor">
|
||||
<span class="badge bg-info text-dark">minor</span> feature
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input v-model="form.bumpType" class="form-check-input" type="radio" value="major" id="bump-major" />
|
||||
<label class="form-check-label small" for="bump-major">
|
||||
<span class="badge bg-warning text-dark">major</span> breaking
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">{{ nextVersion }}</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ t('labels.priority') }} <span class="text-danger">*</span></label>
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 configurations = ref<any[]>([])
|
||||
const availableTags = ref<string[]>([])
|
||||
const page = ref(1)
|
||||
const lastPage = ref(1)
|
||||
const total = ref(0)
|
||||
@@ -16,6 +16,7 @@ const loading = ref(false)
|
||||
|
||||
const filters = ref({
|
||||
search: '',
|
||||
tag: '',
|
||||
policy: '',
|
||||
enabled: '',
|
||||
})
|
||||
@@ -24,14 +25,36 @@ const hasActiveFilters = computed(() =>
|
||||
Object.values(filters.value).some((v) => v !== '')
|
||||
)
|
||||
|
||||
onMounted(loadConfigurations)
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch([page, filters], loadConfigurations, { deep: true })
|
||||
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('/api/v1/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)
|
||||
|
||||
@@ -48,7 +71,7 @@ async function loadConfigurations() {
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = { search: '', policy: '', enabled: '' }
|
||||
filters.value = { search: '', tag: '', policy: '', enabled: '' }
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
@@ -57,17 +80,6 @@ function removeFilter(key: keyof typeof filters.value) {
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
const filteredConfigurations = computed(() => {
|
||||
if (!filters.value.search) return configurations.value
|
||||
const q = filters.value.search.toLowerCase()
|
||||
return configurations.value.filter(
|
||||
(c) =>
|
||||
c.identifier?.toLowerCase().includes(q) ||
|
||||
c.name?.toLowerCase().includes(q) ||
|
||||
c.tags?.some((tag: string) => tag.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
async function toggleEnabled(id: string, enabled: boolean) {
|
||||
await fetch(`/api/v1/configurations/${id}/toggle`, {
|
||||
method: 'PATCH',
|
||||
@@ -83,17 +95,9 @@ async function deleteConfig(id: string, name: string) {
|
||||
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`)
|
||||
}
|
||||
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>
|
||||
@@ -105,7 +109,7 @@ function goToEdit(id: string) {
|
||||
<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-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">{{ t('labels.search') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -114,6 +118,13 @@ function goToEdit(id: string) {
|
||||
: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">
|
||||
@@ -130,10 +141,10 @@ function goToEdit(id: string) {
|
||||
<option value="false">{{ t('labels.disabled') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button v-if="hasActiveFilters" class="btn btn-outline-secondary btn-sm" @click="clearFilters">✕ Clear</button>
|
||||
<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-md-2 text-end">
|
||||
<div class="col text-end">
|
||||
<button class="btn btn-primary btn-sm" @click="goToCreate">
|
||||
+ {{ t('labels.newConfiguration') }}
|
||||
</button>
|
||||
@@ -144,6 +155,10 @@ function goToEdit(id: string) {
|
||||
|
||||
<!-- 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')" />
|
||||
@@ -157,7 +172,7 @@ function goToEdit(id: string) {
|
||||
<!-- Results count -->
|
||||
<p class="text-muted small mb-2">
|
||||
<span v-if="loading">{{ t('labels.loading') }}</span>
|
||||
<span v-else>{{ filteredConfigurations.length }} {{ t('labels.results') }}</span>
|
||||
<span v-else>{{ total }} {{ t('labels.results') }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Table -->
|
||||
@@ -170,7 +185,6 @@ function goToEdit(id: string) {
|
||||
<th class="text-center">{{ t('labels.priority') }}</th>
|
||||
<th>{{ t('labels.policy') }}</th>
|
||||
<th class="text-center">{{ t('labels.enabled') }}</th>
|
||||
<th>{{ t('labels.compliance') }}</th>
|
||||
<th>{{ t('labels.tags') }}</th>
|
||||
<th>{{ t('labels.updatedAt') }}</th>
|
||||
<th></th>
|
||||
@@ -178,7 +192,7 @@ function goToEdit(id: string) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="config in filteredConfigurations"
|
||||
v-for="config in configurations"
|
||||
:key="config.identifier"
|
||||
class="align-middle"
|
||||
style="cursor: pointer"
|
||||
@@ -201,10 +215,6 @@ function goToEdit(id: string) {
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ComplianceBadge v-if="config.compliance?.status" :status="config.compliance.status" />
|
||||
<span v-else class="text-muted small">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<span
|
||||
@@ -224,9 +234,8 @@ function goToEdit(id: string) {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && filteredConfigurations.length === 0">
|
||||
<td colspan="9" class="text-center text-muted py-4">{{ t('labels.noResults') }}</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>
|
||||
|
||||
Reference in new issue
Block a user