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