| 1 | <template> |
| 2 | <n-spin :show="loading" class="min-h-40"> |
| 3 | <div class="flex flex-col gap-4"> |
| 4 | <div v-if="!loading && reports.length < 2"> |
| 5 | <n-empty |
| 6 | description="Only one report exists for this alert. Replay with a different template to generate a second report to compare." |
| 7 | class="min-h-40 justify-center" |
| 8 | /> |
| 9 | </div> |
| 10 | |
| 11 | <template v-else> |
| 12 | <div class="text-secondary text-sm"> |
| 13 | Side-by-side view of two investigations for alert |
| 14 | <code class="text-primary">#{{ alertId }}</code> |
| 15 | . Pick any two runs below — defaults to the current report on the left and the next-most-recent run |
| 16 | on the right. |
| 17 | </div> |
| 18 | |
| 19 | <div class="grid grid-cols-1 gap-4 md:grid-cols-2"> |
| 20 | <!-- Side A --> |
| 21 | <div class="flex flex-col gap-3"> |
| 22 | <n-form-item label="Version A" :show-feedback="false"> |
| 23 | <n-select |
| 24 | v-model:value="idA" |
| 25 | :options="reportOptions" |
| 26 | :render-label="renderOption" |
| 27 | size="large" |
| 28 | /> |
| 29 | </n-form-item> |
| 30 | <ReportColumn v-if="reportA" v-model:show-full-report="showFullReport" :report="reportA" /> |
| 31 | </div> |
| 32 | |
| 33 | <!-- Side B --> |
| 34 | <div class="flex flex-col gap-3"> |
| 35 | <n-form-item label="Version B" :show-feedback="false"> |
| 36 | <n-select |
| 37 | v-model:value="idB" |
| 38 | :options="reportOptions" |
| 39 | :render-label="renderOption" |
| 40 | size="large" |
| 41 | /> |
| 42 | </n-form-item> |
| 43 | <ReportColumn v-if="reportB" v-model:show-full-report="showFullReport" :report="reportB" /> |
| 44 | </div> |
| 45 | </div> |
| 46 | </template> |
| 47 | </div> |
| 48 | </n-spin> |
| 49 | </template> |
| 50 | |
| 51 | <script setup lang="ts"> |
| 52 | import type { AiAnalystReport } from "@/types/aiAnalyst.d" |
| 53 | import { NEmpty, NFormItem, NSelect, NSpin, useMessage } from "naive-ui" |
| 54 | import { computed, h, ref, toRefs, watch } from "vue" |
| 55 | import Api from "@/api" |
| 56 | import { useSettingsStore } from "@/stores/settings" |
| 57 | import { formatDate } from "@/utils/format" |
| 58 | import ReportColumn from "./AlertReportCompareColumn.vue" |
| 59 | |
| 60 | const props = defineProps<{ |
| 61 | alertId: number |
| 62 | currentReportId?: number |
| 63 | }>() |
| 64 | |
| 65 | const { alertId, currentReportId } = toRefs(props) |
| 66 | |
| 67 | const message = useMessage() |
| 68 | const loading = ref(false) |
| 69 | const dFormats = useSettingsStore().dateFormat |
| 70 | const reports = ref<AiAnalystReport[]>([]) |
| 71 | |
| 72 | const idA = ref<number | null>(null) |
| 73 | const idB = ref<number | null>(null) |
| 74 | |
| 75 | const showFullReport = ref(false) |
| 76 | |
| 77 | const reportOptions = computed(() => |
| 78 | reports.value.map(r => ({ |
| 79 | label: r.id.toString(), |
| 80 | value: r.id, |
| 81 | severity: r.severity_assessment, |
| 82 | created_at: r.created_at |
| 83 | })) |
| 84 | ) |
| 85 | |
| 86 | const reportA = computed(() => reports.value.find(r => r.id === idA.value) ?? null) |
| 87 | const reportB = computed(() => reports.value.find(r => r.id === idB.value) ?? null) |
| 88 | |
| 89 | // Render option with created_at + severity so the picker shows meaningful |
| 90 | // distinctions between runs rather than just bare IDs. |
| 91 | function renderOption(option: { label: string; value: number; severity?: string | null; created_at?: string }) { |
| 92 | const ts = option.created_at ? String(formatDate(option.created_at, dFormats.datetime)) : "" |
| 93 | const sev = option.severity ? ` · ${option.severity}` : "" |
| 94 | return h("div", { class: "flex flex-col leading-none py-2" }, [ |
| 95 | h("span", `#${option.label}${sev}`), |
| 96 | h("span", { class: "text-secondary text-xs" }, ts) |
| 97 | ]) |
| 98 | } |
| 99 | |
| 100 | async function loadReports() { |
| 101 | loading.value = true |
| 102 | |
| 103 | try { |
| 104 | const res = await Api.aiAnalyst.getReportsByAlert(alertId.value) |
| 105 | if (res.data.success) { |
| 106 | // Newest first — backend already sorts by created_at desc, but we |
| 107 | // re-sort defensively in case that changes. |
| 108 | const sorted = [...(res.data.reports || [])].sort( |
| 109 | (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() |
| 110 | ) |
| 111 | reports.value = sorted |
| 112 | if (sorted.length >= 2) { |
| 113 | // Default A to the currentReportId if provided (so the user always |
| 114 | // sees "this report" on the left), otherwise newest. |
| 115 | const preferA = currentReportId?.value |
| 116 | ? sorted.find(r => r.id === currentReportId.value)?.id |
| 117 | : sorted[0].id |
| 118 | idA.value = preferA ?? sorted[0].id |
| 119 | idB.value = sorted.find(r => r.id !== idA.value)?.id ?? sorted[1].id |
| 120 | } |
| 121 | } else { |
| 122 | message.warning(res.data.message || "Failed to load reports") |
| 123 | } |
| 124 | } catch (err: unknown) { |
| 125 | const e = err as { response?: { data?: { message?: string } }; message?: string } |
| 126 | message.error(e.response?.data?.message || e.message || "Failed to load reports") |
| 127 | } finally { |
| 128 | loading.value = false |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | watch(alertId, () => loadReports(), { immediate: true }) |
| 133 | </script> |