@cryptotaxi247 / CoPilot / commits / de2816e2

Feat/detection catalog (#881)

* feat: add Wazuh detection catalog components and types - Implement WazuhLogTest component for testing log lines against Wazuh rules. - Create WazuhRuleDetail component to display detailed information about a specific Wazuh rule. - Develop WazuhRulesIndex component to list and filter Wazuh rules, integrating log test functionality. - Define TypeScript types for detection catalog responses, including Wazuh rules and log test results. - Add DetectionCatalog view to serve as the main entry point for the detection catalog interface. * feat: enhance detection catalog with compliance framework and stats - Added `last_seen` property to `CatalogWazuhRuleRow` and `CatalogWazuhRuleDetailResponse` for tracking rule activity. - Introduced `customer_code` in `CatalogWazuhRulesResponse` to reflect customer scope. - Implemented new interfaces for compliance frameworks and responses, including `CatalogComplianceFramework`, `CatalogComplianceFrameworksResponse`, `CatalogComplianceGroupRow`, and `CatalogComplianceResponse`. - Created `CatalogStatTile` component for displaying statistics in the detection catalog. - Developed `ComplianceIndex` component to show compliance coverage with filtering and modal details for control IDs. - Added database migrations to include `alert_id`, `match_field`, and `match_value` columns in relevant tables. * precommit-fixes * chore(deps): update dependencies * lint * refactor(catalog): Improve template readability * format * lint * refactor(customer-size): Migrate to Tailwind CSS * feat(customer-size): Add launch icon to indices * style(indices): Adjust content padding * feat(TopIndices): Integrate vue-echarts for improved chart rendering and update chart options * style(common): Enhance input autofill styles for better user experience * refactor(MetricsGauge): Replace gauge element with VChart component and streamline chart options * refactor(MetricsChart): Replace chart rendering with VChart component and enhance chart configuration * refactor(MetricsGauge): Update gauge chart configuration with gradient colors and improved pointer design * refactor(UncommittedEntries): Migrate chart rendering to VChart component and enhance chart configuration with ECharts options * refactor(ChartColumn): Transition to VChart component and implement ECharts for enhanced chart functionality and configuration * refactor(ChartPie): Transition to VChart component and implement ECharts for improved pie chart functionality and configuration * refactor(ChartPie): Enhance pie chart configuration by adjusting label positioning, font size, and implementing item click resolution * refactor(ChartPie): Update tooltip configuration with enhanced styling and background adjustments for improved visual clarity * refactor(ChartBar): Transition to VChart component and implement ECharts for enhanced bar chart functionality and configuration * refactor(ChartBar): Implement sorting for bar chart data and update axis labels for improved visualization * refactor(ChartPie): Enhance tooltip functionality with custom formatting and styling for improved user experience * refactor(charts): Consolidate tooltip functionality and color constants across chart components for improved maintainability and consistency * refactor(charts): Enhance tooltip formatting across chart components for improved color resolution and consistency * refactor(charts): Add time axis tooltip formatting for enhanced data representation in chart components * refactor(charts): Introduce multi-series tooltip formatting for improved data presentation in chart components * refactor(metrics): Rename formatBytes prop to useFormatBytes for clarity and update related references in MetricsChart and MetricsDashboard components * refactor(charts): Introduce CHART_GRID_CONTAIN_AXIS_LABELS constant for consistent axis label handling across ChartBar and ChartColumn components * refactor(detection-catalog): Update styling & date utils * refactor(detection-catalog): Dynamic stat tiles * refactor(detection-catalog): Use CardLink for header stats * refactor(detection-catalog): Update tab and card styles * feat(cardlink): Add size and left icon props * refactor(detection-catalog): Streamline UI * refactor(detection-catalog): Streamline shell * refactor(catalog): Use CardLink for all stats * feat(detection-catalog): Add stats component * chore(deps): Update frontend dependencies * chore(build): Configure pnpm and Vite * chore(build): Configure type-check and update taze * refactor(common): Extract CardLink prop types * feat(detection-catalog): Modernize story UI * refactor(detection-catalog): Streamline index UI * feat(detection-catalog): Add logtest drawer * refactor(detection-catalog): Modernize logtest UI * refactor(detection-catalog): Refine logtest UI layout * refactor(detection-catalog): Refine logtest result UI * refactor(detection-catalog): Use NTag for UI chips * refactor(detection-catalog): Use NTag in rule detail * feat(common): Add copy button for code source * refactor(detection-catalog): Clean up rule detail * lint * refactor(detection-catalog): Improve gaps table UX * feat(detection-catalog): Handle story detail errors * refactor(detection-catalog): Refactor index UI * feat(detection-catalog): Add compliance detail * lint * refactor(detectionCatalog): Use useStorage for history --------- Co-authored-by: taylor_socfortress <taylor.walton@socfortress.co> Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

Amine Moussa committed May 27, 2026 at 15:27 UTC de2816e2d911a642420083106c1229a1432981b6
47 files changed +7359 -1628
.vscode/settings.json
+2
@@ -5,6 +5,7 @@
5 "apexchart",
6 "arcticons",
7 "artifactstring",
8 + "autoresize",
9 "bitdefender",
10 "CARBONBLACK",
11 "clickoutside",
@@ -38,6 +39,7 @@
39 "lastupdate",
40 "linebreak",
41 "Logsource",
42 + "logtest",
43 "majesticons",
44 "mimecast",
45 "MSSP",
CLAUDE.md
+57
@@ -165,6 +165,55 @@ MySQL is the primary store via async SQLAlchemy/SQLModel (`app/db/db_session.py`
165 - **Source-mapped ingest dictionaries** — the `incident_management_*fieldname` family (`fieldname`, `assetfieldname`, `timestampfieldname`, `alerttitlefieldname`, `iocfieldname`, `customercodefieldname`) maps per-source (`wazuh`, `velociraptor`, `office365`, …) field names to canonical ingest fields. New SIEM source = new rows in *all six* tables.
166 - **Connector credentials are global, integration auth keys are per-customer.** Connectors (`app/connectors/models.py:Connectors`) hold one row per first-party tool for the whole deployment. Integrations (`integration_auth_keys` via the subscription chain) hold one row per (customer, integration). The third axis — Shuffle — uses `customer_shuffle_integration.shuffle_org_id` as the per-customer differentiator while the deployment-wide `SHUFFLER_API_KEY` lives in the `connectors` table.
167
168 +### Detections Catalog (view layer over `rules_cache` + Wazuh)
169 +
170 +The Detections Catalog (top-level nav, `/detection-catalog` — note URL path stays singular for backward-compat; user-facing label is plural since the Wazuh tab was added) is a discovery surface — *not* its own data store. It is **the single feature in CoPilot that deliberately introduces no new tables, no new columns, no Alembic migrations, no schema of any kind.** Everything is in-memory caches loading from sources that already exist.
171 +
172 +**Source caches (all in `app/integrations/copilot_searches/services/`):**
173 +
174 +- **`rules_cache`** — CoPilot Searches rule corpus, loaded from GitHub. Each rule carries `analytic_story`, `product`, `data_source`, `mitre_attack_id`, `tactics`, `type`, `references`, …
175 +- **`wazuh_rules_cache`** — full Wazuh Manager ruleset, loaded via one `get_wazuh_rules(limit=100000)` call. 1-hour TTL (rules change only on operator action). Graceful degradation: when Wazuh is unreachable the cache holds `unavailable_reason` and `is_available=False` so the UI can render an inline warning instead of erroring.
176 +- **`wazuh_firing_stats_cache`** — per-rule hit counts from the Wazuh **indexer** (not manager). 15-min TTL. One ES terms aggregation per refresh buckets the last 30d of alerts by `rule.id` with **three sub-aggs**: `last_7d` (filter to 7d), `last_seen` (max of `timestamp` — gives the most recent firing per rule, rendered as relative time in the modal), and the doc_count itself (30d). ~50–100 KB of buckets, sub-second response even on 100M-doc indices. **Uses an ES-native wildcard+exclusion pattern** rather than hardcoding `wazuh-alerts-*`, because real SOCFortress deployments spread alerts across vendor-/customer-prefixed indices (`office365-<code>`, `crowdstrike-<code>`, `carbonblack-<code>`, `huntress_<code>`, ad-hoc test indices — easily 1500+ on lab clusters). The pattern is `*,-.*,-_*,-wazuh-monitoring-*,-wazuh-statistics-*,-wazuh-states-*,-wazuh-vulnerabilities-*,-security-auditlog-*` — exclusions remove Wazuh internals + Kibana/OpenSearch system indices; the `*` includes everything else. A separate `_cat/indices` call runs alongside but only to populate `resolved_indices` for debug logging — the search itself uses the pattern string. Field-name fallback chain `rule.id → rule_id → id` because the index mapping varies across versions; the winning field is sticky between refreshes. Search uses `ignore_unavailable=True` and `allow_no_indices=True` so one inaccessible index doesn't fail the whole aggregation.
177 +
178 +- **Per-customer firing slice is NOT cached** — `fetch_firing_stats_for_customer(customer_code)` runs a fresh ES aggregation per request with an extra `term` filter on `agent_labels_customer` (or `agent.labels.customer` — both shapes tried in fallback). Reason: caching every customer × rule combo would balloon memory for customers nobody is looking at. On-demand keeps the global cache lean and the per-customer query is still sub-second. The customer field uses Graylog's flat convention (`agent_labels_customer`) by default since that's what SOCFortress's pipelines produce; falls back to the nested Wazuh-vanilla shape (`agent.labels.customer`) for stock installs.
179 +- **`mitre_matrix`** — STIX bundle file. Resolves `mitre_attack_id` → tactic display names and powers the Coverage Gaps tab.
180 +
181 +Consequence: refreshing CoPilot Searches refreshes the Stories surface; Wazuh rules + firing stats have their own TTL'd refresh paths. There is no migration, no new table. Aggregations (group by story, project to row shape, compute coverage gaps, format synthesized XML, …) happen *fresh on each request* inside `services/detection_catalog.py`. **If you find yourself adding a database column, schema, or migration for the catalog, stop** — that's a design violation. The catalog must remain a thin projection.
182 +
183 +**Surfaces (browse modes):**
184 +
185 +- **CoPilot Searches tab** (URL token still `?tab=stories` for back-compat) — CoPilot Searches rules grouped by `analytic_story` tag. Click a row → story detail page (description, member detections table, deduplicated data sources / references). The story detail is its own URL state (`?story=Foo`), not a tab.
186 +- **Wazuh Rules tab** — flat searchable grid over the entire Wazuh ruleset (~3–5k rows). Columns: ID · Level · Description · Groups · MITRE · File · Hits 30d (with 7d sub-count) when firing stats available. Quick-filter chips **All / Top noisy (50) / Dead (level ≥7, 0 hits 30d)** for tuning workflows. Customer-scope dropdown to slice firing stats by `agent_labels_customer` (or `agent.labels.customer` — both shapes tried). Row click → modal with full meta + reconstructed `<rule>` XML + "Last fired" relative timestamp.
187 +- **Coverage Gaps tab** — MITRE techniques no rule in either corpus addresses. Sub-techniques collapsed into parents (a hit on T1059.001 counts as coverage for T1059) — listing every sub-technique would flood the gap report.
188 +- **Compliance tab** — Wazuh rules grouped by control IDs for a selected compliance framework (PCI DSS / HIPAA / NIST 800-53 / GDPR / TSC / GPG13). Each row shows rule count + total firing hits per control. Frameworks are statically declared in `services/detection_catalog.py:COMPLIANCE_FRAMEWORKS` — adding a new one is one row in that dict + the field already being on the cached Wazuh rules.
189 +- **Logtest panel** (collapsible, above the Wazuh table, not its own tab) — paste a raw log line, pick a `log_format`, see which Wazuh rule fires. Wraps Wazuh's `PUT /logtest` so we get real Wazuh decoder/rule-chain behavior rather than re-implementing the engine. The panel persists the **last 5 tests** to localStorage (`detectionCatalog.logtest.history`) so analysts can click any prior paste to restore the input + format without retyping.
190 +
191 +**Rule source XML synthesis:** The Wazuh rule detail modal renders a reconstructed `<rule id="…" level="…">…</rule>` block in the "Rule Source" section. We **don't fetch** the original `.xml` file via Wazuh's `GET /rules/files/{filename}` because (a) that endpoint returns the whole file (dozens of rules per file), requiring XML parsing to extract one, and (b) it's admin-scoped while the catalog is analyst-accessible. Synthesis from cached fields (`id`, `level`, `description`, `groups`, `mitre`, compliance arrays, the free-form `details` dict) is functionally identical and avoids both problems. See `_synthesize_rule_xml` in `services/detection_catalog.py` if you need to evolve the format.
192 +
193 +**Auth-scope sidestep pattern** (used throughout the catalog): when a catalog route needs data from an admin-only connector route, **call the underlying service function directly** rather than proxying through the connector route. Scope checks live on the route handler (the `Security(...require_any_scope("admin"))` dependency), not on the service function. The catalog's `wazuh_rules_cache.refresh()` calls `get_wazuh_rules()` directly; the catalog's logtest calls `run_logtest()` directly; both let the catalog routes stay `admin|analyst|customer_user` while the connector routes (used for management actions like enable/disable) stay locked down. If you find yourself wanting to widen the scope on an existing connector route to feed the catalog, don't — sidestep instead.
194 +
195 +**Wiring layout** (everything stays inside the `copilot_searches` namespace on the backend and a `detectionCatalog/` namespace on the frontend):
196 +
197 +- Backend services: `services/detection_catalog.py` (aggregators), `services/wazuh_rules_cache.py`, `services/wazuh_firing_stats_cache.py`
198 +- Backend connector wrapper: `app/connectors/wazuh_manager/services/logtest.py` (thin `PUT /logtest` wrapper — see `send_put_request` footgun below)
199 +- Backend routes (appended to `routes/copilot_searches.py`):
200 + - `GET /catalog/stats` — counts for the header strip (includes Wazuh-side counts mirrored from the cache)
201 + - `GET /catalog/stories` + `GET /catalog/stories/{story_name:path}` (the `:path` converter is required — story names contain spaces and punctuation)
202 + - `GET /catalog/wazuh-rules` (optional `?customer_code=X` for per-customer firing scope) + `GET /catalog/wazuh-rules/{rule_id}` (rule_id is `int`; route ordering matters — see route-ordering footgun)
203 + - `GET /catalog/coverage-gaps`
204 + - `GET /catalog/compliance/frameworks` (static, before wildcard) + `GET /catalog/compliance/{framework}` (framework key from `COMPLIANCE_FRAMEWORKS` dict; 400 on unknown)
205 + - `POST /catalog/wazuh-rules/test` — logtest
206 +- Frontend view: `src/views/DetectionCatalog.vue` (thin wrapper) → `src/components/detectionCatalog/` directory:
207 + - `DetectionCatalogShell.vue` — header strip + tab switcher (URL-synced via `?tab=stories|wazuh|gaps`)
208 + - `StoriesIndex.vue` + `StoryDetail.vue` — the original Stories surface
209 + - `WazuhRulesIndex.vue` — Wazuh tab grid + quick-filter chips + customer-scope dropdown, mounts `WazuhLogTest.vue` above the table
210 + - `WazuhRuleDetail.vue` — Wazuh rule detail modal contents (incl. Last fired relative-time badge)
211 + - `WazuhLogTest.vue` — collapsible logtest panel, persists last 5 tests to localStorage
212 + - `CoverageGapsIndex.vue` — Coverage Gaps tab
213 + - `ComplianceIndex.vue` — Compliance tab (framework selector + grouped table + drill-down modal)
214 +- Frontend supporting files: `src/types/detectionCatalog.d.ts`, `src/api/endpoints/detectionCatalog.ts`
215 +- Three authorized cross-cutting edits and only these three: nav entry in `app-layouts/common/Navbar/items.tsx`, route in `router/index.ts`, barrel registration in `api/index.ts`. Nothing else outside the catalog namespace should change for this feature.
216 +
217 ### Frontend essentials
218
219 - `src/api/httpClient.ts` (axios) and `src/api/sseClient.ts` (`@microsoft/fetch-event-source`) — backend traffic. Wrappers in `src/api/endpoints/`.
@@ -196,3 +245,11 @@ The `customer-portal/` mirrors this structure but is a leaner standalone app, se
245 - **`regex=` → `pattern=` is FastAPI-only.** FastAPI 0.116 deprecated `regex=` on `Query/Path/Header/Body` in favor of `pattern=`. **`sqlmodel.Field` still uses `regex=` and rejects `pattern=`** — blindly running a global rename will crashloop the backend at import time with `TypeError: Field() got an unexpected keyword argument 'pattern'`. Pydantic v2 native `Field` uses `pattern=`. Three different surfaces, three different rules; check the import before renaming.
246 - **`pyvelociraptor` is pure gRPC transport.** It `json.loads` whatever the Velociraptor server emits in `Response` chunks; it does no semantic deserialization. The shape is whatever the server version picks, and *can change between minor versions*. 0.75.6 changed `_client_config` from a structured object to a YAML string. Schemas for VQL outputs should default to tolerant — drop unused fields, structure only what we actually read, lean on `extra='ignore'`.
247 - **`elasticsearch7.exceptions.RequestError` message lives on `.info`, not `str(err)`.** `str(err)` returns only the error code (`search_phase_execution_exception`); the human-readable cause is on `err.info`. `_is_text_field_agg_error` in `app/siem/services/dashboards.py` is the canonical example — match against `err.error` for the type and `str(err.info)` for the message.
248 +- **FastAPI route ordering inside a single router: static paths must come before wildcards.** A `@router.get("/{id}")` declared above `@router.get("/library")` will swallow `/library` and try to parse `"library"` as the `id` param, returning a 422 with `Input is not a valid integer`. We've hit this on both the Case Templates Library (`/library` vs `/{template_id}`) and the Detection Catalog (`/catalog/stats`, `/catalog/stories` vs `/catalog/stories/{story_name:path}`). When appending new routes to an existing router, scan for wildcard routes already declared and insert above them.
249 +- **Vue JSX (`<script setup lang="tsx">`) does not use the `:` v-bind prefix.** Inside a `.vue` `<template>` you write `<NTag :bordered="false">`; inside a JSX `render: row => (...)` you write `<NTag bordered={false}>`. Mixing them up — easy when copying from one to the other — produces a Vite parser error (`Unexpected token`). The catalog's `StoriesIndex.vue` / `StoryDetail.vue` are pure-JSX render columns; the surrounding `<template>` is Vue syntax. Don't conflate.
250 +- **SPA sub-views driven by URL query params: `push` to open, `replace` to close.** When a parent component swaps between an index view and a detail view based on a query param (`?story=Foo`), entering the detail must use `router.push` so the browser-back button lands on the index, not on whatever was open before this page. Exiting via an in-app button should use `router.replace` to avoid piling up duplicate index entries in history. Detection Catalog's `DetectionCatalogShell.vue` `openStory` / `closeStory` is the canonical pair — getting this wrong is invisible until a user complains that browser-back skips the index entirely.
251 +- **Iconify icon collections loaded in the frontend: `carbon:`, `mdi:`, `logos:`.** `simple-icons:` and other unbundled sets render as an empty box with no console error. If you reach for `simple-icons:wazuh` or similar, either switch to a loaded set or fall back to a plain text label.
252 +- **`app/connectors/wazuh_manager/utils/universal.py:send_put_request` form-encodes dicts even with `Content-Type: application/json`.** The helper sets the JSON content-type header but then calls `requests.put(data=<dict>)`, which `requests` URL-form-encodes (`key=value&key=value`). Wazuh sees the JSON header but a form-encoded body, tries to parse it as JSON, and 400s with `Expecting value: line 1 column 1 (char 0)`. **Fix at the call site by pre-serializing**: `send_put_request(endpoint="/logtest", data=json.dumps(payload))` — `requests` sends string bodies as-is, no encoding. The wazuh-manager `logtest.py` connector has the canonical example. Don't try to "fix" it by passing a dict and assuming JSON encoding; the helper is used by the rule-file-upload flow which expects raw bytes/XML, and changing it globally would break that path. (Cleaner long-term fix would be a `json_data=True` flag on the helper, but it touches every Wazuh integration that PUTs.)
253 +- **Cross-branch local-DB alembic divergence is invisible until startup.** If you run a migration from a feature branch against your local DB and then switch to a different branch that lacks the migration file, `apply_migrations()` at startup crashes with `Can't locate revision identified by <hash>` — the DB's `alembic_version` table records the missing revision, alembic has no file to walk. **Safe recovery**: `git checkout <other-branch-commit> -- backend/alembic/versions/<missing-revision>_*.py` (plus any predecessors in the chain). That brings the migration *file* into the working tree without changing the DB — the schema the file describes is already applied, alembic just needs the file to recognize the state. Don't `UPDATE alembic_version` manually unless you're prepared to fight it later; don't `alembic downgrade` unless you genuinely want to drop the columns.
254 +- **Don't assume Wazuh alerts live only in `wazuh-alerts-*`.** Real SOCFortress deployments spread alerts across vendor- and customer-prefixed indices: `office365-<customer_code>`, `crowdstrike-<customer_code>`, `carbonblack-<customer_code>`, `huntress_<customer_code>`, ad-hoc `newest-*` test indices, and so on — easily 1500+ on lab clusters. A query targeting only `wazuh-alerts-*` will miss the bulk of real traffic. Equally, `wazuh-*` is *too broad* and includes Wazuh's own internal indices (`wazuh-monitoring-*`, `wazuh-statistics-*`, `wazuh-states-*`, `wazuh-vulnerabilities-*`) — those carry `rule.id` for system/control events with Wazuh's parent-template rule IDs (2 = firewall template, 3 = ids template, 4 = web-log template) that never fire on real analyst alerts but produce massive fake hit counts. **Use ES's native wildcard+exclusion pattern**, not client-side index enumeration: `*,-.*,-_*,-wazuh-monitoring-*,-wazuh-statistics-*,-wazuh-states-*,-wazuh-vulnerabilities-*,-security-auditlog-*` passes as one short string and ES resolves it server-side. `wazuh_firing_stats_cache.py:ALERT_INDEX_PATTERN` is the canonical implementation. Integer coercion on the bucket keys naturally drops vendor-native rule IDs that aren't Wazuh-compatible, so over-including is safe.
255 +- **Don't pass a long index list directly to `client.search(index=[...])`.** The elasticsearch7 client serializes the list as a comma-joined URL path segment. At ~1500 indices that crosses the 4096-byte HTTP line limit and you get `too_long_http_line_exception` from the server. The fix is server-side pattern resolution — pass a short string like `*,-foo-*,-bar-*` to `client.search(index=pattern)` and let ES expand it. Combine with `ignore_unavailable=True, allow_no_indices=True` so per-index permission errors and empty matches don't fail the whole call.
backend/app/connectors/wazuh_manager/services/logtest.py new
+152
@@ -0,0 +1,152 @@
1 +"""
2 +Thin wrapper around Wazuh Manager's ``PUT /logtest`` endpoint.
3 +
4 +What this is for:
5 +The Detections Catalog "Test a log" feature lets analysts paste a raw log
6 +line and asks Wazuh "which rule(s) would have matched this?". Rather than
7 +re-implementing Wazuh's decoder + rule engine in Python (months of work,
8 +guaranteed drift), we use Wazuh's own logtest API — same engine that runs
9 +in production, no semantic gap.
10 +
11 +Stateless mode only:
12 +Wazuh's logtest supports stateful sessions (``token`` field) so multi-line
13 +correlated rules can fire across calls. We don't need that for the
14 +catalog's "test one log line" use case, and stateless calls don't leave
15 +session state hanging on the Manager. If we ever want multi-line tests
16 +we can extend with a ``token`` param + a ``DELETE /logtest/sessions/{token}``
17 +cleanup call.
18 +
19 +No DB writes, no schema changes — pure HTTP wrapper.
20 +"""
21 +
22 +import json
23 +from typing import Any
24 +from typing import Dict
25 +from typing import Optional
26 +
27 +from fastapi import HTTPException
28 +from loguru import logger
29 +
30 +from app.connectors.wazuh_manager.utils.universal import send_put_request
31 +
32 +
33 +async def run_logtest(
34 + event: str,
35 + log_format: str = "syslog",
36 + location: str = "logtest",
37 +) -> Dict[str, Any]:
38 + """
39 + Run a stateless logtest against the Wazuh Manager.
40 +
41 + Args:
42 + event: The raw log line to evaluate (single line, no JSON envelope).
43 + log_format: Wazuh log format. Common values: ``syslog`` (default),
44 + ``json``, ``snort-full``, ``squid``, ``apache``, ``iis``, etc.
45 + ``syslog`` is the most permissive and works for most operator-
46 + captured log lines.
47 + location: A pseudo-source label Wazuh records on the test. We pass
48 + ``"logtest"`` by default — Wazuh uses this to scope ``if_*``
49 + location-based rule conditions, and a generic value avoids
50 + accidentally matching location-specific rules.
51 +
52 + Returns:
53 + A dict with keys:
54 + - ``matched`` (bool): whether any rule matched
55 + - ``rule`` (dict|None): the matched rule's summary (id, level,
56 + description, groups, mitre, …) when matched, else None
57 + - ``alert`` (dict|None): the full Wazuh alert envelope (decoder,
58 + predecoder, data fields, full_log, …) — kept for the UI's
59 + "what did Wazuh actually parse?" panel
60 + - ``raw`` (dict): the unmodified Wazuh response payload, kept for
61 + debugging when ``matched`` is False but the analyst expects a hit
62 +
63 + Raises:
64 + HTTPException(400): event is empty / invalid input
65 + HTTPException(503): Wazuh Manager unreachable / refused the request
66 + """
67 + if not event or not event.strip():
68 + raise HTTPException(status_code=400, detail="event must be a non-empty log line")
69 +
70 + payload = {
71 + "event": event,
72 + "log_format": log_format,
73 + "location": location,
74 + }
75 +
76 + logger.debug(f"Running Wazuh logtest with format={log_format} location={location}")
77 +
78 + # WAZUH-PUT GOTCHA: send_put_request uses ``requests.put(data=...)`` which
79 + # form-encodes dicts (key=value&key=value), but the Content-Type header is
80 + # set to application/json. Wazuh's logtest endpoint then tries to parse
81 + # the form-encoded body as JSON and 400s with
82 + # ``Expecting value: line 1 column 1 (char 0)``. Pre-serializing to a JSON
83 + # string sidesteps it — ``requests`` sends strings as the raw body without
84 + # form encoding, so Wazuh sees actual JSON. (Cleaner fix would be a
85 + # ``json_data=True`` flag on send_put_request, but that touches shared
86 + # connector code used by every other Wazuh integration.)
87 + response = await send_put_request(endpoint="/logtest", data=json.dumps(payload))
88 +
89 + if not response or not response.get("success"):
90 + # send_put_request returns a dict with success=False on transport
91 + # failures; bubble its message up so the UI can show why.
92 + raise HTTPException(
93 + status_code=503,
94 + detail=response.get("message", "Wazuh Manager logtest failed") if response else "Wazuh Manager not reachable",
95 + )
96 +
97 + # Wazuh logtest wraps everything two layers deep:
98 + # response["data"]["data"]["output"] holds the actual logtest result.
99 + # Be defensive — version drift has changed this shape before.
100 + raw_payload = response.get("data") or {}
101 + inner = raw_payload.get("data") or {}
102 + output = inner.get("output") or {}
103 +
104 + rule_summary = _extract_rule_summary(output)
105 + matched = rule_summary is not None
106 +
107 + return {
108 + "matched": matched,
109 + "rule": rule_summary,
110 + "alert": output if output else None,
111 + "raw": raw_payload,
112 + }
113 +
114 +
115 +def _extract_rule_summary(output: Dict[str, Any]) -> Optional[Dict[str, Any]]:
116 + """
117 + Pull the matched-rule summary out of a Wazuh logtest output, normalizing
118 + field names so the frontend can render a stable shape.
119 +
120 + Wazuh's logtest output puts the matched rule under ``output.rule`` —
121 + same structure as a regular alert envelope. If no rule matched the
122 + ``rule`` key is missing or the rule's id is 0, both of which mean
123 + "no match" for our purposes.
124 + """
125 + rule = output.get("rule")
126 + if not isinstance(rule, dict):
127 + return None
128 +
129 + rid = rule.get("id")
130 + # Wazuh sometimes returns rule.id as a string; normalize to int when possible.
131 + try:
132 + rid_int = int(rid) if rid is not None else None
133 + except (TypeError, ValueError):
134 + rid_int = None
135 +
136 + # rule.id == 0 / None == no real match (Wazuh's "rule" entry can be a
137 + # synthetic envelope even when no analyst-facing rule fired).
138 + if not rid_int:
139 + return None
140 +
141 + return {
142 + "id": rid_int,
143 + "level": rule.get("level"),
144 + "description": rule.get("description") or "",
145 + "groups": rule.get("groups") or [],
146 + "mitre": (rule.get("mitre") or {}).get("id") or [],
147 + "pci_dss": rule.get("pci_dss") or [],
148 + "gdpr": rule.get("gdpr") or [],
149 + "hipaa": rule.get("hipaa") or [],
150 + "nist_800_53": rule.get("nist_800_53") or [],
151 + "firedtimes": rule.get("firedtimes"),
152 + }
backend/app/integrations/copilot_searches/routes/copilot_searches.py
+338
@@ -18,6 +18,36 @@ from app.integrations.copilot_searches.schema.copilot_searches import (
18 from app.integrations.copilot_searches.schema.copilot_searches import (
19 BulkProvisionRuleResult,
20 )
21 +from app.integrations.copilot_searches.schema.copilot_searches import (
22 + CatalogComplianceFrameworksResponse,
23 +)
24 +from app.integrations.copilot_searches.schema.copilot_searches import (
25 + CatalogComplianceResponse,
26 +)
27 +from app.integrations.copilot_searches.schema.copilot_searches import (
28 + CatalogCoverageGapsResponse,
29 +)
30 +from app.integrations.copilot_searches.schema.copilot_searches import (
31 + CatalogLogTestRequest,
32 +)
33 +from app.integrations.copilot_searches.schema.copilot_searches import (
34 + CatalogLogTestResponse,
35 +)
36 +from app.integrations.copilot_searches.schema.copilot_searches import (
37 + CatalogStatsResponse,
38 +)
39 +from app.integrations.copilot_searches.schema.copilot_searches import (
40 + CatalogStoryDetailResponse,
41 +)
42 +from app.integrations.copilot_searches.schema.copilot_searches import (
43 + CatalogStoryListResponse,
44 +)
45 +from app.integrations.copilot_searches.schema.copilot_searches import (
46 + CatalogWazuhRuleDetailResponse,
47 +)
48 +from app.integrations.copilot_searches.schema.copilot_searches import (
49 + CatalogWazuhRulesResponse,
50 +)
51 from app.integrations.copilot_searches.schema.copilot_searches import (
52 ExecuteGraylogQueryRequest,
53 )
@@ -72,6 +102,29 @@ from app.integrations.copilot_searches.services.copilot_searches import (
102 refresh_rules_cache,
103 )
104 from app.integrations.copilot_searches.services.copilot_searches import rules_cache
105 +from app.integrations.copilot_searches.services.detection_catalog import (
106 + get_catalog_stats,
107 +)
108 +from app.integrations.copilot_searches.services.detection_catalog import (
109 + get_story_detail,
110 +)
111 +from app.integrations.copilot_searches.services.detection_catalog import (
112 + get_wazuh_rule_detail,
113 +)
114 +from app.integrations.copilot_searches.services.detection_catalog import (
115 + list_compliance_frameworks,
116 +)
117 +from app.integrations.copilot_searches.services.detection_catalog import (
118 + list_compliance_pivot,
119 +)
120 +from app.integrations.copilot_searches.services.detection_catalog import (
121 + list_coverage_gaps,
122 +)
123 +from app.integrations.copilot_searches.services.detection_catalog import list_stories
124 +from app.integrations.copilot_searches.services.detection_catalog import (
125 + list_wazuh_rules,
126 +)
127 +from app.integrations.copilot_searches.services.detection_catalog import run_log_test
128 from app.integrations.copilot_searches.services.mitre_coverage import get_coverage
129 from app.integrations.copilot_searches.services.mitre_coverage import mitre_matrix
130
@@ -840,3 +893,288 @@ async def provision_graylog_alert(request: ProvisionGraylogAlertRequest):
893 status_code=500,
894 detail=f"Failed to provision Graylog alert: {str(e)}",
895 )
896 +
897 +
898 +# =============================================================================
899 +# Detection Catalog
900 +#
901 +# Read-only discovery surface over the rules already loaded by CoPilot Searches.
902 +# Same underlying cache, same refresh story (POST /refresh above). All three
903 +# endpoints walk the in-memory ``rules_cache`` and aggregate fresh per call;
904 +# they do not maintain a second cache layer.
905 +# =============================================================================
906 +
907 +
908 +@copilot_searches_router.get(
909 + "/catalog/stats",
910 + response_model=CatalogStatsResponse,
911 + description="Catalog overview counts (detections, stories, products, data sources, tactics).",
912 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
913 +)
914 +async def get_catalog_stats_endpoint() -> CatalogStatsResponse:
915 + try:
916 + stats = await get_catalog_stats()
917 + return CatalogStatsResponse(**stats)
918 + except Exception as e:
919 + raise HTTPException(status_code=503, detail=f"Failed to build catalog stats: {str(e)}")
920 +
921 +
922 +@copilot_searches_router.get(
923 + "/catalog/stories",
924 + response_model=CatalogStoryListResponse,
925 + description=(
926 + "List every analytic story discovered across the loaded detections, with "
927 + "per-story summary fields (data sources, tactics, products, latest date, "
928 + "detection count). Mirrors Splunk's Analytic Stories index table."
929 + ),
930 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
931 +)
932 +async def list_catalog_stories_endpoint() -> CatalogStoryListResponse:
933 + try:
934 + stories = await list_stories()
935 + return CatalogStoryListResponse(
936 + success=True,
937 + message=f"Found {len(stories)} story(ies)",
938 + stories=stories,
939 + )
940 + except Exception as e:
941 + raise HTTPException(status_code=503, detail=f"Failed to list catalog stories: {str(e)}")
942 +
943 +
944 +@copilot_searches_router.get(
945 + "/catalog/stories/{story_name:path}",
946 + response_model=CatalogStoryDetailResponse,
947 + description=(
948 + "Detail view for a single analytic story: aggregated description, "
949 + "the detections it contains, deduplicated data sources, references, "
950 + "and metadata. ``story_name`` is the raw tag value (case-sensitive); "
951 + "the route accepts arbitrary characters including spaces."
952 + ),
953 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
954 +)
955 +async def get_catalog_story_detail_endpoint(story_name: str) -> CatalogStoryDetailResponse:
956 + try:
957 + detail = await get_story_detail(story_name)
958 + if detail is None:
959 + raise HTTPException(
960 + status_code=404,
961 + detail=f"No detections found for analytic story '{story_name}'",
962 + )
963 + return CatalogStoryDetailResponse(**detail)
964 + except HTTPException:
965 + raise
966 + except Exception as e:
967 + raise HTTPException(status_code=503, detail=f"Failed to load story detail: {str(e)}")
968 +
969 +
970 +# ---------------------------------------------------------------------------
971 +# Wazuh Rules tab — list + per-rule detail
972 +#
973 +# Route ordering note: the static ``/catalog/wazuh-rules`` MUST be declared
974 +# before ``/catalog/wazuh-rules/{rule_id}`` so FastAPI doesn't route the bare
975 +# list path into the wildcard handler and try to parse the empty path as an
976 +# int. (Same footgun documented in CLAUDE.md "Things that bite".)
977 +#
978 +# Auth note: we deliberately call the underlying ``list_wazuh_rules`` /
979 +# ``get_wazuh_rule_detail`` service functions instead of proxying the
980 +# wazuh_manager router (which is admin-only). Catalog viewers should be able
981 +# to *see* rule metadata without holding admin scope; the management surface
982 +# (enable / disable / upload) stays gated where it already is.
983 +# ---------------------------------------------------------------------------
984 +
985 +
986 +@copilot_searches_router.get(
987 + "/catalog/wazuh-rules",
988 + response_model=CatalogWazuhRulesResponse,
989 + description=(
990 + "List the full Wazuh Manager ruleset projected to the catalog's "
991 + "index-table shape. Returns every rule in one shot — pagination "
992 + "and filtering happen client-side in the same pattern as the "
993 + "Analytic Stories tab. When the Wazuh Manager is unreachable the "
994 + "response carries ``available=false`` + ``unavailable_reason`` so "
995 + "the UI can render an inline empty state instead of erroring."
996 + ),
997 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
998 +)
999 +async def list_catalog_wazuh_rules_endpoint(
1000 + customer_code: Optional[str] = Query(
1001 + None,
1002 + description=(
1003 + "Optional customer code (e.g. ``00002``, ``lab``). When set, the "
1004 + "Hits 30d / Hits 7d / Last fired columns are scoped to this "
1005 + "customer's alerts only. When unset, the global firing-stats "
1006 + "cache is used."
1007 + ),
1008 + ),
1009 +) -> CatalogWazuhRulesResponse:
1010 + try:
1011 + payload = await list_wazuh_rules(customer_code=customer_code)
1012 + return CatalogWazuhRulesResponse(
1013 + success=True,
1014 + message=(f"Listed {payload['total']} Wazuh rule(s)" if payload["available"] else "Wazuh Manager not available"),
1015 + **payload,
1016 + )
1017 + except Exception as e:
1018 + raise HTTPException(status_code=503, detail=f"Failed to list Wazuh rules: {str(e)}")
1019 +
1020 +
1021 +@copilot_searches_router.get(
1022 + "/catalog/wazuh-rules/{rule_id}",
1023 + response_model=CatalogWazuhRuleDetailResponse,
1024 + description=(
1025 + "Full meta payload for a single Wazuh rule: header (id/level/status), "
1026 + "description, file location, groups, MITRE techniques + resolved "
1027 + "tactics, compliance frameworks, and the raw if-then logic dict "
1028 + "(if_sid / match / regex / decoded_as / etc.). Served entirely from "
1029 + "the in-memory cache — no second call to the Wazuh Manager."
1030 + ),
1031 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1032 +)
1033 +async def get_catalog_wazuh_rule_endpoint(rule_id: int) -> CatalogWazuhRuleDetailResponse:
1034 + try:
1035 + detail = await get_wazuh_rule_detail(rule_id)
1036 + if detail is None:
1037 + raise HTTPException(
1038 + status_code=404,
1039 + detail=f"No Wazuh rule found with id {rule_id}",
1040 + )
1041 + return CatalogWazuhRuleDetailResponse(**detail)
1042 + except HTTPException:
1043 + raise
1044 + except Exception as e:
1045 + raise HTTPException(status_code=503, detail=f"Failed to load Wazuh rule detail: {str(e)}")
1046 +
1047 +
1048 +# ---------------------------------------------------------------------------
1049 +# Coverage Gaps tab — uncovered MITRE techniques across both corpora
1050 +# ---------------------------------------------------------------------------
1051 +
1052 +
1053 +@copilot_searches_router.get(
1054 + "/catalog/coverage-gaps",
1055 + response_model=CatalogCoverageGapsResponse,
1056 + description=(
1057 + "MITRE ATT&CK techniques not covered by any rule in either the "
1058 + "CoPilot Searches corpus or the Wazuh ruleset. Sub-techniques are "
1059 + "collapsed into their parents (a hit on T1059.001 counts as coverage "
1060 + "for T1059). Use this surface to spot detection gaps that warrant "
1061 + "new rule authoring."
1062 + ),
1063 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1064 +)
1065 +async def list_catalog_coverage_gaps_endpoint() -> CatalogCoverageGapsResponse:
1066 + try:
1067 + payload = await list_coverage_gaps()
1068 + return CatalogCoverageGapsResponse(
1069 + success=True,
1070 + message=f"{payload['gap_count']} gap(s) across {payload['total_techniques']} technique(s)",
1071 + **payload,
1072 + )
1073 + except Exception as e:
1074 + raise HTTPException(status_code=503, detail=f"Failed to compute coverage gaps: {str(e)}")
1075 +
1076 +
1077 +# ---------------------------------------------------------------------------
1078 +# Compliance pivot — Wazuh rules grouped by framework control ID
1079 +#
1080 +# Route ordering: static ``/catalog/compliance/frameworks`` MUST come before
1081 +# the parameterized ``/catalog/compliance/{framework}`` so FastAPI doesn't
1082 +# route the bare list path into the wildcard handler. (Same footgun as the
1083 +# wazuh-rules routes — see CLAUDE.md "Things that bite".)
1084 +# ---------------------------------------------------------------------------
1085 +
1086 +
1087 +@copilot_searches_router.get(
1088 + "/catalog/compliance/frameworks",
1089 + response_model=CatalogComplianceFrameworksResponse,
1090 + description=(
1091 + "List the compliance frameworks the catalog can pivot Wazuh rules "
1092 + "by (PCI DSS, HIPAA, NIST 800-53, GDPR, TSC, GPG13). Drives the "
1093 + "framework selector dropdown on the Compliance tab."
1094 + ),
1095 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1096 +)
1097 +async def list_catalog_compliance_frameworks_endpoint() -> CatalogComplianceFrameworksResponse:
1098 + return CatalogComplianceFrameworksResponse(
1099 + success=True,
1100 + message="Frameworks listed successfully",
1101 + frameworks=list_compliance_frameworks(),
1102 + )
1103 +
1104 +
1105 +@copilot_searches_router.get(
1106 + "/catalog/compliance/{framework}",
1107 + response_model=CatalogComplianceResponse,
1108 + description=(
1109 + "Group every Wazuh rule by its control IDs for the given framework "
1110 + "(e.g. ``pci_dss``, ``hipaa``, ``nist_800_53``). Each group reports "
1111 + "rule count + total firing hits — the answer to ``which rules cover "
1112 + "PCI DSS 10.2.4 and how active are they?`` in one round-trip. Rules "
1113 + "without any control values for the framework are excluded."
1114 + ),
1115 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1116 +)
1117 +async def get_catalog_compliance_endpoint(framework: str) -> CatalogComplianceResponse:
1118 + try:
1119 + payload = await list_compliance_pivot(framework)
1120 + return CatalogComplianceResponse(
1121 + success=True,
1122 + message=(
1123 + f"{payload['control_count']} control(s) across {payload['rules_with_compliance']} "
1124 + f"rule(s) tagged for {payload['framework_label']}"
1125 + ),
1126 + **payload,
1127 + )
1128 + except ValueError as ve:
1129 + # Unknown framework key — surface as 400, not 503.
1130 + raise HTTPException(status_code=400, detail=str(ve))
1131 + except Exception as e:
1132 + raise HTTPException(status_code=503, detail=f"Failed to compute compliance pivot: {str(e)}")
1133 +
1134 +
1135 +# ---------------------------------------------------------------------------
1136 +# Logtest — "which rule would match this log line?"
1137 +#
1138 +# POST'ed by the catalog UI when an analyst pastes a sample log line. The
1139 +# heavy lifting is done by Wazuh's own logtest API (PUT /logtest) — we
1140 +# wrap it so the catalog gets a stable, enriched response shape.
1141 +# ---------------------------------------------------------------------------
1142 +
1143 +
1144 +@copilot_searches_router.post(
1145 + "/catalog/wazuh-rules/test",
1146 + response_model=CatalogLogTestResponse,
1147 + description=(
1148 + "Submit a raw log line to Wazuh's logtest engine and return the "
1149 + "matched rule (if any) plus the full alert envelope (decoder, "
1150 + "predecoder, data, full_log). Stateless — no Wazuh session is "
1151 + "created or persisted. Wraps Wazuh's PUT /logtest with mitre_matrix "
1152 + "tactic-name enrichment so the result panel matches the catalog "
1153 + "elsewhere."
1154 + ),
1155 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
1156 +)
1157 +async def run_catalog_logtest_endpoint(request: CatalogLogTestRequest) -> CatalogLogTestResponse:
1158 + try:
1159 + result = await run_log_test(
1160 + event=request.event,
1161 + log_format=request.log_format,
1162 + location=request.location,
1163 + )
1164 + # The service may return unavailable_reason — surface it on the
1165 + # envelope; success=True still because the call shape was valid.
1166 + return CatalogLogTestResponse(
1167 + success=True,
1168 + message=(
1169 + f"Matched rule {result['rule']['id']}"
1170 + if result.get("matched") and result.get("rule")
1171 + else "No rule matched"
1172 + if result.get("unavailable_reason") is None
1173 + else "Logtest unavailable"
1174 + ),
1175 + **result,
1176 + )
1177 + except HTTPException:
1178 + raise
1179 + except Exception as e:
1180 + raise HTTPException(status_code=503, detail=f"Failed to run logtest: {str(e)}")
backend/app/integrations/copilot_searches/schema/copilot_searches.py
+300
@@ -426,3 +426,303 @@ class RulesByIdsResponse(BaseModel):
426 message: str = "Rules fetched successfully"
427 rules: list[RuleSummary]
428 missing: list[str] = Field(default_factory=list, description="IDs that were requested but not found in cache")
429 +
430 +
431 +# =============================================================================
432 +# Detection Catalog — discovery surface over the rules cache.
433 +# See backend/app/integrations/copilot_searches/services/detection_catalog.py.
434 +# =============================================================================
435 +
436 +
437 +class CatalogStoryRow(BaseModel):
438 + """One row in the Stories index table."""
439 +
440 + name: str
441 + data_sources: list[str] = Field(default_factory=list)
442 + tactics: list[str] = Field(default_factory=list, description="MITRE tactic display names derived from member rules")
443 + products: list[str] = Field(default_factory=list)
444 + date: Optional[str] = Field(None, description="Most recent date string across member detections")
445 + detection_count: int = 0
446 +
447 +
448 +class CatalogStoryListResponse(BaseModel):
449 + success: bool = True
450 + message: str = "Stories listed successfully"
451 + stories: list[CatalogStoryRow] = Field(default_factory=list)
452 +
453 +
454 +class CatalogStoryDetection(BaseModel):
455 + """One detection (rule) appearing inside a story's Detections table."""
456 +
457 + id: str
458 + name: str
459 + type: str
460 + severity: Optional[str] = None
461 + mitre_attack_id: list[str] = Field(default_factory=list)
462 + tactics: list[str] = Field(default_factory=list)
463 + description: Optional[str] = None
464 +
465 +
466 +class CatalogStoryDetailResponse(BaseModel):
467 + success: bool = True
468 + message: str = "Story detail retrieved successfully"
469 + name: str
470 + id: str = Field(..., description="Stable slug for the story (URL-safe)")
471 + description: str
472 + why_it_matters: str = Field(..., description="Auto-generated until/unless curated narrative is added upstream")
473 + detections: list[CatalogStoryDetection] = Field(default_factory=list)
474 + data_sources: list[str] = Field(default_factory=list)
475 + tactics: list[str] = Field(default_factory=list)
476 + products: list[str] = Field(default_factory=list)
477 + authors: list[str] = Field(default_factory=list)
478 + references: list[str] = Field(default_factory=list)
479 + date: Optional[str] = None
480 + version: Optional[int] = None
481 + detection_count: int = 0
482 +
483 +
484 +class CatalogStatsResponse(BaseModel):
485 + success: bool = True
486 + message: str = "Catalog stats retrieved successfully"
487 + detection_count: int = 0
488 + story_count: int = 0
489 + product_count: int = 0
490 + data_source_count: int = 0
491 + tactic_count: int = 0
492 + last_refresh: Optional[datetime] = None
493 + # Wazuh-side counts — mirror of the wazuh_rules_cache state. Optional /
494 + # defaulted so deployments that have never loaded the cache (or hit an
495 + # outage on first load) still get a valid response shape.
496 + wazuh_rule_count: int = 0
497 + wazuh_last_refresh: Optional[datetime] = None
498 + wazuh_available: bool = True
499 + wazuh_unavailable_reason: Optional[str] = None
500 +
501 +
502 +# ---------------------------------------------------------------------------
503 +# Wazuh Rules tab — index row + list envelope + detail
504 +# ---------------------------------------------------------------------------
505 +
506 +
507 +class CatalogWazuhRuleRow(BaseModel):
508 + """
509 + One row in the Wazuh Rules index table. Mirror of the projection in
510 + ``detection_catalog._wazuh_row``; keeps the OpenAPI doc honest.
511 + """
512 +
513 + id: Optional[int] = None
514 + level: Optional[int] = None
515 + status: Optional[str] = None
516 + description: str = ""
517 + filename: str = ""
518 + relative_dirname: str = ""
519 + groups: list[str] = Field(default_factory=list)
520 + mitre: list[str] = Field(default_factory=list, description="MITRE ATT&CK technique IDs declared on the rule")
521 + # Firing counts from the indexer. Always 0 when the firing-stats cache
522 + # is unavailable — readers should check the envelope's
523 + # ``firing_stats_available`` flag before treating 0 as "no hits".
524 + hits_7d: int = 0
525 + hits_30d: int = 0
526 + # ISO timestamp of the most recent hit in the 30d window, or None when
527 + # the rule hasn't fired / stats are unavailable. The UI renders this as
528 + # relative time ("2 minutes ago").
529 + last_seen: Optional[str] = None
530 +
531 +
532 +class CatalogWazuhRulesResponse(BaseModel):
533 + """
534 + Envelope for the full Wazuh rules list. ``available=False`` signals the
535 + Wazuh Manager is unreachable / unconfigured — the UI should render an
536 + inline empty state with ``unavailable_reason`` instead of erroring.
537 +
538 + Firing-stats availability is reported separately because the Manager and
539 + the Indexer are different services that can fail independently — you
540 + could have rules loaded but no hit counts, or vice versa.
541 + """
542 +
543 + success: bool = True
544 + message: str = "Wazuh rules listed successfully"
545 + rules: list[CatalogWazuhRuleRow] = Field(default_factory=list)
546 + total: int = 0
547 + available: bool = True
548 + unavailable_reason: Optional[str] = None
549 + last_refresh: Optional[datetime] = None
550 + # Indexer-side availability for the firing-stats column.
551 + firing_stats_available: bool = True
552 + firing_stats_unavailable_reason: Optional[str] = None
553 + firing_stats_last_refresh: Optional[datetime] = None
554 + # Echoes the customer scope: empty string for the global view, customer
555 + # code when scoped. Lets the UI display "Showing hits for customer X"
556 + # and confirm the right slice was returned.
557 + customer_code: str = ""
558 +
559 +
560 +class CatalogWazuhRuleCompliance(BaseModel):
561 + """
562 + Compliance-framework arrays grouped under one nested object so the UI can
563 + iterate frameworks without hard-coding the list.
564 + """
565 +
566 + pci_dss: list[str] = Field(default_factory=list)
567 + gdpr: list[str] = Field(default_factory=list)
568 + hipaa: list[str] = Field(default_factory=list)
569 + nist_800_53: list[str] = Field(default_factory=list)
570 + tsc: list[str] = Field(default_factory=list)
571 + gpg13: list[str] = Field(default_factory=list)
572 +
573 +
574 +class CatalogWazuhRuleDetailResponse(BaseModel):
575 + """Full meta payload for a single Wazuh rule — drives the detail modal."""
576 +
577 + success: bool = True
578 + message: str = "Wazuh rule detail retrieved successfully"
579 + id: Optional[int] = None
580 + level: Optional[int] = None
581 + status: Optional[str] = None
582 + description: str = ""
583 + filename: str = ""
584 + relative_dirname: str = ""
585 + groups: list[str] = Field(default_factory=list)
586 + mitre: list[str] = Field(default_factory=list)
587 + tactics: list[str] = Field(default_factory=list, description="MITRE tactic display names resolved via mitre_matrix")
588 + compliance: CatalogWazuhRuleCompliance = Field(default_factory=CatalogWazuhRuleCompliance)
589 + # ``details`` is intentionally typed as a free-form dict: the keys Wazuh
590 + # emits (if_sid, match, regex, decoded_as, info, group, …) vary per rule
591 + # and we don't want to silently drop unknown ones. The frontend iterates
592 + # whatever keys arrive.
593 + details: dict = Field(default_factory=dict)
594 + # Reconstructed ``<rule>...</rule>`` XML block, synthesized server-side
595 + # from the cached fields above (no second Wazuh API call). Rendered by
596 + # the modal as a code snippet so analysts can read the rule "as written".
597 + source_xml: str = ""
598 + # Firing counts pulled from the Wazuh indexer aggregation cache.
599 + hits_7d: int = 0
600 + hits_30d: int = 0
601 + last_seen: Optional[str] = None
602 + firing_stats_available: bool = True
603 + firing_stats_unavailable_reason: Optional[str] = None
604 +
605 +
606 +# ---------------------------------------------------------------------------
607 +# Coverage Gaps — MITRE techniques not covered by either rule corpus
608 +# ---------------------------------------------------------------------------
609 +
610 +
611 +class CatalogCoverageGapRow(BaseModel):
612 + """One uncovered MITRE technique."""
613 +
614 + technique_id: str
615 + technique_name: str
616 + tactics: list[str] = Field(default_factory=list)
617 + url: Optional[str] = None
618 +
619 +
620 +class CatalogCoverageGapsResponse(BaseModel):
621 + """Envelope for the Coverage Gaps tab — gaps + coverage summary numbers."""
622 +
623 + success: bool = True
624 + message: str = "Coverage gaps computed successfully"
625 + gaps: list[CatalogCoverageGapRow] = Field(default_factory=list)
626 + gap_count: int = 0
627 + covered_count: int = 0
628 + total_techniques: int = 0
629 + coverage_pct: float = 0.0
630 +
631 +
632 +# ---------------------------------------------------------------------------
633 +# Compliance pivot — Wazuh rules grouped by framework control ID
634 +# ---------------------------------------------------------------------------
635 +
636 +
637 +class CatalogComplianceFramework(BaseModel):
638 + """One row in the framework selector dropdown."""
639 +
640 + key: str # API/URL value: pci_dss, hipaa, etc.
641 + label: str # Human-facing: "PCI DSS", "HIPAA", etc.
642 +
643 +
644 +class CatalogComplianceFrameworksResponse(BaseModel):
645 + success: bool = True
646 + message: str = "Frameworks listed successfully"
647 + frameworks: list[CatalogComplianceFramework] = Field(default_factory=list)
648 +
649 +
650 +class CatalogComplianceGroupRow(BaseModel):
651 + """One control bucket: e.g. PCI DSS 10.2.4 → 23 rules, 487 hits in 30d."""
652 +
653 + control: str # The control identifier itself, e.g. "10.2.4"
654 + rule_count: int = 0
655 + rule_ids: list[int] = Field(default_factory=list)
656 + total_hits_30d: int = 0
657 + total_hits_7d: int = 0
658 +
659 +
660 +class CatalogComplianceResponse(BaseModel):
661 + """Compliance pivot for a single framework."""
662 +
663 + success: bool = True
664 + message: str = "Compliance pivot computed successfully"
665 + framework: str # echoes the request
666 + framework_label: str
667 + groups: list[CatalogComplianceGroupRow] = Field(default_factory=list)
668 + control_count: int = 0
669 + rules_with_compliance: int = 0 # rules carrying ≥1 control value for this framework
670 + total_rules: int = 0 # total Wazuh rules in the cache (for "%" math)
671 + firing_stats_available: bool = True
672 +
673 +
674 +# ---------------------------------------------------------------------------
675 +# Logtest — "which rule would match this log line?"
676 +# ---------------------------------------------------------------------------
677 +
678 +
679 +class CatalogLogTestRequest(BaseModel):
680 + """Inputs for ``POST /catalog/wazuh-rules/test``."""
681 +
682 + event: str = Field(..., description="Raw log line to evaluate (single line)")
683 + log_format: str = Field(
684 + default="syslog",
685 + description="Wazuh log_format. Common: syslog, json, snort-full, squid, apache, iis",
686 + )
687 + location: str = Field(
688 + default="logtest",
689 + description="Pseudo-source label Wazuh records on the test. Keep generic to avoid location-conditional rule matches.",
690 + )
691 +
692 +
693 +class CatalogLogTestRuleSummary(BaseModel):
694 + """The matched rule's summary, normalized from Wazuh's logtest output."""
695 +
696 + id: Optional[int] = None
697 + level: Optional[int] = None
698 + description: str = ""
699 + groups: list[str] = Field(default_factory=list)
700 + mitre: list[str] = Field(default_factory=list)
701 + pci_dss: list[str] = Field(default_factory=list)
702 + gdpr: list[str] = Field(default_factory=list)
703 + hipaa: list[str] = Field(default_factory=list)
704 + nist_800_53: list[str] = Field(default_factory=list)
705 + firedtimes: Optional[int] = None
706 +
707 +
708 +class CatalogLogTestResponse(BaseModel):
709 + """
710 + Result envelope for a logtest run.
711 +
712 + ``matched=False`` is a valid, successful outcome — it means Wazuh's
713 + decoder/rule chain saw the event but no analyst-facing rule fired.
714 + ``unavailable_reason`` is only populated when the logtest call itself
715 + failed (Wazuh unreachable, invalid input, etc.).
716 + """
717 +
718 + success: bool = True
719 + message: str = "Logtest executed"
720 + matched: bool = False
721 + rule: Optional[CatalogLogTestRuleSummary] = None
722 + # Resolved tactic display names from the mitre_matrix — saves the UI
723 + # from doing a second resolution pass on the frontend.
724 + tactics: list[str] = Field(default_factory=list)
725 + # Full Wazuh alert envelope (decoder, predecoder, data, full_log, …).
726 + # Free-form dict because Wazuh's shape varies per decoder type.
727 + alert: Optional[dict] = None
728 + unavailable_reason: Optional[str] = None
backend/app/integrations/copilot_searches/services/detection_catalog.py new
+985
@@ -0,0 +1,985 @@
1 +"""
2 +Service layer for the Detection Catalog — a discovery surface over the same
3 +rules already loaded by the CoPilot Searches feature.
4 +
5 +Architecture notes:
6 +- This is a **pure view layer**. It does NOT fetch anything from GitHub and
7 + does NOT maintain its own cache. It walks the in-memory ``rules_cache``
8 + populated by ``app.integrations.copilot_searches.services.copilot_searches``
9 + and aggregates differently per dimension (analytic_story, product,
10 + data_source, etc.).
11 +- MITRE tactic names are resolved via the in-memory ``mitre_matrix`` populated
12 + by ``app.integrations.copilot_searches.services.mitre_coverage``. Same
13 + reasoning: no second fetch, single source of truth.
14 +- Both backing caches load lazily on first access (``ensure_loaded``), and
15 + refresh of the CoPilot Searches cache is the single user-facing refresh
16 + path — there is no separate "refresh the catalog" action.
17 +- All aggregation happens fresh on each request. The corpus is small enough
18 + (~500 rules) that walking it is sub-millisecond; a separate cache layer
19 + here would just create another invalidation surface.
20 +"""
21 +
22 +from collections import defaultdict
23 +from typing import Any
24 +from typing import Dict
25 +from typing import List
26 +from typing import Optional
27 +
28 +from loguru import logger
29 +
30 +from app.integrations.copilot_searches.services.copilot_searches import rules_cache
31 +from app.integrations.copilot_searches.services.mitre_coverage import mitre_matrix
32 +from app.integrations.copilot_searches.services.wazuh_firing_stats_cache import (
33 + fetch_firing_stats_for_customer,
34 +)
35 +from app.integrations.copilot_searches.services.wazuh_firing_stats_cache import (
36 + wazuh_firing_stats_cache,
37 +)
38 +from app.integrations.copilot_searches.services.wazuh_rules_cache import (
39 + wazuh_rules_cache,
40 +)
41 +
42 +# ---------------------------------------------------------------------------
43 +# Helpers
44 +# ---------------------------------------------------------------------------
45 +
46 +
47 +def _ensure_list_of_str(value: Any) -> List[str]:
48 + """Return ``value`` as a list of strings, tolerating None / scalars / mixed lists."""
49 + if value is None:
50 + return []
51 + if isinstance(value, str):
52 + return [value]
53 + if isinstance(value, list):
54 + return [v for v in value if isinstance(v, str)]
55 + return []
56 +
57 +
58 +def _resolve_tactics_for_mitre_ids(mitre_ids: List[str]) -> List[str]:
59 + """
60 + Map a list of MITRE technique IDs (e.g. ``["T1059.007", "T1218"]``) to the
61 + unique set of tactic display names (e.g. ``["Execution", "Defense Evasion"]``).
62 +
63 + Uses the in-memory MITRE matrix; returns an empty list if the matrix isn't
64 + loaded yet (caller is expected to have invoked ``mitre_matrix.ensure_loaded``).
65 + """
66 + techniques = mitre_matrix.techniques
67 + tactics_by_short = {t["short_name"]: t["name"] for t in mitre_matrix.tactics}
68 +
69 + tactic_names: List[str] = []
70 + seen: set[str] = set()
71 + for raw in mitre_ids:
72 + if not isinstance(raw, str):
73 + continue
74 + # Match against the base technique (e.g. T1059.007 -> T1059); sub-techniques
75 + # carry their parent's tactic mapping in the STIX bundle.
76 + base = raw.split(".")[0].strip().upper()
77 + meta = techniques.get(base)
78 + if meta is None:
79 + continue
80 + for short_name in meta.get("tactic_short_names", []):
81 + display = tactics_by_short.get(short_name)
82 + if display and display not in seen:
83 + seen.add(display)
84 + tactic_names.append(display)
85 + return tactic_names
86 +
87 +
88 +def _rule_mitre_ids(rule: Dict[str, Any]) -> List[str]:
89 + return _ensure_list_of_str(rule.get("tags", {}).get("mitre_attack_id"))
90 +
91 +
92 +def _rule_stories(rule: Dict[str, Any]) -> List[str]:
93 + return _ensure_list_of_str(rule.get("tags", {}).get("analytic_story"))
94 +
95 +
96 +def _rule_products(rule: Dict[str, Any]) -> List[str]:
97 + return _ensure_list_of_str(rule.get("tags", {}).get("product"))
98 +
99 +
100 +def _rule_data_sources(rule: Dict[str, Any]) -> List[str]:
101 + return _ensure_list_of_str(rule.get("data_source"))
102 +
103 +
104 +def _rule_max_date(rule: Dict[str, Any]) -> Optional[str]:
105 + date = rule.get("date")
106 + return date if isinstance(date, str) else None
107 +
108 +
109 +def _newest(a: Optional[str], b: Optional[str]) -> Optional[str]:
110 + """Pick the later of two ISO date strings; None-tolerant."""
111 + if a is None:
112 + return b
113 + if b is None:
114 + return a
115 + return a if a >= b else b
116 +
117 +
118 +# ---------------------------------------------------------------------------
119 +# Stories index — table-of-stories landing
120 +# ---------------------------------------------------------------------------
121 +
122 +
123 +async def list_stories() -> List[Dict[str, Any]]:
124 + """
125 + Aggregate all rules into the set of unique analytic stories, with the
126 + per-story summary fields needed to render the index table:
127 +
128 + name | data_sources | tactics | products | date | detection_count
129 +
130 + Rules carrying multiple ``analytic_story`` tags contribute to every story
131 + they're tagged with (intentional — a single detection genuinely belongs
132 + to several stories in the published taxonomy).
133 +
134 + Rules with no ``analytic_story`` tag are silently excluded from the
135 + Stories surface. (They still appear in the Rules grid; the catalog is
136 + just a story-centric view.)
137 + """
138 + await rules_cache.ensure_loaded()
139 + await mitre_matrix.ensure_loaded()
140 +
141 + agg: Dict[str, Dict[str, Any]] = defaultdict(
142 + lambda: {
143 + "data_sources": set(),
144 + "products": set(),
145 + "tactic_names": [], # ordered, dedup'd
146 + "detection_count": 0,
147 + "date": None,
148 + },
149 + )
150 +
151 + for rule in rules_cache.get_all_rules():
152 + story_names = _rule_stories(rule)
153 + if not story_names:
154 + continue
155 +
156 + rule_tactics = _resolve_tactics_for_mitre_ids(_rule_mitre_ids(rule))
157 + rule_date = _rule_max_date(rule)
158 +
159 + for story_name in story_names:
160 + row = agg[story_name]
161 + row["data_sources"].update(_rule_data_sources(rule))
162 + row["products"].update(_rule_products(rule))
163 + for t in rule_tactics:
164 + if t not in row["tactic_names"]:
165 + row["tactic_names"].append(t)
166 + row["detection_count"] += 1
167 + row["date"] = _newest(row["date"], rule_date)
168 +
169 + return [
170 + {
171 + "name": name,
172 + "data_sources": sorted(row["data_sources"]),
173 + "tactics": row["tactic_names"],
174 + "products": sorted(row["products"]),
175 + "date": row["date"],
176 + "detection_count": row["detection_count"],
177 + }
178 + for name, row in sorted(agg.items(), key=lambda kv: kv[0].lower())
179 + ]
180 +
181 +
182 +# ---------------------------------------------------------------------------
183 +# Single story detail
184 +# ---------------------------------------------------------------------------
185 +
186 +
187 +async def get_story_detail(story_name: str) -> Optional[Dict[str, Any]]:
188 + """
189 + Build the detail payload for one analytic story:
190 +
191 + - header metadata (id slug, author, latest version, latest date)
192 + - aggregated description (auto-generated from member detections)
193 + - detections table (one row per rule in the story)
194 + - data sources (deduplicated)
195 + - references (deduplicated)
196 +
197 + Returns ``None`` if no rule in the cache carries this story tag.
198 + """
199 + await rules_cache.ensure_loaded()
200 + await mitre_matrix.ensure_loaded()
201 +
202 + target = story_name.strip()
203 + members: List[Dict[str, Any]] = [rule for rule in rules_cache.get_all_rules() if target in _rule_stories(rule)]
204 + if not members:
205 + return None
206 +
207 + detections: List[Dict[str, Any]] = []
208 + data_sources: set[str] = set()
209 + references: List[str] = []
210 + references_seen: set[str] = set()
211 + products: set[str] = set()
212 + authors: set[str] = set()
213 + tactic_names: List[str] = []
214 + tactic_seen: set[str] = set()
215 + type_counts: Dict[str, int] = defaultdict(int)
216 + latest_date: Optional[str] = None
217 + latest_version: Optional[int] = None
218 +
219 + for rule in members:
220 + rule_tactics = _resolve_tactics_for_mitre_ids(_rule_mitre_ids(rule))
221 + for t in rule_tactics:
222 + if t not in tactic_seen:
223 + tactic_seen.add(t)
224 + tactic_names.append(t)
225 +
226 + rule_type = rule.get("type") or "Unknown"
227 + type_counts[rule_type] += 1
228 +
229 + detections.append(
230 + {
231 + "id": rule.get("id"),
232 + "name": rule.get("name", rule.get("id", "(unnamed)")),
233 + "type": rule_type,
234 + "severity": rule.get("response", {}).get("severity"),
235 + "mitre_attack_id": _rule_mitre_ids(rule),
236 + "tactics": rule_tactics,
237 + "description": rule.get("description"),
238 + },
239 + )
240 +
241 + data_sources.update(_rule_data_sources(rule))
242 + products.update(_rule_products(rule))
243 + author = rule.get("author")
244 + if isinstance(author, str) and author.strip():
245 + authors.add(author.strip())
246 +
247 + for ref in _ensure_list_of_str(rule.get("references")):
248 + if ref not in references_seen:
249 + references_seen.add(ref)
250 + references.append(ref)
251 +
252 + rule_date = _rule_max_date(rule)
253 + latest_date = _newest(latest_date, rule_date)
254 +
255 + version = rule.get("version")
256 + if isinstance(version, int):
257 + if latest_version is None or version > latest_version:
258 + latest_version = version
259 +
260 + detections.sort(key=lambda d: (d["name"] or "").lower())
261 +
262 + # Auto-generated "Why it matters" narrative — used until/unless a
263 + # curated story_metadata file is added in a sibling repo folder.
264 + type_summary = ", ".join(f"{count} {kind}" for kind, count in sorted(type_counts.items()))
265 + why_it_matters = (
266 + f"This story contains {len(detections)} detection(s) "
267 + f"covering {len(tactic_names)} MITRE ATT&CK tactic(s)"
268 + + (f" ({', '.join(tactic_names)})" if tactic_names else "")
269 + + ". "
270 + + (f"Detection types: {type_summary}. " if type_summary else "")
271 + + "Story metadata is auto-generated from member detections; curated narratives can be added later via a story-metadata file in the rule repo."
272 + )
273 +
274 + description = (
275 + f"{len(detections)} detection(s) tagged with the analytic story "
276 + f"'{target}'. Member detections span {len(tactic_names)} MITRE tactic(s)."
277 + )
278 +
279 + return {
280 + "name": target,
281 + "id": _story_slug(target),
282 + "description": description,
283 + "why_it_matters": why_it_matters,
284 + "detections": detections,
285 + "data_sources": sorted(data_sources),
286 + "tactics": tactic_names,
287 + "products": sorted(products),
288 + "authors": sorted(authors),
289 + "references": references,
290 + "date": latest_date,
291 + "version": latest_version,
292 + "detection_count": len(detections),
293 + }
294 +
295 +
296 +def _story_slug(name: str) -> str:
297 + """A URL/display-safe slug for a story name. Stable per name."""
298 + safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name.strip().lower())
299 + while "__" in safe:
300 + safe = safe.replace("__", "_")
301 + return safe.strip("_") or "story"
302 +
303 +
304 +# ---------------------------------------------------------------------------
305 +# Catalog stats (for an Overview pane / facet counts)
306 +# ---------------------------------------------------------------------------
307 +
308 +
309 +async def get_catalog_stats() -> Dict[str, Any]:
310 + """
311 + Lightweight aggregation for the Catalog overview header — how many
312 + detections, stories, products, data sources we have indexed right now.
313 + Same in-memory walk pattern as ``list_stories``; cheap.
314 +
315 + Includes Wazuh-side counts so the header reflects the union of both
316 + corpora. Wazuh fields are returned as 0 / ``wazuh_available=false`` when
317 + the Wazuh Manager is unreachable rather than failing the whole call —
318 + the Stories tab must keep working even on Wazuh outages.
319 + """
320 + await rules_cache.ensure_loaded()
321 + await mitre_matrix.ensure_loaded()
322 + await wazuh_rules_cache.ensure_loaded()
323 +
324 + stories: set[str] = set()
325 + products: set[str] = set()
326 + data_sources: set[str] = set()
327 + tactic_names: set[str] = set()
328 + detection_count = 0
329 +
330 + for rule in rules_cache.get_all_rules():
331 + detection_count += 1
332 + stories.update(_rule_stories(rule))
333 + products.update(_rule_products(rule))
334 + data_sources.update(_rule_data_sources(rule))
335 + tactic_names.update(_resolve_tactics_for_mitre_ids(_rule_mitre_ids(rule)))
336 +
337 + return {
338 + "detection_count": detection_count,
339 + "story_count": len(stories),
340 + "product_count": len(products),
341 + "data_source_count": len(data_sources),
342 + "tactic_count": len(tactic_names),
343 + "last_refresh": rules_cache.last_refresh,
344 + # Wazuh-side counts — read-only mirror of the wazuh_rules_cache state.
345 + "wazuh_rule_count": wazuh_rules_cache.rules_count,
346 + "wazuh_last_refresh": wazuh_rules_cache.last_refresh,
347 + "wazuh_available": wazuh_rules_cache.is_available,
348 + "wazuh_unavailable_reason": wazuh_rules_cache.unavailable_reason,
349 + }
350 +
351 +
352 +# ---------------------------------------------------------------------------
353 +# Compliance pivot — group Wazuh rules by compliance framework control ID
354 +# ---------------------------------------------------------------------------
355 +
356 +
357 +# Frameworks we expose in the compliance pivot. Maps the API-friendly key
358 +# (also the URL query value) to the human-readable label + the Wazuh
359 +# rule-dict field name. Wazuh stores these as lists of control identifiers
360 +# per rule. New frameworks: add a row here, no other code change needed.
361 +COMPLIANCE_FRAMEWORKS: Dict[str, Dict[str, str]] = {
362 + "pci_dss": {"label": "PCI DSS", "field": "pci_dss"},
363 + "gdpr": {"label": "GDPR", "field": "gdpr"},
364 + "hipaa": {"label": "HIPAA", "field": "hipaa"},
365 + "nist_800_53": {"label": "NIST 800-53", "field": "nist_800_53"},
366 + "tsc": {"label": "TSC", "field": "tsc"},
367 + "gpg13": {"label": "GPG13", "field": "gpg13"},
368 +}
369 +
370 +
371 +def _rule_compliance_values(rule: Dict[str, Any], field: str) -> List[str]:
372 + """Pull and normalize a compliance-array field off a cached Wazuh rule."""
373 + raw = rule.get(field)
374 + if isinstance(raw, list):
375 + return [v for v in raw if isinstance(v, str) and v.strip()]
376 + if isinstance(raw, str):
377 + return [v.strip() for v in raw.split(",") if v.strip()]
378 + return []
379 +
380 +
381 +async def list_compliance_pivot(framework: str) -> Dict[str, Any]:
382 + """
383 + Group every Wazuh rule by its values for the given compliance framework.
384 +
385 + Output shape:
386 + {
387 + "framework": "pci_dss",
388 + "framework_label": "PCI DSS",
389 + "groups": [
390 + {
391 + "control": "10.2.4",
392 + "rule_count": 23,
393 + "total_hits_30d": 487,
394 + "rule_ids": [5710, 5711, ...]
395 + },
396 + ...
397 + ],
398 + ...
399 + }
400 +
401 + Rules that don't carry ANY value for the framework field are excluded —
402 + "no compliance tag" isn't a group, it's just absence. The frontend can
403 + surface that count separately if useful, but for the pivot table itself
404 + we only list real controls.
405 +
406 + Groups are sorted by ``total_hits_30d`` descending, then by ``control``
407 + alphabetically — the "what's actively firing for PCI 10.2.4?" question
408 + is the most common analyst entry point.
409 + """
410 + framework_meta = COMPLIANCE_FRAMEWORKS.get(framework)
411 + if not framework_meta:
412 + raise ValueError(
413 + f"Unknown compliance framework {framework!r}. Valid: {list(COMPLIANCE_FRAMEWORKS)}",
414 + )
415 +
416 + await wazuh_rules_cache.ensure_loaded()
417 + await wazuh_firing_stats_cache.ensure_loaded()
418 +
419 + field = framework_meta["field"]
420 +
421 + # control_id -> {"rule_ids": [], "total_hits_30d": int}
422 + grouped: Dict[str, Dict[str, Any]] = defaultdict(
423 + lambda: {"rule_ids": [], "total_hits_30d": 0, "total_hits_7d": 0},
424 + )
425 + rules_with_compliance = 0
426 +
427 + for raw_rule in wazuh_rules_cache.get_all_rules():
428 + controls = _rule_compliance_values(raw_rule, field)
429 + if not controls:
430 + continue
431 + rules_with_compliance += 1
432 +
433 + rid = raw_rule.get("id")
434 + stats = wazuh_firing_stats_cache.get(rid) if isinstance(rid, int) else {"hits_30d": 0, "hits_7d": 0}
435 +
436 + for control in controls:
437 + bucket = grouped[control]
438 + if isinstance(rid, int):
439 + bucket["rule_ids"].append(rid)
440 + bucket["total_hits_30d"] += stats["hits_30d"]
441 + bucket["total_hits_7d"] += stats["hits_7d"]
442 +
443 + groups = [
444 + {
445 + "control": control,
446 + "rule_count": len(bucket["rule_ids"]),
447 + "rule_ids": sorted(bucket["rule_ids"]),
448 + "total_hits_30d": bucket["total_hits_30d"],
449 + "total_hits_7d": bucket["total_hits_7d"],
450 + }
451 + for control, bucket in grouped.items()
452 + ]
453 + # Most-noisy-controls first; ties broken by control ID alphabetical so
454 + # the order is stable across calls.
455 + groups.sort(key=lambda g: (-g["total_hits_30d"], g["control"]))
456 +
457 + return {
458 + "framework": framework,
459 + "framework_label": framework_meta["label"],
460 + "groups": groups,
461 + "control_count": len(groups),
462 + "rules_with_compliance": rules_with_compliance,
463 + "total_rules": len(wazuh_rules_cache.get_all_rules()),
464 + "firing_stats_available": wazuh_firing_stats_cache.is_available,
465 + }
466 +
467 +
468 +def list_compliance_frameworks() -> List[Dict[str, str]]:
469 + """List the frameworks the compliance pivot supports. Drives the UI selector."""
470 + return [{"key": key, "label": meta["label"]} for key, meta in COMPLIANCE_FRAMEWORKS.items()]
471 +
472 +
473 +# ---------------------------------------------------------------------------
474 +# Logtest — "which rule would match this log line?"
475 +# ---------------------------------------------------------------------------
476 +
477 +
478 +async def run_log_test(
479 + event: str,
480 + log_format: str = "syslog",
481 + location: str = "logtest",
482 +) -> Dict[str, Any]:
483 + """
484 + Wrapper that runs Wazuh's logtest and decorates the result with details
485 + the catalog already has cached (so the UI gets one consistent rule
486 + shape regardless of whether it's reading from the index table, the
487 + detail modal, or a logtest match).
488 +
489 + Returns a dict the route can serialize directly:
490 + - ``matched``: bool
491 + - ``rule_id``, ``description``, ``level``, ``groups``, ``mitre``,
492 + ``tactics``: from the logtest output, enriched with the catalog's
493 + mitre_matrix-resolved tactic display names when we have them
494 + - ``alert``: the full Wazuh alert envelope (decoder/predecoder/data)
495 + - ``unavailable_reason``: filled when the logtest call itself failed
496 + so the UI can show an inline error
497 + """
498 + from app.connectors.wazuh_manager.services.logtest import run_logtest
499 +
500 + await mitre_matrix.ensure_loaded()
501 +
502 + try:
503 + result = await run_logtest(event=event, log_format=log_format, location=location)
504 + except Exception as exc: # noqa: BLE001
505 + # Service exceptions are converted to a structured "unavailable"
506 + # response rather than re-raised — the catalog should feel like a
507 + # stable surface; the route wraps real HTTPException for input-
508 + # validation errors but transport hiccups are reported inline.
509 + logger.warning(f"Logtest failed: {exc}")
510 + return {
511 + "matched": False,
512 + "rule": None,
513 + "alert": None,
514 + "tactics": [],
515 + "unavailable_reason": str(exc),
516 + }
517 +
518 + tactics: List[str] = []
519 + rule = result.get("rule")
520 + if rule:
521 + # Enrich with resolved tactic display names using the catalog's
522 + # mitre_matrix — Wazuh only emits T-IDs; we want analyst-readable
523 + # tactic names too so the result panel matches the rest of the catalog.
524 + tactics = _resolve_tactics_for_mitre_ids(rule.get("mitre") or [])
525 +
526 + return {
527 + "matched": result["matched"],
528 + "rule": rule,
529 + "alert": result.get("alert"),
530 + "tactics": tactics,
531 + "unavailable_reason": None,
532 + }
533 +
534 +
535 +# ---------------------------------------------------------------------------
536 +# Coverage Gaps — MITRE techniques unaddressed by either rule corpus
537 +# ---------------------------------------------------------------------------
538 +
539 +
540 +async def list_coverage_gaps() -> Dict[str, Any]:
541 + """
542 + Return every MITRE ATT&CK technique that is NOT covered by any rule in
543 + the CoPilot Searches corpus OR the Wazuh ruleset.
544 +
545 + A technique is "covered" if at least one rule (from either source)
546 + declares its T-ID or any sub-technique thereof. We normalize to base
547 + technique IDs (``T1059.007 → T1059``) so a sub-technique counts as
548 + coverage for its parent — same convention used elsewhere in this file
549 + when resolving tactics for a rule.
550 +
551 + Output is sorted by technique ID so the UI list is stable across loads.
552 + Sub-techniques are deliberately collapsed into their parents — analysts
553 + care about "do we have anything for PowerShell (T1059)?" not "do we
554 + have anything for T1059.001 specifically?". Listing every sub-technique
555 + individually would flood the gap report with thousands of rows.
556 + """
557 + await rules_cache.ensure_loaded()
558 + await mitre_matrix.ensure_loaded()
559 + await wazuh_rules_cache.ensure_loaded()
560 +
561 + # Collect the set of covered base-technique IDs across both corpora.
562 + covered_ids: set[str] = set()
563 + for rule in rules_cache.get_all_rules():
564 + for tid in _rule_mitre_ids(rule):
565 + base = tid.split(".")[0].strip().upper()
566 + if base:
567 + covered_ids.add(base)
568 + for rule in wazuh_rules_cache.get_all_rules():
569 + for tid in _wazuh_mitre_ids(rule):
570 + base = tid.split(".")[0].strip().upper()
571 + if base:
572 + covered_ids.add(base)
573 +
574 + techniques = mitre_matrix.techniques
575 + tactics_by_short = {t["short_name"]: t["name"] for t in mitre_matrix.tactics}
576 +
577 + gaps: List[Dict[str, Any]] = []
578 + base_technique_count = 0
579 + for tid, meta in techniques.items():
580 + # Skip sub-techniques in the gap list — see docstring rationale.
581 + if meta.get("is_subtechnique"):
582 + continue
583 + base_technique_count += 1
584 + if tid in covered_ids:
585 + continue
586 +
587 + tactic_names: List[str] = []
588 + for short in meta.get("tactic_short_names", []):
589 + display = tactics_by_short.get(short)
590 + if display and display not in tactic_names:
591 + tactic_names.append(display)
592 +
593 + gaps.append(
594 + {
595 + "technique_id": tid,
596 + "technique_name": meta.get("name", tid),
597 + "tactics": tactic_names,
598 + "url": meta.get("url"),
599 + },
600 + )
601 +
602 + gaps.sort(key=lambda g: g["technique_id"])
603 +
604 + covered_count = base_technique_count - len(gaps)
605 + coverage_pct = (covered_count / base_technique_count * 100) if base_technique_count else 0.0
606 +
607 + return {
608 + "gaps": gaps,
609 + "gap_count": len(gaps),
610 + "covered_count": covered_count,
611 + "total_techniques": base_technique_count,
612 + "coverage_pct": round(coverage_pct, 1),
613 + }
614 +
615 +
616 +# ---------------------------------------------------------------------------
617 +# Wazuh Rules tab — list + single-rule detail
618 +# ---------------------------------------------------------------------------
619 +
620 +
621 +def _wazuh_groups(rule: Dict[str, Any]) -> List[str]:
622 + """
623 + Wazuh's ``groups`` field is already a list in the schema but Wazuh
624 + occasionally returns it as a comma-joined string. Tolerate both.
625 + """
626 + raw = rule.get("groups")
627 + if isinstance(raw, list):
628 + return [g for g in raw if isinstance(g, str) and g.strip()]
629 + if isinstance(raw, str):
630 + return [g.strip() for g in raw.split(",") if g.strip()]
631 + return []
632 +
633 +
634 +def _wazuh_mitre_ids(rule: Dict[str, Any]) -> List[str]:
635 + """
636 + Same tolerance as ``_wazuh_groups`` — Wazuh's ``mitre`` field is sometimes
637 + a list of T-IDs, sometimes a single string. Normalize.
638 + """
639 + raw = rule.get("mitre")
640 + if isinstance(raw, list):
641 + return [m for m in raw if isinstance(m, str) and m.strip()]
642 + if isinstance(raw, str):
643 + return [m.strip() for m in raw.split(",") if m.strip()]
644 + return []
645 +
646 +
647 +def _wazuh_compliance(rule: Dict[str, Any]) -> Dict[str, List[str]]:
648 + """
649 + Group all compliance-framework arrays into one nested dict so the UI can
650 + render them as labelled chip groups without hard-coding the framework
651 + list in the frontend.
652 + """
653 + return {
654 + "pci_dss": _ensure_list_of_str(rule.get("pci_dss")),
655 + "gdpr": _ensure_list_of_str(rule.get("gdpr")),
656 + "hipaa": _ensure_list_of_str(rule.get("hipaa")),
657 + "nist_800_53": _ensure_list_of_str(rule.get("nist_800_53")),
658 + "tsc": _ensure_list_of_str(rule.get("tsc")),
659 + "gpg13": _ensure_list_of_str(rule.get("gpg13")),
660 + }
661 +
662 +
663 +def _wazuh_row(rule: Dict[str, Any]) -> Dict[str, Any]:
664 + """
665 + Project a cached Wazuh rule down to the columns the index table renders.
666 +
667 + Firing counts come from ``wazuh_firing_stats_cache`` if available — a
668 + missing entry means "0 hits in the 30d window", not "no data". The cache's
669 + own ``is_available`` flag (mirrored on the list envelope as
670 + ``firing_stats_available``) is what tells the UI whether to render the
671 + Hits column at all vs. hiding it because we can't tell.
672 + """
673 + rid = rule.get("id")
674 + stats = wazuh_firing_stats_cache.get(rid) if isinstance(rid, int) else {"hits_30d": 0, "hits_7d": 0, "last_seen": None}
675 + return {
676 + "id": rid,
677 + "level": rule.get("level"),
678 + "status": rule.get("status"),
679 + "description": rule.get("description") or "",
680 + "filename": rule.get("filename") or "",
681 + "relative_dirname": rule.get("relative_dirname") or "",
682 + "groups": _wazuh_groups(rule),
683 + "mitre": _wazuh_mitre_ids(rule),
684 + "hits_7d": stats["hits_7d"],
685 + "hits_30d": stats["hits_30d"],
686 + "last_seen": stats.get("last_seen"),
687 + }
688 +
689 +
690 +async def list_wazuh_rules(customer_code: Optional[str] = None) -> Dict[str, Any]:
691 + """
692 + Return the full cached Wazuh ruleset projected to the index-table shape.
693 +
694 + Returns the whole corpus in one shot (typical Wazuh install ships ~3–5k
695 + rules, ~3–5 MB JSON). Filtering and pagination happen client-side in the
696 + same pattern as the Stories index — keeps the API simple and the UX
697 + responsive (no round-trip per filter keystroke).
698 +
699 + When ``customer_code`` is set, firing counts (hits_7d / hits_30d /
700 + last_seen) are scoped to that customer's alerts via a fresh ES query
701 + (not cached — see ``fetch_firing_stats_for_customer`` for rationale).
702 + The rule list itself is the same — every rule is shown — but the hit
703 + columns reflect "what's been firing for THIS customer." Rules without
704 + any hits for the customer get zeros.
705 +
706 + Always returns a populated envelope. When Wazuh is unavailable, ``rules``
707 + is empty and ``available=False`` + ``unavailable_reason`` carries the
708 + explanation so the frontend can render an inline empty state.
709 + """
710 + await wazuh_rules_cache.ensure_loaded()
711 + # Load firing stats too — they're cached separately with their own TTL,
712 + # so this is cheap. _wazuh_row reads from the firing-stats cache below.
713 + await wazuh_firing_stats_cache.ensure_loaded()
714 +
715 + # Per-customer override: fetch a fresh per-customer aggregation and
716 + # splice it into each row in place of the global stats. We don't mutate
717 + # the row builder — instead build rows with global stats, then patch the
718 + # firing fields. Keeps _wazuh_row's semantics clean and isolated.
719 + customer_stats: Dict[int, Dict[str, Any]] = {}
720 + if customer_code:
721 + customer_stats = await fetch_firing_stats_for_customer(customer_code)
722 +
723 + rows: List[Dict[str, Any]] = []
724 + for raw in wazuh_rules_cache.get_all_rules():
725 + row = _wazuh_row(raw)
726 + if customer_code:
727 + # Override stats with the per-customer numbers. Missing rule_id
728 + # in customer_stats means "this rule hasn't fired for this
729 + # customer" — zero everything out rather than leaving the global
730 + # numbers in place, which would be misleading.
731 + rid = row.get("id")
732 + cstats = customer_stats.get(rid) if isinstance(rid, int) else None
733 + if cstats:
734 + row["hits_7d"] = cstats["hits_7d"]
735 + row["hits_30d"] = cstats["hits_30d"]
736 + row["last_seen"] = cstats.get("last_seen")
737 + else:
738 + row["hits_7d"] = 0
739 + row["hits_30d"] = 0
740 + row["last_seen"] = None
741 + rows.append(row)
742 +
743 + # Sort by integer ID for a stable, intuitive default order — operators
744 + # think of Wazuh rules numerically.
745 + rows.sort(key=lambda r: r["id"] if isinstance(r["id"], int) else 0)
746 +
747 + return {
748 + "rules": rows,
749 + "total": len(rows),
750 + "available": wazuh_rules_cache.is_available,
751 + "unavailable_reason": wazuh_rules_cache.unavailable_reason,
752 + "last_refresh": wazuh_rules_cache.last_refresh,
753 + # Firing-stats availability mirrored on the envelope so the UI knows
754 + # whether to render the Hits column (no point showing "0" for every
755 + # row when the indexer isn't reachable — that'd be misleading).
756 + "firing_stats_available": wazuh_firing_stats_cache.is_available,
757 + "firing_stats_unavailable_reason": wazuh_firing_stats_cache.unavailable_reason,
758 + "firing_stats_last_refresh": wazuh_firing_stats_cache.last_refresh,
759 + # Echoes the request so the UI can confirm the scope it's showing.
760 + # Empty string when the global view is being served.
761 + "customer_code": customer_code or "",
762 + }
763 +
764 +
765 +async def get_wazuh_rule_detail(rule_id: int) -> Optional[Dict[str, Any]]:
766 + """
767 + Build the full meta payload for a single Wazuh rule — fed into the detail
768 + modal. Returns ``None`` when the cache doesn't know that ID (caller maps
769 + that to 404).
770 +
771 + No second Wazuh API call: everything the UI needs is already on the cached
772 + list response. The ``details`` field carries the if-then logic (if_sid,
773 + match, regex, decoded_as, …) which the modal pretty-prints as chips, and
774 + ``source_xml`` is a reconstructed ``<rule>`` block the modal renders as a
775 + code snippet — analysts asked to "see how the rule is written" without
776 + the auth and parsing overhead of fetching the original ``.xml`` file.
777 + """
778 + await wazuh_rules_cache.ensure_loaded()
779 + await mitre_matrix.ensure_loaded()
780 + await wazuh_firing_stats_cache.ensure_loaded()
781 +
782 + rule = wazuh_rules_cache.get_rule(rule_id)
783 + if rule is None:
784 + return None
785 +
786 + mitre_ids = _wazuh_mitre_ids(rule)
787 + tactic_names = _resolve_tactics_for_mitre_ids(mitre_ids)
788 + stats = wazuh_firing_stats_cache.get(rule_id)
789 +
790 + return {
791 + "id": rule.get("id"),
792 + "level": rule.get("level"),
793 + "status": rule.get("status"),
794 + "description": rule.get("description") or "",
795 + "filename": rule.get("filename") or "",
796 + "relative_dirname": rule.get("relative_dirname") or "",
797 + "groups": _wazuh_groups(rule),
798 + "mitre": mitre_ids,
799 + "tactics": tactic_names, # resolved via mitre_matrix for richer display
800 + "compliance": _wazuh_compliance(rule),
801 + # The if/then logic dict, pretty-printed by the modal as labelled
802 + # rows. Passed through as-is so the UI can iterate any keys Wazuh
803 + # decides to emit (we don't want to hard-code the field list).
804 + "details": rule.get("details") or {},
805 + # Reconstructed XML for the "Rule Source" section. See
806 + # _synthesize_rule_xml below for the why-not-fetch-the-file rationale.
807 + "source_xml": _synthesize_rule_xml(rule),
808 + # Firing counts from the indexer. ``firing_stats_available`` mirrors
809 + # the cache state — the modal hides the hits panel entirely when
810 + # we can't pull stats (don't show "0 hits" when we really mean
811 + # "indexer unreachable, no idea").
812 + "hits_7d": stats["hits_7d"],
813 + "hits_30d": stats["hits_30d"],
814 + "last_seen": stats.get("last_seen"),
815 + "firing_stats_available": wazuh_firing_stats_cache.is_available,
816 + "firing_stats_unavailable_reason": wazuh_firing_stats_cache.unavailable_reason,
817 + }
818 +
819 +
820 +# ---------------------------------------------------------------------------
821 +# Rule source XML synthesis
822 +# ---------------------------------------------------------------------------
823 +
824 +
825 +def _synthesize_rule_xml(rule: Dict[str, Any]) -> str:
826 + """
827 + Reconstruct the ``<rule>`` XML block for a Wazuh rule from its cached
828 + metadata.
829 +
830 + Why synthesize instead of fetching the original file via Wazuh's
831 + ``GET /rules/files/{filename}``:
832 +
833 + - That endpoint returns the **whole file** — a single .xml file routinely
834 + contains dozens of rules wrapped in ``<group>`` blocks. We'd have to
835 + parse it and extract just the rule with the matching ``id``, which
836 + adds an XML-parsing dependency and a second network round-trip.
837 + - The original file endpoint is also admin-scoped. Proxying it through
838 + the catalog (which is admin|analyst|customer_user) means an extra
839 + route, extra schema, and extra service plumbing for what amounts to
840 + formatting.
841 + - Every field we'd need to fill the XML is already on the cached rule —
842 + ``id``, ``level``, ``description``, ``groups``, ``mitre``, compliance
843 + arrays, and the free-form ``details`` dict carrying the if-then logic.
844 +
845 + The result is functionally identical to what an analyst would see if they
846 + cracked open the source ``.xml`` file: a clean ``<rule id="…" level="…">``
847 + block with the same children. It may not be byte-identical (comments,
848 + original whitespace, attribute order) — if anyone needs the exact source
849 + later we can layer a "View raw file" button on top of a future
850 + ``/catalog/wazuh-rules/{id}/source`` endpoint.
851 + """
852 + rid = rule.get("id")
853 + level = rule.get("level")
854 + # Header attributes — id + level only. Wazuh's other rule attributes
855 + # (frequency, timeframe, maxsize, …) live under ``details`` and are
856 + # rendered as child elements below for consistency.
857 + header_parts = [f'id="{rid}"' if rid is not None else None, f'level="{level}"' if level is not None else None]
858 + header = " ".join(p for p in header_parts if p)
859 +
860 + lines: List[str] = [f"<rule {header}>" if header else "<rule>"]
861 +
862 + # Children. Order tries to match what hand-written Wazuh rule files look
863 + # like in practice: decoded_as → description → group → if-then logic →
864 + # mitre → compliance. The exact order isn't load-bearing but it keeps
865 + # the output readable.
866 + details = rule.get("details") or {}
867 +
868 + # decoded_as is conventionally near the top.
869 + if "decoded_as" in details:
870 + lines.append(f" <decoded_as>{_xml_escape(_stringify_detail_value(details['decoded_as']))}</decoded_as>")
871 +
872 + description = rule.get("description")
873 + if description:
874 + lines.append(f" <description>{_xml_escape(description)}</description>")
875 +
876 + # Groups are stored as a list in the cache; Wazuh's file convention is a
877 + # single ``<group>`` with comma-separated values (trailing comma included).
878 + groups = _wazuh_groups(rule)
879 + if groups:
880 + joined = ",".join(groups) + ","
881 + lines.append(f" <group>{_xml_escape(joined)}</group>")
882 +
883 + # Remaining details — anything we haven't already rendered above. We
884 + # iterate whatever keys Wazuh emits so new fields appear automatically.
885 + rendered_keys = {"decoded_as"}
886 + for key, value in details.items():
887 + if key in rendered_keys:
888 + continue
889 + lines.append(_render_detail_element(key, value))
890 +
891 + # MITRE — Wazuh's convention is a single <mitre> wrapper with <id>
892 + # children per technique.
893 + mitre_ids = _wazuh_mitre_ids(rule)
894 + if mitre_ids:
895 + lines.append(" <mitre>")
896 + for mid in mitre_ids:
897 + lines.append(f" <id>{_xml_escape(mid)}</id>")
898 + lines.append(" </mitre>")
899 +
900 + # Compliance arrays — one element per value, matching Wazuh's source
901 + # format (multiple <pci_dss>X.Y.Z</pci_dss> rather than a list).
902 + compliance = _wazuh_compliance(rule)
903 + for framework_key, values in compliance.items():
904 + for v in values:
905 + # Use the schema key verbatim except for the NIST renaming, which
906 + # uses the hyphenated form in source files.
907 + tag = "nist-800-53" if framework_key == "nist_800_53" else framework_key
908 + lines.append(f" <{tag}>{_xml_escape(v)}</{tag}>")
909 +
910 + lines.append("</rule>")
911 + return "\n".join(lines)
912 +
913 +
914 +def _render_detail_element(key: str, value: Any) -> str:
915 + """
916 + Render one ``details`` entry as an XML child element.
917 +
918 + Wazuh's API returns mixed shapes for these. A few cases worth handling:
919 +
920 + - **String** → ``<key>value</key>``. The common case (if_sid, regex, …).
921 + - **Dict with a "pattern" key** → ``<key>pattern_value</key>``. Wazuh's
922 + API sometimes wraps match/regex values in ``{"pattern": "…"}``; the
923 + analyst-facing XML just shows the text content.
924 + - **Dict with other keys** → treat the dict as attributes + a possible
925 + text body. Mirrors Wazuh's ``<match type="pcre2">…</match>`` shape.
926 + - **List** → emit one element per item (e.g. multiple ``<match>`` lines).
927 + - **Anything else** → JSON-encode as the text body. Last-resort fallback
928 + so we don't silently drop unknown shapes.
929 + """
930 + if isinstance(value, list):
931 + # One element per list entry, recursively rendered.
932 + return "\n".join(_render_detail_element(key, item) for item in value)
933 +
934 + if isinstance(value, dict):
935 + # The "pattern" wrapper case — flatten to text content.
936 + if set(value.keys()) == {"pattern"}:
937 + return f" <{key}>{_xml_escape(_stringify_detail_value(value['pattern']))}</{key}>"
938 + # Mixed dict — pull out any text-ish key as the body, treat the rest
939 + # as attributes. This is a heuristic; Wazuh's exact shape varies.
940 + text_keys = {"pattern", "value", "text", "#text"}
941 + attrs: Dict[str, Any] = {}
942 + body: Optional[str] = None
943 + for k, v in value.items():
944 + if k in text_keys and body is None:
945 + body = _stringify_detail_value(v)
946 + else:
947 + attrs[k] = v
948 + attr_str = "".join(f' {ak}="{_xml_escape(str(av))}"' for ak, av in attrs.items())
949 + if body is not None:
950 + return f" <{key}{attr_str}>{_xml_escape(body)}</{key}>"
951 + return f" <{key}{attr_str} />"
952 +
953 + # Scalar — string, number, bool, None.
954 + text = _stringify_detail_value(value)
955 + if text == "":
956 + return f" <{key} />"
957 + return f" <{key}>{_xml_escape(text)}</{key}>"
958 +
959 +
960 +def _stringify_detail_value(value: Any) -> str:
961 + """Best-effort scalar-to-string. JSON-encodes complex values."""
962 + if value is None:
963 + return ""
964 + if isinstance(value, str):
965 + return value
966 + if isinstance(value, (int, float, bool)):
967 + return str(value)
968 + # Last resort — render unknown shapes as compact JSON rather than
969 + # Python's repr (which would emit single quotes and 'True'/'None').
970 + try:
971 + import json
972 +
973 + return json.dumps(value, separators=(",", ":"))
974 + except Exception:
975 + return str(value)
976 +
977 +
978 +def _xml_escape(text: str) -> str:
979 + """
980 + Minimal XML escaping for the five reserved characters. We don't use
981 + ``xml.sax.saxutils.escape`` because we need to handle ``"`` and ``'`` as
982 + well (they appear inside attribute values, and we use both quote styles
983 + in synthesized output).
984 + """
985 + return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&apos;")
backend/app/integrations/copilot_searches/services/wazuh_firing_stats_cache.py new
+541
@@ -0,0 +1,541 @@
1 +"""
2 +In-memory cache of per-rule firing counts from the Wazuh indexer.
3 +
4 +Why this exists:
5 +The catalog wants to surface "how often has this rule fired in the last
6 +7d/30d?" for every Wazuh rule. The naive way (one ES query per rule, per
7 +request, per user) would crush the indexer. Instead we do **one** terms
8 +aggregation over the alerts indices, cache the result, and serve every
9 +per-rule lookup from the dict.
10 +
11 +Cost: one ``_cat/indices`` call + one ``search`` request to the Wazuh
12 +indexer every ``CACHE_TTL_MINUTES`` minutes (15 by default). The
13 +aggregation is a date-filtered ``terms`` over rule.id with a
14 +sub-aggregation for the 7d sub-window. Even across hundreds of millions of
15 +docs, this returns in well under a second.
16 +
17 +Index auto-discovery (why we don't hardcode ``wazuh-alerts-*``):
18 +In a default Wazuh install, all alerts live in ``wazuh-alerts-*``. In
19 +real SOCFortress deployments they don't — different integrations write to
20 +different indices (``office365-<customer>``, ``crowdstrike-<customer>``,
21 +``carbonblack-<customer>``, ``huntress_<customer>``, ad-hoc ``newest-*``
22 +test indices, …). Hardcoding ``wazuh-alerts-*`` misses all of them.
23 +Maintaining a comma-separated env list is fragile — every new customer
24 +integration would need a config bump.
25 +
26 +Instead we list every index in the cluster on each refresh, filter out
27 +indices that are definitely *not* alert indices (Wazuh's internal
28 +``monitoring/statistics/states`` plus Kibana/OpenSearch system indices),
29 +and aggregate across whatever's left. The aggregation's
30 +``exists`` filter on the rule-ID field naturally drops indices that don't
31 +carry one, and ``_coerce_rule_id`` drops bucket keys that aren't
32 +integer-parseable — so vendor indices with their own non-Wazuh rule IDs
33 +(e.g. ``"Office365_FailedLogin"``) don't pollute the cache.
34 +
35 +Field-name fallback:
36 +Wazuh's indexer schema varies across versions and shipping configurations.
37 +The rule ID may live under ``rule.id`` (object mapping), ``rule_id`` (flat
38 +mapping), or just ``id`` (older schemas). We try them in that order, taking
39 +the first one that returns non-empty buckets — same fallback pattern used
40 +in ``app/connectors/wazuh_manager/services/mitre.py``.
41 +
42 +No DB writes, no schema changes, no Alembic migrations, no env var —
43 +pure read-only aggregation against the live indices plus an in-memory
44 +dict on the CoPilot side.
45 +"""
46 +
47 +import asyncio
48 +import re
49 +from datetime import datetime
50 +from datetime import timedelta
51 +from typing import Any
52 +from typing import Dict
53 +from typing import List
54 +from typing import Optional
55 +
56 +from elasticsearch7.exceptions import RequestError
57 +from loguru import logger
58 +
59 +# 15-min TTL strikes a balance: fresh enough that the dashboard reflects
60 +# real-time tuning, cheap enough that the indexer barely notices.
61 +CACHE_TTL_MINUTES = 15
62 +
63 +# ES-native index pattern with exclusions. We pass this as a single short
64 +# string (~150 bytes) to ``client.search(index=...)``, which lets ES resolve
65 +# it server-side. The previous approach of enumerating indices via
66 +# ``_cat/indices`` and passing the list client-side blew up at scale —
67 +# 1582 indices in a single URL crosses the 4096-byte HTTP line limit
68 +# (``too_long_http_line_exception``).
69 +#
70 +# Exclusion rationale:
71 +# - ``.*`` / ``_*``: Kibana / OpenSearch / Security plugin internals.
72 +# - ``wazuh-monitoring-*`` / ``wazuh-statistics-*`` / ``wazuh-states-*`` /
73 +# ``wazuh-vulnerabilities-*``: Wazuh's own internal indices. Carry rule.id
74 +# for system/control events with parent-template rule IDs (2 = firewall,
75 +# 3 = ids, 4 = web-log) that never fire on real alerts but produce massive
76 +# fake hit counts. ``wazuh-vulnerabilities-*`` is the agent vulnerability
77 +# state index, also not alert data.
78 +# - ``security-auditlog-*``: OpenSearch Security plugin's audit log.
79 +#
80 +# The integer-coerce filter in ``_coerce_rule_id`` is the second line of
81 +# defense — even if a vendor index sneaks in, non-Wazuh rule IDs (string
82 +# identifiers like ``"Office365_FailedLogin"``) get silently dropped.
83 +ALERT_INDEX_PATTERN = (
84 + "*" ",-.*" ",-_*" ",-wazuh-monitoring-*" ",-wazuh-statistics-*" ",-wazuh-states-*" ",-wazuh-vulnerabilities-*" ",-security-auditlog-*"
85 +)
86 +
87 +# Equivalent regex list — used only by the introspection helper
88 +# ``_discover_alert_indices`` to populate ``resolved_indices`` for
89 +# logging/debugging. The search itself never uses these; ES handles the
90 +# pattern above natively.
91 +EXCLUDE_PATTERNS = [
92 + re.compile(r"^\."),
93 + re.compile(r"^_"),
94 + re.compile(r"^wazuh-monitoring"),
95 + re.compile(r"^wazuh-statistics"),
96 + re.compile(r"^wazuh-states"),
97 + re.compile(r"^wazuh-vulnerabilities"),
98 + re.compile(r"security-auditlog"),
99 +]
100 +
101 +# Try these field paths in order. First one that yields non-empty buckets
102 +# wins and is cached for subsequent refreshes (avoids retrying the misses
103 +# on every refresh).
104 +RULE_ID_FIELD_OPTIONS = ["rule.id", "rule_id", "id"]
105 +
106 +
107 +class WazuhFiringStatsCache:
108 + """
109 + Per-rule firing-count cache, keyed by integer rule ID.
110 +
111 + Returns a small dict per rule: ``{"hits_30d": int, "hits_7d": int}``.
112 + Rules with zero hits over the 30d window are deliberately omitted from
113 + the dict — callers should treat "missing" as "0 hits" rather than
114 + "data unavailable". The cache's ``is_available`` flag distinguishes
115 + those two states explicitly.
116 + """
117 +
118 + def __init__(self) -> None:
119 + # rule_id (int) -> {"hits_30d": int, "hits_7d": int}
120 + self._stats: Dict[int, Dict[str, int]] = {}
121 + self._last_refresh: Optional[datetime] = None
122 + self._unavailable_reason: Optional[str] = None
123 + # Field path that worked last time; tried first on next refresh.
124 + self._winning_field: Optional[str] = None
125 + # Indices discovered on last refresh — kept for introspection so
126 + # operators can see "which indices did we actually aggregate over?"
127 + # without having to spelunk through logs.
128 + self._resolved_indices: List[str] = []
129 + self._lock = asyncio.Lock()
130 +
131 + # ---- introspection ----------------------------------------------------
132 +
133 + @property
134 + def is_stale(self) -> bool:
135 + if self._last_refresh is None:
136 + return True
137 + return (datetime.utcnow() - self._last_refresh) > timedelta(minutes=CACHE_TTL_MINUTES)
138 +
139 + @property
140 + def last_refresh(self) -> Optional[datetime]:
141 + return self._last_refresh
142 +
143 + @property
144 + def is_available(self) -> bool:
145 + return self._unavailable_reason is None
146 +
147 + @property
148 + def unavailable_reason(self) -> Optional[str]:
149 + return self._unavailable_reason
150 +
151 + @property
152 + def resolved_indices(self) -> List[str]:
153 + """Indices that the last refresh actually aggregated across."""
154 + return list(self._resolved_indices)
155 +
156 + # ---- loading ----------------------------------------------------------
157 +
158 + async def ensure_loaded(self) -> None:
159 + """Lazy-load on first access; refresh on TTL expiry. Never raises."""
160 + if self.is_stale:
161 + await self.refresh()
162 +
163 + async def refresh(self) -> int:
164 + """
165 + Fire one terms-aggregation against the alerts index and rebuild the
166 + per-rule stats dict. Returns the number of rules with at least one
167 + hit in the 30d window.
168 +
169 + Never raises — failures are captured in ``unavailable_reason`` and
170 + the existing cached snapshot is left in place. A transient indexer
171 + outage shouldn't blank the firing-count column.
172 + """
173 + # Local import to avoid pulling the indexer client at module-import
174 + # time on deployments where the indexer connector isn't usable.
175 + from app.connectors.wazuh_indexer.utils.universal import (
176 + create_wazuh_indexer_client_async,
177 + )
178 +
179 + async with self._lock:
180 + logger.info("Refreshing Wazuh firing-stats cache from the indexer…")
181 +
182 + try:
183 + client = await create_wazuh_indexer_client_async()
184 + except Exception as exc: # noqa: BLE001
185 + self._unavailable_reason = _short_reason(exc)
186 + logger.warning(f"Wazuh indexer client unavailable: {self._unavailable_reason}")
187 + self._last_refresh = datetime.utcnow()
188 + return len(self._stats)
189 +
190 + # Always search via the single ES wildcard+exclusion pattern —
191 + # ES resolves it server-side, sub-millisecond, no HTTP line
192 + # limits to worry about. The cat-indices discovery call below is
193 + # only for the introspection / debug log so operators can see
194 + # WHICH concrete indices the pattern expanded to without having
195 + # to run the cat call themselves. Discovery failure is non-fatal
196 + # — the search still runs on the pattern.
197 + self._resolved_indices = await _discover_alert_indices(client)
198 + logger.debug(
199 + f"Firing-stats aggregating across {len(self._resolved_indices)} concrete index target(s); "
200 + f"first few: {self._resolved_indices[:5]}",
201 + )
202 + search_target = ALERT_INDEX_PATTERN
203 +
204 + # Try the winning field first (if we have one from a prior load),
205 + # then fall back through the canonical list. First field that
206 + # returns non-empty buckets wins.
207 + fields_to_try: List[str] = []
208 + if self._winning_field:
209 + fields_to_try.append(self._winning_field)
210 + for f in RULE_ID_FIELD_OPTIONS:
211 + if f not in fields_to_try:
212 + fields_to_try.append(f)
213 +
214 + new_stats: Dict[int, Dict[str, int]] = {}
215 + winner: Optional[str] = None
216 + last_error: Optional[str] = None
217 +
218 + for field in fields_to_try:
219 + try:
220 + body = _build_firing_stats_query(field)
221 + # ``ignore_unavailable`` swallows per-index permission /
222 + # missing-index errors so one bad index doesn't fail the
223 + # whole aggregation. ``allow_no_indices`` likewise stops
224 + # the request from 4xx-ing when the pattern matches
225 + # zero indices (fresh deployment, etc.).
226 + response = await client.search(
227 + index=search_target,
228 + body=body,
229 + ignore_unavailable=True,
230 + allow_no_indices=True,
231 + )
232 + buckets = response.get("aggregations", {}).get("by_rule", {}).get("buckets", [])
233 + if not buckets:
234 + # Empty buckets = field exists but no docs, OR field
235 + # doesn't exist. Either way, try the next one.
236 + continue
237 +
238 + for bucket in buckets:
239 + rule_id = _coerce_rule_id(bucket.get("key"))
240 + if rule_id is None:
241 + continue
242 + hits_30d = int(bucket.get("doc_count") or 0)
243 + hits_7d = int(
244 + bucket.get("last_7d", {}).get("doc_count") or 0,
245 + )
246 + # ``max(timestamp)`` bucket comes back as
247 + # ``{"value": 1700000000000.0, "value_as_string": "..."}``
248 + # (epoch millis + ISO string). Prefer the ISO string
249 + # for direct UI rendering; fall back to None if the
250 + # bucket is missing (shouldn't happen but defensive).
251 + last_seen_bucket = bucket.get("last_seen") or {}
252 + last_seen = last_seen_bucket.get("value_as_string")
253 + new_stats[rule_id] = {
254 + "hits_30d": hits_30d,
255 + "hits_7d": hits_7d,
256 + "last_seen": last_seen,
257 + }
258 + winner = field
259 + break
260 + except RequestError as re:
261 + # Most common: field doesn't exist on the index — Wazuh
262 + # versions differ. The .info payload has the human
263 + # message (see CLAUDE.md "Things that bite").
264 + last_error = f"{field}: {str(re.info)[:120]}"
265 + logger.debug(f"Firing-stats field '{field}' rejected: {last_error}")
266 + continue
267 + except Exception as exc: # noqa: BLE001
268 + last_error = f"{field}: {str(exc)[:120]}"
269 + logger.debug(f"Firing-stats field '{field}' errored: {last_error}")
270 + continue
271 + finally:
272 + pass
273 +
274 + try:
275 + await client.close()
276 + except Exception:
277 + pass
278 +
279 + if winner is None:
280 + # No field worked, or all returned empty. Treat as
281 + # "unavailable" so the UI doesn't render a useless "0 hits"
282 + # column for every rule when really we just can't tell.
283 + self._unavailable_reason = last_error or "No rule-ID field returned hits in the alerts index."
284 + self._last_refresh = datetime.utcnow()
285 + logger.warning(
286 + f"Could not resolve a rule-ID field for firing stats. {self._unavailable_reason}",
287 + )
288 + return len(self._stats)
289 +
290 + self._stats = new_stats
291 + self._winning_field = winner
292 + self._unavailable_reason = None
293 + self._last_refresh = datetime.utcnow()
294 + logger.info(
295 + f"Loaded firing stats for {len(self._stats)} rule(s) using field '{winner}'",
296 + )
297 + return len(self._stats)
298 +
299 + # ---- accessors --------------------------------------------------------
300 +
301 + def get(self, rule_id: int) -> Dict[str, Any]:
302 + """
303 + Return ``{"hits_30d": int, "hits_7d": int, "last_seen": str | None}``
304 + for ``rule_id``.
305 +
306 + Missing rule IDs return zero counts + None last_seen — the rule
307 + exists in the cache but simply hasn't fired in the 30d window.
308 + Callers that need to distinguish "0 hits" from "stats unavailable"
309 + should check ``is_available`` separately.
310 + """
311 + return self._stats.get(
312 + rule_id,
313 + {"hits_30d": 0, "hits_7d": 0, "last_seen": None},
314 + )
315 +
316 +
317 +# ---------------------------------------------------------------------------
318 +# Per-customer firing stats — on-demand, not cached
319 +# ---------------------------------------------------------------------------
320 +
321 +
322 +async def fetch_firing_stats_for_customer(customer_code: str) -> Dict[int, Dict[str, Any]]:
323 + """
324 + Run the firing-stats aggregation filtered to a single customer code.
325 +
326 + Why this isn't cached:
327 + The catalog has potentially dozens of customer codes; pre-caching all of
328 + them would balloon memory and waste indexer cycles on customers nobody
329 + is looking at. Instead we run the query on demand when an analyst picks
330 + a customer from the dropdown. The query is the same shape as the global
331 + refresh (one terms agg + sub-aggs), just with an extra term filter on
332 + ``agent_labels_customer`` — sub-second on any realistic deployment.
333 +
334 + Returns ``{rule_id: {hits_30d, hits_7d, last_seen}}`` — same shape as
335 + ``WazuhRulesFiringStatsCache.get()`` so callers can use it
336 + interchangeably. Empty dict when:
337 + - The customer code doesn't match any alerts in the 30d window.
338 + - The winning_field couldn't be determined (indexer error / no field).
339 + - The indexer is unreachable.
340 +
341 + All failures are silent / non-raising — the catalog falls back to "0
342 + hits for this customer" rather than erroring.
343 +
344 + Field naming note: the customer code is conventionally indexed under
345 + ``agent_labels_customer`` (Graylog convention, flat) in SOCFortress
346 + deployments — confirmed in the Suricata alert sample we tested. Wazuh
347 + vanilla uses ``agent.labels.customer`` (nested). We try both.
348 + """
349 + from app.connectors.wazuh_indexer.utils.universal import (
350 + create_wazuh_indexer_client_async,
351 + )
352 +
353 + if not customer_code or not customer_code.strip():
354 + return {}
355 +
356 + # Use whatever rule-ID field the global cache discovered — saves us from
357 + # re-running the field-fallback dance per customer query.
358 + field = wazuh_firing_stats_cache._winning_field or RULE_ID_FIELD_OPTIONS[0]
359 +
360 + try:
361 + client = await create_wazuh_indexer_client_async()
362 + except Exception as exc: # noqa: BLE001
363 + logger.warning(f"Per-customer firing stats: indexer unavailable ({_short_reason(exc)})")
364 + return {}
365 +
366 + try:
367 + # Try each customer-code field shape until one returns buckets.
368 + # Same fallback philosophy as the rule-ID field discovery.
369 + for customer_field in ("agent_labels_customer", "agent.labels.customer"):
370 + try:
371 + body = _build_firing_stats_query(field)
372 + # Splice the customer filter into the existing query — keeps
373 + # the agg shape identical so the bucket-unpacking code below
374 + # matches the cached path exactly.
375 + body["query"]["bool"]["filter"].append(
376 + {"term": {customer_field: customer_code}},
377 + )
378 + response = await client.search(
379 + index=ALERT_INDEX_PATTERN,
380 + body=body,
381 + ignore_unavailable=True,
382 + allow_no_indices=True,
383 + )
384 + buckets = response.get("aggregations", {}).get("by_rule", {}).get("buckets", [])
385 + if not buckets:
386 + continue
387 +
388 + out: Dict[int, Dict[str, Any]] = {}
389 + for bucket in buckets:
390 + rule_id = _coerce_rule_id(bucket.get("key"))
391 + if rule_id is None:
392 + continue
393 + out[rule_id] = {
394 + "hits_30d": int(bucket.get("doc_count") or 0),
395 + "hits_7d": int(bucket.get("last_7d", {}).get("doc_count") or 0),
396 + "last_seen": (bucket.get("last_seen") or {}).get("value_as_string"),
397 + }
398 + logger.debug(
399 + f"Per-customer firing stats: {len(out)} rules for customer " f"{customer_code!r} via field {customer_field!r}",
400 + )
401 + return out
402 + except Exception as exc: # noqa: BLE001
403 + logger.debug(
404 + f"Per-customer firing stats: customer field {customer_field!r} failed: {exc}",
405 + )
406 + continue
407 + return {}
408 + finally:
409 + try:
410 + await client.close()
411 + except Exception:
412 + pass
413 +
414 +
415 +def _build_firing_stats_query(field: str) -> Dict[str, Any]:
416 + """
417 + One query gets us 30d totals + 7d sub-totals in a single round-trip.
418 +
419 + The outer ``terms`` agg buckets the last 30 days by rule ID; the nested
420 + ``last_7d`` filter inside each bucket counts how many of those hits
421 + fell in the most recent week. Painless-free, fast, and the result is
422 + just a list of buckets — no docs returned (``size: 0``).
423 + """
424 + return {
425 + "size": 0,
426 + "query": {
427 + "bool": {
428 + "filter": [
429 + {"range": {"timestamp": {"gte": "now-30d"}}},
430 + {"exists": {"field": field}},
431 + ],
432 + },
433 + },
434 + "aggs": {
435 + "by_rule": {
436 + # 50000 is the ES "terms" agg's recommended ceiling for a
437 + # single query; Wazuh ships ~3-5k rules so we'll never come
438 + # close. We just don't want a silent truncation.
439 + "terms": {"field": field, "size": 50000, "order": {"_count": "desc"}},
440 + "aggs": {
441 + "last_7d": {
442 + "filter": {"range": {"timestamp": {"gte": "now-7d"}}},
443 + },
444 + # max(timestamp) gives us "when did this rule last fire?"
445 + # for free — same query, same scan, ES returns it as both
446 + # epoch-millis and an ISO string. We thread the ISO form
447 + # through to the UI so it can render relative time
448 + # ("2 minutes ago", "26 days ago") without parsing.
449 + "last_seen": {
450 + "max": {"field": "timestamp"},
451 + },
452 + },
453 + },
454 + },
455 + }
456 +
457 +
458 +async def _discover_alert_indices(client: Any) -> List[str]:
459 + """
460 + List every index in the cluster and return the ones that look like
461 + alert indices (i.e. not a system / Wazuh-internal / audit-log index).
462 +
463 + Returns a list of concrete index names — pass directly to
464 + ``client.search(index=<list>)``. Returns ``[]`` on any failure or if
465 + the cluster is empty; callers should fall back to a sensible default
466 + pattern in that case.
467 +
468 + Why this is safe even with vendor indices that aren't Wazuh-alert
469 + shaped:
470 +
471 + - The aggregation's ``exists`` filter on the rule-ID field drops
472 + documents that don't carry one.
473 + - ``_coerce_rule_id`` drops bucket keys that aren't integer-parseable —
474 + vendor-native rule identifiers like ``"Office365_FailedLogin"``
475 + don't pollute the Wazuh rule cache.
476 +
477 + So the worst case of including a "wrong" index is zero contribution,
478 + not garbage data. Erring on the side of inclusion is correct.
479 + """
480 + try:
481 + # ``cat.indices`` returns a list of dicts with at least an ``index``
482 + # field when ``format="json"``. We only need the name; ``h="index"``
483 + # restricts the columns to keep the response small.
484 + cat_response = await client.cat.indices(format="json", h="index")
485 + except Exception as exc: # noqa: BLE001
486 + # Permission errors, network blips, etc. Caller falls back.
487 + logger.warning(f"Firing-stats index discovery failed: {exc}")
488 + return []
489 +
490 + if not isinstance(cat_response, list):
491 + # Some clients return the raw HTTP response object instead of the
492 + # parsed body — defend against shape drift.
493 + logger.warning(
494 + f"Unexpected cat.indices response shape: {type(cat_response).__name__}",
495 + )
496 + return []
497 +
498 + kept: List[str] = []
499 + for item in cat_response:
500 + name = item.get("index") if isinstance(item, dict) else None
501 + if not isinstance(name, str) or not name:
502 + continue
503 + if any(pat.search(name) for pat in EXCLUDE_PATTERNS):
504 + continue
505 + kept.append(name)
506 +
507 + return kept
508 +
509 +
510 +def _coerce_rule_id(key: Any) -> Optional[int]:
511 + """
512 + Bucket keys come back as strings or ints depending on the field mapping
513 + (keyword vs long). Normalize to int; drop anything that doesn't parse.
514 + """
515 + if key is None:
516 + return None
517 + if isinstance(key, int):
518 + return key
519 + if isinstance(key, str):
520 + try:
521 + return int(key)
522 + except ValueError:
523 + return None
524 + return None
525 +
526 +
527 +def _short_reason(exc: BaseException) -> str:
528 + """Mirror of the helper in wazuh_rules_cache — keep it local to avoid coupling."""
529 + raw = str(exc).strip() or exc.__class__.__name__
530 + lower = raw.lower()
531 + if "connection" in lower or "timed out" in lower or "timeout" in lower:
532 + return "Wazuh indexer is not reachable."
533 + if "401" in raw or "403" in raw or "unauthor" in lower:
534 + return "Wazuh indexer rejected the credentials."
535 + if "not configured" in lower or "not found" in lower:
536 + return "Wazuh indexer connector is not configured."
537 + return raw[:160] + ("…" if len(raw) > 160 else "")
538 +
539 +
540 +# Module-level singleton — same pattern as the rules caches.
541 +wazuh_firing_stats_cache = WazuhFiringStatsCache()
backend/app/integrations/copilot_searches/services/wazuh_rules_cache.py new
+174
@@ -0,0 +1,174 @@
1 +"""
2 +In-memory cache for Wazuh Manager rules — powers the "Wazuh Rules" tab of the
3 +Detections Catalog.
4 +
5 +Why this exists separately from ``RulesCache`` (CoPilot Searches):
6 +- Different source: Wazuh Manager REST API, not a GitHub repo. We reuse the
7 + existing connector wrapper ``get_wazuh_rules()`` so there's only one place
8 + in the codebase that knows how to call ``GET /rules``.
9 +- Different cadence: Wazuh rules change only on operator action (XML upload,
10 + rule enable/disable). A long TTL (1 hour) is generous; the data is
11 + effectively static between operator events.
12 +- Different failure mode: the Wazuh Manager may be unconfigured or down on a
13 + given deployment. We must NOT raise out of the catalog code path in that
14 + case — the Stories tab and the rest of CoPilot keep working without the
15 + Wazuh tab. The cache holds an ``unavailable_reason`` string so the UI can
16 + render an explanatory empty state instead of a generic error.
17 +
18 +Auth note:
19 +The wrapped connector route (``/wazuh_manager/rules``) is admin-only via
20 +``Security(...scopes=["admin"])``. The catalog deliberately calls the underlying
21 +service function ``get_wazuh_rules()`` instead, because scope checks live on
22 +the route handler, not the service. That keeps the catalog viewable by
23 +analysts / customer_user roles without loosening the Wazuh management surface.
24 +"""
25 +
26 +import asyncio
27 +from datetime import datetime
28 +from datetime import timedelta
29 +from typing import Any
30 +from typing import Dict
31 +from typing import List
32 +from typing import Optional
33 +
34 +from loguru import logger
35 +
36 +# Long TTL by design — Wazuh rules don't change without operator action. A
37 +# manual ``refresh()`` is exposed for the rare "I just uploaded a rule file"
38 +# case; we don't poll aggressively.
39 +CACHE_TTL_MINUTES = 60
40 +
41 +
42 +class WazuhRulesCache:
43 + """
44 + Single-source-of-truth in-memory copy of the Wazuh Manager ruleset, keyed
45 + by integer rule ID, with the same ``ensure_loaded`` / ``refresh`` /
46 + ``last_refresh`` surface as the CoPilot Searches ``RulesCache`` so the
47 + catalog aggregator code can treat them symmetrically.
48 + """
49 +
50 + def __init__(self) -> None:
51 + # rule_id (int) -> raw rule dict (the Wazuh API "affected_items" entry)
52 + self._rules: Dict[int, Dict[str, Any]] = {}
53 + self._last_refresh: Optional[datetime] = None
54 + # When Wazuh is unreachable / not configured, populated with a short
55 + # human-readable reason so the catalog UI can show an empty state
56 + # instead of bubbling a 5xx. None means "last load succeeded".
57 + self._unavailable_reason: Optional[str] = None
58 + self._lock = asyncio.Lock()
59 +
60 + # ---- introspection ----------------------------------------------------
61 +
62 + @property
63 + def is_stale(self) -> bool:
64 + if self._last_refresh is None:
65 + return True
66 + return (datetime.utcnow() - self._last_refresh) > timedelta(minutes=CACHE_TTL_MINUTES)
67 +
68 + @property
69 + def last_refresh(self) -> Optional[datetime]:
70 + return self._last_refresh
71 +
72 + @property
73 + def rules_count(self) -> int:
74 + return len(self._rules)
75 +
76 + @property
77 + def is_available(self) -> bool:
78 + """True if the last load succeeded — i.e. the catalog can render rule rows."""
79 + return self._unavailable_reason is None
80 +
81 + @property
82 + def unavailable_reason(self) -> Optional[str]:
83 + return self._unavailable_reason
84 +
85 + # ---- loading ----------------------------------------------------------
86 +
87 + async def ensure_loaded(self) -> None:
88 + """Lazy-load on first access; refresh on TTL expiry. Never raises."""
89 + if self.is_stale:
90 + await self.refresh()
91 +
92 + async def refresh(self) -> int:
93 + """
94 + Pull the full Wazuh ruleset into memory in one shot.
95 +
96 + Returns the number of rules loaded (0 if Wazuh is unavailable).
97 + Never raises — failures are captured in ``unavailable_reason`` and
98 + the existing cached snapshot (if any) is left in place rather than
99 + wiped, so a transient Wazuh outage doesn't blank the UI.
100 + """
101 + # Local import to avoid a hard dependency at module import time —
102 + # keeps copilot_searches importable even on deployments where the
103 + # wazuh_manager package isn't usable (e.g. unit tests).
104 + from app.connectors.wazuh_manager.services.rules import get_wazuh_rules
105 +
106 + async with self._lock:
107 + logger.info("Refreshing Wazuh rules cache from Wazuh Manager…")
108 + try:
109 + # limit=100000 is the Wazuh API ceiling; one call gets the
110 + # entire ruleset for any realistic deployment (~3–5k rules).
111 + # offset stays at 0.
112 + response = await get_wazuh_rules(limit=100000, offset=0)
113 + except Exception as exc: # noqa: BLE001 — we deliberately catch all
114 + self._unavailable_reason = _short_reason(exc)
115 + logger.warning(
116 + f"Wazuh rules cache refresh failed: {self._unavailable_reason}. " "Keeping previously cached snapshot (if any).",
117 + )
118 + # Still mark refresh time so we don't hammer Wazuh on every
119 + # request when it's down; user can force ``refresh()`` to retry.
120 + self._last_refresh = datetime.utcnow()
121 + return len(self._rules)
122 +
123 + # Success — fully replace the cache. Don't merge: we want
124 + # disabled / deleted rules to disappear too.
125 + new_rules: Dict[int, Dict[str, Any]] = {}
126 + for item in response.results:
127 + # The schema is Pydantic — convert to plain dict so downstream
128 + # aggregators get a uniform shape (.dict() not .model_dump()
129 + # because both work in v2 and the older spelling reads less
130 + # surprising to anyone who hasn't tracked the v1→v2 rename).
131 + rule_dict = item.model_dump(by_alias=False)
132 + rid = rule_dict.get("id")
133 + if isinstance(rid, int):
134 + new_rules[rid] = rule_dict
135 +
136 + self._rules = new_rules
137 + self._unavailable_reason = None
138 + self._last_refresh = datetime.utcnow()
139 + logger.info(f"Loaded {len(self._rules)} Wazuh rules into cache")
140 + return len(self._rules)
141 +
142 + # ---- accessors --------------------------------------------------------
143 +
144 + def get_all_rules(self) -> List[Dict[str, Any]]:
145 + """Return every cached rule as a plain dict (caller-owned, safe to mutate)."""
146 + return list(self._rules.values())
147 +
148 + def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]:
149 + return self._rules.get(rule_id)
150 +
151 +
152 +def _short_reason(exc: BaseException) -> str:
153 + """
154 + Trim noisy exception messages down to a UI-friendly one-liner.
155 +
156 + Wazuh connector errors typically wrap an httpx/HTTPException and include
157 + full URLs + stack-trace-ish detail. We want something the empty-state
158 + component can show inline without exploding the layout.
159 + """
160 + raw = str(exc).strip() or exc.__class__.__name__
161 + # Common cases — keep them short and actionable.
162 + lower = raw.lower()
163 + if "connection" in lower or "timed out" in lower or "timeout" in lower:
164 + return "Wazuh Manager is not reachable."
165 + if "401" in raw or "403" in raw or "unauthor" in lower:
166 + return "Wazuh Manager rejected the credentials."
167 + if "not configured" in lower or "not found" in lower:
168 + return "Wazuh Manager connector is not configured."
169 + # Generic fallback — truncate aggressively so it fits in a tooltip.
170 + return raw[:160] + ("…" if len(raw) > 160 else "")
171 +
172 +
173 +# Module-level singleton — symmetrical with ``rules_cache`` in copilot_searches.
174 +wazuh_rules_cache = WazuhRulesCache()
frontend/eslint.config.mjs
+8 -3
@@ -1,5 +1,8 @@
1 +import process from "node:process"
2 import antfu from "@antfu/eslint-config"
3
4 +const isProduction = process.env.NODE_ENV === "production"
5 +
6 export default antfu(
7 {
8 vue: true,
@@ -12,9 +15,10 @@ export default antfu(
15 quotes: "double"
16 },
17 rules: {
15 - "e18e/ban-dependencies": ["error", { allowed: ["axios", "lodash", "depcheck", "fs-extra"] }],
16 - "antfu/if-newline": "off",
18 "@typescript-eslint/no-non-null-assertion": "error",
19 + "antfu/if-newline": "off",
20 + "e18e/ban-dependencies": ["error", { allowed: ["axios", "lodash", "depcheck", "fs-extra"] }],
21 + "pnpm/yaml-enforce-settings": isProduction ? "error" : "off",
22 "style/operator-linebreak": "off",
23 "style/arrow-parens": "off",
24 "style/brace-style": "off",
@@ -54,7 +58,8 @@ export default antfu(
58 "vue/quote-props": "off",
59 "vue/one-component-per-file": "off",
60 "vue/prefer-true-attribute-shorthand": "error",
57 - "vue/v-bind-style": ["error", "shorthand", { sameNameShorthand: "always" }]
61 + "vue/v-bind-style": ["error", "shorthand", { sameNameShorthand: "always" }],
62 + "style/multiline-ternary": "off"
63 }
64 }
65 )
frontend/package.json
+19 -18
@@ -3,7 +3,7 @@
3 "type": "module",
4 "version": "1.0.0",
5 "private": true,
6 - "packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
6 + "packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
7 "engines": {
8 "node": ">=24.0.0"
9 },
@@ -19,7 +19,7 @@
19 "preview:only": "vite preview --port 4173 --host",
20 "test:unit": "vitest run",
21 "dup-check": "jscpd --ignore \"**/node_modules/**,**/dist/**,**/.nuxt/**,**/*.svg\" --min-tokens 100 --reporters console,html,json .",
22 - "type-check": "vue-tsc --build --force",
22 + "type-check": "vue-tsc --build --force tsconfig.app.json",
23 "lint": "eslint . --fix",
24 "format": "prettier --write src/",
25 "lint-type-format": "run-s lint type-check format",
@@ -49,20 +49,20 @@
49 "@fontsource/public-sans": "^5.2.7",
50 "@microsoft/fetch-event-source": "^2.0.1",
51 "@mui/material": "^9.0.1",
52 - "@shikijs/markdown-it": "^4.0.2",
53 - "@shuffleio/shuffle-mcps": "^0.0.13",
52 + "@shikijs/markdown-it": "^4.1.0",
53 + "@shuffleio/shuffle-mcps": "^0.0.26",
54 "@types/codemirror": "^5.60.17",
55 "@vueuse/core": "^14.3.0",
56 "@vueuse/motion": "^3.0.3",
57 "algoliasearch": "^5.52.1",
58 - "apexcharts": "^5.12.0",
58 + "apexcharts": "^5.13.0",
59 "axios": "^1.16.1",
60 "bytes": "^3.1.2",
61 "codemirror": "~6.0.2",
62 "colord": "^2.9.3",
63 "dayjs": "^1.11.20",
64 "detect-touch-device": "^1.1.6",
65 - "echarts": "^6.0.0",
65 + "echarts": "^6.1.0",
66 "fast-xml-parser": "^5.8.0",
67 "file-saver": "^2.0.5",
68 "html-entities": "^2.6.0",
@@ -82,12 +82,13 @@
82 "react18-json-view": "^0.2.10",
83 "remark-breaks": "^4.0.0",
84 "secure-ls": "^2.0.0",
85 - "shiki": "^4.0.2",
85 + "shiki": "^4.1.0",
86 "thememirror": "^2.0.1",
87 "validator": "^13.15.35",
88 "vue": "^3.5.34",
89 "vue-advanced-cropper": "^2.8.9",
90 "vue-codemirror": "^6.1.1",
91 + "vue-echarts": "^8.0.1",
92 "vue-highlight-words": "^3.0.1",
93 "vue-i18n": "^11.4.4",
94 "vue-router": "^5.0.7",
@@ -115,38 +116,38 @@
116 "@types/jsdom": "^28.0.3",
117 "@types/lodash": "^4.17.24",
118 "@types/markdown-it": "^14.1.2",
118 - "@types/node": "^25.9.0",
119 + "@types/node": "^25.9.1",
120 "@types/papaparse": "^5.5.2",
120 - "@types/react": "^19.2.14",
121 + "@types/react": "^19.2.15",
122 "@types/react-dom": "^19.2.3",
123 "@types/validator": "^13.15.10",
124 "@vitejs/plugin-vue": "^6.0.7",
125 "@vitejs/plugin-vue-jsx": "^5.1.5",
126 "@vue/test-utils": "^2.4.10",
127 "@vue/tsconfig": "~0.9.1",
127 - "baseline-browser-mapping": "^2.10.31",
128 + "baseline-browser-mapping": "^2.10.32",
129 "depcheck": "^1.4.7",
130 "eslint": "~10.4.0",
131 "flourite": "^1.3.0",
132 "fs-extra": "^11.3.5",
132 - "jscpd": "^4.2.3",
133 + "jscpd": "^4.2.4",
134 "jsdom": "^29.1.1",
134 - "npm-run-all2": "^8.0.4",
135 + "npm-run-all2": "^9.0.1",
136 "prettier": "^3.8.3",
137 "prettier-plugin-tailwindcss": "^0.8.0",
137 - "rolldown": "1.0.1",
138 - "sass": "^1.99.0",
138 + "rolldown": "1.0.2",
139 + "sass": "^1.100.0",
140 "start-server-and-test": "^3.0.5",
141 "tailwindcss": "^4.3.0",
141 - "taze": "^19.12.0",
142 + "taze": "^19.14.1",
143 "type-fest": "^5.6.0",
144 "typescript": "~6.0.3",
144 - "vite": "^8.0.13",
145 + "vite": "8.0.13",
146 "vite-bundle-visualizer": "^1.2.1",
147 "vite-plugin-inspect": "^11.3.3",
148 "vite-plugin-vue-devtools": "^8.1.2",
149 "vite-svg-loader": "^5.1.1",
149 - "vitest": "^4.1.6",
150 - "vue-tsc": "^3.3.0"
150 + "vitest": "^4.1.7",
151 + "vue-tsc": "^3.3.2"
152 }
153 }
\ No newline at end of file
frontend/pnpm-lock.yaml
+991 -824
@@ -52,13 +52,13 @@ importers:
52 version: 2.0.1
53 '@mui/material':
54 specifier: ^9.0.1
55 - version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
55 + version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
56 '@shikijs/markdown-it':
57 - specifier: ^4.0.2
58 - version: 4.0.2
57 + specifier: ^4.1.0
58 + version: 4.1.0
59 '@shuffleio/shuffle-mcps':
60 - specifier: ^0.0.13
61 - version: 0.0.13(@emotion/is-prop-valid@1.4.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/react@19.2.14)(lucide-react@1.16.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
60 + specifier: ^0.0.26
61 + version: 0.0.26(@emotion/is-prop-valid@1.4.0)(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
62 '@types/codemirror':
63 specifier: ^5.60.17
64 version: 5.60.17
@@ -72,8 +72,8 @@ importers:
72 specifier: ^5.52.1
73 version: 5.52.1
74 apexcharts:
75 - specifier: ^5.12.0
76 - version: 5.12.0
75 + specifier: ^5.13.0
76 + version: 5.13.0
77 axios:
78 specifier: ^1.16.1
79 version: 1.16.1(debug@4.4.3)
@@ -93,8 +93,8 @@ importers:
93 specifier: ^1.1.6
94 version: 1.1.6
95 echarts:
96 - specifier: ^6.0.0
97 - version: 6.0.0
96 + specifier: ^6.1.0
97 + version: 6.1.0
98 fast-xml-parser:
99 specifier: ^5.8.0
100 version: 5.8.0
@@ -133,7 +133,7 @@ importers:
133 version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
134 pinia-plugin-persistedstate:
135 specifier: ^4.7.1
136 - version: 4.7.1(@nuxt/kit@3.21.5)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))
136 + version: 4.7.1(@nuxt/kit@3.21.6)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))
137 react:
138 specifier: ^19.2.6
139 version: 19.2.6
@@ -153,8 +153,8 @@ importers:
153 specifier: ^2.0.0
154 version: 2.0.0
155 shiki:
156 - specifier: ^4.0.2
157 - version: 4.0.2
156 + specifier: ^4.1.0
157 + version: 4.1.0
158 thememirror:
159 specifier: ^2.0.1
160 version: 2.0.1(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
@@ -170,6 +170,9 @@ importers:
170 vue-codemirror:
171 specifier: ^6.1.1
172 version: 6.1.1(codemirror@6.0.2)(vue@3.5.34(typescript@6.0.3))
173 + vue-echarts:
174 + specifier: ^8.0.1
175 + version: 8.0.1(echarts@6.1.0)(vue@3.5.34(typescript@6.0.3))
176 vue-highlight-words:
177 specifier: ^3.0.1
178 version: 3.0.1(vue@3.5.34(typescript@6.0.3))
@@ -184,7 +187,7 @@ importers:
187 version: 0.0.6(vue@3.5.34(typescript@6.0.3))
188 vue3-apexcharts:
189 specifier: ^1.11.1
187 - version: 1.11.1(apexcharts@5.12.0)(vue@3.5.34(typescript@6.0.3))
190 + version: 1.11.1(apexcharts@5.13.0)(vue@3.5.34(typescript@6.0.3))
191 vue3-marquee:
192 specifier: ^4.2.2
193 version: 4.2.2(vue@3.5.34(typescript@6.0.3))
@@ -196,11 +199,11 @@ importers:
199 version: 0.1.1
200 xmllint-wasm:
201 specifier: ^5.2.0
199 - version: 5.2.0(@types/node@25.9.0)
202 + version: 5.2.0(@types/node@25.9.1)
203 devDependencies:
204 '@antfu/eslint-config':
205 specifier: ~9.0.0
203 - version: 9.0.0(@typescript-eslint/rule-tester@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3))(@typescript-eslint/utils@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)))
206 + version: 9.0.0(@typescript-eslint/rule-tester@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.60.0(typescript@6.0.3))(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)))
207 '@clack/prompts':
208 specifier: ^1.4.0
209 version: 1.4.0
@@ -209,7 +212,7 @@ importers:
212 version: 5.0.1(vue@3.5.34(typescript@6.0.3))
213 '@tailwindcss/vite':
214 specifier: ^4.3.0
212 - version: 4.3.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
215 + version: 4.3.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
216 '@tsconfig/node20':
217 specifier: ^20.1.9
218 version: 20.1.9
@@ -232,26 +235,26 @@ importers:
235 specifier: ^14.1.2
236 version: 14.1.2
237 '@types/node':
235 - specifier: ^25.9.0
236 - version: 25.9.0
238 + specifier: ^25.9.1
239 + version: 25.9.1
240 '@types/papaparse':
241 specifier: ^5.5.2
242 version: 5.5.2
243 '@types/react':
241 - specifier: ^19.2.14
242 - version: 19.2.14
244 + specifier: ^19.2.15
245 + version: 19.2.15
246 '@types/react-dom':
247 specifier: ^19.2.3
245 - version: 19.2.3(@types/react@19.2.14)
248 + version: 19.2.3(@types/react@19.2.15)
249 '@types/validator':
250 specifier: ^13.15.10
251 version: 13.15.10
252 '@vitejs/plugin-vue':
253 specifier: ^6.0.7
251 - version: 6.0.7(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
254 + version: 6.0.7(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
255 '@vitejs/plugin-vue-jsx':
256 specifier: ^5.1.5
254 - version: 5.1.5(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
257 + version: 5.1.5(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
258 '@vue/test-utils':
259 specifier: ^2.4.10
260 version: 2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
@@ -259,8 +262,8 @@ importers:
262 specifier: ~0.9.1
263 version: 0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
264 baseline-browser-mapping:
262 - specifier: ^2.10.31
263 - version: 2.10.31
265 + specifier: ^2.10.32
266 + version: 2.10.32
267 depcheck:
268 specifier: ^1.4.7
269 version: 1.4.7
@@ -274,14 +277,14 @@ importers:
277 specifier: ^11.3.5
278 version: 11.3.5
279 jscpd:
277 - specifier: ^4.2.3
278 - version: 4.2.3
280 + specifier: ^4.2.4
281 + version: 4.2.4
282 jsdom:
283 specifier: ^29.1.1
284 version: 29.1.1
285 npm-run-all2:
283 - specifier: ^8.0.4
284 - version: 8.0.4
286 + specifier: ^9.0.1
287 + version: 9.0.1
288 prettier:
289 specifier: ^3.8.3
290 version: 3.8.3
@@ -289,11 +292,11 @@ importers:
292 specifier: ^0.8.0
293 version: 0.8.0(prettier@3.8.3)
294 rolldown:
292 - specifier: 1.0.1
293 - version: 1.0.1
295 + specifier: 1.0.2
296 + version: 1.0.2
297 sass:
295 - specifier: ^1.99.0
296 - version: 1.99.0
298 + specifier: ^1.100.0
299 + version: 1.100.0
300 start-server-and-test:
301 specifier: ^3.0.5
302 version: 3.0.5
@@ -301,8 +304,8 @@ importers:
304 specifier: ^4.3.0
305 version: 4.3.0
306 taze:
304 - specifier: ^19.12.0
305 - version: 19.12.0
307 + specifier: ^19.14.1
308 + version: 19.14.1
309 type-fest:
310 specifier: ^5.6.0
311 version: 5.6.0
@@ -310,26 +313,26 @@ importers:
313 specifier: ~6.0.3
314 version: 6.0.3
315 vite:
313 - specifier: ^8.0.13
314 - version: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
316 + specifier: 8.0.13
317 + version: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
318 vite-bundle-visualizer:
319 specifier: ^1.2.1
317 - version: 1.2.1(rolldown@1.0.1)
320 + version: 1.2.1(rolldown@1.0.2)
321 vite-plugin-inspect:
322 specifier: ^11.3.3
320 - version: 11.3.3(@nuxt/kit@3.21.5)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
323 + version: 11.3.3(@nuxt/kit@3.21.6)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
324 vite-plugin-vue-devtools:
325 specifier: ^8.1.2
323 - version: 8.1.2(@nuxt/kit@3.21.5)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
326 + version: 8.1.2(@nuxt/kit@3.21.6)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
327 vite-svg-loader:
328 specifier: ^5.1.1
329 version: 5.1.1(vue@3.5.34(typescript@6.0.3))
330 vitest:
328 - specifier: ^4.1.6
329 - version: 4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
331 + specifier: ^4.1.7
332 + version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
333 vue-tsc:
331 - specifier: ^3.3.0
332 - version: 3.3.0(typescript@6.0.3)
334 + specifier: ^3.3.2
335 + version: 3.3.2(typescript@6.0.3)
336 optionalDependencies:
337 '@rollup/rollup-linux-x64-gnu':
338 specifier: ^4.46.2
@@ -486,124 +489,124 @@ packages:
489 '@asamuzakjp/nwsapi@2.3.9':
490 resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
491
489 - '@babel/code-frame@7.29.0':
490 - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
492 + '@babel/code-frame@7.29.7':
493 + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
494 engines: {node: '>=6.9.0'}
495
493 - '@babel/compat-data@7.29.3':
494 - resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
496 + '@babel/compat-data@7.29.7':
497 + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
498 engines: {node: '>=6.9.0'}
499
497 - '@babel/core@7.29.0':
498 - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
500 + '@babel/core@7.29.7':
501 + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
502 engines: {node: '>=6.9.0'}
503
501 - '@babel/generator@7.29.1':
502 - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
504 + '@babel/generator@7.29.7':
505 + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
506 engines: {node: '>=6.9.0'}
507
505 - '@babel/generator@8.0.0-rc.5':
506 - resolution: {integrity: sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==}
508 + '@babel/generator@8.0.0-rc.6':
509 + resolution: {integrity: sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==}
510 engines: {node: ^22.18.0 || >=24.11.0}
511
509 - '@babel/helper-annotate-as-pure@7.27.3':
510 - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
512 + '@babel/helper-annotate-as-pure@7.29.7':
513 + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==}
514 engines: {node: '>=6.9.0'}
515
513 - '@babel/helper-compilation-targets@7.28.6':
514 - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
516 + '@babel/helper-compilation-targets@7.29.7':
517 + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
518 engines: {node: '>=6.9.0'}
519
517 - '@babel/helper-create-class-features-plugin@7.29.3':
518 - resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==}
520 + '@babel/helper-create-class-features-plugin@7.29.7':
521 + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==}
522 engines: {node: '>=6.9.0'}
523 peerDependencies:
524 '@babel/core': ^7.0.0
525
523 - '@babel/helper-globals@7.28.0':
524 - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
526 + '@babel/helper-globals@7.29.7':
527 + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
528 engines: {node: '>=6.9.0'}
529
527 - '@babel/helper-member-expression-to-functions@7.28.5':
528 - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
530 + '@babel/helper-member-expression-to-functions@7.29.7':
531 + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==}
532 engines: {node: '>=6.9.0'}
533
531 - '@babel/helper-module-imports@7.28.6':
532 - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
534 + '@babel/helper-module-imports@7.29.7':
535 + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
536 engines: {node: '>=6.9.0'}
537
535 - '@babel/helper-module-transforms@7.28.6':
536 - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
538 + '@babel/helper-module-transforms@7.29.7':
539 + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
540 engines: {node: '>=6.9.0'}
541 peerDependencies:
542 '@babel/core': ^7.0.0
543
541 - '@babel/helper-optimise-call-expression@7.27.1':
542 - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==}
544 + '@babel/helper-optimise-call-expression@7.29.7':
545 + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==}
546 engines: {node: '>=6.9.0'}
547
545 - '@babel/helper-plugin-utils@7.28.6':
546 - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
548 + '@babel/helper-plugin-utils@7.29.7':
549 + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
550 engines: {node: '>=6.9.0'}
551
549 - '@babel/helper-replace-supers@7.28.6':
550 - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==}
552 + '@babel/helper-replace-supers@7.29.7':
553 + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==}
554 engines: {node: '>=6.9.0'}
555 peerDependencies:
556 '@babel/core': ^7.0.0
557
555 - '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
556 - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
558 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
559 + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==}
560 engines: {node: '>=6.9.0'}
561
559 - '@babel/helper-string-parser@7.27.1':
560 - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
562 + '@babel/helper-string-parser@7.29.7':
563 + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
564 engines: {node: '>=6.9.0'}
565
563 - '@babel/helper-string-parser@8.0.0-rc.5':
564 - resolution: {integrity: sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==}
566 + '@babel/helper-string-parser@8.0.0-rc.6':
567 + resolution: {integrity: sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g==}
568 engines: {node: ^22.18.0 || >=24.11.0}
569
567 - '@babel/helper-validator-identifier@7.28.5':
568 - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
570 + '@babel/helper-validator-identifier@7.29.7':
571 + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
572 engines: {node: '>=6.9.0'}
573
571 - '@babel/helper-validator-identifier@8.0.0-rc.5':
572 - resolution: {integrity: sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==}
574 + '@babel/helper-validator-identifier@8.0.0-rc.6':
575 + resolution: {integrity: sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==}
576 engines: {node: ^22.18.0 || >=24.11.0}
577
575 - '@babel/helper-validator-option@7.27.1':
576 - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
578 + '@babel/helper-validator-option@7.29.7':
579 + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
580 engines: {node: '>=6.9.0'}
581
579 - '@babel/helpers@7.29.2':
580 - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
582 + '@babel/helpers@7.29.7':
583 + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
584 engines: {node: '>=6.9.0'}
585
583 - '@babel/parser@7.29.3':
584 - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
586 + '@babel/parser@7.29.7':
587 + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
588 engines: {node: '>=6.0.0'}
589 hasBin: true
590
588 - '@babel/parser@8.0.0-rc.5':
589 - resolution: {integrity: sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==}
591 + '@babel/parser@8.0.0-rc.6':
592 + resolution: {integrity: sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==}
593 engines: {node: ^22.18.0 || >=24.11.0}
594 hasBin: true
595
593 - '@babel/plugin-proposal-decorators@7.29.0':
594 - resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==}
596 + '@babel/plugin-proposal-decorators@7.29.7':
597 + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==}
598 engines: {node: '>=6.9.0'}
599 peerDependencies:
600 '@babel/core': ^7.0.0-0
601
599 - '@babel/plugin-syntax-decorators@7.28.6':
600 - resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==}
602 + '@babel/plugin-syntax-decorators@7.29.7':
603 + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==}
604 engines: {node: '>=6.9.0'}
605 peerDependencies:
606 '@babel/core': ^7.0.0-0
607
605 - '@babel/plugin-syntax-import-attributes@7.28.6':
606 - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==}
608 + '@babel/plugin-syntax-import-attributes@7.29.7':
609 + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==}
610 engines: {node: '>=6.9.0'}
611 peerDependencies:
612 '@babel/core': ^7.0.0-0
@@ -613,42 +616,42 @@ packages:
616 peerDependencies:
617 '@babel/core': ^7.0.0-0
618
616 - '@babel/plugin-syntax-jsx@7.28.6':
617 - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
619 + '@babel/plugin-syntax-jsx@7.29.7':
620 + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
621 engines: {node: '>=6.9.0'}
622 peerDependencies:
623 '@babel/core': ^7.0.0-0
624
622 - '@babel/plugin-syntax-typescript@7.28.6':
623 - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==}
625 + '@babel/plugin-syntax-typescript@7.29.7':
626 + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==}
627 engines: {node: '>=6.9.0'}
628 peerDependencies:
629 '@babel/core': ^7.0.0-0
630
628 - '@babel/plugin-transform-typescript@7.28.6':
629 - resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==}
631 + '@babel/plugin-transform-typescript@7.29.7':
632 + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==}
633 engines: {node: '>=6.9.0'}
634 peerDependencies:
635 '@babel/core': ^7.0.0-0
636
634 - '@babel/runtime@7.29.2':
635 - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
637 + '@babel/runtime@7.29.7':
638 + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
639 engines: {node: '>=6.9.0'}
640
638 - '@babel/template@7.28.6':
639 - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
641 + '@babel/template@7.29.7':
642 + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
643 engines: {node: '>=6.9.0'}
644
642 - '@babel/traverse@7.29.0':
643 - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
645 + '@babel/traverse@7.29.7':
646 + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
647 engines: {node: '>=6.9.0'}
648
646 - '@babel/types@7.29.0':
647 - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
649 + '@babel/types@7.29.7':
650 + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
651 engines: {node: '>=6.9.0'}
652
650 - '@babel/types@8.0.0-rc.5':
651 - resolution: {integrity: sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==}
653 + '@babel/types@8.0.0-rc.6':
654 + resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==}
655 engines: {node: ^22.18.0 || >=24.11.0}
656
657 '@bramus/specificity@2.4.2':
@@ -873,8 +876,8 @@ packages:
876 resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
877 engines: {node: ^20.19.0 || ^22.13.0 || >=24}
878
876 - '@eslint/markdown@8.0.1':
877 - resolution: {integrity: sha512-WWKmld/EyNdEB8GMq7JMPX1SDWgyJAM1uhtCi5ySrqYQM4HQjmg11EX/q3ZpnpRXHfdccFtli3NBvvGaYjWyQw==}
879 + '@eslint/markdown@8.0.2':
880 + resolution: {integrity: sha512-W+/0qHp0WbvFEljUvvECNpSWrUHpBWIWwp7F3QqEwQKmaRCmfEWvk6VfUia9pTQ0th6HyBGBsPfg/kG3/aQxLA==}
881 engines: {node: ^20.19.0 || ^22.13.0 || >=24}
882
883 '@eslint/object-schema@3.0.5':
@@ -889,8 +892,8 @@ packages:
892 resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
893 engines: {node: ^20.19.0 || ^22.13.0 || >=24}
894
892 - '@exodus/bytes@1.15.0':
893 - resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
895 + '@exodus/bytes@1.15.1':
896 + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
897 engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
898 peerDependencies:
899 '@noble/hashes': ^1.8.0 || ^2.0.0
@@ -999,20 +1002,20 @@ packages:
1002 '@jridgewell/trace-mapping@0.3.31':
1003 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
1004
1002 - '@jscpd/badge-reporter@4.2.3':
1003 - resolution: {integrity: sha512-yNvbwWl/NwogHT5XrHyqXgF9yVZeLWA2QOhGqYTopvgi7LsSbDumpOqOcJMHP9Z4RalhMfahh+dVXFSI7tMcaA==}
1005 + '@jscpd/badge-reporter@4.2.4':
1006 + resolution: {integrity: sha512-g5vu05u0lX9rcHA0k3CptLfpOiuMzxh5+mUe2iYRAznTwH3ks6JAVAf9aPi5mBFttMCRiJh2zSt3xnSadHtMGg==}
1007
1005 - '@jscpd/core@4.2.3':
1006 - resolution: {integrity: sha512-VQ2gH+tiI51ty3PBRD4HClNNgyX/VH9cs0dcFKuywxDzLQ64jYp7vhJPcqnyiVX9tVEIAa12sucRHQP/VHwugA==}
1008 + '@jscpd/core@4.2.4':
1009 + resolution: {integrity: sha512-9V9YzmmhYg9682kFqi+n0KGOhXNSoqxHbuIP3i/l/oSd6upBOnnSeBWDZMGOenQRQnyKEtCIbnS9YFz+3B+siQ==}
1010
1008 - '@jscpd/finder@4.2.3':
1009 - resolution: {integrity: sha512-ZpjviFAg6zLojQHS+owvrn8DG1OY1d4835Je4LUKzbMurndmQDhvRRFDkN9V6xPn6gvRaMVkJHN2tyljsnUjWA==}
1011 + '@jscpd/finder@4.2.4':
1012 + resolution: {integrity: sha512-4LLEuAAmAraud/TAAlB5BByVdWfy7SYiPKacj5yEggpkNs0qsw2kiZ5EyU3LonB+/vntJJEDDpJMmvOeS58e0A==}
1013
1011 - '@jscpd/html-reporter@4.2.3':
1012 - resolution: {integrity: sha512-kp1pqJXCKwyRu5mJK5IvXdFQEDHWQDb7svLFlbVXGI0dVH1y1XNl8mrIrSoRw+0AySxhDkuSyIlQOSDC2GRwQg==}
1014 + '@jscpd/html-reporter@4.2.4':
1015 + resolution: {integrity: sha512-6UljCTVGf7O+o6D6fs1zNBG+vR1PTn47W2mSgb5hzSrvNw60rLrVoAMZMnr/TeIEdd/OEgAu+icbdvvVBfnvJw==}
1016
1014 - '@jscpd/tokenizer@4.2.3':
1015 - resolution: {integrity: sha512-RvjD7/hwqtcQC9MWOl31odTti6kGCFxZ77DKEhwyMn+r6oVEUFbXgcGvzn0GC/wuTl7f3j5MF9JNMeTneOFwYA==}
1017 + '@jscpd/tokenizer@4.2.4':
1018 + resolution: {integrity: sha512-nM4kGyDvpcevt8t0zOsMQ82ShSc65c3LIQUHClTYwraiOGOmWgUQyen+JIiFCNF8eDCGR2Qa5iI5XBfGWYQzIg==}
1019
1020 '@juggle/resize-observer@3.4.0':
1021 resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
@@ -1139,8 +1142,8 @@ packages:
1142 resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
1143 engines: {node: '>= 8'}
1144
1142 - '@nuxt/kit@3.21.5':
1143 - resolution: {integrity: sha512-eGo9DjJ9NzKMbJpFU/UTd4c5iOSYuivghKD8W/jVGHs7kew+hdSMvUy401IfQB7EObKPvt/WXEutAIaTg9OsyA==}
1145 + '@nuxt/kit@3.21.6':
1146 + resolution: {integrity: sha512-5VOwxUcoM/z6w4c75hQrikHpY+TzjTLZQ+QnuO7KajyGx0IJBLVy1lw25oy79leF+GgyjJJO1cHfUfWeuEDCzA==}
1147 engines: {node: '>=18.12.0'}
1148
1149 '@one-ini/wasm@0.1.1':
@@ -1153,6 +1156,9 @@ packages:
1156 '@oxc-project/types@0.130.0':
1157 resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
1158
1159 + '@oxc-project/types@0.132.0':
1160 + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
1161 +
1162 '@parcel/watcher-android-arm64@2.5.6':
1163 resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
1164 engines: {node: '>= 10.0.0'}
@@ -1264,30 +1270,60 @@ packages:
1270 cpu: [arm64]
1271 os: [android]
1272
1273 + '@rolldown/binding-android-arm64@1.0.2':
1274 + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
1275 + engines: {node: ^20.19.0 || >=22.12.0}
1276 + cpu: [arm64]
1277 + os: [android]
1278 +
1279 '@rolldown/binding-darwin-arm64@1.0.1':
1280 resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==}
1281 engines: {node: ^20.19.0 || >=22.12.0}
1282 cpu: [arm64]
1283 os: [darwin]
1284
1285 + '@rolldown/binding-darwin-arm64@1.0.2':
1286 + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
1287 + engines: {node: ^20.19.0 || >=22.12.0}
1288 + cpu: [arm64]
1289 + os: [darwin]
1290 +
1291 '@rolldown/binding-darwin-x64@1.0.1':
1292 resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==}
1293 engines: {node: ^20.19.0 || >=22.12.0}
1294 cpu: [x64]
1295 os: [darwin]
1296
1297 + '@rolldown/binding-darwin-x64@1.0.2':
1298 + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
1299 + engines: {node: ^20.19.0 || >=22.12.0}
1300 + cpu: [x64]
1301 + os: [darwin]
1302 +
1303 '@rolldown/binding-freebsd-x64@1.0.1':
1304 resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==}
1305 engines: {node: ^20.19.0 || >=22.12.0}
1306 cpu: [x64]
1307 os: [freebsd]
1308
1309 + '@rolldown/binding-freebsd-x64@1.0.2':
1310 + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
1311 + engines: {node: ^20.19.0 || >=22.12.0}
1312 + cpu: [x64]
1313 + os: [freebsd]
1314 +
1315 '@rolldown/binding-linux-arm-gnueabihf@1.0.1':
1316 resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==}
1317 engines: {node: ^20.19.0 || >=22.12.0}
1318 cpu: [arm]
1319 os: [linux]
1320
1321 + '@rolldown/binding-linux-arm-gnueabihf@1.0.2':
1322 + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
1323 + engines: {node: ^20.19.0 || >=22.12.0}
1324 + cpu: [arm]
1325 + os: [linux]
1326 +
1327 '@rolldown/binding-linux-arm64-gnu@1.0.1':
1328 resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
1329 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1295,6 +1331,13 @@ packages:
1331 os: [linux]
1332 libc: [glibc]
1333
1334 + '@rolldown/binding-linux-arm64-gnu@1.0.2':
1335 + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
1336 + engines: {node: ^20.19.0 || >=22.12.0}
1337 + cpu: [arm64]
1338 + os: [linux]
1339 + libc: [glibc]
1340 +
1341 '@rolldown/binding-linux-arm64-musl@1.0.1':
1342 resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
1343 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1302,6 +1345,13 @@ packages:
1345 os: [linux]
1346 libc: [musl]
1347
1348 + '@rolldown/binding-linux-arm64-musl@1.0.2':
1349 + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
1350 + engines: {node: ^20.19.0 || >=22.12.0}
1351 + cpu: [arm64]
1352 + os: [linux]
1353 + libc: [musl]
1354 +
1355 '@rolldown/binding-linux-ppc64-gnu@1.0.1':
1356 resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
1357 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1309,6 +1359,13 @@ packages:
1359 os: [linux]
1360 libc: [glibc]
1361
1362 + '@rolldown/binding-linux-ppc64-gnu@1.0.2':
1363 + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
1364 + engines: {node: ^20.19.0 || >=22.12.0}
1365 + cpu: [ppc64]
1366 + os: [linux]
1367 + libc: [glibc]
1368 +
1369 '@rolldown/binding-linux-s390x-gnu@1.0.1':
1370 resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
1371 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1316,6 +1373,13 @@ packages:
1373 os: [linux]
1374 libc: [glibc]
1375
1376 + '@rolldown/binding-linux-s390x-gnu@1.0.2':
1377 + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
1378 + engines: {node: ^20.19.0 || >=22.12.0}
1379 + cpu: [s390x]
1380 + os: [linux]
1381 + libc: [glibc]
1382 +
1383 '@rolldown/binding-linux-x64-gnu@1.0.1':
1384 resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
1385 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1323,6 +1387,13 @@ packages:
1387 os: [linux]
1388 libc: [glibc]
1389
1390 + '@rolldown/binding-linux-x64-gnu@1.0.2':
1391 + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
1392 + engines: {node: ^20.19.0 || >=22.12.0}
1393 + cpu: [x64]
1394 + os: [linux]
1395 + libc: [glibc]
1396 +
1397 '@rolldown/binding-linux-x64-musl@1.0.1':
1398 resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
1399 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1330,29 +1401,59 @@ packages:
1401 os: [linux]
1402 libc: [musl]
1403
1404 + '@rolldown/binding-linux-x64-musl@1.0.2':
1405 + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
1406 + engines: {node: ^20.19.0 || >=22.12.0}
1407 + cpu: [x64]
1408 + os: [linux]
1409 + libc: [musl]
1410 +
1411 '@rolldown/binding-openharmony-arm64@1.0.1':
1412 resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
1413 engines: {node: ^20.19.0 || >=22.12.0}
1414 cpu: [arm64]
1415 os: [openharmony]
1416
1417 + '@rolldown/binding-openharmony-arm64@1.0.2':
1418 + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
1419 + engines: {node: ^20.19.0 || >=22.12.0}
1420 + cpu: [arm64]
1421 + os: [openharmony]
1422 +
1423 '@rolldown/binding-wasm32-wasi@1.0.1':
1424 resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==}
1425 engines: {node: ^20.19.0 || >=22.12.0}
1426 cpu: [wasm32]
1427
1428 + '@rolldown/binding-wasm32-wasi@1.0.2':
1429 + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
1430 + engines: {node: ^20.19.0 || >=22.12.0}
1431 + cpu: [wasm32]
1432 +
1433 '@rolldown/binding-win32-arm64-msvc@1.0.1':
1434 resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==}
1435 engines: {node: ^20.19.0 || >=22.12.0}
1436 cpu: [arm64]
1437 os: [win32]
1438
1439 + '@rolldown/binding-win32-arm64-msvc@1.0.2':
1440 + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
1441 + engines: {node: ^20.19.0 || >=22.12.0}
1442 + cpu: [arm64]
1443 + os: [win32]
1444 +
1445 '@rolldown/binding-win32-x64-msvc@1.0.1':
1446 resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==}
1447 engines: {node: ^20.19.0 || >=22.12.0}
1448 cpu: [x64]
1449 os: [win32]
1450
1451 + '@rolldown/binding-win32-x64-msvc@1.0.2':
1452 + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
1453 + engines: {node: ^20.19.0 || >=22.12.0}
1454 + cpu: [x64]
1455 + os: [win32]
1456 +
1457 '@rolldown/pluginutils@1.0.1':
1458 resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
1459
@@ -1362,24 +1463,24 @@ packages:
1463 os: [linux]
1464 libc: [glibc]
1465
1365 - '@shikijs/core@4.0.2':
1366 - resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
1466 + '@shikijs/core@4.1.0':
1467 + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
1468 engines: {node: '>=20'}
1469
1369 - '@shikijs/engine-javascript@4.0.2':
1370 - resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
1470 + '@shikijs/engine-javascript@4.1.0':
1471 + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
1472 engines: {node: '>=20'}
1473
1373 - '@shikijs/engine-oniguruma@4.0.2':
1374 - resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
1474 + '@shikijs/engine-oniguruma@4.1.0':
1475 + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
1476 engines: {node: '>=20'}
1477
1377 - '@shikijs/langs@4.0.2':
1378 - resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
1478 + '@shikijs/langs@4.1.0':
1479 + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
1480 engines: {node: '>=20'}
1481
1381 - '@shikijs/markdown-it@4.0.2':
1382 - resolution: {integrity: sha512-7DDEhknj/mXTN7ME8CjKWBv5O/4YgOiJBZLgs/NbUFMC7Ik1x/VEhaK+aBjX60bJdok0E2mxEYan/GzJ2xRx+A==}
1482 + '@shikijs/markdown-it@4.1.0':
1483 + resolution: {integrity: sha512-qGtTnWF3FG/fDS2wMkE5abMYeGm1l1VVjSUoEOhtr13xk5CtqrdVorEYwKnrLCSpeTjU+j3lY/fWHEJI+m8n6g==}
1484 engines: {node: '>=20'}
1485 peerDependencies:
1486 markdown-it-async: ^2.2.0
@@ -1387,23 +1488,23 @@ packages:
1488 markdown-it-async:
1489 optional: true
1490
1390 - '@shikijs/primitive@4.0.2':
1391 - resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
1491 + '@shikijs/primitive@4.1.0':
1492 + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
1493 engines: {node: '>=20'}
1494
1394 - '@shikijs/themes@4.0.2':
1395 - resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
1495 + '@shikijs/themes@4.1.0':
1496 + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
1497 engines: {node: '>=20'}
1498
1398 - '@shikijs/types@4.0.2':
1399 - resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
1499 + '@shikijs/types@4.1.0':
1500 + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
1501 engines: {node: '>=20'}
1502
1503 '@shikijs/vscode-textmate@10.0.2':
1504 resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
1505
1405 - '@shuffleio/shuffle-mcps@0.0.13':
1406 - resolution: {integrity: sha512-Fs26fqOYorPZ39LCvFbKS4vfy9k7Wp5A3dm6xzb3NSP2IYmZMp9iwYKpNAlOPeC2SE/a7lR0KqNXwicQxhXoNQ==}
1506 + '@shuffleio/shuffle-mcps@0.0.26':
1507 + resolution: {integrity: sha512-FycWJX2I6ILNOnVxtkTJy5KcUxzgD+uuZr6EsQwns5Mm0lmCLUQwdtRlZjfM7/ZOwdTPv305KCH0+vwtxZIdIA==}
1508 peerDependencies:
1509 '@emotion/react': '>=11'
1510 '@emotion/styled': '>=11'
@@ -1598,11 +1699,8 @@ packages:
1699 '@types/ms@2.1.0':
1700 resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
1701
1601 - '@types/node@25.8.0':
1602 - resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==}
1603 -
1604 - '@types/node@25.9.0':
1605 - resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
1702 + '@types/node@25.9.1':
1703 + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
1704
1705 '@types/papaparse@5.5.2':
1706 resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
@@ -1623,8 +1721,8 @@ packages:
1721 peerDependencies:
1722 '@types/react': '*'
1723
1626 - '@types/react@19.2.14':
1627 - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
1724 + '@types/react@19.2.15':
1725 + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==}
1726
1727 '@types/sarif@2.1.7':
1728 resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==}
@@ -1647,70 +1745,70 @@ packages:
1745 '@types/web-bluetooth@0.0.21':
1746 resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
1747
1650 - '@typescript-eslint/eslint-plugin@8.59.3':
1651 - resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
1748 + '@typescript-eslint/eslint-plugin@8.60.0':
1749 + resolution: {integrity: sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==}
1750 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1751 peerDependencies:
1654 - '@typescript-eslint/parser': ^8.59.3
1752 + '@typescript-eslint/parser': ^8.60.0
1753 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1754 typescript: '>=4.8.4 <6.1.0'
1755
1658 - '@typescript-eslint/parser@8.59.3':
1659 - resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==}
1756 + '@typescript-eslint/parser@8.60.0':
1757 + resolution: {integrity: sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==}
1758 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1759 peerDependencies:
1760 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1761 typescript: '>=4.8.4 <6.1.0'
1762
1665 - '@typescript-eslint/project-service@8.59.3':
1666 - resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==}
1763 + '@typescript-eslint/project-service@8.60.0':
1764 + resolution: {integrity: sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==}
1765 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1766 peerDependencies:
1767 typescript: '>=4.8.4 <6.1.0'
1768
1671 - '@typescript-eslint/rule-tester@8.59.3':
1672 - resolution: {integrity: sha512-GH5g42Dt0IvM/G4ojD4JoOJaZCmogsQBGTSNExRa1ND+sDLI1SVppzYX66+xLIX/jFhcJRpYhiBOhHJEpLtgkA==}
1769 + '@typescript-eslint/rule-tester@8.60.0':
1770 + resolution: {integrity: sha512-lQct+GlpO4JKKxZbo+ZTWjnxA2BqSoOWmJYvQxezuJGGpl6Lk2XTd2odVP/7lAJUQaw3Qip+qo/1gsAda4jGlA==}
1771 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1772 peerDependencies:
1773 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1774 typescript: '>=4.8.4 <6.1.0'
1775
1678 - '@typescript-eslint/scope-manager@8.59.3':
1679 - resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==}
1776 + '@typescript-eslint/scope-manager@8.60.0':
1777 + resolution: {integrity: sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==}
1778 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1779
1682 - '@typescript-eslint/tsconfig-utils@8.59.3':
1683 - resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==}
1780 + '@typescript-eslint/tsconfig-utils@8.60.0':
1781 + resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==}
1782 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1783 peerDependencies:
1784 typescript: '>=4.8.4 <6.1.0'
1785
1688 - '@typescript-eslint/type-utils@8.59.3':
1689 - resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==}
1786 + '@typescript-eslint/type-utils@8.60.0':
1787 + resolution: {integrity: sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==}
1788 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1789 peerDependencies:
1790 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1791 typescript: '>=4.8.4 <6.1.0'
1792
1695 - '@typescript-eslint/types@8.59.3':
1696 - resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==}
1793 + '@typescript-eslint/types@8.60.0':
1794 + resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==}
1795 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1796
1699 - '@typescript-eslint/typescript-estree@8.59.3':
1700 - resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==}
1797 + '@typescript-eslint/typescript-estree@8.60.0':
1798 + resolution: {integrity: sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==}
1799 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1800 peerDependencies:
1801 typescript: '>=4.8.4 <6.1.0'
1802
1705 - '@typescript-eslint/utils@8.59.3':
1706 - resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==}
1803 + '@typescript-eslint/utils@8.60.0':
1804 + resolution: {integrity: sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==}
1805 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1806 peerDependencies:
1807 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1808 typescript: '>=4.8.4 <6.1.0'
1809
1712 - '@typescript-eslint/visitor-keys@8.59.3':
1713 - resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==}
1810 + '@typescript-eslint/visitor-keys@8.60.0':
1811 + resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==}
1812 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1813
1814 '@ungap/structured-clone@1.3.1':
@@ -1730,8 +1828,8 @@ packages:
1828 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
1829 vue: ^3.2.25
1830
1733 - '@vitest/eslint-plugin@1.6.17':
1734 - resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==}
1831 + '@vitest/eslint-plugin@1.6.18':
1832 + resolution: {integrity: sha512-J6U4X0jH3NwTuYouvrJn6I8ypTOU+GhKEjyVwpoPnDuc23usa/xi/R0caWLBbNp3xLy3/rL1YkuJuneTMVV4Mg==}
1833 engines: {node: '>=18'}
1834 peerDependencies:
1835 '@typescript-eslint/eslint-plugin': '*'
@@ -1746,11 +1844,11 @@ packages:
1844 vitest:
1845 optional: true
1846
1749 - '@vitest/expect@4.1.6':
1750 - resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
1847 + '@vitest/expect@4.1.7':
1848 + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
1849
1752 - '@vitest/mocker@4.1.6':
1753 - resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==}
1850 + '@vitest/mocker@4.1.7':
1851 + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==}
1852 peerDependencies:
1853 msw: ^2.4.9
1854 vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -1760,20 +1858,20 @@ packages:
1858 vite:
1859 optional: true
1860
1763 - '@vitest/pretty-format@4.1.6':
1764 - resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
1861 + '@vitest/pretty-format@4.1.7':
1862 + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
1863
1766 - '@vitest/runner@4.1.6':
1767 - resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==}
1864 + '@vitest/runner@4.1.7':
1865 + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==}
1866
1769 - '@vitest/snapshot@4.1.6':
1770 - resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==}
1867 + '@vitest/snapshot@4.1.7':
1868 + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==}
1869
1772 - '@vitest/spy@4.1.6':
1773 - resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==}
1870 + '@vitest/spy@4.1.7':
1871 + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==}
1872
1775 - '@vitest/utils@4.1.6':
1776 - resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
1873 + '@vitest/utils@4.1.7':
1874 + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
1875
1876 '@volar/language-core@2.4.28':
1877 resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -1863,8 +1961,8 @@ packages:
1961 '@vue/devtools-shared@8.1.2':
1962 resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==}
1963
1866 - '@vue/language-core@3.3.0':
1867 - resolution: {integrity: sha512-EyUxq1b8Yoxk6hQ6X33BIRnfFLb9Rbm9w/8G8y6uMxlQu7CW7yy9JS/z54xSpIvBvVWX6Lt5v1aBGwmrqD4aJw==}
1964 + '@vue/language-core@3.3.2':
1965 + resolution: {integrity: sha512-CLwjSfHlPLhjd2qhuS3tTFtnOIWHXAM5u4X1DxmzlQ8j5bmOYlKCsSusOP7jCRJnlVg0mCTQtHU3vwFvopZGoQ==}
1966
1967 '@vue/reactivity@3.5.34':
1968 resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==}
@@ -1988,8 +2086,8 @@ packages:
2086 resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==}
2087 engines: {node: '>=14'}
2088
1991 - apexcharts@5.12.0:
1992 - resolution: {integrity: sha512-vNCw62M5rhVg09FHFCL/ztpH7VlTOF8/+lWLUVjBdQffIAgQaxtyDGfojCyYoZHM3Hh17srWwz6rrD/XzblRSw==}
2089 + apexcharts@5.13.0:
2090 + resolution: {integrity: sha512-PJuXT6zdiCbv0IkX5cqkKFVIIh+9v3kqP9zsOHEGpIWi7DfTgzvfOKc8icw6G3/ulR3V1alDDUtOVH0zWCWGEQ==}
2091
2092 are-docs-informative@0.0.2:
2093 resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
@@ -2064,13 +2162,8 @@ packages:
2162 resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
2163 engines: {node: 18 || 20 || >=22}
2164
2067 - baseline-browser-mapping@2.10.29:
2068 - resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==}
2069 - engines: {node: '>=6.0.0'}
2070 - hasBin: true
2071 -
2072 - baseline-browser-mapping@2.10.31:
2073 - resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==}
2165 + baseline-browser-mapping@2.10.32:
2166 + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
2167 engines: {node: '>=6.0.0'}
2168 hasBin: true
2169
@@ -2093,8 +2186,8 @@ packages:
2186 brace-expansion@1.1.14:
2187 resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
2188
2096 - brace-expansion@2.1.0:
2097 - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
2189 + brace-expansion@2.1.1:
2190 + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
2191
2192 brace-expansion@5.0.6:
2193 resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
@@ -2156,8 +2249,8 @@ packages:
2249 resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
2250 engines: {node: '>=10'}
2251
2159 - caniuse-lite@1.0.30001792:
2160 - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
2252 + caniuse-lite@1.0.30001793:
2253 + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
2254
2255 ccount@2.0.1:
2256 resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -2188,10 +2281,6 @@ packages:
2281 resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
2282 engines: {node: '>= 0.8.0'}
2283
2191 - chokidar@4.0.3:
2192 - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
2193 - engines: {node: '>= 14.16.0'}
2194 -
2284 chokidar@5.0.0:
2285 resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
2286 engines: {node: '>= 20.19.0'}
@@ -2273,6 +2362,10 @@ packages:
2362 resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==}
2363 engines: {node: '>= 12.0.0'}
2364
2365 + comment-parser@1.4.7:
2366 + resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==}
2367 + engines: {node: '>= 12.0.0'}
2368 +
2369 concat-map@0.0.1:
2370 resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
2371
@@ -2372,8 +2465,8 @@ packages:
2465 peerDependencies:
2466 date-fns: ^3.0.0 || ^4.0.0
2467
2375 - date-fns@4.1.0:
2376 - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
2468 + date-fns@4.3.0:
2469 + resolution: {integrity: sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==}
2470
2471 dayjs@1.11.20:
2472 resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
@@ -2488,16 +2581,16 @@ packages:
2581 easy-bem@1.1.1:
2582 resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==}
2583
2491 - echarts@6.0.0:
2492 - resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
2584 + echarts@6.1.0:
2585 + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
2586
2587 editorconfig@1.0.7:
2588 resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
2589 engines: {node: '>=14'}
2590 hasBin: true
2591
2499 - electron-to-chromium@1.5.355:
2500 - resolution: {integrity: sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==}
2592 + electron-to-chromium@1.5.361:
2593 + resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==}
2594
2595 emoji-regex@8.0.0:
2596 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2512,8 +2605,8 @@ packages:
2605 end-of-stream@1.4.5:
2606 resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
2607
2515 - enhanced-resolve@5.21.3:
2516 - resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
2608 + enhanced-resolve@5.22.0:
2609 + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==}
2610 engines: {node: '>=10.13.0'}
2611
2612 entities@4.5.0:
@@ -2548,8 +2641,8 @@ packages:
2641 es-module-lexer@2.1.0:
2642 resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
2643
2551 - es-object-atoms@1.1.1:
2552 - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
2644 + es-object-atoms@1.1.2:
2645 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
2646 engines: {node: '>= 0.4'}
2647
2648 es-set-tostringtag@2.1.0:
@@ -2662,8 +2755,8 @@ packages:
2755 peerDependencies:
2756 eslint: ^8.45.0 || ^9.0.0 || ^10.0.0
2757
2665 - eslint-plugin-pnpm@1.6.0:
2666 - resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==}
2758 + eslint-plugin-pnpm@1.6.1:
2759 + resolution: {integrity: sha512-pgcaJclu3YxZ/WMsiKMF58bHQasbGVARSMqCJvFaETYxSc7KcR2H74UVWV6exuGv9nNv9c0KKqn4PVHQlzMSEg==}
2760 peerDependencies:
2761 eslint: ^9.0.0 || ^10.0.0
2762
@@ -2831,8 +2924,8 @@ packages:
2924 fast-string-width@3.0.2:
2925 resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
2926
2834 - fast-wrap-ansi@0.2.0:
2835 - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
2927 + fast-wrap-ansi@0.2.2:
2928 + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
2929
2930 fast-xml-builder@1.2.0:
2931 resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
@@ -3215,9 +3308,9 @@ packages:
3308 isexe@2.0.0:
3309 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
3310
3218 - isexe@3.1.5:
3219 - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
3220 - engines: {node: '>=18'}
3311 + isexe@4.0.0:
3312 + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
3313 + engines: {node: '>=20'}
3314
3315 jackspeak@3.4.3:
3316 resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -3238,9 +3331,9 @@ packages:
3331 engines: {node: '>=14'}
3332 hasBin: true
3333
3241 - js-cookie@3.0.5:
3242 - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
3243 - engines: {node: '>=14'}
3334 + js-cookie@3.0.7:
3335 + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
3336 + engines: {node: '>=20'}
3337
3338 js-md5@0.8.3:
3339 resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==}
@@ -3255,11 +3348,11 @@ packages:
3348 resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
3349 hasBin: true
3350
3258 - jscpd-sarif-reporter@4.2.3:
3259 - resolution: {integrity: sha512-rM0LM5S0kdASLCtDsr1s51rJOPf8nubaxaWQUTWVVPda1UMPymXbELG+A3Rgpoa4D4QFUFfXqz60Jn/W+vlFtA==}
3351 + jscpd-sarif-reporter@4.2.4:
3352 + resolution: {integrity: sha512-JtX79kFSyAhqJh5TdLUcvtYJtJd1F8UW8b4Miaga+EIgUn2/nR0N2zWL9mH5cRXgbzLuQbbsw9kReUVIECApwQ==}
3353
3261 - jscpd@4.2.3:
3262 - resolution: {integrity: sha512-/1BEga1E1cY56/sdQOzU/PFtnea+n1beqG8/Xx4HopG9c5rkUO8ptnu9En8Xf1ILGW6KSWidV4vLQTm2FGYvpw==}
3354 + jscpd@4.2.4:
3355 + resolution: {integrity: sha512-PSo2U0G8OxULayGyQMv7T/0ZQ+c3PPltdMOz/57v9Xnmq5xSIhh4cnZ0oYZPKqejy10aFwAbMVxqAlo24+PQ3g==}
3356 hasBin: true
3357
3358 jsdoc-type-pratt-parser@7.1.1:
@@ -3290,9 +3383,9 @@ packages:
3383 json-parse-even-better-errors@2.3.1:
3384 resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
3385
3293 - json-parse-even-better-errors@4.0.0:
3294 - resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==}
3295 - engines: {node: ^18.17.0 || >=20.5.0}
3386 + json-parse-even-better-errors@6.0.0:
3387 + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==}
3388 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
3389
3390 json-schema-traverse@0.4.1:
3391 resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -3315,8 +3408,8 @@ packages:
3408 jstransformer@1.0.0:
3409 resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==}
3410
3318 - katex@0.16.46:
3319 - resolution: {integrity: sha512-WHy4Coo+bGZyH7NwJKHkS04YFsFcarWbAEOAC3EMndzdN6VSZqklLLIgfxzyaW9jDoeGYJX9SWbJPKpecox0Uw==}
3411 + katex@0.16.47:
3412 + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
3413 hasBin: true
3414
3415 keyv@4.5.4:
@@ -3417,11 +3510,11 @@ packages:
3510 lines-and-columns@1.2.4:
3511 resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
3512
3420 - linkify-it@5.0.0:
3421 - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
3513 + linkify-it@5.0.1:
3514 + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==}
3515
3423 - local-pkg@1.1.2:
3424 - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
3516 + local-pkg@1.2.1:
3517 + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
3518 engines: {node: '>=14'}
3519
3520 locate-path@6.0.0:
@@ -3450,8 +3543,8 @@ packages:
3543 lru-cache@10.4.3:
3544 resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
3545
3453 - lru-cache@11.3.6:
3454 - resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
3546 + lru-cache@11.5.0:
3547 + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==}
3548 engines: {node: 20 || >=22}
3549
3550 lru-cache@5.1.1:
@@ -3473,8 +3566,8 @@ packages:
3566 magic-string@0.30.21:
3567 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
3568
3476 - markdown-it@14.1.1:
3477 - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
3569 + markdown-it@14.2.0:
3570 + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
3571 hasBin: true
3572
3573 markdown-table@2.0.0:
@@ -3754,8 +3847,9 @@ packages:
3847 node-fetch-native@1.6.7:
3848 resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
3849
3757 - node-releases@2.0.44:
3758 - resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
3850 + node-releases@2.0.46:
3851 + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
3852 + engines: {node: '>=18'}
3853
3854 node-sarif-builder@3.4.0:
3855 resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==}
@@ -3766,13 +3860,13 @@ packages:
3860 engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
3861 hasBin: true
3862
3769 - npm-normalize-package-bin@4.0.0:
3770 - resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
3771 - engines: {node: ^18.17.0 || >=20.5.0}
3863 + npm-normalize-package-bin@6.0.0:
3864 + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==}
3865 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
3866
3773 - npm-run-all2@8.0.4:
3774 - resolution: {integrity: sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==}
3775 - engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'}
3867 + npm-run-all2@9.0.1:
3868 + resolution: {integrity: sha512-ZtK8WXZBUA9x0XD6nxYdFLe86FxpkCTq2LiQxzX0LeXQY/vyAigQZXjjj/xfTwgV4Yqe/vYNIq2W09lrHKTcuQ==}
3869 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'}
3870 hasBin: true
3871
3872 npm-run-path@4.0.1:
@@ -3786,8 +3880,8 @@ packages:
3880 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
3881 engines: {node: '>=0.10.0'}
3882
3789 - object-deep-merge@2.0.0:
3790 - resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==}
3883 + object-deep-merge@2.0.1:
3884 + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==}
3885
3886 obug@2.1.1:
3887 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -3963,8 +4057,8 @@ packages:
4057 resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
4058 engines: {node: '>=4'}
4059
3966 - pnpm-workspace-yaml@1.6.0:
3967 - resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==}
4060 + pnpm-workspace-yaml@1.6.1:
4061 + resolution: {integrity: sha512-yTeZntGWi8m9WNuhoVsP0DpFc4sC1U0+rr/qR6Zi9n2g3sxXY+JfccjXjjruNz96tM8I09yaJUA86doRnNLkbg==}
4062
4063 popmotion@11.0.5:
4064 resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
@@ -3973,8 +4067,8 @@ packages:
4067 resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
4068 engines: {node: '>=4'}
4069
3976 - postcss@8.5.14:
3977 - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
4070 + postcss@8.5.15:
4071 + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
4072 engines: {node: ^10 || ^12 || >=14}
4073
4074 prelude-ls@1.2.1:
@@ -4127,8 +4221,8 @@ packages:
4221 react-is@19.2.6:
4222 resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==}
4223
4130 - react-markdown@9.1.0:
4131 - resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==}
4224 + react-markdown@10.1.0:
4225 + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
4226 peerDependencies:
4227 '@types/react': '>=18'
4228 react: '>=18'
@@ -4165,18 +4259,14 @@ packages:
4259 resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
4260 engines: {node: '>=0.10.0'}
4261
4168 - read-package-json-fast@4.0.0:
4169 - resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==}
4170 - engines: {node: ^18.17.0 || >=20.5.0}
4262 + read-package-json-fast@6.0.0:
4263 + resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==}
4264 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
4265
4266 readdirp@3.6.0:
4267 resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
4268 engines: {node: '>=8.10.0'}
4269
4176 - readdirp@4.1.2:
4177 - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
4178 - engines: {node: '>= 14.18.0'}
4179 -
4270 readdirp@5.0.0:
4271 resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
4272 engines: {node: '>= 20.19.0'}
@@ -4276,6 +4366,11 @@ packages:
4366 engines: {node: ^20.19.0 || >=22.12.0}
4367 hasBin: true
4368
4369 + rolldown@1.0.2:
4370 + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
4371 + engines: {node: ^20.19.0 || >=22.12.0}
4372 + hasBin: true
4373 +
4374 rollup-plugin-visualizer@5.14.0:
4375 resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==}
4376 engines: {node: '>=18'}
@@ -4299,9 +4394,9 @@ packages:
4394 rxjs@7.8.2:
4395 resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
4396
4302 - sass@1.99.0:
4303 - resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==}
4304 - engines: {node: '>=14.0.0'}
4397 + sass@1.100.0:
4398 + resolution: {integrity: sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==}
4399 + engines: {node: '>=20.19.0'}
4400 hasBin: true
4401
4402 sax@1.6.0:
@@ -4336,8 +4431,8 @@ packages:
4431 resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
4432 hasBin: true
4433
4339 - semver@7.8.0:
4340 - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
4434 + semver@7.8.1:
4435 + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
4436 engines: {node: '>=10'}
4437 hasBin: true
4438
@@ -4352,12 +4447,12 @@ packages:
4447 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
4448 engines: {node: '>=8'}
4449
4355 - shell-quote@1.8.3:
4356 - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
4450 + shell-quote@1.8.4:
4451 + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
4452 engines: {node: '>= 0.4'}
4453
4359 - shiki@4.0.2:
4360 - resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
4454 + shiki@4.1.0:
4455 + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
4456 engines: {node: '>=20'}
4457
4458 siginfo@2.0.0:
@@ -4507,8 +4602,8 @@ packages:
4602 resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
4603 engines: {node: '>=6'}
4604
4510 - taze@19.12.0:
4511 - resolution: {integrity: sha512-qe9mTHwyJUNgWyqx62chVcJN0bfel2AJHsqlFVVxVto0GiBhKjNsPo0FOnFxpKin1Nh0uBooZHgNAjcsP8t2Fw==}
4605 + taze@19.14.1:
4606 + resolution: {integrity: sha512-+wf/IqGReU68vBE/iJ7JCuV5QeD6zQBp9MI6YphN7bT2vf/YIHd0oVA4AJiX3uANI1hQY58MrVmDwLv0x/q3BA==}
4607 hasBin: true
4608
4609 thememirror@2.0.1:
@@ -4521,8 +4616,8 @@ packages:
4616 tinybench@2.9.0:
4617 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
4618
4524 - tinyexec@1.1.2:
4525 - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
4619 + tinyexec@1.2.2:
4620 + resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==}
4621 engines: {node: '>=18'}
4622
4623 tinyglobby@0.2.16:
@@ -4533,11 +4628,11 @@ packages:
4628 resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
4629 engines: {node: '>=14.0.0'}
4630
4536 - tldts-core@7.0.30:
4537 - resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
4631 + tldts-core@7.4.0:
4632 + resolution: {integrity: sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==}
4633
4539 - tldts@7.0.30:
4540 - resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
4634 + tldts@7.4.0:
4635 + resolution: {integrity: sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==}
4636 hasBin: true
4637
4638 tmp@0.2.5:
@@ -4633,11 +4728,11 @@ packages:
4728 undici-types@7.24.6:
4729 resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
4730
4636 - undici-types@7.25.0:
4637 - resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==}
4731 + undici-types@7.26.0:
4732 + resolution: {integrity: sha512-OY7qWYg4TsPpqg/kL2FfNnGA8cmAhPpLt45XQ2jd8p9UobYQ7Q09LeiCq5QwZhlKNLBj0iTUlBNhs4M2AVFmxA==}
4733
4639 - undici@7.25.0:
4640 - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
4734 + undici@7.26.0:
4735 + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==}
4736 engines: {node: '>=20.18.1'}
4737
4738 unified@11.0.5:
@@ -4792,20 +4887,20 @@ packages:
4887 yaml:
4888 optional: true
4889
4795 - vitest@4.1.6:
4796 - resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==}
4890 + vitest@4.1.7:
4891 + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==}
4892 engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
4893 hasBin: true
4894 peerDependencies:
4895 '@edge-runtime/vm': '*'
4896 '@opentelemetry/api': ^1.9.0
4897 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
4803 - '@vitest/browser-playwright': 4.1.6
4804 - '@vitest/browser-preview': 4.1.6
4805 - '@vitest/browser-webdriverio': 4.1.6
4806 - '@vitest/coverage-istanbul': 4.1.6
4807 - '@vitest/coverage-v8': 4.1.6
4808 - '@vitest/ui': 4.1.6
4898 + '@vitest/browser-playwright': 4.1.7
4899 + '@vitest/browser-preview': 4.1.7
4900 + '@vitest/browser-webdriverio': 4.1.7
4901 + '@vitest/coverage-istanbul': 4.1.7
4902 + '@vitest/coverage-v8': 4.1.7
4903 + '@vitest/ui': 4.1.7
4904 happy-dom: '*'
4905 jsdom: '*'
4906 vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -4857,8 +4952,14 @@ packages:
4952 codemirror: 6.x
4953 vue: 3.x
4954
4860 - vue-component-type-helpers@3.2.9:
4861 - resolution: {integrity: sha512-S3BiWYaLSzHxTpln665ELSrMR9UYmrIDUmhik7nVZxmJjTKL2/a+ew1hvGxksKelivm0ujjWfG1fYOiU/2e8rA==}
4955 + vue-component-type-helpers@3.3.2:
4956 + resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
4957 +
4958 + vue-echarts@8.0.1:
4959 + resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
4960 + peerDependencies:
4961 + echarts: ^6.0.0
4962 + vue: ^3.3.0
4963
4964 vue-eslint-parser@10.4.0:
4965 resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
@@ -4897,8 +4998,8 @@ packages:
4998 peerDependencies:
4999 vue: ^3.3.4
5000
4900 - vue-tsc@3.3.0:
4901 - resolution: {integrity: sha512-kY8RcoTOENASi0P1GLPvJgA2+hoGF+t8We1UGgmnAb1r/GjTUMSE3zz+WGfjPORZNnBHdAt67sVPhBLXWunkeg==}
5001 + vue-tsc@3.3.2:
5002 + resolution: {integrity: sha512-n7nQoA3YWW/eiDR8jMiv/uJvlg0uLGs+YgUrsTrf9EZaYSt3tuvMZb5V8+7Mvh/EH5pnY/hoVdgfjH+XcK+wwA==}
5003 hasBin: true
5004 peerDependencies:
5005 typescript: '>=5.0.0'
@@ -4969,9 +5070,9 @@ packages:
5070 engines: {node: '>= 8'}
5071 hasBin: true
5072
4972 - which@5.0.0:
4973 - resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==}
4974 - engines: {node: ^18.17.0 || >=20.5.0}
5073 + which@7.0.0:
5074 + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==}
5075 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
5076 hasBin: true
5077
5078 why-is-node-running@2.3.0:
@@ -5066,8 +5167,8 @@ packages:
5167 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
5168 engines: {node: '>=10'}
5169
5069 - zrender@6.0.0:
5070 - resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
5170 + zrender@6.1.0:
5171 + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
5172
5173 zwitch@2.0.4:
5174 resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -5162,17 +5263,17 @@ snapshots:
5263 dependencies:
5264 '@algolia/client-common': 5.52.1
5265
5165 - '@antfu/eslint-config@9.0.0(@typescript-eslint/rule-tester@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3))(@typescript-eslint/utils@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)))':
5266 + '@antfu/eslint-config@9.0.0(@typescript-eslint/rule-tester@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.60.0(typescript@6.0.3))(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.34)(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)))':
5267 dependencies:
5268 '@antfu/install-pkg': 1.1.0
5269 '@clack/prompts': 1.4.0
5270 '@e18e/eslint-plugin': 0.4.1(eslint@10.4.0(jiti@2.7.0))
5271 '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.4.0(jiti@2.7.0))
5171 - '@eslint/markdown': 8.0.1
5272 + '@eslint/markdown': 8.0.2
5273 '@stylistic/eslint-plugin': 5.10.0(eslint@10.4.0(jiti@2.7.0))
5173 - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
5174 - '@typescript-eslint/parser': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
5175 - '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)))
5274 + '@typescript-eslint/eslint-plugin': 8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
5275 + '@typescript-eslint/parser': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
5276 + '@vitest/eslint-plugin': 1.6.18(@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)))
5277 ansis: 4.3.0
5278 cac: 7.0.0
5279 eslint: 10.4.0(jiti@2.7.0)
@@ -5180,23 +5281,23 @@ snapshots:
5281 eslint-flat-config-utils: 3.2.0
5282 eslint-merge-processors: 2.0.0(eslint@10.4.0(jiti@2.7.0))
5283 eslint-plugin-antfu: 3.2.3(eslint@10.4.0(jiti@2.7.0))
5183 - eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3))(@typescript-eslint/utils@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))
5284 + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.60.0(typescript@6.0.3))(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))
5285 eslint-plugin-import-lite: 0.6.0(eslint@10.4.0(jiti@2.7.0))
5286 eslint-plugin-jsdoc: 62.9.0(eslint@10.4.0(jiti@2.7.0))
5287 eslint-plugin-jsonc: 3.1.2(eslint@10.4.0(jiti@2.7.0))
5288 eslint-plugin-n: 18.0.1(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
5289 eslint-plugin-no-only-tests: 3.4.0
5290 eslint-plugin-perfectionist: 5.9.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
5190 - eslint-plugin-pnpm: 1.6.0(eslint@10.4.0(jiti@2.7.0))
5291 + eslint-plugin-pnpm: 1.6.1(eslint@10.4.0(jiti@2.7.0))
5292 eslint-plugin-regexp: 3.1.0(eslint@10.4.0(jiti@2.7.0))
5293 eslint-plugin-toml: 1.3.1(eslint@10.4.0(jiti@2.7.0))
5294 eslint-plugin-unicorn: 64.0.0(eslint@10.4.0(jiti@2.7.0))
5194 - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))
5195 - eslint-plugin-vue: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.4.0(jiti@2.7.0)))(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.7.0)))
5295 + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))
5296 + eslint-plugin-vue: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.4.0(jiti@2.7.0)))(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.7.0)))
5297 eslint-plugin-yml: 3.3.2(eslint@10.4.0(jiti@2.7.0))
5298 eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.34)(eslint@10.4.0(jiti@2.7.0))
5299 globals: 17.6.0
5199 - local-pkg: 1.1.2
5300 + local-pkg: 1.2.1
5301 parse-gitignore: 2.0.0
5302 toml-eslint-parser: 1.0.3
5303 vue-eslint-parser: 10.4.0(eslint@10.4.0(jiti@2.7.0))
@@ -5216,13 +5317,13 @@ snapshots:
5317 '@antfu/install-pkg@1.1.0':
5318 dependencies:
5319 package-manager-detector: 1.6.0
5219 - tinyexec: 1.1.2
5320 + tinyexec: 1.2.2
5321
5322 '@antfu/ni@30.1.0':
5323 dependencies:
5324 fzf: 0.5.2
5325 package-manager-detector: 1.6.0
5225 - tinyexec: 1.1.2
5326 + tinyexec: 1.2.2
5327 tinyglobby: 0.2.16
5328
5329 '@asamuzakjp/css-color@5.1.11':
@@ -5245,25 +5346,25 @@ snapshots:
5346
5347 '@asamuzakjp/nwsapi@2.3.9': {}
5348
5248 - '@babel/code-frame@7.29.0':
5349 + '@babel/code-frame@7.29.7':
5350 dependencies:
5250 - '@babel/helper-validator-identifier': 7.28.5
5351 + '@babel/helper-validator-identifier': 7.29.7
5352 js-tokens: 4.0.0
5353 picocolors: 1.1.1
5354
5254 - '@babel/compat-data@7.29.3': {}
5355 + '@babel/compat-data@7.29.7': {}
5356
5256 - '@babel/core@7.29.0':
5357 + '@babel/core@7.29.7':
5358 dependencies:
5258 - '@babel/code-frame': 7.29.0
5259 - '@babel/generator': 7.29.1
5260 - '@babel/helper-compilation-targets': 7.28.6
5261 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
5262 - '@babel/helpers': 7.29.2
5263 - '@babel/parser': 7.29.3
5264 - '@babel/template': 7.28.6
5265 - '@babel/traverse': 7.29.0
5266 - '@babel/types': 7.29.0
5359 + '@babel/code-frame': 7.29.7
5360 + '@babel/generator': 7.29.7
5361 + '@babel/helper-compilation-targets': 7.29.7
5362 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
5363 + '@babel/helpers': 7.29.7
5364 + '@babel/parser': 7.29.7
5365 + '@babel/template': 7.29.7
5366 + '@babel/traverse': 7.29.7
5367 + '@babel/types': 7.29.7
5368 '@jridgewell/remapping': 2.3.5
5369 convert-source-map: 2.0.0
5370 debug: 4.4.3
@@ -5273,192 +5374,192 @@ snapshots:
5374 transitivePeerDependencies:
5375 - supports-color
5376
5276 - '@babel/generator@7.29.1':
5377 + '@babel/generator@7.29.7':
5378 dependencies:
5278 - '@babel/parser': 7.29.3
5279 - '@babel/types': 7.29.0
5379 + '@babel/parser': 7.29.7
5380 + '@babel/types': 7.29.7
5381 '@jridgewell/gen-mapping': 0.3.13
5382 '@jridgewell/trace-mapping': 0.3.31
5383 jsesc: 3.1.0
5384
5284 - '@babel/generator@8.0.0-rc.5':
5385 + '@babel/generator@8.0.0-rc.6':
5386 dependencies:
5286 - '@babel/parser': 8.0.0-rc.5
5287 - '@babel/types': 8.0.0-rc.5
5387 + '@babel/parser': 8.0.0-rc.6
5388 + '@babel/types': 8.0.0-rc.6
5389 '@jridgewell/gen-mapping': 0.3.13
5390 '@jridgewell/trace-mapping': 0.3.31
5391 '@types/jsesc': 2.5.1
5392 jsesc: 3.1.0
5393
5293 - '@babel/helper-annotate-as-pure@7.27.3':
5394 + '@babel/helper-annotate-as-pure@7.29.7':
5395 dependencies:
5295 - '@babel/types': 7.29.0
5396 + '@babel/types': 7.29.7
5397
5297 - '@babel/helper-compilation-targets@7.28.6':
5398 + '@babel/helper-compilation-targets@7.29.7':
5399 dependencies:
5299 - '@babel/compat-data': 7.29.3
5300 - '@babel/helper-validator-option': 7.27.1
5400 + '@babel/compat-data': 7.29.7
5401 + '@babel/helper-validator-option': 7.29.7
5402 browserslist: 4.28.2
5403 lru-cache: 5.1.1
5404 semver: 6.3.1
5405
5305 - '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)':
5406 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)':
5407 dependencies:
5307 - '@babel/core': 7.29.0
5308 - '@babel/helper-annotate-as-pure': 7.27.3
5309 - '@babel/helper-member-expression-to-functions': 7.28.5
5310 - '@babel/helper-optimise-call-expression': 7.27.1
5311 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0)
5312 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
5313 - '@babel/traverse': 7.29.0
5408 + '@babel/core': 7.29.7
5409 + '@babel/helper-annotate-as-pure': 7.29.7
5410 + '@babel/helper-member-expression-to-functions': 7.29.7
5411 + '@babel/helper-optimise-call-expression': 7.29.7
5412 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
5413 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
5414 + '@babel/traverse': 7.29.7
5415 semver: 6.3.1
5416 transitivePeerDependencies:
5417 - supports-color
5418
5318 - '@babel/helper-globals@7.28.0': {}
5419 + '@babel/helper-globals@7.29.7': {}
5420
5320 - '@babel/helper-member-expression-to-functions@7.28.5':
5421 + '@babel/helper-member-expression-to-functions@7.29.7':
5422 dependencies:
5322 - '@babel/traverse': 7.29.0
5323 - '@babel/types': 7.29.0
5423 + '@babel/traverse': 7.29.7
5424 + '@babel/types': 7.29.7
5425 transitivePeerDependencies:
5426 - supports-color
5427
5327 - '@babel/helper-module-imports@7.28.6':
5428 + '@babel/helper-module-imports@7.29.7':
5429 dependencies:
5329 - '@babel/traverse': 7.29.0
5330 - '@babel/types': 7.29.0
5430 + '@babel/traverse': 7.29.7
5431 + '@babel/types': 7.29.7
5432 transitivePeerDependencies:
5433 - supports-color
5434
5334 - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
5435 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
5436 dependencies:
5336 - '@babel/core': 7.29.0
5337 - '@babel/helper-module-imports': 7.28.6
5338 - '@babel/helper-validator-identifier': 7.28.5
5339 - '@babel/traverse': 7.29.0
5437 + '@babel/core': 7.29.7
5438 + '@babel/helper-module-imports': 7.29.7
5439 + '@babel/helper-validator-identifier': 7.29.7
5440 + '@babel/traverse': 7.29.7
5441 transitivePeerDependencies:
5442 - supports-color
5443
5343 - '@babel/helper-optimise-call-expression@7.27.1':
5444 + '@babel/helper-optimise-call-expression@7.29.7':
5445 dependencies:
5345 - '@babel/types': 7.29.0
5446 + '@babel/types': 7.29.7
5447
5347 - '@babel/helper-plugin-utils@7.28.6': {}
5448 + '@babel/helper-plugin-utils@7.29.7': {}
5449
5349 - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)':
5450 + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)':
5451 dependencies:
5351 - '@babel/core': 7.29.0
5352 - '@babel/helper-member-expression-to-functions': 7.28.5
5353 - '@babel/helper-optimise-call-expression': 7.27.1
5354 - '@babel/traverse': 7.29.0
5452 + '@babel/core': 7.29.7
5453 + '@babel/helper-member-expression-to-functions': 7.29.7
5454 + '@babel/helper-optimise-call-expression': 7.29.7
5455 + '@babel/traverse': 7.29.7
5456 transitivePeerDependencies:
5457 - supports-color
5458
5358 - '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
5459 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
5460 dependencies:
5360 - '@babel/traverse': 7.29.0
5361 - '@babel/types': 7.29.0
5461 + '@babel/traverse': 7.29.7
5462 + '@babel/types': 7.29.7
5463 transitivePeerDependencies:
5464 - supports-color
5465
5365 - '@babel/helper-string-parser@7.27.1': {}
5466 + '@babel/helper-string-parser@7.29.7': {}
5467
5367 - '@babel/helper-string-parser@8.0.0-rc.5': {}
5468 + '@babel/helper-string-parser@8.0.0-rc.6': {}
5469
5369 - '@babel/helper-validator-identifier@7.28.5': {}
5470 + '@babel/helper-validator-identifier@7.29.7': {}
5471
5371 - '@babel/helper-validator-identifier@8.0.0-rc.5': {}
5472 + '@babel/helper-validator-identifier@8.0.0-rc.6': {}
5473
5373 - '@babel/helper-validator-option@7.27.1': {}
5474 + '@babel/helper-validator-option@7.29.7': {}
5475
5375 - '@babel/helpers@7.29.2':
5476 + '@babel/helpers@7.29.7':
5477 dependencies:
5377 - '@babel/template': 7.28.6
5378 - '@babel/types': 7.29.0
5478 + '@babel/template': 7.29.7
5479 + '@babel/types': 7.29.7
5480
5380 - '@babel/parser@7.29.3':
5481 + '@babel/parser@7.29.7':
5482 dependencies:
5382 - '@babel/types': 7.29.0
5483 + '@babel/types': 7.29.7
5484
5384 - '@babel/parser@8.0.0-rc.5':
5485 + '@babel/parser@8.0.0-rc.6':
5486 dependencies:
5386 - '@babel/types': 8.0.0-rc.5
5487 + '@babel/types': 8.0.0-rc.6
5488
5388 - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)':
5489 + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)':
5490 dependencies:
5390 - '@babel/core': 7.29.0
5391 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
5392 - '@babel/helper-plugin-utils': 7.28.6
5393 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0)
5491 + '@babel/core': 7.29.7
5492 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7)
5493 + '@babel/helper-plugin-utils': 7.29.7
5494 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7)
5495 transitivePeerDependencies:
5496 - supports-color
5497
5397 - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)':
5498 + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)':
5499 dependencies:
5399 - '@babel/core': 7.29.0
5400 - '@babel/helper-plugin-utils': 7.28.6
5500 + '@babel/core': 7.29.7
5501 + '@babel/helper-plugin-utils': 7.29.7
5502
5402 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)':
5503 + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)':
5504 dependencies:
5404 - '@babel/core': 7.29.0
5405 - '@babel/helper-plugin-utils': 7.28.6
5505 + '@babel/core': 7.29.7
5506 + '@babel/helper-plugin-utils': 7.29.7
5507
5407 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)':
5508 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)':
5509 dependencies:
5409 - '@babel/core': 7.29.0
5410 - '@babel/helper-plugin-utils': 7.28.6
5510 + '@babel/core': 7.29.7
5511 + '@babel/helper-plugin-utils': 7.29.7
5512
5412 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
5513 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
5514 dependencies:
5414 - '@babel/core': 7.29.0
5415 - '@babel/helper-plugin-utils': 7.28.6
5515 + '@babel/core': 7.29.7
5516 + '@babel/helper-plugin-utils': 7.29.7
5517
5417 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
5518 + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)':
5519 dependencies:
5419 - '@babel/core': 7.29.0
5420 - '@babel/helper-plugin-utils': 7.28.6
5520 + '@babel/core': 7.29.7
5521 + '@babel/helper-plugin-utils': 7.29.7
5522
5422 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)':
5523 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)':
5524 dependencies:
5424 - '@babel/core': 7.29.0
5425 - '@babel/helper-annotate-as-pure': 7.27.3
5426 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
5427 - '@babel/helper-plugin-utils': 7.28.6
5428 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
5429 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
5525 + '@babel/core': 7.29.7
5526 + '@babel/helper-annotate-as-pure': 7.29.7
5527 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7)
5528 + '@babel/helper-plugin-utils': 7.29.7
5529 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
5530 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
5531 transitivePeerDependencies:
5532 - supports-color
5533
5433 - '@babel/runtime@7.29.2': {}
5534 + '@babel/runtime@7.29.7': {}
5535
5435 - '@babel/template@7.28.6':
5536 + '@babel/template@7.29.7':
5537 dependencies:
5437 - '@babel/code-frame': 7.29.0
5438 - '@babel/parser': 7.29.3
5439 - '@babel/types': 7.29.0
5538 + '@babel/code-frame': 7.29.7
5539 + '@babel/parser': 7.29.7
5540 + '@babel/types': 7.29.7
5541
5441 - '@babel/traverse@7.29.0':
5542 + '@babel/traverse@7.29.7':
5543 dependencies:
5443 - '@babel/code-frame': 7.29.0
5444 - '@babel/generator': 7.29.1
5445 - '@babel/helper-globals': 7.28.0
5446 - '@babel/parser': 7.29.3
5447 - '@babel/template': 7.28.6
5448 - '@babel/types': 7.29.0
5544 + '@babel/code-frame': 7.29.7
5545 + '@babel/generator': 7.29.7
5546 + '@babel/helper-globals': 7.29.7
5547 + '@babel/parser': 7.29.7
5548 + '@babel/template': 7.29.7
5549 + '@babel/types': 7.29.7
5550 debug: 4.4.3
5551 transitivePeerDependencies:
5552 - supports-color
5553
5453 - '@babel/types@7.29.0':
5554 + '@babel/types@7.29.7':
5555 dependencies:
5455 - '@babel/helper-string-parser': 7.27.1
5456 - '@babel/helper-validator-identifier': 7.28.5
5556 + '@babel/helper-string-parser': 7.29.7
5557 + '@babel/helper-validator-identifier': 7.29.7
5558
5458 - '@babel/types@8.0.0-rc.5':
5559 + '@babel/types@8.0.0-rc.6':
5560 dependencies:
5460 - '@babel/helper-string-parser': 8.0.0-rc.5
5461 - '@babel/helper-validator-identifier': 8.0.0-rc.5
5561 + '@babel/helper-string-parser': 8.0.0-rc.6
5562 + '@babel/helper-validator-identifier': 8.0.0-rc.6
5563
5564 '@bramus/specificity@2.4.2':
5565 dependencies:
@@ -5466,14 +5567,14 @@ snapshots:
5567
5568 '@clack/core@1.3.1':
5569 dependencies:
5469 - fast-wrap-ansi: 0.2.0
5570 + fast-wrap-ansi: 0.2.2
5571 sisteransi: 1.0.5
5572
5573 '@clack/prompts@1.4.0':
5574 dependencies:
5575 '@clack/core': 1.3.1
5576 fast-string-width: 3.0.2
5476 - fast-wrap-ansi: 0.2.0
5577 + fast-wrap-ansi: 0.2.2
5578 sisteransi: 1.0.5
5579
5580 '@codemirror/autocomplete@6.20.2':
@@ -5587,7 +5688,7 @@ snapshots:
5688 dependencies:
5689 empathic: 2.0.1
5690 module-replacements: 3.0.0-beta.7
5590 - semver: 7.8.0
5691 + semver: 7.8.1
5692 optionalDependencies:
5693 eslint: 10.4.0(jiti@2.7.0)
5694
@@ -5609,8 +5710,8 @@ snapshots:
5710
5711 '@emotion/babel-plugin@11.13.5':
5712 dependencies:
5612 - '@babel/helper-module-imports': 7.28.6
5613 - '@babel/runtime': 7.29.2
5713 + '@babel/helper-module-imports': 7.29.7
5714 + '@babel/runtime': 7.29.7
5715 '@emotion/hash': 0.9.2
5716 '@emotion/memoize': 0.9.0
5717 '@emotion/serialize': 1.3.3
@@ -5641,9 +5742,9 @@ snapshots:
5742
5743 '@emotion/memoize@0.9.0': {}
5744
5644 - '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6)':
5745 + '@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)':
5746 dependencies:
5646 - '@babel/runtime': 7.29.2
5747 + '@babel/runtime': 7.29.7
5748 '@emotion/babel-plugin': 11.13.5
5749 '@emotion/cache': 11.14.0
5750 '@emotion/serialize': 1.3.3
@@ -5653,7 +5754,7 @@ snapshots:
5754 hoist-non-react-statics: 3.3.2
5755 react: 19.2.6
5756 optionalDependencies:
5656 - '@types/react': 19.2.14
5757 + '@types/react': 19.2.15
5758 transitivePeerDependencies:
5759 - supports-color
5760
@@ -5667,18 +5768,18 @@ snapshots:
5768
5769 '@emotion/sheet@1.4.0': {}
5770
5670 - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)':
5771 + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)':
5772 dependencies:
5672 - '@babel/runtime': 7.29.2
5773 + '@babel/runtime': 7.29.7
5774 '@emotion/babel-plugin': 11.13.5
5775 '@emotion/is-prop-valid': 1.4.0
5675 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6)
5776 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)
5777 '@emotion/serialize': 1.3.3
5778 '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6)
5779 '@emotion/utils': 1.4.2
5780 react: 19.2.6
5781 optionalDependencies:
5681 - '@types/react': 19.2.14
5782 + '@types/react': 19.2.15
5783 transitivePeerDependencies:
5784 - supports-color
5785
@@ -5695,7 +5796,7 @@ snapshots:
5796 '@es-joy/jsdoccomment@0.84.0':
5797 dependencies:
5798 '@types/estree': 1.0.9
5698 - '@typescript-eslint/types': 8.59.3
5799 + '@typescript-eslint/types': 8.60.0
5800 comment-parser: 1.4.5
5801 esquery: 1.7.0
5802 jsdoc-type-pratt-parser: 7.1.1
@@ -5703,7 +5804,7 @@ snapshots:
5804 '@es-joy/jsdoccomment@0.86.0':
5805 dependencies:
5806 '@types/estree': 1.0.9
5706 - '@typescript-eslint/types': 8.59.3
5807 + '@typescript-eslint/types': 8.60.0
5808 comment-parser: 1.4.6
5809 esquery: 1.7.0
5810 jsdoc-type-pratt-parser: 7.2.0
@@ -5749,10 +5850,10 @@ snapshots:
5850 dependencies:
5851 '@types/json-schema': 7.0.15
5852
5752 - '@eslint/markdown@8.0.1':
5853 + '@eslint/markdown@8.0.2':
5854 dependencies:
5855 '@eslint/core': 1.2.1
5755 - '@eslint/plugin-kit': 0.6.1
5856 + '@eslint/plugin-kit': 0.7.1
5857 github-slugger: 2.0.0
5858 mdast-util-from-markdown: 2.0.3
5859 mdast-util-frontmatter: 2.0.1
@@ -5777,11 +5878,11 @@ snapshots:
5878 '@eslint/core': 1.2.1
5879 levn: 0.4.1
5880
5780 - '@exodus/bytes@1.15.0': {}
5881 + '@exodus/bytes@1.15.1': {}
5882
5883 '@f3ve/vue-markdown-it@0.2.3(vue@3.5.34(typescript@6.0.3))':
5884 dependencies:
5784 - markdown-it: 14.1.1
5885 + markdown-it: 14.2.0
5886 vue: 3.5.34(typescript@6.0.3)
5887
5888 '@fontsource/jetbrains-mono@5.2.8': {}
@@ -5877,20 +5978,20 @@ snapshots:
5978 '@jridgewell/resolve-uri': 3.1.2
5979 '@jridgewell/sourcemap-codec': 1.5.5
5980
5880 - '@jscpd/badge-reporter@4.2.3':
5981 + '@jscpd/badge-reporter@4.2.4':
5982 dependencies:
5983 badgen: 3.3.2
5984 colors: 1.4.0
5985 fs-extra: 11.3.5
5986
5886 - '@jscpd/core@4.2.3':
5987 + '@jscpd/core@4.2.4':
5988 dependencies:
5989 eventemitter3: 5.0.4
5990
5890 - '@jscpd/finder@4.2.3':
5991 + '@jscpd/finder@4.2.4':
5992 dependencies:
5892 - '@jscpd/core': 4.2.3
5893 - '@jscpd/tokenizer': 4.2.3
5993 + '@jscpd/core': 4.2.4
5994 + '@jscpd/tokenizer': 4.2.4
5995 blamer: 1.0.7
5996 bytes: 3.1.2
5997 cli-table3: 0.6.5
@@ -5900,15 +6001,15 @@ snapshots:
6001 markdown-table: 2.0.0
6002 pug: 3.0.4
6003
5903 - '@jscpd/html-reporter@4.2.3':
6004 + '@jscpd/html-reporter@4.2.4':
6005 dependencies:
6006 colors: 1.4.0
6007 fs-extra: 11.3.5
6008 pug: 3.0.4
6009
5909 - '@jscpd/tokenizer@4.2.3':
6010 + '@jscpd/tokenizer@4.2.4':
6011 dependencies:
5911 - '@jscpd/core': 4.2.3
6012 + '@jscpd/core': 4.2.4
6013 spark-md5: 3.0.2
6014
6015 '@juggle/resize-observer@3.4.0': {}
@@ -5941,15 +6042,15 @@ snapshots:
6042
6043 '@mui/core-downloads-tracker@9.0.1': {}
6044
5944 - '@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
6045 + '@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
6046 dependencies:
5946 - '@babel/runtime': 7.29.2
6047 + '@babel/runtime': 7.29.7
6048 '@mui/core-downloads-tracker': 9.0.1
5948 - '@mui/system': 9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)
5949 - '@mui/types': 9.0.0(@types/react@19.2.14)
5950 - '@mui/utils': 9.0.1(@types/react@19.2.14)(react@19.2.6)
6049 + '@mui/system': 9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)
6050 + '@mui/types': 9.0.0(@types/react@19.2.15)
6051 + '@mui/utils': 9.0.1(@types/react@19.2.15)(react@19.2.6)
6052 '@popperjs/core': 2.11.8
5952 - '@types/react-transition-group': 4.4.12(@types/react@19.2.14)
6053 + '@types/react-transition-group': 4.4.12(@types/react@19.2.15)
6054 clsx: 2.1.1
6055 csstype: 3.2.3
6056 prop-types: 15.8.1
@@ -5958,22 +6059,22 @@ snapshots:
6059 react-is: 19.2.6
6060 react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
6061 optionalDependencies:
5961 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6)
5962 - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)
5963 - '@types/react': 19.2.14
6062 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)
6063 + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)
6064 + '@types/react': 19.2.15
6065
5965 - '@mui/private-theming@9.0.1(@types/react@19.2.14)(react@19.2.6)':
6066 + '@mui/private-theming@9.0.1(@types/react@19.2.15)(react@19.2.6)':
6067 dependencies:
5967 - '@babel/runtime': 7.29.2
5968 - '@mui/utils': 9.0.1(@types/react@19.2.14)(react@19.2.6)
6068 + '@babel/runtime': 7.29.7
6069 + '@mui/utils': 9.0.1(@types/react@19.2.15)(react@19.2.6)
6070 prop-types: 15.8.1
6071 react: 19.2.6
6072 optionalDependencies:
5972 - '@types/react': 19.2.14
6073 + '@types/react': 19.2.15
6074
5974 - '@mui/styled-engine@9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
6075 + '@mui/styled-engine@9.0.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(react@19.2.6)':
6076 dependencies:
5976 - '@babel/runtime': 7.29.2
6077 + '@babel/runtime': 7.29.7
6078 '@emotion/cache': 11.14.0
6079 '@emotion/serialize': 1.3.3
6080 '@emotion/sheet': 1.4.0
@@ -5981,42 +6082,42 @@ snapshots:
6082 prop-types: 15.8.1
6083 react: 19.2.6
6084 optionalDependencies:
5984 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6)
5985 - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)
6085 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)
6086 + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)
6087
5987 - '@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)':
6088 + '@mui/system@9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)':
6089 dependencies:
5989 - '@babel/runtime': 7.29.2
5990 - '@mui/private-theming': 9.0.1(@types/react@19.2.14)(react@19.2.6)
5991 - '@mui/styled-engine': 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
5992 - '@mui/types': 9.0.0(@types/react@19.2.14)
5993 - '@mui/utils': 9.0.1(@types/react@19.2.14)(react@19.2.6)
6090 + '@babel/runtime': 7.29.7
6091 + '@mui/private-theming': 9.0.1(@types/react@19.2.15)(react@19.2.6)
6092 + '@mui/styled-engine': 9.0.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(react@19.2.6)
6093 + '@mui/types': 9.0.0(@types/react@19.2.15)
6094 + '@mui/utils': 9.0.1(@types/react@19.2.15)(react@19.2.6)
6095 clsx: 2.1.1
6096 csstype: 3.2.3
6097 prop-types: 15.8.1
6098 react: 19.2.6
6099 optionalDependencies:
5999 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6)
6000 - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)
6001 - '@types/react': 19.2.14
6100 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)
6101 + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)
6102 + '@types/react': 19.2.15
6103
6003 - '@mui/types@9.0.0(@types/react@19.2.14)':
6104 + '@mui/types@9.0.0(@types/react@19.2.15)':
6105 dependencies:
6005 - '@babel/runtime': 7.29.2
6106 + '@babel/runtime': 7.29.7
6107 optionalDependencies:
6007 - '@types/react': 19.2.14
6108 + '@types/react': 19.2.15
6109
6009 - '@mui/utils@9.0.1(@types/react@19.2.14)(react@19.2.6)':
6110 + '@mui/utils@9.0.1(@types/react@19.2.15)(react@19.2.6)':
6111 dependencies:
6011 - '@babel/runtime': 7.29.2
6012 - '@mui/types': 9.0.0(@types/react@19.2.14)
6112 + '@babel/runtime': 7.29.7
6113 + '@mui/types': 9.0.0(@types/react@19.2.15)
6114 '@types/prop-types': 15.7.15
6115 clsx: 2.1.1
6116 prop-types: 15.8.1
6117 react: 19.2.6
6118 react-is: 19.2.6
6119 optionalDependencies:
6019 - '@types/react': 19.2.14
6120 + '@types/react': 19.2.15
6121
6122 '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
6123 dependencies:
@@ -6039,7 +6140,7 @@ snapshots:
6140 '@nodelib/fs.scandir': 2.1.5
6141 fastq: 1.20.1
6142
6042 - '@nuxt/kit@3.21.5':
6143 + '@nuxt/kit@3.21.6':
6144 dependencies:
6145 c12: 3.3.4
6146 consola: 3.4.2
@@ -6057,7 +6158,7 @@ snapshots:
6158 pkg-types: 2.3.1
6159 rc9: 3.0.1
6160 scule: 1.3.0
6060 - semver: 7.8.0
6161 + semver: 7.8.1
6162 tinyglobby: 0.2.16
6163 ufo: 1.6.4
6164 unctx: 2.5.0
@@ -6072,6 +6173,8 @@ snapshots:
6173
6174 '@oxc-project/types@0.130.0': {}
6175
6176 + '@oxc-project/types@0.132.0': {}
6177 +
6178 '@parcel/watcher-android-arm64@2.5.6':
6179 optional: true
6180
@@ -6149,39 +6252,75 @@ snapshots:
6252 '@rolldown/binding-android-arm64@1.0.1':
6253 optional: true
6254
6255 + '@rolldown/binding-android-arm64@1.0.2':
6256 + optional: true
6257 +
6258 '@rolldown/binding-darwin-arm64@1.0.1':
6259 optional: true
6260
6261 + '@rolldown/binding-darwin-arm64@1.0.2':
6262 + optional: true
6263 +
6264 '@rolldown/binding-darwin-x64@1.0.1':
6265 optional: true
6266
6267 + '@rolldown/binding-darwin-x64@1.0.2':
6268 + optional: true
6269 +
6270 '@rolldown/binding-freebsd-x64@1.0.1':
6271 optional: true
6272
6273 + '@rolldown/binding-freebsd-x64@1.0.2':
6274 + optional: true
6275 +
6276 '@rolldown/binding-linux-arm-gnueabihf@1.0.1':
6277 optional: true
6278
6279 + '@rolldown/binding-linux-arm-gnueabihf@1.0.2':
6280 + optional: true
6281 +
6282 '@rolldown/binding-linux-arm64-gnu@1.0.1':
6283 optional: true
6284
6285 + '@rolldown/binding-linux-arm64-gnu@1.0.2':
6286 + optional: true
6287 +
6288 '@rolldown/binding-linux-arm64-musl@1.0.1':
6289 optional: true
6290
6291 + '@rolldown/binding-linux-arm64-musl@1.0.2':
6292 + optional: true
6293 +
6294 '@rolldown/binding-linux-ppc64-gnu@1.0.1':
6295 optional: true
6296
6297 + '@rolldown/binding-linux-ppc64-gnu@1.0.2':
6298 + optional: true
6299 +
6300 '@rolldown/binding-linux-s390x-gnu@1.0.1':
6301 optional: true
6302
6303 + '@rolldown/binding-linux-s390x-gnu@1.0.2':
6304 + optional: true
6305 +
6306 '@rolldown/binding-linux-x64-gnu@1.0.1':
6307 optional: true
6308
6309 + '@rolldown/binding-linux-x64-gnu@1.0.2':
6310 + optional: true
6311 +
6312 '@rolldown/binding-linux-x64-musl@1.0.1':
6313 optional: true
6314
6315 + '@rolldown/binding-linux-x64-musl@1.0.2':
6316 + optional: true
6317 +
6318 '@rolldown/binding-openharmony-arm64@1.0.1':
6319 optional: true
6320
6321 + '@rolldown/binding-openharmony-arm64@1.0.2':
6322 + optional: true
6323 +
6324 '@rolldown/binding-wasm32-wasi@1.0.1':
6325 dependencies:
6326 '@emnapi/core': 1.10.0
@@ -6189,73 +6328,86 @@ snapshots:
6328 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
6329 optional: true
6330
6331 + '@rolldown/binding-wasm32-wasi@1.0.2':
6332 + dependencies:
6333 + '@emnapi/core': 1.10.0
6334 + '@emnapi/runtime': 1.10.0
6335 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
6336 + optional: true
6337 +
6338 '@rolldown/binding-win32-arm64-msvc@1.0.1':
6339 optional: true
6340
6341 + '@rolldown/binding-win32-arm64-msvc@1.0.2':
6342 + optional: true
6343 +
6344 '@rolldown/binding-win32-x64-msvc@1.0.1':
6345 optional: true
6346
6347 + '@rolldown/binding-win32-x64-msvc@1.0.2':
6348 + optional: true
6349 +
6350 '@rolldown/pluginutils@1.0.1': {}
6351
6352 '@rollup/rollup-linux-x64-gnu@4.60.4':
6353 optional: true
6354
6203 - '@shikijs/core@4.0.2':
6355 + '@shikijs/core@4.1.0':
6356 dependencies:
6205 - '@shikijs/primitive': 4.0.2
6206 - '@shikijs/types': 4.0.2
6357 + '@shikijs/primitive': 4.1.0
6358 + '@shikijs/types': 4.1.0
6359 '@shikijs/vscode-textmate': 10.0.2
6360 '@types/hast': 3.0.4
6361 hast-util-to-html: 9.0.5
6362
6211 - '@shikijs/engine-javascript@4.0.2':
6363 + '@shikijs/engine-javascript@4.1.0':
6364 dependencies:
6213 - '@shikijs/types': 4.0.2
6365 + '@shikijs/types': 4.1.0
6366 '@shikijs/vscode-textmate': 10.0.2
6367 oniguruma-to-es: 4.3.6
6368
6217 - '@shikijs/engine-oniguruma@4.0.2':
6369 + '@shikijs/engine-oniguruma@4.1.0':
6370 dependencies:
6219 - '@shikijs/types': 4.0.2
6371 + '@shikijs/types': 4.1.0
6372 '@shikijs/vscode-textmate': 10.0.2
6373
6222 - '@shikijs/langs@4.0.2':
6374 + '@shikijs/langs@4.1.0':
6375 dependencies:
6224 - '@shikijs/types': 4.0.2
6376 + '@shikijs/types': 4.1.0
6377
6226 - '@shikijs/markdown-it@4.0.2':
6378 + '@shikijs/markdown-it@4.1.0':
6379 dependencies:
6228 - markdown-it: 14.1.1
6229 - shiki: 4.0.2
6380 + markdown-it: 14.2.0
6381 + shiki: 4.1.0
6382
6231 - '@shikijs/primitive@4.0.2':
6383 + '@shikijs/primitive@4.1.0':
6384 dependencies:
6233 - '@shikijs/types': 4.0.2
6385 + '@shikijs/types': 4.1.0
6386 '@shikijs/vscode-textmate': 10.0.2
6387 '@types/hast': 3.0.4
6388
6237 - '@shikijs/themes@4.0.2':
6389 + '@shikijs/themes@4.1.0':
6390 dependencies:
6239 - '@shikijs/types': 4.0.2
6391 + '@shikijs/types': 4.1.0
6392
6241 - '@shikijs/types@4.0.2':
6393 + '@shikijs/types@4.1.0':
6394 dependencies:
6395 '@shikijs/vscode-textmate': 10.0.2
6396 '@types/hast': 3.0.4
6397
6398 '@shikijs/vscode-textmate@10.0.2': {}
6399
6248 - '@shuffleio/shuffle-mcps@0.0.13(@emotion/is-prop-valid@1.4.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/react@19.2.14)(lucide-react@1.16.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
6400 + '@shuffleio/shuffle-mcps@0.0.26(@emotion/is-prop-valid@1.4.0)(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@mui/material@9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
6401 dependencies:
6250 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6)
6251 - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)
6252 - '@mui/material': 9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
6402 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)
6403 + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)
6404 + '@mui/material': 9.0.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
6405 algoliasearch: 5.52.1
6406 framer-motion: 11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
6407 lucide-react: 1.16.0(react@19.2.6)
6408 react: 19.2.6
6409 react-dom: 19.2.6(react@19.2.6)
6258 - react-markdown: 9.1.0(@types/react@19.2.14)(react@19.2.6)
6410 + react-markdown: 10.1.0(@types/react@19.2.15)(react@19.2.6)
6411 react-router-dom: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
6412 react18-json-view: 0.2.10(react@19.2.6)
6413 remark-breaks: 4.0.0
@@ -6273,7 +6425,7 @@ snapshots:
6425 '@stylistic/eslint-plugin@5.10.0(eslint@10.4.0(jiti@2.7.0))':
6426 dependencies:
6427 '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
6276 - '@typescript-eslint/types': 8.59.3
6428 + '@typescript-eslint/types': 8.60.0
6429 eslint: 10.4.0(jiti@2.7.0)
6430 eslint-visitor-keys: 4.2.1
6431 espree: 10.4.0
@@ -6283,7 +6435,7 @@ snapshots:
6435 '@tailwindcss/node@4.3.0':
6436 dependencies:
6437 '@jridgewell/remapping': 2.3.5
6286 - enhanced-resolve: 5.21.3
6438 + enhanced-resolve: 5.22.0
6439 jiti: 2.7.0
6440 lightningcss: 1.32.0
6441 magic-string: 0.30.21
@@ -6341,12 +6493,12 @@ snapshots:
6493 '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
6494 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0
6495
6344 - '@tailwindcss/vite@4.3.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))':
6496 + '@tailwindcss/vite@4.3.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))':
6497 dependencies:
6498 '@tailwindcss/node': 4.3.0
6499 '@tailwindcss/oxide': 4.3.0
6500 tailwindcss: 4.3.0
6349 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
6501 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
6502
6503 '@tsconfig/node20@20.1.9': {}
6504
@@ -6385,7 +6537,7 @@ snapshots:
6537 '@types/fs-extra@11.0.4':
6538 dependencies:
6539 '@types/jsonfile': 6.1.4
6388 - '@types/node': 25.8.0
6540 + '@types/node': 25.9.1
6541
6542 '@types/hast@3.0.4':
6543 dependencies:
@@ -6393,10 +6545,10 @@ snapshots:
6545
6546 '@types/jsdom@28.0.3':
6547 dependencies:
6396 - '@types/node': 25.8.0
6548 + '@types/node': 25.9.1
6549 '@types/tough-cookie': 4.0.5
6550 parse5: 8.0.1
6399 - undici-types: 7.25.0
6551 + undici-types: 7.26.0
6552
6553 '@types/jsesc@2.5.1': {}
6554
@@ -6404,7 +6556,7 @@ snapshots:
6556
6557 '@types/jsonfile@6.1.4':
6558 dependencies:
6407 - '@types/node': 25.8.0
6559 + '@types/node': 25.9.1
6560
6561 '@types/katex@0.16.8': {}
6562
@@ -6431,31 +6583,27 @@ snapshots:
6583
6584 '@types/ms@2.1.0': {}
6585
6434 - '@types/node@25.8.0':
6435 - dependencies:
6436 - undici-types: 7.24.6
6437 -
6438 - '@types/node@25.9.0':
6586 + '@types/node@25.9.1':
6587 dependencies:
6588 undici-types: 7.24.6
6589
6590 '@types/papaparse@5.5.2':
6591 dependencies:
6444 - '@types/node': 25.8.0
6592 + '@types/node': 25.9.1
6593
6594 '@types/parse-json@4.0.2': {}
6595
6596 '@types/prop-types@15.7.15': {}
6597
6450 - '@types/react-dom@19.2.3(@types/react@19.2.14)':
6598 + '@types/react-dom@19.2.3(@types/react@19.2.15)':
6599 dependencies:
6452 - '@types/react': 19.2.14
6600 + '@types/react': 19.2.15
6601
6454 - '@types/react-transition-group@4.4.12(@types/react@19.2.14)':
6602 + '@types/react-transition-group@4.4.12(@types/react@19.2.15)':
6603 dependencies:
6456 - '@types/react': 19.2.14
6604 + '@types/react': 19.2.15
6605
6458 - '@types/react@19.2.14':
6606 + '@types/react@19.2.15':
6607 dependencies:
6608 csstype: 3.2.3
6609
@@ -6475,14 +6623,14 @@ snapshots:
6623
6624 '@types/web-bluetooth@0.0.21': {}
6625
6478 - '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6626 + '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6627 dependencies:
6628 '@eslint-community/regexpp': 4.12.2
6481 - '@typescript-eslint/parser': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6482 - '@typescript-eslint/scope-manager': 8.59.3
6483 - '@typescript-eslint/type-utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6484 - '@typescript-eslint/utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6485 - '@typescript-eslint/visitor-keys': 8.59.3
6629 + '@typescript-eslint/parser': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6630 + '@typescript-eslint/scope-manager': 8.60.0
6631 + '@typescript-eslint/type-utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6632 + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6633 + '@typescript-eslint/visitor-keys': 8.60.0
6634 eslint: 10.4.0(jiti@2.7.0)
6635 ignore: 7.0.5
6636 natural-compare: 1.4.0
@@ -6491,55 +6639,55 @@ snapshots:
6639 transitivePeerDependencies:
6640 - supports-color
6641
6494 - '@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6642 + '@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6643 dependencies:
6496 - '@typescript-eslint/scope-manager': 8.59.3
6497 - '@typescript-eslint/types': 8.59.3
6498 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
6499 - '@typescript-eslint/visitor-keys': 8.59.3
6644 + '@typescript-eslint/scope-manager': 8.60.0
6645 + '@typescript-eslint/types': 8.60.0
6646 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@6.0.3)
6647 + '@typescript-eslint/visitor-keys': 8.60.0
6648 debug: 4.4.3
6649 eslint: 10.4.0(jiti@2.7.0)
6650 typescript: 6.0.3
6651 transitivePeerDependencies:
6652 - supports-color
6653
6506 - '@typescript-eslint/project-service@8.59.3(typescript@6.0.3)':
6654 + '@typescript-eslint/project-service@8.60.0(typescript@6.0.3)':
6655 dependencies:
6508 - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.3)
6509 - '@typescript-eslint/types': 8.59.3
6656 + '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@6.0.3)
6657 + '@typescript-eslint/types': 8.60.0
6658 debug: 4.4.3
6659 typescript: 6.0.3
6660 transitivePeerDependencies:
6661 - supports-color
6662
6515 - '@typescript-eslint/rule-tester@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6663 + '@typescript-eslint/rule-tester@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6664 dependencies:
6517 - '@typescript-eslint/parser': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6518 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
6519 - '@typescript-eslint/utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6665 + '@typescript-eslint/parser': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6666 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@6.0.3)
6667 + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6668 ajv: 6.15.0
6669 eslint: 10.4.0(jiti@2.7.0)
6670 json-stable-stringify-without-jsonify: 1.0.1
6671 lodash.merge: 4.6.2
6524 - semver: 7.8.0
6672 + semver: 7.8.1
6673 typescript: 6.0.3
6674 transitivePeerDependencies:
6675 - supports-color
6676
6529 - '@typescript-eslint/scope-manager@8.59.3':
6677 + '@typescript-eslint/scope-manager@8.60.0':
6678 dependencies:
6531 - '@typescript-eslint/types': 8.59.3
6532 - '@typescript-eslint/visitor-keys': 8.59.3
6679 + '@typescript-eslint/types': 8.60.0
6680 + '@typescript-eslint/visitor-keys': 8.60.0
6681
6534 - '@typescript-eslint/tsconfig-utils@8.59.3(typescript@6.0.3)':
6682 + '@typescript-eslint/tsconfig-utils@8.60.0(typescript@6.0.3)':
6683 dependencies:
6684 typescript: 6.0.3
6685
6538 - '@typescript-eslint/type-utils@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6686 + '@typescript-eslint/type-utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6687 dependencies:
6540 - '@typescript-eslint/types': 8.59.3
6541 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
6542 - '@typescript-eslint/utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6688 + '@typescript-eslint/types': 8.60.0
6689 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@6.0.3)
6690 + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6691 debug: 4.4.3
6692 eslint: 10.4.0(jiti@2.7.0)
6693 ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -6547,109 +6695,109 @@ snapshots:
6695 transitivePeerDependencies:
6696 - supports-color
6697
6550 - '@typescript-eslint/types@8.59.3': {}
6698 + '@typescript-eslint/types@8.60.0': {}
6699
6552 - '@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3)':
6700 + '@typescript-eslint/typescript-estree@8.60.0(typescript@6.0.3)':
6701 dependencies:
6554 - '@typescript-eslint/project-service': 8.59.3(typescript@6.0.3)
6555 - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.3)
6556 - '@typescript-eslint/types': 8.59.3
6557 - '@typescript-eslint/visitor-keys': 8.59.3
6702 + '@typescript-eslint/project-service': 8.60.0(typescript@6.0.3)
6703 + '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@6.0.3)
6704 + '@typescript-eslint/types': 8.60.0
6705 + '@typescript-eslint/visitor-keys': 8.60.0
6706 debug: 4.4.3
6707 minimatch: 10.2.5
6560 - semver: 7.8.0
6708 + semver: 7.8.1
6709 tinyglobby: 0.2.16
6710 ts-api-utils: 2.5.0(typescript@6.0.3)
6711 typescript: 6.0.3
6712 transitivePeerDependencies:
6713 - supports-color
6714
6567 - '@typescript-eslint/utils@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6715 + '@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
6716 dependencies:
6717 '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
6570 - '@typescript-eslint/scope-manager': 8.59.3
6571 - '@typescript-eslint/types': 8.59.3
6572 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
6718 + '@typescript-eslint/scope-manager': 8.60.0
6719 + '@typescript-eslint/types': 8.60.0
6720 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@6.0.3)
6721 eslint: 10.4.0(jiti@2.7.0)
6722 typescript: 6.0.3
6723 transitivePeerDependencies:
6724 - supports-color
6725
6578 - '@typescript-eslint/visitor-keys@8.59.3':
6726 + '@typescript-eslint/visitor-keys@8.60.0':
6727 dependencies:
6580 - '@typescript-eslint/types': 8.59.3
6728 + '@typescript-eslint/types': 8.60.0
6729 eslint-visitor-keys: 5.0.1
6730
6731 '@ungap/structured-clone@1.3.1': {}
6732
6585 - '@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))':
6733 + '@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))':
6734 dependencies:
6587 - '@babel/core': 7.29.0
6588 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
6589 - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
6735 + '@babel/core': 7.29.7
6736 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
6737 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
6738 '@rolldown/pluginutils': 1.0.1
6591 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0)
6592 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
6739 + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7)
6740 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
6741 vue: 3.5.34(typescript@6.0.3)
6742 transitivePeerDependencies:
6743 - supports-color
6744
6597 - '@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))':
6745 + '@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))':
6746 dependencies:
6747 '@rolldown/pluginutils': 1.0.1
6600 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
6748 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
6749 vue: 3.5.34(typescript@6.0.3)
6750
6603 - '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)))':
6751 + '@vitest/eslint-plugin@1.6.18(@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)))':
6752 dependencies:
6605 - '@typescript-eslint/scope-manager': 8.59.3
6606 - '@typescript-eslint/utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6753 + '@typescript-eslint/scope-manager': 8.60.0
6754 + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6755 eslint: 10.4.0(jiti@2.7.0)
6756 optionalDependencies:
6609 - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6757 + '@typescript-eslint/eslint-plugin': 8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
6758 typescript: 6.0.3
6611 - vitest: 4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
6759 + vitest: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
6760 transitivePeerDependencies:
6761 - supports-color
6762
6615 - '@vitest/expect@4.1.6':
6763 + '@vitest/expect@4.1.7':
6764 dependencies:
6765 '@standard-schema/spec': 1.1.0
6766 '@types/chai': 5.2.3
6619 - '@vitest/spy': 4.1.6
6620 - '@vitest/utils': 4.1.6
6767 + '@vitest/spy': 4.1.7
6768 + '@vitest/utils': 4.1.7
6769 chai: 6.2.2
6770 tinyrainbow: 3.1.0
6771
6624 - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))':
6772 + '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))':
6773 dependencies:
6626 - '@vitest/spy': 4.1.6
6774 + '@vitest/spy': 4.1.7
6775 estree-walker: 3.0.3
6776 magic-string: 0.30.21
6777 optionalDependencies:
6630 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
6778 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
6779
6632 - '@vitest/pretty-format@4.1.6':
6780 + '@vitest/pretty-format@4.1.7':
6781 dependencies:
6782 tinyrainbow: 3.1.0
6783
6636 - '@vitest/runner@4.1.6':
6784 + '@vitest/runner@4.1.7':
6785 dependencies:
6638 - '@vitest/utils': 4.1.6
6786 + '@vitest/utils': 4.1.7
6787 pathe: 2.0.3
6788
6641 - '@vitest/snapshot@4.1.6':
6789 + '@vitest/snapshot@4.1.7':
6790 dependencies:
6643 - '@vitest/pretty-format': 4.1.6
6644 - '@vitest/utils': 4.1.6
6791 + '@vitest/pretty-format': 4.1.7
6792 + '@vitest/utils': 4.1.7
6793 magic-string: 0.30.21
6794 pathe: 2.0.3
6795
6648 - '@vitest/spy@4.1.6': {}
6796 + '@vitest/spy@4.1.7': {}
6797
6650 - '@vitest/utils@4.1.6':
6798 + '@vitest/utils@4.1.7':
6799 dependencies:
6652 - '@vitest/pretty-format': 4.1.6
6800 + '@vitest/pretty-format': 4.1.7
6801 convert-source-map: 2.0.0
6802 tinyrainbow: 3.1.0
6803
@@ -6669,7 +6817,7 @@ snapshots:
6817 dependencies:
6818 '@vue/compiler-sfc': 3.5.34
6819 ast-kit: 2.2.0
6672 - local-pkg: 1.1.2
6820 + local-pkg: 1.2.1
6821 magic-string-ast: 1.0.3
6822 unplugin-utils: 0.3.1
6823 optionalDependencies:
@@ -6679,63 +6827,63 @@ snapshots:
6827
6828 '@vue/babel-helper-vue-transform-on@2.0.1': {}
6829
6682 - '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.0)':
6830 + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.7)':
6831 dependencies:
6684 - '@babel/helper-module-imports': 7.28.6
6685 - '@babel/helper-plugin-utils': 7.28.6
6686 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
6687 - '@babel/template': 7.28.6
6688 - '@babel/traverse': 7.29.0
6689 - '@babel/types': 7.29.0
6832 + '@babel/helper-module-imports': 7.29.7
6833 + '@babel/helper-plugin-utils': 7.29.7
6834 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
6835 + '@babel/template': 7.29.7
6836 + '@babel/traverse': 7.29.7
6837 + '@babel/types': 7.29.7
6838 '@vue/babel-helper-vue-transform-on': 1.5.0
6691 - '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.0)
6839 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.7)
6840 '@vue/shared': 3.5.34
6841 optionalDependencies:
6694 - '@babel/core': 7.29.0
6842 + '@babel/core': 7.29.7
6843 transitivePeerDependencies:
6844 - supports-color
6845
6698 - '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.0)':
6846 + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7)':
6847 dependencies:
6700 - '@babel/helper-module-imports': 7.28.6
6701 - '@babel/helper-plugin-utils': 7.28.6
6702 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
6703 - '@babel/template': 7.28.6
6704 - '@babel/traverse': 7.29.0
6705 - '@babel/types': 7.29.0
6848 + '@babel/helper-module-imports': 7.29.7
6849 + '@babel/helper-plugin-utils': 7.29.7
6850 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
6851 + '@babel/template': 7.29.7
6852 + '@babel/traverse': 7.29.7
6853 + '@babel/types': 7.29.7
6854 '@vue/babel-helper-vue-transform-on': 2.0.1
6707 - '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0)
6855 + '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7)
6856 '@vue/shared': 3.5.34
6857 optionalDependencies:
6710 - '@babel/core': 7.29.0
6858 + '@babel/core': 7.29.7
6859 transitivePeerDependencies:
6860 - supports-color
6861
6714 - '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.0)':
6862 + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.7)':
6863 dependencies:
6716 - '@babel/code-frame': 7.29.0
6717 - '@babel/core': 7.29.0
6718 - '@babel/helper-module-imports': 7.28.6
6719 - '@babel/helper-plugin-utils': 7.28.6
6720 - '@babel/parser': 7.29.3
6864 + '@babel/code-frame': 7.29.7
6865 + '@babel/core': 7.29.7
6866 + '@babel/helper-module-imports': 7.29.7
6867 + '@babel/helper-plugin-utils': 7.29.7
6868 + '@babel/parser': 7.29.7
6869 '@vue/compiler-sfc': 3.5.34
6870 transitivePeerDependencies:
6871 - supports-color
6872
6725 - '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.0)':
6873 + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7)':
6874 dependencies:
6727 - '@babel/code-frame': 7.29.0
6728 - '@babel/core': 7.29.0
6729 - '@babel/helper-module-imports': 7.28.6
6730 - '@babel/helper-plugin-utils': 7.28.6
6731 - '@babel/parser': 7.29.3
6875 + '@babel/code-frame': 7.29.7
6876 + '@babel/core': 7.29.7
6877 + '@babel/helper-module-imports': 7.29.7
6878 + '@babel/helper-plugin-utils': 7.29.7
6879 + '@babel/parser': 7.29.7
6880 '@vue/compiler-sfc': 3.5.34
6881 transitivePeerDependencies:
6882 - supports-color
6883
6884 '@vue/compiler-core@3.5.34':
6885 dependencies:
6738 - '@babel/parser': 7.29.3
6886 + '@babel/parser': 7.29.7
6887 '@vue/shared': 3.5.34
6888 entities: 7.0.1
6889 estree-walker: 2.0.2
@@ -6748,14 +6896,14 @@ snapshots:
6896
6897 '@vue/compiler-sfc@3.5.34':
6898 dependencies:
6751 - '@babel/parser': 7.29.3
6899 + '@babel/parser': 7.29.7
6900 '@vue/compiler-core': 3.5.34
6901 '@vue/compiler-dom': 3.5.34
6902 '@vue/compiler-ssr': 3.5.34
6903 '@vue/shared': 3.5.34
6904 estree-walker: 2.0.2
6905 magic-string: 0.30.21
6758 - postcss: 8.5.14
6906 + postcss: 8.5.15
6907 source-map-js: 1.2.1
6908
6909 '@vue/compiler-ssr@3.5.34':
@@ -6802,7 +6950,7 @@ snapshots:
6950
6951 '@vue/devtools-shared@8.1.2': {}
6952
6805 - '@vue/language-core@3.3.0':
6953 + '@vue/language-core@3.3.2':
6954 dependencies:
6955 '@volar/language-core': 2.4.28
6956 '@vue/compiler-dom': 3.5.34
@@ -6841,7 +6989,7 @@ snapshots:
6989 '@vue/compiler-dom': 3.5.34
6990 js-beautify: 1.15.4
6991 vue: 3.5.34(typescript@6.0.3)
6844 - vue-component-type-helpers: 3.2.9
6992 + vue-component-type-helpers: 3.3.2
6993 optionalDependencies:
6994 '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3))
6995
@@ -6878,7 +7026,7 @@ snapshots:
7026 style-value-types: 5.1.2
7027 vue: 3.5.34(typescript@6.0.3)
7028 optionalDependencies:
6881 - '@nuxt/kit': 3.21.5
7029 + '@nuxt/kit': 3.21.6
7030 transitivePeerDependencies:
7031 - magicast
7032
@@ -6944,7 +7092,7 @@ snapshots:
7092
7093 ansis@4.3.0: {}
7094
6947 - apexcharts@5.12.0: {}
7095 + apexcharts@5.13.0: {}
7096
7097 are-docs-informative@0.0.2: {}
7098
@@ -6970,12 +7118,12 @@ snapshots:
7118
7119 ast-kit@2.2.0:
7120 dependencies:
6973 - '@babel/parser': 7.29.3
7121 + '@babel/parser': 7.29.7
7122 pathe: 2.0.3
7123
7124 ast-walker-scope@0.8.3:
7125 dependencies:
6978 - '@babel/parser': 7.29.3
7126 + '@babel/parser': 7.29.7
7127 ast-kit: 2.2.0
7128
7129 async-validator@4.2.5: {}
@@ -6994,13 +7142,13 @@ snapshots:
7142
7143 babel-plugin-macros@3.1.0:
7144 dependencies:
6997 - '@babel/runtime': 7.29.2
7145 + '@babel/runtime': 7.29.7
7146 cosmiconfig: 7.1.0
7147 resolve: 1.22.12
7148
7149 babel-walk@3.0.0-canary-5:
7150 dependencies:
7003 - '@babel/types': 7.29.0
7151 + '@babel/types': 7.29.7
7152
7153 badgen@3.3.2: {}
7154
@@ -7010,9 +7158,7 @@ snapshots:
7158
7159 balanced-match@4.0.4: {}
7160
7013 - baseline-browser-mapping@2.10.29: {}
7014 -
7015 - baseline-browser-mapping@2.10.31: {}
7161 + baseline-browser-mapping@2.10.32: {}
7162
7163 bidi-js@1.0.3:
7164 dependencies:
@@ -7034,7 +7180,7 @@ snapshots:
7180 balanced-match: 1.0.2
7181 concat-map: 0.0.1
7182
7037 - brace-expansion@2.1.0:
7183 + brace-expansion@2.1.1:
7184 dependencies:
7185 balanced-match: 1.0.2
7186
@@ -7048,10 +7194,10 @@ snapshots:
7194
7195 browserslist@4.28.2:
7196 dependencies:
7051 - baseline-browser-mapping: 2.10.29
7052 - caniuse-lite: 1.0.30001792
7053 - electron-to-chromium: 1.5.355
7054 - node-releases: 2.0.44
7197 + baseline-browser-mapping: 2.10.32
7198 + caniuse-lite: 1.0.30001793
7199 + electron-to-chromium: 1.5.361
7200 + node-releases: 2.0.46
7201 update-browserslist-db: 1.2.3(browserslist@4.28.2)
7202
7203 builtin-modules@5.2.0: {}
@@ -7098,7 +7244,7 @@ snapshots:
7244
7245 camelcase@6.3.0: {}
7246
7101 - caniuse-lite@1.0.30001792: {}
7247 + caniuse-lite@1.0.30001793: {}
7248
7249 ccount@2.0.1: {}
7250
@@ -7120,10 +7266,6 @@ snapshots:
7266
7267 check-more-types@2.24.0: {}
7268
7123 - chokidar@4.0.3:
7124 - dependencies:
7125 - readdirp: 4.1.2
7126 -
7269 chokidar@5.0.0:
7270 dependencies:
7271 readdirp: 5.0.0
@@ -7199,6 +7341,8 @@ snapshots:
7341
7342 comment-parser@1.4.6: {}
7343
7344 + comment-parser@1.4.7: {}
7345 +
7346 concat-map@0.0.1: {}
7347
7348 confbox@0.1.8: {}
@@ -7215,8 +7359,8 @@ snapshots:
7359
7360 constantinople@4.0.1:
7361 dependencies:
7218 - '@babel/parser': 7.29.3
7219 - '@babel/types': 7.29.0
7362 + '@babel/parser': 7.29.7
7363 + '@babel/types': 7.29.7
7364
7365 convert-source-map@1.9.0: {}
7366
@@ -7301,11 +7445,11 @@ snapshots:
7445 transitivePeerDependencies:
7446 - '@noble/hashes'
7447
7304 - date-fns-tz@3.2.0(date-fns@4.1.0):
7448 + date-fns-tz@3.2.0(date-fns@4.3.0):
7449 dependencies:
7306 - date-fns: 4.1.0
7450 + date-fns: 4.3.0
7451
7308 - date-fns@4.1.0: {}
7452 + date-fns@4.3.0: {}
7453
7454 dayjs@1.11.20: {}
7455
@@ -7340,8 +7484,8 @@ snapshots:
7484
7485 depcheck@1.4.7:
7486 dependencies:
7343 - '@babel/parser': 7.29.3
7344 - '@babel/traverse': 7.29.0
7487 + '@babel/parser': 7.29.7
7488 + '@babel/traverse': 7.29.7
7489 '@vue/compiler-sfc': 3.5.34
7490 callsite: 1.0.0
7491 camelcase: 6.3.0
@@ -7361,7 +7505,7 @@ snapshots:
7505 require-package-name: 2.0.1
7506 resolve: 1.22.12
7507 resolve-from: 5.0.0
7364 - semver: 7.8.0
7508 + semver: 7.8.1
7509 yargs: 16.2.0
7510 transitivePeerDependencies:
7511 - supports-color
@@ -7388,7 +7532,7 @@ snapshots:
7532
7533 dom-helpers@5.2.1:
7534 dependencies:
7391 - '@babel/runtime': 7.29.2
7535 + '@babel/runtime': 7.29.7
7536 csstype: 3.2.3
7537
7538 dom-serializer@2.0.0:
@@ -7422,19 +7566,19 @@ snapshots:
7566
7567 easy-bem@1.1.1: {}
7568
7425 - echarts@6.0.0:
7569 + echarts@6.1.0:
7570 dependencies:
7571 tslib: 2.3.0
7428 - zrender: 6.0.0
7572 + zrender: 6.1.0
7573
7574 editorconfig@1.0.7:
7575 dependencies:
7576 '@one-ini/wasm': 0.1.1
7577 commander: 10.0.1
7578 minimatch: 9.0.9
7435 - semver: 7.8.0
7579 + semver: 7.8.1
7580
7437 - electron-to-chromium@1.5.355: {}
7581 + electron-to-chromium@1.5.361: {}
7582
7583 emoji-regex@8.0.0: {}
7584
@@ -7446,7 +7590,7 @@ snapshots:
7590 dependencies:
7591 once: 1.4.0
7592
7449 - enhanced-resolve@5.21.3:
7593 + enhanced-resolve@5.22.0:
7594 dependencies:
7595 graceful-fs: 4.2.11
7596 tapable: 2.3.3
@@ -7472,7 +7616,7 @@ snapshots:
7616
7617 es-module-lexer@2.1.0: {}
7618
7475 - es-object-atoms@1.1.1:
7619 + es-object-atoms@1.1.2:
7620 dependencies:
7621 es-errors: 1.3.0
7622
@@ -7494,7 +7638,7 @@ snapshots:
7638 eslint-compat-utils@0.5.1(eslint@10.4.0(jiti@2.7.0)):
7639 dependencies:
7640 eslint: 10.4.0(jiti@2.7.0)
7497 - semver: 7.8.0
7641 + semver: 7.8.1
7642
7643 eslint-config-flat-gitignore@2.3.0(eslint@10.4.0(jiti@2.7.0)):
7644 dependencies:
@@ -7520,12 +7664,12 @@ snapshots:
7664 dependencies:
7665 eslint: 10.4.0(jiti@2.7.0)
7666
7523 - eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3))(@typescript-eslint/utils@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0)):
7667 + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.60.0(typescript@6.0.3))(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0)):
7668 dependencies:
7669 '@es-joy/jsdoccomment': 0.84.0
7526 - '@typescript-eslint/rule-tester': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7527 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
7528 - '@typescript-eslint/utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7670 + '@typescript-eslint/rule-tester': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7671 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@6.0.3)
7672 + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7673 eslint: 10.4.0(jiti@2.7.0)
7674
7675 eslint-plugin-es-x@7.8.0(eslint@10.4.0(jiti@2.7.0)):
@@ -7551,9 +7695,9 @@ snapshots:
7695 espree: 11.2.0
7696 esquery: 1.7.0
7697 html-entities: 2.6.0
7554 - object-deep-merge: 2.0.0
7698 + object-deep-merge: 2.0.1
7699 parse-imports-exports: 0.2.4
7556 - semver: 7.8.0
7700 + semver: 7.8.1
7701 spdx-expression-parse: 4.0.0
7702 to-valid-identifier: 1.0.0
7703 transitivePeerDependencies:
@@ -7577,14 +7721,14 @@ snapshots:
7721 eslint-plugin-n@18.0.1(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3):
7722 dependencies:
7723 '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
7580 - enhanced-resolve: 5.21.3
7724 + enhanced-resolve: 5.22.0
7725 eslint: 10.4.0(jiti@2.7.0)
7726 eslint-plugin-es-x: 7.8.0(eslint@10.4.0(jiti@2.7.0))
7727 get-tsconfig: 4.14.0
7728 globals: 15.15.0
7729 globrex: 0.1.2
7730 ignore: 5.3.2
7587 - semver: 7.8.0
7731 + semver: 7.8.1
7732 optionalDependencies:
7733 typescript: 6.0.3
7734
@@ -7592,20 +7736,20 @@ snapshots:
7736
7737 eslint-plugin-perfectionist@5.9.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3):
7738 dependencies:
7595 - '@typescript-eslint/utils': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7739 + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7740 eslint: 10.4.0(jiti@2.7.0)
7741 natural-orderby: 5.0.0
7742 transitivePeerDependencies:
7743 - supports-color
7744 - typescript
7745
7602 - eslint-plugin-pnpm@1.6.0(eslint@10.4.0(jiti@2.7.0)):
7746 + eslint-plugin-pnpm@1.6.1(eslint@10.4.0(jiti@2.7.0)):
7747 dependencies:
7748 empathic: 2.0.1
7749 eslint: 10.4.0(jiti@2.7.0)
7750 jsonc-eslint-parser: 3.1.0
7751 pathe: 2.0.3
7608 - pnpm-workspace-yaml: 1.6.0
7752 + pnpm-workspace-yaml: 1.6.1
7753 tinyglobby: 0.2.16
7754 yaml: 2.9.0
7755 yaml-eslint-parser: 2.0.0
@@ -7614,7 +7758,7 @@ snapshots:
7758 dependencies:
7759 '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
7760 '@eslint-community/regexpp': 4.12.2
7617 - comment-parser: 1.4.6
7761 + comment-parser: 1.4.7
7762 eslint: 10.4.0(jiti@2.7.0)
7763 jsdoc-type-pratt-parser: 7.2.0
7764 refa: 0.12.1
@@ -7634,7 +7778,7 @@ snapshots:
7778
7779 eslint-plugin-unicorn@64.0.0(eslint@10.4.0(jiti@2.7.0)):
7780 dependencies:
7637 - '@babel/helper-validator-identifier': 7.28.5
7781 + '@babel/helper-validator-identifier': 7.29.7
7782 '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
7783 change-case: 5.4.4
7784 ci-info: 4.4.0
@@ -7649,28 +7793,28 @@ snapshots:
7793 pluralize: 8.0.0
7794 regexp-tree: 0.1.27
7795 regjsparser: 0.13.1
7652 - semver: 7.8.0
7796 + semver: 7.8.1
7797 strip-indent: 4.1.1
7798
7655 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0)):
7799 + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0)):
7800 dependencies:
7801 eslint: 10.4.0(jiti@2.7.0)
7802 optionalDependencies:
7659 - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7803 + '@typescript-eslint/eslint-plugin': 8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7804
7661 - eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.4.0(jiti@2.7.0)))(@typescript-eslint/parser@8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.7.0))):
7805 + eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.4.0(jiti@2.7.0)))(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.7.0))):
7806 dependencies:
7807 '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0))
7808 eslint: 10.4.0(jiti@2.7.0)
7809 natural-compare: 1.4.0
7810 nth-check: 2.1.1
7811 postcss-selector-parser: 7.1.1
7668 - semver: 7.8.0
7812 + semver: 7.8.1
7813 vue-eslint-parser: 10.4.0(eslint@10.4.0(jiti@2.7.0))
7814 xml-name-validator: 4.0.0
7815 optionalDependencies:
7816 '@stylistic/eslint-plugin': 5.10.0(eslint@10.4.0(jiti@2.7.0))
7673 - '@typescript-eslint/parser': 8.59.3(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7817 + '@typescript-eslint/parser': 8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
7818
7819 eslint-plugin-yml@3.3.2(eslint@10.4.0(jiti@2.7.0)):
7820 dependencies:
@@ -7830,7 +7974,7 @@ snapshots:
7974 dependencies:
7975 fast-string-truncated-width: 3.0.3
7976
7833 - fast-wrap-ansi@0.2.0:
7977 + fast-wrap-ansi@0.2.2:
7978 dependencies:
7979 fast-string-width: 3.0.2
7980
@@ -7949,7 +8093,7 @@ snapshots:
8093 call-bind-apply-helpers: 1.0.2
8094 es-define-property: 1.0.1
8095 es-errors: 1.3.0
7952 - es-object-atoms: 1.1.1
8096 + es-object-atoms: 1.1.2
8097 function-bind: 1.1.2
8098 get-proto: 1.0.1
8099 gopd: 1.2.0
@@ -7960,7 +8104,7 @@ snapshots:
8104 get-proto@1.0.1:
8105 dependencies:
8106 dunder-proto: 1.0.1
7963 - es-object-atoms: 1.1.1
8107 + es-object-atoms: 1.1.2
8108
8109 get-stream@5.2.0:
8110 dependencies:
@@ -8084,7 +8228,7 @@ snapshots:
8228
8229 html-encoding-sniffer@6.0.0:
8230 dependencies:
8087 - '@exodus/bytes': 1.15.0
8231 + '@exodus/bytes': 1.15.1
8232 transitivePeerDependencies:
8233 - '@noble/hashes'
8234
@@ -8206,7 +8350,7 @@ snapshots:
8350
8351 isexe@2.0.0: {}
8352
8209 - isexe@3.1.5: {}
8353 + isexe@4.0.0: {}
8354
8355 jackspeak@3.4.3:
8356 dependencies:
@@ -8233,10 +8377,10 @@ snapshots:
8377 config-chain: 1.1.13
8378 editorconfig: 1.0.7
8379 glob: 10.5.0
8236 - js-cookie: 3.0.5
8380 + js-cookie: 3.0.7
8381 nopt: 7.2.1
8382
8239 - js-cookie@3.0.5: {}
8383 + js-cookie@3.0.7: {}
8384
8385 js-md5@0.8.3: {}
8386
@@ -8249,23 +8393,23 @@ snapshots:
8393 argparse: 1.0.10
8394 esprima: 4.0.1
8395
8252 - jscpd-sarif-reporter@4.2.3:
8396 + jscpd-sarif-reporter@4.2.4:
8397 dependencies:
8398 colors: 1.4.0
8399 fs-extra: 11.3.5
8400 node-sarif-builder: 3.4.0
8401
8258 - jscpd@4.2.3:
8402 + jscpd@4.2.4:
8403 dependencies:
8260 - '@jscpd/badge-reporter': 4.2.3
8261 - '@jscpd/core': 4.2.3
8262 - '@jscpd/finder': 4.2.3
8263 - '@jscpd/html-reporter': 4.2.3
8264 - '@jscpd/tokenizer': 4.2.3
8404 + '@jscpd/badge-reporter': 4.2.4
8405 + '@jscpd/core': 4.2.4
8406 + '@jscpd/finder': 4.2.4
8407 + '@jscpd/html-reporter': 4.2.4
8408 + '@jscpd/tokenizer': 4.2.4
8409 colors: 1.4.0
8410 commander: 5.1.0
8411 fs-extra: 11.3.5
8268 - jscpd-sarif-reporter: 4.2.3
8412 + jscpd-sarif-reporter: 4.2.4
8413
8414 jsdoc-type-pratt-parser@7.1.1: {}
8415
@@ -8277,18 +8421,18 @@ snapshots:
8421 '@asamuzakjp/dom-selector': 7.1.1
8422 '@bramus/specificity': 2.4.2
8423 '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1)
8280 - '@exodus/bytes': 1.15.0
8424 + '@exodus/bytes': 1.15.1
8425 css-tree: 3.2.1
8426 data-urls: 7.0.0
8427 decimal.js: 10.6.0
8428 html-encoding-sniffer: 6.0.0
8429 is-potential-custom-element-name: 1.0.1
8286 - lru-cache: 11.3.6
8430 + lru-cache: 11.5.0
8431 parse5: 8.0.1
8432 saxes: 6.0.0
8433 symbol-tree: 3.2.4
8434 tough-cookie: 6.0.1
8291 - undici: 7.25.0
8435 + undici: 7.26.0
8436 w3c-xmlserializer: 5.0.0
8437 webidl-conversions: 8.0.1
8438 whatwg-mimetype: 5.0.0
@@ -8303,7 +8447,7 @@ snapshots:
8447
8448 json-parse-even-better-errors@2.3.1: {}
8449
8306 - json-parse-even-better-errors@4.0.0: {}
8450 + json-parse-even-better-errors@6.0.0: {}
8451
8452 json-schema-traverse@0.4.1: {}
8453
@@ -8315,7 +8459,7 @@ snapshots:
8459 dependencies:
8460 acorn: 8.16.0
8461 eslint-visitor-keys: 5.0.1
8318 - semver: 7.8.0
8462 + semver: 7.8.1
8463
8464 jsonfile@6.2.1:
8465 dependencies:
@@ -8328,7 +8472,7 @@ snapshots:
8472 is-promise: 2.2.2
8473 promise: 7.3.1
8474
8331 - katex@0.16.46:
8475 + katex@0.16.47:
8476 dependencies:
8477 commander: 8.3.0
8478
@@ -8402,11 +8546,11 @@ snapshots:
8546
8547 lines-and-columns@1.2.4: {}
8548
8405 - linkify-it@5.0.0:
8549 + linkify-it@5.0.1:
8550 dependencies:
8551 uc.micro: 2.1.0
8552
8409 - local-pkg@1.1.2:
8553 + local-pkg@1.2.1:
8554 dependencies:
8555 mlly: 1.8.2
8556 pkg-types: 2.3.1
@@ -8432,7 +8576,7 @@ snapshots:
8576
8577 lru-cache@10.4.3: {}
8578
8435 - lru-cache@11.3.6: {}
8579 + lru-cache@11.5.0: {}
8580
8581 lru-cache@5.1.1:
8582 dependencies:
@@ -8452,11 +8596,11 @@ snapshots:
8596 dependencies:
8597 '@jridgewell/sourcemap-codec': 1.5.5
8598
8455 - markdown-it@14.1.1:
8599 + markdown-it@14.2.0:
8600 dependencies:
8601 argparse: 2.0.1
8602 entities: 4.5.0
8459 - linkify-it: 5.0.0
8603 + linkify-it: 5.0.1
8604 mdurl: 2.0.0
8605 punycode.js: 2.3.1
8606 uc.micro: 2.1.0
@@ -8752,7 +8896,7 @@ snapshots:
8896 dependencies:
8897 '@types/katex': 0.16.8
8898 devlop: 1.1.0
8755 - katex: 0.16.46
8899 + katex: 0.16.47
8900 micromark-factory-space: 2.0.1
8901 micromark-util-character: 2.1.1
8902 micromark-util-symbol: 2.0.1
@@ -8897,11 +9041,11 @@ snapshots:
9041
9042 minimatch@7.4.9:
9043 dependencies:
8900 - brace-expansion: 2.1.0
9044 + brace-expansion: 2.1.1
9045
9046 minimatch@9.0.9:
9047 dependencies:
8904 - brace-expansion: 2.1.0
9048 + brace-expansion: 2.1.1
9049
9050 minimist@1.2.8: {}
9051
@@ -8947,8 +9091,8 @@ snapshots:
9091 async-validator: 4.2.5
9092 css-render: 0.15.14
9093 csstype: 3.2.3
8950 - date-fns: 4.1.0
8951 - date-fns-tz: 3.2.0(date-fns@4.1.0)
9094 + date-fns: 4.3.0
9095 + date-fns-tz: 3.2.0(date-fns@4.3.0)
9096 evtd: 0.2.4
9097 highlight.js: 11.11.1
9098 lodash: 4.18.1
@@ -8973,7 +9117,7 @@ snapshots:
9117
9118 node-fetch-native@1.6.7: {}
9119
8976 - node-releases@2.0.44: {}
9120 + node-releases@2.0.46: {}
9121
9122 node-sarif-builder@3.4.0:
9123 dependencies:
@@ -8984,18 +9128,18 @@ snapshots:
9128 dependencies:
9129 abbrev: 2.0.0
9130
8987 - npm-normalize-package-bin@4.0.0: {}
9131 + npm-normalize-package-bin@6.0.0: {}
9132
8989 - npm-run-all2@8.0.4:
9133 + npm-run-all2@9.0.1:
9134 dependencies:
9135 ansi-styles: 6.2.3
9136 cross-spawn: 7.0.6
9137 memorystream: 0.3.1
9138 picomatch: 4.0.4
9139 pidtree: 0.6.0
8996 - read-package-json-fast: 4.0.0
8997 - shell-quote: 1.8.3
8998 - which: 5.0.0
9140 + read-package-json-fast: 6.0.0
9141 + shell-quote: 1.8.4
9142 + which: 7.0.0
9143
9144 npm-run-path@4.0.1:
9145 dependencies:
@@ -9007,7 +9151,7 @@ snapshots:
9151
9152 object-assign@4.1.1: {}
9153
9010 - object-deep-merge@2.0.0: {}
9154 + object-deep-merge@2.0.1: {}
9155
9156 obug@2.1.1: {}
9157
@@ -9097,7 +9241,7 @@ snapshots:
9241
9242 parse-json@5.2.0:
9243 dependencies:
9100 - '@babel/code-frame': 7.29.0
9244 + '@babel/code-frame': 7.29.7
9245 error-ex: 1.3.4
9246 json-parse-even-better-errors: 2.3.1
9247 lines-and-columns: 1.2.4
@@ -9143,11 +9287,11 @@ snapshots:
9287
9288 pidtree@0.6.0: {}
9289
9146 - pinia-plugin-persistedstate@4.7.1(@nuxt/kit@3.21.5)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))):
9290 + pinia-plugin-persistedstate@4.7.1(@nuxt/kit@3.21.6)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))):
9291 dependencies:
9292 defu: 6.1.7
9293 optionalDependencies:
9150 - '@nuxt/kit': 3.21.5
9294 + '@nuxt/kit': 3.21.6
9295 pinia: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
9296
9297 pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)):
@@ -9175,7 +9319,7 @@ snapshots:
9319
9320 pluralize@8.0.0: {}
9321
9178 - pnpm-workspace-yaml@1.6.0:
9322 + pnpm-workspace-yaml@1.6.1:
9323 dependencies:
9324 yaml: 2.9.0
9325
@@ -9191,7 +9335,7 @@ snapshots:
9335 cssesc: 3.0.0
9336 util-deprecate: 1.0.2
9337
9194 - postcss@8.5.14:
9338 + postcss@8.5.15:
9339 dependencies:
9340 nanoid: 3.3.12
9341 picocolors: 1.1.1
@@ -9318,11 +9462,11 @@ snapshots:
9462
9463 react-is@19.2.6: {}
9464
9321 - react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.6):
9465 + react-markdown@10.1.0(@types/react@19.2.15)(react@19.2.6):
9466 dependencies:
9467 '@types/hast': 3.0.4
9468 '@types/mdast': 4.0.4
9325 - '@types/react': 19.2.14
9469 + '@types/react': 19.2.15
9470 devlop: 1.1.0
9471 hast-util-to-jsx-runtime: 2.3.6
9472 html-url-attributes: 3.0.1
@@ -9352,7 +9496,7 @@ snapshots:
9496
9497 react-transition-group@4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
9498 dependencies:
9355 - '@babel/runtime': 7.29.2
9499 + '@babel/runtime': 7.29.7
9500 dom-helpers: 5.2.1
9501 loose-envify: 1.4.0
9502 prop-types: 15.8.1
@@ -9366,17 +9510,15 @@ snapshots:
9510
9511 react@19.2.6: {}
9512
9369 - read-package-json-fast@4.0.0:
9513 + read-package-json-fast@6.0.0:
9514 dependencies:
9371 - json-parse-even-better-errors: 4.0.0
9372 - npm-normalize-package-bin: 4.0.0
9515 + json-parse-even-better-errors: 6.0.0
9516 + npm-normalize-package-bin: 6.0.0
9517
9518 readdirp@3.6.0:
9519 dependencies:
9520 picomatch: 2.3.2
9521
9378 - readdirp@4.1.2: {}
9379 -
9522 readdirp@5.0.0: {}
9523
9524 refa@0.12.1:
@@ -9502,14 +9644,35 @@ snapshots:
9644 '@rolldown/binding-win32-arm64-msvc': 1.0.1
9645 '@rolldown/binding-win32-x64-msvc': 1.0.1
9646
9505 - rollup-plugin-visualizer@5.14.0(rolldown@1.0.1):
9647 + rolldown@1.0.2:
9648 + dependencies:
9649 + '@oxc-project/types': 0.132.0
9650 + '@rolldown/pluginutils': 1.0.1
9651 + optionalDependencies:
9652 + '@rolldown/binding-android-arm64': 1.0.2
9653 + '@rolldown/binding-darwin-arm64': 1.0.2
9654 + '@rolldown/binding-darwin-x64': 1.0.2
9655 + '@rolldown/binding-freebsd-x64': 1.0.2
9656 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2
9657 + '@rolldown/binding-linux-arm64-gnu': 1.0.2
9658 + '@rolldown/binding-linux-arm64-musl': 1.0.2
9659 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2
9660 + '@rolldown/binding-linux-s390x-gnu': 1.0.2
9661 + '@rolldown/binding-linux-x64-gnu': 1.0.2
9662 + '@rolldown/binding-linux-x64-musl': 1.0.2
9663 + '@rolldown/binding-openharmony-arm64': 1.0.2
9664 + '@rolldown/binding-wasm32-wasi': 1.0.2
9665 + '@rolldown/binding-win32-arm64-msvc': 1.0.2
9666 + '@rolldown/binding-win32-x64-msvc': 1.0.2
9667 +
9668 + rollup-plugin-visualizer@5.14.0(rolldown@1.0.2):
9669 dependencies:
9670 open: 8.4.2
9671 picomatch: 4.0.4
9672 source-map: 0.7.6
9673 yargs: 17.7.2
9674 optionalDependencies:
9512 - rolldown: 1.0.1
9675 + rolldown: 1.0.2
9676
9677 run-applescript@7.1.0: {}
9678
@@ -9521,9 +9684,9 @@ snapshots:
9684 dependencies:
9685 tslib: 2.8.1
9686
9524 - sass@1.99.0:
9687 + sass@1.100.0:
9688 dependencies:
9526 - chokidar: 4.0.3
9689 + chokidar: 5.0.0
9690 immutable: 5.1.5
9691 source-map-js: 1.2.1
9692 optionalDependencies:
@@ -9556,7 +9719,7 @@ snapshots:
9719
9720 semver@6.3.1: {}
9721
9559 - semver@7.8.0: {}
9722 + semver@7.8.1: {}
9723
9724 set-cookie-parser@2.7.2: {}
9725
@@ -9566,16 +9729,16 @@ snapshots:
9729
9730 shebang-regex@3.0.0: {}
9731
9569 - shell-quote@1.8.3: {}
9732 + shell-quote@1.8.4: {}
9733
9571 - shiki@4.0.2:
9734 + shiki@4.1.0:
9735 dependencies:
9573 - '@shikijs/core': 4.0.2
9574 - '@shikijs/engine-javascript': 4.0.2
9575 - '@shikijs/engine-oniguruma': 4.0.2
9576 - '@shikijs/langs': 4.0.2
9577 - '@shikijs/themes': 4.0.2
9578 - '@shikijs/types': 4.0.2
9736 + '@shikijs/core': 4.1.0
9737 + '@shikijs/engine-javascript': 4.1.0
9738 + '@shikijs/engine-oniguruma': 4.1.0
9739 + '@shikijs/langs': 4.1.0
9740 + '@shikijs/themes': 4.1.0
9741 + '@shikijs/types': 4.1.0
9742 '@shikijs/vscode-textmate': 10.0.2
9743 '@types/hast': 3.0.4
9744
@@ -9716,18 +9879,17 @@ snapshots:
9879
9880 tapable@2.3.3: {}
9881
9719 - taze@19.12.0:
9882 + taze@19.14.1:
9883 dependencies:
9884 '@antfu/ni': 30.1.0
9885 '@henrygd/queue': 1.2.0
9886 cac: 7.0.0
9724 - find-up-simple: 1.0.1
9887 ofetch: 1.5.1
9888 package-manager-detector: 1.6.0
9889 pathe: 2.0.3
9728 - pnpm-workspace-yaml: 1.6.0
9890 + pnpm-workspace-yaml: 1.6.1
9891 restore-cursor: 5.1.0
9730 - tinyexec: 1.1.2
9892 + tinyexec: 1.2.2
9893 tinyglobby: 0.2.16
9894 unconfig: 7.5.0
9895 yaml: 2.9.0
@@ -9740,7 +9902,7 @@ snapshots:
9902
9903 tinybench@2.9.0: {}
9904
9743 - tinyexec@1.1.2: {}
9905 + tinyexec@1.2.2: {}
9906
9907 tinyglobby@0.2.16:
9908 dependencies:
@@ -9749,11 +9911,11 @@ snapshots:
9911
9912 tinyrainbow@3.1.0: {}
9913
9752 - tldts-core@7.0.30: {}
9914 + tldts-core@7.4.0: {}
9915
9754 - tldts@7.0.30:
9916 + tldts@7.4.0:
9917 dependencies:
9756 - tldts-core: 7.0.30
9918 + tldts-core: 7.4.0
9919
9920 tmp@0.2.5: {}
9921
@@ -9778,7 +9940,7 @@ snapshots:
9940
9941 tough-cookie@6.0.1:
9942 dependencies:
9781 - tldts: 7.0.30
9943 + tldts: 7.4.0
9944
9945 tr46@6.0.0:
9946 dependencies:
@@ -9839,9 +10001,9 @@ snapshots:
10001
10002 undici-types@7.24.6: {}
10003
9842 - undici-types@7.25.0: {}
10004 + undici-types@7.26.0: {}
10005
9844 - undici@7.25.0: {}
10006 + undici@7.26.0: {}
10007
10008 unified@11.0.5:
10009 dependencies:
@@ -9940,28 +10102,28 @@ snapshots:
10102 '@types/unist': 3.0.3
10103 vfile-message: 4.0.3
10104
9943 - vite-bundle-visualizer@1.2.1(rolldown@1.0.1):
10105 + vite-bundle-visualizer@1.2.1(rolldown@1.0.2):
10106 dependencies:
10107 cac: 6.7.14
10108 import-from-esm: 1.3.4
9947 - rollup-plugin-visualizer: 5.14.0(rolldown@1.0.1)
10109 + rollup-plugin-visualizer: 5.14.0(rolldown@1.0.2)
10110 tmp: 0.2.5
10111 transitivePeerDependencies:
10112 - rolldown
10113 - rollup
10114 - supports-color
10115
9954 - vite-dev-rpc@1.1.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)):
10116 + vite-dev-rpc@1.1.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)):
10117 dependencies:
10118 birpc: 2.9.0
9957 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
9958 - vite-hot-client: 2.2.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
10119 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
10120 + vite-hot-client: 2.2.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
10121
9960 - vite-hot-client@2.2.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)):
10122 + vite-hot-client@2.2.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)):
10123 dependencies:
9962 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
10124 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
10125
9964 - vite-plugin-inspect@11.3.3(@nuxt/kit@3.21.5)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)):
10126 + vite-plugin-inspect@11.3.3(@nuxt/kit@3.21.6)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)):
10127 dependencies:
10128 ansis: 4.3.0
10129 debug: 4.4.3
@@ -9971,39 +10133,39 @@ snapshots:
10133 perfect-debounce: 2.1.0
10134 sirv: 3.0.2
10135 unplugin-utils: 0.3.1
9974 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
9975 - vite-dev-rpc: 1.1.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
10136 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
10137 + vite-dev-rpc: 1.1.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
10138 optionalDependencies:
9977 - '@nuxt/kit': 3.21.5
10139 + '@nuxt/kit': 3.21.6
10140 transitivePeerDependencies:
10141 - supports-color
10142
9981 - vite-plugin-vue-devtools@8.1.2(@nuxt/kit@3.21.5)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3)):
10143 + vite-plugin-vue-devtools@8.1.2(@nuxt/kit@3.21.6)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3)):
10144 dependencies:
10145 '@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@6.0.3))
10146 '@vue/devtools-kit': 8.1.2
10147 '@vue/devtools-shared': 8.1.2
10148 sirv: 3.0.2
9987 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
9988 - vite-plugin-inspect: 11.3.3(@nuxt/kit@3.21.5)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
9989 - vite-plugin-vue-inspector: 6.0.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
10149 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
10150 + vite-plugin-inspect: 11.3.3(@nuxt/kit@3.21.6)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
10151 + vite-plugin-vue-inspector: 6.0.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
10152 transitivePeerDependencies:
10153 - '@nuxt/kit'
10154 - supports-color
10155 - vue
10156
9995 - vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)):
10157 + vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)):
10158 dependencies:
9997 - '@babel/core': 7.29.0
9998 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
9999 - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
10000 - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
10001 - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
10002 - '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0)
10159 + '@babel/core': 7.29.7
10160 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7)
10161 + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7)
10162 + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7)
10163 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
10164 + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.7)
10165 '@vue/compiler-dom': 3.5.34
10166 kolorist: 1.8.0
10167 magic-string: 0.30.21
10006 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
10168 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
10169 transitivePeerDependencies:
10170 - supports-color
10171
@@ -10015,29 +10177,29 @@ snapshots:
10177 transitivePeerDependencies:
10178 - supports-color
10179
10018 - vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0):
10180 + vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0):
10181 dependencies:
10182 lightningcss: 1.32.0
10183 picomatch: 4.0.4
10022 - postcss: 8.5.14
10184 + postcss: 8.5.15
10185 rolldown: 1.0.1
10186 tinyglobby: 0.2.16
10187 optionalDependencies:
10026 - '@types/node': 25.9.0
10188 + '@types/node': 25.9.1
10189 fsevents: 2.3.3
10190 jiti: 2.7.0
10029 - sass: 1.99.0
10191 + sass: 1.100.0
10192 yaml: 2.9.0
10193
10032 - vitest@4.1.6(@types/node@25.9.0)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)):
10194 + vitest@4.1.7(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)):
10195 dependencies:
10034 - '@vitest/expect': 4.1.6
10035 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0))
10036 - '@vitest/pretty-format': 4.1.6
10037 - '@vitest/runner': 4.1.6
10038 - '@vitest/snapshot': 4.1.6
10039 - '@vitest/spy': 4.1.6
10040 - '@vitest/utils': 4.1.6
10196 + '@vitest/expect': 4.1.7
10197 + '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0))
10198 + '@vitest/pretty-format': 4.1.7
10199 + '@vitest/runner': 4.1.7
10200 + '@vitest/snapshot': 4.1.7
10201 + '@vitest/spy': 4.1.7
10202 + '@vitest/utils': 4.1.7
10203 es-module-lexer: 2.1.0
10204 expect-type: 1.3.0
10205 magic-string: 0.30.21
@@ -10046,13 +10208,13 @@ snapshots:
10208 picomatch: 4.0.4
10209 std-env: 4.1.0
10210 tinybench: 2.9.0
10049 - tinyexec: 1.1.2
10211 + tinyexec: 1.2.2
10212 tinyglobby: 0.2.16
10213 tinyrainbow: 3.1.0
10052 - vite: 8.0.13(@types/node@25.9.0)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0)
10214 + vite: 8.0.13(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(yaml@2.9.0)
10215 why-is-node-running: 2.3.0
10216 optionalDependencies:
10055 - '@types/node': 25.9.0
10217 + '@types/node': 25.9.1
10218 jsdom: 29.1.1
10219 transitivePeerDependencies:
10220 - msw
@@ -10082,7 +10244,12 @@ snapshots:
10244 codemirror: 6.0.2
10245 vue: 3.5.34(typescript@6.0.3)
10246
10085 - vue-component-type-helpers@3.2.9: {}
10247 + vue-component-type-helpers@3.3.2: {}
10248 +
10249 + vue-echarts@8.0.1(echarts@6.1.0)(vue@3.5.34(typescript@6.0.3)):
10250 + dependencies:
10251 + echarts: 6.1.0
10252 + vue: 3.5.34(typescript@6.0.3)
10253
10254 vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.7.0)):
10255 dependencies:
@@ -10092,7 +10259,7 @@ snapshots:
10259 eslint-visitor-keys: 5.0.1
10260 espree: 11.2.0
10261 esquery: 1.7.0
10095 - semver: 7.8.0
10262 + semver: 7.8.1
10263 transitivePeerDependencies:
10264 - supports-color
10265
@@ -10111,13 +10278,13 @@ snapshots:
10278
10279 vue-router@5.0.7(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)):
10280 dependencies:
10114 - '@babel/generator': 8.0.0-rc.5
10281 + '@babel/generator': 8.0.0-rc.6
10282 '@vue-macros/common': 3.1.2(vue@3.5.34(typescript@6.0.3))
10283 '@vue/devtools-api': 8.1.2
10284 ast-walker-scope: 0.8.3
10285 chokidar: 5.0.0
10286 json5: 2.2.3
10120 - local-pkg: 1.1.2
10287 + local-pkg: 1.2.1
10288 magic-string: 0.30.21
10289 mlly: 1.8.2
10290 muggle-string: 0.4.1
@@ -10137,15 +10304,15 @@ snapshots:
10304 dependencies:
10305 vue: 3.5.34(typescript@6.0.3)
10306
10140 - vue-tsc@3.3.0(typescript@6.0.3):
10307 + vue-tsc@3.3.2(typescript@6.0.3):
10308 dependencies:
10309 '@volar/typescript': 2.4.28
10143 - '@vue/language-core': 3.3.0
10310 + '@vue/language-core': 3.3.2
10311 typescript: 6.0.3
10312
10146 - vue3-apexcharts@1.11.1(apexcharts@5.12.0)(vue@3.5.34(typescript@6.0.3)):
10313 + vue3-apexcharts@1.11.1(apexcharts@5.13.0)(vue@3.5.34(typescript@6.0.3)):
10314 dependencies:
10148 - apexcharts: 5.12.0
10315 + apexcharts: 5.13.0
10316 vue: 3.5.34(typescript@6.0.3)
10317
10318 vue3-marquee@4.2.2(vue@3.5.34(typescript@6.0.3)):
@@ -10203,7 +10370,7 @@ snapshots:
10370
10371 whatwg-url@16.0.1:
10372 dependencies:
10206 - '@exodus/bytes': 1.15.0
10373 + '@exodus/bytes': 1.15.1
10374 tr46: 6.0.0
10375 webidl-conversions: 8.0.1
10376 transitivePeerDependencies:
@@ -10217,9 +10384,9 @@ snapshots:
10384 dependencies:
10385 isexe: 2.0.0
10386
10220 - which@5.0.0:
10387 + which@7.0.0:
10388 dependencies:
10222 - isexe: 3.1.5
10389 + isexe: 4.0.0
10390
10391 why-is-node-running@2.3.0:
10392 dependencies:
@@ -10228,8 +10395,8 @@ snapshots:
10395
10396 with@7.0.2:
10397 dependencies:
10231 - '@babel/parser': 7.29.3
10232 - '@babel/types': 7.29.0
10398 + '@babel/parser': 7.29.7
10399 + '@babel/types': 7.29.7
10400 assert-never: 1.4.0
10401 babel-walk: 3.0.0-canary-5
10402
@@ -10261,9 +10428,9 @@ snapshots:
10428
10429 xmlchars@2.2.0: {}
10430
10264 - xmllint-wasm@5.2.0(@types/node@25.9.0):
10431 + xmllint-wasm@5.2.0(@types/node@25.9.1):
10432 dependencies:
10266 - '@types/node': 25.9.0
10433 + '@types/node': 25.9.1
10434
10435 xmllint@0.1.1: {}
10436
@@ -10306,7 +10473,7 @@ snapshots:
10473
10474 yocto-queue@0.1.0: {}
10475
10309 - zrender@6.0.0:
10476 + zrender@6.1.0:
10477 dependencies:
10478 tslib: 2.3.0
10479
frontend/pnpm-workspace.yaml
+3 -1
@@ -1,6 +1,7 @@
1 +minimumReleaseAge: 0
2 shellEmulator: true
3
3 -trustPolicy: no-downgrade
4 +trustPolicy: off
5
6 trustPolicyExclude:
7 - chokidar
@@ -14,6 +15,7 @@ allowBuilds:
15
16 overrides:
17 "@mui/icons-material": ^7.3.9
18 +
19 peerDependencyRules:
20 allowedVersions:
21 eslint: "10"
frontend/src/api/endpoints/detectionCatalog.ts new
+108
@@ -0,0 +1,108 @@
1 +// API client for the Detection Catalog — a discovery surface over CoPilot Searches.
2 +// Backend routes live under the existing copilot_searches router because the
3 +// catalog reads from the same in-memory rules cache; the URLs are namespaced
4 +// under /catalog/* so they don't conflict with the rules-grid endpoints.
5 +
6 +import type {
7 + CatalogComplianceFrameworksResponse,
8 + CatalogComplianceResponse,
9 + CatalogCoverageGapsResponse,
10 + CatalogLogTestRequest,
11 + CatalogLogTestResponse,
12 + CatalogStatsResponse,
13 + CatalogStoryDetailResponse,
14 + CatalogStoryListResponse,
15 + CatalogWazuhRuleDetailResponse,
16 + CatalogWazuhRulesResponse
17 +} from "@/types/detectionCatalog.d"
18 +import type { FlaskBaseResponse } from "@/types/flask.d"
19 +import { HttpClient } from "../httpClient"
20 +
21 +export default {
22 + /** Top-level metrics for the catalog overview pane. */
23 + getStats() {
24 + return HttpClient.get<FlaskBaseResponse & CatalogStatsResponse>(`/copilot_searches/catalog/stats`)
25 + },
26 +
27 + /** List every analytic story with per-story aggregated summary fields. */
28 + listStories() {
29 + return HttpClient.get<FlaskBaseResponse & CatalogStoryListResponse>(`/copilot_searches/catalog/stories`)
30 + },
31 +
32 + /**
33 + * Detail payload for one analytic story (description, why-it-matters narrative,
34 + * detections table, data sources, references). The backend uses ``{story_name:path}``
35 + * so spaces and other characters are tolerated — we encodeURIComponent here too.
36 + */
37 + getStory(storyName: string) {
38 + return HttpClient.get<FlaskBaseResponse & CatalogStoryDetailResponse>(
39 + `/copilot_searches/catalog/stories/${encodeURIComponent(storyName)}`
40 + )
41 + },
42 +
43 + /**
44 + * List the full Wazuh ruleset for the Wazuh Rules tab. Returns the whole
45 + * corpus (~3–5k rules) in a single shot — pagination/filtering happens
46 + * client-side. When the Wazuh Manager is unreachable, ``available=false``
47 + * and ``unavailable_reason`` carries the human-readable cause.
48 + *
49 + * Pass ``customerCode`` to scope the firing-stats columns (Hits 30d /
50 + * Hits 7d / Last fired) to a single customer's alerts. The rule list
51 + * itself is unchanged — every rule is still returned — but rules without
52 + * any hits for that customer get zeros.
53 + */
54 + listWazuhRules(customerCode?: string) {
55 + return HttpClient.get<FlaskBaseResponse & CatalogWazuhRulesResponse>(`/copilot_searches/catalog/wazuh-rules`, {
56 + params: customerCode ? { customer_code: customerCode } : {}
57 + })
58 + },
59 +
60 + /** Full meta payload for one Wazuh rule (header, compliance, if-then details, …). */
61 + getWazuhRule(ruleId: number) {
62 + return HttpClient.get<FlaskBaseResponse & CatalogWazuhRuleDetailResponse>(
63 + `/copilot_searches/catalog/wazuh-rules/${ruleId}`
64 + )
65 + },
66 +
67 + /**
68 + * MITRE techniques NOT covered by any rule across either corpus —
69 + * the "where are our blind spots?" view. Sub-techniques are collapsed
70 + * into their parents server-side (a hit on T1059.001 covers T1059).
71 + */
72 + listCoverageGaps() {
73 + return HttpClient.get<FlaskBaseResponse & CatalogCoverageGapsResponse>(
74 + `/copilot_searches/catalog/coverage-gaps`
75 + )
76 + },
77 +
78 + /**
79 + * Run a raw log line through Wazuh's logtest engine. Returns the matched
80 + * rule (if any) + the full alert envelope. Stateless — no Wazuh session
81 + * is created. Wrapped by the backend with mitre_matrix tactic-name
82 + * enrichment so the result matches the rest of the catalog.
83 + */
84 + runLogTest(payload: CatalogLogTestRequest) {
85 + return HttpClient.post<FlaskBaseResponse & CatalogLogTestResponse>(
86 + `/copilot_searches/catalog/wazuh-rules/test`,
87 + payload
88 + )
89 + },
90 +
91 + /** List the compliance frameworks the Compliance tab can pivot by. */
92 + listComplianceFrameworks() {
93 + return HttpClient.get<FlaskBaseResponse & CatalogComplianceFrameworksResponse>(
94 + `/copilot_searches/catalog/compliance/frameworks`
95 + )
96 + },
97 +
98 + /**
99 + * Wazuh rules grouped by control IDs for the given framework. Each group
100 + * carries rule count + total firing hits — the "what coverage do we have
101 + * for PCI 10.2.4?" answer in one round-trip.
102 + */
103 + getCompliancePivot(framework: string) {
104 + return HttpClient.get<FlaskBaseResponse & CatalogComplianceResponse>(
105 + `/copilot_searches/catalog/compliance/${encodeURIComponent(framework)}`
106 + )
107 + }
108 +}
frontend/src/api/index.ts
+2
@@ -12,6 +12,7 @@ import copilotMCP from "./endpoints/copilotMCP"
12 import copilotSearches from "./endpoints/copilotSearches"
13 import customerPortal from "./endpoints/customerPortal"
14 import customers from "./endpoints/customers"
15 +import detectionCatalog from "./endpoints/detectionCatalog"
16 import flow from "./endpoints/flow"
17 import githubAudit from "./endpoints/githubAudit"
18 import graylog from "./endpoints/graylog"
@@ -77,6 +78,7 @@ export default {
78 networkConnectors,
79 notifications,
80 copilotSearches,
81 + detectionCatalog,
82 cloudSecurityAssessment,
83 webVulnerabilityAssessment,
84 incidentManagement,
frontend/src/app-layouts/common/Navbar/items.tsx
+15
@@ -20,6 +20,7 @@ const ReportCreationIcon = "carbon:report-data"
20 // const SchedulerIcon = "material-symbols:autoplay"
21 // const CustomerPortalIcon = "streamline-ultimate:coding-apps-website-apps-browser"
22 const AiAnalystIcon = "carbon:machine-learning-model"
23 +const DetectionCatalogIcon = "carbon:catalog"
24 const ToolsIcon = "carbon:tool-box"
25 // const EventSearchIcon = "carbon:search-locate"
26
@@ -54,6 +55,20 @@ export default function getItems(): MenuMixedOption[] {
55 key: "AiAnalyst",
56 icon: renderIcon(AiAnalystIcon)
57 },
58 + {
59 + label: () =>
60 + h(
61 + RouterLink,
62 + {
63 + to: {
64 + name: "DetectionCatalog"
65 + }
66 + },
67 + { default: () => "Detections Catalog" }
68 + ),
69 + key: "DetectionCatalog",
70 + icon: renderIcon(DetectionCatalogIcon)
71 + },
72 {
73 label: () =>
74 h(
frontend/src/assets/scss/common.scss
+12
@@ -162,3 +162,15 @@
162 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
163 grid-auto-flow: row dense;
164 }
165 +
166 +input:-webkit-autofill,
167 +input:-webkit-autofill:hover,
168 +input:-webkit-autofill:focus,
169 +textarea:-webkit-autofill,
170 +select:-webkit-autofill {
171 + -webkit-text-fill-color: var(--fg-default-color);
172 + transition: background-color 9999s ease-in-out 0s;
173 + -webkit-box-shadow: 0 0 0px 1000px var(--bg-default-color) inset;
174 + box-shadow: 0 0 0px 1000px var(--bg-default-color) inset;
175 + caret-color: var(--fg-default-color);
176 +}
frontend/src/components/common/CodeSource.vue
+18 -2
@@ -19,15 +19,29 @@
19 }"
20 />
21
22 - <div v-if="showToggleButton" class="flex justify-end">
23 - <n-button quaternary size="tiny" @click="showSource = !showSource">toggle source view</n-button>
22 + <div v-if="showToggleButton" class="flex items-center justify-end gap-2">
23 + <n-button v-if="isSupported" quaternary size="tiny" @click="copy(source)">
24 + <template #icon>
25 + <Icon name="carbon:copy" :size="12" />
26 + </template>
27 + {{ copied ? "copied!" : "copy source" }}
28 + </n-button>
29 +
30 + <n-button quaternary size="tiny" @click="showSource = !showSource">
31 + <template #icon>
32 + <Icon name="carbon:code" :size="14" />
33 + </template>
34 + toggle source view
35 + </n-button>
36 </div>
37 </div>
38 </template>
39
40 <script setup lang="ts">
41 +import { useClipboard } from "@vueuse/core"
42 import { NButton, NCard, NInput } from "naive-ui"
43 import { computed, ref } from "vue"
44 +import Icon from "@/components/common/Icon.vue"
45 import vShiki from "@/directives/v-shiki"
46
47 const {
@@ -42,6 +56,8 @@ const {
56 showToggleButton?: boolean
57 }>()
58
59 +const { copy, copied, isSupported } = useClipboard()
60 +
61 const showSource = ref(false)
62 const source = computed(() =>
63 typeof code === "string" || typeof code === "number" ? `${code}` : JSON.stringify(code, null, "\t")
frontend/src/components/common/cards/CardLink.vue
+47 -12
@@ -1,18 +1,41 @@
1 <template>
2 - <n-card size="small" :class="{ 'group cursor-pointer': clickable }" :hoverable>
3 - <div class="flex flex-col gap-2">
2 + <n-card
3 + size="small"
4 + :class="{ 'group cursor-pointer': clickable }"
5 + :hoverable
6 + content-class="flex items-center gap-4"
7 + >
8 + <div
9 + v-if="iconLeft"
10 + class="flex size-12 shrink-0 items-center justify-center rounded-sm p-1"
11 + :class="{
12 + 'bg-warning/10 text-warning': color === 'warning',
13 + 'bg-success/10 text-success': color === 'success',
14 + 'bg-error/10 text-error': color === 'danger',
15 + 'bg-primary/10 text-primary': color === 'primary',
16 + 'bg-secondary text-secondary': !color
17 + }"
18 + >
19 + <Icon :name="iconLeft" :size="26" />
20 + </div>
21 + <div class="flex grow flex-col" :class="{ 'gap-1': size === 'small', 'gap-2': size === 'medium' }">
22 <div class="flex items-center justify-between gap-2">
23 <span
6 - class="text-secondary text-sm transition-colors"
7 - :class="{ 'group-hover:text-primary': clickable }"
24 + class="text-secondary transition-colors"
25 + :class="{
26 + 'group-hover:text-primary': clickable,
27 + 'text-xs uppercase': size === 'small',
28 + 'text-sm': size === 'medium'
29 + }"
30 >
31 <slot name="title">{{ title }}</slot>
32 </span>
33 <Icon
34 v-if="clickable"
13 - name="carbon:arrow-up-right"
35 + :name="icon || 'carbon:arrow-up-right'"
36 class="group-hover:text-primary transition-colors"
37 />
38 + <Icon v-else-if="icon" :name="icon" />
39 <slot name="header-extra" />
40 </div>
41 <slot />
@@ -30,11 +53,23 @@
53 import { NCard } from "naive-ui"
54 import Icon from "@/components/common/Icon.vue"
55
33 -defineProps<{
34 - title?: string | number
35 - value?: string | number
36 - subtitle?: string | number
37 - hoverable?: boolean
38 - clickable?: boolean
39 -}>()
56 +export type CardLinkSize = "small" | "medium"
57 +export type CardLinkColor = "warning" | "success" | "danger" | "primary"
58 +
59 +withDefaults(
60 + defineProps<{
61 + title?: string | number
62 + value?: string | number
63 + subtitle?: string | number
64 + hoverable?: boolean
65 + clickable?: boolean
66 + icon?: string
67 + iconLeft?: string
68 + size?: CardLinkSize
69 + color?: CardLinkColor
70 + }>(),
71 + {
72 + size: "medium"
73 + }
74 +)
75 </script>
frontend/src/components/common/charts/ChartBar.vue
+124 -102
@@ -1,16 +1,33 @@
1 <template>
2 - <div :style="{ height, width: '100%' }">
3 - <apexchart width="100%" :height :options="chartOptions" :series="chartSeries" />
4 - </div>
2 + <VChart
3 + ref="chartRef"
4 + class="w-full"
5 + :style="{ height, width: '100%' }"
6 + :autoresize="{ onResize: updatePlotSize }"
7 + :option="chartOption"
8 + @finished="updatePlotSize"
9 + @click="onChartClick"
10 + />
11 </template>
12
13 <script setup lang="ts">
8 -import type { ApexOptions } from "apexcharts"
9 -import { computed } from "vue"
10 -import apexchart from "vue3-apexcharts"
14 +import type { BarSeriesOption } from "echarts/charts"
15 +import type { GridComponentOption, TitleComponentOption, TooltipComponentOption } from "echarts/components"
16 +import type { ComposeOption, ECElementEvent } from "echarts/core"
17 +import { BarChart } from "echarts/charts"
18 +import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components"
19 +import { use } from "echarts/core"
20 +import { CanvasRenderer } from "echarts/renderers"
21 +import { computed, ref } from "vue"
22 +import VChart from "vue-echarts"
23 import { useThemeStore } from "@/stores/theme"
12 -import { DASHBOARD_CHART_COLORS } from "./chartColors"
13 -import "@/assets/scss/overrides/apexchart-override.scss"
24 +import {
25 + buildChartTooltipGlassBase,
26 + CHART_COLORS,
27 + CHART_GRID_CONTAIN_AXIS_LABELS,
28 + chartTooltipThemeFromStyle,
29 + formatChartTooltipAxisFirst
30 +} from "."
31
32 const props = withDefaults(
33 defineProps<{
@@ -30,116 +47,121 @@ const emit = defineEmits<{
47 itemClick: [item: { name: string }]
48 }>()
49
50 +use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent])
51 +
52 +type ChartOption = ComposeOption<TitleComponentOption | TooltipComponentOption | GridComponentOption | BarSeriesOption>
53 +
54 const themeStore = useThemeStore()
55 +const chartRef = ref<InstanceType<typeof VChart> | null>(null)
56 +const chartWidth = ref(0)
57
35 -const chartSeries = computed(() => [
36 - {
37 - name: "value",
38 - data: props.labels.map((_, i) => Number(props.data[i] ?? 0))
39 - }
40 -])
58 +function updatePlotSize() {
59 + chartWidth.value = chartRef.value?.getWidth() ?? 0
60 +}
61 +
62 +const yAxisLabelWidth = computed(() => (chartWidth.value === 0 || chartWidth.value >= 1080 ? 200 : 100))
63
42 -const chartOptions = computed<ApexOptions>(() => {
64 +const sortedRows = computed(() =>
65 + props.labels
66 + .map((label, i) => ({
67 + label,
68 + value: Number(props.data[i] ?? 0)
69 + }))
70 + .sort((a, b) => b.value - a.value)
71 +)
72 +
73 +const chartOption = computed((): ChartOption => {
74 const style = themeStore.style
75 const fg = style["fg-default-color"]
76 const bc = style["border-color"]
77 const ff = style["font-family"]
78 + const palette = props.monochrome ? [CHART_COLORS[0]] : CHART_COLORS
79 + const hasData = props.labels.length > 0
80
48 - return {
49 - chart: {
50 - type: "bar",
51 - fontFamily: ff,
52 - toolbar: { show: false },
53 - animations: { enabled: true },
54 - events: {
55 - dataPointSelection(_event, _chartContext, config) {
56 - const idx = config?.dataPointIndex ?? 0
57 - const name = props.labels[idx]
58 - if (name) emit("itemClick", { name })
59 - }
81 + if (!hasData) {
82 + return {
83 + backgroundColor: "transparent",
84 + title: {
85 + text: "No data",
86 + left: "center",
87 + top: "center",
88 + textStyle: { color: fg, fontSize: 16, fontFamily: ff }
89 }
90 + }
91 + }
92 +
93 + const barData = sortedRows.value.map((row, i) => ({
94 + value: row.value,
95 + itemStyle: {
96 + color: palette[i % palette.length],
97 + borderRadius: [0, 4, 4, 0]
98 + }
99 + }))
100 +
101 + return {
102 + backgroundColor: "transparent",
103 + grid: {
104 + left: 8,
105 + right: 16,
106 + top: 8,
107 + bottom: 8,
108 + ...CHART_GRID_CONTAIN_AXIS_LABELS
109 },
62 - responsive: [
63 - {
64 - breakpoint: 1080,
65 - options: {
66 - yaxis: {
67 - labels: {
68 - style: { colors: fg, fontSize: "10px" },
69 - maxWidth: 100
70 - }
110 + tooltip: {
111 + ...buildChartTooltipGlassBase(chartTooltipThemeFromStyle(style), { trigger: "axis" }),
112 + axisPointer: { type: "shadow" },
113 + formatter: params =>
114 + formatChartTooltipAxisFirst(params, {
115 + resolveColor: p => {
116 + const idx = p.dataIndex ?? 0
117 + return palette[idx % palette.length]
118 }
72 - }
73 - }
74 - ],
75 - colors: props.monochrome ? [DASHBOARD_CHART_COLORS[0]] : DASHBOARD_CHART_COLORS,
76 - states: {
77 - active: {
78 - allowMultipleDataPointsSelection: false,
79 - filter: {
80 - type: "none"
81 - }
82 - }
119 + })
120 },
84 - plotOptions: {
85 - bar: {
86 - expandOnClick: false,
87 - horizontal: true,
88 - distributed: true,
89 - borderRadius: 4,
90 - barHeight: "60%",
91 - dataLabels: {
92 - position: "top"
93 - }
94 - }
95 - },
96 - dataLabels: {
97 - enabled: true,
98 - textAnchor: "start",
99 - offsetX: 5,
100 - style: {
101 - fontSize: "12px",
102 - colors: ["#fff"]
103 - }
121 + xAxis: {
122 + type: "value",
123 + axisLine: { show: false },
124 + axisTick: { show: false },
125 + axisLabel: { color: fg, fontSize: 10 },
126 + splitLine: { lineStyle: { color: bc } }
127 },
105 - stroke: { show: false },
106 - xaxis: {
107 - categories: [...props.labels],
108 - labels: {
109 - style: { colors: fg, fontSize: "10px" }
128 + yAxis: {
129 + type: "category",
130 + data: sortedRows.value.map(row => row.label),
131 + inverse: true,
132 + axisLine: { show: false },
133 + axisTick: { show: false },
134 + axisLabel: {
135 + color: fg,
136 + fontSize: 11,
137 + width: yAxisLabelWidth.value,
138 + overflow: "truncate"
139 },
111 - axisBorder: { show: false },
112 - axisTicks: { show: false }
113 - },
114 - yaxis: {
115 - labels: {
116 - style: { colors: fg, fontSize: "11px" },
117 - maxWidth: 200,
118 - trim: true
119 - }
140 + splitLine: { show: false }
141 },
121 - grid: {
122 - borderColor: bc,
123 - strokeDashArray: 0,
124 - xaxis: { lines: { show: true } },
125 - yaxis: { lines: { show: false } }
126 - },
127 - legend: { show: false },
128 - tooltip: {
129 - theme: themeStore.isThemeDark ? "dark" : "light",
130 - y: {
131 - formatter: (val: number) => String(val)
132 - }
133 - },
134 - noData: {
135 - text: "No data",
136 - align: "center",
137 - style: {
138 - color: fg,
139 - fontSize: "16px",
140 - fontFamily: ff
142 + series: [
143 + {
144 + name: "value",
145 + type: "bar",
146 + barWidth: "60%",
147 + data: barData,
148 + label: {
149 + show: true,
150 + position: "right",
151 + distance: 5,
152 + color: "#fff",
153 + fontSize: 12,
154 + formatter: params => String(params.value ?? 0)
155 + },
156 + emphasis: { focus: "series" }
157 }
142 - }
158 + ]
159 }
160 })
161 +
162 +function onChartClick(params: ECElementEvent) {
163 + if (params.componentType !== "series" || params.dataIndex == null) return
164 + const name = sortedRows.value[params.dataIndex]?.label
165 + if (name) emit("itemClick", { name })
166 +}
167 </script>
frontend/src/components/common/charts/ChartColumn.vue
+116 -121
@@ -1,18 +1,35 @@
1 <template>
2 - <div :style="{ height, width: '100%' }">
3 - <apexchart width="100%" :height :options="chartOptions" :series="chartSeries" />
4 - </div>
2 + <VChart
3 + ref="chartRef"
4 + class="w-full"
5 + :style="{ height, width: '100%' }"
6 + :autoresize="{ onResize: updatePlotWidth }"
7 + :option="chartOption"
8 + @finished="updatePlotWidth"
9 + @click="onChartClick"
10 + />
11 </template>
12
13 <script setup lang="ts">
8 -import type { ApexOptions } from "apexcharts"
9 -import { computed } from "vue"
10 -import apexchart from "vue3-apexcharts"
14 +import type { BarSeriesOption } from "echarts/charts"
15 +import type { GridComponentOption, TitleComponentOption, TooltipComponentOption } from "echarts/components"
16 +import type { ComposeOption } from "echarts/core"
17 +import { BarChart } from "echarts/charts"
18 +import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components"
19 +import { use } from "echarts/core"
20 +import { CanvasRenderer } from "echarts/renderers"
21 +import { computed, ref } from "vue"
22 +import VChart from "vue-echarts"
23 import { useSettingsStore } from "@/stores/settings"
24 import { useThemeStore } from "@/stores/theme"
25 import dayjs from "@/utils/dayjs"
14 -import { DASHBOARD_CHART_COLORS } from "./chartColors"
15 -import "@/assets/scss/overrides/apexchart-override.scss"
26 +import {
27 + buildChartTooltipGlassBase,
28 + CHART_COLORS,
29 + CHART_GRID_CONTAIN_AXIS_LABELS,
30 + chartTooltipThemeFromStyle,
31 + formatChartTooltipAxisFirst
32 +} from "."
33
34 const props = withDefaults(
35 defineProps<{
@@ -33,136 +50,114 @@ const emit = defineEmits<{
50 itemClick: [item: { name: string }]
51 }>()
52
53 +use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent])
54 +
55 +type ChartOption = ComposeOption<TitleComponentOption | TooltipComponentOption | GridComponentOption | BarSeriesOption>
56 +
57 +const GRID_HORIZONTAL_PADDING = 48
58 +
59 const themeStore = useThemeStore()
60 const dFormats = useSettingsStore().dateFormat
61 +const chartRef = ref<InstanceType<typeof VChart> | null>(null)
62 +const plotWidth = ref(0)
63
39 -const chartSeries = computed(() => [
40 - {
41 - name: "value",
42 - data: props.labels.map((_, i) => Number(props.data[i] ?? 0))
43 - }
44 -])
64 +function updatePlotWidth() {
65 + const chartWidth = chartRef.value?.getWidth() ?? 0
66 + plotWidth.value = Math.max(0, chartWidth - GRID_HORIZONTAL_PADDING)
67 +}
68
46 -const chartOptions = computed<ApexOptions>(() => {
69 +const categoryLabels = computed(() =>
70 + props.labels.map(label =>
71 + props.labelsDatetime ? `${dayjs(label).format(dFormats.date)}\n${dayjs(label).format(dFormats.time)}` : label
72 + )
73 +)
74 +
75 +const showXAxisLabels = computed(() => plotWidth.value === 0 || plotWidth.value >= 500)
76 +
77 +const chartOption = computed((): ChartOption => {
78 const style = themeStore.style
79 const fg = style["fg-default-color"]
80 const bc = style["border-color"]
81 const ff = style["font-family"]
82 + const palette = props.monochrome ? [CHART_COLORS[0]] : CHART_COLORS
83 + const hasData = props.labels.length > 0
84
52 - return {
53 - chart: {
54 - type: "bar",
55 - fontFamily: ff,
56 - toolbar: { show: false },
57 - animations: { enabled: true },
58 - events: {
59 - dataPointSelection(_event, _chartContext, config) {
60 - const idx = config?.dataPointIndex ?? 0
61 - const name = props.labels[idx]
62 - if (name) emit("itemClick", { name })
63 - }
85 + if (!hasData) {
86 + return {
87 + backgroundColor: "transparent",
88 + title: {
89 + text: "No data",
90 + left: "center",
91 + top: "center",
92 + textStyle: { color: fg, fontSize: 16, fontFamily: ff }
93 }
94 + }
95 + }
96 +
97 + const barData = props.labels.map((_, i) => ({
98 + value: Number(props.data[i] ?? 0),
99 + itemStyle: {
100 + color: palette[i % palette.length],
101 + borderRadius: [4, 4, 0, 0]
102 + }
103 + }))
104 +
105 + return {
106 + backgroundColor: "transparent",
107 + grid: {
108 + left: 8,
109 + right: 8,
110 + top: 8,
111 + bottom: showXAxisLabels.value ? 56 : 16,
112 + ...CHART_GRID_CONTAIN_AXIS_LABELS
113 },
66 - responsive: [
67 - {
68 - breakpoint: 1100,
69 - options: {
70 - xaxis: {
71 - tickAmount: 10,
72 - labels: {
73 - show: true
74 - }
75 - }
76 - }
77 - },
78 - {
79 - breakpoint: 750,
80 - options: {
81 - xaxis: {
82 - tickAmount: 5,
83 - labels: {
84 - show: true
85 - }
86 - }
87 - }
88 - },
89 - {
90 - breakpoint: 500,
91 - options: {
92 - xaxis: {
93 - labels: {
94 - show: false
95 - }
114 + tooltip: {
115 + ...buildChartTooltipGlassBase(chartTooltipThemeFromStyle(style), { trigger: "axis" }),
116 + axisPointer: { type: "shadow" },
117 + formatter: params =>
118 + formatChartTooltipAxisFirst(params, {
119 + resolveColor: p => {
120 + const idx = p.dataIndex ?? 0
121 + return palette[idx % palette.length]
122 }
97 - }
98 - }
99 - ],
100 - colors: props.monochrome ? [DASHBOARD_CHART_COLORS[0]] : DASHBOARD_CHART_COLORS,
101 - states: {
102 - active: {
103 - allowMultipleDataPointsSelection: false,
104 - filter: {
105 - type: "none"
106 - }
107 - }
108 - },
109 - plotOptions: {
110 - bar: {
111 - expandOnClick: false,
112 - horizontal: false,
113 - distributed: true,
114 - borderRadius: 4,
115 - borderRadiusApplication: "end",
116 - columnWidth: "60%"
117 - }
118 - },
119 - dataLabels: { enabled: false },
120 - stroke: { show: false },
121 - xaxis: {
122 - categories: Array.from(props.labels, label => {
123 - return props.labelsDatetime
124 - ? [dayjs(label).format(dFormats.date), dayjs(label).format(dFormats.time)]
125 - : label
126 - }),
127 - tickAmount: 20,
128 - labels: {
129 - show: true,
130 - style: { colors: fg, fontSize: "11px" },
131 - rotate: -50,
132 - rotateAlways: true,
133 - hideOverlappingLabels: true,
134 - trim: false
135 - },
136 - axisBorder: { show: false },
137 - axisTicks: { show: false }
123 + })
124 },
139 - yaxis: {
140 - labels: {
141 - style: { colors: fg, fontSize: "10px" }
125 + xAxis: {
126 + type: "category",
127 + data: categoryLabels.value,
128 + axisLine: { show: false },
129 + axisTick: { show: false },
130 + axisLabel: {
131 + show: showXAxisLabels.value,
132 + color: fg,
133 + fontSize: 11,
134 + interval: "auto",
135 + hideOverlap: true
136 }
137 },
144 - grid: {
145 - borderColor: bc,
146 - strokeDashArray: 0,
147 - xaxis: { lines: { show: false } },
148 - yaxis: { lines: { show: true } }
149 - },
150 - legend: { show: false },
151 - tooltip: {
152 - theme: themeStore.isThemeDark ? "dark" : "light",
153 - y: {
154 - formatter: (val: number) => String(val)
155 - }
138 + yAxis: {
139 + type: "value",
140 + axisLine: { show: false },
141 + axisTick: { show: false },
142 + axisLabel: { color: fg, fontSize: 10 },
143 + splitLine: { lineStyle: { color: bc } }
144 },
157 - noData: {
158 - text: "No data",
159 - align: "center",
160 - style: {
161 - color: fg,
162 - fontSize: "16px",
163 - fontFamily: ff
145 + series: [
146 + {
147 + name: "value",
148 + type: "bar",
149 + barWidth: "60%",
150 + data: barData,
151 + emphasis: { focus: "series" }
152 }
165 - }
153 + ]
154 }
155 })
156 +
157 +function onChartClick(params: unknown) {
158 + const p = params as { componentType?: string; dataIndex?: number }
159 + if (p.componentType !== "series" || p.dataIndex == null) return
160 + const name = props.labels[p.dataIndex]
161 + if (name) emit("itemClick", { name })
162 +}
163 </script>
frontend/src/components/common/charts/ChartPie.vue
+115 -79
@@ -1,16 +1,19 @@
1 <template>
2 - <div :style="{ height, width: '100%' }">
3 - <apexchart width="100%" :height :options="chartOptions" :series="chartSeries" />
4 - </div>
2 + <VChart class="w-full" :style="{ height, width: '100%' }" autoresize :option="chartOption" @click="onChartClick" />
3 </template>
4
5 <script setup lang="ts">
8 -import type { ApexOptions } from "apexcharts"
6 +import type { PieSeriesOption } from "echarts/charts"
7 +import type { LegendComponentOption, TitleComponentOption, TooltipComponentOption } from "echarts/components"
8 +import type { ComposeOption, ECElementEvent } from "echarts/core"
9 +import { PieChart } from "echarts/charts"
10 +import { GraphicComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components"
11 +import { use } from "echarts/core"
12 +import { CanvasRenderer } from "echarts/renderers"
13 import { computed } from "vue"
10 -import apexchart from "vue3-apexcharts"
14 +import VChart from "vue-echarts"
15 import { useThemeStore } from "@/stores/theme"
12 -import { DASHBOARD_CHART_COLORS } from "./chartColors"
13 -import "@/assets/scss/overrides/apexchart-override.scss"
16 +import { buildChartTooltipGlassBase, CHART_COLORS, chartTooltipThemeFromStyle, formatChartTooltipPieItem } from "."
17
18 const props = withDefaults(
19 defineProps<{
@@ -30,94 +33,127 @@ const emit = defineEmits<{
33 itemClick: [item: { name: string }]
34 }>()
35
36 +use([CanvasRenderer, PieChart, LegendComponent, TooltipComponent, TitleComponent, GraphicComponent])
37 +
38 +type ChartOption = ComposeOption<
39 + TitleComponentOption | TooltipComponentOption | LegendComponentOption | PieSeriesOption
40 +>
41 +
42 const themeStore = useThemeStore()
43
35 -const chartSeries = computed<number[]>(() => {
36 - if (!props.labels.length) return []
37 - return props.labels.map((_, i) => Number(props.data[i] ?? 0))
38 -})
44 +const pieData = computed(() =>
45 + props.labels.map((label, i) => ({
46 + name: label,
47 + value: Number(props.data[i] ?? 0)
48 + }))
49 +)
50
40 -const chartOptions = computed<ApexOptions>(() => {
51 +const totalValue = computed(() => pieData.value.reduce((sum, item) => sum + item.value, 0))
52 +
53 +const chartOption = computed((): ChartOption => {
54 const style = themeStore.style
55 const fg = style["fg-default-color"]
56 const ff = style["font-family"]
57 + const palette = props.monochrome ? [CHART_COLORS[0]] : [...CHART_COLORS]
58 + const hasData = props.labels.length > 0
59
45 - return {
46 - chart: {
47 - type: "donut",
48 - fontFamily: ff,
49 - toolbar: { show: false },
50 - animations: { enabled: true },
51 - events: {
52 - dataPointSelection(_event, _chartContext, config) {
53 - const idx = config?.dataPointIndex ?? 0
54 - const name = props.labels[idx]
55 - if (name) emit("itemClick", { name })
56 - }
60 + if (!hasData) {
61 + return {
62 + backgroundColor: "transparent",
63 + title: {
64 + text: "No data",
65 + left: "center",
66 + top: "center",
67 + textStyle: { color: fg, fontSize: 16, fontFamily: ff }
68 }
58 - },
59 - labels: [...props.labels],
60 - colors: props.monochrome ? [DASHBOARD_CHART_COLORS[0]] : DASHBOARD_CHART_COLORS,
61 - states: {
62 - active: {
63 - allowMultipleDataPointsSelection: false,
64 - filter: {
65 - type: "none"
66 - }
67 - }
68 - },
69 - plotOptions: {
70 - pie: {
71 - expandOnClick: false,
72 - donut: {
73 - size: "55%",
74 - labels: {
75 - show: true,
76 - name: { fontFamily: ff, fontSize: "12px", offsetY: -2 },
77 - value: { color: fg, fontFamily: ff, fontSize: "12px", offsetY: 0 },
78 - total: { color: fg, fontFamily: ff, fontSize: "12px", offsetY: 2 }
69 + }
70 + }
71 +
72 + return {
73 + backgroundColor: "transparent",
74 + color: palette,
75 + tooltip: {
76 + ...buildChartTooltipGlassBase(chartTooltipThemeFromStyle(style)),
77 + formatter: params =>
78 + formatChartTooltipPieItem(params, {
79 + resolveColor: p => {
80 + const idx = p.dataIndex ?? 0
81 + return palette[idx % palette.length]
82 }
83 + })
84 + },
85 + graphic: [
86 + {
87 + type: "text",
88 + left: "center",
89 + top: "33%",
90 + style: {
91 + text: `Total\n\n${totalValue.value}`,
92 + fill: fg,
93 + fontSize: 14,
94 + fontFamily: ff,
95 + textAlign: "center",
96 + textVerticalAlign: "middle"
97 }
98 }
82 - },
83 - dataLabels: {
84 - enabled: true
85 - },
86 - stroke: {
87 - show: false
88 - },
99 + ],
100 legend: {
101 show: true,
91 - position: "right",
92 - fontSize: "11px",
93 - fontFamily: ff,
94 - labels: { colors: fg },
95 - itemMargin: { vertical: 6, horizontal: 4 },
96 - markers: {
97 - size: 7,
98 - strokeWidth: 0,
99 - offsetX: -5
100 - },
101 - formatter: (seriesName, opts) => {
102 - const series = opts?.w.globals.series as number[]
103 - const value = series[opts?.seriesIndex ?? 0] ?? 0
104 - const total = series.reduce((sum, v) => sum + v, 0)
105 - const pct = total > 0 ? (value / total) * 100 : 0
106 - return `${seriesName} - ${value} (${pct.toFixed(1)}%)`
102 + selectedMode: false,
103 + type: "scroll",
104 + orient: "horizontal",
105 + bottom: 4,
106 + left: "center",
107 + width: "92%",
108 + textStyle: { color: fg, fontSize: 11, fontFamily: ff },
109 + pageTextStyle: { color: fg },
110 + pageIconColor: fg,
111 + pageIconInactiveColor: style["fg-secondary-color"],
112 + itemWidth: 7,
113 + itemHeight: 7,
114 + itemGap: 16,
115 + formatter: (name: string) => {
116 + const item = pieData.value.find(d => d.name === name)
117 + const val = item?.value ?? 0
118 + const pct = totalValue.value > 0 ? (val / totalValue.value) * 100 : 0
119 + return `${name} - ${val} (${pct.toFixed(1)}%)`
120 }
121 },
109 - tooltip: {
110 - theme: themeStore.isThemeDark ? "dark" : "light"
111 - },
112 - noData: {
113 - text: "No data",
114 - align: "center",
115 - style: {
116 - color: fg,
117 - fontSize: "16px",
118 - fontFamily: ff
122 + series: [
123 + {
124 + name: "value",
125 + type: "pie",
126 + radius: ["40%", "55%"],
127 + center: ["50%", "40%"],
128 + avoidLabelOverlap: true,
129 + itemStyle: { borderWidth: 0 },
130 + label: {
131 + show: true,
132 + position: "outside",
133 + color: fg,
134 + fontSize: 11,
135 + fontFamily: ff,
136 + formatter: params => {
137 + const pct = typeof params.percent === "number" ? params.percent : 0
138 + return `${pct.toFixed(1)}%`
139 + }
140 + },
141 + labelLine: { show: true, length: 10, length2: 6 },
142 + data: pieData.value
143 }
120 - }
144 + ]
145 }
146 })
147 +
148 +function resolveClickedItemName(params: ECElementEvent): string | undefined {
149 + if (params.componentType === "legend" || params.componentType === "series") {
150 + return params.name ?? props.labels[params.dataIndex ?? -1]
151 + }
152 + return undefined
153 +}
154 +
155 +function onChartClick(params: ECElementEvent) {
156 + const name = resolveClickedItemName(params)
157 + if (name) emit("itemClick", { name })
158 +}
159 </script>
frontend/src/components/common/charts/chartColors.ts deleted
-17
@@ -1,17 +0,0 @@
1 -/** Palette shared by dashboard charts (pie segments, etc.). */
2 -export const DASHBOARD_CHART_COLORS = [
3 - "#8a3ffc",
4 - "#33b1ff",
5 - "#007d79",
6 - "#ff7eb6",
7 - "#fa4d56",
8 - "#fff1f1",
9 - "#6fdc8c",
10 - "#4589ff",
11 - "#d12771",
12 - "#d2a106",
13 - "#08bdba",
14 - "#bae6ff",
15 - "#ba4e00",
16 - "#d4bbff"
17 -]
frontend/src/components/common/charts/chartConstants.ts new
+23
@@ -0,0 +1,23 @@
1 +/** Palette condivisa per grafici dashboard (segmenti pie, barre distribuite, …). */
2 +export const CHART_COLORS = [
3 + "#8a3ffc",
4 + "#33b1ff",
5 + "#007d79",
6 + "#ff7eb6",
7 + "#fa4d56",
8 + "#fff1f1",
9 + "#6fdc8c",
10 + "#4589ff",
11 + "#d12771",
12 + "#d2a106",
13 + "#08bdba",
14 + "#bae6ff",
15 + "#ba4e00",
16 + "#d4bbff"
17 +] as const
18 +
19 +/** ECharts 6: equivalente a `grid.containLabel: true` senza il plugin legacy. */
20 +export const CHART_GRID_CONTAIN_AXIS_LABELS = {
21 + outerBoundsMode: "same",
22 + outerBoundsContain: "axisLabel"
23 +} as const
frontend/src/components/common/charts/chartTooltip.ts new
+198
@@ -0,0 +1,198 @@
1 +import type { TooltipComponentOption } from "echarts/components"
2 +import type { CallbackDataParams, TopLevelFormatterParams } from "echarts/types/dist/shared"
3 +
4 +export const CHART_TOOLTIP_GLASS_BLUR_PX = 3
5 +
6 +export type ChartTooltipBorder = "primary" | "black"
7 +
8 +export interface ChartTooltipTheme {
9 + fgDefaultColor: string
10 + fontFamily: string
11 + primaryColor?: string
12 +}
13 +
14 +export function chartTooltipThemeFromStyle(style: Record<string, string>): ChartTooltipTheme {
15 + return {
16 + fgDefaultColor: style["fg-default-color"],
17 + fontFamily: style["font-family"],
18 + primaryColor: style["primary-color"]
19 + }
20 +}
21 +
22 +export function getChartTooltipGlassExtraCss(): string {
23 + const alpha = 0.8
24 + const rules = [
25 + `backdrop-filter: blur(${CHART_TOOLTIP_GLASS_BLUR_PX}px)`,
26 + `-webkit-backdrop-filter: blur(${CHART_TOOLTIP_GLASS_BLUR_PX}px)`,
27 + `background-color: rgba(var(--bg-default-color-rgb) / ${alpha}) !important`,
28 + "border-radius: var(--border-radius)",
29 + "box-shadow: 0 5px 10px -5px rgba(0,0,0,0.2), 0 5px 20px 0 rgba(0,0,0,0.2)",
30 + "padding: 0",
31 + "overflow: hidden"
32 + ]
33 + return rules.join("; ")
34 +}
35 +
36 +export function buildChartTooltipGlassBase(
37 + theme: ChartTooltipTheme,
38 + options?: { trigger?: TooltipComponentOption["trigger"] }
39 +): Pick<
40 + TooltipComponentOption,
41 + "trigger" | "backgroundColor" | "borderColor" | "borderWidth" | "textStyle" | "extraCssText"
42 +> {
43 + return {
44 + trigger: options?.trigger ?? "item",
45 + backgroundColor: "transparent",
46 + borderColor: "var(--border-color)",
47 + borderWidth: 1,
48 + textStyle: {
49 + color: theme.fgDefaultColor,
50 + fontSize: 12,
51 + fontFamily: theme.fontFamily
52 + },
53 + extraCssText: getChartTooltipGlassExtraCss()
54 + }
55 +}
56 +
57 +export function resolveChartItemColor(color: CallbackDataParams["color"]): string {
58 + return typeof color === "string" ? color : "#000000"
59 +}
60 +
61 +export function resolveChartTooltipMarker(options: {
62 + marker: CallbackDataParams["marker"]
63 + color: CallbackDataParams["color"]
64 +}): string {
65 + if (typeof options.marker === "string") return options.marker
66 + const fill = resolveChartItemColor(options.color)
67 + return `<span style="display:inline-block;vertical-align:middle;width:10px;height:10px;border-radius:50%;background:${fill};"></span>`
68 +}
69 +
70 +/** Tooltip a due sezioni con header (pallino + titolo) e corpo. */
71 +export function formatChartTooltipWithMarker(options: {
72 + marker: CallbackDataParams["marker"]
73 + color: CallbackDataParams["color"]
74 + title: string
75 + lines: string[]
76 +}): string {
77 + const dot = resolveChartTooltipMarker(options)
78 + return `
79 + <div style="display:flex;align-items:center;gap:8px;background-color:var(--bg-default-color);padding: 4px 8px;">
80 + ${dot} ${options.title}
81 + </div>
82 + <div style="padding: 4px 8px;">
83 + ${options.lines.join("<br/>")}
84 + </div>`
85 +}
86 +
87 +export interface ChartTooltipPieFormatterOptions {
88 + resolveColor?: (param: CallbackDataParams) => CallbackDataParams["color"]
89 +}
90 +
91 +export function formatChartTooltipPieItem(
92 + params: CallbackDataParams | CallbackDataParams[] | undefined,
93 + options?: ChartTooltipPieFormatterOptions
94 +): string {
95 + if (!params || Array.isArray(params)) return ""
96 + const val = typeof params.value === "number" ? params.value : 0
97 + const pct = typeof params.percent === "number" ? params.percent : 0
98 + return formatChartTooltipWithMarker({
99 + marker: params.marker,
100 + color: options?.resolveColor?.(params) ?? params.color,
101 + title: params.name ?? "",
102 + lines: [`<strong>${val}</strong> (${pct.toFixed(1)}%)`]
103 + })
104 +}
105 +
106 +export interface ChartTooltipAxisFormatterOptions {
107 + resolveColor?: (param: CallbackDataParams) => CallbackDataParams["color"]
108 +}
109 +
110 +export function formatChartTooltipAxisFirst(
111 + params: TopLevelFormatterParams,
112 + options?: ChartTooltipAxisFormatterOptions
113 +): string {
114 + if (!Array.isArray(params) || params.length === 0) return ""
115 + const p = params[0]
116 + const raw = Array.isArray(p.value) ? p.value[0] : p.value
117 + const val = typeof raw === "number" ? raw : 0
118 + return formatChartTooltipWithMarker({
119 + marker: p.marker,
120 + color: options?.resolveColor?.(p) ?? p.color,
121 + title: p.name ?? "",
122 + lines: [`<strong>${val}</strong>`]
123 + })
124 +}
125 +
126 +export interface ChartTooltipTimeAxisFormatterOptions extends ChartTooltipAxisFormatterOptions {
127 + formatTime?: (timestamp: number) => string
128 +}
129 +
130 +export interface ChartTooltipAxisMultiSeriesRow {
131 + marker: CallbackDataParams["marker"]
132 + color: CallbackDataParams["color"]
133 + label: string
134 + valueHtml: string
135 +}
136 +
137 +export interface ChartTooltipAxisMultiSeriesOptions {
138 + /** Riga titolo senza marker (es. timestamp). */
139 + title: string
140 + /** Colore testo titolo; default `var(--fg-secondary-color)`. */
141 + titleMutedColor?: string
142 + rows: ChartTooltipAxisMultiSeriesRow[]
143 +}
144 +
145 +/** Asse multi-serie: titolo in alto, sotto righe `marker + label + valore`. */
146 +export function formatChartTooltipAxisMultiSeries(options: ChartTooltipAxisMultiSeriesOptions): string {
147 + const titleColor = options.titleMutedColor ?? "var(--fg-secondary-color)"
148 + const rowsHtml = options.rows
149 + .map(row => {
150 + const marker = resolveChartTooltipMarker({ marker: row.marker, color: row.color })
151 + return `<div style="display:flex;align-items:center;gap:4px;line-height:1.5">${marker}<span>${row.label}: <b>${row.valueHtml}</b></span></div>`
152 + })
153 + .join("")
154 + return `
155 + <div style="font-size:11px;color:${titleColor};padding:4px 8px;background-color:var(--bg-default-color)">${options.title}</div>
156 + <div style="padding:4px 8px">${rowsHtml}</div>`
157 +}
158 +
159 +export function formatChartTooltipAxisMultiSeriesFromParams(
160 + params: TopLevelFormatterParams,
161 + options: {
162 + formatTitle: (first: CallbackDataParams) => string
163 + formatRow: (param: CallbackDataParams) => { label: string; valueHtml: string }
164 + titleMutedColor?: string
165 + }
166 +): string {
167 + if (!Array.isArray(params) || params.length === 0) return ""
168 + const first = params[0]
169 + return formatChartTooltipAxisMultiSeries({
170 + title: options.formatTitle(first),
171 + titleMutedColor: options.titleMutedColor,
172 + rows: params.map(p => ({
173 + marker: p.marker,
174 + color: p.color,
175 + label: p.seriesName ?? "",
176 + valueHtml: options.formatRow(p).valueHtml
177 + }))
178 + })
179 +}
180 +
181 +/** Sparkline / asse `time`: titolo = timestamp formattato, corpo = valore. */
182 +export function formatChartTooltipTimeAxisFirst(
183 + params: TopLevelFormatterParams,
184 + options?: ChartTooltipTimeAxisFormatterOptions
185 +): string {
186 + if (!Array.isArray(params) || params.length === 0) return ""
187 + const p = params[0]
188 + const time = Array.isArray(p.value) && typeof p.value[0] === "number" ? p.value[0] : null
189 + const raw = Array.isArray(p.value) ? p.value[1] : p.value
190 + const val = typeof raw === "number" ? raw : Number(raw) || 0
191 + const timeLabel = time != null ? (options?.formatTime?.(time) ?? String(time)) : ""
192 + return formatChartTooltipWithMarker({
193 + marker: p.marker,
194 + color: options?.resolveColor?.(p) ?? p.color,
195 + title: timeLabel,
196 + lines: [`<strong>${val}</strong>`]
197 + })
198 +}
frontend/src/components/common/charts/index.ts new
+23
@@ -0,0 +1,23 @@
1 +export { CHART_COLORS, CHART_GRID_CONTAIN_AXIS_LABELS } from "./chartConstants"
2 +export {
3 + buildChartTooltipGlassBase,
4 + chartTooltipThemeFromStyle,
5 + formatChartTooltipAxisFirst,
6 + formatChartTooltipAxisMultiSeries,
7 + formatChartTooltipAxisMultiSeriesFromParams,
8 + formatChartTooltipPieItem,
9 + formatChartTooltipTimeAxisFirst,
10 + formatChartTooltipWithMarker,
11 + getChartTooltipGlassExtraCss,
12 + resolveChartItemColor,
13 + resolveChartTooltipMarker
14 +} from "./chartTooltip"
15 +export type {
16 + ChartTooltipAxisFormatterOptions,
17 + ChartTooltipAxisMultiSeriesOptions,
18 + ChartTooltipAxisMultiSeriesRow,
19 + ChartTooltipBorder,
20 + ChartTooltipPieFormatterOptions,
21 + ChartTooltipTheme,
22 + ChartTooltipTimeAxisFormatterOptions
23 +} from "./chartTooltip"
frontend/src/components/detectionCatalog/ComplianceDetail.vue new
+74
@@ -0,0 +1,74 @@
1 +<template>
2 + <div v-if="group" class="flex flex-col gap-4">
3 + <div class="flex flex-wrap gap-2">
4 + <Badge type="splitted" color="primary">
5 + <template #label>Rules</template>
6 + <template #value>{{ group.rule_count }}</template>
7 + </Badge>
8 + <Badge type="splitted" :color="group.total_hits_30d > 0 ? 'warning' : undefined">
9 + <template #label>Hits 30d</template>
10 + <template #value>{{ group.total_hits_30d.toLocaleString() }}</template>
11 + </Badge>
12 + <Badge type="splitted">
13 + <template #label>Hits 7d</template>
14 + <template #value>{{ group.total_hits_7d.toLocaleString() }}</template>
15 + </Badge>
16 + </div>
17 +
18 + <CardEntity>
19 + <template #headerMain>
20 + <div class="flex items-center gap-2">
21 + <Icon name="carbon:list" :size="14" />
22 + <span class="text-sm font-semibold tracking-wide uppercase">Rule IDs</span>
23 + </div>
24 + </template>
25 + <template #default>
26 + <div class="flex flex-wrap gap-1.5">
27 + <n-tag
28 + v-for="rid of group.rule_ids"
29 + :key="rid"
30 + size="small"
31 + class="hover:bg-primary/10! cursor-pointer!"
32 + @click="openRuleDetail(rid)"
33 + >
34 + {{ rid }}
35 + </n-tag>
36 + </div>
37 + </template>
38 + </CardEntity>
39 +
40 + <!-- Detail modal -->
41 + <n-modal
42 + v-model:show="showDetailModal"
43 + preset="card"
44 + :style="{ maxWidth: 'min(880px, 94vw)', minHeight: 'min(600px, 90vh)' }"
45 + :title="modalTitle"
46 + :bordered="false"
47 + segmented
48 + >
49 + <WazuhRuleDetail v-if="modalRuleId !== null" :rule-id="modalRuleId" />
50 + </n-modal>
51 + </div>
52 +</template>
53 +
54 +<script setup lang="tsx">
55 +import type { CatalogComplianceGroupRow } from "@/types/detectionCatalog.d"
56 +import { NModal, NTag } from "naive-ui"
57 +import { ref } from "vue"
58 +import Badge from "@/components/common/Badge.vue"
59 +import CardEntity from "@/components/common/cards/CardEntity.vue"
60 +import Icon from "@/components/common/Icon.vue"
61 +import WazuhRuleDetail from "./WazuhRuleDetail.vue"
62 +
63 +defineProps<{ group: CatalogComplianceGroupRow }>()
64 +
65 +const showDetailModal = ref(false)
66 +const modalTitle = ref("Compliance Control")
67 +const modalRuleId = ref<number | null>(null)
68 +
69 +function openRuleDetail(rid: number) {
70 + modalRuleId.value = rid
71 + modalTitle.value = `Rule ${rid}`
72 + showDetailModal.value = true
73 +}
74 +</script>
frontend/src/components/detectionCatalog/ComplianceIndex.vue new
+313
@@ -0,0 +1,313 @@
1 +<template>
2 + <div class="@container flex flex-col gap-4">
3 + <div class="flex flex-col gap-1">
4 + <h3 class="text-lg font-semibold">Compliance Coverage</h3>
5 + <p class="text-secondary text-sm">
6 + Wazuh rules grouped by control ID for the selected compliance framework. Each row shows how many rules
7 + cover that control and how active they've been — useful for auditor questions like "what coverage do we
8 + have for PCI DSS 10.2.4?".
9 + </p>
10 + </div>
11 +
12 + <!-- HERO STATS for the selected framework -->
13 + <div v-if="!loading && pivot" class="grid grid-cols-1 gap-4 @2xl:grid-cols-3">
14 + <CardLink
15 + v-for="tile in complianceStatTiles"
16 + :key="tile.id"
17 + :title="tile.label"
18 + :value="tile.value"
19 + :icon-left="tile.icon"
20 + :color="tile.color"
21 + :subtitle="tile.sub"
22 + size="small"
23 + />
24 + </div>
25 +
26 + <!-- Framework selector + control search -->
27 + <div class="flex flex-wrap items-center gap-2">
28 + <n-select
29 + v-model:value="selectedFramework"
30 + :options="frameworkOptions"
31 + :loading="loadingFrameworks"
32 + size="small"
33 + class="min-w-80 flex-1"
34 + @update:value="load"
35 + />
36 + <n-input
37 + v-model:value="filter"
38 + size="small"
39 + placeholder="Filter by control ID…"
40 + clearable
41 + class="min-w-80 flex-1"
42 + >
43 + <template #prefix><Icon name="carbon:search" /></template>
44 + </n-input>
45 + <Badge v-if="pivot" type="splitted" color="primary" class="shrink-0">
46 + <template #label>Showing</template>
47 + <template #value>{{ filteredGroups.length }} / {{ pivot.groups.length }}</template>
48 + </Badge>
49 + </div>
50 +
51 + <n-data-table
52 + :columns
53 + :data="filteredGroups"
54 + :loading
55 + size="small"
56 + :pagination
57 + :row-props
58 + :scroll-x="1000"
59 + class="catalog-table"
60 + />
61 +
62 + <!-- Control drill-down modal -->
63 + <n-modal
64 + v-model:show="showGroupModal"
65 + preset="card"
66 + :style="{ maxWidth: 'min(720px, 92vw)' }"
67 + :title="modalTitle"
68 + :bordered="false"
69 + segmented
70 + >
71 + <ComplianceDetail v-if="modalGroup" :group="modalGroup" />
72 + </n-modal>
73 + </div>
74 +</template>
75 +
76 +<script setup lang="tsx">
77 +import type { DataTableColumns, SelectOption } from "naive-ui"
78 +import type {
79 + CatalogComplianceFramework,
80 + CatalogComplianceGroupRow,
81 + CatalogComplianceResponse
82 +} from "@/types/detectionCatalog.d"
83 +import { NDataTable, NInput, NModal, NSelect, NTag, useMessage } from "naive-ui"
84 +import { computed, onBeforeMount, ref } from "vue"
85 +import Api from "@/api"
86 +import Badge from "@/components/common/Badge.vue"
87 +import CardLink from "@/components/common/cards/CardLink.vue"
88 +import Icon from "@/components/common/Icon.vue"
89 +import ComplianceDetail from "./ComplianceDetail.vue"
90 +
91 +interface ComplianceStatTile {
92 + id: string
93 + label: string
94 + value: string
95 + icon: string
96 + sub: string
97 + color?: "warning" | "success" | "danger" | "primary"
98 +}
99 +
100 +const message = useMessage()
101 +
102 +const frameworks = ref<CatalogComplianceFramework[]>([])
103 +const selectedFramework = ref<string>("pci_dss")
104 +const pivot = ref<CatalogComplianceResponse | null>(null)
105 +const loadingFrameworks = ref(false)
106 +const loading = ref(false)
107 +const filter = ref("")
108 +
109 +const showGroupModal = ref(false)
110 +const modalGroup = ref<CatalogComplianceGroupRow | null>(null)
111 +const modalTitle = ref("Compliance Control")
112 +
113 +const ControlIcon = "carbon:certificate-check"
114 +const RulesIcon = "carbon:document-security"
115 +const UntaggedIcon = "carbon:document-blank"
116 +
117 +const pagination = {
118 + pageSize: 25,
119 + pageSizes: [10, 25, 50, 100],
120 + showSizePicker: true
121 +}
122 +
123 +const frameworkOptions = computed<SelectOption[]>(() => frameworks.value.map(f => ({ label: f.label, value: f.key })))
124 +
125 +const filteredGroups = computed<CatalogComplianceGroupRow[]>(() => {
126 + const groups = pivot.value?.groups || []
127 + const q = filter.value.trim().toLowerCase()
128 + if (!q) return groups
129 + return groups.filter(g => g.control.toLowerCase().includes(q))
130 +})
131 +
132 +const complianceStatTiles = computed<ComplianceStatTile[]>(() => {
133 + if (!pivot.value) return []
134 +
135 + return [
136 + {
137 + id: "controls",
138 + label: `${pivot.value.framework_label} controls`,
139 + value: pivot.value.control_count.toLocaleString(),
140 + icon: ControlIcon,
141 + sub: "Distinct control IDs",
142 + color: "primary"
143 + },
144 + {
145 + id: "rules-tagged",
146 + label: "Rules tagged",
147 + value: pivot.value.rules_with_compliance.toLocaleString(),
148 + icon: RulesIcon,
149 + sub: `of ${pivot.value.total_rules.toLocaleString()} total Wazuh rules`
150 + },
151 + {
152 + id: "rules-without-tag",
153 + label: "Rules without tag",
154 + value: (pivot.value.total_rules - pivot.value.rules_with_compliance).toLocaleString(),
155 + icon: UntaggedIcon,
156 + sub: "Not classified for this framework",
157 + color: "warning"
158 + }
159 + ]
160 +})
161 +
162 +function openGroup(group: CatalogComplianceGroupRow) {
163 + modalGroup.value = group
164 + modalTitle.value = `${pivot.value?.framework_label ?? ""} ${group.control}`
165 + showGroupModal.value = true
166 +}
167 +
168 +function rowProps(row: CatalogComplianceGroupRow) {
169 + return {
170 + style: "cursor: pointer;",
171 + onClick: () => openGroup(row)
172 + }
173 +}
174 +
175 +const columns: DataTableColumns<CatalogComplianceGroupRow> = [
176 + {
177 + title: "Activity",
178 + key: "total_hits_30d",
179 + width: 150,
180 + fixed: "left",
181 + defaultSortOrder: "descend",
182 + sorter: (a, b) => a.total_hits_30d - b.total_hits_30d,
183 + render: row => {
184 + if (row.total_hits_30d === 0) {
185 + return (
186 + <div class="flex items-center gap-1.5">
187 + <span class="dot dot-muted"></span>
188 + <span class="text-secondary text-xs">No hits 30d</span>
189 + </div>
190 + )
191 + }
192 + const dotClass =
193 + row.total_hits_30d >= 10000
194 + ? "dot-danger"
195 + : row.total_hits_30d >= 1000
196 + ? "dot-warning"
197 + : row.total_hits_30d >= 100
198 + ? "dot-info"
199 + : "dot-success"
200 + return (
201 + <div class="flex items-center gap-2">
202 + <span class={`dot ${dotClass}`}></span>
203 + <div class="flex flex-col leading-tight">
204 + <span class="font-mono text-xs font-medium">{row.total_hits_30d.toLocaleString()}</span>
205 + <span class="text-secondary text-xs">{`${row.total_hits_7d.toLocaleString()} in 7d`}</span>
206 + </div>
207 + </div>
208 + )
209 + }
210 + },
211 + {
212 + title: "Control",
213 + key: "control",
214 + width: 200,
215 + sorter: (a, b) => a.control.localeCompare(b.control),
216 + render: row => <span class="font-mono text-sm font-medium">{row.control}</span>
217 + },
218 + {
219 + title: "Rules",
220 + key: "rule_count",
221 + width: 100,
222 + sorter: (a, b) => a.rule_count - b.rule_count,
223 + render: row => (
224 + <NTag size="small" type="primary">
225 + {row.rule_count}
226 + </NTag>
227 + )
228 + },
229 + {
230 + title: "Rule IDs (preview)",
231 + key: "rule_ids",
232 + render: row => (
233 + <div class="flex flex-wrap gap-1">
234 + {row.rule_ids.slice(0, 10).map(rid => (
235 + <NTag key={rid} size="small">
236 + {rid}
237 + </NTag>
238 + ))}
239 + {row.rule_ids.length > 10 && (
240 + <NTag size="small" type="default" bordered={false}>{`+${row.rule_ids.length - 10}`}</NTag>
241 + )}
242 + </div>
243 + )
244 + }
245 +]
246 +
247 +function loadFrameworks() {
248 + loadingFrameworks.value = true
249 + Api.detectionCatalog
250 + .listComplianceFrameworks()
251 + .then(res => {
252 + if (res.data?.success) {
253 + frameworks.value = res.data.frameworks || []
254 + if (frameworks.value.length && !frameworks.value.some(f => f.key === selectedFramework.value)) {
255 + selectedFramework.value = frameworks.value[0].key
256 + }
257 + }
258 + })
259 + .catch(() => {
260 + /* Non-fatal — analyst can retry. */
261 + })
262 + .finally(() => {
263 + loadingFrameworks.value = false
264 + load()
265 + })
266 +}
267 +
268 +function load() {
269 + if (!selectedFramework.value) return
270 + loading.value = true
271 + Api.detectionCatalog
272 + .getCompliancePivot(selectedFramework.value)
273 + .then(res => {
274 + if (res.data?.success) pivot.value = res.data
275 + else message.warning(res.data?.message || "Failed to load compliance pivot")
276 + })
277 + .catch(err => {
278 + message.error(
279 + err.response?.data?.detail || err.response?.data?.message || "Failed to load compliance pivot"
280 + )
281 + })
282 + .finally(() => {
283 + loading.value = false
284 + })
285 +}
286 +
287 +onBeforeMount(loadFrameworks)
288 +</script>
289 +
290 +<style scoped lang="scss">
291 +:deep(.dot) {
292 + display: inline-block;
293 + width: 8px;
294 + height: 8px;
295 + border-radius: 50%;
296 + flex-shrink: 0;
297 +}
298 +:deep(.dot-muted) {
299 + background-color: var(--border-color);
300 +}
301 +:deep(.dot-success) {
302 + background-color: var(--success-color);
303 +}
304 +:deep(.dot-info) {
305 + background-color: var(--primary-color);
306 +}
307 +:deep(.dot-warning) {
308 + background-color: var(--warning-color);
309 +}
310 +:deep(.dot-danger) {
311 + background-color: var(--error-color);
312 +}
313 +</style>
frontend/src/components/detectionCatalog/CoverageGapsIndex.vue new
+202
@@ -0,0 +1,202 @@
1 +<template>
2 + <div class="@container flex flex-col gap-4">
3 + <!-- Title row + framing copy -->
4 + <div class="flex flex-col gap-1">
5 + <h3 class="text-lg font-semibold">Coverage Gaps</h3>
6 + <p class="text-secondary text-sm">
7 + MITRE ATT&amp;CK techniques no rule covers — across both the CoPilot Searches corpus and the Wazuh
8 + ruleset. Sub-techniques are collapsed into their parents (coverage of T1059.001 counts as coverage for
9 + T1059). Use this list to spot where new detection authoring would expand your coverage.
10 + </p>
11 + </div>
12 +
13 + <!-- COVERAGE HERO STATS - same CardLink pattern used by the catalog header. -->
14 + <div v-if="!loading" class="grid grid-cols-1 gap-4 @2xl:grid-cols-3">
15 + <CardLink
16 + v-for="tile in coverageStatTiles"
17 + :key="tile.id"
18 + :title="tile.label"
19 + :value="tile.value"
20 + :icon-left="tile.icon"
21 + :color="tile.color"
22 + :subtitle="tile.sub"
23 + size="small"
24 + />
25 + </div>
26 +
27 + <div class="flex flex-wrap items-center gap-2">
28 + <n-input
29 + v-model:value="filter"
30 + size="small"
31 + placeholder="Filter by technique ID, name, or tactic…"
32 + clearable
33 + class="min-w-80 flex-1"
34 + >
35 + <template #prefix><Icon name="carbon:search" /></template>
36 + </n-input>
37 +
38 + <Badge type="splitted" color="primary" class="shrink-0">
39 + <template #label>Showing</template>
40 + <template #value>{{ filteredGaps.length }} / {{ gaps.length }}</template>
41 + </Badge>
42 + </div>
43 +
44 + <n-data-table :columns :data="filteredGaps" :loading size="small" :pagination :scroll-x="100" />
45 + </div>
46 +</template>
47 +
48 +<script setup lang="tsx">
49 +import type { DataTableColumns } from "naive-ui"
50 +import type { CardLinkColor } from "@/components/common/cards/CardLink.vue"
51 +import type { CatalogCoverageGapRow } from "@/types/detectionCatalog.d"
52 +import { NButton, NDataTable, NInput, NTag, useMessage } from "naive-ui"
53 +import { computed, onBeforeMount, ref } from "vue"
54 +import Api from "@/api"
55 +import Badge from "@/components/common/Badge.vue"
56 +import CardLink from "@/components/common/cards/CardLink.vue"
57 +import Icon from "@/components/common/Icon.vue"
58 +
59 +interface CoverageStatTile {
60 + id: string
61 + label: string
62 + value: string
63 + icon: string
64 + sub: string
65 + color: CardLinkColor
66 +}
67 +
68 +const message = useMessage()
69 +
70 +const gaps = ref<CatalogCoverageGapRow[]>([])
71 +const gap_count = ref(0)
72 +const total_techniques = ref(0)
73 +const covered_count = ref(0)
74 +const coverage_pct = ref(0)
75 +
76 +const loading = ref(false)
77 +const filter = ref("")
78 +
79 +const ShieldIcon = "carbon:ibm-cloud-security-groups"
80 +const CoveredIcon = "carbon:checkmark-outline"
81 +const GapsIcon = "carbon:warning-square"
82 +
83 +const pagination = {
84 + pageSize: 25,
85 + pageSizes: [10, 25, 50, 100],
86 + showSizePicker: true
87 +}
88 +
89 +const filteredGaps = computed<CatalogCoverageGapRow[]>(() => {
90 + const q = filter.value.trim().toLowerCase()
91 + if (!q) return gaps.value
92 + return gaps.value.filter(g =>
93 + [g.technique_id, g.technique_name, ...(g.tactics || [])].join(" ").toLowerCase().includes(q)
94 + )
95 +})
96 +
97 +const coverageStatTiles = computed<CoverageStatTile[]>(() => [
98 + {
99 + id: "coverage",
100 + label: "Coverage",
101 + value: `${coverage_pct.value}%`,
102 + icon: ShieldIcon,
103 + sub: "Across both corpora",
104 + color: "warning"
105 + },
106 + {
107 + id: "covered-techniques",
108 + label: "Covered techniques",
109 + value: covered_count.value.toLocaleString(),
110 + icon: CoveredIcon,
111 + sub: `of ${total_techniques.value.toLocaleString()} total`,
112 + color: "success"
113 + },
114 + {
115 + id: "gaps",
116 + label: "Gaps",
117 + value: gap_count.value.toLocaleString(),
118 + icon: GapsIcon,
119 + sub: "Techniques with no rule",
120 + color: "danger"
121 + }
122 +])
123 +
124 +const columns: DataTableColumns<CatalogCoverageGapRow> = [
125 + {
126 + title: "Technique ID",
127 + key: "technique_id",
128 + width: 150,
129 + fixed: "left",
130 + sorter: (a, b) => a.technique_id.localeCompare(b.technique_id),
131 + render: row =>
132 + row.url ? (
133 + <NButton
134 + tag="a"
135 + // @ts-expect-error tag="a" forwards native anchor attrs omitted from ButtonProps
136 + rel="noopener"
137 + href={row.url}
138 + target="_blank"
139 + size="tiny"
140 + icon-placement="right"
141 + secondary
142 + type="primary"
143 + v-slots={{
144 + icon: () => <Icon name="carbon:launch" />
145 + }}
146 + >
147 + {row.technique_id}
148 + </NButton>
149 + ) : (
150 + <span class="text-secondary font-mono text-xs">{row.technique_id}</span>
151 + )
152 + },
153 + {
154 + title: "Technique",
155 + key: "technique_name",
156 + width: 400,
157 + sorter: (a, b) => a.technique_name.localeCompare(b.technique_name)
158 + },
159 + {
160 + title: "Tactics",
161 + key: "tactics",
162 + minWidth: 300,
163 + render: row =>
164 + row.tactics.length ? (
165 + <div class="flex flex-wrap gap-1">
166 + {row.tactics.map(t => (
167 + <NTag key={t} type="warning" size="small">
168 + {t.toUpperCase()}
169 + </NTag>
170 + ))}
171 + </div>
172 + ) : (
173 + <span class="text-tertiary text-xs">—</span>
174 + )
175 + }
176 +]
177 +
178 +function load() {
179 + loading.value = true
180 + Api.detectionCatalog
181 + .listCoverageGaps()
182 + .then(res => {
183 + if (res.data?.success) {
184 + gaps.value = res.data.gaps || []
185 + gap_count.value = res.data.gap_count
186 + total_techniques.value = res.data.total_techniques
187 + covered_count.value = res.data.covered_count
188 + coverage_pct.value = res.data.coverage_pct
189 + } else {
190 + message.warning(res.data?.message || "Failed to load coverage gaps")
191 + }
192 + })
193 + .catch(err => {
194 + message.error(err.response?.data?.detail || err.response?.data?.message || "Failed to load coverage gaps")
195 + })
196 + .finally(() => {
197 + loading.value = false
198 + })
199 +}
200 +
201 +onBeforeMount(load)
202 +</script>
frontend/src/components/detectionCatalog/DetectionCatalogShell.vue new
+48
@@ -0,0 +1,48 @@
1 +<template>
2 + <div class="flex flex-col gap-6">
3 + <header class="flex flex-col gap-4">
4 + <div class="flex flex-wrap items-end justify-between gap-3">
5 + <div class="flex flex-col gap-1">
6 + <div class="flex items-center gap-3">
7 + <Icon :name="CatalogIcon" :size="22" />
8 + <h2 class="text-2xl font-semibold">Detections Catalog</h2>
9 + </div>
10 + <p class="text-sm">
11 + Discovery surface for the CoPilot detection corpus and the Wazuh ruleset. Browse the CoPilot
12 + Searches grouped by analytic story, inspect every Wazuh rule shipped by the manager, see your
13 + MITRE coverage gaps, and pivot by compliance framework.
14 + </p>
15 + </div>
16 + </div>
17 +
18 + <DetectionCatalogStats />
19 + </header>
20 +
21 + <n-tabs type="line" animated>
22 + <n-tab-pane name="stories" tab="CoPilot Searches" display-directive="show:lazy">
23 + <StoriesIndex />
24 + </n-tab-pane>
25 + <n-tab-pane name="wazuh" tab="Wazuh Rules" display-directive="show:lazy">
26 + <WazuhRulesIndex />
27 + </n-tab-pane>
28 + <n-tab-pane name="gaps" tab="Coverage Gaps" display-directive="show:lazy">
29 + <CoverageGapsIndex />
30 + </n-tab-pane>
31 + <n-tab-pane name="compliance" tab="Compliance" display-directive="show:lazy">
32 + <ComplianceIndex />
33 + </n-tab-pane>
34 + </n-tabs>
35 + </div>
36 +</template>
37 +
38 +<script setup lang="ts">
39 +import { NTabPane, NTabs } from "naive-ui"
40 +import Icon from "@/components/common/Icon.vue"
41 +import ComplianceIndex from "./ComplianceIndex.vue"
42 +import CoverageGapsIndex from "./CoverageGapsIndex.vue"
43 +import DetectionCatalogStats from "./DetectionCatalogStats.vue"
44 +import StoriesIndex from "./StoriesIndex.vue"
45 +import WazuhRulesIndex from "./WazuhRulesIndex.vue"
46 +
47 +const CatalogIcon = "carbon:catalog"
48 +</script>
frontend/src/components/detectionCatalog/DetectionCatalogStats.vue new
+143
@@ -0,0 +1,143 @@
1 +<template>
2 + <div class="@container flex flex-col gap-4">
3 + <div class="flex items-center justify-end gap-2">
4 + <span v-if="stats" class="text-tertiary text-xs">
5 + Last refreshed
6 + <span v-if="stats.last_refresh">{{ formatRelativeTime(stats.last_refresh) }}</span>
7 + <span v-else>just now</span>
8 + </span>
9 + <n-button :loading="loadingStats" size="tiny" secondary @click="loadStats">
10 + <template #icon><Icon :name="RefreshIcon" :size="15" /></template>
11 + Refresh
12 + </n-button>
13 + </div>
14 +
15 + <n-spin :show="loadingStats">
16 + <div class="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3 @7xl:grid-cols-6">
17 + <CardLink
18 + v-for="tile in headerStatTiles"
19 + :key="tile.id"
20 + :title="tile.label"
21 + :value="tile.value"
22 + :icon="tile.icon"
23 + :subtitle="tile.sub"
24 + />
25 + </div>
26 + </n-spin>
27 + </div>
28 +</template>
29 +
30 +<script setup lang="ts">
31 +import type { CatalogStatsResponse } from "@/types/detectionCatalog.d"
32 +import { NButton, NSpin } from "naive-ui"
33 +import { computed, onBeforeMount, ref } from "vue"
34 +import Api from "@/api"
35 +import CardLink from "@/components/common/cards/CardLink.vue"
36 +import Icon from "@/components/common/Icon.vue"
37 +import dayjs from "@/utils/dayjs"
38 +
39 +interface HeaderStatTileDef {
40 + id: string
41 + label: string
42 + icon?: string
43 + sub?: string
44 + value: (stats: CatalogStatsResponse | null) => number
45 + show?: (stats: CatalogStatsResponse | null) => boolean
46 +}
47 +
48 +interface HeaderStatTile {
49 + id: string
50 + label: string
51 + sub?: string
52 + icon?: string
53 + value: string
54 +}
55 +
56 +const stats = ref<CatalogStatsResponse | null>(null)
57 +const loadingStats = ref(false)
58 +
59 +const DetectionIcon = "carbon:radar"
60 +const StoryIcon = "carbon:book"
61 +const WazuhIcon = "carbon:document-security"
62 +const TacticIcon = "carbon:flag"
63 +const DataSourceIcon = "carbon:data-base"
64 +const ProductIcon = "carbon:cube"
65 +const RefreshIcon = "carbon:renew"
66 +
67 +const HEADER_STAT_TILE_DEFS: HeaderStatTileDef[] = [
68 + {
69 + id: "detections",
70 + label: "Detections",
71 + icon: DetectionIcon,
72 + sub: "CoPilot Searches",
73 + value: s => s?.detection_count ?? 0
74 + },
75 + {
76 + id: "stories",
77 + label: "Stories",
78 + icon: StoryIcon,
79 + sub: "Analytic stories",
80 + value: s => s?.story_count ?? 0
81 + },
82 + {
83 + id: "wazuh-rules",
84 + label: "Wazuh rules",
85 + icon: WazuhIcon,
86 + sub: "From Wazuh Manager",
87 + show: s => !!s?.wazuh_available,
88 + value: s => s?.wazuh_rule_count ?? 0
89 + },
90 + {
91 + id: "mitre-tactics",
92 + label: "MITRE tactics",
93 + icon: TacticIcon,
94 + sub: "Covered",
95 + value: s => s?.tactic_count ?? 0
96 + },
97 + {
98 + id: "data-sources",
99 + label: "Data sources",
100 + icon: DataSourceIcon,
101 + sub: "Distinct sources",
102 + value: s => s?.data_source_count ?? 0
103 + },
104 + {
105 + id: "products",
106 + label: "Products",
107 + icon: ProductIcon,
108 + sub: "Distinct products",
109 + value: s => s?.product_count ?? 0
110 + }
111 +]
112 +
113 +const headerStatTiles = computed<HeaderStatTile[]>(() =>
114 + HEADER_STAT_TILE_DEFS.filter(tile => !tile.show || tile.show(stats.value)).map(tile => ({
115 + id: tile.id,
116 + label: tile.label,
117 + sub: tile.sub,
118 + icon: tile.icon,
119 + value: tile.value(stats.value).toLocaleString()
120 + }))
121 +)
122 +
123 +function loadStats() {
124 + loadingStats.value = true
125 + Api.detectionCatalog
126 + .getStats()
127 + .then(res => {
128 + if (res.data?.success) stats.value = res.data
129 + })
130 + .catch(() => {
131 + /* Stats are best-effort cosmetic; don't toast an error. */
132 + })
133 + .finally(() => {
134 + loadingStats.value = false
135 + })
136 +}
137 +
138 +function formatRelativeTime(iso: string): string {
139 + return dayjs(iso).fromNow()
140 +}
141 +
142 +onBeforeMount(loadStats)
143 +</script>
frontend/src/components/detectionCatalog/StoriesIndex.vue new
+212
@@ -0,0 +1,212 @@
1 +<template>
2 + <div class="flex flex-col gap-4">
3 + <div class="flex flex-wrap items-end justify-between gap-3">
4 + <div class="flex flex-col gap-1">
5 + <h3 class="text-lg font-semibold">Analytic Stories</h3>
6 + <p class="text-secondary text-sm">
7 + Each story is a threat narrative covered by one or more CoPilot Searches detections. Click a row to
8 + see the member detections, data sources, and references.
9 + </p>
10 + </div>
11 + </div>
12 +
13 + <div class="flex flex-wrap items-center gap-2">
14 + <n-input
15 + v-model:value="filter"
16 + size="small"
17 + placeholder="Filter by story name, data source, tactic, or product…"
18 + clearable
19 + class="min-w-80 flex-1"
20 + >
21 + <template #prefix><Icon name="carbon:search" /></template>
22 + </n-input>
23 + <Badge type="splitted" color="primary" class="shrink-0">
24 + <template #label>Showing</template>
25 + <template #value>{{ filteredStories.length }} / {{ stories.length }}</template>
26 + </Badge>
27 + </div>
28 +
29 + <n-data-table :columns :data="filteredStories" :loading size="small" :row-props :pagination :scroll-x="1200" />
30 +
31 + <n-modal
32 + v-model:show="showStoryModal"
33 + preset="card"
34 + :style="{ maxWidth: 'min(1080px, 94vw)' }"
35 + title="Analytic Story"
36 + :bordered="false"
37 + segmented
38 + @after-leave="selectedStoryName = null"
39 + >
40 + <StoryDetail v-if="selectedStoryName" :story-name="selectedStoryName" @error="handleError" />
41 + </n-modal>
42 + </div>
43 +</template>
44 +
45 +<script setup lang="tsx">
46 +import type { DataTableColumns } from "naive-ui"
47 +import type { CatalogStoryRow } from "@/types/detectionCatalog.d"
48 +import { NDataTable, NInput, NModal, NTag, useMessage } from "naive-ui"
49 +import { computed, onBeforeMount, ref } from "vue"
50 +import Api from "@/api"
51 +import Badge from "@/components/common/Badge.vue"
52 +import Icon from "@/components/common/Icon.vue"
53 +import { useSettingsStore } from "@/stores/settings"
54 +import { formatDate } from "@/utils/format"
55 +import StoryDetail from "./StoryDetail.vue"
56 +
57 +const MAX_SOURCES_TAG = 3
58 +const MAX_TACTICS_TAG = 3
59 +const MAX_PRODUCTS_TAG = 3
60 +
61 +const message = useMessage()
62 +const stories = ref<CatalogStoryRow[]>([])
63 +const loading = ref(false)
64 +const filter = ref("")
65 +const showStoryModal = ref(false)
66 +const selectedStoryName = ref<string | null>(null)
67 +const dFormats = useSettingsStore().dateFormat
68 +
69 +const pagination = {
70 + pageSize: 25,
71 + pageSizes: [10, 25, 50, 100],
72 + showSizePicker: true
73 +}
74 +
75 +const filteredStories = computed<CatalogStoryRow[]>(() => {
76 + const q = filter.value.trim().toLowerCase()
77 +
78 + if (!q) return stories.value
79 +
80 + return stories.value.filter(s =>
81 + [s.name, ...(s.data_sources || []), ...(s.tactics || []), ...(s.products || [])]
82 + .join(" ")
83 + .toLowerCase()
84 + .includes(q)
85 + )
86 +})
87 +
88 +function rowProps(row: CatalogStoryRow) {
89 + return {
90 + class: "cursor-pointer",
91 + onClick: () => openStoryDetail(row.name)
92 + }
93 +}
94 +
95 +function openStoryDetail(storyName: string) {
96 + selectedStoryName.value = storyName
97 + showStoryModal.value = true
98 +}
99 +
100 +function handleError() {
101 + showStoryModal.value = false
102 + selectedStoryName.value = null
103 +}
104 +
105 +const columns: DataTableColumns<CatalogStoryRow> = [
106 + {
107 + title: "Story",
108 + key: "name",
109 + fixed: "left",
110 + width: 200,
111 + sorter: (a, b) => a.name.localeCompare(b.name),
112 + render: row => (
113 + <div class="flex flex-col gap-1 py-1">
114 + <div class="leading-snug font-semibold">{row.name}</div>
115 + <div class="text-secondary font-mono text-xs uppercase">
116 + {`${row.detection_count} detection`}
117 + {row.detection_count === 1 ? "" : "s"}
118 + </div>
119 + </div>
120 + )
121 + },
122 + {
123 + title: "Data sources",
124 + key: "data_sources",
125 + render: row =>
126 + row.data_sources.length ? (
127 + <div class="flex flex-wrap gap-1">
128 + {row.data_sources.slice(0, MAX_SOURCES_TAG).map(s => (
129 + <NTag size="small" type="primary" key={s}>
130 + {s}
131 + </NTag>
132 + ))}
133 + {row.data_sources.length > MAX_SOURCES_TAG && (
134 + <NTag size="small">{`+${row.data_sources.length - MAX_SOURCES_TAG}`}</NTag>
135 + )}
136 + </div>
137 + ) : (
138 + <span class="text-tertiary text-xs">—</span>
139 + )
140 + },
141 + {
142 + title: "MITRE tactics",
143 + key: "tactics",
144 + render: row =>
145 + row.tactics.length ? (
146 + <div class="flex flex-wrap gap-1">
147 + {row.tactics.slice(0, MAX_TACTICS_TAG).map(t => (
148 + <NTag size="small" type="warning" key={t}>
149 + {t.toUpperCase()}
150 + </NTag>
151 + ))}
152 + {row.tactics.length > MAX_TACTICS_TAG && (
153 + <NTag size="small">{`+${row.tactics.length - MAX_TACTICS_TAG}`}</NTag>
154 + )}
155 + </div>
156 + ) : (
157 + <span class="text-tertiary text-xs">—</span>
158 + )
159 + },
160 + {
161 + title: "Products",
162 + key: "products",
163 + width: 180,
164 + render: row =>
165 + row.products.length ? (
166 + <div class="flex flex-wrap gap-1">
167 + {row.products.slice(0, MAX_PRODUCTS_TAG).map(p => (
168 + <NTag size="small" key={p}>
169 + {p}
170 + </NTag>
171 + ))}
172 + {row.products.length > MAX_PRODUCTS_TAG && (
173 + <NTag size="small">{`+${row.products.length - MAX_PRODUCTS_TAG}`}</NTag>
174 + )}
175 + </div>
176 + ) : (
177 + <span class="text-tertiary text-xs">—</span>
178 + )
179 + },
180 + {
181 + title: "Last updated",
182 + key: "date",
183 + width: 130,
184 + sorter: (a, b) => (a.date || "").localeCompare(b.date || ""),
185 + render: row =>
186 + row.date ? (
187 + <span class="font-mono text-xs">{formatDate(row.date, dFormats.date)}</span>
188 + ) : (
189 + <span class="text-tertiary text-xs">—</span>
190 + )
191 + }
192 +]
193 +
194 +function load() {
195 + loading.value = true
196 +
197 + Api.detectionCatalog
198 + .listStories()
199 + .then(res => {
200 + if (res.data?.success) stories.value = res.data.stories || []
201 + else message.warning(res.data?.message || "Failed to load analytic stories")
202 + })
203 + .catch(err => {
204 + message.error(err.response?.data?.message || "Failed to load analytic stories")
205 + })
206 + .finally(() => {
207 + loading.value = false
208 + })
209 +}
210 +
211 +onBeforeMount(load)
212 +</script>
frontend/src/components/detectionCatalog/StoryDetail.vue new
+302
@@ -0,0 +1,302 @@
1 +<template>
2 + <n-spin :show="loading" class="min-h-50">
3 + <div v-if="story" class="flex flex-col gap-6">
4 + <CardEntity>
5 + <template #headerMain>
6 + <div class="flex flex-col gap-1">
7 + <div class="text-secondary text-xs tracking-wide uppercase">Analytic Story</div>
8 + <h2 class="text-default text-xl leading-tight font-semibold">
9 + {{ story.name }}
10 + </h2>
11 + </div>
12 + </template>
13 +
14 + <template #default>
15 + <div class="flex flex-wrap gap-2">
16 + <Badge v-if="story.date" type="splitted" color="success">
17 + <template #label>Updated</template>
18 + <template #value>{{ formatDate(story.date, dFormats.date) }}</template>
19 + </Badge>
20 + <Badge v-if="story.authors.length" type="splitted" color="primary">
21 + <template #label>
22 + {{ story.authors.length === 1 ? "Author" : "Authors" }}
23 + </template>
24 + <template #value>{{ story.authors.join(", ") }}</template>
25 + </Badge>
26 + <Badge type="splitted">
27 + <template #label>Detections</template>
28 + <template #value>{{ story.detection_count }}</template>
29 + </Badge>
30 + <Badge v-if="story.products.length" type="splitted">
31 + <template #label>
32 + {{ story.products.length === 1 ? "Product" : "Products" }}
33 + </template>
34 + <template #value>{{ story.products.join(", ") }}</template>
35 + </Badge>
36 + <Badge v-if="story.version !== null" type="splitted">
37 + <template #label>Version</template>
38 + <template #value>v{{ story.version }}</template>
39 + </Badge>
40 + <Badge v-if="story.id" type="splitted">
41 + <template #label>ID</template>
42 + <template #value>
43 + {{ story.id }}
44 + </template>
45 + </Badge>
46 + </div>
47 + </template>
48 + </CardEntity>
49 +
50 + <!-- Description -->
51 + <CardEntity>
52 + <template #headerMain>
53 + <div class="flex items-center gap-2">
54 + <Icon name="carbon:document" :size="14" />
55 + <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Description</span>
56 + </div>
57 + </template>
58 + <template #default>
59 + {{ story.description }}
60 + </template>
61 + </CardEntity>
62 +
63 + <!-- Why it matters -->
64 + <CardEntity status="warning">
65 + <template #headerMain>
66 + <div class="flex items-center gap-2">
67 + <Icon name="carbon:warning-alt" :size="14" />
68 + <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Why It Matters</span>
69 + </div>
70 + </template>
71 + <template #default>
72 + {{ story.why_it_matters }}
73 + </template>
74 + </CardEntity>
75 +
76 + <!-- MITRE tactics (if any resolved) -->
77 + <CardEntity v-if="story.tactics.length">
78 + <template #headerMain>
79 + <div class="flex items-center gap-2">
80 + <Icon name="carbon:flag" :size="14" />
81 + <span class="text-secondary text-xs font-semibold tracking-wide uppercase">
82 + MITRE ATT&CK Tactics
83 + </span>
84 + </div>
85 + </template>
86 + <template #default>
87 + <div class="flex flex-wrap gap-2">
88 + <n-tag v-for="t of story.tactics" :key="t" type="warning">
89 + {{ t.toUpperCase() }}
90 + </n-tag>
91 + </div>
92 + </template>
93 + </CardEntity>
94 +
95 + <!-- Detections table -->
96 + <CardEntity>
97 + <template #header>
98 + <div class="flex flex-wrap items-center justify-between gap-2">
99 + <div class="flex items-center gap-2">
100 + <Icon name="carbon:radar" :size="14" />
101 + <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Detections</span>
102 + </div>
103 + <Badge type="splitted" size="small">
104 + <template #label>Count</template>
105 + <template #value>{{ story.detections.length }}</template>
106 + </Badge>
107 + </div>
108 + </template>
109 + <template #default>
110 + <n-data-table :columns="detectionColumns" :data="story.detections" size="small" />
111 + </template>
112 + </CardEntity>
113 +
114 + <!-- Data sources -->
115 + <CardEntity>
116 + <template #headerMain>
117 + <div class="flex items-center gap-2">
118 + <Icon name="carbon:data-base" :size="14" />
119 + <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Data Sources</span>
120 + </div>
121 + </template>
122 + <template #default>
123 + <div v-if="story.data_sources.length" class="flex flex-wrap gap-2">
124 + <n-tag v-for="ds of story.data_sources" :key="ds" type="primary">
125 + {{ ds }}
126 + </n-tag>
127 + </div>
128 + <p v-else class="text-sm">No data sources declared by member detections.</p>
129 + </template>
130 + </CardEntity>
131 +
132 + <!-- References -->
133 + <CardEntity>
134 + <template #headerMain>
135 + <div class="flex items-center gap-2">
136 + <Icon name="carbon:link" :size="14" />
137 + <span class="text-secondary text-xs font-semibold tracking-wide uppercase">References</span>
138 + </div>
139 + </template>
140 + <template #default>
141 + <div v-if="story.references.length" class="flex flex-col items-start gap-2">
142 + <n-button
143 + v-for="item of story.references"
144 + :key="item"
145 + type="primary"
146 + size="small"
147 + secondary
148 + @click="openReference(item)"
149 + >
150 + <template #icon>
151 + <Icon name="carbon:launch" :size="12" />
152 + </template>
153 + {{ item }}
154 + </n-button>
155 + </div>
156 + <p v-else class="text-sm">No references provided by member detections.</p>
157 + </template>
158 + </CardEntity>
159 + </div>
160 +
161 + <n-modal
162 + v-model:show="showRuleModal"
163 + preset="card"
164 + :style="{ maxWidth: 'min(820px, 92vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
165 + title="Detection Rule"
166 + :bordered="false"
167 + segmented
168 + >
169 + <RuleCardContent v-if="modalRuleId" :rule-id="modalRuleId" />
170 + </n-modal>
171 + </n-spin>
172 +</template>
173 +
174 +<script setup lang="tsx">
175 +import type { DataTableColumns } from "naive-ui"
176 +import type { CatalogStoryDetailResponse, CatalogStoryDetection } from "@/types/detectionCatalog.d"
177 +import { NButton, NDataTable, NModal, NSpin, NTag, useMessage } from "naive-ui"
178 +import { onBeforeMount, ref, watch } from "vue"
179 +import Api from "@/api"
180 +import Badge from "@/components/common/Badge.vue"
181 +import CardEntity from "@/components/common/cards/CardEntity.vue"
182 +import Icon from "@/components/common/Icon.vue"
183 +import RuleCardContent from "@/components/copilotSearches/RuleCardContent.vue"
184 +import { useSettingsStore } from "@/stores/settings"
185 +import { getApiErrorMessage } from "@/utils"
186 +import { formatDate } from "@/utils/format"
187 +
188 +const props = defineProps<{ storyName: string }>()
189 +
190 +const emits = defineEmits<{
191 + (e: "error", message: string): void
192 +}>()
193 +
194 +const message = useMessage()
195 +const story = ref<CatalogStoryDetailResponse | null>(null)
196 +const loading = ref(false)
197 +const dFormats = useSettingsStore().dateFormat
198 +const showRuleModal = ref(false)
199 +const modalRuleId = ref<string | null>(null)
200 +
201 +function openRuleDetail(ruleId: string) {
202 + modalRuleId.value = ruleId
203 + showRuleModal.value = true
204 +}
205 +
206 +function openReference(reference: string) {
207 + window.open(reference, "_blank")
208 +}
209 +
210 +function typeBadgeColor(t: string): "error" | "warning" | "primary" | undefined {
211 + const type = (t || "").toLowerCase()
212 + if (type === "ttp") return "error"
213 + if (type === "anomaly") return "warning"
214 + if (type === "hunting") return "primary"
215 + return undefined
216 +}
217 +
218 +const detectionColumns: DataTableColumns<CatalogStoryDetection> = [
219 + {
220 + title: "Name",
221 + key: "name",
222 + render: row => (
223 + <span class="text-primary cursor-pointer underline" onClick={() => openRuleDetail(row.id)}>
224 + {row.name}
225 + </span>
226 + )
227 + },
228 + {
229 + title: "Technique",
230 + key: "tactics",
231 + render: row => {
232 + const ids = row.mitre_attack_id || []
233 + if (!ids.length && !row.tactics.length) {
234 + return <span class="text-secondary text-xs">—</span>
235 + }
236 + return (
237 + <div class="flex flex-wrap gap-1">
238 + {row.tactics.map(t => (
239 + <NTag key={t} type="warning" size="small">
240 + {t.toUpperCase()}
241 + </NTag>
242 + ))}
243 + {ids.map(id => (
244 + <NTag key={id} type="info" size="small">
245 + {id}
246 + </NTag>
247 + ))}
248 + </div>
249 + )
250 + }
251 + },
252 + {
253 + title: "Type",
254 + key: "type",
255 + width: 120,
256 + render: row => {
257 + const color = typeBadgeColor(row.type)
258 + const text = (row.type || "—").toUpperCase()
259 + return (
260 + <NTag type={color} size="small" bordered={false}>
261 + {text}
262 + </NTag>
263 + )
264 + }
265 + }
266 +]
267 +
268 +function load(name: string) {
269 + loading.value = true
270 + story.value = null
271 + Api.detectionCatalog
272 + .getStory(name)
273 + .then(res => {
274 + if (res.data?.success) {
275 + story.value = res.data
276 + } else {
277 + message.warning(res.data?.message || "Failed to load story detail")
278 + }
279 + })
280 + .catch(err => {
281 + const status = err.response?.status
282 + let errorMessage = getApiErrorMessage(err) || "Failed to load story detail"
283 + if (status === 404) {
284 + errorMessage = `No detections found for story '${name}'`
285 + message.warning(errorMessage)
286 + } else {
287 + message.error(errorMessage)
288 + }
289 +
290 + emits("error", errorMessage)
291 + })
292 + .finally(() => {
293 + loading.value = false
294 + })
295 +}
296 +
297 +watch(
298 + () => props.storyName,
299 + name => load(name)
300 +)
301 +onBeforeMount(() => load(props.storyName))
302 +</script>
frontend/src/components/detectionCatalog/WazuhLogTest.vue new
+339
@@ -0,0 +1,339 @@
1 +<template>
2 + <div class="flex flex-col gap-6">
3 + <div class="flex flex-col gap-2">
4 + <p>Paste a raw log, see which Wazuh rule would fire (via Wazuh logtest)</p>
5 +
6 + <div v-if="history.length" class="flex justify-end">
7 + <n-popover placement="left-start" class="p-3!">
8 + <template #trigger>
9 + <div class="text-secondary flex items-center gap-2 text-xs">
10 + <Icon name="carbon:time" />
11 + History
12 + </div>
13 + </template>
14 +
15 + <div class="flex flex-col gap-4">
16 + <n-scrollbar trigger="none" style="max-height: 200px">
17 + <div class="flex flex-col gap-2">
18 + <Badge
19 + v-for="(item, idx) of history"
20 + :key="idx"
21 + point-cursor
22 + type="splitted"
23 + @click="restoreFromHistory(item)"
24 + >
25 + <template #label>
26 + <span
27 + v-if="item.matched"
28 + class="text-primary max-w-20 truncate font-mono text-xs"
29 + :title="item.rule_id?.toString() ?? '?'"
30 + >
31 + {{ item.rule_id ?? "?" }}
32 + </span>
33 + <span v-else class="text-tertiary font-mono text-xs">no match</span>
34 + </template>
35 + <template #value>
36 + <span class="text-secondary max-w-40 truncate text-xs" :title="item.event">
37 + {{ item.event }}
38 + </span>
39 + </template>
40 + </Badge>
41 + </div>
42 + </n-scrollbar>
43 + <div class="flex justify-end">
44 + <n-button size="tiny" secondary @click="clearHistory">Clear history</n-button>
45 + </div>
46 + </div>
47 + </n-popover>
48 + </div>
49 +
50 + <n-input
51 + v-model:value="event"
52 + type="textarea"
53 + placeholder="Paste a single log line — auditd / syslog / Windows EventChannel / Suricata eve.json etc."
54 + :autosize="{ minRows: 3, maxRows: 8 }"
55 + />
56 +
57 + <div class="flex flex-wrap items-center justify-between gap-3">
58 + <n-input-group size="small" class="flex-1">
59 + <n-input-group-label size="small" class="text-secondary!">Format</n-input-group-label>
60 + <n-select v-model:value="logFormat" :options="logFormatOptions" size="small" />
61 + </n-input-group>
62 +
63 + <div class="flex items-center gap-2">
64 + <n-button type="primary" size="small" :loading="testing" :disabled="!event.trim()" @click="runTest">
65 + <template #icon><Icon name="carbon:play" /></template>
66 + Test against Wazuh
67 + </n-button>
68 + </div>
69 + </div>
70 + </div>
71 +
72 + <div v-if="result" class="flex flex-col gap-2">
73 + <div class="flex justify-end">
74 + <n-button size="tiny" quaternary @click="clearResult">Clear test result</n-button>
75 + </div>
76 +
77 + <n-alert v-if="result.unavailable_reason" type="error" show-icon>
78 + <template #header>Logtest failed</template>
79 + {{ result.unavailable_reason }}
80 + </n-alert>
81 +
82 + <CardEntity v-else-if="!result.matched" size="small" status="warning">
83 + <div class="flex items-start gap-3">
84 + <Icon name="carbon:information" :size="18" class="text-warning mt-0.5" />
85 + <div class="flex flex-col gap-1">
86 + <div class="text-sm font-semibold">No rule matched</div>
87 + <div class="text-secondary text-xs leading-relaxed">
88 + Wazuh saw the log but no analyst-facing rule fired. Try a different "Format" — most
89 + agent-forwarded logs use
90 + <code>syslog</code>
91 + ; pure JSON payloads use
92 + <code>json</code>
93 + .
94 + </div>
95 + </div>
96 + </div>
97 + </CardEntity>
98 +
99 + <CardEntity v-else-if="result.rule" size="small" status="success">
100 + <template #headerMain>
101 + <div class="flex flex-wrap items-center justify-between gap-2">
102 + <div class="flex items-center gap-2">
103 + <Icon name="carbon:checkmark-filled" :size="16" class="text-success" />
104 + <span class="text-success text-sm font-semibold tracking-wide uppercase">Match</span>
105 + </div>
106 + </div>
107 + </template>
108 + <template #headerExtra>
109 + <n-button
110 + v-if="result.rule.id !== null"
111 + size="tiny"
112 + secondary
113 + @click="emit('open-rule', result.rule.id)"
114 + >
115 + <template #icon><Icon name="carbon:view" /></template>
116 + View in catalog
117 + </n-button>
118 + </template>
119 +
120 + <template #default>
121 + <div class="flex flex-col gap-2">
122 + <div class="text-sm">
123 + {{ result.rule.description }}
124 + </div>
125 + <div class="flex items-center gap-2">
126 + <Badge type="splitted" color="success" size="small">
127 + <template #label>Rule</template>
128 + <template #value>{{ result.rule.id }}</template>
129 + </Badge>
130 + <Badge type="splitted" :color="levelBadgeColor(result.rule.level)" size="small">
131 + <template #label>Level</template>
132 + <template #value>{{ result.rule.level ?? "—" }}</template>
133 + </Badge>
134 + </div>
135 + </div>
136 + </template>
137 +
138 + <template #mainExtra>
139 + <div class="flex flex-wrap gap-x-8 gap-y-5">
140 + <div v-if="result.rule.groups.length" class="flex flex-col gap-1">
141 + <span class="text-secondary text-[10px] tracking-wider uppercase">Groups</span>
142 + <div class="flex flex-wrap gap-1.5">
143 + <n-tag v-for="item of result.rule.groups" :key="item" size="small">
144 + {{ item }}
145 + </n-tag>
146 + </div>
147 + </div>
148 + <div v-if="result.rule.mitre.length" class="flex flex-col gap-1">
149 + <span class="text-secondary text-[10px] tracking-wider uppercase">MITRE ATT&CK</span>
150 + <div class="flex flex-wrap gap-1.5">
151 + <n-tag v-for="item of result.rule.mitre" :key="item" size="small">
152 + {{ item }}
153 + </n-tag>
154 + </div>
155 + </div>
156 + <div v-if="result.tactics.length" class="flex flex-col gap-1">
157 + <span class="text-secondary text-[10px] tracking-wider uppercase">Tactics</span>
158 + <div class="flex flex-wrap gap-1.5">
159 + <n-tag v-for="item of result.tactics" :key="item" size="small">
160 + {{ item.toUpperCase() }}
161 + </n-tag>
162 + </div>
163 + </div>
164 + </div>
165 + </template>
166 + </CardEntity>
167 +
168 + <n-collapse v-if="result.alert" arrow-placement="right">
169 + <n-collapse-item name="alert" class="[&_.n-collapse-item\_\_content-inner]:pt-2!">
170 + <template #header>
171 + <span class="text-secondary text-sm">Decoded alert envelope</span>
172 + </template>
173 + <CodeSource :code="result.alert" />
174 + </n-collapse-item>
175 + </n-collapse>
176 + </div>
177 + </div>
178 +</template>
179 +
180 +<script setup lang="ts">
181 +import type { CatalogLogTestResponse } from "@/types/detectionCatalog.d"
182 +import { useStorage } from "@vueuse/core"
183 +import {
184 + NAlert,
185 + NButton,
186 + NCollapse,
187 + NCollapseItem,
188 + NInput,
189 + NInputGroup,
190 + NInputGroupLabel,
191 + NPopover,
192 + NScrollbar,
193 + NSelect,
194 + NTag,
195 + useMessage
196 +} from "naive-ui"
197 +import { ref, watch } from "vue"
198 +import Api from "@/api"
199 +import Badge from "@/components/common/Badge.vue"
200 +import CardEntity from "@/components/common/cards/CardEntity.vue"
201 +import CodeSource from "@/components/common/CodeSource.vue"
202 +import Icon from "@/components/common/Icon.vue"
203 +
204 +const emit = defineEmits<{ (e: "open-rule", ruleId: number): void }>()
205 +const HISTORY_STORAGE_KEY = "detectionCatalog.logtest.history"
206 +const HISTORY_MAX = 5
207 +
208 +interface LogTestHistoryItem {
209 + event: string
210 + log_format: string
211 + matched: boolean
212 + rule_id: number | null
213 + rule_description: string
214 + timestamp: string
215 +}
216 +
217 +const message = useMessage()
218 +
219 +const event = ref("")
220 +const logFormat = ref("syslog")
221 +
222 +// Wazuh's most common log formats. Covers ~95% of analyst pastes — full list
223 +// would have ~30 entries and feel overwhelming for a one-off test.
224 +const logFormatOptions = [
225 + { label: "syslog", value: "syslog" },
226 + { label: "json", value: "json" },
227 + { label: "snort-full", value: "snort-full" },
228 + { label: "squid", value: "squid" },
229 + { label: "apache", value: "apache" },
230 + { label: "iis", value: "iis" },
231 + { label: "audit", value: "audit" },
232 + { label: "djb-multilog", value: "djb-multilog" },
233 + { label: "eventlog (Windows EVT)", value: "eventlog" },
234 + { label: "eventchannel (Windows EventChannel)", value: "eventchannel" }
235 +]
236 +
237 +const testing = ref(false)
238 +const result = ref<CatalogLogTestResponse | null>(null)
239 +const historyStorage = useStorage<unknown>(HISTORY_STORAGE_KEY, [], localStorage)
240 +const history = ref<LogTestHistoryItem[]>([])
241 +
242 +function clearResult() {
243 + result.value = null
244 +}
245 +
246 +function syncHistoryFromStorage() {
247 + try {
248 + const parsed = historyStorage.value
249 + history.value = Array.isArray(parsed)
250 + ? parsed
251 + .filter(item => typeof item?.event === "string" && typeof item?.log_format === "string")
252 + .slice(0, HISTORY_MAX)
253 + : []
254 + } catch {
255 + /* Storage unavailable/corrupted: history is best-effort. */
256 + history.value = []
257 + }
258 +}
259 +
260 +function persistHistory() {
261 + try {
262 + historyStorage.value = history.value
263 + } catch {
264 + /* QuotaExceeded / disabled — history is best-effort. */
265 + }
266 +}
267 +
268 +function recordInHistory(res: CatalogLogTestResponse) {
269 + const item: LogTestHistoryItem = {
270 + event: event.value,
271 + log_format: logFormat.value,
272 + matched: res.matched,
273 + rule_id: res.rule?.id ?? null,
274 + rule_description: res.rule?.description ?? "",
275 + timestamp: new Date().toISOString()
276 + }
277 + // De-dupe consecutive identical pastes.
278 + const dup = history.value[0]
279 + if (dup && dup.event === item.event && dup.log_format === item.log_format) {
280 + history.value[0] = item
281 + } else {
282 + history.value = [item, ...history.value].slice(0, HISTORY_MAX)
283 + }
284 + persistHistory()
285 +}
286 +
287 +function restoreFromHistory(item: LogTestHistoryItem) {
288 + event.value = item.event
289 + logFormat.value = item.log_format
290 + result.value = null
291 +}
292 +
293 +function clearHistory() {
294 + history.value = []
295 + persistHistory()
296 +}
297 +
298 +function runTest() {
299 + if (!event.value.trim()) return
300 + testing.value = true
301 + Api.detectionCatalog
302 + .runLogTest({
303 + event: event.value,
304 + log_format: logFormat.value,
305 + location: "logtest"
306 + })
307 + .then(res => {
308 + if (res.data?.success) {
309 + result.value = res.data
310 + recordInHistory(res.data)
311 + } else {
312 + message.warning(res.data?.message || "Logtest returned an unexpected response")
313 + }
314 + })
315 + .catch(err => {
316 + const detail = err.response?.data?.detail || err.response?.data?.message
317 + message.error(detail || "Logtest request failed")
318 + })
319 + .finally(() => {
320 + testing.value = false
321 + })
322 +}
323 +
324 +function levelBadgeColor(level: number | null): "danger" | "warning" | "primary" | "success" | undefined {
325 + if (level === null || level === undefined) return undefined
326 + if (level >= 12) return "danger"
327 + if (level >= 7) return "warning"
328 + if (level >= 3) return "primary"
329 + return "success"
330 +}
331 +
332 +watch(
333 + () => historyStorage.value,
334 + () => {
335 + syncHistoryFromStorage()
336 + },
337 + { immediate: true }
338 +)
339 +</script>
frontend/src/components/detectionCatalog/WazuhRuleDetail.vue new
+254
@@ -0,0 +1,254 @@
1 +<template>
2 + <n-spin :show="loading" class="min-h-50">
3 + <div v-if="rule" class="flex flex-col gap-4">
4 + <CardEntity :status="hitsCardStatus">
5 + <template #headerMain>
6 + <div class="flex items-center gap-3">
7 + <n-tag
8 + size="large"
9 + :type="levelTagType(rule.level)"
10 + :bordered="false"
11 + class="flex min-h-10 min-w-10 items-center justify-center font-mono font-bold"
12 + >
13 + {{ rule.level ?? "—" }}
14 + </n-tag>
15 + <div class="flex flex-col gap-0.5">
16 + <div class="text-secondary text-xs tracking-wide uppercase">Wazuh Rule</div>
17 + <div class="text-default text-lg leading-tight font-semibold">Rule {{ rule.id }}</div>
18 + </div>
19 + </div>
20 + </template>
21 +
22 + <template #default>
23 + <div class="flex flex-col gap-2">
24 + <div v-if="rule.description">
25 + {{ rule.description }}
26 + </div>
27 + <div class="flex flex-wrap gap-2">
28 + <Badge v-if="rule.status" type="splitted" color="success">
29 + <template #label>Status</template>
30 + <template #value>{{ rule.status }}</template>
31 + </Badge>
32 + <Badge v-if="rule.firing_stats_available" type="splitted" :color="hitsBadgeColor">
33 + <template #label>Hits 30d</template>
34 + <template #value>{{ rule.hits_30d.toLocaleString() }}</template>
35 + </Badge>
36 + <Badge v-if="rule.firing_stats_available" type="splitted">
37 + <template #label>Hits 7d</template>
38 + <template #value>{{ rule.hits_7d.toLocaleString() }}</template>
39 + </Badge>
40 + <Badge v-if="rule.firing_stats_available && rule.last_seen" type="splitted" color="primary">
41 + <template #label>Last fired</template>
42 + <template #value>{{ formatRelativeTime(rule.last_seen) }}</template>
43 + </Badge>
44 + <Badge v-if="rule.filename" type="splitted">
45 + <template #label>File</template>
46 + <template #value>{{ rule.filename }}</template>
47 + </Badge>
48 + </div>
49 + </div>
50 + </template>
51 + </CardEntity>
52 +
53 + <!-- Groups -->
54 + <CardEntity v-if="rule.groups.length">
55 + <template #headerMain>
56 + <SectionLabel icon="carbon:tag" label="Groups" />
57 + </template>
58 + <template #default>
59 + <div class="flex flex-wrap gap-1.5">
60 + <n-tag v-for="g of rule.groups" :key="g" size="small" type="primary">
61 + {{ g }}
62 + </n-tag>
63 + </div>
64 + </template>
65 + </CardEntity>
66 +
67 + <!-- MITRE ATT&CK -->
68 + <CardEntity v-if="rule.mitre.length">
69 + <template #headerMain>
70 + <SectionLabel icon="carbon:flag" label="MITRE ATT&CK" />
71 + </template>
72 + <template #default>
73 + <div class="flex flex-col gap-2">
74 + <div v-if="rule.mitre.length" class="flex flex-wrap gap-1.5">
75 + <n-tag v-for="t of rule.mitre" :key="t" size="small" type="warning">
76 + {{ t }}
77 + </n-tag>
78 + </div>
79 + </div>
80 + </template>
81 + </CardEntity>
82 +
83 + <CardEntity v-if="rule.tactics.length">
84 + <template #headerMain>
85 + <SectionLabel icon="carbon:radar" label="Tactics" />
86 + </template>
87 + <template #default>
88 + <div class="flex flex-col gap-2">
89 + <div v-if="rule.tactics.length" class="flex flex-wrap gap-1.5">
90 + <n-tag v-for="t of rule.tactics" :key="t" size="small">
91 + {{ t.toUpperCase() }}
92 + </n-tag>
93 + </div>
94 + </div>
95 + </template>
96 + </CardEntity>
97 +
98 + <!-- Compliance — only render frameworks that actually have values. -->
99 + <CardEntity v-if="hasCompliance">
100 + <template #headerMain>
101 + <SectionLabel icon="carbon:certificate-check" label="Compliance" />
102 + </template>
103 + <template #default>
104 + <div class="divide-border flex flex-col gap-0 divide-y">
105 + <div v-for="[key, values] of complianceEntries" :key class="flex items-start gap-3 py-2">
106 + <div
107 + class="text-secondary w-28 shrink-0 pt-0.5 text-xs font-semibold tracking-wide uppercase"
108 + >
109 + {{ key }}
110 + </div>
111 + <div class="flex flex-wrap gap-1">
112 + <n-tag v-for="v of values" :key="v" size="small">
113 + {{ v }}
114 + </n-tag>
115 + </div>
116 + </div>
117 + </div>
118 + </template>
119 + </CardEntity>
120 +
121 + <CardEntity v-if="rule.source_xml">
122 + <template #header>
123 + <SectionLabel icon="carbon:code" label="Rule Source" />
124 + </template>
125 + <template #default>
126 + <CodeSource :code="rule.source_xml" />
127 + </template>
128 + </CardEntity>
129 +
130 + <!-- File location footer -->
131 + <CardEntity v-if="rule.relative_dirname" embedded>
132 + <template #default>
133 + <div class="flex items-center gap-2 text-xs">
134 + <Icon name="carbon:folder" :size="12" />
135 + <span class="text-secondary">Location:</span>
136 + <code class="font-mono">{{ rule.relative_dirname }}/{{ rule.filename }}</code>
137 + </div>
138 + </template>
139 + </CardEntity>
140 + </div>
141 + </n-spin>
142 +</template>
143 +
144 +<script setup lang="ts">
145 +import type { TagProps } from "naive-ui"
146 +import type { CatalogWazuhRuleDetailResponse } from "@/types/detectionCatalog.d"
147 +import { NSpin, NTag, useMessage } from "naive-ui"
148 +import { computed, defineComponent, h, onBeforeMount, ref, watch } from "vue"
149 +import Api from "@/api"
150 +import Badge from "@/components/common/Badge.vue"
151 +import CardEntity from "@/components/common/cards/CardEntity.vue"
152 +import CodeSource from "@/components/common/CodeSource.vue"
153 +import Icon from "@/components/common/Icon.vue"
154 +import dayjs from "@/utils/dayjs"
155 +
156 +const props = defineProps<{ ruleId: number }>()
157 +
158 +const SectionLabel = defineComponent({
159 + props: { icon: String, label: String },
160 + setup(props) {
161 + return () =>
162 + h("div", { class: "flex items-center gap-2" }, [
163 + props.icon ? h(Icon, { name: props.icon, size: 14 }) : null,
164 + h("span", { class: "text-secondary text-xs font-semibold tracking-wide uppercase" }, props.label)
165 + ])
166 + }
167 +})
168 +
169 +const message = useMessage()
170 +
171 +const rule = ref<CatalogWazuhRuleDetailResponse | null>(null)
172 +const loading = ref(false)
173 +
174 +// Severity classes for the big hero level badge — same buckets as the
175 +// index column. Single source of truth at the top so they stay in sync.
176 +function levelTagType(level: number | null | undefined): TagProps["type"] {
177 + if (level === null || level === undefined) return "default"
178 + if (level >= 12) return "error"
179 + if (level >= 7) return "warning"
180 + if (level >= 3) return "info"
181 + return "default"
182 +}
183 +
184 +// Pick a card status tint for the hero based on hit volume — lets the modal
185 +// signal "this rule is hot right now" without screaming.
186 +const hitsCardStatus = computed<"success" | "warning" | "error" | undefined>(() => {
187 + if (!rule.value?.firing_stats_available) return undefined
188 + const h = rule.value.hits_30d
189 + if (h >= 10000) return "error"
190 + if (h >= 1000) return "warning"
191 + return undefined
192 +})
193 +
194 +const hitsBadgeColor = computed<"danger" | "warning" | "primary" | "success" | undefined>(() => {
195 + const h = rule.value?.hits_30d
196 + if (h === undefined || h === 0) return undefined
197 + if (h >= 10000) return "danger"
198 + if (h >= 1000) return "warning"
199 + if (h >= 100) return "primary"
200 + return "success"
201 +})
202 +
203 +const complianceEntries = computed<[string, string[]][]>(() => {
204 + const c = rule.value?.compliance
205 + if (!c) return []
206 + const labels: Record<string, string> = {
207 + pci_dss: "PCI DSS",
208 + gdpr: "GDPR",
209 + hipaa: "HIPAA",
210 + nist_800_53: "NIST 800-53",
211 + tsc: "TSC",
212 + gpg13: "GPG13"
213 + }
214 + return Object.entries(c)
215 + .filter(([, v]) => Array.isArray(v) && v.length > 0)
216 + .map(([k, v]) => [labels[k] ?? k, v as string[]])
217 +})
218 +
219 +const hasCompliance = computed(() => complianceEntries.value.length > 0)
220 +
221 +function formatRelativeTime(iso: string): string {
222 + return dayjs(iso).fromNow()
223 +}
224 +
225 +function load(id: number) {
226 + loading.value = true
227 + rule.value = null
228 + Api.detectionCatalog
229 + .getWazuhRule(id)
230 + .then(res => {
231 + if (res.data?.success) rule.value = res.data
232 + else message.warning(res.data?.message || "Failed to load Wazuh rule detail")
233 + })
234 + .catch(err => {
235 + const status = err.response?.status
236 + if (status === 404) {
237 + message.warning(`Wazuh rule ${id} not found in the cache`)
238 + } else {
239 + message.error(
240 + err.response?.data?.detail || err.response?.data?.message || "Failed to load Wazuh rule detail"
241 + )
242 + }
243 + })
244 + .finally(() => {
245 + loading.value = false
246 + })
247 +}
248 +
249 +watch(
250 + () => props.ruleId,
251 + id => load(id)
252 +)
253 +onBeforeMount(() => load(props.ruleId))
254 +</script>
frontend/src/components/detectionCatalog/WazuhRulesIndex.vue new
+467
@@ -0,0 +1,467 @@
1 +<template>
2 + <div class="flex flex-col gap-4">
3 + <div class="flex flex-wrap items-end justify-between gap-3">
4 + <div class="flex min-w-110 flex-1 flex-col gap-1">
5 + <h3 class="text-lg font-semibold">Wazuh Rules</h3>
6 + <p class="text-secondary text-sm">
7 + Every rule shipped by the Wazuh Manager. Sort by hits to spot noisy rules, switch to "Dead" to find
8 + rules that never fire, or filter by customer to see the picture for a specific tenant.
9 + </p>
10 + </div>
11 + <n-button secondary type="primary" size="small" @click="showTestLogLineDrawer = true">
12 + <template #icon><Icon name="carbon:test-tool" /></template>
13 + Test a log line
14 + </n-button>
15 + </div>
16 +
17 + <div class="flex flex-wrap items-center gap-2">
18 + <n-input
19 + v-model:value="filter"
20 + size="small"
21 + placeholder="Filter by ID, description, group, MITRE ID, or filename…"
22 + clearable
23 + class="min-w-80 flex-1"
24 + >
25 + <template #prefix><Icon name="carbon:search" /></template>
26 + </n-input>
27 +
28 + <n-select
29 + v-if="firingStatsAvailable"
30 + v-model:value="customerScope"
31 + clearable
32 + :options="customerOptions"
33 + :loading="loadingCustomers || refetchingForCustomer"
34 + size="small"
35 + class="min-w-80 flex-1"
36 + :consistent-menu-width="false"
37 + @update:value="onCustomerChange"
38 + />
39 +
40 + <Badge type="splitted" color="primary" class="shrink-0">
41 + <template #label>Showing</template>
42 + <template #value>{{ filteredRules.length }} / {{ rules.length }}</template>
43 + </Badge>
44 + </div>
45 +
46 + <!-- QUICK FILTER CHIPS - segmented style with hit-count summaries -->
47 + <div v-if="firingStatsAvailable" class="flex flex-wrap gap-2">
48 + <n-tag
49 + :type="activeChip === 'all' ? 'primary' : 'default'"
50 + round
51 + :bordered="activeChip !== 'all'"
52 + class="cursor-pointer!"
53 + @click="activeChip = 'all'"
54 + >
55 + <div class="flex items-center gap-2 text-xs">
56 + <Icon name="carbon:list" :size="13" />
57 + <span>All</span>
58 + <span
59 + class="text-secondary font-mono text-[11px] font-semibold"
60 + :class="{ 'text-primary!': activeChip === 'all' }"
61 + >
62 + {{ rules.length }}
63 + </span>
64 + </div>
65 + </n-tag>
66 + <n-tag
67 + :type="activeChip === 'noisy' ? 'warning' : 'default'"
68 + round
69 + :bordered="activeChip !== 'noisy'"
70 + class="cursor-pointer!"
71 + @click="activeChip = 'noisy'"
72 + >
73 + <div class="flex items-center gap-2 text-xs">
74 + <Icon name="carbon:flash" :size="13" />
75 + <span>Top noisy</span>
76 + <span
77 + class="text-secondary font-mono text-[11px] font-semibold"
78 + :class="{ 'text-warning!': activeChip === 'noisy' }"
79 + >
80 + 50
81 + </span>
82 + </div>
83 + </n-tag>
84 + <n-tag
85 + :type="activeChip === 'dead' ? 'error' : 'default'"
86 + round
87 + :bordered="activeChip !== 'dead'"
88 + class="cursor-pointer!"
89 + @click="activeChip = 'dead'"
90 + >
91 + <div class="flex items-center gap-2 text-xs">
92 + <Icon name="carbon:warning" :size="13" />
93 + <span>Dead (level ≥7)</span>
94 + <span
95 + class="text-secondary font-mono text-[11px] font-semibold"
96 + :class="{ 'text-error!': activeChip === 'dead' }"
97 + >
98 + {{ deadCount }}
99 + </span>
100 + </div>
101 + </n-tag>
102 + </div>
103 +
104 + <!-- Unavailable state: Wazuh Manager not reachable / not configured. -->
105 + <n-alert v-if="!loading && !available" type="warning" show-icon>
106 + <template #header>Wazuh Manager not available</template>
107 + {{ unavailableReason || "Could not reach the Wazuh Manager to load rules." }}
108 + </n-alert>
109 +
110 + <n-data-table
111 + v-else
112 + :columns
113 + :data="filteredRules"
114 + :loading
115 + size="small"
116 + :row-props
117 + :scroll-x="1400"
118 + :pagination
119 + class="catalog-table wazuh-rules-table"
120 + />
121 +
122 + <!-- Detail modal -->
123 + <n-modal
124 + v-model:show="showDetailModal"
125 + preset="card"
126 + :style="{ maxWidth: 'min(880px, 94vw)', minHeight: 'min(600px, 90vh)' }"
127 + :title="modalTitle"
128 + :bordered="false"
129 + segmented
130 + >
131 + <WazuhRuleDetail v-if="modalRuleId !== null" :rule-id="modalRuleId" />
132 + </n-modal>
133 +
134 + <n-drawer
135 + v-model:show="showTestLogLineDrawer"
136 + :width="700"
137 + class="max-w-[95vw]"
138 + placement="right"
139 + display-directive="show"
140 + >
141 + <n-drawer-content closable :native-scrollbar="false">
142 + <template #header>Test a log line</template>
143 + <WazuhLogTest @open-rule="openRuleById" />
144 + </n-drawer-content>
145 + </n-drawer>
146 + </div>
147 +</template>
148 +
149 +<script setup lang="tsx">
150 +import type { DataTableColumns, SelectOption, TagProps } from "naive-ui"
151 +import type { CatalogWazuhRuleRow } from "@/types/detectionCatalog.d"
152 +import {
153 + NAlert,
154 + NButton,
155 + NDataTable,
156 + NDrawer,
157 + NDrawerContent,
158 + NInput,
159 + NModal,
160 + NSelect,
161 + NTag,
162 + useMessage
163 +} from "naive-ui"
164 +import { computed, onBeforeMount, ref } from "vue"
165 +import Api from "@/api"
166 +import Badge from "@/components/common/Badge.vue"
167 +import Icon from "@/components/common/Icon.vue"
168 +import WazuhLogTest from "./WazuhLogTest.vue"
169 +import WazuhRuleDetail from "./WazuhRuleDetail.vue"
170 +
171 +const message = useMessage()
172 +const rules = ref<CatalogWazuhRuleRow[]>([])
173 +const loading = ref(false)
174 +const filter = ref("")
175 +
176 +const available = ref(true)
177 +const unavailableReason = ref<string | null>(null)
178 +const firingStatsAvailable = ref(true)
179 +
180 +type ChipKey = "all" | "noisy" | "dead"
181 +const activeChip = ref<ChipKey>("all")
182 +
183 +const customerScope = ref<string>("")
184 +const customerOptions = ref<SelectOption[]>([{ label: "All customers", value: "" }])
185 +const loadingCustomers = ref(false)
186 +const refetchingForCustomer = ref(false)
187 +
188 +const showTestLogLineDrawer = ref(false)
189 +const showDetailModal = ref(false)
190 +const modalRuleId = ref<number | null>(null)
191 +const modalTitle = ref("Wazuh Rule")
192 +
193 +const pagination = {
194 + pageSize: 50,
195 + pageSizes: [25, 50, 100, 200],
196 + showSizePicker: true
197 +}
198 +
199 +// Count of "dead" rules for the chip badge — keeps the analyst informed of
200 +// how many candidates the filter would surface before clicking.
201 +const deadCount = computed(() => rules.value.filter(r => r.hits_30d === 0 && (r.level ?? 0) >= 7).length)
202 +
203 +const filteredRules = computed<CatalogWazuhRuleRow[]>(() => {
204 + const q = filter.value.trim().toLowerCase()
205 + const textFiltered = !q
206 + ? rules.value
207 + : rules.value.filter(r =>
208 + [
209 + String(r.id ?? ""),
210 + r.description,
211 + r.filename,
212 + r.relative_dirname,
213 + ...(r.groups || []),
214 + ...(r.mitre || [])
215 + ]
216 + .join(" ")
217 + .toLowerCase()
218 + .includes(q)
219 + )
220 +
221 + if (activeChip.value === "noisy") {
222 + return [...textFiltered].sort((a, b) => b.hits_30d - a.hits_30d).slice(0, 50)
223 + }
224 + if (activeChip.value === "dead") {
225 + return textFiltered.filter(r => r.hits_30d === 0 && (r.level ?? 0) >= 7)
226 + }
227 + return textFiltered
228 +})
229 +
230 +function openRuleDetail(row: CatalogWazuhRuleRow) {
231 + if (typeof row.id !== "number") return
232 + modalRuleId.value = row.id
233 + modalTitle.value = `Rule ${row.id}`
234 + showDetailModal.value = true
235 +}
236 +
237 +function openRuleById(ruleId: number) {
238 + const row = rules.value.find(r => r.id === ruleId)
239 + modalRuleId.value = ruleId
240 + modalTitle.value = row ? `Rule ${ruleId}${row.description ? ` — ${row.description}` : ""}` : `Rule ${ruleId}`
241 + showDetailModal.value = true
242 +}
243 +
244 +function rowProps(row: CatalogWazuhRuleRow) {
245 + return {
246 + style: "cursor: pointer;",
247 + onClick: () => openRuleDetail(row)
248 + }
249 +}
250 +
251 +function levelTagType(level: number | null): TagProps["type"] {
252 + if (level === null || level === undefined) return "default"
253 + if (level >= 12) return "error"
254 + if (level >= 7) return "warning"
255 + if (level >= 3) return "info"
256 + return "default"
257 +}
258 +
259 +function renderRuleDescription(row: CatalogWazuhRuleRow) {
260 + if (!row.description) {
261 + return <span class="text-tertiary text-xs">(no description)</span>
262 + }
263 + return <span class="leading-snug">{row.description}</span>
264 +}
265 +
266 +function renderRuleGroups(row: CatalogWazuhRuleRow) {
267 + if (!row.groups.length) {
268 + return <span class="text-tertiary text-xs">—</span>
269 + }
270 + return (
271 + <div class="flex flex-wrap gap-1">
272 + {row.groups.slice(0, 3).map(g => (
273 + <NTag key={g} type="primary" size="small">
274 + {g}
275 + </NTag>
276 + ))}
277 + {row.groups.length > 3 && <NTag size="small">{`+${row.groups.length - 3}`}</NTag>}
278 + </div>
279 + )
280 +}
281 +
282 +function renderRuleMitre(row: CatalogWazuhRuleRow) {
283 + if (!row.mitre.length) {
284 + return <span class="text-tertiary text-xs">—</span>
285 + }
286 + return (
287 + <div class="flex flex-wrap gap-1">
288 + {row.mitre.map(t => (
289 + <NTag key={t} size="small">
290 + {t}
291 + </NTag>
292 + ))}
293 + </div>
294 + )
295 +}
296 +
297 +function loadCustomers() {
298 + loadingCustomers.value = true
299 + Api.customers
300 + .getCustomers()
301 + .then(res => {
302 + const list = res.data?.customers || []
303 + customerOptions.value = [
304 + { label: "All customers", value: "" },
305 + ...list.map(c => ({
306 + label: c.customer_name ? `${c.customer_name} (${c.customer_code})` : c.customer_code,
307 + value: c.customer_code
308 + }))
309 + ]
310 + })
311 + .catch(() => {
312 + /* Non-fatal — keep just "All customers" option. */
313 + })
314 + .finally(() => {
315 + loadingCustomers.value = false
316 + })
317 +}
318 +
319 +function onCustomerChange(value: string) {
320 + customerScope.value = value
321 + refetchingForCustomer.value = true
322 + load(true)
323 +}
324 +
325 +// Hits column — only included when the indexer is reachable. Rendering "0"
326 +// everywhere when stats are unavailable would mislead, so we hide the column
327 +// entirely.
328 +const hitsColumn = computed(() => ({
329 + title: "Activity",
330 + key: "hits_30d",
331 + width: 140,
332 + sorter: (a: CatalogWazuhRuleRow, b: CatalogWazuhRuleRow) => a.hits_30d - b.hits_30d,
333 + render: (row: CatalogWazuhRuleRow) => {
334 + if (row.hits_30d === 0) {
335 + return (
336 + <div class="flex items-center gap-1.5">
337 + <span class="dot dot-muted"></span>
338 + <span class="text-secondary text-xs">No hits 30d</span>
339 + </div>
340 + )
341 + }
342 + // Color the indicator dot by intensity bucket so analysts can scan
343 + // the column without reading numbers.
344 + const dotClass =
345 + row.hits_30d >= 10000
346 + ? "dot-danger"
347 + : row.hits_30d >= 1000
348 + ? "dot-warning"
349 + : row.hits_30d >= 100
350 + ? "dot-info"
351 + : "dot-success"
352 + return (
353 + <div class="flex items-center gap-2">
354 + <span class={`dot ${dotClass}`}></span>
355 + <div class="flex flex-col leading-tight">
356 + <span class="font-mono text-xs font-medium">{row.hits_30d.toLocaleString()}</span>
357 + <span class="text-secondary text-xs">{`${row.hits_7d.toLocaleString()} in 7d`}</span>
358 + </div>
359 + </div>
360 + )
361 + }
362 +}))
363 +
364 +const columns = computed<DataTableColumns<CatalogWazuhRuleRow>>(() => {
365 + const cols: DataTableColumns<CatalogWazuhRuleRow> = [
366 + {
367 + title: "ID",
368 + key: "id",
369 + fixed: "left",
370 + width: 100,
371 + sorter: (a, b) => (a.id ?? 0) - (b.id ?? 0),
372 + render: row => <span class="text-secondary font-mono text-xs">{row.id ?? "—"}</span>
373 + },
374 + {
375 + title: "Level",
376 + key: "level",
377 + width: 90,
378 + sorter: (a, b) => (a.level ?? 0) - (b.level ?? 0),
379 + render: row => (
380 + <NTag size="small" type={levelTagType(row.level)} bordered={false} class="font-mono font-bold">
381 + {row.level ?? "—"}
382 + </NTag>
383 + )
384 + },
385 + {
386 + title: "Description",
387 + key: "description",
388 + width: 400,
389 + render: renderRuleDescription
390 + },
391 + {
392 + title: "Groups",
393 + key: "groups",
394 + minWidth: 100,
395 + render: renderRuleGroups
396 + },
397 + {
398 + title: "MITRE",
399 + key: "mitre",
400 + width: 140,
401 + render: renderRuleMitre
402 + },
403 + {
404 + title: "File",
405 + key: "filename",
406 + width: 200,
407 + ellipsis: { tooltip: true },
408 + render: row => <span class="text-secondary font-mono text-xs">{row.filename || "—"}</span>
409 + }
410 + ]
411 + if (firingStatsAvailable.value) cols.push(hitsColumn.value)
412 + return cols
413 +})
414 +
415 +function load(isCustomerChange = false) {
416 + if (!isCustomerChange) loading.value = true
417 + Api.detectionCatalog
418 + .listWazuhRules(customerScope.value || undefined)
419 + .then(res => {
420 + if (res.data?.success) {
421 + rules.value = res.data.rules || []
422 + available.value = res.data.available
423 + unavailableReason.value = res.data.unavailable_reason
424 + firingStatsAvailable.value = res.data.firing_stats_available
425 + } else {
426 + message.warning(res.data?.message || "Failed to load Wazuh rules")
427 + }
428 + })
429 + .catch(err => {
430 + message.error(err.response?.data?.detail || err.response?.data?.message || "Failed to load Wazuh rules")
431 + })
432 + .finally(() => {
433 + loading.value = false
434 + refetchingForCustomer.value = false
435 + })
436 +}
437 +
438 +onBeforeMount(() => {
439 + loadCustomers()
440 + load()
441 +})
442 +</script>
443 +
444 +<style scoped lang="scss">
445 +:deep(.dot) {
446 + display: inline-block;
447 + width: 8px;
448 + height: 8px;
449 + border-radius: 50%;
450 + flex-shrink: 0;
451 +}
452 +:deep(.dot-muted) {
453 + background-color: var(--border-color);
454 +}
455 +:deep(.dot-success) {
456 + background-color: var(--success-color);
457 +}
458 +:deep(.dot-info) {
459 + background-color: var(--primary-color);
460 +}
461 +:deep(.dot-warning) {
462 + background-color: var(--warning-color);
463 +}
464 +:deep(.dot-danger) {
465 + background-color: var(--error-color);
466 +}
467 +</style>
frontend/src/components/graylog/Metrics/UncommittedEntries.vue
+66 -75
@@ -6,7 +6,7 @@
6 <Icon v-if="isWarning" :name="DangerIcon" />
7 <span>Uncommitted Journal Entries</span>
8 </div>
9 - <apexchart height="70" :options :series></apexchart>
9 + <VChart class="w-full" autoresize :option="chartOption" :style="{ height: '70px' }" />
10 </div>
11 <div class="value flex flex-col justify-center" :class="{ warning: isWarning }">
12 <span>{{ value }}</span>
@@ -29,21 +29,38 @@
29 </template>
30
31 <script setup lang="ts">
32 -import type { ApexOptions } from "apexcharts"
32 +import type { LineSeriesOption } from "echarts/charts"
33 +import type { GridComponentOption, TooltipComponentOption } from "echarts/components"
34 +import type { ComposeOption } from "echarts/core"
35 +import { LineChart } from "echarts/charts"
36 +import { GridComponent, TooltipComponent } from "echarts/components"
37 +import { use } from "echarts/core"
38 +import { CanvasRenderer } from "echarts/renderers"
39 import { NButton } from "naive-ui"
40 import { computed, ref, toRefs, watch } from "vue"
35 -import apexchart from "vue3-apexcharts"
41 +import VChart from "vue-echarts"
42 +import {
43 + buildChartTooltipGlassBase,
44 + chartTooltipThemeFromStyle,
45 + formatChartTooltipTimeAxisFirst
46 +} from "@/components/common/charts"
47 import Icon from "@/components/common/Icon.vue"
48 import { useNavigation } from "@/composables/useNavigation"
49 import { useHealthcheckStore } from "@/stores/healthcheck"
50 import { useThemeStore } from "@/stores/theme"
51 import dayjs from "@/utils/dayjs"
41 -import "@/assets/scss/overrides/apexchart-override.scss"
52
53 const props = defineProps<{
54 value: number
55 }>()
56
57 +use([CanvasRenderer, LineChart, TooltipComponent, GridComponent])
58 +
59 +type ChartOption = ComposeOption<TooltipComponentOption | GridComponentOption | LineSeriesOption>
60 +
61 +const MAX_POINTS = 100
62 +const CHART_ANIMATION_MAX_POINTS = 100
63 +
64 const UNCOMMITTED_JOURNAL_ENTRIES_THRESHOLD = useHealthcheckStore().uncommittedJournalEntriesThreshold
65
66 const { value } = toRefs(props)
@@ -52,90 +69,64 @@ const LinkIcon = "carbon:launch"
69 const DangerIcon = "majesticons:exclamation-line"
70
71 const style = computed(() => useThemeStore().style)
55 -const isThemeDark = computed(() => useThemeStore().isThemeDark)
72 const { routeGraylogManagement } = useNavigation()
73
58 -const isWarning = computed<boolean>(() => {
59 - return value.value > UNCOMMITTED_JOURNAL_ENTRIES_THRESHOLD
60 -})
74 +const dataPoints = ref<[number, number][]>([])
75
62 -const series = ref<{ name: string; data: [Date, number][] }[]>([
63 - {
64 - name: "Entries",
65 - data: []
66 - }
67 -])
76 +const isWarning = computed<boolean>(() => value.value > UNCOMMITTED_JOURNAL_ENTRIES_THRESHOLD)
77
69 -const serieLength = computed<number>(() => series.value[0]?.data.length ?? 0)
78 +const enableAnimation = computed(() => dataPoints.value.length < CHART_ANIMATION_MAX_POINTS)
79 +
80 +const chartOption = computed((): ChartOption => {
81 + const primary = style.value["primary-color"]
82
71 -function getOptions(): ApexOptions {
83 return {
73 - chart: {
74 - height: 70,
75 - type: "area",
76 - sparkline: {
77 - enabled: true
78 - },
79 - animations: {
80 - enabled: serieLength.value < 100,
81 - dynamicAnimation: {
82 - speed: 200
83 - }
84 - },
85 - toolbar: {
86 - show: false
87 - },
88 - zoom: {
89 - enabled: false
90 - }
91 - },
92 - dataLabels: {
93 - enabled: false
84 + backgroundColor: "transparent",
85 + grid: { left: 0, right: 0, top: 2, bottom: 2 },
86 + xAxis: {
87 + type: "time",
88 + show: false
89 },
95 - stroke: {
96 - curve: "straight",
97 - width: 3
90 + yAxis: {
91 + type: "value",
92 + show: false,
93 + scale: true
94 },
99 - xaxis: {
100 - type: "datetime"
101 - },
102 - colors: [style.value["primary-color"]],
95 tooltip: {
104 - x: {
105 - formatter: (time: number) => {
106 - return dayjs(time).format("HH:mm:ss")
107 - }
108 - },
109 - y: {
110 - formatter: (val: number) => {
111 - return `${val}` || "&nbsp;0"
112 - }
113 - },
114 - style: {
115 - fontFamily: style.value["font-family-mono"]
116 - },
117 - theme: isThemeDark.value ? "dark" : "light"
118 - }
96 + ...buildChartTooltipGlassBase(
97 + chartTooltipThemeFromStyle({
98 + ...style.value,
99 + "font-family": style.value["font-family-mono"]
100 + }),
101 + { trigger: "axis" }
102 + ),
103 + formatter: params =>
104 + formatChartTooltipTimeAxisFirst(params, {
105 + formatTime: ts => dayjs(ts).format("HH:mm:ss"),
106 + resolveColor: () => primary
107 + })
108 + },
109 + series: [
110 + {
111 + name: "Entries",
112 + type: "line",
113 + showSymbol: false,
114 + smooth: false,
115 + lineStyle: { width: 3, color: primary },
116 + areaStyle: { color: primary, opacity: 0.12 },
117 + animation: enableAnimation.value,
118 + animationDurationUpdate: 200,
119 + data: dataPoints.value
120 + }
121 + ]
122 }
120 -}
121 -
122 -const options = ref(getOptions())
123 +})
124
125 watch(value, val => {
125 - if (serieLength.value > 100) {
126 - series.value[0]?.data.shift()
127 -
128 - if (options.value.chart?.animations?.enabled) {
129 - options.value = getOptions()
130 - }
131 - }
132 - if (series.value[0]) {
133 - series.value[0].data.push([new Date(), val])
126 + if (dataPoints.value.length >= MAX_POINTS) {
127 + dataPoints.value.shift()
128 }
135 -})
136 -
137 -watch(isThemeDark, () => {
138 - options.value = getOptions()
129 + dataPoints.value.push([Date.now(), val])
130 })
131 </script>
132
frontend/src/components/indices/CustomerIndicesSize.vue
+48 -115
@@ -1,48 +1,53 @@
1 <template>
2 - <n-card title="Storage per Customer" :bordered>
2 + <n-card title="Storage per Customer" segmented content-class="pr-1!">
3 <n-spin :show="loading">
4 - <div v-if="customerSizes && customerSizes.length > 0" class="customer-list">
5 - <div v-for="customer in customerSizes" :key="customer.customer" class="customer-item">
6 - <div class="customer-header">
7 - <div class="customer-name">
8 - <n-text strong>{{ customer.customer }}</n-text>
9 - <n-popover trigger="click" placement="bottom" :width="300">
10 - <template #trigger>
11 - <n-tag size="small" :bordered="false" type="info" class="clickable-tag">
12 - {{ customer.index_count }}
13 - {{ customer.index_count === 1 ? "index" : "indices" }}
14 - </n-tag>
15 - </template>
16 - <div class="indices-popover">
17 - <div class="popover-header">
18 - <n-text strong>Indices for {{ customer.customer }}</n-text>
4 + <div v-if="customerSizes && customerSizes.length > 0">
5 + <n-scrollbar style="max-height: 200px" trigger="none" content-class="flex flex-col gap-4 pr-5!">
6 + <div v-for="customer in customerSizes" :key="customer.customer" class="flex flex-col gap-1">
7 + <div class="flex items-center justify-between gap-2">
8 + <div class="flex items-center gap-2">
9 + <div class="font-mono font-bold">{{ customer.customer }}</div>
10 + <n-popover trigger="click" placement="bottom" :width="300">
11 + <template #trigger>
12 + <n-tag size="small" class="cursor-pointer!">
13 + {{ customer.index_count }}
14 + {{ customer.index_count === 1 ? "index" : "indices" }}
15 + </n-tag>
16 + </template>
17 + <div class="flex flex-col gap-2">
18 + <span class="text-sm font-semibold">Indices for {{ customer.customer }}</span>
19 + <n-scrollbar style="max-height: 200px" trigger="none">
20 + <ul>
21 + <li
22 + v-for="index in customer.indices"
23 + :key="index"
24 + @click="selectIndex(index)"
25 + >
26 + <div
27 + class="hover:text-primary flex cursor-pointer items-center gap-2 font-mono text-sm transition-colors"
28 + >
29 + {{ index }}
30 +
31 + <Icon name="carbon:launch" />
32 + </div>
33 + </li>
34 + </ul>
35 + </n-scrollbar>
36 </div>
20 - <n-scrollbar style="max-height: 200px">
21 - <div class="indices-list">
22 - <div
23 - v-for="index in customer.indices"
24 - :key="index"
25 - class="index-item"
26 - @click="selectIndex(index)"
27 - >
28 - <n-text code class="index-link">{{ index }}</n-text>
29 - </div>
30 - </div>
31 - </n-scrollbar>
32 - </div>
33 - </n-popover>
37 + </n-popover>
38 + </div>
39 + <div class="text-secondary font-mono text-sm">{{ customer.total_size_human }}</div>
40 </div>
35 - <n-text class="customer-size">{{ customer.total_size_human }}</n-text>
41 + <n-progress
42 + type="line"
43 + :percentage="getPercentage(customer.total_size_bytes)"
44 + :show-indicator="false"
45 + :height="8"
46 + :border-radius="4"
47 + :color="getProgressColor(customer.total_size_bytes)"
48 + />
49 </div>
37 - <n-progress
38 - type="line"
39 - :percentage="getPercentage(customer.total_size_bytes)"
40 - :show-indicator="false"
41 - :height="8"
42 - :border-radius="4"
43 - :color="getProgressColor(customer.total_size_bytes)"
44 - />
45 - </div>
50 + </n-scrollbar>
51 </div>
52 <n-empty v-else-if="!loading" description="No customer data available" />
53 </n-spin>
@@ -50,10 +55,10 @@
55 </template>
56
57 <script lang="ts" setup>
53 -// TODO-FE: refactor
54 -import { NCard, NEmpty, NPopover, NProgress, NScrollbar, NSpin, NTag, NText, useMessage, useThemeVars } from "naive-ui"
58 +import { NCard, NEmpty, NPopover, NProgress, NScrollbar, NSpin, NTag, useMessage, useThemeVars } from "naive-ui"
59 import { computed, onBeforeMount, ref } from "vue"
60 import Api from "@/api"
61 +import Icon from "@/components/common/Icon.vue"
62
63 interface CustomerIndicesSize {
64 customer: string
@@ -63,10 +68,6 @@ interface CustomerIndicesSize {
68 indices: string[]
69 }
70
66 -defineProps<{
67 - bordered?: boolean
68 -}>()
69 -
71 const emit = defineEmits<{
72 (e: "click", value: string): void
73 }>()
@@ -91,7 +92,7 @@ function getProgressColor(sizeBytes: number): string {
92 const percentage = getPercentage(sizeBytes)
93 if (percentage >= 80) return themeVars.value.errorColor
94 if (percentage >= 60) return themeVars.value.warningColor
94 - return themeVars.value.primaryColor
95 + return themeVars.value.infoColor
96 }
97
98 function selectIndex(indexName: string) {
@@ -122,71 +123,3 @@ onBeforeMount(() => {
123 getCustomerIndicesSize()
124 })
125 </script>
125 -
126 -<style lang="scss" scoped>
127 -.customer-list {
128 - display: flex;
129 - flex-direction: column;
130 - gap: 16px;
131 - max-height: 400px;
132 - overflow-y: auto;
133 -
134 - .customer-item {
135 - .customer-header {
136 - display: flex;
137 - justify-content: space-between;
138 - align-items: center;
139 - margin-bottom: 6px;
140 -
141 - .customer-name {
142 - display: flex;
143 - align-items: center;
144 - gap: 8px;
145 -
146 - .clickable-tag {
147 - cursor: pointer;
148 - transition: opacity 0.2s;
149 -
150 - &:hover {
151 - opacity: 0.8;
152 - }
153 - }
154 - }
155 -
156 - .customer-size {
157 - font-weight: 600;
158 - font-family: var(--font-family-mono);
159 - }
160 - }
161 - }
162 -}
163 -
164 -.indices-popover {
165 - .popover-header {
166 - margin-bottom: 8px;
167 - padding-bottom: 8px;
168 - border-bottom: 1px solid var(--border-color);
169 - }
170 -
171 - .indices-list {
172 - display: flex;
173 - flex-direction: column;
174 - gap: 4px;
175 -
176 - .index-item {
177 - padding: 4px 0;
178 - cursor: pointer;
179 - border-radius: 4px;
180 - transition: background-color 0.2s;
181 -
182 - &:hover {
183 - background-color: var(--hover-color);
184 - }
185 -
186 - .index-link {
187 - cursor: pointer;
188 - }
189 - }
190 - }
191 -}
192 -</style>
frontend/src/components/indices/TopIndices.vue
+88 -106
@@ -1,76 +1,104 @@
1 <template>
2 - <n-card class="top-indices-chart-container" title="Top 8 indices size & health" segmented>
3 - <n-spin style="height: 400px; overflow: hidden" :show="loading">
4 - <div class="overflow-hidden">
5 - <div id="top-indices-chart" style="max-width: 100%; height: 400px"></div>
2 + <n-card class="box-border w-full overflow-hidden" title="Top 8 indices size & health" segmented>
3 + <n-spin class="h-100 overflow-hidden" :show="loading">
4 + <div class="h-100 max-w-full overflow-hidden">
5 + <VChart ref="chartRef" class="h-full w-full" autoresize :option="chartOption" />
6 </div>
7 </n-spin>
8 </n-card>
9 </template>
10
11 <script setup lang="ts">
12 -// TODO-FE: refactor
13 -import type { ECharts } from "echarts/core"
12 +import type { PieSeriesOption } from "echarts/charts"
13 +import type { GridComponentOption, LegendComponentOption, TooltipComponentOption } from "echarts/components"
14 +import type { ComposeOption } from "echarts/core"
15 import type { IndexStats } from "@/types/indices.d"
16 import bytes from "bytes"
17 import { PieChart } from "echarts/charts"
18 import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components"
18 -
19 -import { init as echartsInit, use as echartsUse } from "echarts/core"
19 +import { use } from "echarts/core"
20 import { CanvasRenderer } from "echarts/renderers"
21 import _ from "lodash"
22 import { NCard, NSpin } from "naive-ui"
23 -import { computed, onMounted, ref, toRefs, watch } from "vue"
23 +import { computed, ref, toRefs } from "vue"
24 +import VChart from "vue-echarts"
25 +import {
26 + buildChartTooltipGlassBase,
27 + CHART_COLORS,
28 + chartTooltipThemeFromStyle,
29 + formatChartTooltipWithMarker
30 +} from "@/components/common/charts"
31 import { useThemeStore } from "@/stores/theme"
32 import { IndexHealth } from "@/types/indices.d"
33
34 const props = defineProps<{
35 indices: IndexStats[] | null
36 }>()
37 +
38 +use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent, GridComponent])
39 +
40 +type ChartOption = ComposeOption<TooltipComponentOption | LegendComponentOption | GridComponentOption | PieSeriesOption>
41 +
42 const { indices } = toRefs(props)
43
44 const style = computed(() => useThemeStore().style)
33 -const loading = computed(() => !indices?.value || indices.value === null)
34 -const chartCtx = ref<ECharts | null>(null)
45 +const loading = computed(() => !indices.value)
46 +const chartRef = ref<InstanceType<typeof VChart> | null>(null)
47
36 -function getOptions() {
37 - const data = _.chain(indices.value || [])
48 +const labelFontSize = computed(() => (window.innerWidth > 1000 ? 13 : 11))
49 +
50 +const chartOption = computed((): ChartOption => {
51 + const list = indices.value ?? []
52 +
53 + const data = _.chain(list)
54 .map(i => {
39 - if (typeof i.store_size === "string") {
40 - i.store_size_value = bytes(i.store_size) || undefined
55 + const item = { ...i }
56 + if (typeof item.store_size === "string") {
57 + item.store_size_value = bytes(item.store_size) || undefined
58 } else {
42 - i.store_size_value = i.store_size
59 + item.store_size_value = item.store_size
60 }
44 - return i
61 + return item
62 })
63 .orderBy(["store_size"], ["desc"])
64 .slice(0, 8)
65 .value()
66
50 - const green = (indices.value || []).filter(i => i.health === IndexHealth.GREEN).length
51 - const yellow = (indices.value || []).filter(i => i.health === IndexHealth.YELLOW).length
52 - const red = (indices.value || []).filter(i => i.health === IndexHealth.RED).length
67 + const green = list.filter(i => i.health === IndexHealth.GREEN).length
68 + const yellow = list.filter(i => i.health === IndexHealth.YELLOW).length
69 + const red = list.filter(i => i.health === IndexHealth.RED).length
70
54 - const sizeData: { value: number; name: string }[] = data.map(i => ({
71 + const sizeData = data.map(i => ({
72 value: i.store_size_value || 0,
73 name: i.index
74 }))
75
76 + const fg = style.value["fg-default-color"]
77 + const bg = style.value["bg-default-color"]
78 +
79 + const tooltipBase = buildChartTooltipGlassBase(chartTooltipThemeFromStyle(style.value))
80 +
81 return {
82 tooltip: {
61 - trigger: "item",
62 - formatter: "{a}<hr/>{b}: <strong>{c}</strong> ({d}%)"
83 + ...tooltipBase,
84 + formatter: params => {
85 + if (!params || Array.isArray(params)) return ""
86 + const value = typeof params.value === "number" ? params.value : 0
87 + const percent = typeof params.percent === "number" ? params.percent : 0
88 + return formatChartTooltipWithMarker({
89 + marker: params.marker,
90 + color: params.color,
91 + title: params.seriesName ?? "",
92 + lines: [`${params.name}: <strong>${value}</strong> (${percent}%)`]
93 + })
94 + }
95 },
96 legend: {
97 data: sizeData.map(i => i.name),
98 left: "left",
99 type: "scroll",
68 - textStyle: {
69 - color: style.value["fg-default-color"]
70 - },
71 - pageTextStyle: {
72 - color: style.value["fg-default-color"]
73 - }
100 + textStyle: { color: fg },
101 + pageTextStyle: { color: fg }
102 },
103 grid: {
104 top: "0%",
@@ -80,16 +108,13 @@ function getOptions() {
108 series: [
109 {
110 name: "Indices Health",
83 - top: "-35%",
84 - bottom: "-50%",
111 + top: "-30%",
112 zlevel: 1,
113 type: "pie",
114 radius: [0, "20%"],
88 - label: {
89 - show: false
90 - },
115 + label: { show: false },
116 itemStyle: {
92 - borderColor: style.value["bg-default-color"],
117 + borderColor: bg,
118 borderWidth: 2
119 },
120 data: [
@@ -101,26 +126,24 @@ function getOptions() {
126 {
127 name: "Indices Size",
128 type: "pie",
104 - top: "-35%",
105 - bottom: "-50%",
106 - color: [
107 - "#082f49",
108 - "#0c4a6e",
109 - "#075985",
110 - "#0369a1",
111 - "#0284c7",
112 - "#0ea5e9",
113 - "#38bdf8",
114 - "#7dd3fc",
115 - "#bae6fd"
116 - ],
129 + top: "-20%",
130 + bottom: "10%",
131 + color: [...CHART_COLORS],
132 tooltip: {
118 - formatter: (params: { seriesName: string; name: string; value: number; percent: number }) => {
119 - return `${params.seriesName}<hr/>${params.name}<br/><strong>${bytes(params.value)}</strong> (${
120 - params.percent
121 - }%)`
133 + ...tooltipBase,
134 + formatter: params => {
135 + if (!params || Array.isArray(params)) return ""
136 + const value = typeof params.value === "number" ? params.value : 0
137 + const percent = typeof params.percent === "number" ? params.percent : 0
138 + return formatChartTooltipWithMarker({
139 + marker: params.marker,
140 + color: params.color,
141 + title: params.seriesName ?? "",
142 + lines: [`${params.name ?? ""}`, `<strong>${bytes(value)}</strong> (${percent}%)`]
143 + })
144 }
145 },
146 +
147 avoidLabelOverlap: true,
148 radius: ["24%", "35%"],
149 minAngle: 5,
@@ -139,25 +162,28 @@ function getOptions() {
162 minMargin: 10,
163 edgeDistance: 10,
164 lineHeight: 15,
142 - // alignTo: "edge",
165 rich: {
166 name: {
145 - color: style.value["fg-default-color"],
146 - fontSize: window.innerWidth > 1000 ? 13 : 11
167 + color: fg,
168 + fontSize: labelFontSize.value
169 },
170 per: {
149 - fontSize: window.innerWidth > 1000 ? 13 : 11,
171 + fontSize: labelFontSize.value,
172 fontWeight: "bold",
151 - color: style.value["fg-default-color"]
173 + color: fg
174 }
175 }
176 },
155 - labelLayout(params: { labelLinePoints: Array<number[]>; labelRect: { x: number; width: number } }) {
156 - const isLeft = chartCtx.value ? params.labelRect.x < chartCtx.value.getWidth() / 2 : false
177 + labelLayout(params) {
178 + const chartWidth = chartRef.value?.getWidth() ?? 0
179 const points = params.labelLinePoints
158 - // Update the end point.
180 + const labelRect = params.labelRect
181 + if (!points || !labelRect) {
182 + return {}
183 + }
184 + const isLeft = chartWidth > 0 ? labelRect.x < chartWidth / 2 : false
185 if (points[2]) {
160 - points[2][0] = isLeft ? params.labelRect.x : params.labelRect.x + params.labelRect.width
186 + points[2][0] = isLeft ? labelRect.x : labelRect.x + labelRect.width
187 }
188 return {
189 labelLinePoints: points,
@@ -167,56 +193,12 @@ function getOptions() {
193 }
194 },
195 itemStyle: {
170 - borderColor: style.value["bg-default-color"],
196 + borderColor: bg,
197 borderWidth: 1
198 },
199 data: sizeData
200 }
201 ]
202 }
177 -}
178 -
179 -watch(indices, () => {
180 - if (chartCtx.value) {
181 - chartCtx.value.setOption(getOptions())
182 - }
183 -})
184 -
185 -watch(style, () => {
186 - if (chartCtx.value) {
187 - chartCtx.value.setOption(getOptions())
188 - }
189 -})
190 -
191 -onMounted(() => {
192 - const chartDom = document.getElementById("top-indices-chart")
193 -
194 - echartsUse([
195 - TooltipComponent,
196 - LegendComponent,
197 - GridComponent,
198 - PieChart,
199 - CanvasRenderer // If you only need to use the canvas rendering mode, the bundle will not include the SVGRenderer module, which is not needed.
200 - ])
201 -
202 - chartCtx.value = echartsInit(chartDom)
203 -
204 - chartCtx.value.setOption(getOptions())
205 -
206 - if (chartDom) {
207 - new ResizeObserver(() => {
208 - if (chartCtx.value) {
209 - chartCtx.value.resize()
210 - }
211 - }).observe(chartDom)
212 - }
203 })
204 </script>
215 -
216 -<style lang="scss" scoped>
217 -.top-indices-chart-container {
218 - width: 100%;
219 - overflow: hidden;
220 - box-sizing: border-box;
221 -}
222 -</style>
frontend/src/components/indices/UnhealthyIndices.vue
+2 -2
@@ -1,5 +1,5 @@
1 <template>
2 - <n-card class="unhealthy-indices" segmented>
2 + <n-card class="unhealthy-indices" segmented content-class="pr-1!">
3 <template #header>
4 <div class="align-center flex justify-between">
5 <span>Unhealthy Indices</span>
@@ -8,7 +8,7 @@
8 </template>
9 <n-spin :show="loading">
10 <div class="min-h-14">
11 - <n-scrollbar class="max-h-125" trigger="none">
11 + <n-scrollbar class="max-h-125" trigger="none" content-class="pr-5!">
12 <template v-if="unhealthyIndices && unhealthyIndices.length">
13 <div
14 v-for="item of unhealthyIndices"
frontend/src/components/metrics/MetricsChart.vue
+111 -91
@@ -1,16 +1,39 @@
1 <template>
2 - <div ref="chartEl" :style="{ height: `${height}px` }"></div>
2 + <VChart
3 + ref="chartRef"
4 + class="w-full"
5 + :autoresize="{ onResize: updatePlotWidth }"
6 + :option="chartOption"
7 + :style="{ height: `${height}px` }"
8 + @finished="updatePlotWidth"
9 + />
10 </template>
11
12 <script setup lang="ts">
6 -import type { ECharts } from "echarts/core"
13 +import type { LineSeriesOption } from "echarts/charts"
14 +import type {
15 + GridComponentOption,
16 + LegendComponentOption,
17 + TitleComponentOption,
18 + TooltipComponentOption
19 +} from "echarts/components"
20 +import type { ComposeOption } from "echarts/core"
21 +import type { CallbackDataParams } from "echarts/types/dist/shared"
22 import type { TimeSeriesData } from "@/types/metrics.d"
8 -import { BarChart, GaugeChart, LineChart } from "echarts/charts"
9 -import { DataZoomComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components"
10 -import { init as echartsInit, use as echartsUse } from "echarts/core"
23 +import { LineChart } from "echarts/charts"
24 +import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components"
25 +import { use } from "echarts/core"
26 import { CanvasRenderer } from "echarts/renderers"
12 -import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue"
27 +import { computed, ref, toRefs } from "vue"
28 +import VChart from "vue-echarts"
29 import { useThemeStore } from "@/stores/theme"
30 +import { formatBytes } from "@/utils/format"
31 +import {
32 + buildChartTooltipGlassBase,
33 + CHART_COLORS,
34 + chartTooltipThemeFromStyle,
35 + formatChartTooltipAxisMultiSeriesFromParams
36 +} from "../common/charts"
37
38 const props = withDefaults(
39 defineProps<{
@@ -18,60 +41,66 @@ const props = withDefaults(
41 series: TimeSeriesData
42 height?: number
43 yAxisName?: string
21 - formatBytes?: boolean
44 + useFormatBytes?: boolean
45 }>(),
46 {
47 height: 250,
48 yAxisName: "",
26 - formatBytes: false
49 + useFormatBytes: false
50 }
51 )
52
30 -echartsUse([
31 - TitleComponent,
32 - TooltipComponent,
33 - LegendComponent,
34 - GridComponent,
35 - DataZoomComponent,
36 - LineChart,
37 - BarChart,
38 - GaugeChart,
39 - CanvasRenderer
40 -])
53 +use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
54
42 -const COLORS = ["#38bdf8", "#22c55e", "#eab308", "#ef4444", "#a855f7", "#f97316", "#06b6d4", "#ec4899"]
55 +type ChartOption = ComposeOption<
56 + TitleComponentOption | TooltipComponentOption | LegendComponentOption | GridComponentOption | LineSeriesOption
57 +>
58
44 -const { title, series, height, yAxisName, formatBytes } = toRefs(props)
59 +const GRID_HORIZONTAL_PADDING = 80
60
46 -const chartEl = ref<HTMLElement | null>(null)
47 -const chartCtx = ref<ECharts | null>(null)
61 +const { title, series, height, yAxisName, useFormatBytes } = toRefs(props)
62 const style = computed(() => useThemeStore().style)
49 -let resizeObserver: ResizeObserver | null = null
63 +const chartRef = ref<InstanceType<typeof VChart> | null>(null)
64 +const plotWidth = ref(0)
65
51 -function fmtBytes(bytes: number): string {
52 - if (bytes === null || bytes === undefined) return "—"
53 - const units = ["B", "KB", "MB", "GB", "TB"]
54 - let i = 0
55 - let v = Number(bytes)
56 - while (v >= 1024 && i < units.length - 1) {
57 - v /= 1024
58 - i++
59 - }
60 - return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
66 +function updatePlotWidth() {
67 + const chartWidth = chartRef.value?.getWidth() ?? 0
68 + plotWidth.value = Math.max(0, chartWidth - GRID_HORIZONTAL_PADDING)
69 }
70
63 -function getOptions() {
71 +const xAxisLabelFormatter = computed(() => {
72 + const compact = plotWidth.value > 0 && plotWidth.value < 360
73 + return (value: string | number) => {
74 + const ts = typeof value === "number" ? value : Number(value)
75 + const d = new Date(ts)
76 + if (Number.isNaN(d.getTime())) return String(value)
77 + if (compact) {
78 + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
79 + }
80 + return d.toLocaleString(undefined, {
81 + month: "short",
82 + day: "numeric",
83 + hour: "2-digit",
84 + minute: "2-digit"
85 + })
86 + }
87 +})
88 +
89 +const chartOption = computed((): ChartOption => {
90 const seriesData = series.value || {}
91 const seriesNames = Object.keys(seriesData)
92 + const fg = style.value["fg-default-color"]
93 + const fgSecondary = style.value["fg-secondary-color"]
94 + const border = style.value["border-color"]
95
67 - const eSeries = seriesNames.map((name, i) => ({
96 + const eSeries: LineSeriesOption[] = seriesNames.map((name, i) => ({
97 name,
69 - type: "line" as const,
98 + type: "line",
99 smooth: true,
100 symbol: "none",
101 lineStyle: { width: 1.5 },
102 areaStyle: { opacity: 0.08 },
74 - itemStyle: { color: COLORS[i % COLORS.length] },
103 + itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length] },
104 data: (seriesData[name] || []).map(d => [new Date(d.time).getTime(), d.value])
105 }))
106
@@ -80,39 +109,43 @@ function getOptions() {
109 title: {
110 text: title.value,
111 textStyle: {
83 - color: style.value["fg-default-color"],
112 + color: fg,
113 fontSize: 13,
114 fontWeight: 500,
115 fontFamily: style.value["font-family"]
116 },
117 left: 10,
89 - top: 5
118 + top: 0
119 },
120 tooltip: {
92 - trigger: "axis",
93 - backgroundColor: style.value["bg-default-color"],
94 - borderColor: style.value["primary-color"],
95 - textStyle: {
96 - color: style.value["fg-default-color"],
97 - fontSize: 12,
98 - fontFamily: style.value["font-family-mono"]
99 - },
100 - formatter(params: any[]) {
101 - let html = `<div style="font-size:11px;color:${style.value["fg-secondary-color"]};margin-bottom:4px">${new Date(params[0].value[0]).toLocaleString()}</div>`
102 - params.forEach((p: any) => {
103 - const val = formatBytes.value
104 - ? fmtBytes(p.value[1])
105 - : typeof p.value[1] === "number"
106 - ? p.value[1].toFixed(2)
107 - : p.value[1]
108 - html += `<div>${p.marker} ${p.seriesName}: <b>${val}</b></div>`
121 + ...buildChartTooltipGlassBase(
122 + chartTooltipThemeFromStyle({
123 + ...style.value,
124 + "font-family": style.value["font-family-mono"]
125 + }),
126 + { trigger: "axis" }
127 + ),
128 + formatter: params =>
129 + formatChartTooltipAxisMultiSeriesFromParams(params, {
130 + titleMutedColor: fgSecondary,
131 + formatTitle: (first: CallbackDataParams) => {
132 + const time = Array.isArray(first.value) ? first.value[0] : null
133 + return time != null ? new Date(time).toLocaleString() : ""
134 + },
135 + formatRow: (p: CallbackDataParams) => {
136 + const raw = Array.isArray(p.value) ? p.value[1] : p.value
137 + const valueHtml = useFormatBytes.value
138 + ? `${formatBytes(`${raw}`)}`
139 + : typeof raw === "number"
140 + ? raw.toFixed(2)
141 + : String(raw ?? "")
142 + return { label: p.seriesName ?? "", valueHtml }
143 + }
144 })
110 - return html
111 - }
145 },
146 legend: {
147 bottom: 0,
115 - textStyle: { color: style.value["fg-secondary-color"], fontSize: 11 },
148 + textStyle: { color: fgSecondary, fontSize: 11 },
149 icon: "roundRect",
150 itemWidth: 12,
151 itemHeight: 3
@@ -120,49 +153,36 @@ function getOptions() {
153 grid: {
154 left: 60,
155 right: 20,
123 - top: 35,
124 - bottom: seriesNames.length > 1 ? 40 : 20
156 + top: 55,
157 + bottom: 40
158 },
159 xAxis: {
127 - type: "time",
128 - axisLine: { lineStyle: { color: style.value["border-color"] } },
129 - axisLabel: { color: style.value["fg-secondary-color"], fontSize: 10 },
160 + type: "category",
161 + axisLine: { lineStyle: { color: border } },
162 + axisLabel: {
163 + color: fgSecondary,
164 + fontSize: 10,
165 + interval: "auto",
166 + hideOverlap: true,
167 + showMinLabel: true,
168 + showMaxLabel: true,
169 + formatter: xAxisLabelFormatter.value
170 + },
171 splitLine: { show: false }
172 },
173 yAxis: {
174 type: "value",
175 name: yAxisName.value,
135 - nameTextStyle: { color: style.value["fg-secondary-color"], fontSize: 10 },
136 - axisLine: { lineStyle: { color: style.value["border-color"] } },
176 + nameTextStyle: { color: fgSecondary, fontSize: 10 },
177 + axisLine: { lineStyle: { color: border } },
178 axisLabel: {
138 - color: style.value["fg-secondary-color"],
179 + color: fgSecondary,
180 fontSize: 10,
140 - formatter: formatBytes.value ? (v: number) => fmtBytes(v) : undefined
181 + formatter: useFormatBytes.value ? (v: number) => `${formatBytes(`${v}`)}` : undefined
182 },
142 - splitLine: { lineStyle: { color: style.value["border-color"], opacity: 0.3 } }
183 + splitLine: { lineStyle: { color: border, opacity: 0.3 } }
184 },
185 series: eSeries
186 }
146 -}
147 -
148 -function renderChart() {
149 - if (!chartCtx.value) return
150 - chartCtx.value.setOption(getOptions(), true)
151 -}
152 -
153 -watch([series, style], renderChart)
154 -
155 -onMounted(() => {
156 - if (!chartEl.value) return
157 - chartCtx.value = echartsInit(chartEl.value)
158 - renderChart()
159 -
160 - resizeObserver = new ResizeObserver(() => chartCtx.value?.resize())
161 - resizeObserver.observe(chartEl.value)
162 -})
163 -
164 -onBeforeUnmount(() => {
165 - resizeObserver?.disconnect()
166 - chartCtx.value?.dispose()
187 })
188 </script>
frontend/src/components/metrics/MetricsDashboard.vue
+3 -3
@@ -105,7 +105,7 @@
105 :series="memory.mem_used || {}"
106 :height="280"
107 y-axis-name="bytes"
108 - format-bytes
108 + use-format-bytes
109 />
110 </n-card>
111 </section>
@@ -142,7 +142,7 @@
142 title="Disk I/O (bytes/s)"
143 :series="disks.disk_io || {}"
144 y-axis-name="B/s"
145 - format-bytes
145 + use-format-bytes
146 />
147 </n-card>
148 </div>
@@ -175,7 +175,7 @@
175 :series="network.traffic || {}"
176 :height="280"
177 y-axis-name="B/s"
178 - format-bytes
178 + use-format-bytes
179 />
180 </n-card>
181 <n-card>
frontend/src/components/metrics/MetricsGauge.vue
+71 -55
@@ -1,14 +1,17 @@
1 <template>
2 - <div ref="gaugeEl" :style="{ height: `${height}px` }"></div>
2 + <VChart class="w-full" autoresize :option="chartOption" :style="{ height: `${height}px` }" />
3 </template>
4
5 <script setup lang="ts">
6 -import type { ECharts } from "echarts/core"
6 +import type { GaugeSeriesOption } from "echarts/charts"
7 +import type { GridComponentOption, TooltipComponentOption } from "echarts/components"
8 +import type { ComposeOption } from "echarts/core"
9 import { GaugeChart } from "echarts/charts"
10 import { GridComponent, TooltipComponent } from "echarts/components"
9 -import { init as echartsInit, use as echartsUse } from "echarts/core"
11 +import { use } from "echarts/core"
12 import { CanvasRenderer } from "echarts/renderers"
11 -import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue"
13 +import { computed, toRefs } from "vue"
14 +import VChart from "vue-echarts"
15 import { useThemeStore } from "@/stores/theme"
16
17 const props = withDefaults(
@@ -23,74 +26,87 @@ const props = withDefaults(
26 }
27 )
28
26 -echartsUse([TooltipComponent, GridComponent, GaugeChart, CanvasRenderer])
29 +use([CanvasRenderer, GaugeChart, TooltipComponent, GridComponent])
30
28 -const { value, title, height } = toRefs(props)
31 +type ChartOption = ComposeOption<TooltipComponentOption | GridComponentOption | GaugeSeriesOption>
32 +
33 +/** Arco a gradiente come nell'esempio gauge-grade. */
34 +const GRADE_AXIS_COLORS: [number, string][] = [
35 + [0.25, "#FF6E76"],
36 + [0.5, "#FDDD60"],
37 + [0.75, "#58D9F9"],
38 + [1, "#7CFFB2"]
39 +]
40 +
41 +const GAUGE_POINTER_ICON = "path://M12.8,0.7l12,40.1H0.7L12.8,0.7z"
42
30 -const gaugeEl = ref<HTMLElement | null>(null)
31 -const chartCtx = ref<ECharts | null>(null)
43 +const { value, title, height } = toRefs(props)
44 const style = computed(() => useThemeStore().style)
33 -let resizeObserver: ResizeObserver | null = null
45
35 -function getOptions() {
46 +const chartOption = computed((): ChartOption => {
47 + const fgSecondary = style.value["fg-secondary-color"]
48 + const gaugeValue = Math.min(1, Math.max(0, Math.round(value.value * 10) / 10 / 100))
49 +
50 return {
51 backgroundColor: "transparent",
52 series: [
53 {
54 type: "gauge",
41 - startAngle: 220,
42 - endAngle: -40,
55 + startAngle: 180,
56 + endAngle: 0,
57 + center: ["50%", "75%"],
58 + radius: "90%",
59 min: 0,
44 - max: 100,
45 - detail: {
46 - formatter: "{value}%",
47 - fontSize: 18,
48 - color: style.value["fg-default-color"],
49 - offsetCenter: [0, "70%"]
50 - },
51 - data: [{ value: Math.round(value.value * 10) / 10, name: title.value }],
60 + max: 1,
61 + splitNumber: 8,
62 axisLine: {
63 lineStyle: {
54 - width: 15,
55 - color: [
56 - [0.2, "#ef4444"],
57 - [0.5, "#eab308"],
58 - [1, "#22c55e"]
59 - ]
64 + width: 6,
65 + color: GRADE_AXIS_COLORS
66 + }
67 + },
68 + pointer: {
69 + icon: GAUGE_POINTER_ICON,
70 + length: "12%",
71 + width: 20,
72 + offsetCenter: [0, "-60%"],
73 + itemStyle: { color: "auto" }
74 + },
75 + axisTick: {
76 + length: 12,
77 + lineStyle: { color: "auto", width: 2 }
78 + },
79 + splitLine: {
80 + length: 20,
81 + lineStyle: { color: "auto", width: 5 }
82 + },
83 + axisLabel: {
84 + color: fgSecondary,
85 + fontSize: 11,
86 + distance: -48,
87 + rotate: "tangential",
88 + formatter: (axisValue: number) => {
89 + if (axisValue === 0) return "0%"
90 + if (axisValue === 0.5) return "50%"
91 + if (axisValue === 1) return "100%"
92 + return ""
93 }
94 },
62 - pointer: { length: "60%", width: 4, itemStyle: { color: style.value["primary-color"] } },
63 - axisTick: { show: false },
64 - splitLine: { length: 10, lineStyle: { color: style.value["border-color"] } },
65 - axisLabel: { color: style.value["fg-secondary-color"], fontSize: 10 },
95 title: {
67 - color: style.value["fg-secondary-color"],
68 - fontSize: 12,
69 - offsetCenter: [0, "90%"]
70 - }
96 + color: fgSecondary,
97 + fontSize: 13,
98 + offsetCenter: [0, "-10%"]
99 + },
100 + detail: {
101 + fontSize: 22,
102 + offsetCenter: [0, "-35%"],
103 + valueAnimation: true,
104 + formatter: (detailValue: number) => `${Math.round(detailValue * 100)}%`,
105 + color: "inherit"
106 + },
107 + data: [{ value: gaugeValue, name: title.value }]
108 }
109 ]
110 }
74 -}
75 -
76 -function renderChart() {
77 - if (!chartCtx.value) return
78 - chartCtx.value.setOption(getOptions(), true)
79 -}
80 -
81 -watch([value, style], renderChart)
82 -
83 -onMounted(() => {
84 - if (!gaugeEl.value) return
85 - chartCtx.value = echartsInit(gaugeEl.value)
86 - renderChart()
87 -
88 - resizeObserver = new ResizeObserver(() => chartCtx.value?.resize())
89 - resizeObserver.observe(gaugeEl.value)
90 -})
91 -
92 -onBeforeUnmount(() => {
93 - resizeObserver?.disconnect()
94 - chartCtx.value?.dispose()
111 })
112 </script>
frontend/src/components/shuffle/AppDetailDrawerEmbed.vue
+1 -1
@@ -64,7 +64,7 @@ function render() {
64 )
65 }
66
67 -watch([show.value, () => props.appName, () => props.width, () => props.anchor, isThemeDark], () => render())
67 +watch([show, () => props.appName, () => props.width, () => props.anchor, isThemeDark], () => render())
68
69 onBeforeUnmount(() => {
70 if (root) {
frontend/src/router/index.ts
+6
@@ -291,6 +291,12 @@ const router = createRouter({
291 component: () => import("@/views/AiAnalyst.vue"),
292 meta: { title: "AI Analyst", auth: true, roles: RouteRole.All }
293 },
294 + {
295 + path: "/detection-catalog",
296 + name: "DetectionCatalog",
297 + component: () => import("@/views/DetectionCatalog.vue"),
298 + meta: { title: "Detections Catalog", auth: true, roles: RouteRole.All }
299 + },
300 {
301 path: "/healthcheck",
302 redirect: "/healthcheck/alerts",
frontend/src/types/detectionCatalog.d.ts new
+228
@@ -0,0 +1,228 @@
1 +// Mirrors backend/app/integrations/copilot_searches/schema/copilot_searches.py
2 +// Detection Catalog block. Field shapes track FastAPI's response models
3 +// verbatim (snake_case kept on the wire).
4 +
5 +export interface CatalogStoryRow {
6 + name: string
7 + data_sources: string[]
8 + tactics: string[]
9 + products: string[]
10 + date: string | null
11 + detection_count: number
12 +}
13 +
14 +export interface CatalogStoryListResponse {
15 + success: boolean
16 + message: string
17 + stories: CatalogStoryRow[]
18 +}
19 +
20 +export interface CatalogStoryDetection {
21 + id: string
22 + name: string
23 + type: string
24 + severity: string | null
25 + mitre_attack_id: string[]
26 + tactics: string[]
27 + description: string | null
28 +}
29 +
30 +export interface CatalogStoryDetailResponse {
31 + success: boolean
32 + message: string
33 + name: string
34 + id: string
35 + description: string
36 + why_it_matters: string
37 + detections: CatalogStoryDetection[]
38 + data_sources: string[]
39 + tactics: string[]
40 + products: string[]
41 + authors: string[]
42 + references: string[]
43 + date: string | null
44 + version: number | null
45 + detection_count: number
46 +}
47 +
48 +export interface CatalogStatsResponse {
49 + success: boolean
50 + message: string
51 + detection_count: number
52 + story_count: number
53 + product_count: number
54 + data_source_count: number
55 + tactic_count: number
56 + last_refresh: string | null
57 + // Wazuh-side counts — present on every response; default to 0 / true /
58 + // null when the deployment has never loaded the Wazuh cache.
59 + wazuh_rule_count: number
60 + wazuh_last_refresh: string | null
61 + wazuh_available: boolean
62 + wazuh_unavailable_reason: string | null
63 +}
64 +
65 +// ---------------------------------------------------------------------------
66 +// Wazuh Rules tab
67 +// ---------------------------------------------------------------------------
68 +
69 +export interface CatalogWazuhRuleRow {
70 + id: number | null
71 + level: number | null
72 + status: string | null
73 + description: string
74 + filename: string
75 + relative_dirname: string
76 + groups: string[]
77 + mitre: string[]
78 + hits_7d: number
79 + hits_30d: number
80 + // ISO timestamp of the rule's most recent firing in the 30d window,
81 + // or null when the rule hasn't fired / firing stats are unavailable.
82 + last_seen: string | null
83 +}
84 +
85 +export interface CatalogWazuhRulesResponse {
86 + success: boolean
87 + message: string
88 + rules: CatalogWazuhRuleRow[]
89 + total: number
90 + available: boolean
91 + unavailable_reason: string | null
92 + last_refresh: string | null
93 + // Indexer-side availability for the firing-stats column. Distinct from
94 + // `available` (Wazuh Manager status) because the two backends can fail
95 + // independently.
96 + firing_stats_available: boolean
97 + firing_stats_unavailable_reason: string | null
98 + firing_stats_last_refresh: string | null
99 + // Echoes the customer scope: "" for global, customer code when scoped.
100 + customer_code: string
101 +}
102 +
103 +export interface CatalogWazuhRuleCompliance {
104 + pci_dss: string[]
105 + gdpr: string[]
106 + hipaa: string[]
107 + nist_800_53: string[]
108 + tsc: string[]
109 + gpg13: string[]
110 +}
111 +
112 +export interface CatalogWazuhRuleDetailResponse {
113 + success: boolean
114 + message: string
115 + id: number | null
116 + level: number | null
117 + status: string | null
118 + description: string
119 + filename: string
120 + relative_dirname: string
121 + groups: string[]
122 + mitre: string[]
123 + tactics: string[]
124 + compliance: CatalogWazuhRuleCompliance
125 + // Free-form: keys vary per rule (if_sid, match, regex, decoded_as, …).
126 + // The modal iterates whatever arrives.
127 + details: Record<string, unknown>
128 + // Reconstructed <rule>...</rule> XML synthesized server-side from the
129 + // cached fields. Displayed as a code snippet in the modal's "Rule Source"
130 + // section. Empty string when the cache doesn't have enough to render.
131 + source_xml: string
132 + // Firing counts (mirrored on the row-level type above). Same "0 means
133 + // 0 only when firing_stats_available is true" semantics.
134 + hits_7d: number
135 + hits_30d: number
136 + last_seen: string | null
137 + firing_stats_available: boolean
138 + firing_stats_unavailable_reason: string | null
139 +}
140 +
141 +// ---------------------------------------------------------------------------
142 +// Coverage Gaps tab
143 +// ---------------------------------------------------------------------------
144 +
145 +export interface CatalogCoverageGapRow {
146 + technique_id: string
147 + technique_name: string
148 + tactics: string[]
149 + url: string | null
150 +}
151 +
152 +export interface CatalogCoverageGapsResponse {
153 + success: boolean
154 + message: string
155 + gaps: CatalogCoverageGapRow[]
156 + gap_count: number
157 + covered_count: number
158 + total_techniques: number
159 + coverage_pct: number
160 +}
161 +
162 +// ---------------------------------------------------------------------------
163 +// Logtest — paste a raw log line, see which rule matches
164 +// ---------------------------------------------------------------------------
165 +
166 +export interface CatalogLogTestRequest {
167 + event: string
168 + log_format?: string
169 + location?: string
170 +}
171 +
172 +export interface CatalogLogTestRuleSummary {
173 + id: number | null
174 + level: number | null
175 + description: string
176 + groups: string[]
177 + mitre: string[]
178 + pci_dss: string[]
179 + gdpr: string[]
180 + hipaa: string[]
181 + nist_800_53: string[]
182 + firedtimes: number | null
183 +}
184 +
185 +export interface CatalogLogTestResponse {
186 + success: boolean
187 + message: string
188 + matched: boolean
189 + rule: CatalogLogTestRuleSummary | null
190 + tactics: string[]
191 + alert: Record<string, unknown> | null
192 + unavailable_reason: string | null
193 +}
194 +
195 +// ---------------------------------------------------------------------------
196 +// Compliance pivot tab
197 +// ---------------------------------------------------------------------------
198 +
199 +export interface CatalogComplianceFramework {
200 + key: string // API value: pci_dss, hipaa, etc.
201 + label: string // UI: "PCI DSS", "HIPAA"
202 +}
203 +
204 +export interface CatalogComplianceFrameworksResponse {
205 + success: boolean
206 + message: string
207 + frameworks: CatalogComplianceFramework[]
208 +}
209 +
210 +export interface CatalogComplianceGroupRow {
211 + control: string
212 + rule_count: number
213 + rule_ids: number[]
214 + total_hits_30d: number
215 + total_hits_7d: number
216 +}
217 +
218 +export interface CatalogComplianceResponse {
219 + success: boolean
220 + message: string
221 + framework: string
222 + framework_label: string
223 + groups: CatalogComplianceGroupRow[]
224 + control_count: number
225 + rules_with_compliance: number
226 + total_rules: number
227 + firing_stats_available: boolean
228 +}
frontend/src/views/DetectionCatalog.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <DetectionCatalogShell />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import DetectionCatalogShell from "@/components/detectionCatalog/DetectionCatalogShell.vue"
9 +</script>
frontend/vite.config.ts
+1 -1
@@ -59,7 +59,7 @@ export default defineConfig(({ mode, command }) => {
59 },
60 optimizeDeps: {
61 exclude: ["xmllint-wasm"],
62 - include: ["fast-deep-equal"],
62 + // include: ["fast-deep-equal"],
63 rolldownOptions: {
64 onwarn: suppressRolldownIifeNameWarning
65 }