From c667126fd537362f5e5810ffec22957960b025cb Mon Sep 17 00:00:00 2001 From: kmosoti <47609243+kmosoti@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:51:53 -0500 Subject: [PATCH] Ship v0.2 observability dashboard catalog --- .github/ci/npm-validator/README.md | 6 +- .github/workflows/ci.yml | 77 +- .github/workflows/release.yml | 193 +++ CHANGELOG.md | 41 + README.md | 151 +- docs/architecture.md | 99 +- docs/compatibility.md | 94 +- docs/example-catalog.md | 72 + docs/operational-security.md | 60 + docs/skills.md | 59 + examples/README.md | 19 + .../catalog/api_gateway_overview/README.md | 28 + .../catalog/api_gateway_overview/builder.py | 14 + .../api_gateway_overview/dashboard.json | 204 +++ .../api_gateway_overview/manifest.json | 125 ++ .../catalog/batch_cron_reliability/README.md | 28 + .../catalog/batch_cron_reliability/builder.py | 14 + .../batch_cron_reliability/dashboard.json | 204 +++ .../batch_cron_reliability/manifest.json | 116 ++ .../catalog/business_journey_slo/README.md | 28 + .../catalog/business_journey_slo/builder.py | 14 + .../business_journey_slo/dashboard.json | 204 +++ .../business_journey_slo/manifest.json | 128 ++ .../catalog/cicd_delivery_health/README.md | 28 + .../catalog/cicd_delivery_health/builder.py | 14 + .../cicd_delivery_health/dashboard.json | 204 +++ .../cicd_delivery_health/manifest.json | 130 ++ examples/catalog/ec2_host_capacity/README.md | 28 + examples/catalog/ec2_host_capacity/builder.py | 14 + .../catalog/ec2_host_capacity/dashboard.json | 204 +++ .../catalog/ec2_host_capacity/manifest.json | 116 ++ .../kubernetes_workload_health/README.md | 28 + .../kubernetes_workload_health/builder.py | 14 + .../kubernetes_workload_health/dashboard.json | 204 +++ .../kubernetes_workload_health/manifest.json | 126 ++ .../load_balancer_edge_health/README.md | 28 + .../load_balancer_edge_health/builder.py | 14 + .../load_balancer_edge_health/dashboard.json | 204 +++ .../load_balancer_edge_health/manifest.json | 116 ++ .../microservice_service_map/README.md | 28 + .../microservice_service_map/builder.py | 14 + .../microservice_service_map/dashboard.json | 204 +++ .../microservice_service_map/manifest.json | 129 ++ .../catalog/rds_database_health/README.md | 28 + .../catalog/rds_database_health/builder.py | 14 + .../rds_database_health/dashboard.json | 204 +++ .../catalog/rds_database_health/manifest.json | 135 ++ .../security_operations_overview/README.md | 28 + .../security_operations_overview/builder.py | 14 + .../dashboard.json | 204 +++ .../manifest.json | 117 ++ pyproject.toml | 16 +- scripts/check_engine_locks.py | 111 ++ scripts/check_examples.py | 149 ++ scripts/release_evidence.py | 173 +++ splunk_dashboard_studio/__init__.py | 60 +- splunk_dashboard_studio/_version.py | 3 + splunk_dashboard_studio/catalog.py | 1247 +++++++++++++++++ splunk_dashboard_studio/cli.py | 59 +- splunk_dashboard_studio/codec.py | 234 ++++ splunk_dashboard_studio/contracts.py | 225 +++ splunk_dashboard_studio/corpus.py | 18 + splunk_dashboard_studio/generation.py | 94 +- splunk_dashboard_studio/models.py | 5 + splunk_dashboard_studio/schema.py | 31 + splunk_dashboard_studio/validation.py | 6 +- tests/test_catalog.py | 176 +++ tests/test_codec.py | 122 ++ tests/test_contracts.py | 64 + tests/test_generation.py | 86 ++ tests/test_properties.py | 146 ++ tests/test_release_evidence.py | 36 + tests/test_schema_corpus_cli.py | 24 +- tests/test_validation.py | 2 +- uv.lock | 123 +- 75 files changed, 7618 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 docs/example-catalog.md create mode 100644 docs/operational-security.md create mode 100644 docs/skills.md create mode 100644 examples/README.md create mode 100644 examples/catalog/api_gateway_overview/README.md create mode 100644 examples/catalog/api_gateway_overview/builder.py create mode 100644 examples/catalog/api_gateway_overview/dashboard.json create mode 100644 examples/catalog/api_gateway_overview/manifest.json create mode 100644 examples/catalog/batch_cron_reliability/README.md create mode 100644 examples/catalog/batch_cron_reliability/builder.py create mode 100644 examples/catalog/batch_cron_reliability/dashboard.json create mode 100644 examples/catalog/batch_cron_reliability/manifest.json create mode 100644 examples/catalog/business_journey_slo/README.md create mode 100644 examples/catalog/business_journey_slo/builder.py create mode 100644 examples/catalog/business_journey_slo/dashboard.json create mode 100644 examples/catalog/business_journey_slo/manifest.json create mode 100644 examples/catalog/cicd_delivery_health/README.md create mode 100644 examples/catalog/cicd_delivery_health/builder.py create mode 100644 examples/catalog/cicd_delivery_health/dashboard.json create mode 100644 examples/catalog/cicd_delivery_health/manifest.json create mode 100644 examples/catalog/ec2_host_capacity/README.md create mode 100644 examples/catalog/ec2_host_capacity/builder.py create mode 100644 examples/catalog/ec2_host_capacity/dashboard.json create mode 100644 examples/catalog/ec2_host_capacity/manifest.json create mode 100644 examples/catalog/kubernetes_workload_health/README.md create mode 100644 examples/catalog/kubernetes_workload_health/builder.py create mode 100644 examples/catalog/kubernetes_workload_health/dashboard.json create mode 100644 examples/catalog/kubernetes_workload_health/manifest.json create mode 100644 examples/catalog/load_balancer_edge_health/README.md create mode 100644 examples/catalog/load_balancer_edge_health/builder.py create mode 100644 examples/catalog/load_balancer_edge_health/dashboard.json create mode 100644 examples/catalog/load_balancer_edge_health/manifest.json create mode 100644 examples/catalog/microservice_service_map/README.md create mode 100644 examples/catalog/microservice_service_map/builder.py create mode 100644 examples/catalog/microservice_service_map/dashboard.json create mode 100644 examples/catalog/microservice_service_map/manifest.json create mode 100644 examples/catalog/rds_database_health/README.md create mode 100644 examples/catalog/rds_database_health/builder.py create mode 100644 examples/catalog/rds_database_health/dashboard.json create mode 100644 examples/catalog/rds_database_health/manifest.json create mode 100644 examples/catalog/security_operations_overview/README.md create mode 100644 examples/catalog/security_operations_overview/builder.py create mode 100644 examples/catalog/security_operations_overview/dashboard.json create mode 100644 examples/catalog/security_operations_overview/manifest.json create mode 100644 scripts/check_engine_locks.py create mode 100644 scripts/check_examples.py create mode 100644 scripts/release_evidence.py create mode 100644 splunk_dashboard_studio/_version.py create mode 100644 splunk_dashboard_studio/catalog.py create mode 100644 splunk_dashboard_studio/codec.py create mode 100644 splunk_dashboard_studio/contracts.py create mode 100644 tests/test_catalog.py create mode 100644 tests/test_codec.py create mode 100644 tests/test_contracts.py create mode 100644 tests/test_properties.py create mode 100644 tests/test_release_evidence.py diff --git a/.github/ci/npm-validator/README.md b/.github/ci/npm-validator/README.md index 120912b..9f0fbbe 100644 --- a/.github/ci/npm-validator/README.md +++ b/.github/ci/npm-validator/README.md @@ -2,7 +2,11 @@ Each `engines/` directory is an isolated, exact NPM lock. CI installs one lock, builds the Enterprise preset schema, and streams the Python-generated JSONL corpus through -`DashboardValidator` and the official Dynamic Options Syntax parser. +`DashboardValidator` and the official Dynamic Options Syntax parser. The corpus includes generic +positive/negative cases plus every catalog dashboard eligible for the target release line. + +Run `uv run python scripts/check_engine_locks.py` before installation to prove the profile registry, +direct dependencies, root lock dependencies, and resolved package versions agree. These files are development infrastructure. They are excluded from Python source and wheel artifacts, and this project does not redistribute the downloaded packages or generated schemas. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb71c7d..7feee8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,18 +14,18 @@ concurrency: cancel-in-progress: true jobs: - python: - name: Python 3.14 native tooling + quality: + name: Quality and distributions (Python 3.12) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python 3.14 + - name: Set up Python 3.12 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: "3.14" + python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 @@ -45,11 +45,11 @@ jobs: - name: Type check run: uv run mypy splunk_dashboard_studio - - name: Test with branch coverage - run: uv run pytest --cov=splunk_dashboard_studio --cov-report=term-missing -q + - name: Verify official-engine locks match profiles + run: uv run python scripts/check_engine_locks.py - - name: Verify native compatibility corpus - run: uv run python scripts/check_corpus.py + - name: Verify release version sources + run: uv run python scripts/release_evidence.py --tag v0.2.0 --check-version-only - name: Build Python distributions run: uv build @@ -57,6 +57,63 @@ jobs: - name: Prove distributions contain no Node assets run: uv run python scripts/check_distribution.py dist + python-compatibility: + name: Python ${{ matrix.python-version }} compatibility + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13", "3.14"] + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.11.25" + enable-cache: true + + - name: Install locked dependencies + run: uv sync --frozen --all-groups + + - name: Test with branch coverage + run: uv run pytest --cov=splunk_dashboard_studio --cov-report=term-missing -q + + examples-snapshots: + name: Catalog artifacts and native corpus + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.11.25" + enable-cache: true + + - name: Install locked dependencies + run: uv sync --frozen --all-groups + + - name: Verify checked catalog artifacts + run: uv run python scripts/check_examples.py --check + + - name: Generate every target variant and verify native expectations + run: uv run python scripts/check_corpus.py + official-engine: name: Splunk NPM engine ${{ matrix.engine }} runs-on: ubuntu-latest @@ -75,10 +132,10 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python 3.14 + - name: Set up Python 3.12 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: "3.14" + python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e03bb8f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,193 @@ +name: Release + +on: + release: + types: [published] + +concurrency: + group: release-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + official-engine: + name: Release validator ${{ matrix.engine }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - engine: dashboard-27.5.1 + targets: "9.4.3" + - engine: dashboard-28.6.0 + targets: "10.0.0 10.2.0" + - engine: dashboard-29.8.0 + targets: "10.4.0" + steps: + - name: Check out release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.11.25" + enable-cache: true + save-cache: false + + - name: Install Python runtime dependencies + run: uv sync --frozen --no-dev + + - name: Set up Node 24 for CI only + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: >- + .github/ci/npm-validator/engines/${{ matrix.engine }}/package-lock.json + + - name: Install exact Splunk NPM engine lock + shell: bash + run: | + set -euo pipefail + engine_path=".github/ci/npm-validator/engines/${{ matrix.engine }}" + npm ci --prefix "${engine_path}" --ignore-scripts --no-audit --no-fund + + - name: Validate release corpus with official libraries + shell: bash + run: | + set -euo pipefail + engine_path=".github/ci/npm-validator/engines/${{ matrix.engine }}" + for target in ${{ matrix.targets }}; do + corpus_path="${RUNNER_TEMP}/corpus-${target}.jsonl" + uv run --frozen --no-dev splunk-studio corpus \ + --target "${target}" \ + --output "${corpus_path}" + uv run --frozen --no-dev python .github/ci/npm-validator/run.py \ + --engine-dir "${engine_path}" \ + --corpus "${corpus_path}" + done + + build: + name: Build and attest once + needs: official-engine + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Check out release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python 3.12 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.11.25" + enable-cache: true + + - name: Install locked dependencies + run: uv sync --frozen --all-groups + + - name: Verify tag and version agreement + run: >- + uv run python scripts/release_evidence.py + --tag "${{ github.event.release.tag_name }}" + --check-version-only + + - name: Run native release gates + run: | + uv run ruff format --check . + uv run ruff check . + uv run mypy splunk_dashboard_studio + uv run pytest --cov=splunk_dashboard_studio --cov-report=term-missing -q + uv run python scripts/check_engine_locks.py + uv run python scripts/check_examples.py --check + uv run python scripts/check_corpus.py + + - name: Build distributions once + run: uv build + + - name: Inspect distribution boundary + run: uv run python scripts/check_distribution.py dist + + - name: Create checksums and release evidence + run: >- + uv run python scripts/release_evidence.py + --tag "${{ github.event.release.tag_name }}" + --dist dist + --output release/release-evidence.json + --checksums-output release/SHA256SUMS + + - name: Attest Python distributions + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: "dist/*" + + - name: Upload release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-release-bundle + path: | + dist/* + release/* + if-no-files-found: error + retention-days: 14 + + publish-pypi: + name: Publish to PyPI with OIDC + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/p/splunk-dashboard-studio-python + permissions: + id-token: write + steps: + - name: Download release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-release-bundle + + - name: Publish distributions + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + with: + packages-dir: dist/ + + attach-release-assets: + name: Attach distributions and evidence + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-release-bundle + + - name: Attach release assets + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: >- + gh release upload "${{ github.event.release.tag_name }}" + dist/* release/* --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..daa0daa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +This project follows semantic versioning once a version is published. Entries marked “release +candidate” describe repository state and do not claim a GitHub or PyPI release. + +## Unreleased + +### 0.2.0 release candidate + +Added: + +- Python 3.12, 3.13, and 3.14 compatibility policy. +- Typed observability telemetry, provenance, saved-search, catalog, evidence, and agent-skill + contracts plus a complete schema bundle. +- Builder defaults, token defaults, richer visualization/input fields, and duplicate-write guards. +- Ten packaged observability dashboards, CLI catalog commands, generated minimum-target artifacts, + and native/official-engine corpus coverage. +- An offline Dashboard Studio REST XML codec with safe CDATA handling and deterministic semantic + diffs. +- Engine-lock consistency, generated-example drift, release-evidence, checksum, artifact + attestation, and PyPI trusted-publishing workflows. +- Operational security, skills, architecture, compatibility, and catalog documentation. + +Changed: + +- Package and runtime version to `0.2.0`. +- Minimum Python version from 3.14 to 3.12. +- The 10.4 network graph type to the official `splunk.networkGraph` spelling. +- Compatibility corpus from eight generic cases per target to include every eligible catalog + definition. + +Deferred: + +- Live Splunk publish/readback fixture and HTTP adapter. +- Machine-enforced ACL, sensitive-index, or publication policy. +- Splunk Cloud, 9.2.x, and 9.3.x support. + +### 0.1.0 repository baseline + +- Deterministic builder, native validator, support profiles, corpus, official-engine CI adapter, + optimization proposals, schema commands, and distribution guard. diff --git a/README.md b/README.md index 8a735ce..afeefff 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,140 @@ # splunk-dashboard-studio-python -Generate and validate deterministic Splunk Enterprise Dashboard Studio definitions with Python -3.14 and Pydantic 2. +Build and validate deterministic Splunk Enterprise Dashboard Studio definitions with Python 3.12+ +and Pydantic 2. ```python from splunk_dashboard_studio import DashboardBuilder, canonical_json builder = DashboardBuilder(title="Service health", target="9.4.3") search = builder.add_search( - "index=_internal | stats count by sourcetype", - name="events", + "index=otel_traces service.name=checkout | stats perc95(duration_ms) AS p95_ms", + name="latency", ) builder.add_visualization( - "splunk.table", - name="events", + "splunk.singlevalue", + name="p95 latency", data_sources={"primary": search}, - options={}, + title="p95 latency", ) -dashboard = builder.build() -print(canonical_json(dashboard, indent=2)) +print(canonical_json(builder.build(), indent=2)) ``` -The package is intentionally Splunk Enterprise-only. It does not claim Splunk Cloud -compatibility, does not connect to a Splunk deployment, and never installs or bundles Node. +Version 0.2.0 is an unreleased release candidate. The package is intentionally Splunk +Enterprise-only. It does not claim Splunk Cloud compatibility, connect to a deployment, create +saved searches, publish dashboards, or install Node at runtime. -## What it validates +## Included in v0.2 -- Pydantic 2 dashboard envelopes with stable JSON aliases. -- Explicit Splunk Enterprise targets beginning at 9.4.3. -- Feature availability across the 9.4, 10.0, 10.2, and 10.4 release lines. -- Data-source and visualization references. -- Base/chain parent existence, cycles, depth, fan-out, and inherited options. -- Canvas positions and boundary overflow. -- Structural SPL1 pipelines and Dynamic Options Syntax expressions. -- Deterministic search optimization proposals without silently rewriting SPL. +- Exact target profiles for the actively supported 9.4, 10.0, 10.2, and 10.4 release lines. +- Deterministic builders for searches, saved-search references, chains, defaults, tokens, inputs, + visualizations, tabbed absolute layouts, application properties, and expressions. +- Native validation for references, feature boundaries, SPL1 structure, Dynamic Options Syntax, + canvas bounds, and search-chain depth, fan-out, cycles, and inherited options. +- A typed `portable-observability-v1` telemetry contract, per-panel provenance, saved-search + proposals, evidence manifests, and an eight-skill agent taxonomy. +- Ten packaged observability dashboards with checked definitions and manifests. +- An offline codec for documented `data/ui/views` XML, including safe CDATA encoding, normalized + SHA-256 comparison, and deterministic JSON-pointer diffs. +- Locked Splunk-owned NPM validation engines in CI without redistributing them in Python artifacts. -The native validator is deliberately stricter about cross-object relationships than the official -Dashboard Framework schema. CI adds a second validation lane using pinned Splunk NPM engines. - -## Install for development +## Development install ```console uv sync --all-groups uv run pytest -q ``` -The project requires Python 3.14 or later. Pydantic's compiled `pydantic-core` performs the hot-path -model parsing and serialization. +The native compatibility matrix covers Python 3.12, 3.13, and 3.14. Pydantic's compiled +`pydantic-core` handles model parsing and serialization. -## CLI +## Catalog -Validate a dashboard for an exact Enterprise target: +List the included dashboards and their telemetry requirements: ```console -uv run splunk-studio validate dashboard.json --target 9.4.3 +uv run splunk-studio catalog list ``` -Read from standard input: +Build a canonical definition or a definition-plus-evidence bundle: ```console -uv run splunk-studio validate - --target 10.2.0 < dashboard.json +uv run splunk-studio catalog build kubernetes_workload_health \ + --target 9.4.3 --output dashboard.json + +uv run splunk-studio catalog build business_journey_slo \ + --target 10.2.0 --artifact bundle --output bundle.json ``` -Emit the complete agent contract, including the Enterprise capability manifest: +The catalog uses logical indexes such as `otel_metrics`, `otel_logs`, `otel_traces`, and +`platform_events`. Deployers map those names and the documented semantic fields to local data. +The package deliberately ships no sample telemetry or hidden index assumptions. -```console -uv run splunk-studio schema agent > agent-schema.json -``` +See [the example catalog](docs/example-catalog.md) and the checked files under +[`examples/catalog/`](examples/catalog/). -Generate the deterministic compatibility corpus used by CI: +## CLI ```console -uv run splunk-studio corpus --target 10.2.0 --output corpus.jsonl -``` +# Validate a definition for an exact Enterprise target. +uv run splunk-studio validate dashboard.json --target 10.2.0 -Analyze existing searches for possible base/chain consolidation: +# Read a definition from standard input. +uv run splunk-studio validate - --target 9.4.3 < dashboard.json -```console +# Emit one schema or the complete machine-facing schema bundle. +uv run splunk-studio schema agent > agent-schema.json +uv run splunk-studio schema bundle > schema-bundle.json + +# Generate the native and official-engine compatibility corpus. +uv run splunk-studio corpus --target 10.4.0 --output corpus.jsonl + +# Propose safe base/chain consolidation without rewriting SPL. uv run splunk-studio optimize dashboard.json ``` -All commands produce machine-readable JSON or JSONL. Exit codes are `0` for success, `1` for a -validly executed check that found an invalid dashboard, and `2` for an invocation or system error. +Commands emit machine-readable JSON or JSONL. Exit code `0` means success, `1` means a completed +validation found an invalid dashboard, and `2` means an invocation, target, input, or system error. -## Version evidence +## Offline view codec -Splunk Enterprise versions and public NPM package versions are separate version axes. Each profile -records an evidence grade: +```python +from splunk_dashboard_studio import StudioView, compare_roundtrip, encode_view_xml -- `official_attribution`: Splunk's Enterprise attribution identifies the Dashboard Framework line. -- `verified_installation`: package metadata was captured from the matching licensed Enterprise - installation. -- `temporal_surrogate`: the closest applicable public package is used for differential CI, but is - not presented as an official product mapping. +view = StudioView(label="Service health", definition=builder.build(), theme="dark") +xml = encode_view_xml(view) +comparison = compare_roundtrip(view, xml) +assert comparison.equivalent +``` -The 9.4.3 lane uses `@splunk/dashboard-validation@27.5.1` as a temporal surrogate because it is the -last public release before Enterprise 9.4 GA. The repository explicitly rejects the inaccurate -`24.0.0` mapping: that version does not exist for the public validation package. Enterprise 10.2 -uses the officially evidenced Dashboard Framework 28.6 line. +The codec implements the documented storage envelope only. It performs no HTTP requests and has +not yet been verified against a disposable live Splunk publish/readback fixture. Treat it as an +offline serialization and drift-analysis tool, not deployment automation. -See [compatibility.md](docs/compatibility.md) for the complete policy. +## Validation authority -## Official NPM validation in CI +The native validator and official CI engines have different jobs: -Node is development infrastructure, not a runtime dependency. GitHub Actions: +- Python owns product-version policy and cross-object semantics. +- Locked Splunk NPM schemas own exact Splunk option shapes and the official DOS parser. +- Release evidence is valid only when every required lane agrees with its declared expectation. -1. Generates a deterministic positive and negative dashboard corpus in Python. -2. Installs an exact, locked NPM engine in an ephemeral runner. -3. Generates the Enterprise Dashboard Studio schema from the matching presets. -4. Runs `DashboardValidator` plus Splunk's DOS parser. -5. Compares actual results with the corpus expectations. -6. Inspects the Python wheel and source distribution to prove that no Node or NPM assets ship. +Node remains CI-only. Build inspection rejects JavaScript, NPM manifests, and `node_modules` from +both wheel and source-distribution artifacts. See [architecture](docs/architecture.md) and +[compatibility policy](docs/compatibility.md). -The CI harness lives under `.github/ci/npm-validator/` and is explicitly excluded from Python build -artifacts. Splunk NPM packages are downloaded during CI and are never redistributed by this project. +## Security and status -## Project status +Dashboard JSON is presentation state and executable search workload. Review indexes, tokens, +saved-search ACLs, refresh behavior, and publication permissions before deployment. The package +does not enforce organization-specific sensitivity or ACL policy; the operational checklist is in +[operational security](docs/operational-security.md). -This is an independent open-source project and is not affiliated with, endorsed by, or supported by -Splunk Inc. “Splunk” and related marks are the property of their respective owners. +This is an independent open-source project and is not affiliated with, endorsed by, or supported +by Splunk Inc. “Splunk” and related marks are the property of their respective owners. ## License -Apache-2.0. The license applies to this project's source code, not to separately downloaded Splunk +Apache-2.0. The license covers this project's source code, not separately downloaded Splunk packages or Splunk software. diff --git a/docs/architecture.md b/docs/architecture.md index 007333f..7551224 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,46 +1,87 @@ # Architecture -The project uses a ports-and-adapters boundary without imposing a framework on callers. +The project is a versioned Dashboard Studio compiler and validator with hard boundaries between +authoring, evidence, official validation, and deployment. -## Python runtime +## Runtime layers -The shipped runtime owns deterministic behavior: +1. `models.py` defines the stable Pydantic dashboard envelope while preserving explicit extension + boundaries for Splunk-owned option dictionaries. +2. `generation.py` owns deterministic IDs, defaults, tokens, data sources, visualizations, inputs, + and a 1440-pixel two-column tabbed layout. +3. `contracts.py` defines telemetry, provenance, catalog, artifact, and agent-skill contracts. +4. `catalog.py` compiles ten immutable dashboard specifications into target-aware definitions and + honest evidence bundles. +5. `validation.py`, `graph.py`, `spl.py`, and `dos.py` own native cross-object and structural + validation. +6. `codec.py` encodes and decodes the documented `data/ui/views` XML envelope without networking. +7. `cli.py` exposes these surfaces as deterministic JSON or JSONL commands. -1. Pydantic validates the stable Dashboard Studio envelope. -2. The Enterprise profile registry evaluates versioned capabilities. -3. Native validators inspect SPL, DOS, references, canvas bounds, and search graphs. -4. Generators emit canonical JSON with deterministic identifiers. -5. Graph analysis emits immutable optimization proposals and never rewrites a query implicitly. +Canonical dashboard JSON contains only fields Splunk owns. Telemetry assumptions, provenance, +saved-search proposals, validation state, and evidence grades stay in the artifact manifest so +project metadata cannot make a valid dashboard definition invalid. -Splunk-owned visualization and input option dictionaries remain deliberate extension boundaries. -The official schema is better suited to exact option-level validation and runs as a second CI lane. +## Determinism and mutation policy -## CI-only official engine +- Generated IDs are stable for a fixed call sequence. +- Canonical JSON sorts keys and has stable compact or indented rendering. +- Duplicate IDs, default scopes, and token defaults fail instead of overwriting earlier intent. +- Optimization returns immutable proposals and never silently changes SPL. +- Catalog artifacts are generated from package code and checked for drift. +- Saved-search specifications are advisory. The package can reference a saved search but never + creates, schedules, edits, or grants access to one. -The official engine adapter is isolated under `.github/ci/npm-validator`: +## Validation authorities -- Each engine has an exact dependency lock. -- Node is provisioned by GitHub Actions. -- Schema generation runs once per engine profile. -- Dashboard requests cross the process boundary as JSONL through standard input. -- The runner has read-only repository permissions and no secrets. +Neither lane is a complete oracle: -The adapter is never imported by the Python package. Hatch build inclusion rules and an artifact -inspection gate prevent CI assets from entering the wheel or source distribution. +- The Python validator owns exact Enterprise target selection, feature introduction boundaries, + references, layout bounds, and chain graph rules. +- The Splunk NPM validator owns the exact official schema for visualization, input, and data-source + option surfaces, plus Splunk's Dynamic Options Syntax parser. +- Checked manifests record native success and the selected engine/evidence grade, but deliberately + report official validation as `not_run`; CI output is the proof of an official-engine run. -## Validation authority +A release candidate is acceptable only when native expectations, official-engine expectations, +generated artifacts, typing, and distribution inspection all pass. -Neither validator is treated as a complete oracle: +## CI-only official engines -- The official schema owns exact visualization and input option shapes. -- Python owns product-version policy and cross-object semantics that the NPM validator does not - consistently enforce, such as chain cycles and missing data-source references. +The adapter under `.github/ci/npm-validator/` is isolated from the runtime: -A dashboard is release-ready only when every required lane agrees with its declared expectation. +- Each engine directory has an exact `package.json` and `package-lock.json`. +- `scripts/check_engine_locks.py` proves profile metadata and resolved lock versions agree. +- GitHub Actions provisions Node, generates an Enterprise preset schema, and streams JSONL cases + through `DashboardValidator` and the official DOS parser. +- The runner has read-only repository permissions and no package credentials. +- Hatch exclusions plus `scripts/check_distribution.py` prevent Node assets from entering Python + artifacts. -## Search-chain limits +## Search graph policy -The native graph policy follows Splunk Enterprise's documented Dashboard Studio limits: at most -10 direct chain searches from a base search, at most one additional chained level, and no -`queryParameters`, `refresh`, or `refreshType` overrides on a chain. See Splunk's +Native graph validation follows documented Dashboard Studio limits: no more than ten direct chain +searches from one parent, no more than one additional chained level, and no child overrides for +`queryParameters`, `refresh`, or `refreshType`. Base/chain optimization is proposed only when +searches share inherited settings and a safe transforming prefix. + +See Splunk's [base and chain search documentation](https://help.splunk.com/en/splunk-enterprise/create-dashboards-and-reports/dashboard-studio/9.4/use-data-sources/chain-searches-together-with-a-base-search-and-chain-searches). + +## Agent contract + +The v0.2 agent layer is typed policy, not an autonomous runtime. `schema agent` preserves the +original `{target, definition}` contract and adds an `x-observability-skills` extension. `schema +bundle` includes dashboard, artifact, telemetry, and skill schemas in one document. The eight skill +descriptors declare inputs, outputs, heuristics, constraints, and tool names, but do not execute or +grant authority. See [skills](skills.md). + +## Offline round-trip boundary + +`StudioView`, `encode_view_xml`, `decode_view_xml`, and `compare_roundtrip` implement the documented +`` format. The decoder rejects DTD/entity declarations, normalizes JSON via +`DashboardDefinition`, ignores unknown server-added XML fields, and reports deterministic +JSON-pointer differences. + +There is intentionally no HTTP client, SDK dependency, credential handling, publish command, or +live CI fixture in v0.2. A future integration extra may add a disposable publish/readback test, but +it must remain separate from the compiler runtime. diff --git a/docs/compatibility.md b/docs/compatibility.md index 5a216b0..d8b8a1c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,36 +2,70 @@ ## Supported release lines -| Target | Native profile | NPM engine | Evidence | +| Target | Native profile | Locked NPM engine | Evidence | |---|---|---|---| | 9.4.3 and later 9.4 patches | `splunk-enterprise-9.4.3` | Dashboard 27.5.1 | Temporal surrogate | -| 10.0.x | `splunk-enterprise-10.0` | Dashboard 28.6.0 | Release-family surrogate | +| 10.0.x | `splunk-enterprise-10.0` | Dashboard 28.6.0 | Temporal surrogate | | 10.2.x | `splunk-enterprise-10.2` | Dashboard 28.6.0 | Official attribution | -| 10.4.x | `splunk-enterprise-10.4` | Dashboard 29.8.0 | Current public surrogate | - -Targets below 9.4.3, unknown Enterprise release lines, and Splunk Cloud identifiers fail closed. - -## Why 9.4.3 is not mapped to 24.0.0 - -The public NPM registry does not contain -`@splunk/dashboard-validation@24.0.0`. Public v24 validation releases date from 2022, while the -[official release notes](https://help.splunk.com/en/splunk-enterprise/release-notes-and-updates/release-notes/9.4/whats-new/welcome-to-splunk-enterprise-9.4) -date Enterprise 9.4 to December 16, 2024. The last public validation release before that GA date -was 27.5.1. The [official 9.4 attribution](https://help.splunk.com/en/splunk-enterprise/release-notes-and-updates/release-notes/9.4/third-party-software/credits) -does not list `dashboard-validation`, `dashboard-definition`, or `dashboard-presets`, so it cannot -establish a package mapping. Timing makes 27.5.1 a useful differential-test surrogate, but it is -not proof of the package embedded in Enterprise. - -The 9.4.3 mapping must remain `temporal_surrogate` until package metadata is captured from an -authoritative source or a matching licensed installation. CI and documentation must preserve that -qualification. - -## Adding a release profile - -1. Obtain an official attribution, SBOM, or package manifest from the Enterprise release. -2. Record the exact product release line separately from every NPM package version. -3. Add feature gates with official documentation URLs. -4. Add before/at/after boundary corpus cases. -5. Pin and lock the CI engine. -6. Pass native, official-engine, artifact, and determinism gates. -7. Promote the evidence grade only when the source justifies it. +| 10.4.x | `splunk-enterprise-10.4` | Dashboard 29.8.0 | Temporal surrogate | + +Targets below 9.4.3, 9.2.x, 9.3.x, unknown release lines, and Splunk Cloud identifiers fail closed. +Public documentation calls these release lines; this project does not label them LTS without a +separate authoritative support contract. + +The Python runtime supports CPython 3.12, 3.13, and 3.14. Node is never a runtime requirement. + +## Evidence grades + +- `official_attribution`: Splunk's Enterprise attribution identifies the relevant Dashboard + Framework line. +- `verified_installation`: exact package metadata was captured from a matching licensed Enterprise + installation. +- `temporal_surrogate`: a public package is useful for differential CI, but no exact product bundle + mapping is claimed. + +Product versions and NPM package versions are independent axes. A package published near a Splunk +release is not, by timing alone, proof that the product embeds it. + +## 9.4 evidence + +The public NPM registry has no `@splunk/dashboard-validation@24.0.0`. Public v24 validation releases +date from 2022, while the +[Enterprise 9.4 release notes](https://help.splunk.com/en/splunk-enterprise/release-notes-and-updates/release-notes/9.4/whats-new/welcome-to-splunk-enterprise-9.4) +date 9.4 GA to December 16, 2024. Dashboard 27.5.1 is the last public release before that date. + +The +[9.4 third-party attribution](https://help.splunk.com/en/splunk-enterprise/release-notes-and-updates/release-notes/9.4/third-party-software/credits) +does not establish exact `dashboard-validation`, `dashboard-definition`, and `dashboard-presets` +versions. Therefore 27.5.1 remains a temporal surrogate. + +## 10.x evidence + +The 10.0 lane reuses Dashboard 28.6.0 as a release-family surrogate pending an authoritative bundle +manifest. The +[10.2 third-party attribution](https://help.splunk.com/en/splunk-enterprise/release-notes-and-updates/release-notes/10.2/third-party-software/credits) +supports the Dashboard Framework 28.6 line, so that profile is graded `official_attribution`. + +The public `@splunk/dashboard-validation` registry now includes 29.8.0, and the repository has an +exact lock for it. The public +[10.4 third-party attribution](https://help.splunk.com/en/splunk-enterprise/release-notes-and-updates/release-notes/10.4/third-party-software/credits) +still does not prove that exact product mapping. The 10.4 lane therefore remains +`temporal_surrogate`, despite the lock being real and the expanded corpus passing it. + +## Catalog compatibility + +Nine catalog dashboards have a minimum target of 9.4.3 and compile across every supported profile. +`microservice_service_map` requires 10.4.0 because it uses the official `splunk.networkGraph` +visualization type. CI generates all eligible dashboard/target combinations transiently; the +repository checks in one canonical definition and evidence manifest per dashboard at its minimum +target. + +## Adding or promoting a profile + +1. Obtain an official attribution, SBOM, package manifest, or verified licensed installation. +2. Record the Enterprise release line separately from every NPM version. +3. Add feature gates with primary documentation URLs. +4. Add before/at/after native corpus cases and all eligible catalog cases. +5. Pin an isolated exact NPM lock and pass `scripts/check_engine_locks.py`. +6. Pass native, official-engine, determinism, artifact, and distribution gates. +7. Promote the evidence grade only when the source proves the stronger claim. diff --git a/docs/example-catalog.md b/docs/example-catalog.md new file mode 100644 index 0000000..5b352a8 --- /dev/null +++ b/docs/example-catalog.md @@ -0,0 +1,72 @@ +# Observability example catalog + +The v0.2 catalog compiles ten operational dashboards from one +`portable-observability-v1` telemetry contract. Every dashboard uses SPL1, a global `-24h@h,now` +time input, centralized `ds.search` query parameters, no automatic refresh, no external assets, and +a deterministic 1440-pixel two-column layout. + +| Catalog ID | Minimum target | Frameworks | Operational focus | +|---|---:|---|---| +| `kubernetes_workload_health` | 9.4.3 | RED, USE | Error rate, p95 latency, restarts, CPU/memory saturation, failing pods | +| `ec2_host_capacity` | 9.4.3 | USE | CPU, memory, disk, network/load, host errors | +| `rds_database_health` | 9.4.3 | RED, USE | Operation latency, connections, locks, storage, replication lag | +| `load_balancer_edge_health` | 9.4.3 | Four Golden Signals | Traffic, 4xx/5xx, p95/p99, backend failures, saturation | +| `api_gateway_overview` | 9.4.3 | RED | Route health, authentication, throttling, tenants, latency distribution | +| `microservice_service_map` | 10.4.0 | RED, Four Golden Signals | Service health, network graph, dependency hotspots, deploys, traces | +| `batch_cron_reliability` | 9.4.3 | RED, SLI/SLO | Schedule adherence, duration, backlog, failure, freshness | +| `cicd_delivery_health` | 9.4.3 | RED, SLI/SLO | Lead time, failures, flakiness, queue time, rollbacks | +| `security_operations_overview` | 9.4.3 | Four Golden Signals | Authentication, risk, noisy detections, ingestion and platform health | +| `business_journey_slo` | 9.4.3 | SLI/SLO, RED | Success, latency, abandonment, freshness, error-budget burn | + +## Logical data sources + +The contract declares these deployment-time logical indexes: + +- `otel_metrics`, `otel_logs`, and `otel_traces`; +- `platform_events`; +- `batch_events` and `cicd_events`; +- `security_events`; and +- `business_events`. + +Fields use OpenTelemetry semantic-convention names where stable, including `service.name`, +`deployment.environment.name`, `http.route`, `http.response.status_code`, Kubernetes resource +attributes, host attributes, and database attributes. Derived names such as `duration_ms`, +`business.outcome`, and `ingest_lag_seconds` are explicitly catalog-level normalization +requirements. Inspect the machine-readable contract with `splunk-studio catalog list`. + +## Build and inspect + +```console +uv run splunk-studio catalog list +uv run splunk-studio catalog build api_gateway_overview --target 10.2.0 +uv run splunk-studio catalog build business_journey_slo \ + --target 9.4.3 --artifact bundle --output business-slo.json +``` + +Python callers use `catalog_entries()`, `build_catalog_dashboard()`, +`build_catalog_bundle()`, and `portable_telemetry_contract()`. + +## Checked artifacts + +Each directory under `examples/catalog/` has: + +- `builder.py`, a runnable package API example; +- `dashboard.json`, canonical JSON at the minimum target; +- `manifest.json`, hash, target, validator evidence, provenance, and assumptions; and +- `README.md`, a short operational description. + +Run `uv run python scripts/check_examples.py --check` to detect drift or `--write` to regenerate. +CI also generates every eligible target variant through the compatibility corpus. + +## Saved-search proposals + +The RDS, CI/CD, and business-SLO entries include typed `SavedSearchSpec` recommendations for +expensive shared rollups. These stay in manifests and are not silently substituted into the +checked dashboard JSON. An operator must create, schedule, own, and authorize any saved search +outside this package. + +## Portability boundary + +These are structurally valid templates, not promises that local telemetry already has the required +indexes, units, or fields. Before deployment, profile the data, map logical indexes, verify units, +bound high-cardinality dimensions, review SPL cost, and run the exact target's official validator. diff --git a/docs/operational-security.md b/docs/operational-security.md new file mode 100644 index 0000000..3d618c2 --- /dev/null +++ b/docs/operational-security.md @@ -0,0 +1,60 @@ +# Operational security + +Dashboard Studio definitions execute searches and expose their results. Treat generated JSON as +workload and access-control configuration, not harmless presentation metadata. + +## Package boundary + +The core package has no network client, Splunk SDK, credentials, secret store, Node runtime, or +publication command. The offline XML codec serializes the documented `data/ui/views` envelope but +does not authenticate, send, or receive a request. Unknown server-added XML fields are ignored for +semantic comparison; this is not proof of a live round-trip. + +## Deployment checklist + +Before publishing a generated dashboard: + +1. Map every logical index and required field to an approved local data source. +2. Review SPL for index scope, wildcard expansion, cardinality, subsearches, and concurrency cost. +3. Constrain token inputs to allowlisted values; do not turn tokens into unrestricted SPL + interpolation. +4. Verify dashboard, app, role, and saved-search ACLs under the intended service account. +5. Confirm that panels do not disclose personal data, secrets, security detections, tenant data, or + internal topology to unauthorized viewers. +6. Keep publication private or app-scoped by default. Published dashboards bypass authentication + and must contain only explicitly approved non-sensitive content. +7. Review external assets against the Splunk Dashboards Trusted Domains List. The packaged catalog + uses none. +8. Review refresh and search-concurrency controls. Catalog dashboards deliberately set no + automatic refresh. +9. Validate with the exact Enterprise profile and official engine, then test in a non-production + Splunk namespace before promotion. + +## Saved searches + +Saved searches can reduce repeated work for high-viewer dashboards, but they are separately owned +knowledge objects. Discovery capabilities and ACL behavior may expose more metadata than expected. +The package therefore emits only external `SavedSearchSpec` proposals or explicit references. It +never creates schedules, changes ownership, grants capabilities, or assumes that reference access +implies authorization to inspect all saved searches. + +## Sensitive observability signals + +Trace IDs, tenant IDs, security detection names, risk events, user-journey events, and infrastructure +topology may be sensitive even when they are not credentials. Use pseudonymous dimensions where +possible, avoid raw user identifiers in top-N panels, and apply retention and role policies to both +source indexes and dashboard read access. + +Kernel, network, and eBPF-derived telemetry can materially improve diagnosis but may require +privileged collection. Keep privileged collectors and their raw data outside low-privilege +dashboard roles, and review their host, container, and network exposure separately. + +## XML and supply chain + +The codec rejects DTD and entity declarations and safely splits `]]>` sequences in CDATA. It still +expects trusted-size input and is not a general XML sanitizer. + +Release automation is triggered only by a published GitHub release, verifies tag/version equality, +reruns native and official-engine gates, builds once, inspects artifacts, creates checksums and an +evidence manifest, produces a build-provenance attestation, and uses PyPI trusted publishing. The +repository owner must configure and protect the `pypi` environment before any release. diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..b204ee0 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,59 @@ +# Observability agent skill contract + +The package models an observability platform expert as eight bounded skills. This is a typed, +machine-readable policy layer, not an executable agent router. It cannot run searches, publish a +dashboard, alter ACLs, or create saved searches. + +| Skill | Primary inputs | Outputs | Hard boundary | +|---|---|---|---| +| `data_discovery` | Field inventory, samples, semantic conventions | Dataset profile and candidate entities | Never infer stability from one sample | +| `spl_optimization` | SPL, search graph, panel intent | Explicit proposals and shared-base candidates | Never silently rewrite SPL | +| `visualization_selection` | Intent, cardinality, metric type, audience | Visualization and defaults recommendation | Avoid decorative choices | +| `alerting_slo` | SLI, thresholds, burn policy | SLO sections and saved-search specifications | Never create alerts or saved searches | +| `provenance` | Source mapping, schema, definition | Panel provenance and evidence metadata | No unlabeled derived KPI | +| `validation` | Definition, target profile, engine locks | Normalized report and evidence | NPM code never ships in Python artifacts | +| `drift_detection` | Corpus, snapshots, schema deltas | Compatibility diff and regeneration work | Separate product drift from telemetry drift | +| `access_control` | App context, role policy, publish intent | Least-privilege guidance | Advisory only; never publish or mutate ACLs | + +## Typed inputs and outputs + +`AgentSkill` enumerates the eight stable identifiers. Each `SkillDescriptor` declares: + +- `purpose`; +- required conceptual `inputs` and `outputs`; +- decision `heuristics`; +- non-negotiable `constraints`; and +- abstract `tools` that an external orchestrator may map to authorized implementations. + +The dashboard-design contract is supported by: + +- `TelemetryContract` and `TelemetryField` for signal and field assumptions; +- `CatalogEntry` for dashboard intent and target boundaries; +- `PanelProvenance` for source-to-panel dependencies; +- `SavedSearchSpec` for externally owned performance proposals; +- `DashboardEvidenceManifest` for hashes and validation claims; and +- `DashboardArtifactBundle` for a canonical definition plus its manifest. + +## Schema discovery + +```console +uv run splunk-studio schema agent +uv run splunk-studio schema bundle +``` + +The original `DashboardAgentContract(target, definition)` remains source-compatible. Its JSON +Schema now contains `x-observability-skills`. The full schema bundle also includes telemetry, +skill-descriptor, artifact-bundle, dashboard, and Enterprise profile contracts. + +## Authority model + +An external agent may use these contracts to propose a dashboard, but authority is still explicit: + +- discovery does not grant search execution; +- validation does not grant publication; +- a saved-search specification does not grant knowledge-object creation; +- access-control guidance does not grant ACL mutation; and +- an offline codec result does not prove a live Splunk round-trip. + +This separation keeps generated artifacts inspectable and prevents “optimization,” “validation,” +or “publishing” from becoming hidden mutation channels. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..42540ab --- /dev/null +++ b/examples/README.md @@ -0,0 +1,19 @@ +# Examples + +`basic.py` is the smallest builder walkthrough. The `catalog/` tree contains the ten checked +observability examples shipped for v0.2. Each catalog directory includes: + +- `builder.py`, a runnable Python entry point; +- `dashboard.json`, the canonical definition at that dashboard's minimum target; +- `manifest.json`, provenance and compatibility evidence; and +- `README.md`, telemetry assumptions and panel intent. + +Regenerate or verify the complete tree with: + +```console +uv run python scripts/check_examples.py --write +uv run python scripts/check_examples.py --check +``` + +The examples use the `portable-observability-v1` logical schema. They contain no data, secrets, +external assets, live Splunk connection code, or automatic publication behavior. diff --git a/examples/catalog/api_gateway_overview/README.md b/examples/catalog/api_gateway_overview/README.md new file mode 100644 index 0000000..1a613e7 --- /dev/null +++ b/examples/catalog/api_gateway_overview/README.md @@ -0,0 +1,28 @@ +# API Gateway Overview + +- Catalog ID: `api_gateway_overview` +- Priority: `high` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies red to log, trace telemetry. It assumes the logical +indexes `otel_logs`, `otel_traces`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_route_red` — Compare request volume, failures, and latency by route. +- `viz_authentication_failures` — Track rejected authentication events by route. +- `viz_throttling` — Detect routes constrained by rate limits. +- `viz_top_tenants` — Rank tenant traffic without exposing raw user identifiers. +- `viz_latency_distribution` — Inspect route-level latency percentiles. + +## Rebuild + +```console +uv run python examples/catalog/api_gateway_overview/builder.py +uv run splunk-studio catalog build api_gateway_overview --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/api_gateway_overview/builder.py b/examples/catalog/api_gateway_overview/builder.py new file mode 100644 index 0000000..48a4bee --- /dev/null +++ b/examples/catalog/api_gateway_overview/builder.py @@ -0,0 +1,14 @@ +"""Build the checked api_gateway_overview catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "api_gateway_overview" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/api_gateway_overview/dashboard.json b/examples/catalog/api_gateway_overview/dashboard.json new file mode 100644 index 0000000..36ddbeb --- /dev/null +++ b/examples/catalog/api_gateway_overview/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_authentication_failures": { + "name": "Authentication failures", + "options": { + "query": "index=otel_logs service.name=api-gateway event.name=auth.failure | timechart span=5m count AS failures by http.route" + }, + "type": "ds.search" + }, + "ds_latency_distribution": { + "name": "Latency distribution", + "options": { + "query": "index=otel_traces service.name=api-gateway | stats perc50(duration_ms) AS p50_ms perc95(duration_ms) AS p95_ms perc99(duration_ms) AS p99_ms by http.route" + }, + "type": "ds.search" + }, + "ds_route_red": { + "name": "Route RED summary", + "options": { + "query": "index=otel_logs service.name=api-gateway | stats count AS requests count(eval(http.response.status_code>=500)) AS errors perc95(duration_ms) AS p95_ms by http.route" + }, + "type": "ds.search" + }, + "ds_throttling": { + "name": "Throttled requests", + "options": { + "query": "index=otel_logs service.name=api-gateway http.response.status_code=429 | timechart span=5m count AS throttled by http.route" + }, + "type": "ds.search" + }, + "ds_top_tenants": { + "name": "Top tenants", + "options": { + "query": "index=otel_logs service.name=api-gateway tenant.id=* | stats count AS requests by tenant.id | sort - requests | head 20" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Route and tenant RED signals with authentication and throttling health.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_route_red", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_authentication_failures", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_throttling", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_top_tenants", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_latency_distribution", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "API Gateway Overview", + "version": "2", + "visualizations": { + "viz_authentication_failures": { + "context": {}, + "dataSources": { + "primary": "ds_authentication_failures" + }, + "description": "Track rejected authentication events by route.", + "options": {}, + "title": "Authentication failures", + "type": "splunk.line" + }, + "viz_latency_distribution": { + "context": {}, + "dataSources": { + "primary": "ds_latency_distribution" + }, + "description": "Inspect route-level latency percentiles.", + "options": {}, + "title": "Latency distribution", + "type": "splunk.table" + }, + "viz_route_red": { + "context": {}, + "dataSources": { + "primary": "ds_route_red" + }, + "description": "Compare request volume, failures, and latency by route.", + "options": {}, + "title": "Route RED summary", + "type": "splunk.table" + }, + "viz_throttling": { + "context": {}, + "dataSources": { + "primary": "ds_throttling" + }, + "description": "Detect routes constrained by rate limits.", + "options": {}, + "title": "Throttled requests", + "type": "splunk.line" + }, + "viz_top_tenants": { + "context": {}, + "dataSources": { + "primary": "ds_top_tenants" + }, + "description": "Rank tenant traffic without exposing raw user identifiers.", + "options": {}, + "title": "Top tenants", + "type": "splunk.table" + } + } +} diff --git a/examples/catalog/api_gateway_overview/manifest.json b/examples/catalog/api_gateway_overview/manifest.json new file mode 100644 index 0000000..82439ca --- /dev/null +++ b/examples/catalog/api_gateway_overview/manifest.json @@ -0,0 +1,125 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3536, + "definition_sha256": "cf222473cd837762b336b9a3922b7610f027b9ade9af166b8137c64a127bf1c3", + "engine_evidence": "temporal_surrogate", + "example_id": "api_gateway_overview", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_route_red" + ], + "drilldown_signals": [ + "trace" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_route_red", + "purpose": "Compare request volume, failures, and latency by route.", + "required_fields": [ + "service.name", + "http.route", + "http.response.status_code", + "duration_ms" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_authentication_failures" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_authentication_failures", + "purpose": "Track rejected authentication events by route.", + "required_fields": [ + "service.name", + "event.name", + "http.route" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_throttling" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_throttling", + "purpose": "Detect routes constrained by rate limits.", + "required_fields": [ + "service.name", + "http.response.status_code", + "http.route" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_top_tenants" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_top_tenants", + "purpose": "Rank tenant traffic without exposing raw user identifiers.", + "required_fields": [ + "service.name", + "tenant.id" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_latency_distribution" + ], + "drilldown_signals": [ + "log" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_latency_distribution", + "purpose": "Inspect route-level latency percentiles.", + "required_fields": [ + "service.name", + "duration_ms", + "http.route" + ], + "signals": [ + "trace" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/batch_cron_reliability/README.md b/examples/catalog/batch_cron_reliability/README.md new file mode 100644 index 0000000..54fefed --- /dev/null +++ b/examples/catalog/batch_cron_reliability/README.md @@ -0,0 +1,28 @@ +# Batch and Cron Reliability + +- Catalog ID: `batch_cron_reliability` +- Priority: `medium` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies red, sli_slo to event telemetry. It assumes the logical +indexes `batch_events`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_schedule_adherence` — Find jobs that start materially after their schedules. +- `viz_job_duration` — Track duration regressions by job. +- `viz_job_backlog` — Detect pending work accumulation. +- `viz_job_failures` — Rank recurring job failures. +- `viz_job_freshness` — Show elapsed time since each job last succeeded. + +## Rebuild + +```console +uv run python examples/catalog/batch_cron_reliability/builder.py +uv run splunk-studio catalog build batch_cron_reliability --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/batch_cron_reliability/builder.py b/examples/catalog/batch_cron_reliability/builder.py new file mode 100644 index 0000000..e76dfb9 --- /dev/null +++ b/examples/catalog/batch_cron_reliability/builder.py @@ -0,0 +1,14 @@ +"""Build the checked batch_cron_reliability catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "batch_cron_reliability" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/batch_cron_reliability/dashboard.json b/examples/catalog/batch_cron_reliability/dashboard.json new file mode 100644 index 0000000..75e7e93 --- /dev/null +++ b/examples/catalog/batch_cron_reliability/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_job_backlog": { + "name": "Queue backlog", + "options": { + "query": "index=batch_events queue.backlog=* | timechart span=5m max(queue.backlog) AS backlog by job.name" + }, + "type": "ds.search" + }, + "ds_job_duration": { + "name": "Job duration", + "options": { + "query": "index=batch_events job.name=* | timechart span=1h perc95(duration_ms) AS p95_ms by job.name" + }, + "type": "ds.search" + }, + "ds_job_failures": { + "name": "Job failures", + "options": { + "query": "index=batch_events job.status=failure | stats count AS failures by job.name | sort - failures | head 20" + }, + "type": "ds.search" + }, + "ds_job_freshness": { + "name": "Job freshness", + "options": { + "query": "index=batch_events job.status=success | stats latest(_time) AS last_success by job.name | eval freshness_seconds=now()-last_success | sort - freshness_seconds" + }, + "type": "ds.search" + }, + "ds_schedule_adherence": { + "name": "Schedule adherence", + "options": { + "query": "index=batch_events job.name=* | eval delay_seconds=_time-scheduled_time | stats perc95(delay_seconds) AS p95_delay_seconds by job.name" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Schedule adherence, duration, backlog, failures, and freshness.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_schedule_adherence", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_job_duration", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_job_backlog", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_job_failures", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_job_freshness", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "Batch and Cron Reliability", + "version": "2", + "visualizations": { + "viz_job_backlog": { + "context": {}, + "dataSources": { + "primary": "ds_job_backlog" + }, + "description": "Detect pending work accumulation.", + "options": {}, + "title": "Queue backlog", + "type": "splunk.line" + }, + "viz_job_duration": { + "context": {}, + "dataSources": { + "primary": "ds_job_duration" + }, + "description": "Track duration regressions by job.", + "options": {}, + "title": "Job duration", + "type": "splunk.line" + }, + "viz_job_failures": { + "context": {}, + "dataSources": { + "primary": "ds_job_failures" + }, + "description": "Rank recurring job failures.", + "options": {}, + "title": "Job failures", + "type": "splunk.table" + }, + "viz_job_freshness": { + "context": {}, + "dataSources": { + "primary": "ds_job_freshness" + }, + "description": "Show elapsed time since each job last succeeded.", + "options": {}, + "title": "Job freshness", + "type": "splunk.table" + }, + "viz_schedule_adherence": { + "context": {}, + "dataSources": { + "primary": "ds_schedule_adherence" + }, + "description": "Find jobs that start materially after their schedules.", + "options": {}, + "title": "Schedule adherence", + "type": "splunk.table" + } + } +} diff --git a/examples/catalog/batch_cron_reliability/manifest.json b/examples/catalog/batch_cron_reliability/manifest.json new file mode 100644 index 0000000..2f8e368 --- /dev/null +++ b/examples/catalog/batch_cron_reliability/manifest.json @@ -0,0 +1,116 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3327, + "definition_sha256": "56f3b8032e7f92b5c66a21180e730aefebc27347963ddfcb2e69e38dd204bd53", + "engine_evidence": "temporal_surrogate", + "example_id": "batch_cron_reliability", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_schedule_adherence" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_schedule_adherence", + "purpose": "Find jobs that start materially after their schedules.", + "required_fields": [ + "job.name", + "scheduled_time" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_job_duration" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_job_duration", + "purpose": "Track duration regressions by job.", + "required_fields": [ + "job.name", + "duration_ms" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_job_backlog" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_job_backlog", + "purpose": "Detect pending work accumulation.", + "required_fields": [ + "job.name", + "queue.backlog" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_job_failures" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_job_failures", + "purpose": "Rank recurring job failures.", + "required_fields": [ + "job.name", + "job.status" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_job_freshness" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_job_freshness", + "purpose": "Show elapsed time since each job last succeeded.", + "required_fields": [ + "job.name", + "job.status" + ], + "signals": [ + "event" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/business_journey_slo/README.md b/examples/catalog/business_journey_slo/README.md new file mode 100644 index 0000000..838b1b0 --- /dev/null +++ b/examples/catalog/business_journey_slo/README.md @@ -0,0 +1,28 @@ +# Business Journey and SLO + +- Catalog ID: `business_journey_slo` +- Priority: `medium` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies sli_slo, red to event, trace telemetry. It assumes the logical +indexes `business_events`, `otel_traces`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_journey_success` — Measure whether users complete the intended journey. +- `viz_journey_latency` — Track customer-perceived journey duration. +- `viz_journey_abandonment` — Measure journeys that start but do not complete. +- `viz_business_freshness` — Detect stale or interrupted journey telemetry. +- `viz_error_budget_burn` — Compare observed journey failures with the declared SLO budget. + +## Rebuild + +```console +uv run python examples/catalog/business_journey_slo/builder.py +uv run splunk-studio catalog build business_journey_slo --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/business_journey_slo/builder.py b/examples/catalog/business_journey_slo/builder.py new file mode 100644 index 0000000..a737dbc --- /dev/null +++ b/examples/catalog/business_journey_slo/builder.py @@ -0,0 +1,14 @@ +"""Build the checked business_journey_slo catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "business_journey_slo" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/business_journey_slo/dashboard.json b/examples/catalog/business_journey_slo/dashboard.json new file mode 100644 index 0000000..ae512d4 --- /dev/null +++ b/examples/catalog/business_journey_slo/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_business_freshness": { + "name": "Business event freshness", + "options": { + "query": "index=business_events business.journey.name=* | stats latest(_time) AS latest_event by business.journey.name | eval freshness_seconds=now()-latest_event | sort - freshness_seconds" + }, + "type": "ds.search" + }, + "ds_error_budget_burn": { + "name": "Error-budget burn", + "options": { + "query": "index=business_events business.journey.name=* | bin _time span=5m | stats count AS attempts count(eval(business.outcome!=\"success\")) AS failures by _time business.journey.name | eval burn_rate=(failures/attempts)/0.001" + }, + "type": "ds.search" + }, + "ds_journey_abandonment": { + "name": "Journey abandonment", + "options": { + "query": "index=business_events business.outcome=abandoned | timechart span=5m count AS abandoned by business.journey.name" + }, + "type": "ds.search" + }, + "ds_journey_latency": { + "name": "Journey p95 latency", + "options": { + "query": "index=business_events business.journey.name=* | timechart span=5m perc95(duration_ms) AS p95_ms by business.journey.name" + }, + "type": "ds.search" + }, + "ds_journey_success": { + "name": "Journey success rate", + "options": { + "query": "index=business_events business.journey.name=* | stats count AS attempts count(eval(business.outcome=\"success\")) AS successes by business.journey.name | eval value=round(successes*100/attempts,2)" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "User-journey success, latency, abandonment, freshness, and error-budget burn.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_journey_success", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_journey_latency", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_journey_abandonment", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_business_freshness", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_error_budget_burn", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "Business Journey and SLO", + "version": "2", + "visualizations": { + "viz_business_freshness": { + "context": {}, + "dataSources": { + "primary": "ds_business_freshness" + }, + "description": "Detect stale or interrupted journey telemetry.", + "options": {}, + "title": "Business event freshness", + "type": "splunk.table" + }, + "viz_error_budget_burn": { + "context": {}, + "dataSources": { + "primary": "ds_error_budget_burn" + }, + "description": "Compare observed journey failures with the declared SLO budget.", + "options": {}, + "title": "Error-budget burn", + "type": "splunk.line" + }, + "viz_journey_abandonment": { + "context": {}, + "dataSources": { + "primary": "ds_journey_abandonment" + }, + "description": "Measure journeys that start but do not complete.", + "options": {}, + "title": "Journey abandonment", + "type": "splunk.line" + }, + "viz_journey_latency": { + "context": {}, + "dataSources": { + "primary": "ds_journey_latency" + }, + "description": "Track customer-perceived journey duration.", + "options": {}, + "title": "Journey p95 latency", + "type": "splunk.line" + }, + "viz_journey_success": { + "context": {}, + "dataSources": { + "primary": "ds_journey_success" + }, + "description": "Measure whether users complete the intended journey.", + "options": {}, + "title": "Journey success rate", + "type": "splunk.singlevalue" + } + } +} diff --git a/examples/catalog/business_journey_slo/manifest.json b/examples/catalog/business_journey_slo/manifest.json new file mode 100644 index 0000000..a2c27ea --- /dev/null +++ b/examples/catalog/business_journey_slo/manifest.json @@ -0,0 +1,128 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3777, + "definition_sha256": "d428c22d29130cefc79c1cac6dad3d2def073fca75539e3e0a2392199b65431d", + "engine_evidence": "temporal_surrogate", + "example_id": "business_journey_slo", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_journey_success" + ], + "drilldown_signals": [ + "trace" + ], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_journey_success", + "purpose": "Measure whether users complete the intended journey.", + "required_fields": [ + "business.journey.name", + "business.outcome" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_journey_latency" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_journey_latency", + "purpose": "Track customer-perceived journey duration.", + "required_fields": [ + "business.journey.name", + "duration_ms" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_journey_abandonment" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_journey_abandonment", + "purpose": "Measure journeys that start but do not complete.", + "required_fields": [ + "business.journey.name", + "business.outcome" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_business_freshness" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_business_freshness", + "purpose": "Detect stale or interrupted journey telemetry.", + "required_fields": [ + "business.journey.name" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_error_budget_burn" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_error_budget_burn", + "purpose": "Compare observed journey failures with the declared SLO budget.", + "required_fields": [ + "business.journey.name", + "business.outcome" + ], + "signals": [ + "event" + ] + } + ], + "saved_searches": [ + { + "ownership": "external", + "purpose": "Precompute shared journey SLIs and error-budget windows.", + "rationale": "Multi-window burn rates should be consistent across viewers and alerts.", + "recommended_schedule": "*/5 * * * *", + "reference": "Platform - Business Journey SLO Rollup", + "source_indexes": [ + "business_events" + ] + } + ], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/cicd_delivery_health/README.md b/examples/catalog/cicd_delivery_health/README.md new file mode 100644 index 0000000..d5b568a --- /dev/null +++ b/examples/catalog/cicd_delivery_health/README.md @@ -0,0 +1,28 @@ +# CI/CD Delivery Health + +- Catalog ID: `cicd_delivery_health` +- Priority: `medium` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies red, sli_slo to event telemetry. It assumes the logical +indexes `cicd_events`, `platform_events`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_delivery_lead_time` — Track time from accepted change to production deployment. +- `viz_pipeline_failures` — Compare failed and total pipeline runs. +- `viz_flaky_jobs` — Rank jobs alternating between pass and fail outcomes. +- `viz_pipeline_queue_time` — Track delivery-system saturation before jobs start. +- `viz_deployment_rollbacks` — Correlate rollback frequency with services and environments. + +## Rebuild + +```console +uv run python examples/catalog/cicd_delivery_health/builder.py +uv run splunk-studio catalog build cicd_delivery_health --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/cicd_delivery_health/builder.py b/examples/catalog/cicd_delivery_health/builder.py new file mode 100644 index 0000000..5e43942 --- /dev/null +++ b/examples/catalog/cicd_delivery_health/builder.py @@ -0,0 +1,14 @@ +"""Build the checked cicd_delivery_health catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "cicd_delivery_health" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/cicd_delivery_health/dashboard.json b/examples/catalog/cicd_delivery_health/dashboard.json new file mode 100644 index 0000000..11e5314 --- /dev/null +++ b/examples/catalog/cicd_delivery_health/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_delivery_lead_time": { + "name": "Delivery lead time", + "options": { + "query": "index=cicd_events event.name=deployment.completed | timechart span=1d perc95(duration_ms) AS p95_lead_time_ms" + }, + "type": "ds.search" + }, + "ds_deployment_rollbacks": { + "name": "Deployment rollbacks", + "options": { + "query": "index=platform_events change.type=rollback | stats count AS rollbacks by service.name deployment.environment.name | sort - rollbacks" + }, + "type": "ds.search" + }, + "ds_flaky_jobs": { + "name": "Flaky jobs", + "options": { + "query": "index=cicd_events cicd.job.name=* | stats dc(cicd.status) AS outcomes count(eval(cicd.status=\"failure\")) AS failures by cicd.job.name | where outcomes>1 | sort - failures | head 20" + }, + "type": "ds.search" + }, + "ds_pipeline_failures": { + "name": "Pipeline failure rate", + "options": { + "query": "index=cicd_events cicd.pipeline.name=* | timechart span=1h count AS runs count(eval(cicd.status=\"failure\")) AS failures by cicd.pipeline.name" + }, + "type": "ds.search" + }, + "ds_pipeline_queue_time": { + "name": "Queue time", + "options": { + "query": "index=cicd_events event.name=job.started | timechart span=1h perc95(duration_ms) AS p95_queue_ms by cicd.pipeline.name" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Delivery lead time, reliability, queueing, flaky jobs, and rollback signals.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_delivery_lead_time", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_pipeline_failures", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_flaky_jobs", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_pipeline_queue_time", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_deployment_rollbacks", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "CI/CD Delivery Health", + "version": "2", + "visualizations": { + "viz_delivery_lead_time": { + "context": {}, + "dataSources": { + "primary": "ds_delivery_lead_time" + }, + "description": "Track time from accepted change to production deployment.", + "options": {}, + "title": "Delivery lead time", + "type": "splunk.line" + }, + "viz_deployment_rollbacks": { + "context": {}, + "dataSources": { + "primary": "ds_deployment_rollbacks" + }, + "description": "Correlate rollback frequency with services and environments.", + "options": {}, + "title": "Deployment rollbacks", + "type": "splunk.table" + }, + "viz_flaky_jobs": { + "context": {}, + "dataSources": { + "primary": "ds_flaky_jobs" + }, + "description": "Rank jobs alternating between pass and fail outcomes.", + "options": {}, + "title": "Flaky jobs", + "type": "splunk.table" + }, + "viz_pipeline_failures": { + "context": {}, + "dataSources": { + "primary": "ds_pipeline_failures" + }, + "description": "Compare failed and total pipeline runs.", + "options": {}, + "title": "Pipeline failure rate", + "type": "splunk.line" + }, + "viz_pipeline_queue_time": { + "context": {}, + "dataSources": { + "primary": "ds_pipeline_queue_time" + }, + "description": "Track delivery-system saturation before jobs start.", + "options": {}, + "title": "Queue time", + "type": "splunk.line" + } + } +} diff --git a/examples/catalog/cicd_delivery_health/manifest.json b/examples/catalog/cicd_delivery_health/manifest.json new file mode 100644 index 0000000..f6b0252 --- /dev/null +++ b/examples/catalog/cicd_delivery_health/manifest.json @@ -0,0 +1,130 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3593, + "definition_sha256": "3f238d5f7b9655b69ed3ddc9d3bc7d7a90d2662b003eb089b9fc449feadd43c5", + "engine_evidence": "temporal_surrogate", + "example_id": "cicd_delivery_health", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_delivery_lead_time" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_delivery_lead_time", + "purpose": "Track time from accepted change to production deployment.", + "required_fields": [ + "event.name", + "duration_ms" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_pipeline_failures" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_pipeline_failures", + "purpose": "Compare failed and total pipeline runs.", + "required_fields": [ + "cicd.pipeline.name", + "cicd.status" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_flaky_jobs" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_flaky_jobs", + "purpose": "Rank jobs alternating between pass and fail outcomes.", + "required_fields": [ + "cicd.job.name", + "cicd.status" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_pipeline_queue_time" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_pipeline_queue_time", + "purpose": "Track delivery-system saturation before jobs start.", + "required_fields": [ + "event.name", + "duration_ms", + "cicd.pipeline.name" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_deployment_rollbacks" + ], + "drilldown_signals": [], + "frameworks": [ + "sli_slo" + ], + "panel_id": "viz_deployment_rollbacks", + "purpose": "Correlate rollback frequency with services and environments.", + "required_fields": [ + "change.type", + "service.name", + "deployment.environment.name" + ], + "signals": [ + "event" + ] + } + ], + "saved_searches": [ + { + "ownership": "external", + "purpose": "Precompute daily delivery and change-failure indicators.", + "rationale": "Cross-event lead-time calculations are expensive and widely shared.", + "recommended_schedule": "15 * * * *", + "reference": "Platform - CI CD Delivery Rollup", + "source_indexes": [ + "cicd_events", + "platform_events" + ] + } + ], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/ec2_host_capacity/README.md b/examples/catalog/ec2_host_capacity/README.md new file mode 100644 index 0000000..053ef34 --- /dev/null +++ b/examples/catalog/ec2_host_capacity/README.md @@ -0,0 +1,28 @@ +# EC2 and Host Capacity + +- Catalog ID: `ec2_host_capacity` +- Priority: `high` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies use to metric, log telemetry. It assumes the logical +indexes `otel_metrics`, `otel_logs`, `platform_events`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_host_cpu` — Track sustained host CPU utilization. +- `viz_host_memory` — Find hosts with memory pressure. +- `viz_host_disk` — Identify full or rapidly filling filesystems. +- `viz_host_network_load` — Compare network throughput with system load. +- `viz_host_errors` — Rank hosts by operating-system and agent errors. + +## Rebuild + +```console +uv run python examples/catalog/ec2_host_capacity/builder.py +uv run splunk-studio catalog build ec2_host_capacity --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/ec2_host_capacity/builder.py b/examples/catalog/ec2_host_capacity/builder.py new file mode 100644 index 0000000..cd1b5e8 --- /dev/null +++ b/examples/catalog/ec2_host_capacity/builder.py @@ -0,0 +1,14 @@ +"""Build the checked ec2_host_capacity catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "ec2_host_capacity" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/ec2_host_capacity/dashboard.json b/examples/catalog/ec2_host_capacity/dashboard.json new file mode 100644 index 0000000..8f17b14 --- /dev/null +++ b/examples/catalog/ec2_host_capacity/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_host_cpu": { + "name": "CPU utilization", + "options": { + "query": "| mstats avg(_value) AS utilization WHERE index=otel_metrics metric_name=\"system.cpu.utilization\" BY host.name span=5m" + }, + "type": "ds.search" + }, + "ds_host_disk": { + "name": "Disk utilization", + "options": { + "query": "| mstats max(_value) AS utilization WHERE index=otel_metrics metric_name=\"system.filesystem.utilization\" BY host.name span=5m" + }, + "type": "ds.search" + }, + "ds_host_errors": { + "name": "Host errors", + "options": { + "query": "index=otel_logs host.name=* event.name=*error* | stats count AS errors by host.name event.name | sort - errors | head 20" + }, + "type": "ds.search" + }, + "ds_host_memory": { + "name": "Memory utilization", + "options": { + "query": "| mstats max(_value) AS utilization WHERE index=otel_metrics metric_name=\"system.memory.utilization\" BY host.name span=5m" + }, + "type": "ds.search" + }, + "ds_host_network_load": { + "name": "Network and load", + "options": { + "query": "| mstats avg(_value) AS value WHERE index=otel_metrics metric_name IN (\"system.network.io\",\"system.cpu.load_average.15m\") BY metric_name host.name span=5m" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "USE-oriented host and autoscaling capacity overview.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_host_cpu", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_host_memory", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_host_disk", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_host_network_load", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_host_errors", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "EC2 and Host Capacity", + "version": "2", + "visualizations": { + "viz_host_cpu": { + "context": {}, + "dataSources": { + "primary": "ds_host_cpu" + }, + "description": "Track sustained host CPU utilization.", + "options": {}, + "title": "CPU utilization", + "type": "splunk.line" + }, + "viz_host_disk": { + "context": {}, + "dataSources": { + "primary": "ds_host_disk" + }, + "description": "Identify full or rapidly filling filesystems.", + "options": {}, + "title": "Disk utilization", + "type": "splunk.line" + }, + "viz_host_errors": { + "context": {}, + "dataSources": { + "primary": "ds_host_errors" + }, + "description": "Rank hosts by operating-system and agent errors.", + "options": {}, + "title": "Host errors", + "type": "splunk.table" + }, + "viz_host_memory": { + "context": {}, + "dataSources": { + "primary": "ds_host_memory" + }, + "description": "Find hosts with memory pressure.", + "options": {}, + "title": "Memory utilization", + "type": "splunk.line" + }, + "viz_host_network_load": { + "context": {}, + "dataSources": { + "primary": "ds_host_network_load" + }, + "description": "Compare network throughput with system load.", + "options": {}, + "title": "Network and load", + "type": "splunk.line" + } + } +} diff --git a/examples/catalog/ec2_host_capacity/manifest.json b/examples/catalog/ec2_host_capacity/manifest.json new file mode 100644 index 0000000..aa0da13 --- /dev/null +++ b/examples/catalog/ec2_host_capacity/manifest.json @@ -0,0 +1,116 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3360, + "definition_sha256": "6f0b095156c743a830fc08dee8c8ac55fc9187b2f1086b04b19f589d75b30e5e", + "engine_evidence": "temporal_surrogate", + "example_id": "ec2_host_capacity", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_host_cpu" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_host_cpu", + "purpose": "Track sustained host CPU utilization.", + "required_fields": [ + "metric_name", + "host.name" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_host_memory" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_host_memory", + "purpose": "Find hosts with memory pressure.", + "required_fields": [ + "metric_name", + "host.name" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_host_disk" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_host_disk", + "purpose": "Identify full or rapidly filling filesystems.", + "required_fields": [ + "metric_name", + "host.name" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_host_network_load" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_host_network_load", + "purpose": "Compare network throughput with system load.", + "required_fields": [ + "metric_name", + "host.name" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_host_errors" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_host_errors", + "purpose": "Rank hosts by operating-system and agent errors.", + "required_fields": [ + "host.name", + "event.name" + ], + "signals": [ + "log" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/kubernetes_workload_health/README.md b/examples/catalog/kubernetes_workload_health/README.md new file mode 100644 index 0000000..3cfa759 --- /dev/null +++ b/examples/catalog/kubernetes_workload_health/README.md @@ -0,0 +1,28 @@ +# Kubernetes Workload Health + +- Catalog ID: `kubernetes_workload_health` +- Priority: `high` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies red, use to metric, log, trace telemetry. It assumes the logical +indexes `otel_metrics`, `otel_logs`, `otel_traces`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_workload_error_rate` — Identify workloads whose request failures are rising. +- `viz_workload_p95_latency` — Track user-visible workload latency over time. +- `viz_pod_restarts` — Surface unstable pods and workload churn. +- `viz_resource_saturation` — Locate pods approaching resource limits. +- `viz_failing_pods` — Rank pods emitting the most error events. + +## Rebuild + +```console +uv run python examples/catalog/kubernetes_workload_health/builder.py +uv run splunk-studio catalog build kubernetes_workload_health --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/kubernetes_workload_health/builder.py b/examples/catalog/kubernetes_workload_health/builder.py new file mode 100644 index 0000000..c244eb3 --- /dev/null +++ b/examples/catalog/kubernetes_workload_health/builder.py @@ -0,0 +1,14 @@ +"""Build the checked kubernetes_workload_health catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "kubernetes_workload_health" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/kubernetes_workload_health/dashboard.json b/examples/catalog/kubernetes_workload_health/dashboard.json new file mode 100644 index 0000000..d77e366 --- /dev/null +++ b/examples/catalog/kubernetes_workload_health/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_failing_pods": { + "name": "Top failing pods", + "options": { + "query": "index=otel_logs k8s.pod.name=* http.response.status_code>=500 | stats count AS failures by k8s.namespace.name k8s.pod.name | sort - failures | head 20" + }, + "type": "ds.search" + }, + "ds_pod_restarts": { + "name": "Pod restarts", + "options": { + "query": "| mstats sum(_value) AS restarts WHERE index=otel_metrics metric_name=\"k8s.pod.restart.count\" BY k8s.namespace.name k8s.pod.name span=5m" + }, + "type": "ds.search" + }, + "ds_resource_saturation": { + "name": "CPU and memory saturation", + "options": { + "query": "| mstats max(_value) AS saturation WHERE index=otel_metrics metric_name IN (\"k8s.container.cpu.utilization\",\"k8s.container.memory.utilization\") BY metric_name k8s.pod.name span=5m" + }, + "type": "ds.search" + }, + "ds_workload_error_rate": { + "name": "Workload error rate", + "options": { + "query": "index=otel_logs k8s.namespace.name=* | stats count AS requests count(eval(http.response.status_code>=500)) AS errors by k8s.namespace.name k8s.workload.name | eval value=round(errors*100/requests,2)" + }, + "type": "ds.search" + }, + "ds_workload_p95_latency": { + "name": "Workload p95 latency", + "options": { + "query": "index=otel_traces k8s.namespace.name=* | timechart span=5m perc95(duration_ms) AS p95_ms by k8s.workload.name" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Namespace and workload RED signals with pod-level resource saturation.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_workload_error_rate", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_workload_p95_latency", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_pod_restarts", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_resource_saturation", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_failing_pods", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "Kubernetes Workload Health", + "version": "2", + "visualizations": { + "viz_failing_pods": { + "context": {}, + "dataSources": { + "primary": "ds_failing_pods" + }, + "description": "Rank pods emitting the most error events.", + "options": {}, + "title": "Top failing pods", + "type": "splunk.table" + }, + "viz_pod_restarts": { + "context": {}, + "dataSources": { + "primary": "ds_pod_restarts" + }, + "description": "Surface unstable pods and workload churn.", + "options": {}, + "title": "Pod restarts", + "type": "splunk.line" + }, + "viz_resource_saturation": { + "context": {}, + "dataSources": { + "primary": "ds_resource_saturation" + }, + "description": "Locate pods approaching resource limits.", + "options": {}, + "title": "CPU and memory saturation", + "type": "splunk.line" + }, + "viz_workload_error_rate": { + "context": {}, + "dataSources": { + "primary": "ds_workload_error_rate" + }, + "description": "Identify workloads whose request failures are rising.", + "options": {}, + "title": "Workload error rate", + "type": "splunk.singlevalue" + }, + "viz_workload_p95_latency": { + "context": {}, + "dataSources": { + "primary": "ds_workload_p95_latency" + }, + "description": "Track user-visible workload latency over time.", + "options": {}, + "title": "Workload p95 latency", + "type": "splunk.line" + } + } +} diff --git a/examples/catalog/kubernetes_workload_health/manifest.json b/examples/catalog/kubernetes_workload_health/manifest.json new file mode 100644 index 0000000..2f0609a --- /dev/null +++ b/examples/catalog/kubernetes_workload_health/manifest.json @@ -0,0 +1,126 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3671, + "definition_sha256": "bf0c9e41c807b5e4a98d52ef1dd342ad3c86bb9e9c9ca9b861c9f824a4a93520", + "engine_evidence": "temporal_surrogate", + "example_id": "kubernetes_workload_health", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_workload_error_rate" + ], + "drilldown_signals": [ + "trace" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_workload_error_rate", + "purpose": "Identify workloads whose request failures are rising.", + "required_fields": [ + "k8s.namespace.name", + "k8s.workload.name", + "http.response.status_code" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_workload_p95_latency" + ], + "drilldown_signals": [ + "log" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_workload_p95_latency", + "purpose": "Track user-visible workload latency over time.", + "required_fields": [ + "k8s.namespace.name", + "k8s.workload.name", + "duration_ms" + ], + "signals": [ + "trace" + ] + }, + { + "data_source_ids": [ + "ds_pod_restarts" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_pod_restarts", + "purpose": "Surface unstable pods and workload churn.", + "required_fields": [ + "metric_name", + "k8s.namespace.name", + "k8s.pod.name" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_resource_saturation" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_resource_saturation", + "purpose": "Locate pods approaching resource limits.", + "required_fields": [ + "metric_name", + "k8s.pod.name" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_failing_pods" + ], + "drilldown_signals": [ + "trace" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_failing_pods", + "purpose": "Rank pods emitting the most error events.", + "required_fields": [ + "k8s.namespace.name", + "k8s.pod.name", + "http.response.status_code" + ], + "signals": [ + "log" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/load_balancer_edge_health/README.md b/examples/catalog/load_balancer_edge_health/README.md new file mode 100644 index 0000000..b11d115 --- /dev/null +++ b/examples/catalog/load_balancer_edge_health/README.md @@ -0,0 +1,28 @@ +# Load Balancer Edge Health + +- Catalog ID: `load_balancer_edge_health` +- Priority: `high` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies four_golden_signals to log, metric telemetry. It assumes the logical +indexes `otel_logs`, `otel_metrics`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_edge_traffic` — Track ingress demand over time. +- `viz_edge_errors` — Separate client and server failure rates. +- `viz_edge_latency` — Track tail latency at the edge. +- `viz_backend_failures` — Rank unhealthy backend services. +- `viz_edge_saturation` — Show active connection pressure. + +## Rebuild + +```console +uv run python examples/catalog/load_balancer_edge_health/builder.py +uv run splunk-studio catalog build load_balancer_edge_health --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/load_balancer_edge_health/builder.py b/examples/catalog/load_balancer_edge_health/builder.py new file mode 100644 index 0000000..4ccbf6c --- /dev/null +++ b/examples/catalog/load_balancer_edge_health/builder.py @@ -0,0 +1,14 @@ +"""Build the checked load_balancer_edge_health catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "load_balancer_edge_health" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/load_balancer_edge_health/dashboard.json b/examples/catalog/load_balancer_edge_health/dashboard.json new file mode 100644 index 0000000..8aa2f59 --- /dev/null +++ b/examples/catalog/load_balancer_edge_health/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_backend_failures": { + "name": "Backend target failures", + "options": { + "query": "index=otel_logs service.name=load-balancer http.response.status_code>=500 | stats count AS failures by peer.service | sort - failures | head 20" + }, + "type": "ds.search" + }, + "ds_edge_errors": { + "name": "4xx and 5xx rate", + "options": { + "query": "index=otel_logs service.name=load-balancer | timechart span=5m count(eval(http.response.status_code>=400 AND http.response.status_code<500)) AS 4xx count(eval(http.response.status_code>=500)) AS 5xx" + }, + "type": "ds.search" + }, + "ds_edge_latency": { + "name": "p95 and p99 latency", + "options": { + "query": "index=otel_logs service.name=load-balancer | timechart span=5m perc95(duration_ms) AS p95_ms perc99(duration_ms) AS p99_ms" + }, + "type": "ds.search" + }, + "ds_edge_saturation": { + "name": "Capacity saturation", + "options": { + "query": "| mstats max(_value) AS saturation WHERE index=otel_metrics metric_name=\"http.server.active_requests\" BY service.name span=5m" + }, + "type": "ds.search" + }, + "ds_edge_traffic": { + "name": "Request traffic", + "options": { + "query": "index=otel_logs service.name=load-balancer | timechart span=5m count AS requests" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Four Golden Signals for ingress and backend target health.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_edge_traffic", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_edge_errors", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_edge_latency", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_backend_failures", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_edge_saturation", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "Load Balancer Edge Health", + "version": "2", + "visualizations": { + "viz_backend_failures": { + "context": {}, + "dataSources": { + "primary": "ds_backend_failures" + }, + "description": "Rank unhealthy backend services.", + "options": {}, + "title": "Backend target failures", + "type": "splunk.table" + }, + "viz_edge_errors": { + "context": {}, + "dataSources": { + "primary": "ds_edge_errors" + }, + "description": "Separate client and server failure rates.", + "options": {}, + "title": "4xx and 5xx rate", + "type": "splunk.line" + }, + "viz_edge_latency": { + "context": {}, + "dataSources": { + "primary": "ds_edge_latency" + }, + "description": "Track tail latency at the edge.", + "options": {}, + "title": "p95 and p99 latency", + "type": "splunk.line" + }, + "viz_edge_saturation": { + "context": {}, + "dataSources": { + "primary": "ds_edge_saturation" + }, + "description": "Show active connection pressure.", + "options": {}, + "title": "Capacity saturation", + "type": "splunk.line" + }, + "viz_edge_traffic": { + "context": {}, + "dataSources": { + "primary": "ds_edge_traffic" + }, + "description": "Track ingress demand over time.", + "options": {}, + "title": "Request traffic", + "type": "splunk.line" + } + } +} diff --git a/examples/catalog/load_balancer_edge_health/manifest.json b/examples/catalog/load_balancer_edge_health/manifest.json new file mode 100644 index 0000000..9625612 --- /dev/null +++ b/examples/catalog/load_balancer_edge_health/manifest.json @@ -0,0 +1,116 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3425, + "definition_sha256": "1a8fe357e0a5a648cb84e392976c61350125e35952853a6cb16dc2982e119f6c", + "engine_evidence": "temporal_surrogate", + "example_id": "load_balancer_edge_health", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_edge_traffic" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_edge_traffic", + "purpose": "Track ingress demand over time.", + "required_fields": [ + "service.name" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_edge_errors" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_edge_errors", + "purpose": "Separate client and server failure rates.", + "required_fields": [ + "service.name", + "http.response.status_code" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_edge_latency" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_edge_latency", + "purpose": "Track tail latency at the edge.", + "required_fields": [ + "service.name", + "duration_ms" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_backend_failures" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_backend_failures", + "purpose": "Rank unhealthy backend services.", + "required_fields": [ + "service.name", + "http.response.status_code", + "peer.service" + ], + "signals": [ + "log" + ] + }, + { + "data_source_ids": [ + "ds_edge_saturation" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_edge_saturation", + "purpose": "Show active connection pressure.", + "required_fields": [ + "metric_name", + "service.name" + ], + "signals": [ + "metric" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/microservice_service_map/README.md b/examples/catalog/microservice_service_map/README.md new file mode 100644 index 0000000..a7649a7 --- /dev/null +++ b/examples/catalog/microservice_service_map/README.md @@ -0,0 +1,28 @@ +# Microservice Service Map + +- Catalog ID: `microservice_service_map` +- Priority: `high` +- Checked target: Splunk Enterprise `10.4.0` +- Telemetry contract: `portable-observability-v1` + +This generated example applies red, four_golden_signals to metric, event, log, trace telemetry. It assumes the logical +indexes `otel_metrics`, `otel_logs`, `otel_traces`, `platform_events`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_service_red` — Compare request health across services. +- `viz_dependency_map` — Show request flow between instrumented services. +- `viz_dependency_hotspots` — Rank slow and failing downstream dependencies. +- `viz_recent_deployments` — Correlate service health with recent changes. +- `viz_trace_samples` — Provide trace identifiers for deep diagnosis. + +## Rebuild + +```console +uv run python examples/catalog/microservice_service_map/builder.py +uv run splunk-studio catalog build microservice_service_map --target 10.4.0 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/microservice_service_map/builder.py b/examples/catalog/microservice_service_map/builder.py new file mode 100644 index 0000000..ed0e62c --- /dev/null +++ b/examples/catalog/microservice_service_map/builder.py @@ -0,0 +1,14 @@ +"""Build the checked microservice_service_map catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "microservice_service_map" +TARGET = "10.4.0" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/microservice_service_map/dashboard.json b/examples/catalog/microservice_service_map/dashboard.json new file mode 100644 index 0000000..a6d3655 --- /dev/null +++ b/examples/catalog/microservice_service_map/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_dependency_hotspots": { + "name": "Dependency hotspots", + "options": { + "query": "index=otel_traces peer.service=* | stats perc95(duration_ms) AS p95_ms count(eval(http.response.status_code>=500)) AS errors by service.name peer.service | sort - p95_ms | head 20" + }, + "type": "ds.search" + }, + "ds_dependency_map": { + "name": "Dependency map", + "options": { + "query": "index=otel_traces service.name=* peer.service=* | stats count AS value by service.name peer.service | rename service.name AS source peer.service AS target" + }, + "type": "ds.search" + }, + "ds_recent_deployments": { + "name": "Recent deployments", + "options": { + "query": "index=platform_events change.type=deployment service.name=* | table _time service.name deployment.environment.name change.type | sort - _time" + }, + "type": "ds.search" + }, + "ds_service_red": { + "name": "Service RED summary", + "options": { + "query": "index=otel_traces service.name=* | stats count AS requests count(eval(http.response.status_code>=500)) AS errors perc95(duration_ms) AS p95_ms by service.name" + }, + "type": "ds.search" + }, + "ds_trace_samples": { + "name": "Slow trace samples", + "options": { + "query": "index=otel_traces service.name=* | sort - duration_ms | table _time service.name trace_id span_id duration_ms http.route | head 50" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Service RED health, dependency hotspots, changes, and trace drill-through.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_service_red", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_dependency_map", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_dependency_hotspots", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_recent_deployments", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_trace_samples", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "Microservice Service Map", + "version": "2", + "visualizations": { + "viz_dependency_hotspots": { + "context": {}, + "dataSources": { + "primary": "ds_dependency_hotspots" + }, + "description": "Rank slow and failing downstream dependencies.", + "options": {}, + "title": "Dependency hotspots", + "type": "splunk.table" + }, + "viz_dependency_map": { + "context": {}, + "dataSources": { + "primary": "ds_dependency_map" + }, + "description": "Show request flow between instrumented services.", + "options": {}, + "title": "Dependency map", + "type": "splunk.networkGraph" + }, + "viz_recent_deployments": { + "context": {}, + "dataSources": { + "primary": "ds_recent_deployments" + }, + "description": "Correlate service health with recent changes.", + "options": {}, + "title": "Recent deployments", + "type": "splunk.table" + }, + "viz_service_red": { + "context": {}, + "dataSources": { + "primary": "ds_service_red" + }, + "description": "Compare request health across services.", + "options": {}, + "title": "Service RED summary", + "type": "splunk.table" + }, + "viz_trace_samples": { + "context": {}, + "dataSources": { + "primary": "ds_trace_samples" + }, + "description": "Provide trace identifiers for deep diagnosis.", + "options": {}, + "title": "Slow trace samples", + "type": "splunk.table" + } + } +} diff --git a/examples/catalog/microservice_service_map/manifest.json b/examples/catalog/microservice_service_map/manifest.json new file mode 100644 index 0000000..ea5b628 --- /dev/null +++ b/examples/catalog/microservice_service_map/manifest.json @@ -0,0 +1,129 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3627, + "definition_sha256": "12e819987861eed98efa935036d807292abec6d45c4cdec176f276146baf5bd9", + "engine_evidence": "temporal_surrogate", + "example_id": "microservice_service_map", + "generator_version": "0.2.0", + "minimum_target": "10.4.0", + "native_validation": "valid", + "official_engine_id": "dashboard-29.8.0", + "official_engine_version": "29.8.0", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_service_red" + ], + "drilldown_signals": [ + "log" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_service_red", + "purpose": "Compare request health across services.", + "required_fields": [ + "service.name", + "http.response.status_code", + "duration_ms" + ], + "signals": [ + "trace" + ] + }, + { + "data_source_ids": [ + "ds_dependency_map" + ], + "drilldown_signals": [ + "log" + ], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_dependency_map", + "purpose": "Show request flow between instrumented services.", + "required_fields": [ + "service.name", + "peer.service" + ], + "signals": [ + "trace" + ] + }, + { + "data_source_ids": [ + "ds_dependency_hotspots" + ], + "drilldown_signals": [], + "frameworks": [ + "red" + ], + "panel_id": "viz_dependency_hotspots", + "purpose": "Rank slow and failing downstream dependencies.", + "required_fields": [ + "service.name", + "peer.service", + "duration_ms", + "http.response.status_code" + ], + "signals": [ + "trace" + ] + }, + { + "data_source_ids": [ + "ds_recent_deployments" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_recent_deployments", + "purpose": "Correlate service health with recent changes.", + "required_fields": [ + "service.name", + "deployment.environment.name", + "change.type" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_trace_samples" + ], + "drilldown_signals": [ + "log" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_trace_samples", + "purpose": "Provide trace identifiers for deep diagnosis.", + "required_fields": [ + "service.name", + "trace_id", + "span_id", + "duration_ms", + "http.route" + ], + "signals": [ + "trace" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "10.4.0" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/rds_database_health/README.md b/examples/catalog/rds_database_health/README.md new file mode 100644 index 0000000..502d2bf --- /dev/null +++ b/examples/catalog/rds_database_health/README.md @@ -0,0 +1,28 @@ +# RDS and Database Health + +- Catalog ID: `rds_database_health` +- Priority: `high` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies red, use to metric, log, trace telemetry. It assumes the logical +indexes `otel_metrics`, `otel_logs`, `otel_traces`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_database_latency` — Track slow database operations by namespace. +- `viz_database_connections` — Find databases approaching connection limits. +- `viz_database_locks` — Surface lock waits and contention. +- `viz_database_storage` — Track remaining storage and write pressure. +- `viz_replication_lag` — Detect replicas falling behind primary databases. + +## Rebuild + +```console +uv run python examples/catalog/rds_database_health/builder.py +uv run splunk-studio catalog build rds_database_health --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/rds_database_health/builder.py b/examples/catalog/rds_database_health/builder.py new file mode 100644 index 0000000..9e71b03 --- /dev/null +++ b/examples/catalog/rds_database_health/builder.py @@ -0,0 +1,14 @@ +"""Build the checked rds_database_health catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "rds_database_health" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/rds_database_health/dashboard.json b/examples/catalog/rds_database_health/dashboard.json new file mode 100644 index 0000000..6f0799b --- /dev/null +++ b/examples/catalog/rds_database_health/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_database_connections": { + "name": "Connection utilization", + "options": { + "query": "| mstats max(_value) AS connections WHERE index=otel_metrics metric_name=\"db.client.connections.usage\" BY db.system.name db.namespace span=5m" + }, + "type": "ds.search" + }, + "ds_database_latency": { + "name": "Operation p95 latency", + "options": { + "query": "index=otel_traces db.system.name=* | timechart span=5m perc95(duration_ms) AS p95_ms by db.namespace" + }, + "type": "ds.search" + }, + "ds_database_locks": { + "name": "Lock pressure", + "options": { + "query": "| mstats max(_value) AS lock_waits WHERE index=otel_metrics metric_name=\"db.lock.waits\" BY db.system.name db.namespace span=5m" + }, + "type": "ds.search" + }, + "ds_database_storage": { + "name": "Storage pressure", + "options": { + "query": "| mstats min(_value) AS remaining WHERE index=otel_metrics metric_name=\"db.storage.remaining\" BY db.system.name db.namespace span=5m" + }, + "type": "ds.search" + }, + "ds_replication_lag": { + "name": "Replication lag", + "options": { + "query": "| mstats max(_value) AS lag_seconds WHERE index=otel_metrics metric_name=\"db.replication.lag\" BY db.system.name db.namespace span=5m" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Database latency, concurrency, errors, and storage pressure.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_database_latency", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_database_connections", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_database_locks", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_database_storage", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_replication_lag", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "RDS and Database Health", + "version": "2", + "visualizations": { + "viz_database_connections": { + "context": {}, + "dataSources": { + "primary": "ds_database_connections" + }, + "description": "Find databases approaching connection limits.", + "options": {}, + "title": "Connection utilization", + "type": "splunk.line" + }, + "viz_database_latency": { + "context": {}, + "dataSources": { + "primary": "ds_database_latency" + }, + "description": "Track slow database operations by namespace.", + "options": {}, + "title": "Operation p95 latency", + "type": "splunk.line" + }, + "viz_database_locks": { + "context": {}, + "dataSources": { + "primary": "ds_database_locks" + }, + "description": "Surface lock waits and contention.", + "options": {}, + "title": "Lock pressure", + "type": "splunk.line" + }, + "viz_database_storage": { + "context": {}, + "dataSources": { + "primary": "ds_database_storage" + }, + "description": "Track remaining storage and write pressure.", + "options": {}, + "title": "Storage pressure", + "type": "splunk.line" + }, + "viz_replication_lag": { + "context": {}, + "dataSources": { + "primary": "ds_replication_lag" + }, + "description": "Detect replicas falling behind primary databases.", + "options": {}, + "title": "Replication lag", + "type": "splunk.line" + } + } +} diff --git a/examples/catalog/rds_database_health/manifest.json b/examples/catalog/rds_database_health/manifest.json new file mode 100644 index 0000000..bb0564c --- /dev/null +++ b/examples/catalog/rds_database_health/manifest.json @@ -0,0 +1,135 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3491, + "definition_sha256": "0d595cfa3e596b3fb6dc8cc4a9afefefd21aeb561d71ab602ea293fc057d81f5", + "engine_evidence": "temporal_surrogate", + "example_id": "rds_database_health", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_database_latency" + ], + "drilldown_signals": [ + "log" + ], + "frameworks": [ + "red" + ], + "panel_id": "viz_database_latency", + "purpose": "Track slow database operations by namespace.", + "required_fields": [ + "db.system.name", + "db.namespace", + "duration_ms" + ], + "signals": [ + "trace" + ] + }, + { + "data_source_ids": [ + "ds_database_connections" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_database_connections", + "purpose": "Find databases approaching connection limits.", + "required_fields": [ + "metric_name", + "db.system.name", + "db.namespace" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_database_locks" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_database_locks", + "purpose": "Surface lock waits and contention.", + "required_fields": [ + "metric_name", + "db.system.name", + "db.namespace" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_database_storage" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_database_storage", + "purpose": "Track remaining storage and write pressure.", + "required_fields": [ + "metric_name", + "db.system.name", + "db.namespace" + ], + "signals": [ + "metric" + ] + }, + { + "data_source_ids": [ + "ds_replication_lag" + ], + "drilldown_signals": [], + "frameworks": [ + "use" + ], + "panel_id": "viz_replication_lag", + "purpose": "Detect replicas falling behind primary databases.", + "required_fields": [ + "metric_name", + "db.system.name", + "db.namespace" + ], + "signals": [ + "metric" + ] + } + ], + "saved_searches": [ + { + "ownership": "external", + "purpose": "Precompute shared database health aggregates for high-viewer dashboards.", + "rationale": "Scheduled aggregation avoids one expensive trace query per viewer.", + "recommended_schedule": "*/5 * * * *", + "reference": "Platform - Database Health Rollup", + "source_indexes": [ + "otel_metrics", + "otel_traces" + ] + } + ], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/examples/catalog/security_operations_overview/README.md b/examples/catalog/security_operations_overview/README.md new file mode 100644 index 0000000..d382640 --- /dev/null +++ b/examples/catalog/security_operations_overview/README.md @@ -0,0 +1,28 @@ +# Security Operations Overview + +- Catalog ID: `security_operations_overview` +- Priority: `medium` +- Checked target: Splunk Enterprise `9.4.3` +- Telemetry contract: `portable-observability-v1` + +This generated example applies four_golden_signals to event telemetry. It assumes the logical +indexes `security_events`, `platform_events`; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +- `viz_auth_failures` — Track authentication failure volume without exposing user values. +- `viz_high_risk_events` — Rank current high-risk security signals. +- `viz_noisy_detections` — Find detections producing disproportionate volume. +- `viz_ingestion_lag` — Detect delayed security telemetry. +- `viz_security_platform_health` — Track collection and detection pipeline errors. + +## Rebuild + +```console +uv run python examples/catalog/security_operations_overview/builder.py +uv run splunk-studio catalog build security_operations_overview --target 9.4.3 --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. diff --git a/examples/catalog/security_operations_overview/builder.py b/examples/catalog/security_operations_overview/builder.py new file mode 100644 index 0000000..a742b81 --- /dev/null +++ b/examples/catalog/security_operations_overview/builder.py @@ -0,0 +1,14 @@ +"""Build the checked security_operations_overview catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = "security_operations_overview" +TARGET = "9.4.3" + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/catalog/security_operations_overview/dashboard.json b/examples/catalog/security_operations_overview/dashboard.json new file mode 100644 index 0000000..55f04dc --- /dev/null +++ b/examples/catalog/security_operations_overview/dashboard.json @@ -0,0 +1,204 @@ +{ + "applicationProperties": { + "collapseNavigation": false, + "downsampleVisualizations": true + }, + "dataSources": { + "ds_auth_failures": { + "name": "Authentication failures", + "options": { + "query": "index=security_events event.name=authentication.failure | timechart span=5m count AS failures by security.severity" + }, + "type": "ds.search" + }, + "ds_high_risk_events": { + "name": "High-risk events", + "options": { + "query": "index=security_events risk.score>=70 | stats count AS events max(risk.score) AS risk by event.name security.severity | sort - risk | head 20" + }, + "type": "ds.search" + }, + "ds_ingestion_lag": { + "name": "Ingestion lag", + "options": { + "query": "index=security_events ingest_lag_seconds=* | timechart span=5m perc95(ingest_lag_seconds) AS p95_lag_seconds" + }, + "type": "ds.search" + }, + "ds_noisy_detections": { + "name": "Noisy detections", + "options": { + "query": "index=security_events event.name=detection.triggered | stats count AS triggers by detection.name security.severity | sort - triggers | head 20" + }, + "type": "ds.search" + }, + "ds_security_platform_health": { + "name": "Security platform health", + "options": { + "query": "index=platform_events event.name=security.pipeline.error | stats count AS errors by service.name | sort - errors" + }, + "type": "ds.search" + } + }, + "defaults": { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$" + } + } + } + }, + "visualizations": { + "global": { + "showProgressBar": true + } + } + }, + "description": "Authentication failures, risk, noisy detections, and ingestion health.", + "expressions": {}, + "inputs": { + "input_global_time": { + "context": {}, + "dataSources": {}, + "options": { + "defaultValue": "-24h@h,now", + "token": "global_time" + }, + "title": "Global Time Range", + "type": "input.timerange" + } + }, + "layout": { + "globalInputs": [ + "input_global_time" + ], + "layoutDefinitions": { + "layout_main": { + "options": { + "height": 860, + "width": 1440 + }, + "structure": [ + { + "item": "viz_auth_failures", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_high_risk_events", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 20 + }, + "type": "block" + }, + { + "item": "viz_noisy_detections", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_ingestion_lag", + "position": { + "h": 260, + "w": 690, + "x": 730, + "y": 300 + }, + "type": "block" + }, + { + "item": "viz_security_platform_health", + "position": { + "h": 260, + "w": 690, + "x": 20, + "y": 580 + }, + "type": "block" + } + ], + "type": "absolute" + } + }, + "options": {}, + "tabs": { + "items": [ + { + "label": "Overview", + "layoutId": "layout_main" + } + ], + "options": {} + } + }, + "title": "Security Operations Overview", + "version": "2", + "visualizations": { + "viz_auth_failures": { + "context": {}, + "dataSources": { + "primary": "ds_auth_failures" + }, + "description": "Track authentication failure volume without exposing user values.", + "options": {}, + "title": "Authentication failures", + "type": "splunk.line" + }, + "viz_high_risk_events": { + "context": {}, + "dataSources": { + "primary": "ds_high_risk_events" + }, + "description": "Rank current high-risk security signals.", + "options": {}, + "title": "High-risk events", + "type": "splunk.table" + }, + "viz_ingestion_lag": { + "context": {}, + "dataSources": { + "primary": "ds_ingestion_lag" + }, + "description": "Detect delayed security telemetry.", + "options": {}, + "title": "Ingestion lag", + "type": "splunk.line" + }, + "viz_noisy_detections": { + "context": {}, + "dataSources": { + "primary": "ds_noisy_detections" + }, + "description": "Find detections producing disproportionate volume.", + "options": {}, + "title": "Noisy detections", + "type": "splunk.table" + }, + "viz_security_platform_health": { + "context": {}, + "dataSources": { + "primary": "ds_security_platform_health" + }, + "description": "Track collection and detection pipeline errors.", + "options": {}, + "title": "Security platform health", + "type": "splunk.table" + } + } +} diff --git a/examples/catalog/security_operations_overview/manifest.json b/examples/catalog/security_operations_overview/manifest.json new file mode 100644 index 0000000..dad13d5 --- /dev/null +++ b/examples/catalog/security_operations_overview/manifest.json @@ -0,0 +1,117 @@ +{ + "assumptions": [ + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred." + ], + "canonical_json_bytes": 3521, + "definition_sha256": "dcdd9cf4cb415d22d2475de2ddc2ef7a8ec340dcd5556a1f5cea44d9916d3862", + "engine_evidence": "temporal_surrogate", + "example_id": "security_operations_overview", + "generator_version": "0.2.0", + "minimum_target": "9.4.3", + "native_validation": "valid", + "official_engine_id": "dashboard-27.5.1", + "official_engine_version": "27.5.1", + "official_validation": "not_run", + "panels": [ + { + "data_source_ids": [ + "ds_auth_failures" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_auth_failures", + "purpose": "Track authentication failure volume without exposing user values.", + "required_fields": [ + "event.name", + "detection.name", + "security.severity" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_high_risk_events" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_high_risk_events", + "purpose": "Rank current high-risk security signals.", + "required_fields": [ + "risk.score", + "event.name", + "security.severity" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_noisy_detections" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_noisy_detections", + "purpose": "Find detections producing disproportionate volume.", + "required_fields": [ + "event.name", + "security.severity" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_ingestion_lag" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_ingestion_lag", + "purpose": "Detect delayed security telemetry.", + "required_fields": [ + "ingest_lag_seconds" + ], + "signals": [ + "event" + ] + }, + { + "data_source_ids": [ + "ds_security_platform_health" + ], + "drilldown_signals": [], + "frameworks": [ + "four_golden_signals" + ], + "panel_id": "viz_security_platform_health", + "purpose": "Track collection and detection pipeline errors.", + "required_fields": [ + "event.name", + "service.name" + ], + "signals": [ + "event" + ] + } + ], + "saved_searches": [], + "schema_version": "dashboard-evidence/v1", + "target": { + "product": "splunk-enterprise", + "version": "9.4.3" + }, + "telemetry_contract": "portable-observability-v1" +} diff --git a/pyproject.toml b/pyproject.toml index b51a83b..1026e82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "hatchling.build" [project] name = "splunk-dashboard-studio-python" -version = "0.1.0" +version = "0.2.0" description = "Deterministic, version-aware Splunk Enterprise Dashboard Studio tooling for Python" readme = "README.md" -requires-python = ">=3.14" +requires-python = ">=3.12" license = "Apache-2.0" authors = [{ name = "Kmosoti" }] keywords = ["splunk", "dashboard-studio", "pydantic", "spl", "validation"] @@ -16,6 +16,8 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Typing :: Typed", ] @@ -45,17 +47,19 @@ packages = ["splunk_dashboard_studio"] [tool.hatch.build.targets.sdist] include = [ + "/CHANGELOG.md", "/LICENSE", "/README.md", + "/docs", + "/examples", "/pyproject.toml", + "/scripts", "/splunk_dashboard_studio", "/tests", ] exclude = [ "/.github", "/benchmarks", - "/docs", - "/examples", "/node_modules", "**/*.js", "**/*.cjs", @@ -76,7 +80,7 @@ fail_under = 85 show_missing = true [tool.ruff] -target-version = "py314" +target-version = "py312" line-length = 100 [tool.ruff.lint] @@ -86,7 +90,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] "tests/**/*.py" = ["S101"] [tool.mypy] -python_version = "3.14" +python_version = "3.12" strict = true packages = ["splunk_dashboard_studio"] plugins = ["pydantic.mypy"] diff --git a/scripts/check_engine_locks.py b/scripts/check_engine_locks.py new file mode 100644 index 0000000..b8fc95b --- /dev/null +++ b/scripts/check_engine_locks.py @@ -0,0 +1,111 @@ +"""Check profile metadata against every isolated CI-only NPM engine lock.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from splunk_dashboard_studio.profiles import available_profiles + +ROOT = Path(__file__).resolve().parents[1] +ENGINES = ROOT / ".github" / "ci" / "npm-validator" / "engines" + + +def _load(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"Expected a JSON object in {path}") + return value + + +def check_engine_locks() -> list[dict[str, str]]: + failures: list[dict[str, str]] = [] + expected_engines = { + profile.engine.engine_id: profile.engine for profile in available_profiles() + } + actual_directories = {path.name for path in ENGINES.iterdir() if path.is_dir()} + + for extra in sorted(actual_directories - set(expected_engines)): + failures.append({"engine": extra, "field": "directory", "message": "unprofiled lock"}) + for missing in sorted(set(expected_engines) - actual_directories): + failures.append({"engine": missing, "field": "directory", "message": "missing lock"}) + + for engine_id, engine in sorted(expected_engines.items()): + directory = ENGINES / engine_id + if not directory.is_dir(): + continue + package = _load(directory / "package.json") + lock = _load(directory / "package-lock.json") + expected = { + "@splunk/dashboard-definition": engine.dashboard_version, + "@splunk/dashboard-presets": engine.dashboard_version, + "@splunk/dashboard-validation": engine.dashboard_version, + "@splunk/visualization-encoding": engine.visualization_encoding_version, + } + dependencies = package.get("dependencies") + lock_packages = lock.get("packages") + if package.get("private") is not True: + failures.append({"engine": engine_id, "field": "private", "message": "must be true"}) + if not isinstance(dependencies, dict): + failures.append( + {"engine": engine_id, "field": "dependencies", "message": "must be an object"} + ) + continue + if not isinstance(lock_packages, dict): + failures.append( + {"engine": engine_id, "field": "packages", "message": "must be an object"} + ) + continue + root_lock = lock_packages.get("") + root_dependencies = root_lock.get("dependencies") if isinstance(root_lock, dict) else None + for dependency, version in expected.items(): + if dependencies.get(dependency) != version: + failures.append( + { + "engine": engine_id, + "field": f"package.json:{dependency}", + "message": f"expected exact version {version!r}", + } + ) + if ( + not isinstance(root_dependencies, dict) + or root_dependencies.get(dependency) != version + ): + failures.append( + { + "engine": engine_id, + "field": f"package-lock.json:root:{dependency}", + "message": f"expected exact version {version!r}", + } + ) + locked = lock_packages.get(f"node_modules/{dependency}") + locked_version = locked.get("version") if isinstance(locked, dict) else None + if locked_version != version: + failures.append( + { + "engine": engine_id, + "field": f"package-lock.json:{dependency}", + "message": f"expected resolved version {version!r}", + } + ) + return failures + + +def main() -> int: + failures = check_engine_locks() + print( + json.dumps( + { + "checked": len({profile.engine.engine_id for profile in available_profiles()}), + "failures": failures, + }, + indent=2, + sort_keys=True, + ) + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_examples.py b/scripts/check_examples.py new file mode 100644 index 0000000..a86234c --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,149 @@ +"""Generate or verify checked catalog examples at each minimum supported target.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from splunk_dashboard_studio.catalog import build_catalog_bundle, catalog_entries +from splunk_dashboard_studio.contracts import CatalogEntry +from splunk_dashboard_studio.generation import canonical_json + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_ROOT = ROOT / "examples" / "catalog" + + +def _json(value: object) -> str: + if hasattr(value, "model_dump"): + value = value.model_dump(mode="json", by_alias=True) + return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def _builder(example_id: str, target: str) -> str: + encoded_id = json.dumps(example_id) + encoded_target = json.dumps(target) + return f'''"""Build the checked {example_id} catalog dashboard.""" + +from splunk_dashboard_studio import build_catalog_dashboard, canonical_json + +EXAMPLE_ID = {encoded_id} +TARGET = {encoded_target} + + +def main() -> None: + print(canonical_json(build_catalog_dashboard(EXAMPLE_ID, TARGET), indent=2)) + + +if __name__ == "__main__": + main() +''' + + +def _readme(entry: CatalogEntry) -> str: + example_id = entry.example_id + title = entry.title + target = str(entry.minimum_target) + frameworks = ", ".join(framework.value for framework in entry.frameworks) + signals = ", ".join(signal.value for signal in entry.signals) + indexes = ", ".join(f"`{index}`" for index in entry.logical_indexes) + panels = "\n".join(f"- `{panel.panel_id}` — {panel.purpose}" for panel in entry.panels) + return f"""# {title} + +- Catalog ID: `{example_id}` +- Priority: `{entry.priority}` +- Checked target: Splunk Enterprise `{target}` +- Telemetry contract: `{entry.telemetry_contract}` + +This generated example applies {frameworks} to {signals} telemetry. It assumes the logical +indexes {indexes}; map those names and the required semantic fields to your local Splunk app +before deployment. It does not create saved searches, publish a view, or include sample data. + +## Panels + +{panels} + +## Rebuild + +```console +uv run python examples/catalog/{example_id}/builder.py +uv run splunk-studio catalog build {example_id} --target {target} --artifact bundle +``` + +`dashboard.json` is the canonical minimum-target definition. `manifest.json` records its hash, +native validation status, validator evidence grade, provenance, and deferred live-test caveat. +""" + + +def expected_files() -> dict[Path, str]: + expected: dict[Path, str] = {} + for entry in catalog_entries(): + target = str(entry.minimum_target) + bundle = build_catalog_bundle(entry.example_id, target) + directory = CATALOG_ROOT / entry.example_id + expected[directory / "builder.py"] = _builder(entry.example_id, target) + expected[directory / "dashboard.json"] = ( + canonical_json( + bundle.definition, + indent=2, + ) + + "\n" + ) + expected[directory / "manifest.json"] = _json(bundle.manifest) + expected[directory / "README.md"] = _readme(entry) + return expected + + +def check_examples(*, write: bool) -> list[dict[str, str]]: + failures: list[dict[str, str]] = [] + expected = expected_files() + for path, content in sorted(expected.items()): + relative = str(path.relative_to(ROOT)) + if write: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + continue + if not path.exists(): + failures.append({"path": relative, "reason": "missing"}) + elif path.read_text(encoding="utf-8") != content: + failures.append({"path": relative, "reason": "drifted"}) + + if CATALOG_ROOT.exists(): + allowed = set(expected) + for path in sorted( + candidate + for candidate in CATALOG_ROOT.rglob("*") + if candidate.is_file() + and "__pycache__" not in candidate.parts + and candidate.suffix != ".pyc" + ): + if path not in allowed: + failures.append( + {"path": str(path.relative_to(ROOT)), "reason": "unexpected generated file"} + ) + return failures + + +def main() -> int: + parser = argparse.ArgumentParser() + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true", help="Verify generated examples") + mode.add_argument("--write", action="store_true", help="Regenerate examples") + arguments = parser.parse_args() + failures = check_examples(write=arguments.write) + print( + json.dumps( + { + "mode": "write" if arguments.write else "check", + "dashboards": len(catalog_entries()), + "failures": failures, + }, + indent=2, + sort_keys=True, + ) + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_evidence.py b/scripts/release_evidence.py new file mode 100644 index 0000000..6a3aa3f --- /dev/null +++ b/scripts/release_evidence.py @@ -0,0 +1,173 @@ +"""Verify release identity and create deterministic checksums and evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import tomllib +from pathlib import Path +from typing import Any + +from splunk_dashboard_studio import __version__ +from splunk_dashboard_studio.catalog import build_catalog_bundle, catalog_entries +from splunk_dashboard_studio.profiles import profile_manifest + +ROOT = Path(__file__).resolve().parents[1] + + +def project_metadata() -> dict[str, Any]: + value = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = value.get("project") + if not isinstance(project, dict): + raise ValueError("pyproject.toml must contain [project]") + return project + + +def verify_release_tag(tag: str) -> str: + project = project_metadata() + version = project.get("version") + if not isinstance(version, str): + raise ValueError("project.version must be a string") + if version != __version__: + raise ValueError( + f"pyproject version {version!r} does not match runtime version {__version__!r}" + ) + expected_tag = f"v{version}" + if tag != expected_tag: + raise ValueError(f"Release tag {tag!r} must equal {expected_tag!r}") + return version + + +def _artifact(path: Path) -> dict[str, str | int]: + content = path.read_bytes() + return { + "name": path.name, + "bytes": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + } + + +def build_release_evidence(tag: str, distribution_directory: Path) -> dict[str, Any]: + version = verify_release_tag(tag) + artifacts = sorted( + [ + *distribution_directory.glob("*.whl"), + *distribution_directory.glob("*.tar.gz"), + ] + ) + project = project_metadata() + distribution_name = str(project["name"]).replace("-", "_") + expected_wheel_prefix = f"{distribution_name}-{version}-" + expected_sdist = f"{distribution_name}-{version}.tar.gz" + wheels = [path for path in artifacts if path.suffix == ".whl"] + sdists = [path for path in artifacts if path.name.endswith(".tar.gz")] + if len(wheels) != 1: + raise ValueError("Release evidence requires exactly one wheel") + if len(sdists) != 1: + raise ValueError("Release evidence requires exactly one sdist") + if not wheels[0].name.startswith(expected_wheel_prefix): + raise ValueError( + f"Wheel {wheels[0].name!r} does not match release {distribution_name}-{version}" + ) + if sdists[0].name != expected_sdist: + raise ValueError(f"Sdist {sdists[0].name!r} must equal {expected_sdist!r}") + catalog = [] + for entry in catalog_entries(): + bundle = build_catalog_bundle(entry.example_id, str(entry.minimum_target)) + catalog.append( + { + "example_id": entry.example_id, + "minimum_target": str(entry.minimum_target), + "definition_sha256": bundle.manifest.definition_sha256, + } + ) + return { + "schema_version": "release-evidence/v1", + "package": { + "name": project["name"], + "version": version, + "requires_python": project["requires-python"], + "tag": tag, + }, + "artifacts": [_artifact(path) for path in artifacts], + "catalog": catalog, + "compatibility": profile_manifest(), + "verification": { + "native": [ + "ruff format --check", + "ruff check", + "mypy", + "pytest with branch coverage", + "native compatibility corpus", + "checked examples", + "distribution inspection", + ], + "official_engines": "validated by the release workflow matrix", + "live_splunk_roundtrip": "deferred", + }, + } + + +def _write_outputs( + evidence: dict[str, Any], + output: Path, + checksums_output: Path, +) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(evidence, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + artifacts = evidence["artifacts"] + checksums_output.parent.mkdir(parents=True, exist_ok=True) + checksums_output.write_text( + "".join(f"{artifact['sha256']} {artifact['name']}\n" for artifact in artifacts), + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--tag", required=True) + parser.add_argument("--dist", type=Path, default=ROOT / "dist") + parser.add_argument("--output", type=Path, default=ROOT / "release" / "evidence.json") + parser.add_argument( + "--checksums-output", + type=Path, + default=ROOT / "release" / "SHA256SUMS", + ) + parser.add_argument("--check-version-only", action="store_true") + arguments = parser.parse_args() + try: + version = verify_release_tag(arguments.tag) + if arguments.check_version_only: + print(json.dumps({"status": "valid", "tag": arguments.tag, "version": version})) + return 0 + evidence = build_release_evidence(arguments.tag, arguments.dist) + _write_outputs(evidence, arguments.output, arguments.checksums_output) + except (OSError, ValueError, KeyError) as error: + print( + json.dumps( + {"status": "error", "error_type": type(error).__name__, "message": str(error)}, + sort_keys=True, + ) + ) + return 2 + print( + json.dumps( + { + "status": "valid", + "tag": arguments.tag, + "artifacts": len(evidence["artifacts"]), + "output": str(arguments.output), + "checksums": str(arguments.checksums_output), + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/splunk_dashboard_studio/__init__.py b/splunk_dashboard_studio/__init__.py index 2cd3a77..a022ee3 100644 --- a/splunk_dashboard_studio/__init__.py +++ b/splunk_dashboard_studio/__init__.py @@ -1,5 +1,37 @@ """Version-aware Splunk Enterprise Dashboard Studio generation and validation.""" +from splunk_dashboard_studio._version import __version__ +from splunk_dashboard_studio.catalog import ( + CatalogEntryNotFound, + CatalogTargetUnsupported, + build_catalog_bundle, + build_catalog_dashboard, + catalog_entries, + portable_telemetry_contract, +) +from splunk_dashboard_studio.codec import ( + RoundTripComparison, + RoundTripDifference, + StudioView, + StudioViewCodecError, + compare_roundtrip, + decode_view_xml, + encode_view_xml, +) +from splunk_dashboard_studio.contracts import ( + AgentSkill, + CatalogEntry, + DashboardArtifactBundle, + DashboardEvidenceManifest, + ObservabilityFramework, + PanelProvenance, + SavedSearchSpec, + SkillDescriptor, + TelemetryContract, + TelemetryField, + TelemetrySignal, + observability_skill_descriptors, +) from splunk_dashboard_studio.generation import DashboardBuilder, canonical_json from splunk_dashboard_studio.graph import ( GraphAnalysis, @@ -14,21 +46,45 @@ from splunk_dashboard_studio.version import EnterpriseVersion, TargetPlatform __all__ = [ + "AgentSkill", + "CatalogEntry", + "CatalogEntryNotFound", + "CatalogTargetUnsupported", + "DashboardArtifactBundle", "DashboardBuilder", "DashboardDefinition", + "DashboardEvidenceManifest", "EnterpriseProfile", "EnterpriseVersion", "GraphAnalysis", + "ObservabilityFramework", + "PanelProvenance", + "RoundTripComparison", + "RoundTripDifference", + "SavedSearchSpec", "SearchOptimizationPlan", + "SkillDescriptor", + "StudioView", + "StudioViewCodecError", "TargetPlatform", + "TelemetryContract", + "TelemetryField", + "TelemetrySignal", "ValidationIssue", "ValidationReport", + "__version__", "analyze_search_graph", "available_profiles", + "build_catalog_bundle", + "build_catalog_dashboard", "canonical_json", + "catalog_entries", + "compare_roundtrip", + "decode_view_xml", + "encode_view_xml", + "observability_skill_descriptors", "plan_search_optimizations", + "portable_telemetry_contract", "profile_for", "validate_dashboard", ] - -__version__ = "0.1.0" diff --git a/splunk_dashboard_studio/_version.py b/splunk_dashboard_studio/_version.py new file mode 100644 index 0000000..020b4ea --- /dev/null +++ b/splunk_dashboard_studio/_version.py @@ -0,0 +1,3 @@ +"""Single-source runtime package version.""" + +__version__ = "0.2.0" diff --git a/splunk_dashboard_studio/catalog.py b/splunk_dashboard_studio/catalog.py new file mode 100644 index 0000000..5713afe --- /dev/null +++ b/splunk_dashboard_studio/catalog.py @@ -0,0 +1,1247 @@ +"""Packaged observability dashboard catalog and artifact generation.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Literal + +from splunk_dashboard_studio._version import __version__ +from splunk_dashboard_studio.contracts import ( + CatalogEntry, + DashboardArtifactBundle, + DashboardEvidenceManifest, + ObservabilityFramework, + PanelProvenance, + SavedSearchSpec, + TelemetryContract, + TelemetryField, + TelemetrySignal, +) +from splunk_dashboard_studio.generation import DashboardBuilder, canonical_json +from splunk_dashboard_studio.models import DashboardDefinition +from splunk_dashboard_studio.profiles import profile_for +from splunk_dashboard_studio.version import EnterpriseVersion, TargetPlatform + +PORTABLE_TELEMETRY_CONTRACT_ID = "portable-observability-v1" + + +class CatalogEntryNotFound(ValueError): + pass + + +class CatalogTargetUnsupported(ValueError): + pass + + +def _field( + name: str, + signals: tuple[TelemetrySignal, ...], + description: str, + *, + semantic_convention: str | None = None, + unit: str | None = None, +) -> TelemetryField: + return TelemetryField( + name=name, + signals=signals, + description=description, + semantic_convention=semantic_convention, + unit=unit, + ) + + +_PORTABLE_TELEMETRY_CONTRACT = TelemetryContract( + contract_id=PORTABLE_TELEMETRY_CONTRACT_ID, + description=( + "Portable normalized telemetry for the catalog. Semantic-convention names are used " + "where stable; catalog-specific derived fields are explicitly identified." + ), + logical_indexes={ + "otel_metrics": TelemetrySignal.METRIC, + "otel_logs": TelemetrySignal.LOG, + "otel_traces": TelemetrySignal.TRACE, + "platform_events": TelemetrySignal.EVENT, + "batch_events": TelemetrySignal.EVENT, + "cicd_events": TelemetrySignal.EVENT, + "security_events": TelemetrySignal.EVENT, + "business_events": TelemetrySignal.EVENT, + }, + fields=( + _field( + "service.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Logical service identity.", + semantic_convention="service.name", + ), + _field( + "deployment.environment.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Deployment environment identity.", + semantic_convention="deployment.environment.name", + ), + _field( + "http.route", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Low-cardinality HTTP route template.", + semantic_convention="http.route", + ), + _field( + "http.request.method", + (TelemetrySignal.LOG, TelemetrySignal.TRACE), + "HTTP request method.", + semantic_convention="http.request.method", + ), + _field( + "http.response.status_code", + (TelemetrySignal.LOG, TelemetrySignal.TRACE), + "HTTP response status code.", + semantic_convention="http.response.status_code", + ), + _field( + "duration_ms", + (TelemetrySignal.EVENT, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Normalized operation duration in milliseconds.", + unit="ms", + ), + _field( + "trace_id", + (TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Trace correlation identifier.", + semantic_convention="trace_id", + ), + _field( + "span_id", + (TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Span correlation identifier.", + semantic_convention="span_id", + ), + _field( + "peer.service", + (TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Normalized downstream dependency identity.", + ), + _field( + "tenant.id", + (TelemetrySignal.LOG,), + "Pseudonymous tenant identity for aggregate traffic analysis.", + ), + _field( + "k8s.namespace.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Kubernetes namespace identity.", + semantic_convention="k8s.namespace.name", + ), + _field( + "k8s.workload.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Normalized Kubernetes workload identity.", + ), + _field( + "k8s.pod.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Kubernetes pod identity.", + semantic_convention="k8s.pod.name", + ), + _field( + "host.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG), + "Host identity.", + semantic_convention="host.name", + ), + _field( + "cloud.provider", + (TelemetrySignal.METRIC, TelemetrySignal.EVENT), + "Cloud provider identity.", + semantic_convention="cloud.provider", + ), + _field( + "cloud.region", + (TelemetrySignal.METRIC, TelemetrySignal.EVENT), + "Cloud region identity.", + semantic_convention="cloud.region", + ), + _field( + "cloud.availability_zone", + (TelemetrySignal.METRIC, TelemetrySignal.EVENT), + "Cloud availability-zone identity.", + semantic_convention="cloud.availability_zone", + ), + _field( + "db.system.name", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Database system identity.", + semantic_convention="db.system.name", + ), + _field( + "db.namespace", + (TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Database namespace identity.", + semantic_convention="db.namespace", + ), + _field( + "db.operation.name", + (TelemetrySignal.LOG, TelemetrySignal.TRACE), + "Database operation name.", + semantic_convention="db.operation.name", + ), + _field( + "metric_name", + (TelemetrySignal.METRIC,), + "Normalized metric identity used by Splunk metric indexes.", + ), + _field( + "job.name", + (TelemetrySignal.EVENT,), + "Batch or scheduled job identity.", + ), + _field( + "job.status", + (TelemetrySignal.EVENT,), + "Normalized job outcome.", + ), + _field( + "scheduled_time", + (TelemetrySignal.EVENT,), + "Expected execution time as an epoch value.", + unit="s", + ), + _field( + "queue.backlog", + (TelemetrySignal.EVENT, TelemetrySignal.METRIC), + "Pending work count.", + unit="{item}", + ), + _field( + "cicd.pipeline.name", + (TelemetrySignal.EVENT,), + "CI/CD pipeline identity.", + ), + _field( + "cicd.job.name", + (TelemetrySignal.EVENT,), + "CI/CD job identity.", + ), + _field( + "cicd.status", + (TelemetrySignal.EVENT,), + "Normalized CI/CD outcome.", + ), + _field( + "change.type", + (TelemetrySignal.EVENT,), + "Deployment or delivery change category.", + ), + _field( + "event.name", + (TelemetrySignal.EVENT, TelemetrySignal.LOG), + "Normalized event identity.", + semantic_convention="event.name", + ), + _field( + "security.severity", + (TelemetrySignal.EVENT,), + "Normalized security severity.", + ), + _field( + "detection.name", + (TelemetrySignal.EVENT,), + "Security detection identity.", + ), + _field( + "risk.score", + (TelemetrySignal.EVENT,), + "Normalized security risk score.", + ), + _field( + "ingest_lag_seconds", + (TelemetrySignal.EVENT,), + "Telemetry ingestion delay.", + unit="s", + ), + _field( + "business.journey.name", + (TelemetrySignal.EVENT,), + "Business journey identity.", + ), + _field( + "business.outcome", + (TelemetrySignal.EVENT,), + "Normalized journey outcome.", + ), + _field( + "business.value", + (TelemetrySignal.EVENT,), + "Optional numeric journey value.", + ), + ), + notes=( + "Metric names referenced in SPL are normalized names, not a promise that every " + "upstream collector emits them without mapping.", + "Fields without semantic_convention are catalog-level normalization requirements.", + "Logical index names are deployment-time aliases and may be mapped to local indexes.", + ), +) + + +@dataclass(frozen=True) +class _PanelSpec: + slug: str + title: str + purpose: str + query: str + visualization_type: str + frameworks: tuple[ObservabilityFramework, ...] + signals: tuple[TelemetrySignal, ...] + required_fields: tuple[str, ...] + drilldown_signals: tuple[TelemetrySignal, ...] = () + + +@dataclass(frozen=True) +class _DashboardSpec: + example_id: str + priority: Literal["high", "medium"] + title: str + description: str + minimum_target: str + frameworks: tuple[ObservabilityFramework, ...] + signals: tuple[TelemetrySignal, ...] + logical_indexes: tuple[str, ...] + panels: tuple[_PanelSpec, ...] + saved_searches: tuple[SavedSearchSpec, ...] = () + tags: tuple[str, ...] = () + + +def _panel( + slug: str, + title: str, + purpose: str, + query: str, + visualization_type: str, + frameworks: tuple[ObservabilityFramework, ...], + signals: tuple[TelemetrySignal, ...], + required_fields: tuple[str, ...], + *, + drilldown_signals: tuple[TelemetrySignal, ...] = (), +) -> _PanelSpec: + return _PanelSpec( + slug=slug, + title=title, + purpose=purpose, + query=query, + visualization_type=visualization_type, + frameworks=frameworks, + signals=signals, + required_fields=required_fields, + drilldown_signals=drilldown_signals, + ) + + +RED = (ObservabilityFramework.RED,) +USE = (ObservabilityFramework.USE,) +GOLDEN = (ObservabilityFramework.FOUR_GOLDEN_SIGNALS,) +SLO = (ObservabilityFramework.SLI_SLO,) + + +_SPECS = ( + _DashboardSpec( + example_id="kubernetes_workload_health", + priority="high", + title="Kubernetes Workload Health", + description="Namespace and workload RED signals with pod-level resource saturation.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.RED, ObservabilityFramework.USE), + signals=(TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + logical_indexes=("otel_metrics", "otel_logs", "otel_traces"), + panels=( + _panel( + "workload_error_rate", + "Workload error rate", + "Identify workloads whose request failures are rising.", + "index=otel_logs k8s.namespace.name=* | stats count AS requests " + "count(eval(http.response.status_code>=500)) AS errors by " + "k8s.namespace.name k8s.workload.name | eval value=round(errors*100/requests,2)", + "splunk.singlevalue", + RED, + (TelemetrySignal.LOG,), + ("k8s.namespace.name", "k8s.workload.name", "http.response.status_code"), + drilldown_signals=(TelemetrySignal.TRACE,), + ), + _panel( + "workload_p95_latency", + "Workload p95 latency", + "Track user-visible workload latency over time.", + "index=otel_traces k8s.namespace.name=* | timechart span=5m " + "perc95(duration_ms) AS p95_ms by k8s.workload.name", + "splunk.line", + RED, + (TelemetrySignal.TRACE,), + ("k8s.namespace.name", "k8s.workload.name", "duration_ms"), + drilldown_signals=(TelemetrySignal.LOG,), + ), + _panel( + "pod_restarts", + "Pod restarts", + "Surface unstable pods and workload churn.", + "| mstats sum(_value) AS restarts WHERE index=otel_metrics " + 'metric_name="k8s.pod.restart.count" BY k8s.namespace.name k8s.pod.name span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "k8s.namespace.name", "k8s.pod.name"), + ), + _panel( + "resource_saturation", + "CPU and memory saturation", + "Locate pods approaching resource limits.", + "| mstats max(_value) AS saturation WHERE index=otel_metrics " + 'metric_name IN ("k8s.container.cpu.utilization",' + '"k8s.container.memory.utilization") BY metric_name k8s.pod.name span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "k8s.pod.name"), + ), + _panel( + "failing_pods", + "Top failing pods", + "Rank pods emitting the most error events.", + "index=otel_logs k8s.pod.name=* http.response.status_code>=500 " + "| stats count AS failures by k8s.namespace.name k8s.pod.name " + "| sort - failures | head 20", + "splunk.table", + RED, + (TelemetrySignal.LOG,), + ("k8s.namespace.name", "k8s.pod.name", "http.response.status_code"), + drilldown_signals=(TelemetrySignal.TRACE,), + ), + ), + tags=("kubernetes", "platform", "red", "use"), + ), + _DashboardSpec( + example_id="ec2_host_capacity", + priority="high", + title="EC2 and Host Capacity", + description="USE-oriented host and autoscaling capacity overview.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.USE,), + signals=(TelemetrySignal.METRIC, TelemetrySignal.LOG), + logical_indexes=("otel_metrics", "otel_logs", "platform_events"), + panels=( + _panel( + "host_cpu", + "CPU utilization", + "Track sustained host CPU utilization.", + "| mstats avg(_value) AS utilization WHERE index=otel_metrics " + 'metric_name="system.cpu.utilization" BY host.name span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "host.name"), + ), + _panel( + "host_memory", + "Memory utilization", + "Find hosts with memory pressure.", + "| mstats max(_value) AS utilization WHERE index=otel_metrics " + 'metric_name="system.memory.utilization" BY host.name span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "host.name"), + ), + _panel( + "host_disk", + "Disk utilization", + "Identify full or rapidly filling filesystems.", + "| mstats max(_value) AS utilization WHERE index=otel_metrics " + 'metric_name="system.filesystem.utilization" BY host.name span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "host.name"), + ), + _panel( + "host_network_load", + "Network and load", + "Compare network throughput with system load.", + "| mstats avg(_value) AS value WHERE index=otel_metrics metric_name IN " + '("system.network.io","system.cpu.load_average.15m") ' + "BY metric_name host.name span=5m", + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "host.name"), + ), + _panel( + "host_errors", + "Host errors", + "Rank hosts by operating-system and agent errors.", + "index=otel_logs host.name=* event.name=*error* | stats count AS errors " + "by host.name event.name | sort - errors | head 20", + "splunk.table", + USE, + (TelemetrySignal.LOG,), + ("host.name", "event.name"), + ), + ), + tags=("aws", "ec2", "hosts", "capacity", "use"), + ), + _DashboardSpec( + example_id="rds_database_health", + priority="high", + title="RDS and Database Health", + description="Database latency, concurrency, errors, and storage pressure.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.RED, ObservabilityFramework.USE), + signals=(TelemetrySignal.METRIC, TelemetrySignal.LOG, TelemetrySignal.TRACE), + logical_indexes=("otel_metrics", "otel_logs", "otel_traces"), + panels=( + _panel( + "database_latency", + "Operation p95 latency", + "Track slow database operations by namespace.", + "index=otel_traces db.system.name=* | timechart span=5m " + "perc95(duration_ms) AS p95_ms by db.namespace", + "splunk.line", + RED, + (TelemetrySignal.TRACE,), + ("db.system.name", "db.namespace", "duration_ms"), + drilldown_signals=(TelemetrySignal.LOG,), + ), + _panel( + "database_connections", + "Connection utilization", + "Find databases approaching connection limits.", + "| mstats max(_value) AS connections WHERE index=otel_metrics " + 'metric_name="db.client.connections.usage" BY db.system.name db.namespace span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "db.system.name", "db.namespace"), + ), + _panel( + "database_locks", + "Lock pressure", + "Surface lock waits and contention.", + "| mstats max(_value) AS lock_waits WHERE index=otel_metrics " + 'metric_name="db.lock.waits" BY db.system.name db.namespace span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "db.system.name", "db.namespace"), + ), + _panel( + "database_storage", + "Storage pressure", + "Track remaining storage and write pressure.", + "| mstats min(_value) AS remaining WHERE index=otel_metrics " + 'metric_name="db.storage.remaining" BY db.system.name db.namespace span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "db.system.name", "db.namespace"), + ), + _panel( + "replication_lag", + "Replication lag", + "Detect replicas falling behind primary databases.", + "| mstats max(_value) AS lag_seconds WHERE index=otel_metrics " + 'metric_name="db.replication.lag" BY db.system.name db.namespace span=5m', + "splunk.line", + USE, + (TelemetrySignal.METRIC,), + ("metric_name", "db.system.name", "db.namespace"), + ), + ), + saved_searches=( + SavedSearchSpec( + reference="Platform - Database Health Rollup", + purpose="Precompute shared database health aggregates for high-viewer dashboards.", + rationale="Scheduled aggregation avoids one expensive trace query per viewer.", + recommended_schedule="*/5 * * * *", + source_indexes=("otel_metrics", "otel_traces"), + ), + ), + tags=("aws", "rds", "database", "red", "use"), + ), + _DashboardSpec( + example_id="load_balancer_edge_health", + priority="high", + title="Load Balancer Edge Health", + description="Four Golden Signals for ingress and backend target health.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.FOUR_GOLDEN_SIGNALS,), + signals=(TelemetrySignal.LOG, TelemetrySignal.METRIC), + logical_indexes=("otel_logs", "otel_metrics"), + panels=( + _panel( + "edge_traffic", + "Request traffic", + "Track ingress demand over time.", + "index=otel_logs service.name=load-balancer | timechart span=5m count AS requests", + "splunk.line", + GOLDEN, + (TelemetrySignal.LOG,), + ("service.name",), + ), + _panel( + "edge_errors", + "4xx and 5xx rate", + "Separate client and server failure rates.", + "index=otel_logs service.name=load-balancer | timechart span=5m " + "count(eval(http.response.status_code>=400 AND " + "http.response.status_code<500)) AS 4xx " + "count(eval(http.response.status_code>=500)) AS 5xx", + "splunk.line", + GOLDEN, + (TelemetrySignal.LOG,), + ("service.name", "http.response.status_code"), + ), + _panel( + "edge_latency", + "p95 and p99 latency", + "Track tail latency at the edge.", + "index=otel_logs service.name=load-balancer | timechart span=5m " + "perc95(duration_ms) AS p95_ms perc99(duration_ms) AS p99_ms", + "splunk.line", + GOLDEN, + (TelemetrySignal.LOG,), + ("service.name", "duration_ms"), + ), + _panel( + "backend_failures", + "Backend target failures", + "Rank unhealthy backend services.", + "index=otel_logs service.name=load-balancer http.response.status_code>=500 " + "| stats count AS failures by peer.service | sort - failures | head 20", + "splunk.table", + GOLDEN, + (TelemetrySignal.LOG,), + ("service.name", "http.response.status_code", "peer.service"), + ), + _panel( + "edge_saturation", + "Capacity saturation", + "Show active connection pressure.", + "| mstats max(_value) AS saturation WHERE index=otel_metrics " + 'metric_name="http.server.active_requests" BY service.name span=5m', + "splunk.line", + GOLDEN, + (TelemetrySignal.METRIC,), + ("metric_name", "service.name"), + ), + ), + tags=("load-balancer", "edge", "golden-signals"), + ), + _DashboardSpec( + example_id="api_gateway_overview", + priority="high", + title="API Gateway Overview", + description="Route and tenant RED signals with authentication and throttling health.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.RED,), + signals=(TelemetrySignal.LOG, TelemetrySignal.TRACE), + logical_indexes=("otel_logs", "otel_traces"), + panels=( + _panel( + "route_red", + "Route RED summary", + "Compare request volume, failures, and latency by route.", + "index=otel_logs service.name=api-gateway | stats count AS requests " + "count(eval(http.response.status_code>=500)) AS errors " + "perc95(duration_ms) AS p95_ms by http.route", + "splunk.table", + RED, + (TelemetrySignal.LOG,), + ("service.name", "http.route", "http.response.status_code", "duration_ms"), + drilldown_signals=(TelemetrySignal.TRACE,), + ), + _panel( + "authentication_failures", + "Authentication failures", + "Track rejected authentication events by route.", + "index=otel_logs service.name=api-gateway event.name=auth.failure " + "| timechart span=5m count AS failures by http.route", + "splunk.line", + RED, + (TelemetrySignal.LOG,), + ("service.name", "event.name", "http.route"), + ), + _panel( + "throttling", + "Throttled requests", + "Detect routes constrained by rate limits.", + "index=otel_logs service.name=api-gateway http.response.status_code=429 " + "| timechart span=5m count AS throttled by http.route", + "splunk.line", + RED, + (TelemetrySignal.LOG,), + ("service.name", "http.response.status_code", "http.route"), + ), + _panel( + "top_tenants", + "Top tenants", + "Rank tenant traffic without exposing raw user identifiers.", + "index=otel_logs service.name=api-gateway tenant.id=* " + "| stats count AS requests by tenant.id | sort - requests | head 20", + "splunk.table", + RED, + (TelemetrySignal.LOG,), + ("service.name", "tenant.id"), + ), + _panel( + "latency_distribution", + "Latency distribution", + "Inspect route-level latency percentiles.", + "index=otel_traces service.name=api-gateway | stats perc50(duration_ms) AS p50_ms " + "perc95(duration_ms) AS p95_ms perc99(duration_ms) AS p99_ms by http.route", + "splunk.table", + RED, + (TelemetrySignal.TRACE,), + ("service.name", "duration_ms", "http.route"), + drilldown_signals=(TelemetrySignal.LOG,), + ), + ), + tags=("api", "gateway", "red"), + ), + _DashboardSpec( + example_id="microservice_service_map", + priority="high", + title="Microservice Service Map", + description="Service RED health, dependency hotspots, changes, and trace drill-through.", + minimum_target="10.4.0", + frameworks=(ObservabilityFramework.RED, ObservabilityFramework.FOUR_GOLDEN_SIGNALS), + signals=( + TelemetrySignal.METRIC, + TelemetrySignal.EVENT, + TelemetrySignal.LOG, + TelemetrySignal.TRACE, + ), + logical_indexes=("otel_metrics", "otel_logs", "otel_traces", "platform_events"), + panels=( + _panel( + "service_red", + "Service RED summary", + "Compare request health across services.", + "index=otel_traces service.name=* | stats count AS requests " + "count(eval(http.response.status_code>=500)) AS errors " + "perc95(duration_ms) AS p95_ms by service.name", + "splunk.table", + RED, + (TelemetrySignal.TRACE,), + ("service.name", "http.response.status_code", "duration_ms"), + drilldown_signals=(TelemetrySignal.LOG,), + ), + _panel( + "dependency_map", + "Dependency map", + "Show request flow between instrumented services.", + "index=otel_traces service.name=* peer.service=* | stats count AS value " + "by service.name peer.service | rename service.name AS source " + "peer.service AS target", + "splunk.networkGraph", + GOLDEN, + (TelemetrySignal.TRACE,), + ("service.name", "peer.service"), + drilldown_signals=(TelemetrySignal.LOG,), + ), + _panel( + "dependency_hotspots", + "Dependency hotspots", + "Rank slow and failing downstream dependencies.", + "index=otel_traces peer.service=* | stats perc95(duration_ms) AS p95_ms " + "count(eval(http.response.status_code>=500)) AS errors " + "by service.name peer.service " + "| sort - p95_ms | head 20", + "splunk.table", + RED, + (TelemetrySignal.TRACE,), + ("service.name", "peer.service", "duration_ms", "http.response.status_code"), + ), + _panel( + "recent_deployments", + "Recent deployments", + "Correlate service health with recent changes.", + "index=platform_events change.type=deployment service.name=* " + "| table _time service.name deployment.environment.name change.type | sort - _time", + "splunk.table", + GOLDEN, + (TelemetrySignal.EVENT,), + ("service.name", "deployment.environment.name", "change.type"), + ), + _panel( + "trace_samples", + "Slow trace samples", + "Provide trace identifiers for deep diagnosis.", + "index=otel_traces service.name=* | sort - duration_ms " + "| table _time service.name trace_id span_id duration_ms http.route | head 50", + "splunk.table", + RED, + (TelemetrySignal.TRACE,), + ("service.name", "trace_id", "span_id", "duration_ms", "http.route"), + drilldown_signals=(TelemetrySignal.LOG,), + ), + ), + tags=("microservices", "service-map", "traces", "red"), + ), + _DashboardSpec( + example_id="batch_cron_reliability", + priority="medium", + title="Batch and Cron Reliability", + description="Schedule adherence, duration, backlog, failures, and freshness.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.RED, ObservabilityFramework.SLI_SLO), + signals=(TelemetrySignal.EVENT,), + logical_indexes=("batch_events",), + panels=( + _panel( + "schedule_adherence", + "Schedule adherence", + "Find jobs that start materially after their schedules.", + "index=batch_events job.name=* | eval delay_seconds=_time-scheduled_time " + "| stats perc95(delay_seconds) AS p95_delay_seconds by job.name", + "splunk.table", + SLO, + (TelemetrySignal.EVENT,), + ("job.name", "scheduled_time"), + ), + _panel( + "job_duration", + "Job duration", + "Track duration regressions by job.", + "index=batch_events job.name=* | timechart span=1h perc95(duration_ms) AS p95_ms " + "by job.name", + "splunk.line", + RED, + (TelemetrySignal.EVENT,), + ("job.name", "duration_ms"), + ), + _panel( + "job_backlog", + "Queue backlog", + "Detect pending work accumulation.", + "index=batch_events queue.backlog=* | timechart span=5m " + "max(queue.backlog) AS backlog " + "by job.name", + "splunk.line", + RED, + (TelemetrySignal.EVENT,), + ("job.name", "queue.backlog"), + ), + _panel( + "job_failures", + "Job failures", + "Rank recurring job failures.", + "index=batch_events job.status=failure | stats count AS failures by job.name " + "| sort - failures | head 20", + "splunk.table", + RED, + (TelemetrySignal.EVENT,), + ("job.name", "job.status"), + ), + _panel( + "job_freshness", + "Job freshness", + "Show elapsed time since each job last succeeded.", + "index=batch_events job.status=success " + "| stats latest(_time) AS last_success by job.name " + "| eval freshness_seconds=now()-last_success | sort - freshness_seconds", + "splunk.table", + SLO, + (TelemetrySignal.EVENT,), + ("job.name", "job.status"), + ), + ), + tags=("batch", "cron", "freshness", "slo"), + ), + _DashboardSpec( + example_id="cicd_delivery_health", + priority="medium", + title="CI/CD Delivery Health", + description="Delivery lead time, reliability, queueing, flaky jobs, and rollback signals.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.RED, ObservabilityFramework.SLI_SLO), + signals=(TelemetrySignal.EVENT,), + logical_indexes=("cicd_events", "platform_events"), + panels=( + _panel( + "delivery_lead_time", + "Delivery lead time", + "Track time from accepted change to production deployment.", + "index=cicd_events event.name=deployment.completed " + "| timechart span=1d perc95(duration_ms) AS p95_lead_time_ms", + "splunk.line", + SLO, + (TelemetrySignal.EVENT,), + ("event.name", "duration_ms"), + ), + _panel( + "pipeline_failures", + "Pipeline failure rate", + "Compare failed and total pipeline runs.", + "index=cicd_events cicd.pipeline.name=* | timechart span=1h count AS runs " + 'count(eval(cicd.status="failure")) AS failures by cicd.pipeline.name', + "splunk.line", + RED, + (TelemetrySignal.EVENT,), + ("cicd.pipeline.name", "cicd.status"), + ), + _panel( + "flaky_jobs", + "Flaky jobs", + "Rank jobs alternating between pass and fail outcomes.", + "index=cicd_events cicd.job.name=* | stats dc(cicd.status) AS outcomes " + 'count(eval(cicd.status="failure")) AS failures by cicd.job.name ' + "| where outcomes>1 | sort - failures | head 20", + "splunk.table", + RED, + (TelemetrySignal.EVENT,), + ("cicd.job.name", "cicd.status"), + ), + _panel( + "pipeline_queue_time", + "Queue time", + "Track delivery-system saturation before jobs start.", + "index=cicd_events event.name=job.started | timechart span=1h " + "perc95(duration_ms) AS p95_queue_ms by cicd.pipeline.name", + "splunk.line", + RED, + (TelemetrySignal.EVENT,), + ("event.name", "duration_ms", "cicd.pipeline.name"), + ), + _panel( + "deployment_rollbacks", + "Deployment rollbacks", + "Correlate rollback frequency with services and environments.", + "index=platform_events change.type=rollback | stats count AS rollbacks " + "by service.name deployment.environment.name | sort - rollbacks", + "splunk.table", + SLO, + (TelemetrySignal.EVENT,), + ("change.type", "service.name", "deployment.environment.name"), + ), + ), + saved_searches=( + SavedSearchSpec( + reference="Platform - CI CD Delivery Rollup", + purpose="Precompute daily delivery and change-failure indicators.", + rationale="Cross-event lead-time calculations are expensive and widely shared.", + recommended_schedule="15 * * * *", + source_indexes=("cicd_events", "platform_events"), + ), + ), + tags=("cicd", "delivery", "dora", "slo"), + ), + _DashboardSpec( + example_id="security_operations_overview", + priority="medium", + title="Security Operations Overview", + description="Authentication failures, risk, noisy detections, and ingestion health.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.FOUR_GOLDEN_SIGNALS,), + signals=(TelemetrySignal.EVENT,), + logical_indexes=("security_events", "platform_events"), + panels=( + _panel( + "auth_failures", + "Authentication failures", + "Track authentication failure volume without exposing user values.", + "index=security_events event.name=authentication.failure " + "| timechart span=5m count AS failures by security.severity", + "splunk.line", + GOLDEN, + (TelemetrySignal.EVENT,), + ("event.name", "detection.name", "security.severity"), + ), + _panel( + "high_risk_events", + "High-risk events", + "Rank current high-risk security signals.", + "index=security_events risk.score>=70 " + "| stats count AS events max(risk.score) AS risk " + "by event.name security.severity | sort - risk | head 20", + "splunk.table", + GOLDEN, + (TelemetrySignal.EVENT,), + ("risk.score", "event.name", "security.severity"), + ), + _panel( + "noisy_detections", + "Noisy detections", + "Find detections producing disproportionate volume.", + "index=security_events event.name=detection.triggered " + "| stats count AS triggers by detection.name security.severity " + "| sort - triggers | head 20", + "splunk.table", + GOLDEN, + (TelemetrySignal.EVENT,), + ("event.name", "security.severity"), + ), + _panel( + "ingestion_lag", + "Ingestion lag", + "Detect delayed security telemetry.", + "index=security_events ingest_lag_seconds=* | timechart span=5m " + "perc95(ingest_lag_seconds) AS p95_lag_seconds", + "splunk.line", + GOLDEN, + (TelemetrySignal.EVENT,), + ("ingest_lag_seconds",), + ), + _panel( + "security_platform_health", + "Security platform health", + "Track collection and detection pipeline errors.", + "index=platform_events event.name=security.pipeline.error " + "| stats count AS errors by service.name | sort - errors", + "splunk.table", + GOLDEN, + (TelemetrySignal.EVENT,), + ("event.name", "service.name"), + ), + ), + tags=("security", "soc", "risk", "platform-health"), + ), + _DashboardSpec( + example_id="business_journey_slo", + priority="medium", + title="Business Journey and SLO", + description="User-journey success, latency, abandonment, freshness, and error-budget burn.", + minimum_target="9.4.3", + frameworks=(ObservabilityFramework.SLI_SLO, ObservabilityFramework.RED), + signals=(TelemetrySignal.EVENT, TelemetrySignal.TRACE), + logical_indexes=("business_events", "otel_traces"), + panels=( + _panel( + "journey_success", + "Journey success rate", + "Measure whether users complete the intended journey.", + "index=business_events business.journey.name=* | stats count AS attempts " + 'count(eval(business.outcome="success")) AS successes by business.journey.name ' + "| eval value=round(successes*100/attempts,2)", + "splunk.singlevalue", + SLO, + (TelemetrySignal.EVENT,), + ("business.journey.name", "business.outcome"), + drilldown_signals=(TelemetrySignal.TRACE,), + ), + _panel( + "journey_latency", + "Journey p95 latency", + "Track customer-perceived journey duration.", + "index=business_events business.journey.name=* | timechart span=5m " + "perc95(duration_ms) AS p95_ms by business.journey.name", + "splunk.line", + SLO, + (TelemetrySignal.EVENT,), + ("business.journey.name", "duration_ms"), + ), + _panel( + "journey_abandonment", + "Journey abandonment", + "Measure journeys that start but do not complete.", + "index=business_events business.outcome=abandoned " + "| timechart span=5m count AS abandoned by business.journey.name", + "splunk.line", + SLO, + (TelemetrySignal.EVENT,), + ("business.journey.name", "business.outcome"), + ), + _panel( + "business_freshness", + "Business event freshness", + "Detect stale or interrupted journey telemetry.", + "index=business_events business.journey.name=* " + "| stats latest(_time) AS latest_event by business.journey.name " + "| eval freshness_seconds=now()-latest_event | sort - freshness_seconds", + "splunk.table", + SLO, + (TelemetrySignal.EVENT,), + ("business.journey.name",), + ), + _panel( + "error_budget_burn", + "Error-budget burn", + "Compare observed journey failures with the declared SLO budget.", + "index=business_events business.journey.name=* " + "| bin _time span=5m | stats count AS attempts " + 'count(eval(business.outcome!="success")) AS failures ' + "by _time business.journey.name " + "| eval burn_rate=(failures/attempts)/0.001", + "splunk.line", + SLO, + (TelemetrySignal.EVENT,), + ("business.journey.name", "business.outcome"), + ), + ), + saved_searches=( + SavedSearchSpec( + reference="Platform - Business Journey SLO Rollup", + purpose="Precompute shared journey SLIs and error-budget windows.", + rationale="Multi-window burn rates should be consistent across viewers and alerts.", + recommended_schedule="*/5 * * * *", + source_indexes=("business_events",), + ), + ), + tags=("business", "journey", "sli", "slo", "error-budget"), + ), +) + + +def portable_telemetry_contract() -> TelemetryContract: + """Return the immutable portable telemetry contract used by every example.""" + + return _PORTABLE_TELEMETRY_CONTRACT + + +def _provenance(panel: _PanelSpec) -> PanelProvenance: + return PanelProvenance( + panel_id=f"viz_{panel.slug}", + purpose=panel.purpose, + frameworks=panel.frameworks, + signals=panel.signals, + data_source_ids=(f"ds_{panel.slug}",), + required_fields=panel.required_fields, + drilldown_signals=panel.drilldown_signals, + ) + + +def _entry(spec: _DashboardSpec) -> CatalogEntry: + return CatalogEntry( + example_id=spec.example_id, + priority=spec.priority, + title=spec.title, + description=spec.description, + minimum_target=EnterpriseVersion.parse(spec.minimum_target), + telemetry_contract=PORTABLE_TELEMETRY_CONTRACT_ID, + frameworks=spec.frameworks, + signals=spec.signals, + logical_indexes=spec.logical_indexes, + required_fields=tuple( + sorted({field for panel in spec.panels for field in panel.required_fields}) + ), + panels=tuple(_provenance(panel) for panel in spec.panels), + saved_searches=spec.saved_searches, + tags=spec.tags, + ) + + +_ENTRIES = tuple(_entry(spec) for spec in _SPECS) +_SPECS_BY_ID = {spec.example_id: spec for spec in _SPECS} +_ENTRIES_BY_ID = {entry.example_id: entry for entry in _ENTRIES} + + +def catalog_entries() -> tuple[CatalogEntry, ...]: + """Return deterministic catalog metadata in documented priority order.""" + + return _ENTRIES + + +def _resolve(example_id: str) -> tuple[_DashboardSpec, CatalogEntry]: + spec = _SPECS_BY_ID.get(example_id) + entry = _ENTRIES_BY_ID.get(example_id) + if spec is None or entry is None: + available = ", ".join(entry.example_id for entry in catalog_entries()) + raise CatalogEntryNotFound( + f"Unknown catalog dashboard {example_id!r}; available dashboards: {available}" + ) + return spec, entry + + +def _target(target: TargetPlatform | str) -> TargetPlatform: + return target if isinstance(target, TargetPlatform) else TargetPlatform.enterprise(target) + + +def build_catalog_dashboard( + example_id: str, + target: TargetPlatform | str, +) -> DashboardDefinition: + """Build one catalog dashboard for an explicitly supported Enterprise target.""" + + spec, entry = _resolve(example_id) + platform = _target(target) + profile_for(platform) + if platform.version < entry.minimum_target: + raise CatalogTargetUnsupported( + f"Catalog dashboard {example_id!r} requires Splunk Enterprise " + f"{entry.minimum_target} or newer; received {platform.version}" + ) + + builder = DashboardBuilder( + title=spec.title, + description=spec.description, + target=platform, + ) + builder.add_input( + "input.timerange", + name="Global Time Range", + input_id="input_global_time", + title="Global Time Range", + options={"token": "global_time", "defaultValue": "-24h@h,now"}, + ) + builder.set_data_source_defaults( + "ds.search", + { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$", + } + } + }, + ) + builder.set_visualization_defaults("global", {"showProgressBar": True}) + builder.set_application_property("collapseNavigation", False) + builder.set_application_property("downsampleVisualizations", True) + + for panel in spec.panels: + data_source_id = builder.add_search( + panel.query, + name=panel.title, + data_source_id=f"ds_{panel.slug}", + earliest=None, + latest=None, + ) + builder.add_visualization( + panel.visualization_type, + name=panel.title, + visualization_id=f"viz_{panel.slug}", + data_sources={"primary": data_source_id}, + title=panel.title, + description=panel.purpose, + ) + return builder.build(canvas_width=1440) + + +def build_catalog_bundle( + example_id: str, + target: TargetPlatform | str, +) -> DashboardArtifactBundle: + """Build one canonical definition with honest native and engine evidence metadata.""" + + _, entry = _resolve(example_id) + platform = _target(target) + definition = build_catalog_dashboard(example_id, platform) + encoded = canonical_json(definition).encode("utf-8") + engine = profile_for(platform).engine + manifest = DashboardEvidenceManifest( + example_id=example_id, + target=platform, + minimum_target=entry.minimum_target, + generator_version=__version__, + telemetry_contract=entry.telemetry_contract, + definition_sha256=hashlib.sha256(encoded).hexdigest(), + canonical_json_bytes=len(encoded), + official_engine_id=engine.engine_id, + official_engine_version=engine.dashboard_version, + engine_evidence=engine.evidence, + panels=entry.panels, + saved_searches=entry.saved_searches, + assumptions=( + "Logical index names are mapped by the deploying Splunk Enterprise app.", + "Official NPM validation is performed in CI and is not claimed by this generated file.", + "Live Splunk REST ingestion and readback remain deferred.", + ), + ) + return DashboardArtifactBundle(definition=definition, manifest=manifest) diff --git a/splunk_dashboard_studio/cli.py b/splunk_dashboard_studio/cli.py index 4df0869..192ea3f 100644 --- a/splunk_dashboard_studio/cli.py +++ b/splunk_dashboard_studio/cli.py @@ -9,13 +9,24 @@ from typing import Any from pydantic import ValidationError +from pydantic_core import to_jsonable_python from splunk_dashboard_studio import __version__ +from splunk_dashboard_studio.catalog import ( + build_catalog_bundle, + build_catalog_dashboard, + catalog_entries, + portable_telemetry_contract, +) from splunk_dashboard_studio.corpus import corpus_jsonl from splunk_dashboard_studio.graph import plan_search_optimizations from splunk_dashboard_studio.models import DashboardDefinition, unwrap_definition from splunk_dashboard_studio.profiles import profile_manifest -from splunk_dashboard_studio.schema import agent_contract_schema, dashboard_definition_schema +from splunk_dashboard_studio.schema import ( + agent_contract_schema, + dashboard_definition_schema, + schema_bundle, +) from splunk_dashboard_studio.validation import validate_dashboard @@ -26,11 +37,19 @@ def _read_json(path: str) -> Any: def _print_json(value: Any) -> None: - if hasattr(value, "model_dump"): - value = value.model_dump(mode="json") + value = to_jsonable_python(value, by_alias=True) print(json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)) +def _write_json(value: Any, output: str) -> None: + value = to_jsonable_python(value, by_alias=True) + rendered = json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + if output == "-": + sys.stdout.write(rendered) + else: + Path(output).write_text(rendered, encoding="utf-8") + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="splunk-studio", @@ -51,7 +70,7 @@ def _parser() -> argparse.ArgumentParser: schema = subparsers.add_parser("schema", help="Emit an agent-facing JSON Schema") schema.add_argument( "kind", - choices=("agent", "dashboard", "profiles"), + choices=("agent", "bundle", "dashboard", "profiles"), default="agent", nargs="?", ) @@ -62,6 +81,19 @@ def _parser() -> argparse.ArgumentParser: optimize = subparsers.add_parser("optimize", help="Analyze base/chain search opportunities") optimize.add_argument("path", nargs="?", default="-", help="JSON path or '-' for stdin") + + catalog = subparsers.add_parser("catalog", help="Inspect or build packaged dashboards") + catalog_commands = catalog.add_subparsers(dest="catalog_command", required=True) + catalog_commands.add_parser("list", help="List catalog metadata and telemetry contract") + catalog_build = catalog_commands.add_parser("build", help="Build a catalog artifact") + catalog_build.add_argument("example_id") + catalog_build.add_argument("--target", required=True) + catalog_build.add_argument( + "--artifact", + choices=("definition", "bundle"), + default="definition", + ) + catalog_build.add_argument("--output", default="-", help="Output path or '-' for stdout") return parser @@ -79,6 +111,8 @@ def main(argv: list[str] | None = None) -> int: if arguments.command == "schema": if arguments.kind == "dashboard": _print_json(dashboard_definition_schema()) + elif arguments.kind == "bundle": + _print_json(schema_bundle()) elif arguments.kind == "profiles": _print_json(profile_manifest()) else: @@ -96,6 +130,23 @@ def main(argv: list[str] | None = None) -> int: definition = DashboardDefinition.model_validate(payload) _print_json(plan_search_optimizations(definition.data_sources)) return 0 + if arguments.command == "catalog": + if arguments.catalog_command == "list": + _print_json( + { + "schema_version": "dashboard-catalog/v1", + "telemetry_contract": portable_telemetry_contract(), + "entries": catalog_entries(), + } + ) + return 0 + artifact = ( + build_catalog_dashboard(arguments.example_id, arguments.target) + if arguments.artifact == "definition" + else build_catalog_bundle(arguments.example_id, arguments.target) + ) + _write_json(artifact, arguments.output) + return 0 except (OSError, ValueError, ValidationError, json.JSONDecodeError) as error: _print_json({"status": "error", "message": str(error), "error_type": type(error).__name__}) return 2 diff --git a/splunk_dashboard_studio/codec.py b/splunk_dashboard_studio/codec.py new file mode 100644 index 0000000..d1b84b9 --- /dev/null +++ b/splunk_dashboard_studio/codec.py @@ -0,0 +1,234 @@ +"""Offline codec for Splunk ``data/ui/views`` Dashboard Studio XML payloads.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from typing import Any, Literal, cast +from xml.etree import ElementTree +from xml.sax.saxutils import escape, quoteattr + +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from splunk_dashboard_studio.generation import canonical_json +from splunk_dashboard_studio.models import DashboardDefinition, unwrap_definition + + +class StudioViewCodecError(ValueError): + pass + + +class StudioView(BaseModel): + """The documented Dashboard Studio portion of a ``data/ui/views`` payload.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + version: Literal["2"] = "2" + label: str = Field(min_length=1) + description: str = "" + definition: DashboardDefinition + theme: Literal["light", "dark"] | None = None + hidden_elements: dict[str, bool] | None = Field(default=None, alias="hiddenElements") + + +class RoundTripDifference(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + path: str + kind: Literal["added", "removed", "changed"] + expected: JsonValue | None = None + actual: JsonValue | None = None + + +class RoundTripComparison(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + equivalent: bool + expected_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + actual_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + differences: tuple[RoundTripDifference, ...] = () + + +def _safe_cdata(value: str) -> str: + return value.replace("]]>", "]]]]>") + + +def encode_view_xml(view: StudioView) -> str: + """Encode a view into deterministic REST storage XML without performing I/O.""" + + attributes = ['version="2"'] + if view.theme is not None: + attributes.append(f"theme={quoteattr(view.theme)}") + if view.hidden_elements is not None: + hidden = json.dumps(view.hidden_elements, sort_keys=True, separators=(",", ":")) + attributes.append(f"hiddenElements={quoteattr(hidden)}") + definition = _safe_cdata(canonical_json(view.definition)) + return ( + f"" + f"" + f"{escape(view.description)}" + f"" + "" + ) + + +def _required_text(root: ElementTree.Element, tag: str) -> str: + element = root.find(tag) + if element is None: + raise StudioViewCodecError(f"Dashboard Studio XML requires a {tag!r} element") + return element.text or "" + + +def decode_view_xml(payload: str | bytes) -> StudioView: + """Decode and normalize the documented fields from REST storage XML.""" + + raw = payload.decode("utf-8") if isinstance(payload, bytes) else payload + upper = raw.upper() + if "") + if root.attrib.get("version") != "2": + raise StudioViewCodecError('Dashboard Studio XML requires version="2"') + + theme = root.attrib.get("theme") + if theme not in {None, "light", "dark"}: + raise StudioViewCodecError("Dashboard Studio theme must be 'light' or 'dark'") + hidden_elements: dict[str, bool] | None = None + if hidden := root.attrib.get("hiddenElements"): + try: + parsed_hidden = json.loads(hidden) + except json.JSONDecodeError as error: + raise StudioViewCodecError("hiddenElements must contain a JSON object") from error + if not isinstance(parsed_hidden, dict) or not all( + isinstance(key, str) and isinstance(value, bool) for key, value in parsed_hidden.items() + ): + raise StudioViewCodecError("hiddenElements must map string keys to booleans") + hidden_elements = parsed_hidden + + definition_text = _required_text(root, "definition") + try: + raw_definition = json.loads(definition_text) + except json.JSONDecodeError as error: + raise StudioViewCodecError("definition must contain valid JSON") from error + try: + definition = DashboardDefinition.model_validate(unwrap_definition(raw_definition)) + except ValueError as error: + raise StudioViewCodecError(f"definition is not a valid dashboard: {error}") from error + label = _required_text(root, "label") + if not label: + raise StudioViewCodecError("Dashboard Studio label must be non-empty") + description_element = root.find("description") + description = (description_element.text or "") if description_element is not None else "" + return StudioView( + label=label, + description=description, + definition=definition, + theme=cast(Literal["light", "dark"] | None, theme), + hidden_elements=hidden_elements, + ) + + +def _normalized(view: StudioView) -> dict[str, JsonValue]: + value: dict[str, JsonValue] = { + "version": view.version, + "label": view.label, + "description": view.description, + "definition": view.definition.as_json_value(), + } + if view.theme is not None: + value["theme"] = view.theme + if view.hidden_elements is not None: + value["hiddenElements"] = cast(JsonValue, dict(sorted(view.hidden_elements.items()))) + return value + + +def _pointer(parent: str, item: str | int) -> str: + escaped = str(item).replace("~", "~0").replace("/", "~1") + return f"{parent}/{escaped}" + + +def _diff(expected: JsonValue, actual: JsonValue, path: str = "") -> list[RoundTripDifference]: + if isinstance(expected, dict) and isinstance(actual, dict): + differences: list[RoundTripDifference] = [] + for key in sorted(expected.keys() | actual.keys()): + item_path = _pointer(path, key) + if key not in expected: + differences.append( + RoundTripDifference( + path=item_path, + kind="added", + actual=actual[key], + ) + ) + elif key not in actual: + differences.append( + RoundTripDifference( + path=item_path, + kind="removed", + expected=expected[key], + ) + ) + else: + differences.extend(_diff(expected[key], actual[key], item_path)) + return differences + if isinstance(expected, list) and isinstance(actual, list): + differences = [] + shared = min(len(expected), len(actual)) + for index in range(shared): + differences.extend(_diff(expected[index], actual[index], _pointer(path, index))) + for index in range(shared, len(expected)): + differences.append( + RoundTripDifference( + path=_pointer(path, index), + kind="removed", + expected=expected[index], + ) + ) + for index in range(shared, len(actual)): + differences.append( + RoundTripDifference( + path=_pointer(path, index), + kind="added", + actual=actual[index], + ) + ) + return differences + if type(expected) is type(actual) and expected == actual: + return [] + return [ + RoundTripDifference( + path=path or "/", + kind="changed", + expected=expected, + actual=actual, + ) + ] + + +def _view(value: StudioView | str | bytes) -> StudioView: + return decode_view_xml(value) if isinstance(value, (str, bytes)) else value + + +def compare_roundtrip( + expected: StudioView | str | bytes, + actual: StudioView | str | bytes, +) -> RoundTripComparison: + """Compare two normalized views with deterministic SHA-256 and JSON-pointer diffs.""" + + expected_value = _normalized(_view(expected)) + actual_value = _normalized(_view(actual)) + expected_json = canonical_json(cast(Mapping[str, Any], expected_value)) + actual_json = canonical_json(cast(Mapping[str, Any], actual_value)) + differences = tuple(_diff(expected_value, actual_value)) + return RoundTripComparison( + equivalent=not differences, + expected_sha256=hashlib.sha256(expected_json.encode("utf-8")).hexdigest(), + actual_sha256=hashlib.sha256(actual_json.encode("utf-8")).hexdigest(), + differences=differences, + ) diff --git a/splunk_dashboard_studio/contracts.py b/splunk_dashboard_studio/contracts.py new file mode 100644 index 0000000..f1e0539 --- /dev/null +++ b/splunk_dashboard_studio/contracts.py @@ -0,0 +1,225 @@ +"""Typed observability, catalog, provenance, and agent-skill contracts.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from splunk_dashboard_studio.models import DashboardDefinition +from splunk_dashboard_studio.profiles import EvidenceGrade +from splunk_dashboard_studio.version import EnterpriseVersion, TargetPlatform + + +class FrozenContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + +class ObservabilityFramework(StrEnum): + RED = "red" + USE = "use" + FOUR_GOLDEN_SIGNALS = "four_golden_signals" + MELT = "melt" + SLI_SLO = "sli_slo" + + +class TelemetrySignal(StrEnum): + METRIC = "metric" + EVENT = "event" + LOG = "log" + TRACE = "trace" + PROFILE = "profile" + + +class TelemetryField(FrozenContract): + name: str = Field(min_length=1) + signals: tuple[TelemetrySignal, ...] = Field(min_length=1) + description: str = Field(min_length=1) + required: bool = True + semantic_convention: str | None = None + unit: str | None = None + + +class TelemetryContract(FrozenContract): + contract_id: str = Field(min_length=1) + description: str = Field(min_length=1) + logical_indexes: dict[str, TelemetrySignal] + fields: tuple[TelemetryField, ...] + notes: tuple[str, ...] = () + + @model_validator(mode="after") + def require_unique_fields(self) -> TelemetryContract: + names = [field.name for field in self.fields] + if len(names) != len(set(names)): + raise ValueError("Telemetry field names must be unique") + return self + + +class PanelProvenance(FrozenContract): + panel_id: str = Field(min_length=1) + purpose: str = Field(min_length=1) + frameworks: tuple[ObservabilityFramework, ...] = Field(min_length=1) + signals: tuple[TelemetrySignal, ...] = Field(min_length=1) + data_source_ids: tuple[str, ...] = Field(min_length=1) + required_fields: tuple[str, ...] = () + drilldown_signals: tuple[TelemetrySignal, ...] = () + + +class SavedSearchSpec(FrozenContract): + reference: str = Field(min_length=1) + purpose: str = Field(min_length=1) + rationale: str = Field(min_length=1) + recommended_schedule: str | None = None + source_indexes: tuple[str, ...] = () + ownership: Literal["external"] = "external" + + +class CatalogEntry(FrozenContract): + example_id: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + priority: Literal["high", "medium"] + title: str = Field(min_length=1) + description: str = Field(min_length=1) + minimum_target: EnterpriseVersion + telemetry_contract: str = Field(min_length=1) + frameworks: tuple[ObservabilityFramework, ...] = Field(min_length=1) + signals: tuple[TelemetrySignal, ...] = Field(min_length=1) + logical_indexes: tuple[str, ...] = Field(min_length=1) + required_fields: tuple[str, ...] = () + panels: tuple[PanelProvenance, ...] = Field(min_length=1) + saved_searches: tuple[SavedSearchSpec, ...] = () + tags: tuple[str, ...] = () + + @model_validator(mode="after") + def require_unique_panels(self) -> CatalogEntry: + panel_ids = [panel.panel_id for panel in self.panels] + if len(panel_ids) != len(set(panel_ids)): + raise ValueError("Catalog panel IDs must be unique") + return self + + +class DashboardEvidenceManifest(FrozenContract): + schema_version: Literal["dashboard-evidence/v1"] = "dashboard-evidence/v1" + example_id: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + target: TargetPlatform + minimum_target: EnterpriseVersion + generator_version: str = Field(min_length=1) + telemetry_contract: str = Field(min_length=1) + definition_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + canonical_json_bytes: int = Field(gt=0) + native_validation: Literal["valid", "invalid"] = "valid" + official_validation: Literal["valid", "invalid", "not_run"] = "not_run" + official_engine_id: str = Field(min_length=1) + official_engine_version: str = Field(min_length=1) + engine_evidence: EvidenceGrade + panels: tuple[PanelProvenance, ...] + saved_searches: tuple[SavedSearchSpec, ...] = () + assumptions: tuple[str, ...] = () + + +class DashboardArtifactBundle(FrozenContract): + definition: DashboardDefinition + manifest: DashboardEvidenceManifest + + +class AgentSkill(StrEnum): + DATA_DISCOVERY = "data_discovery" + SPL_OPTIMIZATION = "spl_optimization" + VISUALIZATION_SELECTION = "visualization_selection" + ALERTING_SLO = "alerting_slo" + PROVENANCE = "provenance" + VALIDATION = "validation" + DRIFT_DETECTION = "drift_detection" + ACCESS_CONTROL = "access_control" + + +class SkillDescriptor(FrozenContract): + skill: AgentSkill + purpose: str = Field(min_length=1) + inputs: tuple[str, ...] = Field(min_length=1) + outputs: tuple[str, ...] = Field(min_length=1) + heuristics: tuple[str, ...] = Field(min_length=1) + constraints: tuple[str, ...] = Field(min_length=1) + tools: tuple[str, ...] = Field(min_length=1) + + +_SKILL_DESCRIPTORS = ( + SkillDescriptor( + skill=AgentSkill.DATA_DISCOVERY, + purpose="Profile available telemetry and map stable entity fields.", + inputs=("field inventory", "sample telemetry", "semantic-convention registry"), + outputs=("dataset profile", "candidate entities", "suitability score"), + heuristics=("Prefer semantic-convention entities over index-local aliases.",), + constraints=("Do not infer field stability from one sample.",), + tools=("field_profiler", "semconv_resolver", "sample_runner"), + ), + SkillDescriptor( + skill=AgentSkill.SPL_OPTIMIZATION, + purpose="Propose transparent SPL and search-graph improvements.", + inputs=("draft SPL", "dashboard graph", "panel intent"), + outputs=("optimization proposals", "shared-base candidates"), + heuristics=("Share a base only when common semantics remain explicit.",), + constraints=("Never silently rewrite SPL.",), + tools=("spl_parser", "chain_graph_analyzer", "optimizer"), + ), + SkillDescriptor( + skill=AgentSkill.VISUALIZATION_SELECTION, + purpose="Choose diagnostic visualizations from result shape and audience.", + inputs=("panel intent", "result cardinality", "metric type", "audience"), + outputs=("visualization type", "option defaults"), + heuristics=("Use KPI, trend, top-N, and distribution views for their matching questions.",), + constraints=("Avoid decorative visuals without diagnostic value.",), + tools=("viz_rules", "cardinality_estimator", "defaults_policy"), + ), + SkillDescriptor( + skill=AgentSkill.ALERTING_SLO, + purpose="Align user-journey SLIs, error budgets, and saved-search proposals.", + inputs=("SLI specification", "thresholds", "burn-rate policy"), + outputs=("SLO dashboard sections", "saved-search specifications"), + heuristics=("Page on user-visible symptoms and use burn-rate overlays.",), + constraints=("Do not create alerts or saved searches.",), + tools=("sli_catalog", "burn_rate_policy", "saved_search_builder"), + ), + SkillDescriptor( + skill=AgentSkill.PROVENANCE, + purpose="Make telemetry and derivation dependencies explicit per panel.", + inputs=("source-to-panel mapping", "schema registry", "dashboard definition"), + outputs=("panel provenance", "evidence metadata"), + heuristics=("Label every derived KPI with its signal and entity assumptions.",), + constraints=("Do not emit unlabeled derived KPIs.",), + tools=("schema_registry", "source_annotator"), + ), + SkillDescriptor( + skill=AgentSkill.VALIDATION, + purpose="Validate native semantics and locked official-engine compatibility.", + inputs=("dashboard definition", "target profile", "engine locks"), + outputs=("normalized validation report", "evidence manifest"), + heuristics=("Fail closed on target ambiguity and dangling references.",), + constraints=("Keep NPM validators outside Python distributions.",), + tools=("native_validator", "official_npm_validator"), + ), + SkillDescriptor( + skill=AgentSkill.DRIFT_DETECTION, + purpose="Distinguish product, validator, artifact, and telemetry drift.", + inputs=("historical corpus", "artifact snapshots", "schema deltas"), + outputs=("compatibility diff", "regeneration tasks"), + heuristics=("Classify product drift separately from telemetry drift.",), + constraints=("Do not equate NPM publication time with product attribution.",), + tools=("corpus_diff", "roundtrip_codec", "schema_drift_check"), + ), + SkillDescriptor( + skill=AgentSkill.ACCESS_CONTROL, + purpose="Describe least-privilege and publication boundaries.", + inputs=("app context", "role policy", "publish intent"), + outputs=("ACL-safe deployment guidance", "publication warnings"), + heuristics=("Default to private or app-scoped dashboards.",), + constraints=("Advisory only; never publish or mutate ACLs.",), + tools=("acl_policy", "publish_guard", "secret_scan"), + ), +) + + +def observability_skill_descriptors() -> tuple[SkillDescriptor, ...]: + """Return the immutable, deterministic eight-skill observability taxonomy.""" + + return _SKILL_DESCRIPTORS diff --git a/splunk_dashboard_studio/corpus.py b/splunk_dashboard_studio/corpus.py index ca7c95c..da1be90 100644 --- a/splunk_dashboard_studio/corpus.py +++ b/splunk_dashboard_studio/corpus.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, JsonValue +from splunk_dashboard_studio.catalog import build_catalog_dashboard, catalog_entries from splunk_dashboard_studio.generation import DashboardBuilder from splunk_dashboard_studio.version import TargetPlatform @@ -61,6 +62,23 @@ def generate_corpus(target: TargetPlatform | str) -> tuple[CorpusCase, ...]: ) ] + for entry in catalog_entries(): + if platform.version < entry.minimum_target: + continue + cases.append( + CorpusCase( + case_id=f"catalog-{entry.example_id}", + target=platform, + expected_native="valid", + expected_npm="valid", + tags=("positive", "catalog", *entry.tags), + definition=build_catalog_dashboard( + entry.example_id, + platform, + ).as_json_value(), + ) + ) + chain = copy.deepcopy(base) chain["dataSources"]["ds_base"] = { "type": "ds.search", diff --git a/splunk_dashboard_studio/generation.py b/splunk_dashboard_studio/generation.py index a669386..7a33318 100644 --- a/splunk_dashboard_studio/generation.py +++ b/splunk_dashboard_studio/generation.py @@ -5,8 +5,8 @@ import json import re from collections import defaultdict -from collections.abc import Mapping -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, cast from pydantic import JsonValue @@ -75,6 +75,9 @@ def __init__( self._positions: dict[str, Position] = {} self._application_properties: dict[str, JsonValue] = {} self._expressions: dict[str, JsonValue] = {} + self._data_source_defaults: dict[str, dict[str, JsonValue]] = {} + self._visualization_defaults: dict[str, dict[str, JsonValue]] = {} + self._token_defaults: dict[str, dict[str, JsonValue]] = {} def _identifier(self, prefix: str, label: str, explicit: str | None) -> str: if explicit: @@ -161,13 +164,28 @@ def add_visualization( visualization_id: str | None = None, position: Position | Mapping[str, int] | None = None, title: str | None = None, + description: str | None = None, + context: Mapping[str, JsonValue] | None = None, + event_handlers: Sequence[Mapping[str, JsonValue]] | Mapping[str, JsonValue] | None = None, + container_options: Mapping[str, JsonValue] | None = None, ) -> str: identifier = self._identifier("viz", name, visualization_id) + normalized_handlers: list[dict[str, JsonValue]] | dict[str, JsonValue] | None + if event_handlers is None: + normalized_handlers = None + elif isinstance(event_handlers, Mapping): + normalized_handlers = dict(event_handlers) + else: + normalized_handlers = [dict(handler) for handler in event_handlers] self._visualizations[identifier] = Visualization( type=visualization_type, data_sources=dict(data_sources or {}), options=dict(options or {}), + context=dict(context or {}), + event_handlers=normalized_handlers, + container_options=(dict(container_options) if container_options is not None else None), title=title, + description=description, ) if position is not None: self._positions[identifier] = ( @@ -181,12 +199,71 @@ def add_input( *, name: str, options: Mapping[str, JsonValue] | None = None, + data_sources: Mapping[str, str] | None = None, input_id: str | None = None, + title: str | None = None, + context: Mapping[str, JsonValue] | None = None, + container_options: Mapping[str, JsonValue] | None = None, ) -> str: identifier = self._identifier("input", name, input_id) - self._inputs[identifier] = InputDefinition(type=input_type, options=dict(options or {})) + self._inputs[identifier] = InputDefinition( + type=input_type, + options=dict(options or {}), + data_sources=dict(data_sources or {}), + title=title, + context=dict(context or {}), + container_options=(dict(container_options) if container_options is not None else None), + ) return identifier + @staticmethod + def _require_default_name(kind: str, value: str) -> None: + if not value.strip(): + raise DashboardGenerationError(f"{kind} must be non-empty") + + def set_data_source_defaults( + self, + scope: str, + values: Mapping[str, JsonValue], + ) -> DashboardBuilder: + """Set one global or data-source-type default without overwriting prior intent.""" + + self._require_default_name("Data source default scope", scope) + if scope in self._data_source_defaults: + raise DashboardGenerationError(f"Duplicate data source default scope {scope!r}") + self._data_source_defaults[scope] = dict(values) + return self + + def set_visualization_defaults( + self, + scope: str, + values: Mapping[str, JsonValue], + ) -> DashboardBuilder: + """Set one global or visualization-type default without implicit merging.""" + + self._require_default_name("Visualization default scope", scope) + if scope in self._visualization_defaults: + raise DashboardGenerationError(f"Duplicate visualization default scope {scope!r}") + self._visualization_defaults[scope] = dict(values) + return self + + def set_token_default( + self, + name: str, + value: JsonValue, + *, + namespace: str = "default", + ) -> DashboardBuilder: + """Set a Dashboard Studio token default in the documented value envelope.""" + + self._require_default_name("Token name", name) + self._require_default_name("Token namespace", namespace) + token_namespace = self._token_defaults.setdefault(namespace, {}) + if name in token_namespace: + raise DashboardGenerationError(f"Duplicate token default {namespace!r}/{name!r}") + token_namespace[name] = value + return self + def set_application_property(self, name: str, value: JsonValue) -> DashboardBuilder: self._application_properties[name] = value return self @@ -241,12 +318,23 @@ def build( }, tabs=Tabs(items=[TabItem(layout_id="layout_main", label="Overview")]), ) + defaults: dict[str, JsonValue] = {} + if self._data_source_defaults: + defaults["dataSources"] = cast(JsonValue, self._data_source_defaults) + if self._visualization_defaults: + defaults["visualizations"] = cast(JsonValue, self._visualization_defaults) + if self._token_defaults: + defaults["tokens"] = { + namespace: {name: {"value": value} for name, value in sorted(token_values.items())} + for namespace, token_values in sorted(self._token_defaults.items()) + } definition = DashboardDefinition( title=self.title, description=self.description, data_sources=dict(self._data_sources), visualizations=dict(self._visualizations), inputs=dict(self._inputs), + defaults=defaults, layout=layout, application_properties=dict(self._application_properties), expressions=dict(self._expressions), diff --git a/splunk_dashboard_studio/models.py b/splunk_dashboard_studio/models.py index cac410b..ea7b9e2 100644 --- a/splunk_dashboard_studio/models.py +++ b/splunk_dashboard_studio/models.py @@ -55,6 +55,11 @@ class InputDefinition(ExtensibleModel): type: str = Field(min_length=1) options: dict[str, JsonValue] = Field(default_factory=dict) data_sources: dict[str, str] = Field(default_factory=dict, alias="dataSources") + context: dict[str, JsonValue] = Field(default_factory=dict) + container_options: dict[str, JsonValue] | None = Field( + default=None, + alias="containerOptions", + ) title: str | None = None diff --git a/splunk_dashboard_studio/schema.py b/splunk_dashboard_studio/schema.py index b48faf5..8ca5bcf 100644 --- a/splunk_dashboard_studio/schema.py +++ b/splunk_dashboard_studio/schema.py @@ -6,6 +6,12 @@ from pydantic import BaseModel, ConfigDict +from splunk_dashboard_studio.contracts import ( + DashboardArtifactBundle, + SkillDescriptor, + TelemetryContract, + observability_skill_descriptors, +) from splunk_dashboard_studio.models import DashboardDefinition from splunk_dashboard_studio.profiles import profile_manifest from splunk_dashboard_studio.version import TargetPlatform @@ -25,4 +31,29 @@ def dashboard_definition_schema() -> dict[str, Any]: def agent_contract_schema() -> dict[str, Any]: schema = DashboardAgentContract.model_json_schema(by_alias=True, mode="validation") schema["x-splunk-enterprise"] = profile_manifest() + schema["x-observability-skills"] = [ + descriptor.model_dump(mode="json") for descriptor in observability_skill_descriptors() + ] return schema + + +def schema_bundle() -> dict[str, Any]: + """Return every stable machine-facing v0.2 contract in one document.""" + + return { + "schema_version": "splunk-dashboard-studio-schema-bundle/v1", + "schemas": { + "agent": agent_contract_schema(), + "artifact_bundle": DashboardArtifactBundle.model_json_schema( + by_alias=True, + mode="validation", + ), + "dashboard": dashboard_definition_schema(), + "skill_descriptor": SkillDescriptor.model_json_schema(mode="validation"), + "telemetry_contract": TelemetryContract.model_json_schema(mode="validation"), + }, + "x-splunk-enterprise": profile_manifest(), + "x-observability-skills": [ + descriptor.model_dump(mode="json") for descriptor in observability_skill_descriptors() + ], + } diff --git a/splunk_dashboard_studio/validation.py b/splunk_dashboard_studio/validation.py index f8032ae..238bf68 100644 --- a/splunk_dashboard_studio/validation.py +++ b/splunk_dashboard_studio/validation.py @@ -94,7 +94,11 @@ def _detected_features(definition: DashboardDefinition) -> set[Feature]: features.add(Feature.TIMELINE) if visualization_type.startswith("viz.") and visualization_type != "viz.timeline": features.add(Feature.CUSTOM_VISUALIZATIONS) - if visualization_type in {"splunk.networkgraph", "splunk.network_graph"}: + if visualization_type in { + "splunk.networkGraph", + "splunk.networkgraph", + "splunk.network_graph", + }: features.add(Feature.NETWORK_GRAPH) return features diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..5f5a416 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from splunk_dashboard_studio.catalog import ( + CatalogEntryNotFound, + CatalogTargetUnsupported, + build_catalog_bundle, + build_catalog_dashboard, + catalog_entries, + portable_telemetry_contract, +) +from splunk_dashboard_studio.cli import main +from splunk_dashboard_studio.generation import canonical_json +from splunk_dashboard_studio.version import EnterpriseVersion + +TARGETS = ("9.4.3", "10.0.0", "10.2.0", "10.4.0") + + +def test_catalog_has_ten_stable_entries_and_declared_fields() -> None: + entries = catalog_entries() + assert len(entries) == 10 + assert tuple(entry.example_id for entry in entries) == tuple( + [ + "kubernetes_workload_health", + "ec2_host_capacity", + "rds_database_health", + "load_balancer_edge_health", + "api_gateway_overview", + "microservice_service_map", + "batch_cron_reliability", + "cicd_delivery_health", + "security_operations_overview", + "business_journey_slo", + ] + ) + assert len({entry.example_id for entry in entries}) == 10 + assert [entry.priority for entry in entries].count("high") == 6 + assert [entry.priority for entry in entries].count("medium") == 4 + + contract = portable_telemetry_contract() + assert contract.contract_id == "portable-observability-v1" + assert set(contract.logical_indexes) == { + "otel_metrics", + "otel_logs", + "otel_traces", + "platform_events", + "batch_events", + "cicd_events", + "security_events", + "business_events", + } + declared_fields = {field.name for field in contract.fields} + assert all(set(entry.required_fields) <= declared_fields for entry in entries) + + +@pytest.mark.parametrize( + ("example_id", "minimum_target"), + [(entry.example_id, str(entry.minimum_target)) for entry in catalog_entries()], +) +def test_catalog_dashboard_builds_deterministically_at_minimum_target( + example_id: str, + minimum_target: str, +) -> None: + first = build_catalog_dashboard(example_id, minimum_target) + second = build_catalog_dashboard(example_id, minimum_target) + assert canonical_json(first) == canonical_json(second) + assert first.layout.global_inputs == ["input_global_time"] + assert first.inputs["input_global_time"].options["defaultValue"] == "-24h@h,now" + assert first.defaults["dataSources"] == { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$", + } + } + } + } + layout = first.layout.layout_definitions["layout_main"] + assert layout.options["width"] == 1440 + assert len(layout.structure) == len(first.visualizations) == 5 + assert all("refresh" not in source.options for source in first.data_sources.values()) + + +@pytest.mark.parametrize("target", TARGETS) +def test_catalog_builds_every_eligible_dashboard_for_each_profile(target: str) -> None: + for entry in catalog_entries(): + if EnterpriseVersion.parse(target) < entry.minimum_target: + continue + definition = build_catalog_dashboard(entry.example_id, target) + assert definition.title == entry.title + + +def test_catalog_rejects_unknown_and_unsupported_targets() -> None: + with pytest.raises(CatalogEntryNotFound, match="Unknown catalog dashboard"): + build_catalog_dashboard("missing", "10.4.0") + with pytest.raises(CatalogTargetUnsupported, match=r"requires Splunk Enterprise 10\.4\.0"): + build_catalog_dashboard("microservice_service_map", "10.2.0") + + +def test_catalog_bundle_hashes_the_canonical_definition() -> None: + bundle = build_catalog_bundle("business_journey_slo", "9.4.3") + encoded = canonical_json(bundle.definition).encode() + assert bundle.manifest.definition_sha256 == hashlib.sha256(encoded).hexdigest() + assert bundle.manifest.canonical_json_bytes == len(encoded) + assert bundle.manifest.native_validation == "valid" + assert bundle.manifest.official_validation == "not_run" + assert bundle.manifest.saved_searches[0].ownership == "external" + + +def test_catalog_cli_lists_and_builds_artifacts( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + assert main(["catalog", "list"]) == 0 + listing = json.loads(capsys.readouterr().out) + assert listing["schema_version"] == "dashboard-catalog/v1" + assert len(listing["entries"]) == 10 + + output = tmp_path / "definition.json" + assert ( + main( + [ + "catalog", + "build", + "kubernetes_workload_health", + "--target", + "9.4.3", + "--output", + str(output), + ] + ) + == 0 + ) + assert json.loads(output.read_text())["title"] == "Kubernetes Workload Health" + + assert ( + main( + [ + "catalog", + "build", + "business_journey_slo", + "--target", + "9.4.3", + "--artifact", + "bundle", + ] + ) + == 0 + ) + assert json.loads(capsys.readouterr().out)["manifest"]["schema_version"] == ( + "dashboard-evidence/v1" + ) + + +def test_catalog_cli_unsupported_target_returns_two( + capsys: pytest.CaptureFixture[str], +) -> None: + assert ( + main( + [ + "catalog", + "build", + "microservice_service_map", + "--target", + "10.2.0", + ] + ) + == 2 + ) + assert json.loads(capsys.readouterr().out)["status"] == "error" diff --git a/tests/test_codec.py b/tests/test_codec.py new file mode 100644 index 0000000..47c1cbb --- /dev/null +++ b/tests/test_codec.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json + +import pytest + +from splunk_dashboard_studio import DashboardBuilder +from splunk_dashboard_studio.codec import ( + StudioView, + StudioViewCodecError, + compare_roundtrip, + decode_view_xml, + encode_view_xml, +) + + +def _view(*, query: str = "index=main | stats count") -> StudioView: + builder = DashboardBuilder(title="Codec dashboard", target="10.2.0") + source = builder.add_search(query, data_source_id="ds_events") + builder.add_visualization( + "splunk.table", + name="Events", + visualization_id="viz_events", + data_sources={"primary": source}, + ) + return StudioView( + label="Codec & evidence", + description="Offline only", + definition=builder.build(), + theme="dark", + hidden_elements={"hideEdit": False, "hideExport": True}, + ) + + +def test_codec_roundtrips_documented_view_fields() -> None: + view = _view() + encoded = encode_view_xml(view) + assert encoded.startswith(' safely", + } + ) + } + ) + encoded = encode_view_xml(payload) + assert "]]]]>" in encoded + assert decode_view_xml(encoded) == payload + + +def test_codec_ignores_server_added_elements_and_attributes() -> None: + encoded = encode_view_xml(_view()) + readback = encoded.replace( + '", "ignored") + assert compare_roundtrip(encoded, readback).equivalent + + +def test_compare_roundtrip_returns_sorted_json_pointer_differences() -> None: + expected = _view() + changed_payload = expected.definition.as_json_value() + changed_payload["title"] = "Changed title" + changed_payload["description"] = "Changed description" + actual = expected.model_copy( + update={"definition": expected.definition.model_validate(changed_payload)} + ) + comparison = compare_roundtrip(expected, actual) + assert not comparison.equivalent + assert [difference.path for difference in comparison.differences] == [ + "/definition/description", + "/definition/title", + ] + assert all(difference.kind == "changed" for difference in comparison.differences) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ('
', "root must be"), + ('', "requires version"), + ( + '', + "DTD and entity", + ), + ( + '', + "requires a 'definition' element", + ), + ( + '' + "", + "must map string keys", + ), + ], +) +def test_codec_rejects_invalid_xml_contract(payload: str, message: str) -> None: + with pytest.raises(StudioViewCodecError, match=message): + decode_view_xml(payload) + + +def test_codec_rejects_invalid_definition_json() -> None: + payload = ( + '" + ) + with pytest.raises(StudioViewCodecError, match="not a valid dashboard"): + decode_view_xml(payload) diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..985503f --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest + +from splunk_dashboard_studio.contracts import ( + AgentSkill, + CatalogEntry, + ObservabilityFramework, + PanelProvenance, + TelemetryContract, + TelemetryField, + TelemetrySignal, + observability_skill_descriptors, +) + + +def _panel(panel_id: str = "viz_requests") -> PanelProvenance: + return PanelProvenance( + panel_id=panel_id, + purpose="Show request rate", + frameworks=(ObservabilityFramework.RED,), + signals=(TelemetrySignal.METRIC,), + data_source_ids=("ds_requests",), + required_fields=("service.name",), + ) + + +def test_observability_skill_taxonomy_is_complete_and_deterministic() -> None: + descriptors = observability_skill_descriptors() + assert tuple(descriptor.skill for descriptor in descriptors) == tuple(AgentSkill) + assert observability_skill_descriptors() is descriptors + assert all(descriptor.constraints for descriptor in descriptors) + + +def test_telemetry_contract_rejects_duplicate_fields() -> None: + field = TelemetryField( + name="service.name", + signals=(TelemetrySignal.TRACE,), + description="Stable service identity", + semantic_convention="service.name", + ) + with pytest.raises(ValueError, match="must be unique"): + TelemetryContract( + contract_id="portable-observability-v1", + description="Portable telemetry", + logical_indexes={"otel_traces": TelemetrySignal.TRACE}, + fields=(field, field), + ) + + +def test_catalog_entry_rejects_duplicate_panel_ids() -> None: + with pytest.raises(ValueError, match="must be unique"): + CatalogEntry( + example_id="service_red", + priority="high", + title="Service RED", + description="Service request health", + minimum_target="9.4.3", + telemetry_contract="portable-observability-v1", + frameworks=(ObservabilityFramework.RED,), + signals=(TelemetrySignal.METRIC,), + logical_indexes=("otel_metrics",), + panels=(_panel(), _panel()), + ) diff --git a/tests/test_generation.py b/tests/test_generation.py index 8fb0fe1..e928cb9 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -53,12 +53,33 @@ def test_builder_supports_search_saved_search_chain_inputs_and_properties() -> N name="Host", input_id="input_host", options={"token": "host"}, + data_sources={"primary": saved}, + title="Host", + context={"label": "host"}, + container_options={"visibility": {"show": True}}, ) builder.add_visualization( "splunk.table", name="top", data_sources={"primary": chain}, + description="Top hosts", + context={"rowColors": ["#ffffff"]}, + event_handlers=[{"type": "drilldown.setToken", "options": {"token": "host"}}], + container_options={"visibility": {"show": True}}, ) + builder.set_data_source_defaults( + "ds.search", + { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$", + } + } + }, + ) + builder.set_visualization_defaults("global", {"showProgressBar": True}) + builder.set_token_default("host", "*") builder.set_application_property("collapseNavigation", True) builder.set_expression("condition_visible", {"name": "visible", "value": "true()"}) definition = builder.build() @@ -70,8 +91,27 @@ def test_builder_supports_search_saved_search_chain_inputs_and_properties() -> N assert definition.data_sources[chain].options["extend"] == base assert definition.data_sources[saved].options["ref"] == "Weekly report" assert definition.layout.global_inputs == [input_id] + assert definition.inputs[input_id].data_sources == {"primary": saved} + assert definition.inputs[input_id].container_options == {"visibility": {"show": True}} assert definition.application_properties["collapseNavigation"] is True assert "condition_visible" in definition.expressions + assert definition.defaults == { + "dataSources": { + "ds.search": { + "options": { + "queryParameters": { + "earliest": "$global_time.earliest$", + "latest": "$global_time.latest$", + } + } + } + }, + "tokens": {"default": {"host": {"value": "*"}}}, + "visualizations": {"global": {"showProgressBar": True}}, + } + visualization = definition.visualizations["viz_top"] + assert visualization.description == "Top hosts" + assert visualization.event_handlers is not None def test_builder_rejects_duplicate_ids_and_invalid_result() -> None: @@ -95,3 +135,49 @@ def test_custom_canvas_height_is_preserved() -> None: definition = builder.build(canvas_width=1200, canvas_height=500) layout = definition.layout.layout_definitions["layout_main"] assert layout.options == {"width": 1200, "height": 500} + + +@pytest.mark.parametrize( + ("configure", "message"), + [ + ( + lambda builder: ( + builder.set_data_source_defaults("global", {"options": {}}), + builder.set_data_source_defaults("global", {"options": {}}), + ), + "Duplicate data source default scope", + ), + ( + lambda builder: ( + builder.set_visualization_defaults("global", {}), + builder.set_visualization_defaults("global", {}), + ), + "Duplicate visualization default scope", + ), + ( + lambda builder: ( + builder.set_token_default("service", "*"), + builder.set_token_default("service", "api"), + ), + "Duplicate token default", + ), + ], +) +def test_builder_rejects_duplicate_defaults(configure: object, message: str) -> None: + builder = DashboardBuilder(title="Defaults", target="10.2.0") + with pytest.raises(DashboardGenerationError, match=message): + configure(builder) # type: ignore[operator] + + +@pytest.mark.parametrize( + "configure", + [ + lambda builder: builder.set_data_source_defaults(" ", {}), + lambda builder: builder.set_visualization_defaults("", {}), + lambda builder: builder.set_token_default("", "value"), + lambda builder: builder.set_token_default("name", "value", namespace=" "), + ], +) +def test_builder_rejects_blank_default_names(configure: object) -> None: + with pytest.raises(DashboardGenerationError, match="non-empty"): + configure(DashboardBuilder(title="Defaults", target="10.2.0")) # type: ignore[operator] diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 0000000..5d39537 --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import string + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from pydantic import JsonValue + +from splunk_dashboard_studio.catalog import ( + CatalogTargetUnsupported, + build_catalog_dashboard, + catalog_entries, +) +from splunk_dashboard_studio.codec import StudioView, compare_roundtrip, encode_view_xml +from splunk_dashboard_studio.generation import DashboardBuilder, canonical_json +from splunk_dashboard_studio.graph import MAX_CHAIN_CHILDREN, MAX_CHAIN_DEPTH, analyze_search_graph +from splunk_dashboard_studio.models import DataSource +from splunk_dashboard_studio.validation import validate_dashboard +from splunk_dashboard_studio.version import EnterpriseVersion + +DETERMINISTIC = settings(max_examples=50, deadline=None, derandomize=True) +IDENTIFIER_LABELS = st.text( + alphabet=string.ascii_letters + string.digits + " _-./:@", + min_size=1, + max_size=32, +) +SCOPES = st.from_regex(r"[a-z][a-z0-9_.]{0,20}", fullmatch=True) +JSON_SCALARS: st.SearchStrategy[JsonValue] = st.one_of( + st.none(), + st.booleans(), + st.integers(min_value=-10_000, max_value=10_000), + st.text(alphabet=string.printable, max_size=24), +) + + +@DETERMINISTIC +@given(st.lists(IDENTIFIER_LABELS, min_size=1, max_size=12)) +def test_generated_ids_are_stable_and_unique(labels: list[str]) -> None: + first = DashboardBuilder(title="Identifiers", target="10.2.0") + second = DashboardBuilder(title="Identifiers", target="10.2.0") + first_ids = [first.add_search("| makeresults | stats count", name=label) for label in labels] + second_ids = [second.add_search("| makeresults | stats count", name=label) for label in labels] + assert first_ids == second_ids + assert len(first_ids) == len(set(first_ids)) + + +@DETERMINISTIC +@given(depth=st.integers(min_value=0, max_value=6)) +def test_chain_depth_property(depth: int) -> None: + sources = {"base": DataSource(type="ds.search", options={"query": "| makeresults"})} + parent = "base" + for index in range(depth): + child = f"child_{index}" + sources[child] = DataSource( + type="ds.chain", + options={"extend": parent, "query": "| head 1"}, + ) + parent = child + codes = {issue.code for issue in analyze_search_graph(sources).issues} + assert ("chain_depth_exceeded" in codes) is (depth > MAX_CHAIN_DEPTH) + + +@DETERMINISTIC +@given(fanout=st.integers(min_value=0, max_value=15)) +def test_chain_fanout_property(fanout: int) -> None: + sources = {"base": DataSource(type="ds.search", options={"query": "| makeresults"})} + sources.update( + { + f"child_{index}": DataSource( + type="ds.chain", + options={"extend": "base", "query": "| head 1"}, + ) + for index in range(fanout) + } + ) + codes = {issue.code for issue in analyze_search_graph(sources).issues} + assert ("chain_fanout_exceeded" in codes) is (fanout > MAX_CHAIN_CHILDREN) + + +@DETERMINISTIC +@given(existing=st.booleans()) +def test_visualization_reference_property(existing: bool) -> None: + builder = DashboardBuilder(title="References", target="10.2.0") + source = builder.add_search("| makeresults | stats count", data_source_id="ds_source") + builder.add_visualization( + "splunk.table", + name="Reference", + data_sources={"primary": source if existing else "ds_missing"}, + ) + definition = builder.build(validate=False) + codes = {issue.code for issue in validate_dashboard(definition, target="10.2.0").issues} + assert ("visualization_data_source_missing" in codes) is (not existing) + + +@DETERMINISTIC +@given( + scopes=st.lists(SCOPES, min_size=1, max_size=8, unique=True), + values=st.lists(JSON_SCALARS, min_size=1, max_size=8), +) +def test_defaults_and_canonical_json_are_order_stable( + scopes: list[str], + values: list[JsonValue], +) -> None: + pairs = list(zip(scopes, values, strict=False)) + first = DashboardBuilder(title="Defaults", target="10.2.0") + second = DashboardBuilder(title="Defaults", target="10.2.0") + for scope, value in pairs: + first.set_visualization_defaults(scope, {"value": value}) + for scope, value in reversed(pairs): + second.set_visualization_defaults(scope, {"value": value}) + assert canonical_json(first.build()) == canonical_json(second.build()) + + +@DETERMINISTIC +@given( + example_id=st.sampled_from(tuple(entry.example_id for entry in catalog_entries())), + target=st.sampled_from(("9.4.3", "10.0.0", "10.2.0", "10.4.0")), +) +def test_catalog_target_support_property(example_id: str, target: str) -> None: + entry = next(entry for entry in catalog_entries() if entry.example_id == example_id) + if EnterpriseVersion.parse(target) < entry.minimum_target: + with pytest.raises(CatalogTargetUnsupported): + build_catalog_dashboard(example_id, target) + else: + assert build_catalog_dashboard(example_id, target).title == entry.title + + +@DETERMINISTIC +@given( + label=st.text( + alphabet=string.ascii_letters + string.digits + " &<>'\"é", + min_size=1, + max_size=30, + ), + prefix=st.text(alphabet=string.ascii_letters + " &<>", max_size=20), + suffix=st.text(alphabet=string.ascii_letters + " &<>", max_size=20), +) +def test_offline_codec_roundtrip_property(label: str, prefix: str, suffix: str) -> None: + builder = DashboardBuilder( + title="Codec property", + description=f"{prefix}]]>{suffix}", + target="10.2.0", + ) + view = StudioView(label=label, definition=builder.build(), description=f"{suffix}]]>{prefix}") + assert compare_roundtrip(view, encode_view_xml(view)).equivalent diff --git a/tests/test_release_evidence.py b/tests/test_release_evidence.py new file mode 100644 index 0000000..143c6ec --- /dev/null +++ b/tests/test_release_evidence.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.release_evidence import build_release_evidence, verify_release_tag + + +def test_release_tag_matches_project_and_runtime_versions() -> None: + assert verify_release_tag("v0.2.0") == "0.2.0" + with pytest.raises(ValueError, match="must equal"): + verify_release_tag("v0.2.1") + + +def test_release_evidence_requires_both_distribution_formats(tmp_path: Path) -> None: + (tmp_path / "splunk_dashboard_studio_python-0.2.0-py3-none-any.whl").write_bytes(b"wheel") + with pytest.raises(ValueError, match="requires exactly one sdist"): + build_release_evidence("v0.2.0", tmp_path) + + +def test_release_evidence_is_deterministic(tmp_path: Path) -> None: + (tmp_path / "splunk_dashboard_studio_python-0.2.0-py3-none-any.whl").write_bytes(b"wheel") + (tmp_path / "splunk_dashboard_studio_python-0.2.0.tar.gz").write_bytes(b"sdist") + first = build_release_evidence("v0.2.0", tmp_path) + second = build_release_evidence("v0.2.0", tmp_path) + assert first == second + assert first["schema_version"] == "release-evidence/v1" + assert len(first["catalog"]) == 10 + + +def test_release_evidence_rejects_stale_artifact_versions(tmp_path: Path) -> None: + (tmp_path / "splunk_dashboard_studio_python-0.1.0-py3-none-any.whl").write_bytes(b"wheel") + (tmp_path / "splunk_dashboard_studio_python-0.1.0.tar.gz").write_bytes(b"sdist") + with pytest.raises(ValueError, match="does not match release"): + build_release_evidence("v0.2.0", tmp_path) diff --git a/tests/test_schema_corpus_cli.py b/tests/test_schema_corpus_cli.py index 03a3a16..db293f0 100644 --- a/tests/test_schema_corpus_cli.py +++ b/tests/test_schema_corpus_cli.py @@ -8,7 +8,11 @@ from splunk_dashboard_studio.cli import main from splunk_dashboard_studio.corpus import corpus_jsonl, generate_corpus, write_corpus from splunk_dashboard_studio.models import DashboardDefinition, Layout -from splunk_dashboard_studio.schema import agent_contract_schema, dashboard_definition_schema +from splunk_dashboard_studio.schema import ( + agent_contract_schema, + dashboard_definition_schema, + schema_bundle, +) def test_agent_schema_contains_aliases_and_capability_extension() -> None: @@ -19,6 +23,17 @@ def test_agent_schema_contains_aliases_and_capability_extension() -> None: agent_schema = agent_contract_schema() assert agent_schema["properties"]["target"] assert agent_schema["x-splunk-enterprise"]["minimum_supported"] == "9.4.3" + assert len(agent_schema["x-observability-skills"]) == 8 + + bundle = schema_bundle() + assert bundle["schema_version"] == "splunk-dashboard-studio-schema-bundle/v1" + assert set(bundle["schemas"]) == { + "agent", + "artifact_bundle", + "dashboard", + "skill_descriptor", + "telemetry_contract", + } def test_layout_shape_must_be_complete() -> None: @@ -40,7 +55,7 @@ def test_corpus_is_deterministic_and_writable(tmp_path: Path) -> None: assert first == second assert first.endswith("\n") cases = [json.loads(line) for line in first.splitlines()] - assert len(cases) == 8 + assert len(cases) == 17 assert {case["case_id"] for case in cases} == { case.case_id for case in generate_corpus("10.2.0") } @@ -61,9 +76,12 @@ def test_cli_schema_profiles_and_corpus(capsys: pytest.CaptureFixture[str], tmp_ assert main(["schema", "agent"]) == 0 assert "x-splunk-enterprise" in json.loads(capsys.readouterr().out) + assert main(["schema", "bundle"]) == 0 + assert json.loads(capsys.readouterr().out)["schema_version"].endswith("/v1") + output = tmp_path / "generated.jsonl" assert main(["corpus", "--target", "9.4.3", "--output", str(output)]) == 0 - assert len(output.read_text(encoding="utf-8").splitlines()) == 8 + assert len(output.read_text(encoding="utf-8").splitlines()) == 17 def test_cli_validate_optimize_and_error( diff --git a/tests/test_validation.py b/tests/test_validation.py index 2c121e4..c94f733 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -85,7 +85,7 @@ def test_versioned_feature_boundaries( elif mutation == "custom": payload["visualizations"]["viz_events"]["type"] = "viz.example.custom" else: - payload["visualizations"]["viz_events"]["type"] = "splunk.networkgraph" + payload["visualizations"]["viz_events"]["type"] = "splunk.networkGraph" older_report = validate_dashboard(payload, target=older) issue = next(issue for issue in older_report.issues if issue.code == "feature_not_available") diff --git a/uv.lock b/uv.lock index 6817c27..94ff727 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.14" +requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version < '3.15'", @@ -71,6 +71,36 @@ version = "7.15.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, @@ -122,6 +152,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/00/0d47e996ccbfa1eceb66d285b6fbf248c7c020e4e18b1bea09b18f05f6f5/hypothesis-6.160.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:64cf59670080aeb3c6048d62df0f6352586410745d14d7045a692eb5d2245110", size = 1307495, upload-time = "2026-07-22T14:11:33.978Z" }, { url = "https://files.pythonhosted.org/packages/1a/b8/01f731cfcf9fc475adbde3c328d0c8f1d24952b4dd2a5049e7156aa64d9c/hypothesis-6.160.0-cp310-abi3-win32.whl", hash = "sha256:993c26c81e9cc9f291cdb64f54aa8f31507d2d472d0f1334f8ba9e7d77666911", size = 651991, upload-time = "2026-07-22T14:11:21.375Z" }, { url = "https://files.pythonhosted.org/packages/87/12/95216fe9a84cafc9bc721b4352cf9b78bf0e9089f278811fbd58c76dbe3f/hypothesis-6.160.0-cp310-abi3-win_amd64.whl", hash = "sha256:95a4b0e1faa366d0cc9d7ce261773cec69f4f130b845ca33b71c22c85493c35d", size = 658114, upload-time = "2026-07-22T14:10:54.298Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/bc800c4925c1f47b61c17f78e57bb58a8743d03da28de13f59cba148daf2/hypothesis-6.160.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:18e058b34f4514da8b2ce15ebee9e6e98d3a95067665accf394415824934f790", size = 767730, upload-time = "2026-07-22T14:11:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/37/b6/d34a7f990eb0a38933a7f6b14d261fda990faef37122e71797b0043fa371/hypothesis-6.160.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b38697f797e9406e20e03cd79e1a69c7ac714e7e244f13121d39b44f27f7ed3", size = 759362, upload-time = "2026-07-22T14:11:05.77Z" }, + { url = "https://files.pythonhosted.org/packages/df/bf/48bd2bf246d22f188c82dbf3682832fc14fa4e6069c5415b1e8a473397a7/hypothesis-6.160.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef7d9e8022a8dd2afa2bfbf6580f21a7fd8b4798d20c027f4afb048d780414fd", size = 1089731, upload-time = "2026-07-22T14:11:39.069Z" }, + { url = "https://files.pythonhosted.org/packages/76/a0/d557bd44f611ec2516c69b6ada1e65f96c4d9d1dbad63f12b1799ca682b8/hypothesis-6.160.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4716ceb2adc72ea20138cd6a5600d102895f46fe95a42d915e032eed54b77ee6", size = 1139776, upload-time = "2026-07-22T14:11:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/32/99/cad454acb11e027773bdba5cb95cb181a46cd1cabb8bfe2f2042e29dc0c5/hypothesis-6.160.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d186b17a25eaf51ebf0376ea9d702dddb4f62cc11c0b5230e0aae77b44f49d3", size = 1262564, upload-time = "2026-07-22T14:11:02.444Z" }, + { url = "https://files.pythonhosted.org/packages/67/e7/61b2e1b6c2f75fa3b791040ba4baf2b617ffaf62ffbafad9463869baf521/hypothesis-6.160.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e5e959bb18ec9b285dcc1d6f455c8860da919b9341842530847e820ed18dbbb", size = 1306756, upload-time = "2026-07-22T14:11:54.464Z" }, + { url = "https://files.pythonhosted.org/packages/89/79/6e9f2da0f298f891930a9fc1ed0559818d4ba840f47ed736c89152fd962e/hypothesis-6.160.0-cp312-cp312-win_amd64.whl", hash = "sha256:ded91bbdd0c3a84903bda3dc08d639b3b3e28c03fb83b568af8e13039042c3c4", size = 655265, upload-time = "2026-07-22T14:10:58.076Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/a05ba058a37681d2aa872abcff9bd7a50c61c6347aedf2e3f5a15b8e932b/hypothesis-6.160.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cb6cd703d38d881505a00e1901844d70d250e90824caa55e0dfaed6c8c7e0244", size = 767604, upload-time = "2026-07-22T14:11:11.346Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a1/33dde1810a52698802fe2e28cfd2696b6aefafdc721cc456dfbc85875bb2/hypothesis-6.160.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9561298d687f9fca38aab451e8eb8a9f18b65a57f81f7331eff5234f0f065dc0", size = 759264, upload-time = "2026-07-22T14:10:40.271Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/573402093577ef0fd86c8156d4c4ecd03b0a5e368e8925074fe565f9faba/hypothesis-6.160.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e19f91119e2e19603210b849508695efabd2a35d6af9ac4d637c1b9a514a52b", size = 1089653, upload-time = "2026-07-22T14:11:37.333Z" }, + { url = "https://files.pythonhosted.org/packages/da/05/c85a35fef75214fc08a27e5099ae51d713c6550252ef7ce4c156780433f1/hypothesis-6.160.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6b73076bb3fbf02001a439a5eb45cdd3db17e2cf6d95f453cfb1f5a97713f5", size = 1139592, upload-time = "2026-07-22T14:12:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/59/53/8f9996fa3a6352edec2c17b743630b6c5f62486db6b43594168a1c0b7571/hypothesis-6.160.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c0dcde9c08f3bdd5318026c57155ce4bfe7615fd27d3eca77a7453cb3ffbba64", size = 1262616, upload-time = "2026-07-22T14:11:14.754Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f7/8b2699131893dd7bcecfe3be9ee758d3939cc8af68374700e68d9df2281b/hypothesis-6.160.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78cb5fcf8518f3a10e888cdff545fa733931e2ff843b02a54e5e0b01b3142f94", size = 1306470, upload-time = "2026-07-22T14:11:23.203Z" }, + { url = "https://files.pythonhosted.org/packages/88/ba/9764eaff70d2a54aa072f709a121f98cf8766fc1591a063f8fab2117b6cf/hypothesis-6.160.0-cp313-cp313-win_amd64.whl", hash = "sha256:e95c3ce8e9c5abd2256854a2e53395fdd91d16cdce8d1621eca8caf5c7a2b1a2", size = 655209, upload-time = "2026-07-22T14:11:17.33Z" }, { url = "https://files.pythonhosted.org/packages/a1/b9/3b92edf73785218f084521c2be9506ce6e5c63a64662cda074e588ff3071/hypothesis-6.160.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9bd3d333a501f1faf8611159a998eb1bb28c43b620822ba6c8b2463f5de2a136", size = 767796, upload-time = "2026-07-22T14:11:28.865Z" }, { url = "https://files.pythonhosted.org/packages/12/c7/eefd510bffc66320015169e2c6669e3a08ea29dda84d81655ecc1c6cbd8c/hypothesis-6.160.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:21ee82802c25282d692eaec7d3b960176c10eb6dc70853b152c5bc6b3b6faf02", size = 759410, upload-time = "2026-07-22T14:10:31.902Z" }, { url = "https://files.pythonhosted.org/packages/1d/e4/6ad1e558d2df6900b0ad9d17081fbed4a74ffb01d86e64813cab4eaf45f1/hypothesis-6.160.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d71e85548be9dd3a6eb59904daa85d5879e337cb69ad42cc2267c05a17ab26", size = 1090131, upload-time = "2026-07-22T14:11:44.448Z" }, @@ -154,6 +198,33 @@ version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, @@ -196,6 +267,20 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, @@ -274,6 +359,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, @@ -304,6 +419,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] @@ -381,7 +500,7 @@ wheels = [ [[package]] name = "splunk-dashboard-studio-python" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pydantic" },