| 1 | <template> |
| 2 | <div class="flex flex-col gap-4"> |
| 3 | <div class="flex flex-wrap items-end justify-between gap-3"> |
| 4 | <div class="flex flex-col gap-1"> |
| 5 | <h3 class="text-lg font-semibold">Analytic Stories</h3> |
| 6 | <p class="text-secondary text-sm"> |
| 7 | Each story is a threat narrative covered by one or more CoPilot Searches detections. Click a row to |
| 8 | see the member detections, data sources, and references. |
| 9 | </p> |
| 10 | </div> |
| 11 | </div> |
| 12 | |
| 13 | <div class="flex flex-wrap items-center gap-2"> |
| 14 | <n-input |
| 15 | v-model:value="filter" |
| 16 | size="small" |
| 17 | placeholder="Filter by story name, data source, tactic, or product…" |
| 18 | clearable |
| 19 | class="min-w-80 flex-1" |
| 20 | > |
| 21 | <template #prefix><Icon name="carbon:search" /></template> |
| 22 | </n-input> |
| 23 | <Badge type="splitted" color="primary" class="shrink-0"> |
| 24 | <template #label>Showing</template> |
| 25 | <template #value>{{ filteredStories.length }} / {{ stories.length }}</template> |
| 26 | </Badge> |
| 27 | </div> |
| 28 | |
| 29 | <n-data-table :columns :data="filteredStories" :loading size="small" :row-props :pagination :scroll-x="1200" /> |
| 30 | |
| 31 | <n-modal |
| 32 | v-model:show="showStoryModal" |
| 33 | preset="card" |
| 34 | :style="{ maxWidth: 'min(1080px, 94vw)' }" |
| 35 | title="Analytic Story" |
| 36 | :bordered="false" |
| 37 | segmented |
| 38 | @after-leave="selectedStoryName = null" |
| 39 | > |
| 40 | <StoryDetail v-if="selectedStoryName" :story-name="selectedStoryName" @error="handleError" /> |
| 41 | </n-modal> |
| 42 | </div> |
| 43 | </template> |
| 44 | |
| 45 | <script setup lang="tsx"> |
| 46 | import type { DataTableColumns } from "naive-ui" |
| 47 | import type { CatalogStoryRow } from "@/types/detectionCatalog.d" |
| 48 | import { NDataTable, NInput, NModal, NTag, useMessage } from "naive-ui" |
| 49 | import { computed, onBeforeMount, ref } from "vue" |
| 50 | import Api from "@/api" |
| 51 | import Badge from "@/components/common/Badge.vue" |
| 52 | import Icon from "@/components/common/Icon.vue" |
| 53 | import { useSettingsStore } from "@/stores/settings" |
| 54 | import { formatDate } from "@/utils/format" |
| 55 | import StoryDetail from "./StoryDetail.vue" |
| 56 | |
| 57 | const MAX_SOURCES_TAG = 3 |
| 58 | const MAX_TACTICS_TAG = 3 |
| 59 | const MAX_PRODUCTS_TAG = 3 |
| 60 | |
| 61 | const message = useMessage() |
| 62 | const stories = ref<CatalogStoryRow[]>([]) |
| 63 | const loading = ref(false) |
| 64 | const filter = ref("") |
| 65 | const showStoryModal = ref(false) |
| 66 | const selectedStoryName = ref<string | null>(null) |
| 67 | const dFormats = useSettingsStore().dateFormat |
| 68 | |
| 69 | const pagination = { |
| 70 | pageSize: 25, |
| 71 | pageSizes: [10, 25, 50, 100], |
| 72 | showSizePicker: true |
| 73 | } |
| 74 | |
| 75 | const filteredStories = computed<CatalogStoryRow[]>(() => { |
| 76 | const q = filter.value.trim().toLowerCase() |
| 77 | |
| 78 | if (!q) return stories.value |
| 79 | |
| 80 | return stories.value.filter(s => |
| 81 | [s.name, ...(s.data_sources || []), ...(s.tactics || []), ...(s.products || [])] |
| 82 | .join(" ") |
| 83 | .toLowerCase() |
| 84 | .includes(q) |
| 85 | ) |
| 86 | }) |
| 87 | |
| 88 | function rowProps(row: CatalogStoryRow) { |
| 89 | return { |
| 90 | class: "cursor-pointer", |
| 91 | onClick: () => openStoryDetail(row.name) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | function openStoryDetail(storyName: string) { |
| 96 | selectedStoryName.value = storyName |
| 97 | showStoryModal.value = true |
| 98 | } |
| 99 | |
| 100 | function handleError() { |
| 101 | showStoryModal.value = false |
| 102 | selectedStoryName.value = null |
| 103 | } |
| 104 | |
| 105 | const columns: DataTableColumns<CatalogStoryRow> = [ |
| 106 | { |
| 107 | title: "Story", |
| 108 | key: "name", |
| 109 | fixed: "left", |
| 110 | width: 200, |
| 111 | sorter: (a, b) => a.name.localeCompare(b.name), |
| 112 | render: row => ( |
| 113 | <div class="flex flex-col gap-1 py-1"> |
| 114 | <div class="leading-snug font-semibold">{row.name}</div> |
| 115 | <div class="text-secondary font-mono text-xs uppercase"> |
| 116 | {`${row.detection_count} detection`} |
| 117 | {row.detection_count === 1 ? "" : "s"} |
| 118 | </div> |
| 119 | </div> |
| 120 | ) |
| 121 | }, |
| 122 | { |
| 123 | title: "Data sources", |
| 124 | key: "data_sources", |
| 125 | render: row => |
| 126 | row.data_sources.length ? ( |
| 127 | <div class="flex flex-wrap gap-1"> |
| 128 | {row.data_sources.slice(0, MAX_SOURCES_TAG).map(s => ( |
| 129 | <NTag size="small" type="primary" key={s}> |
| 130 | {s} |
| 131 | </NTag> |
| 132 | ))} |
| 133 | {row.data_sources.length > MAX_SOURCES_TAG && ( |
| 134 | <NTag size="small">{`+${row.data_sources.length - MAX_SOURCES_TAG}`}</NTag> |
| 135 | )} |
| 136 | </div> |
| 137 | ) : ( |
| 138 | <span class="text-tertiary text-xs">—</span> |
| 139 | ) |
| 140 | }, |
| 141 | { |
| 142 | title: "MITRE tactics", |
| 143 | key: "tactics", |
| 144 | render: row => |
| 145 | row.tactics.length ? ( |
| 146 | <div class="flex flex-wrap gap-1"> |
| 147 | {row.tactics.slice(0, MAX_TACTICS_TAG).map(t => ( |
| 148 | <NTag size="small" type="warning" key={t}> |
| 149 | {t.toUpperCase()} |
| 150 | </NTag> |
| 151 | ))} |
| 152 | {row.tactics.length > MAX_TACTICS_TAG && ( |
| 153 | <NTag size="small">{`+${row.tactics.length - MAX_TACTICS_TAG}`}</NTag> |
| 154 | )} |
| 155 | </div> |
| 156 | ) : ( |
| 157 | <span class="text-tertiary text-xs">—</span> |
| 158 | ) |
| 159 | }, |
| 160 | { |
| 161 | title: "Products", |
| 162 | key: "products", |
| 163 | width: 180, |
| 164 | render: row => |
| 165 | row.products.length ? ( |
| 166 | <div class="flex flex-wrap gap-1"> |
| 167 | {row.products.slice(0, MAX_PRODUCTS_TAG).map(p => ( |
| 168 | <NTag size="small" key={p}> |
| 169 | {p} |
| 170 | </NTag> |
| 171 | ))} |
| 172 | {row.products.length > MAX_PRODUCTS_TAG && ( |
| 173 | <NTag size="small">{`+${row.products.length - MAX_PRODUCTS_TAG}`}</NTag> |
| 174 | )} |
| 175 | </div> |
| 176 | ) : ( |
| 177 | <span class="text-tertiary text-xs">—</span> |
| 178 | ) |
| 179 | }, |
| 180 | { |
| 181 | title: "Last updated", |
| 182 | key: "date", |
| 183 | width: 130, |
| 184 | sorter: (a, b) => (a.date || "").localeCompare(b.date || ""), |
| 185 | render: row => |
| 186 | row.date ? ( |
| 187 | <span class="font-mono text-xs">{formatDate(row.date, dFormats.date)}</span> |
| 188 | ) : ( |
| 189 | <span class="text-tertiary text-xs">—</span> |
| 190 | ) |
| 191 | } |
| 192 | ] |
| 193 | |
| 194 | function load() { |
| 195 | loading.value = true |
| 196 | |
| 197 | Api.detectionCatalog |
| 198 | .listStories() |
| 199 | .then(res => { |
| 200 | if (res.data?.success) stories.value = res.data.stories || [] |
| 201 | else message.warning(res.data?.message || "Failed to load analytic stories") |
| 202 | }) |
| 203 | .catch(err => { |
| 204 | message.error(err.response?.data?.message || "Failed to load analytic stories") |
| 205 | }) |
| 206 | .finally(() => { |
| 207 | loading.value = false |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | onBeforeMount(load) |
| 212 | </script> |