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