145 lines
6.2 KiB
Vue
145 lines
6.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { AlertTriangle, Check, ChevronDown, ChevronRight, RefreshCw } from "@lucide/vue";
|
|
import type { DriftAlert } from "@/types/governance";
|
|
|
|
interface DriftSummary {
|
|
sourceEnv: string;
|
|
targetEnv: string;
|
|
driftCount: number;
|
|
lastDetectedAt: string;
|
|
hasUnacknowledged: boolean;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
drifts: DriftSummary[];
|
|
alerts: DriftAlert[];
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
acknowledge: [alertId: string];
|
|
refresh: [];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const expandedKeys = ref<Set<string>>(new Set());
|
|
|
|
const totalDriftCount = computed(() => props.drifts.reduce((sum, d) => sum + d.driftCount, 0));
|
|
|
|
function toggleKey(key: string) {
|
|
const next = new Set(expandedKeys.value);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
expandedKeys.value = next;
|
|
}
|
|
|
|
function alertsForDrift(summary: DriftSummary): DriftAlert[] {
|
|
return props.alerts.filter((a) => a.sourceEnv === summary.sourceEnv && a.targetEnv === summary.targetEnv);
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(new Date(iso));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rounded-md border bg-card">
|
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
|
<h3 class="text-sm font-medium">{{ t("configDrift.title") }}</h3>
|
|
<Button size="sm" variant="ghost" class="h-7 gap-1 text-xs" @click="emit('refresh')">
|
|
<RefreshCw class="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="border-b px-4 py-2 text-xs">
|
|
<template v-if="totalDriftCount > 0">
|
|
<span class="text-muted-foreground">{{ t("configDrift.driftFound", { count: totalDriftCount }) }}</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="text-muted-foreground">{{ t("configDrift.noDrift") }}</span>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="drifts.length > 0" class="overflow-x-auto">
|
|
<table class="w-full text-xs">
|
|
<thead>
|
|
<tr class="border-b bg-muted/30 text-muted-foreground">
|
|
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.sourceEnv") }}</th>
|
|
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.targetEnv") }}</th>
|
|
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.driftCount") }}</th>
|
|
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.lastDetected") }}</th>
|
|
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.configKey") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="drift in drifts" :key="`${drift.sourceEnv}-${drift.targetEnv}`">
|
|
<tr class="border-b last:border-b-0 hover:bg-accent/50">
|
|
<td class="px-4 py-2.5 font-medium">{{ drift.sourceEnv }}</td>
|
|
<td class="px-4 py-2.5">{{ drift.targetEnv }}</td>
|
|
<td class="px-4 py-2.5">{{ drift.driftCount }}</td>
|
|
<td class="px-4 py-2.5 text-muted-foreground">{{ formatDate(drift.lastDetectedAt) }}</td>
|
|
<td class="px-4 py-2.5">
|
|
<span v-if="!drift.hasUnacknowledged" class="text-green-600 dark:text-green-400">{{ t("configDrift.acknowledged") }}</span>
|
|
<Badge v-else variant="outline" class="text-xs">{{ t("configDrift.unacknowledged") }}</Badge>
|
|
</td>
|
|
</tr>
|
|
|
|
<tr v-for="alert in alertsForDrift(drift)" :key="alert.id" class="border-b bg-muted/20">
|
|
<td colspan="5" class="px-4 py-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<button class="flex items-center gap-1.5 text-left text-xs" @click="toggleKey(alert.id)">
|
|
<ChevronRight v-if="!expandedKeys.has(alert.id)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
<ChevronDown v-else class="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
<code class="rounded bg-muted px-1 py-0.5 font-mono text-xs">{{ alert.configKey }}</code>
|
|
<span v-if="alert.acknowledged" class="text-green-600 dark:text-green-400">{{ t("configDrift.acknowledged") }}</span>
|
|
</button>
|
|
<Button v-if="!alert.acknowledged" size="sm" variant="outline" class="h-7 gap-1 text-xs" @click="emit('acknowledge', alert.id)">
|
|
<Check class="h-3 w-3" />
|
|
{{ t("configDrift.acknowledge") }}
|
|
</Button>
|
|
</div>
|
|
<div v-if="expandedKeys.has(alert.id)" class="mt-2 space-y-1.5 pl-5">
|
|
<div class="grid grid-cols-2 gap-3 text-xs">
|
|
<div>
|
|
<span class="text-muted-foreground">{{ t("configDrift.checksum") }} (expected)</span>
|
|
<code class="ml-2 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{{ alert.expectedChecksum }}</code>
|
|
</div>
|
|
<div>
|
|
<span class="text-muted-foreground">{{ t("configDrift.checksum") }} (actual)</span>
|
|
<code class="ml-2 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{{ alert.actualChecksum }}</code>
|
|
</div>
|
|
</div>
|
|
<div v-if="alert.detailsJson" class="text-xs">
|
|
<span class="text-muted-foreground">{{ t("configDrift.mismatchedFields") }}</span>
|
|
<pre class="mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 font-mono text-[10px] whitespace-pre-wrap">{{ typeof alert.detailsJson === "string" ? alert.detailsJson : JSON.stringify(alert.detailsJson, null, 2) }}</pre>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="drifts.length === 0" class="flex items-center justify-center px-4 py-10 text-xs text-muted-foreground">
|
|
<AlertTriangle class="mr-2 h-4 w-4" />
|
|
{{ t("configDrift.noDrift") }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped></style>
|