1
+<template>
2
+ <div class="flex flex-col gap-3">
3
+ <div class="flex flex-wrap items-center justify-end gap-2">
4
+ <div class="flex min-w-80 grow gap-2">
5
+ <n-popover overlap placement="bottom-start">
6
+ <template #trigger>
7
+ <div class="bg-default rounded-lg">
8
+ <n-button size="small" class="cursor-help!">
9
+ <template #icon>
10
+ <Icon :name="InfoIcon" />
11
+ </template>
12
+ </n-button>
13
+ </div>
14
+ </template>
15
+ <div v-if="coverage" class="flex flex-col gap-2">
16
+ <div class="box">
17
+ Tactics:
18
+ <code>{{ coverage.stats.total_tactics }}</code>
19
+ </div>
20
+ <div class="box">
21
+ Techniques:
22
+ <code>{{ coverage.stats.total_techniques }}</code>
23
+ </div>
24
+ <div class="box">
25
+ Covered:
26
+ <code>{{ coverage.stats.covered_techniques }}</code>
27
+ </div>
28
+ <div class="box">
29
+ Rules in scope:
30
+ <code>{{ coverage.stats.total_rules }}</code>
31
+ </div>
32
+ </div>
33
+ </n-popover>
34
+
35
+ <n-input
36
+ v-model:value="searchQuery"
37
+ size="small"
38
+ placeholder="Search techniques or rule names..."
39
+ class="max-w-120"
40
+ clearable
41
+ >
42
+ <template #prefix>
43
+ <Icon :name="SearchIcon" />
44
+ </template>
45
+ </n-input>
46
+
47
+ <n-popover :show="showFilters" trigger="manual" overlap placement="bottom-start" class="px-0!">
48
+ <template #trigger>
49
+ <div class="bg-default rounded-lg">
50
+ <n-badge :show="anyFiltersActive" dot type="success" :offset="[-4, 0]">
51
+ <n-button size="small" @click="showFilters = !showFilters">
52
+ <template #icon>
53
+ <Icon :name="FilterIcon" />
54
+ </template>
55
+ </n-button>
56
+ </n-badge>
57
+ </div>
58
+ </template>
59
+ <div class="divide-border flex w-50 flex-col gap-0 divide-y">
60
+ <div class="flex flex-col gap-2.5 px-3 pt-1 pb-3">
61
+ <n-select
62
+ v-model:value="selectedPlatform"
63
+ :options="platformOptions"
64
+ size="small"
65
+ placeholder="Platform"
66
+ class="w-full"
67
+ clearable
68
+ :consistent-menu-width="false"
69
+ />
70
+ <n-select
71
+ v-model:value="selectedSeverity"
72
+ :options="severityOptions"
73
+ clearable
74
+ size="small"
75
+ placeholder="Severity"
76
+ class="w-full"
77
+ :consistent-menu-width="false"
78
+ />
79
+ <n-select
80
+ v-model:value="selectedStatus"
81
+ :options="statusOptions"
82
+ clearable
83
+ size="small"
84
+ placeholder="Status"
85
+ class="w-full"
86
+ :consistent-menu-width="false"
87
+ />
88
+ <n-checkbox v-model:checked="hasGraylogFilter" size="small">
89
+ <span class="text-xs">Graylog Only</span>
90
+ </n-checkbox>
91
+ </div>
92
+ <div class="flex justify-between gap-2 px-3 pt-2">
93
+ <n-button size="small" quaternary @click="showFilters = false">Close</n-button>
94
+ <n-button size="small" secondary @click="resetFilters">Reset</n-button>
95
+ </div>
96
+ </div>
97
+ </n-popover>
98
+
99
+ <n-checkbox
100
+ v-model:checked="onlyCovered"
101
+ size="small"
102
+ class="shrink-0! self-center whitespace-nowrap"
103
+ >
104
+ <span class="text-xs">Only covered</span>
105
+ </n-checkbox>
106
+ </div>
107
+
108
+ <n-tooltip placement="bottom-end">
109
+ <template #trigger>
110
+ <n-button size="small" :disabled="!coverage" @click="exportCoverageCsv">
111
+ <template #icon>
112
+ <Icon :name="ExportIcon" />
113
+ </template>
114
+ Export CSV
115
+ </n-button>
116
+ </template>
117
+ Download a CSV of the current coverage (one row per technique and sub-technique, with rule counts and IDs).
118
+ </n-tooltip>
119
+
120
+ <n-tooltip placement="bottom-end">
121
+ <template #trigger>
122
+ <n-button size="small" :loading="refreshing" @click="handleRefresh">
123
+ <template #icon>
124
+ <Icon :name="RefreshIcon" />
125
+ </template>
126
+ Refresh Matrix
127
+ </n-button>
128
+ </template>
129
+ Force a re-fetch of the MITRE ATT&CK STIX bundle from
130
+ <code>github.com/mitre/cti</code>
131
+ , bypassing the 24-hour cache. Use this if MITRE published a new release and you want
132
+ the matrix to pick it up immediately.
133
+ </n-tooltip>
134
+ </div>
135
+
136
+ <div class="legend">
137
+ <span class="text-tertiary text-xs">Rules:</span>
138
+ <div v-for="step of legendSteps" :key="step.label" class="legend-item">
139
+ <span class="legend-swatch" :class="step.cls" />
140
+ <span class="text-secondary text-xs">{{ step.label }}</span>
141
+ </div>
142
+ <div v-if="coverage" class="text-secondary ml-auto text-xs">
143
+ <strong>{{ coverage.stats.covered_techniques }}</strong>
144
+ /
145
+ <strong>{{ coverage.stats.total_techniques }}</strong>
146
+ techniques ·
147
+ <strong>{{ coverage.stats.total_rules }}</strong>
148
+ rules
149
+ </div>
150
+ </div>
151
+
152
+ <div class="matrix-scroll-wrap">
153
+ <!-- Subtle top progress bar replaces the heavy spin overlay during refetches. -->
154
+ <div v-if="loading && coverage" class="matrix-progress" />
155
+
156
+ <div
157
+ class="matrix-scroll"
158
+ :class="{ 'matrix-scroll-loading': loading && coverage }"
159
+ >
160
+ <n-empty
161
+ v-if="!loading && coverage && filteredTactics.length === 0"
162
+ description="No techniques match your filters."
163
+ class="matrix-empty"
164
+ >
165
+ <template #extra>
166
+ <n-button size="small" @click="clearAllFilters">Clear filters</n-button>
167
+ </template>
168
+ </n-empty>
169
+
170
+ <n-spin v-else-if="loading && !coverage" :show="true" class="matrix-initial-load" />
171
+
172
+ <div v-else class="matrix-grid">
173
+ <div v-for="tactic of filteredTactics" :key="tactic.id" class="tactic-column">
174
+ <div
175
+ class="tactic-header"
176
+ :class="{ 'tactic-uncovered': isTacticUncovered(tactic) }"
177
+ >
178
+ <div class="flex items-center justify-between gap-2">
179
+ <div class="tactic-name">{{ tactic.name }}</div>
180
+ <span
181
+ class="tactic-coverage"
182
+ :class="{ 'tactic-coverage-zero': isTacticUncovered(tactic) }"
183
+ :title="`${tacticStats(tactic).covered} of ${tacticStats(tactic).total} techniques covered by CoPilot rules`"
184
+ >
185
+ {{ tacticStats(tactic).covered }}/{{ tacticStats(tactic).total }}
186
+ </span>
187
+ </div>
188
+ <div class="text-tertiary text-xs">{{ tactic.techniques.length }} shown</div>
189
+ </div>
190
+
191
+ <div class="technique-list">
192
+ <n-popover
193
+ v-for="tech of tactic.techniques"
194
+ :key="tactic.id + tech.id"
195
+ trigger="hover"
196
+ :delay="350"
197
+ :duration="80"
198
+ :show-arrow="false"
199
+ placement="right"
200
+ :disabled="tech.total_rule_count === 0"
201
+ >
202
+ <template #trigger>
203
+ <div
204
+ class="technique-cell"
205
+ :class="[
206
+ cellClass(tech),
207
+ {
208
+ 'cell-cross-tactic':
209
+ hoveredTechniqueId === tech.id &&
210
+ hoveredTacticId !== tactic.id
211
+ }
212
+ ]"
213
+ :title="cellTooltip(tech)"
214
+ @click="openTechnique(tactic, tech)"
215
+ @mouseenter="onCellEnter(tactic.id, tech.id)"
216
+ @mouseleave="onCellLeave"
217
+ >
218
+ <div class="technique-row">
219
+ <div class="technique-id">{{ tech.id }}</div>
220
+ <n-tag
221
+ v-if="tech.total_rule_count > 0"
222
+ size="tiny"
223
+ round
224
+ :bordered="false"
225
+ class="count-tag"
226
+ >
227
+ {{ tech.total_rule_count }}
228
+ </n-tag>
229
+ </div>
230
+ <div class="technique-name">{{ tech.name }}</div>
231
+
232
+ <div
233
+ v-if="tech.subtechniques.length"
234
+ class="technique-sub-toggle"
235
+ @click.stop="toggleExpand(tactic.id, tech.id)"
236
+ >
237
+ <Icon
238
+ :name="expanded[tactic.id + tech.id] ? ChevronDown : ChevronRight"
239
+ :size="10"
240
+ />
241
+ {{ tech.subtechniques.length }} sub
242
+ </div>
243
+
244
+ <div
245
+ v-if="expanded[tactic.id + tech.id]"
246
+ class="subtechnique-list"
247
+ @click.stop
248
+ >
249
+ <n-popover
250
+ v-for="sub of visibleSubs(tech, tactic.id + tech.id)"
251
+ :key="sub.id"
252
+ trigger="hover"
253
+ :delay="350"
254
+ :duration="80"
255
+ :show-arrow="false"
256
+ placement="right"
257
+ :disabled="sub.rule_count === 0"
258
+ >
259
+ <template #trigger>
260
+ <div
261
+ class="subtechnique-cell"
262
+ :class="cellClass(sub, true)"
263
+ :title="subCellTooltip(sub)"
264
+ @click="openSubTechnique(tactic, tech, sub)"
265
+ >
266
+ <div class="technique-row">
267
+ <div class="subtechnique-id">{{ sub.id }}</div>
268
+ <n-tag
269
+ v-if="sub.rule_count > 0"
270
+ size="tiny"
271
+ round
272
+ :bordered="false"
273
+ class="count-tag"
274
+ >
275
+ {{ sub.rule_count }}
276
+ </n-tag>
277
+ </div>
278
+ <div class="subtechnique-name">{{ sub.name }}</div>
279
+ </div>
280
+ </template>
281
+
282
+ <RulePreviewList :rule-ids="sub.rule_ids" :index="rulesIndex" />
283
+ </n-popover>
284
+
285
+ <div
286
+ v-if="tech.subtechniques.length > SUB_PREVIEW_LIMIT"
287
+ class="show-all-subs"
288
+ @click.stop="toggleShowAllSubs(tactic.id + tech.id)"
289
+ >
290
+ {{
291
+ showAllSubs[tactic.id + tech.id]
292
+ ? `Show fewer`
293
+ : `Show all ${tech.subtechniques.length}`
294
+ }}
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </template>
299
+
300
+ <RulePreviewList :rule-ids="tech.rule_ids" :index="rulesIndex" :extra-via-subs="tech.total_rule_count - tech.rule_count" />
301
+ </n-popover>
302
+
303
+ <n-empty
304
+ v-if="!tactic.techniques.length"
305
+ description="No techniques"
306
+ class="py-4"
307
+ size="small"
308
+ />
309
+ </div>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </div>
314
+
315
+ <TechniqueDrawer
316
+ v-model:show="drawerOpen"
317
+ :technique="selectedTechnique"
318
+ :sub-technique="selectedSubTechnique"
319
+ @update:show="onDrawerToggle"
320
+ />
321
+
322
+ <!-- Direct-from-hover rule detail modal: skips the drawer entirely
323
+ when the user clicks a rule name inside the hover preview. -->
324
+ <n-modal
325
+ v-model:show="quickRuleOpen"
326
+ preset="card"
327
+ :style="{ maxWidth: 'min(750px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
328
+ title="Detection Rule"
329
+ :bordered="false"
330
+ segmented
331
+ >
332
+ <RuleCardContent v-if="quickRuleId" :rule-id="quickRuleId" />
333
+ </n-modal>
334
+ </div>
335
+</template>
336
+
337
+<script setup lang="ts">
338
+import type {
339
+ MitreCoverageQuery,
340
+ MitreCoverageResponse,
341
+ MitreRuleIndexEntry,
342
+ MitreSubTechnique,
343
+ MitreTactic,
344
+ MitreTechnique,
345
+ PlatformFilter,
346
+ RuleSeverity,
347
+ RuleStatus
348
+} from "@/types/copilotSearches.d"
349
+import { useLocalStorage, watchDebounced } from "@vueuse/core"
350
+import {
351
+ NBadge,
352
+ NButton,
353
+ NCheckbox,
354
+ NEmpty,
355
+ NInput,
356
+ NModal,
357
+ NPopover,
358
+ NSelect,
359
+ NSpin,
360
+ NTag,
361
+ NTooltip,
362
+ useMessage
363
+} from "naive-ui"
364
+import { computed, h, onMounted, ref, watch } from "vue"
365
+import { useRoute, useRouter } from "vue-router"
366
+import Api from "@/api"
367
+import Icon from "@/components/common/Icon.vue"
368
+import RuleCardContent from "./RuleCardContent.vue"
369
+import TechniqueDrawer from "./TechniqueDrawer.vue"
370
+
371
+const InfoIcon = "carbon:information"
372
+const SearchIcon = "carbon:search"
373
+const RefreshIcon = "carbon:renew"
374
+const FilterIcon = "carbon:filter-edit"
375
+const ExportIcon = "carbon:download"
376
+const ChevronRight = "carbon:chevron-right"
377
+const ChevronDown = "carbon:chevron-down"
378
+
379
+const route = useRoute()
380
+const router = useRouter()
381
+const message = useMessage()
382
+
383
+const loading = ref(false)
384
+const refreshing = ref(false)
385
+const coverage = ref<MitreCoverageResponse | null>(null)
386
+const onlyCovered = useLocalStorage("copilot-searches/matrix/only-covered", false)
387
+const searchQuery = ref("")
388
+const expanded = useLocalStorage<Record<string, boolean>>("copilot-searches/matrix/expanded", {})
389
+const showAllSubs = ref<Record<string, boolean>>({})
390
+
391
+const SUB_PREVIEW_LIMIT = 5
392
+
393
+function visibleSubs(tech: MitreTechnique, key: string) {
394
+ if (showAllSubs.value[key]) return tech.subtechniques
395
+ return tech.subtechniques.slice(0, SUB_PREVIEW_LIMIT)
396
+}
397
+function toggleShowAllSubs(key: string) {
398
+ showAllSubs.value[key] = !showAllSubs.value[key]
399
+}
400
+
401
+const selectedPlatform = ref<PlatformFilter | null>(null)
402
+const selectedSeverity = ref<RuleSeverity | null>(null)
403
+const selectedStatus = ref<RuleStatus | null>(null)
404
+const hasGraylogFilter = ref(false)
405
+const showFilters = ref(false)
406
+
407
+const drawerOpen = ref(false)
408
+const selectedTechnique = ref<MitreTechnique | null>(null)
409
+const selectedSubTechnique = ref<MitreSubTechnique | null>(null)
410
+const selectedTacticIdForDeepLink = ref<string | null>(null)
411
+
412
+// Direct-from-hover rule modal
413
+const quickRuleOpen = ref(false)
414
+const quickRuleId = ref<string | null>(null)
415
+function openQuickRule(ruleId: string) {
416
+ quickRuleId.value = ruleId
417
+ quickRuleOpen.value = true
418
+}
419
+
420
+const hoveredTechniqueId = ref<string | null>(null)
421
+const hoveredTacticId = ref<string | null>(null)
422
+
423
+// Suppresses the filter-change watcher during the initial URL→ref hydration
424
+// so we don't fire a duplicate fetch right after the first load.
425
+const ready = ref(false)
426
+
427
+const platformOptions = [
428
+ { label: "Linux", value: "linux" },
429
+ { label: "Windows", value: "windows" },
430
+ { label: "PowerShell", value: "powershell" },
431
+ { label: "CVE", value: "cve" }
432
+]
433
+const severityOptions = [
434
+ { label: "Low", value: "low" },
435
+ { label: "Medium", value: "medium" },
436
+ { label: "High", value: "high" },
437
+ { label: "Critical", value: "critical" }
438
+]
439
+const statusOptions = [
440
+ { label: "Production", value: "production" },
441
+ { label: "Experimental", value: "experimental" },
442
+ { label: "Deprecated", value: "deprecated" }
443
+]
444
+
445
+const anyFiltersActive = computed(
446
+ () =>
447
+ !!selectedPlatform.value ||
448
+ !!selectedSeverity.value ||
449
+ !!selectedStatus.value ||
450
+ !!hasGraylogFilter.value
451
+)
452
+
453
+const rulesIndex = computed<Record<string, MitreRuleIndexEntry>>(() => coverage.value?.rules_index ?? {})
454
+
455
+/**
456
+ * Match against rule names/IDs via the in-memory rules_index. Used to surface
457
+ * techniques whose rules — not whose own name — match the search query.
458
+ */
459
+function ruleIdsMatch(ruleIds: string[], q: string): boolean {
460
+ const idx = rulesIndex.value
461
+ for (const id of ruleIds) {
462
+ if (id.toLowerCase().includes(q)) return true
463
+ const r = idx[id]
464
+ if (r && r.name.toLowerCase().includes(q)) return true
465
+ }
466
+ return false
467
+}
468
+
469
+const filteredTactics = computed<MitreTactic[]>(() => {
470
+ if (!coverage.value) return []
471
+ const q = searchQuery.value.trim().toLowerCase()
472
+ const tactics = coverage.value.tactics.map(tactic => ({
473
+ ...tactic,
474
+ techniques: tactic.techniques.filter(tech => {
475
+ if (onlyCovered.value && tech.total_rule_count === 0) return false
476
+ if (q) {
477
+ const techHaystack = `${tech.id} ${tech.name}`.toLowerCase()
478
+ const techMatches = techHaystack.includes(q)
479
+ const ruleMatches =
480
+ ruleIdsMatch(tech.rule_ids, q) ||
481
+ tech.subtechniques.some(s => ruleIdsMatch(s.rule_ids, q))
482
+ if (!techMatches && !ruleMatches) return false
483
+ }
484
+ return true
485
+ })
486
+ }))
487
+ // When the user is actively searching, drop tactics with no matches so the
488
+ // matrix collapses to just the relevant columns. Without an active search,
489
+ // we keep empty tactics visible (they're informative on their own).
490
+ return q ? tactics.filter(t => t.techniques.length > 0) : tactics
491
+})
492
+
493
+const legendSteps = [
494
+ { label: "0", cls: "cov-empty" },
495
+ { label: "1", cls: "cov-1" },
496
+ { label: "2-3", cls: "cov-2" },
497
+ { label: "4-7", cls: "cov-3" },
498
+ { label: "8+", cls: "cov-4" }
499
+] as const
500
+
501
+function tacticStats(tactic: MitreTactic) {
502
+ const source = coverage.value?.tactics.find(t => t.id === tactic.id)?.techniques ?? tactic.techniques
503
+ const total = source.length
504
+ const covered = source.filter(t => t.total_rule_count > 0).length
505
+ return { total, covered }
506
+}
507
+
508
+function isTacticUncovered(tactic: MitreTactic): boolean {
509
+ const { covered, total } = tacticStats(tactic)
510
+ return total > 0 && covered === 0
511
+}
512
+
513
+function cellClass(item: MitreTechnique | MitreSubTechnique, isSub = false) {
514
+ const count = "total_rule_count" in item ? item.total_rule_count : item.rule_count
515
+ if (count === 0) return `cov-empty`
516
+ if (count === 1) return `cov-1`
517
+ if (count <= 3) return `cov-2`
518
+ if (count <= 7) return `cov-3`
519
+ return `cov-4`
520
+}
521
+
522
+function cellTooltip(tech: MitreTechnique) {
523
+ if (tech.total_rule_count === 0) return `${tech.id} ${tech.name} — no CoPilot rules`
524
+ const subDelta = tech.total_rule_count - tech.rule_count
525
+ return subDelta
526
+ ? `${tech.id} ${tech.name} — ${tech.rule_count} direct, +${subDelta} via sub-techniques`
527
+ : `${tech.id} ${tech.name} — ${tech.rule_count} rule(s)`
528
+}
529
+function subCellTooltip(sub: MitreSubTechnique) {
530
+ return sub.rule_count
531
+ ? `${sub.id} ${sub.name} — ${sub.rule_count} rule(s)`
532
+ : `${sub.id} ${sub.name} — no rules`
533
+}
534
+
535
+function toggleExpand(tacticId: string, techId: string) {
536
+ const k = tacticId + techId
537
+ const willOpen = !expanded.value[k]
538
+ if (willOpen) {
539
+ // Auto-collapse other expanded techniques in the same tactic so columns
540
+ // don't sprawl vertically when several are open at once.
541
+ for (const otherKey of Object.keys(expanded.value)) {
542
+ if (otherKey.startsWith(tacticId) && otherKey !== k) {
543
+ expanded.value[otherKey] = false
544
+ }
545
+ }
546
+ }
547
+ expanded.value[k] = willOpen
548
+}
549
+
550
+function clearAllFilters() {
551
+ selectedPlatform.value = null
552
+ selectedSeverity.value = null
553
+ selectedStatus.value = null
554
+ hasGraylogFilter.value = false
555
+ searchQuery.value = ""
556
+ onlyCovered.value = false
557
+}
558
+
559
+function exportCoverageCsv() {
560
+ if (!coverage.value) return
561
+ const rows: string[][] = [["tactic_id", "tactic_name", "technique_id", "technique_name", "rule_count_direct", "rule_count_total", "rule_ids"]]
562
+ for (const tactic of coverage.value.tactics) {
563
+ for (const tech of tactic.techniques) {
564
+ rows.push([
565
+ tactic.id,
566
+ tactic.name,
567
+ tech.id,
568
+ tech.name,
569
+ String(tech.rule_count),
570
+ String(tech.total_rule_count),
571
+ tech.rule_ids.join("|")
572
+ ])
573
+ for (const sub of tech.subtechniques) {
574
+ rows.push([
575
+ tactic.id,
576
+ tactic.name,
577
+ sub.id,
578
+ sub.name,
579
+ String(sub.rule_count),
580
+ String(sub.rule_count),
581
+ sub.rule_ids.join("|")
582
+ ])
583
+ }
584
+ }
585
+ }
586
+ const csv = rows.map(r => r.map(cell => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n")
587
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
588
+ const url = URL.createObjectURL(blob)
589
+ const link = document.createElement("a")
590
+ link.href = url
591
+ link.download = `copilot-mitre-coverage-${new Date().toISOString().slice(0, 10)}.csv`
592
+ document.body.appendChild(link)
593
+ link.click()
594
+ document.body.removeChild(link)
595
+ URL.revokeObjectURL(url)
596
+}
597
+
598
+function onCellEnter(tacticId: string, techId: string) {
599
+ hoveredTacticId.value = tacticId
600
+ hoveredTechniqueId.value = techId
601
+}
602
+function onCellLeave() {
603
+ hoveredTacticId.value = null
604
+ hoveredTechniqueId.value = null
605
+}
606
+
607
+function resetFilters() {
608
+ selectedPlatform.value = null
609
+ selectedSeverity.value = null
610
+ selectedStatus.value = null
611
+ hasGraylogFilter.value = false
612
+ showFilters.value = false
613
+}
614
+
615
+function openTechnique(tactic: MitreTactic, tech: MitreTechnique) {
616
+ selectedTacticIdForDeepLink.value = tactic.id
617
+ selectedTechnique.value = tech
618
+ selectedSubTechnique.value = null
619
+ drawerOpen.value = true
620
+ syncRouteFromSelection()
621
+}
622
+function openSubTechnique(tactic: MitreTactic, tech: MitreTechnique, sub: MitreSubTechnique) {
623
+ selectedTacticIdForDeepLink.value = tactic.id
624
+ selectedTechnique.value = tech
625
+ selectedSubTechnique.value = sub
626
+ drawerOpen.value = true
627
+ syncRouteFromSelection()
628
+}
629
+
630
+function onDrawerToggle(open: boolean) {
631
+ if (!open) {
632
+ // Drawer just closed — drop the technique deep-link query.
633
+ const next = { ...route.query }
634
+ delete next.technique
635
+ delete next.sub
636
+ router.replace({ query: next })
637
+ }
638
+}
639
+
640
+function syncRouteFromSelection() {
641
+ if (!selectedTechnique.value) return
642
+ const next: Record<string, string> = { ...(route.query as Record<string, string>) }
643
+ next.view = "matrix"
644
+ next.technique = selectedTechnique.value.id
645
+ if (selectedSubTechnique.value) next.sub = selectedSubTechnique.value.id
646
+ else delete next.sub
647
+ router.replace({ query: next })
648
+}
649
+
650
+function syncFiltersToUrl() {
651
+ const next: Record<string, string> = { ...(route.query as Record<string, string>) }
652
+ if (selectedPlatform.value) next.platform = selectedPlatform.value
653
+ else delete next.platform
654
+ if (selectedSeverity.value) next.severity = selectedSeverity.value
655
+ else delete next.severity
656
+ if (selectedStatus.value) next.status = selectedStatus.value
657
+ else delete next.status
658
+ if (hasGraylogFilter.value) next.has_graylog = "true"
659
+ else delete next.has_graylog
660
+ router.replace({ query: next })
661
+}
662
+
663
+function applyFiltersFromUrl() {
664
+ const q = route.query
665
+ const platform = q.platform as string | undefined
666
+ const severity = q.severity as string | undefined
667
+ const status = q.status as string | undefined
668
+
669
+ const validPlatforms: PlatformFilter[] = ["all", "linux", "windows", "powershell", "cve"]
670
+ const validSeverities: RuleSeverity[] = ["low", "medium", "high", "critical"]
671
+ const validStatuses: RuleStatus[] = ["production", "experimental", "deprecated"]
672
+
673
+ selectedPlatform.value =
674
+ platform && (validPlatforms as string[]).includes(platform) ? (platform as PlatformFilter) : null
675
+ selectedSeverity.value =
676
+ severity && (validSeverities as string[]).includes(severity) ? (severity as RuleSeverity) : null
677
+ selectedStatus.value =
678
+ status && (validStatuses as string[]).includes(status) ? (status as RuleStatus) : null
679
+ hasGraylogFilter.value = q.has_graylog === "true"
680
+}
681
+
682
+function applyDeepLinkFromRoute() {
683
+ if (!coverage.value) return
684
+ const techId = (route.query.technique as string | undefined)?.toUpperCase()
685
+ const subId = (route.query.sub as string | undefined)?.toUpperCase()
686
+ if (!techId) return
687
+
688
+ for (const tactic of coverage.value.tactics) {
689
+ const tech = tactic.techniques.find(t => t.id === techId)
690
+ if (!tech) continue
691
+ if (subId) {
692
+ const sub = tech.subtechniques.find(s => s.id === subId)
693
+ if (sub) {
694
+ expanded.value[tactic.id + tech.id] = true
695
+ openSubTechnique(tactic, tech, sub)
696
+ return
697
+ }
698
+ }
699
+ openTechnique(tactic, tech)
700
+ return
701
+ }
702
+}
703
+
704
+async function load(opts: { preserveDeepLink?: boolean } = {}) {
705
+ loading.value = true
706
+ const query: MitreCoverageQuery = {
707
+ platform: selectedPlatform.value || undefined,
708
+ severity: selectedSeverity.value || undefined,
709
+ status: selectedStatus.value || undefined,
710
+ has_graylog: hasGraylogFilter.value || undefined
711
+ }
712
+ try {
713
+ const res = await Api.copilotSearches.getMitreCoverage(query)
714
+ if (res.data?.success) {
715
+ coverage.value = res.data
716
+ if (opts.preserveDeepLink) applyDeepLinkFromRoute()
717
+ } else {
718
+ message.warning(res.data?.message || "Failed to load MITRE coverage")
719
+ }
720
+ } catch (err: any) {
721
+ message.error(err.response?.data?.message || "Failed to load MITRE coverage")
722
+ } finally {
723
+ loading.value = false
724
+ }
725
+}
726
+
727
+async function handleRefresh() {
728
+ refreshing.value = true
729
+ try {
730
+ await Api.copilotSearches.refreshMitreMatrix()
731
+ await load()
732
+ message.success("MITRE matrix refreshed")
733
+ } catch (err: any) {
734
+ message.error(err.response?.data?.message || "Failed to refresh MITRE matrix")
735
+ } finally {
736
+ refreshing.value = false
737
+ }
738
+}
739
+
740
+watchDebounced(
741
+ [selectedPlatform, selectedSeverity, selectedStatus, hasGraylogFilter],
742
+ () => {
743
+ if (!ready.value) return
744
+ syncFiltersToUrl()
745
+ load()
746
+ },
747
+ { debounce: 250 }
748
+)
749
+
750
+// React to deep-link URL changes (back/forward, paste-link, etc.).
751
+watch(
752
+ () => [route.query.technique, route.query.sub] as const,
753
+ () => applyDeepLinkFromRoute()
754
+)
755
+
756
+onMounted(async () => {
757
+ applyFiltersFromUrl()
758
+ await load({ preserveDeepLink: true })
759
+ ready.value = true
760
+})
761
+
762
+// ---------------------------------------------------------------------------
763
+// Hover preview list — inline component. Rule rows are clickable; clicking
764
+// one opens the existing rule-detail modal directly without going through
765
+// the technique drawer.
766
+// ---------------------------------------------------------------------------
767
+const platformIcon: Record<string, string> = {
768
+ linux: "logos:linux-tux",
769
+ windows: "logos:microsoft-icon",
770
+ powershell: "vscode-icons:file-type-powershell",
771
+ cve: "carbon:security",
772
+ unknown: "carbon:help"
773
+}
774
+
775
+const RulePreviewList = (props: {
776
+ ruleIds: string[]
777
+ index: Record<string, MitreRuleIndexEntry>
778
+ extraViaSubs?: number
779
+}) => {
780
+ const ids = props.ruleIds || []
781
+ if (!ids.length) {
782
+ return h("div", { class: "preview-empty text-secondary text-xs" }, "No rules")
783
+ }
784
+ const shown = ids.slice(0, 6)
785
+ const remainder = ids.length - shown.length
786
+ return h("div", { class: "preview-wrap flex flex-col gap-1" }, [
787
+ h(
788
+ "div",
789
+ { class: "text-tertiary text-xs uppercase tracking-wide" },
790
+ `${ids.length} rule${ids.length === 1 ? "" : "s"}` +
791
+ (props.extraViaSubs ? ` · +${props.extraViaSubs} via sub-techniques` : "")
792
+ ),
793
+ ...shown.map(id => {
794
+ const entry = props.index[id]
795
+ const platform = (entry?.platform || "unknown").toLowerCase()
796
+ const iconName = platformIcon[platform] || platformIcon.unknown
797
+ const dataSources = entry?.data_sources || []
798
+ return h(
799
+ "div",
800
+ {
801
+ class: "preview-row flex flex-col gap-1",
802
+ key: id,
803
+ onClick: (e: MouseEvent) => {
804
+ e.stopPropagation()
805
+ openQuickRule(id)
806
+ },
807
+ title: "Click to open rule details"
808
+ },
809
+ [
810
+ h("div", { class: "flex items-center gap-2" }, [
811
+ h(Icon as any, { name: iconName, size: 14, class: "preview-platform shrink-0" }),
812
+ h("span", { class: "preview-name text-default text-xs" }, entry?.name || id),
813
+ entry?.severity
814
+ ? h(
815
+ "span",
816
+ { class: `preview-sev preview-sev-${entry.severity.toLowerCase()} text-xs` },
817
+ entry.severity
818
+ )
819
+ : null
820
+ ]),
821
+ dataSources.length
822
+ ? h(
823
+ "div",
824
+ { class: "preview-sources flex flex-wrap items-center gap-1" },
825
+ dataSources.map(s => h("span", { class: "preview-source text-xs", key: s }, s))
826
+ )
827
+ : null
828
+ ]
829
+ )
830
+ }),
831
+ remainder > 0
832
+ ? h("div", { class: "text-tertiary text-xs" }, `+ ${remainder} more — click cell to view all`)
833
+ : null
834
+ ])
835
+}
836
+</script>
837
+
838
+<style scoped lang="scss">
839
+.legend {
840
+ display: flex;
841
+ flex-wrap: wrap;
842
+ align-items: center;
843
+ gap: 10px;
844
+ padding: 6px 10px;
845
+ background: var(--bg-secondary-color);
846
+ border: 1px solid var(--border-color);
847
+ border-radius: var(--border-radius);
848
+}
849
+
850
+.legend-item {
851
+ display: inline-flex;
852
+ align-items: center;
853
+ gap: 5px;
854
+}
855
+
856
+.legend-swatch {
857
+ display: inline-block;
858
+ width: 14px;
859
+ height: 14px;
860
+ border-radius: 3px;
861
+ border: 1px solid var(--border-color);
862
+}
863
+
864
+.tactic-coverage {
865
+ font-family: var(--font-family-mono, monospace);
866
+ font-size: 0.7rem;
867
+ font-weight: 600;
868
+ color: var(--fg-secondary-color);
869
+ background: var(--bg-default-color);
870
+ border: 1px solid var(--border-color);
871
+ border-radius: 3px;
872
+ padding: 1px 6px;
873
+ white-space: nowrap;
874
+}
875
+
876
+.matrix-scroll-wrap {
877
+ position: relative;
878
+}
879
+
880
+/* Subtle indeterminate progress bar shown during filter refetches in place
881
+ of a heavy spin overlay. Sits at the top of the scroll container and
882
+ doesn't shift the layout when it appears/disappears. */
883
+.matrix-progress {
884
+ position: absolute;
885
+ left: 0;
886
+ right: 0;
887
+ top: 0;
888
+ height: 2px;
889
+ overflow: hidden;
890
+ background: rgba(var(--primary-color-rgb) / 0.1);
891
+ z-index: 3;
892
+ pointer-events: none;
893
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
894
+}
895
+.matrix-progress::after {
896
+ content: "";
897
+ position: absolute;
898
+ top: 0;
899
+ left: -40%;
900
+ width: 40%;
901
+ height: 100%;
902
+ background: var(--primary-color);
903
+ animation: matrix-progress-slide 1.1s ease-in-out infinite;
904
+}
905
+@keyframes matrix-progress-slide {
906
+ 0% { left: -40%; }
907
+ 100% { left: 100%; }
908
+}
909
+
910
+/* Matrix scrolls inside its own bounded box so the horizontal scrollbar
911
+ is always reachable without scrolling the whole page. Height adapts to
912
+ the viewport minus app chrome + our toolbar/legend rows. */
913
+.matrix-scroll {
914
+ overflow: auto;
915
+ max-height: calc(100vh - 260px);
916
+ min-height: 420px;
917
+ padding-bottom: 4px;
918
+ border: 1px solid var(--border-color);
919
+ border-radius: var(--border-radius);
920
+ background: var(--bg-secondary-color);
921
+ transition: opacity 0.18s ease;
922
+}
923
+
924
+/* During a filter refetch, fade existing data slightly so the user sees
925
+ the fresh load is happening without the matrix disappearing. */
926
+.matrix-scroll-loading {
927
+ opacity: 0.55;
928
+}
929
+
930
+.matrix-empty {
931
+ height: 100%;
932
+ min-height: 380px;
933
+ display: flex;
934
+ align-items: center;
935
+ justify-content: center;
936
+}
937
+
938
+.matrix-initial-load {
939
+ display: flex;
940
+ align-items: center;
941
+ justify-content: center;
942
+ min-height: 380px;
943
+ width: 100%;
944
+}
945
+
946
+.matrix-grid {
947
+ display: flex;
948
+ gap: 6px;
949
+ min-width: max-content;
950
+ padding: 4px;
951
+}
952
+
953
+.tactic-column {
954
+ width: 200px;
955
+ flex-shrink: 0;
956
+ display: flex;
957
+ flex-direction: column;
958
+}
959
+
960
+/* Tactic headers stick to the top of the scroll container so the column
961
+ label is always visible while scrolling vertically through techniques. */
962
+.tactic-header {
963
+ position: sticky;
964
+ top: 0;
965
+ z-index: 2;
966
+ padding: 8px 10px;
967
+ background: var(--bg-secondary-color);
968
+ border: 1px solid var(--border-color);
969
+ border-radius: 6px 6px 0 0;
970
+ border-bottom-width: 2px;
971
+}
972
+
973
+/* Tactic with no covered techniques — soft warning border so coverage gaps
974
+ surface at a glance without screaming. */
975
+.tactic-header.tactic-uncovered {
976
+ border-color: rgba(var(--warning-color-rgb) / 0.55);
977
+ border-bottom-color: rgba(var(--warning-color-rgb) / 0.7);
978
+ background: rgba(var(--warning-color-rgb) / 0.06);
979
+}
980
+
981
+.tactic-coverage-zero {
982
+ color: var(--warning-color);
983
+ border-color: rgba(var(--warning-color-rgb) / 0.55);
984
+ background: rgba(var(--warning-color-rgb) / 0.08);
985
+}
986
+
987
+.tactic-name {
988
+ font-weight: 600;
989
+ font-size: 0.85rem;
990
+ color: var(--fg-default-color);
991
+}
992
+
993
+.technique-list {
994
+ display: flex;
995
+ flex-direction: column;
996
+ gap: 3px;
997
+ padding-top: 3px;
998
+}
999
+
1000
+.technique-cell {
1001
+ padding: 6px 8px;
1002
+ border-radius: 4px;
1003
+ cursor: pointer;
1004
+ transition: background-color 0.12s, border-color 0.12s, box-shadow 0.12s;
1005
+ font-size: 0.75rem;
1006
+ border: 1px solid var(--border-color);
1007
+ background: var(--bg-default-color);
1008
+}
1009
+
1010
+.technique-cell:hover {
1011
+ border-color: rgba(var(--primary-color-rgb) / 0.6);
1012
+ background: rgba(var(--primary-color-rgb) / 0.08);
1013
+}
1014
+
1015
+/* Same technique appearing in another tactic column — gets a soft outline
1016
+ so you can see cross-tactic membership at a glance. */
1017
+.cell-cross-tactic {
1018
+ box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb) / 0.45);
1019
+}
1020
+
1021
+.technique-row {
1022
+ display: flex;
1023
+ justify-content: space-between;
1024
+ align-items: center;
1025
+ gap: 6px;
1026
+}
1027
+
1028
+.technique-id {
1029
+ font-weight: 600;
1030
+ font-family: var(--font-family-mono, monospace);
1031
+ color: var(--fg-default-color);
1032
+ font-size: 0.72rem;
1033
+}
1034
+
1035
+.technique-name {
1036
+ font-size: 0.7rem;
1037
+ color: var(--fg-secondary-color);
1038
+ margin-top: 2px;
1039
+ line-height: 1.25;
1040
+}
1041
+
1042
+.count-tag {
1043
+ font-weight: 700;
1044
+ min-width: 22px;
1045
+ justify-content: center;
1046
+}
1047
+
1048
+.technique-sub-toggle {
1049
+ margin-top: 4px;
1050
+ font-size: 0.65rem;
1051
+ color: var(--fg-tertiary-color);
1052
+ cursor: pointer;
1053
+ user-select: none;
1054
+ display: inline-flex;
1055
+ align-items: center;
1056
+ gap: 3px;
1057
+ padding: 2px 4px;
1058
+ border-radius: 3px;
1059
+ width: fit-content;
1060
+}
1061
+
1062
+.technique-sub-toggle:hover {
1063
+ color: var(--primary-color);
1064
+ background: rgba(var(--primary-color-rgb) / 0.08);
1065
+}
1066
+
1067
+.subtechnique-list {
1068
+ margin-top: 4px;
1069
+ padding-left: 6px;
1070
+ display: flex;
1071
+ flex-direction: column;
1072
+ gap: 2px;
1073
+ border-left: 2px solid var(--border-color);
1074
+}
1075
+
1076
+.subtechnique-cell {
1077
+ padding: 4px 6px;
1078
+ border-radius: 3px;
1079
+ cursor: pointer;
1080
+ font-size: 0.7rem;
1081
+ border: 1px solid var(--border-color);
1082
+ background: var(--bg-default-color);
1083
+ transition: background-color 0.12s, border-color 0.12s;
1084
+}
1085
+
1086
+.subtechnique-cell:hover {
1087
+ border-color: rgba(var(--primary-color-rgb) / 0.6);
1088
+ background: rgba(var(--primary-color-rgb) / 0.08);
1089
+}
1090
+
1091
+.subtechnique-id {
1092
+ font-family: var(--font-family-mono, monospace);
1093
+ font-weight: 600;
1094
+ font-size: 0.65rem;
1095
+ color: var(--fg-default-color);
1096
+}
1097
+
1098
+.subtechnique-name {
1099
+ font-size: 0.65rem;
1100
+ color: var(--fg-secondary-color);
1101
+ line-height: 1.25;
1102
+}
1103
+
1104
+.show-all-subs {
1105
+ margin-top: 2px;
1106
+ padding: 3px 6px;
1107
+ font-size: 0.65rem;
1108
+ color: var(--fg-tertiary-color);
1109
+ cursor: pointer;
1110
+ border-radius: 3px;
1111
+ user-select: none;
1112
+ text-align: center;
1113
+ border: 1px dashed var(--border-color);
1114
+}
1115
+.show-all-subs:hover {
1116
+ color: var(--primary-color);
1117
+ border-color: rgba(var(--primary-color-rgb) / 0.5);
1118
+ background: rgba(var(--primary-color-rgb) / 0.06);
1119
+}
1120
+
1121
+/* Coverage heat — subtle brand-tinted backgrounds, neutral borders so the
1122
+ grid still reads as a grid. Text never goes white-on-orange. */
1123
+.cov-empty {
1124
+ background: var(--bg-default-color);
1125
+}
1126
+.cov-1 {
1127
+ background: rgba(var(--primary-color-rgb) / 0.07);
1128
+}
1129
+.cov-2 {
1130
+ background: rgba(var(--primary-color-rgb) / 0.16);
1131
+}
1132
+.cov-3 {
1133
+ background: rgba(var(--primary-color-rgb) / 0.28);
1134
+}
1135
+.cov-4 {
1136
+ background: rgba(var(--primary-color-rgb) / 0.45);
1137
+}
1138
+</style>
1139
+
1140
+<style lang="scss">
1141
+/* Unscoped: applies to the inline RulePreviewList rendered inside n-popover bodies,
1142
+ which sit outside the component tree. */
1143
+.preview-wrap {
1144
+ max-width: 360px;
1145
+}
1146
+.preview-row {
1147
+ cursor: pointer;
1148
+ padding: 2px 4px;
1149
+ border-radius: 3px;
1150
+ transition: background-color 0.1s;
1151
+}
1152
+.preview-row:hover {
1153
+ background: rgba(var(--primary-color-rgb) / 0.1);
1154
+}
1155
+.preview-row:hover .preview-name {
1156
+ color: var(--primary-color);
1157
+}
1158
+.preview-row .preview-name {
1159
+ flex: 1;
1160
+ overflow: hidden;
1161
+ text-overflow: ellipsis;
1162
+ white-space: nowrap;
1163
+}
1164
+.preview-platform {
1165
+ opacity: 0.85;
1166
+}
1167
+
1168
+.preview-sources {
1169
+ margin-left: 22px;
1170
+}
1171
+.preview-source {
1172
+ font-size: 0.6rem;
1173
+ font-weight: 500;
1174
+ letter-spacing: 0.02em;
1175
+ color: var(--fg-tertiary-color);
1176
+ background: var(--bg-default-color);
1177
+ border: 1px solid var(--border-color);
1178
+ border-radius: 3px;
1179
+ padding: 1px 5px;
1180
+}
1181
+.preview-sev {
1182
+ font-size: 0.65rem;
1183
+ font-weight: 600;
1184
+ text-transform: uppercase;
1185
+ padding: 1px 6px;
1186
+ border-radius: 3px;
1187
+ border: 1px solid var(--border-color);
1188
+ color: var(--fg-secondary-color);
1189
+}
1190
+.preview-sev-low {
1191
+ color: var(--info-color);
1192
+ border-color: rgba(var(--info-color-rgb) / 0.4);
1193
+ background: rgba(var(--info-color-rgb) / 0.1);
1194
+}
1195
+.preview-sev-medium {
1196
+ color: var(--warning-color);
1197
+ border-color: rgba(var(--warning-color-rgb) / 0.4);
1198
+ background: rgba(var(--warning-color-rgb) / 0.1);
1199
+}
1200
+.preview-sev-high {
1201
+ color: var(--error-color);
1202
+ border-color: rgba(var(--error-color-rgb) / 0.4);
1203
+ background: rgba(var(--error-color-rgb) / 0.1);
1204
+}
1205
+.preview-sev-critical {
1206
+ color: var(--error-color);
1207
+ border-color: var(--error-color);
1208
+ background: rgba(var(--error-color-rgb) / 0.18);
1209
+}
1210
+</style>