| 1 | <template> |
| 2 | <div class="@container flex flex-col gap-4"> |
| 3 | <n-alert type="info"> |
| 4 | CoPilot Searches provides pre-built detection queries for threat hunting in your Wazuh indexer. See |
| 5 | <a href="https://github.com/socfortress/CoPilot-Search-Queries" target="_blank"> |
| 6 | https://github.com/socfortress/CoPilot-Search-Queries |
| 7 | </a> |
| 8 | for details. |
| 9 | </n-alert> |
| 10 | |
| 11 | <div class="flex flex-col"> |
| 12 | <div class="flex flex-wrap items-center justify-end gap-2"> |
| 13 | <n-button-group size="small"> |
| 14 | <n-button :type="viewMode === 'grid' ? 'primary' : 'default'" @click="viewMode = 'grid'"> |
| 15 | <template #icon> |
| 16 | <Icon :name="GridIcon" /> |
| 17 | </template> |
| 18 | Rules |
| 19 | </n-button> |
| 20 | <n-button :type="viewMode === 'matrix' ? 'primary' : 'default'" @click="viewMode = 'matrix'"> |
| 21 | <template #icon> |
| 22 | <Icon :name="MatrixIcon" /> |
| 23 | </template> |
| 24 | MITRE Navigator |
| 25 | </n-button> |
| 26 | </n-button-group> |
| 27 | |
| 28 | <div v-if="viewMode === 'grid'" class="flex min-w-80 grow gap-2"> |
| 29 | <n-popover overlap placement="bottom-start"> |
| 30 | <template #trigger> |
| 31 | <div class="bg-default rounded-lg"> |
| 32 | <n-button size="small" class="cursor-help!"> |
| 33 | <template #icon> |
| 34 | <Icon :name="InfoIcon" /> |
| 35 | </template> |
| 36 | </n-button> |
| 37 | </div> |
| 38 | </template> |
| 39 | <div class="flex flex-col gap-2"> |
| 40 | <div class="box"> |
| 41 | Total Rules: |
| 42 | <code>{{ pagination.total }}</code> |
| 43 | </div> |
| 44 | <div class="box"> |
| 45 | Filtered: |
| 46 | <code>{{ pagination.filtered }}</code> |
| 47 | </div> |
| 48 | </div> |
| 49 | </n-popover> |
| 50 | |
| 51 | <n-input |
| 52 | v-model:value="searchQuery" |
| 53 | size="small" |
| 54 | placeholder="Search rules..." |
| 55 | class="max-w-120" |
| 56 | clearable |
| 57 | > |
| 58 | <template #prefix> |
| 59 | <Icon :name="SearchIcon" /> |
| 60 | </template> |
| 61 | </n-input> |
| 62 | |
| 63 | <n-popover :show="showFilters" trigger="manual" overlap placement="right" class="px-0!"> |
| 64 | <template #trigger> |
| 65 | <div class="bg-default rounded-lg"> |
| 66 | <n-badge :show="filtered" dot type="success" :offset="[-4, 0]"> |
| 67 | <n-button size="small" @click="showFilters = true"> |
| 68 | <template #icon> |
| 69 | <Icon :name="FilterIcon" /> |
| 70 | </template> |
| 71 | </n-button> |
| 72 | </n-badge> |
| 73 | </div> |
| 74 | </template> |
| 75 | <div class="divide-border flex w-50 flex-col gap-0 divide-y"> |
| 76 | <div class="flex flex-col gap-2.5 px-3 pt-1 pb-3"> |
| 77 | <n-select |
| 78 | v-model:value="selectedPlatform" |
| 79 | :options="platformOptions" |
| 80 | size="small" |
| 81 | placeholder="Platform" |
| 82 | class="w-full" |
| 83 | clearable |
| 84 | :consistent-menu-width="false" |
| 85 | /> |
| 86 | |
| 87 | <n-select |
| 88 | v-model:value="selectedSeverity" |
| 89 | :options="severityOptions" |
| 90 | clearable |
| 91 | size="small" |
| 92 | placeholder="Severity" |
| 93 | class="w-full" |
| 94 | :consistent-menu-width="false" |
| 95 | /> |
| 96 | |
| 97 | <n-select |
| 98 | v-model:value="selectedStatus" |
| 99 | :options="statusOptions" |
| 100 | clearable |
| 101 | size="small" |
| 102 | placeholder="Status" |
| 103 | class="w-full" |
| 104 | :consistent-menu-width="false" |
| 105 | /> |
| 106 | |
| 107 | <n-checkbox v-model:checked="hasGraylogFilter" size="small"> |
| 108 | <span class="text-xs">Graylog Only</span> |
| 109 | </n-checkbox> |
| 110 | </div> |
| 111 | <div class="flex justify-between gap-2 px-3 pt-2"> |
| 112 | <div class="flex justify-start gap-2"> |
| 113 | <n-button size="small" quaternary @click="showFilters = false">Close</n-button> |
| 114 | </div> |
| 115 | <div class="flex justify-end gap-2"> |
| 116 | <n-button size="small" secondary @click="resetFilters()">Reset</n-button> |
| 117 | </div> |
| 118 | </div> |
| 119 | </div> |
| 120 | </n-popover> |
| 121 | </div> |
| 122 | |
| 123 | <n-button |
| 124 | v-if="viewMode === 'grid'" |
| 125 | size="small" |
| 126 | :type="selectMode ? 'primary' : 'default'" |
| 127 | @click="toggleSelectMode" |
| 128 | > |
| 129 | <template #icon> |
| 130 | <Icon :name="SelectIcon" /> |
| 131 | </template> |
| 132 | {{ selectMode ? "Exit select" : "Select" }} |
| 133 | </n-button> |
| 134 | |
| 135 | <n-button v-if="viewMode === 'grid'" size="small" :loading="refreshing" @click="handleRefresh"> |
| 136 | <template #icon> |
| 137 | <Icon :name="RefreshIcon" /> |
| 138 | </template> |
| 139 | Refresh Cache |
| 140 | </n-button> |
| 141 | |
| 142 | <n-pagination |
| 143 | v-if="viewMode === 'grid'" |
| 144 | v-model:page="pagination.current" |
| 145 | :page-size="pagination.size" |
| 146 | :item-count="pagination.filtered" |
| 147 | :page-slot="5" |
| 148 | /> |
| 149 | </div> |
| 150 | |
| 151 | <n-spin v-if="viewMode === 'grid'" :show="loading"> |
| 152 | <div class="my-3"> |
| 153 | <div |
| 154 | v-if="list.length" |
| 155 | class="grid grid-cols-1 gap-4 @2xl:grid-cols-2 @5xl:grid-cols-3 @6xl:grid-cols-4" |
| 156 | > |
| 157 | <RuleCard |
| 158 | v-for="rule of list" |
| 159 | :key="rule.id" |
| 160 | :rule |
| 161 | :provisioned="provisionedMap[rule.id] === true" |
| 162 | :selectable="selectMode" |
| 163 | :selected="selection.has(rule.id)" |
| 164 | @update:selected="v => toggleRuleSelected(rule.id, v)" |
| 165 | /> |
| 166 | </div> |
| 167 | |
| 168 | <template v-else> |
| 169 | <n-empty v-if="!loading" description="No rules found" class="h-48 justify-center" /> |
| 170 | </template> |
| 171 | </div> |
| 172 | </n-spin> |
| 173 | |
| 174 | <MatrixView v-else class="my-3" /> |
| 175 | |
| 176 | <div v-if="viewMode === 'grid'" class="flex justify-end"> |
| 177 | <n-pagination |
| 178 | v-if="list.length > 3" |
| 179 | v-model:page="pagination.current" |
| 180 | :page-size="pagination.size" |
| 181 | :item-count="pagination.filtered" |
| 182 | :page-slot="6" |
| 183 | /> |
| 184 | </div> |
| 185 | </div> |
| 186 | |
| 187 | <!-- Floating selection footer — only shown in grid view, when in select mode AND at least 1 rule selected. --> |
| 188 | <Transition name="fade-up"> |
| 189 | <div v-if="viewMode === 'grid' && selectMode && selection.size > 0" class="selection-footer"> |
| 190 | <div class="text-default text-sm"> |
| 191 | <strong>{{ selection.size }}</strong> |
| 192 | selected |
| 193 | <span class="text-tertiary ml-2 text-xs"> |
| 194 | ({{ provisionableSelectedCount }} with Graylog query) |
| 195 | </span> |
| 196 | </div> |
| 197 | |
| 198 | <div class="ml-auto flex items-center gap-2"> |
| 199 | <n-tooltip placement="top"> |
| 200 | <template #trigger> |
| 201 | <n-button |
| 202 | size="small" |
| 203 | type="primary" |
| 204 | :disabled="provisionableSelectedCount === 0" |
| 205 | @click="openBulkProvisionModal" |
| 206 | > |
| 207 | <template #icon> |
| 208 | <Icon :name="ProvisionIcon" /> |
| 209 | </template> |
| 210 | Provision selected |
| 211 | </n-button> |
| 212 | </template> |
| 213 | <template v-if="provisionableSelectedCount === 0"> |
| 214 | None of the selected rules has a Graylog query. |
| 215 | </template> |
| 216 | <template v-else> |
| 217 | Provision {{ provisionableSelectedCount }} rule{{ |
| 218 | provisionableSelectedCount === 1 ? "" : "s" |
| 219 | }} |
| 220 | as Graylog event definitions. |
| 221 | </template> |
| 222 | </n-tooltip> |
| 223 | |
| 224 | <n-button size="small" @click="exportSelectedCsv"> |
| 225 | <template #icon> |
| 226 | <Icon :name="ExportIcon" /> |
| 227 | </template> |
| 228 | CSV |
| 229 | </n-button> |
| 230 | <n-button size="small" @click="exportSelectedJson"> |
| 231 | <template #icon> |
| 232 | <Icon :name="ExportIcon" /> |
| 233 | </template> |
| 234 | JSON |
| 235 | </n-button> |
| 236 | |
| 237 | <n-button size="small" quaternary @click="clearSelection">Clear</n-button> |
| 238 | </div> |
| 239 | </div> |
| 240 | </Transition> |
| 241 | |
| 242 | <BulkProvisionModal |
| 243 | v-model:show="showBulkProvisionModal" |
| 244 | :rule-ids="selectedRuleIdsWithGraylog" |
| 245 | :provisionable-count="provisionableSelectedCount" |
| 246 | @success="onBulkProvisionSuccess" |
| 247 | /> |
| 248 | </div> |
| 249 | </template> |
| 250 | |
| 251 | <script setup lang="ts"> |
| 252 | import type { |
| 253 | BulkProvisionGraylogAlertResponse, |
| 254 | PlatformFilter, |
| 255 | RuleListQuery, |
| 256 | RuleSeverity, |
| 257 | RuleStatus, |
| 258 | RuleSummary |
| 259 | } from "@/types/copilotSearches.d" |
| 260 | import { watchDebounced } from "@vueuse/core" |
| 261 | import axios from "axios" |
| 262 | import { |
| 263 | NAlert, |
| 264 | NBadge, |
| 265 | NButton, |
| 266 | NButtonGroup, |
| 267 | NCheckbox, |
| 268 | NEmpty, |
| 269 | NInput, |
| 270 | NPagination, |
| 271 | NPopover, |
| 272 | NSelect, |
| 273 | NSpin, |
| 274 | NTooltip, |
| 275 | useMessage |
| 276 | } from "naive-ui" |
| 277 | import { computed, onMounted, ref, watch } from "vue" |
| 278 | import { useRoute, useRouter } from "vue-router" |
| 279 | import Api from "@/api" |
| 280 | import Icon from "@/components/common/Icon.vue" |
| 281 | import BulkProvisionModal from "./BulkProvisionModal.vue" |
| 282 | import MatrixView from "./MatrixView.vue" |
| 283 | import RuleCard from "./RuleCard.vue" |
| 284 | |
| 285 | const loading = ref(false) |
| 286 | const refreshing = ref(false) |
| 287 | const message = useMessage() |
| 288 | const list = ref<RuleSummary[]>([]) |
| 289 | const provisionedMap = ref<Record<string, boolean>>({}) |
| 290 | const pagination = ref({ |
| 291 | current: 1, |
| 292 | size: 24, |
| 293 | total: 0, |
| 294 | filtered: 0 |
| 295 | }) |
| 296 | |
| 297 | const selectedPlatform = ref<PlatformFilter | null>(null) |
| 298 | const selectedSeverity = ref<RuleSeverity | null>(null) |
| 299 | const selectedStatus = ref<RuleStatus | null>(null) |
| 300 | const searchQuery = ref<string | null>(null) |
| 301 | const hasGraylogFilter = ref(false) |
| 302 | const showFilters = ref(false) |
| 303 | |
| 304 | const filtered = computed<boolean>(() => { |
| 305 | return !!selectedPlatform.value || !!selectedSeverity.value || !!selectedStatus.value || !!hasGraylogFilter.value |
| 306 | }) |
| 307 | |
| 308 | const InfoIcon = "carbon:information" |
| 309 | const FilterIcon = "carbon:filter-edit" |
| 310 | const SearchIcon = "carbon:search" |
| 311 | const RefreshIcon = "carbon:renew" |
| 312 | const GridIcon = "carbon:grid" |
| 313 | const MatrixIcon = "carbon:chart-network" |
| 314 | const SelectIcon = "carbon:checkbox-checked" |
| 315 | const ProvisionIcon = "carbon:add-alt" |
| 316 | const ExportIcon = "carbon:download" |
| 317 | |
| 318 | // Always defaults to the rule grid. Matrix view is only entered when the |
| 319 | // user clicks the toggle in this session, or follows a `?view=matrix` link. |
| 320 | // Intentionally NOT persisted — entering CoPilot Searches always lands on |
| 321 | // the familiar rule cards. |
| 322 | const viewMode = ref<"grid" | "matrix">("grid") |
| 323 | |
| 324 | // Deep-linking: ?view=matrix overrides the default; toggling the view writes |
| 325 | // back to the URL so links are shareable. Lives in List.vue (not the parent |
| 326 | // view) so we don't have to touch routing config. |
| 327 | const route = useRoute() |
| 328 | const router = useRouter() |
| 329 | |
| 330 | onMounted(() => { |
| 331 | const v = route.query.view |
| 332 | if (v === "matrix" || v === "grid") viewMode.value = v |
| 333 | }) |
| 334 | |
| 335 | watch(viewMode, v => { |
| 336 | if (route.query.view === v) return |
| 337 | const next = { ...route.query } |
| 338 | if (v === "grid") { |
| 339 | // Drop matrix-only deep-link params when leaving the matrix. |
| 340 | delete next.view |
| 341 | delete next.technique |
| 342 | delete next.sub |
| 343 | } else { |
| 344 | next.view = v |
| 345 | } |
| 346 | router.replace({ query: next }) |
| 347 | }) |
| 348 | |
| 349 | const platformOptions = [ |
| 350 | { label: "Linux", value: "linux" }, |
| 351 | { label: "Windows", value: "windows" }, |
| 352 | { label: "PowerShell", value: "powershell" }, |
| 353 | { label: "CVE", value: "cve" } |
| 354 | ] |
| 355 | |
| 356 | const severityOptions = [ |
| 357 | { label: "Low", value: "low" }, |
| 358 | { label: "Medium", value: "medium" }, |
| 359 | { label: "High", value: "high" }, |
| 360 | { label: "Critical", value: "critical" } |
| 361 | ] |
| 362 | |
| 363 | const statusOptions = [ |
| 364 | { label: "Production", value: "production" }, |
| 365 | { label: "Experimental", value: "experimental" }, |
| 366 | { label: "Deprecated", value: "deprecated" } |
| 367 | ] |
| 368 | |
| 369 | let abortController: AbortController | null = null |
| 370 | |
| 371 | function resetFilters() { |
| 372 | selectedPlatform.value = null |
| 373 | selectedSeverity.value = null |
| 374 | selectedStatus.value = null |
| 375 | hasGraylogFilter.value = false |
| 376 | showFilters.value = false |
| 377 | } |
| 378 | |
| 379 | async function refreshProvisionedMap() { |
| 380 | const ids = list.value.map(r => r.id) |
| 381 | provisionedMap.value = {} |
| 382 | if (!ids.length) return |
| 383 | try { |
| 384 | const res = await Api.copilotSearches.checkGraylogProvisioningStatus(ids) |
| 385 | if (res.data?.success && !res.data.warning) { |
| 386 | provisionedMap.value = res.data.provisioned || {} |
| 387 | } |
| 388 | } catch { |
| 389 | // Silent — if Graylog is unreachable, just don't show the chip. |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | function getList() { |
| 394 | abortController?.abort() |
| 395 | abortController = new AbortController() |
| 396 | |
| 397 | loading.value = true |
| 398 | |
| 399 | const query: RuleListQuery = { |
| 400 | skip: (pagination.value.current - 1) * pagination.value.size, |
| 401 | limit: pagination.value.size, |
| 402 | platform: selectedPlatform.value || undefined, |
| 403 | severity: selectedSeverity.value || undefined, |
| 404 | status: selectedStatus.value || undefined, |
| 405 | search: searchQuery.value || undefined, |
| 406 | has_graylog: hasGraylogFilter.value ? true : undefined |
| 407 | } |
| 408 | |
| 409 | Api.copilotSearches |
| 410 | .getRules(query, abortController.signal) |
| 411 | .then(res => { |
| 412 | loading.value = false |
| 413 | |
| 414 | if (res.data.success) { |
| 415 | list.value = res.data?.rules || [] |
| 416 | pagination.value.total = res.data?.total || 0 |
| 417 | pagination.value.filtered = res.data?.filtered || 0 |
| 418 | // Best-effort: also fetch which of these rules are already in |
| 419 | // Graylog so RuleCard can show the "in Graylog" chip. If it |
| 420 | // fails (e.g. Graylog unreachable), no chips, no error. |
| 421 | refreshProvisionedMap() |
| 422 | } else { |
| 423 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 424 | } |
| 425 | }) |
| 426 | .catch(err => { |
| 427 | if (!axios.isCancel(err)) { |
| 428 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 429 | loading.value = false |
| 430 | } |
| 431 | }) |
| 432 | } |
| 433 | |
| 434 | async function handleRefresh() { |
| 435 | refreshing.value = true |
| 436 | try { |
| 437 | const res = await Api.copilotSearches.refreshCache() |
| 438 | if (res.data.success) { |
| 439 | message.success(`Cache refreshed! Loaded ${res.data.rules_loaded} rules.`) |
| 440 | getList() |
| 441 | } else { |
| 442 | message.warning(res.data?.message || "Failed to refresh cache") |
| 443 | } |
| 444 | } catch (err: any) { |
| 445 | message.error(err.response?.data?.message || "Failed to refresh cache") |
| 446 | } finally { |
| 447 | refreshing.value = false |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | watchDebounced( |
| 452 | [selectedPlatform, selectedSeverity, selectedStatus, searchQuery, hasGraylogFilter, () => pagination.value.current], |
| 453 | getList, |
| 454 | { |
| 455 | deep: true, |
| 456 | debounce: 300, |
| 457 | immediate: true |
| 458 | } |
| 459 | ) |
| 460 | |
| 461 | // --------------------------------------------------------------------------- |
| 462 | // Multi-select bulk actions |
| 463 | // --------------------------------------------------------------------------- |
| 464 | |
| 465 | const selectMode = ref(false) |
| 466 | const selection = ref<Set<string>>(new Set()) |
| 467 | // Cache of full RuleSummary objects for selected IDs, so we can keep them |
| 468 | // available across pagination changes (the visible `list` only holds the |
| 469 | // current page). |
| 470 | const selectionCache = ref<Map<string, RuleSummary>>(new Map()) |
| 471 | |
| 472 | const showBulkProvisionModal = ref(false) |
| 473 | |
| 474 | const provisionableSelectedCount = computed( |
| 475 | () => Array.from(selectionCache.value.values()).filter(r => r.has_graylog_query).length |
| 476 | ) |
| 477 | |
| 478 | const selectedRuleIdsWithGraylog = computed(() => |
| 479 | Array.from(selectionCache.value.values()) |
| 480 | .filter(r => r.has_graylog_query) |
| 481 | .map(r => r.id) |
| 482 | ) |
| 483 | |
| 484 | function toggleSelectMode() { |
| 485 | selectMode.value = !selectMode.value |
| 486 | if (!selectMode.value) clearSelection() |
| 487 | } |
| 488 | |
| 489 | function toggleRuleSelected(ruleId: string, value: boolean) { |
| 490 | if (value) { |
| 491 | selection.value.add(ruleId) |
| 492 | const summary = list.value.find(r => r.id === ruleId) |
| 493 | if (summary) selectionCache.value.set(ruleId, summary) |
| 494 | } else { |
| 495 | selection.value.delete(ruleId) |
| 496 | selectionCache.value.delete(ruleId) |
| 497 | } |
| 498 | // Trigger reactivity (Set/Map mutations don't notify by themselves). |
| 499 | selection.value = new Set(selection.value) |
| 500 | selectionCache.value = new Map(selectionCache.value) |
| 501 | } |
| 502 | |
| 503 | function clearSelection() { |
| 504 | selection.value = new Set() |
| 505 | selectionCache.value = new Map() |
| 506 | } |
| 507 | |
| 508 | function openBulkProvisionModal() { |
| 509 | if (provisionableSelectedCount.value === 0) return |
| 510 | showBulkProvisionModal.value = true |
| 511 | } |
| 512 | |
| 513 | function onBulkProvisionSuccess(res: BulkProvisionGraylogAlertResponse) { |
| 514 | // Reflect new "in Graylog" state on the visible list immediately. |
| 515 | const next = { ...provisionedMap.value } |
| 516 | for (const r of res.results) { |
| 517 | if (r.status === "provisioned" || r.status === "skipped") { |
| 518 | next[r.rule_id] = true |
| 519 | } |
| 520 | } |
| 521 | provisionedMap.value = next |
| 522 | } |
| 523 | |
| 524 | function downloadBlob(blob: Blob, filename: string) { |
| 525 | const url = URL.createObjectURL(blob) |
| 526 | const link = document.createElement("a") |
| 527 | link.href = url |
| 528 | link.download = filename |
| 529 | document.body.appendChild(link) |
| 530 | link.click() |
| 531 | document.body.removeChild(link) |
| 532 | URL.revokeObjectURL(url) |
| 533 | } |
| 534 | |
| 535 | function exportSelectedCsv() { |
| 536 | const rules = Array.from(selectionCache.value.values()) |
| 537 | if (!rules.length) return |
| 538 | const header = [ |
| 539 | "id", |
| 540 | "name", |
| 541 | "severity", |
| 542 | "platform", |
| 543 | "status", |
| 544 | "has_graylog_query", |
| 545 | "mitre_attack_id", |
| 546 | "description" |
| 547 | ] |
| 548 | const rows = rules.map(r => [ |
| 549 | r.id, |
| 550 | r.name, |
| 551 | r.severity, |
| 552 | r.platform, |
| 553 | r.status, |
| 554 | String(r.has_graylog_query), |
| 555 | (r.mitre_attack_id || []).join("|"), |
| 556 | (r.description || "").replace(/\s+/g, " ") |
| 557 | ]) |
| 558 | const csv = [header, ...rows] |
| 559 | .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(",")) |
| 560 | .join("\n") |
| 561 | const stamp = new Date().toISOString().slice(0, 10) |
| 562 | downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8;" }), `copilot-searches-selected-${stamp}.csv`) |
| 563 | } |
| 564 | |
| 565 | function exportSelectedJson() { |
| 566 | const rules = Array.from(selectionCache.value.values()) |
| 567 | if (!rules.length) return |
| 568 | const json = JSON.stringify(rules, null, 2) |
| 569 | const stamp = new Date().toISOString().slice(0, 10) |
| 570 | downloadBlob( |
| 571 | new Blob([json], { type: "application/json;charset=utf-8;" }), |
| 572 | `copilot-searches-selected-${stamp}.json` |
| 573 | ) |
| 574 | } |
| 575 | </script> |
| 576 | |
| 577 | <style scoped lang="scss"> |
| 578 | .selection-footer { |
| 579 | position: fixed; |
| 580 | bottom: 16px; |
| 581 | left: 50%; |
| 582 | transform: translateX(-50%); |
| 583 | z-index: 50; |
| 584 | display: flex; |
| 585 | align-items: center; |
| 586 | gap: 16px; |
| 587 | min-width: min(680px, 92vw); |
| 588 | max-width: 92vw; |
| 589 | padding: 10px 16px; |
| 590 | background: var(--bg-secondary-color); |
| 591 | border: 1px solid var(--border-color); |
| 592 | border-radius: var(--border-radius); |
| 593 | box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25); |
| 594 | } |
| 595 | |
| 596 | .fade-up-enter-active, |
| 597 | .fade-up-leave-active { |
| 598 | transition: |
| 599 | opacity 0.2s ease, |
| 600 | transform 0.2s ease; |
| 601 | } |
| 602 | .fade-up-enter-from, |
| 603 | .fade-up-leave-to { |
| 604 | opacity: 0; |
| 605 | transform: translate(-50%, 12px); |
| 606 | } |
| 607 | </style> |