main
vue 212 lines 5.56 KB
Raw
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>