diff --git a/.gitignore b/.gitignore index bba2c25f5a..36112c6cdc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ htmlcov/ *.log artifacts/ dist/ +node_modules/ .DS_Store uv.lock profile.json diff --git a/README.md b/README.md index 42fd91d7f3..04feb25949 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ Log File: /home/user/Code/aiperf/artifacts/granite4:350m-openai-chat-concurrency - [Multi-Run Confidence](docs/tutorials/multi-run-confidence.md) - Confidence intervals across repeated runs - [Profile Exports](docs/tutorials/working-with-profile-exports.md) - Post-processing with Pydantic models - [Visualization and Plotting](docs/tutorials/plot.md) - PNG charts and multi-run comparison +- [Benchmark History Dashboard](docs/tutorials/history-dashboard.md) - GreptimeDB-backed Vue history and cross-run metric curves - [Auto-Plot After Profile](docs/tutorials/auto-plot.md) - Run `aiperf plot` automatically after `aiperf profile` - [GPU Telemetry](docs/tutorials/gpu-telemetry.md) - DCGM metrics collection - [OTel + MLflow Live Telemetry](docs/tutorials/otel-mlflow.md) - Stream metrics to OTel and MLflow in real time @@ -214,6 +215,7 @@ Log File: /home/user/Code/aiperf/artifacts/granite4:350m-openai-chat-concurrency | Document | Purpose | |----------|---------| | [Architecture](docs/architecture.md) | Three-plane architecture, core components, credit system, data flow | +| [History Service Design](docs/dev/history-service-design.md) | GreptimeDB schema, import lifecycle, API/UI boundary, and failure semantics | | [CLI Options](docs/cli-options.md) | Complete command and option reference | | [Metrics Reference](docs/metrics-reference.md) | All metric definitions, formulas, and requirements | | [Environment Variables](docs/environment-variables.md) | All `AIPERF_*` configuration variables | diff --git a/docs/architecture.md b/docs/architecture.md index 8cf46fe8bb..0280c08e18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -369,3 +369,78 @@ sequenceDiagram F->>M: log_metric(live., delta) end ``` + +## Multimodal benchmark extension layer + +Long-running media sessions use protocol-based extension points rather than embedding +WebRTC, WebSocket, or framework-specific logic in the core runner. + +`StreamBenchmarkRunner` consumes a `StreamWorkloadProtocol` and a +`StreamTransportAdapterProtocol`, then emits transport-independent `SessionResult` +records. `ObservationLifecycle` implements the same phase callbacks for any transport +and joins those client phase windows with `ServerMetricsResults` by `benchmark_id`. + +`aiperf profile --stream-config` selects this mode before the request/response service +graph is built. `ContractStreamWorkload` drives the shared runner through either the +built-in WebRTC or MessagePack WebSocket adapter. The profile controller owns those +event-loop-bound media connections and wraps them with the same server-metrics +collectors and aggregation models used by the multiprocess request pipeline. + +```mermaid +flowchart LR + CLI[aiperf profile --stream-config] --> CFG[StreamProfileConfig] + CFG --> CONTRACT[BenchmarkContract] + CONTRACT --> WORKLOAD[ContractStreamWorkload] + WORKLOAD --> RUNNER[StreamBenchmarkRunner] + RUNNER --> ADAPTER[WebRTC or WebSocket adapter] + ADAPTER --> TARGET[Target service] + TARGET --> AGENT[AIPerf resource agent] + AGENT -->|1 s samples / 15 s batches| HISTORY[History API] + TARGET --> PROM[Prometheus endpoint] + PROM --> COLLECTOR[ServerMetricsDataCollector] + RUNNER --> ARTIFACTS[Sessions and events] + COLLECTOR --> NORMALIZED[Raw and normalized metrics] + ARTIFACTS --> MANIFEST[Observation manifest] + NORMALIZED --> MANIFEST +``` + +Native Prometheus aggregates are converted to `SourceMetricObservation` records and +processed by versioned `SemanticMetricMapping` rules. The mapper produces a derived +view while raw server-metrics exports remain authoritative. See +[Multimodal Generation and Observability Contracts](benchmark-modes/multimodal-generation.md) +for interfaces, artifact semantics, and current integration status. + +## Benchmark history service + +The standalone `aiperf history` command is outside the per-run ZMQ service graph. It +indexes completed artifacts, accepts strict active resource batches, writes to a required +GreptimeDB backend, and exposes a FastAPI API plus a bundled Vue application. JSON and +JSONL artifacts remain replayable benchmark facts; GreptimeDB is the only query path used +by the API and UI. + +```mermaid +flowchart LR + P[Profile exports] --> IMPORT[History importer] + S[Stream artifacts] --> IMPORT + A[Target-side AIPerf resource agent] -->|Versioned batch POST| API + IMPORT --> RUNS[(GreptimeDB runs)] + IMPORT --> POINTS[(GreptimeDB metric points)] + RUNS --> API[History FastAPI] + POINTS --> API + API --> VUE[Vue and ECharts] +``` + +Metric points use framework-neutral dimensions (`run_id`, metric, statistic, scope, +phase, session, sample, device, and source). Target-specific parsing stops at artifact +normalization; neither API queries nor frontend rendering branch on TeleFuser or SGLang. +Artifact replacement preserves source-timestamped `resource` points received during the +run. The agent is common to both targets and includes the configured PID's descendants. +It also records machine Ethernet and RDMA receive/transmit rates plus discoverable link +capacity under one canonical network metric. +GreptimeDB startup failures propagate and prevent the service from becoming ready. +There is no SQLite, in-memory, or direct-filesystem query fallback. + +See [Benchmark History Service Design](dev/history-service-design.md) for ownership, +schema, revision, query, recovery, and extension decisions, and +[Benchmark History Dashboard with GreptimeDB](tutorials/history-dashboard.md) for the +operator workflow. diff --git a/docs/benchmark-modes/multimodal-generation.md b/docs/benchmark-modes/multimodal-generation.md new file mode 100644 index 0000000000..f55e105a95 --- /dev/null +++ b/docs/benchmark-modes/multimodal-generation.md @@ -0,0 +1,465 @@ + + +# Multimodal Generation and Observability Contracts + +AIPerf owns the reusable benchmark interfaces for asynchronous media generation, +long-running streaming sessions, and cross-system observability. Target repositories +provide services, workload assets, and declarative contracts; they do not implement +their own metric collectors, semantic mappers, or result aggregators. + +The implementation described here is maintained in the `ActivePeter/aiperf` `teleai` +branch. It is intentionally framework-neutral: TeleFuser is the first target mapping, +not a dependency of AIPerf's core interfaces. + +## Scope and ownership + +| Concern | Owner | Boundary | +|---|---|---| +| Request and session lifecycle | AIPerf | Common runner and lifecycle protocols | +| Wire protocol | AIPerf adapter | WebRTC, WebSocket, SSE, or HTTP implementation | +| Workload semantics | Declarative target contract | Model, task, media shape, controls, and limits | +| Client-side metrics | AIPerf | Request, session, frame, and control-event clocks | +| Server-side collection | AIPerf `server_metrics` | Prometheus scrape, raw storage, aggregation, and export | +| Active target resources | AIPerf `resource_telemetry` | Local PID tree and host/device sampling with required history upload | +| Semantic normalization | AIPerf `observability` | Versioned framework mappings and explicit availability states | +| Target service | Framework repository | Inference and a stable Prometheus endpoint only | + +Target repositories may keep thin launchers and target-specific test data. Any logic +that schedules sessions, calculates shared metrics, maps native names, or writes common +artifacts belongs in AIPerf so another inference system can reuse it unchanged. + +## Architecture + +```mermaid +flowchart LR + C[BenchmarkContract] --> W[StreamWorkloadProtocol] + W --> R[StreamBenchmarkRunner] + R --> A[StreamTransportAdapterProtocol] + A --> T[Target service] + T --> RA[AIPerf resource agent] + RA -->|Source-timestamped batches| H[History API and GreptimeDB] + R --> E[SessionResult and client events] + R --> L[ObservationLifecycle] + + T --> P[Prometheus endpoint] + P --> S[ServerMetricsManager] + S --> X[ServerMetricsExportData] + X --> N[SourceMetricObservation] + N --> M[SemanticMetricMapper] + M --> D[NormalizedMetricObservation] + + L --> O[ObservationManifest] + E --> O + S --> O + D --> O +``` + +Client events are authoritative for end-to-end latency, FPS, and control-loop metrics. +Server metrics explain queueing, scheduling, resource, and cache behavior. A temporal +overlap between the two is correlation, not proof that one server sample caused one +client event. + +## Public interfaces + +### Streaming + +| Interface | Module | Purpose | +|---|---|---| +| `BenchmarkContract` | `aiperf.streaming.contracts` | Typed, transport-neutral target contract | +| `StreamSessionPlan` | `aiperf.streaming.models` | One transport-independent session plan | +| `SessionResult` | `aiperf.streaming.models` | One normalized session result | +| `StreamWorkloadProtocol` | `aiperf.streaming.protocols` | Builds session plans for a phase | +| `StreamTransportAdapterProtocol` | `aiperf.streaming.protocols` | Runs one plan over a concrete wire protocol | +| `ManagedStreamTransportAdapterProtocol` | `aiperf.streaming.protocols` | Adds health checks and profile-owned resource cleanup | +| `StreamPhaseLifecycleProtocol` | `aiperf.streaming.protocols` | Receives phase start, completion, and cancellation events | +| `StreamBenchmarkRunner` | `aiperf.streaming.runner` | Applies common pacing, failure handling, and lifecycle notifications | +| `ContractStreamWorkload` | `aiperf.streaming.workload` | Builds session plans and phase counts from a target contract | +| `TeleFuserWebRTCAdapter` | `aiperf.streaming.adapters.telefuser_webrtc` | Drives SDP, RTP video, DataChannel controls, and target cleanup | +| `SGLangWebSocketAdapter` | `aiperf.streaming.adapters.sglang_websocket` | Drives MessagePack init, controls, chunk stats, and frame batches | +| `StreamProfileConfig` | `aiperf.streaming.config` | Validates the first-class stream profile configuration | + +The runner catches ordinary adapter failures and emits failed `SessionResult` records, +so one failed session does not erase the rest of the phase. Cancellation is propagated +after the lifecycle is notified. + +```python +runner = StreamBenchmarkRunner( + workload=workload, + transport=transport_adapter, + lifecycle=observation_lifecycle, +) +result = await runner.run( + StreamRunPlan(warmup_sessions=1, profile_sessions=10, stagger_s=0.5) +) +``` + +Transport adapters own encoding and connection mechanics only. They must not redefine +warmup semantics, percentile formulas, availability states, or artifact schemas. + +### Profile command and configuration + +The standard profile command dispatches to the stream-world execution path when +`--stream-config` is present: + +```bash +uv sync --extra streaming-webrtc +uv run aiperf profile --stream-config stream-profile.json +``` + +The WebSocket adapter uses the base AIPerf installation. The optional +`streaming-webrtc` extra installs `aiortc` for WebRTC targets. + +```json +{ + "contract": "targets/telefuser-stream.yaml", + "server_url": "http://127.0.0.1:8088", + "prompt": "walk forward through the scene", + "session_count": 4, + "warmup_sessions": 1, + "session_duration_s": 90.0, + "control_trace_path": "workloads/controls.json", + "transport": { + "connect_timeout_s": 60.0, + "ice_host_ips": ["auto"] + }, + "server_metrics": { + "enabled": true, + "urls": ["http://127.0.0.1:8088/v1/service/metrics"] + }, + "observability": { + "mapping": "builtin:telefuser" + }, + "resource_telemetry": { + "enabled": true, + "history_url": "http://127.0.0.1:8095", + "target_pid": 12345, + "sample_interval_s": 1.0, + "upload_interval_s": 15.0 + }, + "artifacts_dir": "artifacts/stream-world" +} +``` + +The contract selects an adapter by name, for example `adapter: telefuser_webrtc` or +`adapter: sglang_websocket`. External integrations can call +`register_stream_adapter()` before execution to add another target without changing the +runner. CLI overrides are available for the target URL, artifact root, ICE host IPs, +Prometheus URLs, resource history URL, and local target PID. Resource mode requires the +profile command to run on the target PID's host; recursive children are included. + +`ice_host_ips` defaults to `["auto"]`: AIPerf asks the operating-system routing +table which local source address reaches `server_url` and exposes only that address +as an ICE host candidate. This avoids selecting an unrelated NIC on multi-homed +hosts. Supply explicit addresses when a relay or container topology requires them, +or `[]` to retain every address discovered by aioice. + +Stream mode deliberately keeps the media session runner in the profile controller +process: WebRTC peer connections and WebSocket objects are event-loop-owned and are not +sent over the ZMQ request worker bus. Prometheus collection still reuses AIPerf's +`ServerMetricsDataCollector`, accumulator, finite-value discipline, and export models. + +### Observability + +| Interface | Module | Purpose | +|---|---|---| +| `ObservationRunIdentity` | `aiperf.observability.models` | Run, implementation, model, commit, and contract identity | +| `ObservationLifecycle` | `aiperf.observability.lifecycle` | Paired wall/monotonic phase windows and completion state | +| `SourceMetricObservation` | `aiperf.observability.models` | Native aggregated metric series before mapping | +| `SemanticMetricMapping` | `aiperf.observability.models` | Versioned declarative framework rules | +| `SemanticMetricMapper` | `aiperf.observability.mapping` | Produces normalized observations without changing raw data | +| `ObservationManifest` | `aiperf.observability.models` | Versioned provenance and completeness envelope | +| `ObservationManifestExporter` | `aiperf.observability.exporter` | Asynchronous JSON manifest writer | + +`server_metrics_export_to_observations()` converts existing +`ServerMetricsExportData` into mapper input. `ObservationLifecycle` accepts the matching +`ServerMetricsResults`, verifies `benchmark_id`, and records configured, successful, and +failed endpoint state in the manifest. + +## Target exposure protocol + +Targets expose Prometheus text exposition over HTTP. A target-specific JSON metrics API +is not part of the AIPerf contract. + +- A series identity is `endpoint + metric name + complete native label set`. +- AIPerf retains native names, types, descriptions, labels, and aggregate statistics. +- Counter and histogram baselines bracket the profiling window. +- Summary families are not used for benchmark-window percentiles because their + quantiles cover the target process lifetime. +- Prompt text, user identifiers, media paths, request IDs, and session IDs must not be + unbounded Prometheus labels. +- Endpoint credentials are redacted before they enter observability records. + +Collection is fail-open for the workload and fail-visible in artifacts. An unreachable +endpoint cannot stop the request/session workload, but it makes the observation result +`partial` or `disabled`. + +## Phase and clock protocol + +Every benchmark mode reports the same logical phases: + +| Phase | Observation behavior | +|---|---| +| Configure | Validate contracts and discover sources; no performance statistics | +| Baseline | Capture counter and histogram state before measured work | +| Warmup | Retain optional raw data but exclude it from formal aggregates | +| Profiling | Collect client events and server metrics in the measured window | +| Complete | Perform the final scrape, close the phase window, and export | +| Cancel | Close an active window and mark the manifest incomplete | + +Client durations use one process's monotonic clock. Each phase also stores a wall-clock +anchor for correlation with server scrapes. AIPerf uses the observed first-byte time for +Prometheus snapshots rather than trusting the target host clock. + +Each streaming session has two monotonic anchors. The end-to-end anchor starts before +connection setup and remains authoritative for offer RTT, connected latency, +first-frame latency, first-metadata latency, and total runtime. The active-window anchor +starts after WebSocket initialization, or after WebRTC is connected and its DataChannel +is open for a bidirectional session. `session_duration_s`, control-trace delays, and +control send offsets use the active anchor, so slow negotiation cannot consume the +measured media window or cause controls to be replayed in a burst. + +### Target-reported phase and chunk facts + +Client clocks remain authoritative for end-to-end latency and delivered FPS. Some +measurements, however, can only be made inside the target process. Stream adapters +normalize these bounded facts into transport-independent models: + +| Model | Required fields | Meaning | +|---|---|---| +| `StreamPhaseMeasurement` | `name`, `seconds` | Target-monotonic duration for initialization or another named phase | +| `StreamChunkMeasurement` | `index`, `frames`, `compute_seconds` | Per-chunk compute and optional output-path facts before client delivery | +| `StreamDeviceMemoryMeasurement` | `device` and at least one peak field | Optional allocated/reserved allocator peaks after a target-side reset at the phase or chunk boundary | + +`StreamChunkMeasurement` optionally retains request preparation, encoding, output +pacing, header/payload/total transport-write, complete chunk duration, raw/wire byte, +output-batch, content-type, and device-memory facts. Session records also retain a finite +`runtime_metadata` mapping for dimensions, attention settings, and cache capacity. A +contract may declare `endpoint.metadata_path`; AIPerf snapshots that JSON object once as +`target_metadata.json` for service-startup phases and software/hardware identity. This +endpoint is descriptive metadata, not a replacement for Prometheus time-series +collection. + +For the current TeleFuser LingBot actor graph, `compute_seconds` spans target submission +through encode, denoise, decode, and raw-frame return. Native WebRTC media encoding runs +after that chunk fact, so TeleFuser leaves `encode_seconds` unavailable rather than +reporting zero. Generation executes in child actors, so the service process also omits +its process-local allocator peaks instead of presenting them as graph-wide measurements; +active resource telemetry provides separate process-tree GPU-memory curves. Targets such +as SGLang may report a bounded output-build phase separately. + +Target-reported durations must be computed from one target monotonic clock. AIPerf +never subtracts a target timestamp from a client timestamp. The profile option +`warmup_chunks` (default `1`) excludes that many leading chunks from every measured +session before computing chunk mean, standard deviation, percentiles, weighted compute +FPS, and peak-memory summaries. Raw warmup chunks remain in `sessions.jsonl`. + +The report keeps the following concepts separate: + +- `stream_fps`: frames delivered to and decoded by the client over the media transport; +- `chunk_compute_fps`: target-produced frames divided by target compute time; +- `chunk_encode_seconds`: target serialization or media-encoding work; +- `session_runtime_s`: the complete client-observed session lifetime. + +This distinction prevents transport pacing from being presented as model throughput and +prevents target compute time from being presented as user-visible latency. + +### SGLang-Diffusion native chunk mapping + +The SGLang WebSocket adapter consumes the native `chunk_stats` message directly; the +target does not need to import AIPerf or emit an AIPerf-specific envelope. Millisecond +durations are converted to seconds before strict finite-value validation. + +| Native SGLang field | Shared AIPerf field | Meaning | +|---|---|---| +| `request_prepare_ms` | `request_prepare_seconds` | Request construction before scheduler execution | +| `scheduler_forward_ms` | `compute_seconds` | Scheduler/pipeline generation compute, excluding output conversion | +| `raw_payload_build_ms` | `encode_seconds` | Media conversion, compression, and payload construction | +| `pace_wait_ms` | `output_pacing_seconds` | Intentional realtime delivery wait | +| `header_write_ms` | `output_header_write_seconds` | WebSocket header/message framing write time | +| `raw_write_ms` | `output_payload_write_seconds` | Media payload write time | +| `ws_write_ms` | `output_write_seconds` | Total WebSocket write duration | +| `chunk_total_ms` | `total_seconds` | Complete target-side chunk lifecycle | +| `num_frames` / `num_batches` | `frames` / `output_batches` | Produced frames and transport batches | +| `raw_bytes` / `ws_payload_bytes` | `raw_output_bytes` / `wire_output_bytes` | Unencoded media and WebSocket payload bytes | +| `content_type` | `output_content_type` | Bounded output media type | +| `memory_device` / `peak_memory_mb` | `memory[].device` / `memory[].peak_reserved_bytes` | Reset-scoped SGLang rank-0 allocator peak; MiB is converted to bytes | + +SGLang's existing `OutputBatch.peak_memory_mb` is populated from +`max_memory_reserved()` after the worker resets allocator statistics for the request. +The realtime protocol therefore maps it only to `peak_reserved_bytes`. +`peak_allocated_bytes` remains unavailable rather than copying the reserved value into +a different allocator concept. + +The adapter waits for both the final frame batch and its matching `chunk_stats` before +honoring `max_chunks`; otherwise the final target measurement would be lost because +SGLang sends statistics after media delivery. + +Current target-side coverage is explicit: + +| Target | Client/session metrics | Chunk compute/output | Init phases | Allocator peaks | Runtime/cache facts | +|---|---|---|---|---|---| +| TeleFuser LingBot WebRTC | Observed | Compute observed through `measurement`; native WebRTC encoding unavailable | Observed | Unavailable across the current child-actor boundary | Observed | +| SGLang-Diffusion LingBot WebSocket | Observed | Observed from native `chunk_stats` | Not reported by the current endpoint | Reserved peak observed when `chunk_stats.peak_memory_mb` is present; allocated peak unavailable | Model identity from `/v1/models`; request configuration remains in `stream_config.json` | +| Transport mocks | Observed synthetic transport behavior | Synthetic protocol-validation facts only | Unsupported | Unsupported | Mock identity only | + +The 2026-07-13 1xH100 validation also fixes the deployment boundary. SGLang must +use `performance_mode=speed` for the GPU-resident baseline because `auto` may enable +VAE layerwise offload even when the component CPU-offload flags are false. With no +CPU/layerwise offload, the 9-sink / 18-frame cache workload failed with CUDA OOM; +the separately labeled 6-sink / 9-frame tuned workload completed 1/1 measured +sessions at 5.1906 target compute FPS and 5.6788 client stream FPS. Its steady-state +reserved allocator peak was 76,919,341,056 bytes. The two cache geometries are not a +same-configuration comparison. The test host required a PyTorch RoPE fallback because +its FlashInfer CUDA compiler and headers were incompatible, so that qualification is +part of the result provenance. + +AIPerf leaves unreported target facts absent (`null` in aggregate metric entries). It +does not substitute client timing, process-lifetime memory peaks, or configured request +values for target observations. + +## Semantic mapping protocol + +Native metrics remain the source of truth. Normalized metrics are a versioned derived +view that can be recomputed from raw exports. + +A histogram with `count=0` during the profiling window is reported as `missing`, because +there is no latency sample to normalize. `invalid` is reserved for malformed or +non-finite native data and type/unit mismatches; AIPerf never invents a zero latency for +an empty histogram. + +```yaml +schema_version: "1.0" +mapping_version: telefuser-v1 +framework: telefuser +rules: + - source_metric: telefuser_queue_size + target_metric: scheduler.queue_depth + source_type: gauge + target_unit: tasks + statistics: + - source: avg + target: avg + - source: max + target: max + label_allowlist: [] +``` + +Mappings validate native metric type and optional source unit, rename selected aggregate +statistics, and drop labels that are not allowlisted. Framework branches do not belong +in the Prometheus collector. + +AIPerf ships the initial TeleFuser mapping at +`aiperf.observability.mappings/telefuser.yaml`. Load packaged rules with +`load_builtin_semantic_mapping("telefuser")`; load custom rules from a safe path with +`load_semantic_mapping(path)`. + +### Availability states + +Every normalized metric carries a state independently of its value: + +| State | Meaning | +|---|---| +| `observed` | All selected native statistics passed validation | +| `unsupported` | The target capability explicitly does not provide the metric | +| `missing` | The target claims support but no matching native metric was observed | +| `invalid` | Type, unit, or required statistics did not match the rule | +| `partial` | Only part of the requested statistics or collection window is valid | + +Missing states are never converted to numeric zero. Reports must carry both value and +state so a real zero remains distinguishable from unavailable data. + +## Artifacts and versioning + +Existing server-metrics exports remain unchanged: + +- `server_metrics_export.jsonl`: optional raw scrape snapshots. +- `server_metrics_export.parquet`: queryable time series and counter/histogram deltas. +- `server_metrics_export.json` and `.csv`: profiling-window aggregates. + +The shared layer adds `observability_manifest.json`, which records schema version, +AIPerf version and commit, contract digest, mapping versions, phase windows, endpoint +completeness, artifacts, and counted errors. + +Additive optional fields increment a minor schema version. Removing fields or changing +types, units, or statistical meaning requires a major version. Mapping versions are +independent from artifact schema versions, allowing raw data to be remapped without +rerunning a benchmark. + +A completed stream profile writes the following run-local artifacts: + +- `benchmark_contract.json` and `stream_config.json`: resolved reproducibility inputs. +- `target_metadata.json`: optional target startup phases, environment, and static runtime + identity captured through the contract-declared metadata endpoint. +- `summary.json`: aggregate session, frame, and control-loop metrics. +- `sessions.jsonl`: one normalized record per warmup or measured session, including raw + target phase, chunk, memory, and runtime metadata facts when available. +- `events/*.jsonl`: per-session transport and control timeline. +- `server_metrics_export.jsonl`: optional raw Prometheus snapshots. +- `server_metrics_export.json`: profiling-window server aggregates. +- `normalized_metrics.json`: versioned semantic mapping results with explicit states. +- `stream_report.html`: self-contained AIPerf UI for session, client, and normalized + server metrics; it contains no target-specific rendering code. +- `observability_manifest.json`: identity, clock windows, endpoint completeness, errors, + mapping versions, and artifact links. + +## Implemented capabilities + +- typed contracts and stream session models; +- workload, transport, result collector, and lifecycle protocols; +- the common stream runner; +- semantic mapping and explicit availability states; +- conversion from aggregated server metrics; +- phase/server-metrics manifest construction and asynchronous manifest export; +- a built-in TeleFuser semantic mapping; +- built-in TeleFuser WebRTC and SGLang MessagePack WebSocket adapters; +- native SGLang-Diffusion `chunk_stats` normalization, including output-path timing, + byte-count, and optional reset-scoped reserved-memory facts; +- `aiperf profile --stream-config` mode dispatch and typed configuration; +- shared session/event/summary artifact export; +- target phase/chunk normalization with per-session warmup exclusion; +- automatic self-contained stream HTML report generation, including target phases, + chunks, memory peaks, runtime metadata, and environment identity; +- profiling-window Prometheus collection through the existing server-metrics stack; +- automatic server export, normalized metrics, and observability manifest generation; +- GreptimeDB-backed historical indexing for aggregate, session, control, phase, chunk, + GPU, raw server, normalized, and active resource points; +- a common TeleFuser/SGLang target-resource agent with one-second PID-tree, target-cgroup, + host, and NVML samples, 15-second active batches, source UTC timestamps, bounded + retries, and final flush; +- a framework-neutral Vue and ECharts history UI served by `aiperf history serve`. + +Target repositories now need only contracts, workload data, service configuration, and +optional thin launchers. Adapter, scheduling, aggregation, observability, and common +artifact logic remain in AIPerf. + +## Historical comparison boundary + +`aiperf history ingest` replays completed stream artifacts into GreptimeDB. During a run, +`POST /api/v1/history/resource-batches` accepts only the versioned AIPerf resource-agent +contract. The agent retains process-tree, machine-used, and machine-total series under +five canonical metric names, including Ethernet/RDMA receive/transmit history under +`resource.network`, and uses the same protocol for TeleFuser and +SGLang-Diffusion. Artifact replacement preserves those active points. History indexing +retains +warmup and profiling points under separate `phase` dimensions and never collapses +client delivery FPS into target chunk compute FPS. `aiperf history serve` queries only +GreptimeDB and serves the same TypeScript/Vue frontend for TeleFuser, SGLang-Diffusion, +and future adapters. Target repositories do not contain database clients, history API +routes, or frontend components. + +The history selector projects catalog rows into a fixed left sidebar containing the five +semantic domains defined by this protocol: Request and Session, Scheduling and Queue, +Resources, Cache, and Media Stream. It exposes canonical metrics as selectable leaves; +framework and statistic are chart-series dimensions. Native names such as +`telefuser_queue_size` remain stored and API-queryable but are omitted from the frontend +tree, while their mapped canonical observations, such as `scheduler.queue_depth`, occupy +the core tree. New target equivalence therefore belongs in a versioned semantic mapping, +never in a framework-specific frontend branch. + +See [Benchmark History Dashboard with GreptimeDB](../tutorials/history-dashboard.md) for +deployment and frontend development instructions, and +[Benchmark History Service Design](../dev/history-service-design.md) for schema, +idempotency, failure, and extension decisions. diff --git a/docs/cli-options.md b/docs/cli-options.md index a2f1728886..8778c40b00 100644 --- a/docs/cli-options.md +++ b/docs/cli-options.md @@ -28,11 +28,19 @@ Expand a sweep config and print the resulting variations. Validate an AIPerf config file. +### [`history serve`](#aiperf-history-serve) + +Serve benchmark history, metric curves, and the bundled Vue frontend. + +### [`history ingest`](#aiperf-history-ingest) + +Import supported AIPerf artifacts into GreptimeDB once. + ### [`profile`](#aiperf-profile) Run the Profile subcommand. -[Endpoint](#endpoint) • [Tokenizer](#tokenizer) • [Input](#input) • [Fixed Schedule](#fixed-schedule) • [Goodput](#goodput) • [Conversation Input](#conversation-input) • [Prompt](#prompt) • [Prefix Prompt](#prefix-prompt) • [Input Sequence Length (ISL)](#input-sequence-length-isl) • [Output Sequence Length (OSL)](#output-sequence-length-osl) • [Audio Input](#audio-input) • [Image Input](#image-input) • [Video Input](#video-input) • [Rankings](#rankings) • [Synthesis](#synthesis) • [Load Generator](#load-generator) • [Warmup](#warmup) • [User-Centric Rate](#user-centric-rate) • [Request Cancellation](#request-cancellation) • [Output](#output) • [HTTP Trace](#http-trace) • [Server Metrics](#server-metrics) • [GPU Telemetry](#gpu-telemetry) • [UI](#ui) • [Multi-Run](#multi-run) • [Accuracy](#accuracy) • [Service](#service) • [Workers](#workers) • [ZMQ Communication](#zmq-communication) +[Endpoint](#endpoint) • [Tokenizer](#tokenizer) • [Input](#input) • [Fixed Schedule](#fixed-schedule) • [Goodput](#goodput) • [Conversation Input](#conversation-input) • [Prompt](#prompt) • [Prefix Prompt](#prefix-prompt) • [Input Sequence Length (ISL)](#input-sequence-length-isl) • [Output Sequence Length (OSL)](#output-sequence-length-osl) • [Audio Input](#audio-input) • [Image Input](#image-input) • [Video Input](#video-input) • [Rankings](#rankings) • [Synthesis](#synthesis) • [Load Generator](#load-generator) • [Warmup](#warmup) • [User-Centric Rate](#user-centric-rate) • [Request Cancellation](#request-cancellation) • [Output](#output) • [HTTP Trace](#http-trace) • [Server Metrics](#server-metrics) • [GPU Telemetry](#gpu-telemetry) • [UI](#ui) • [Multi-Run](#multi-run) • [Accuracy](#accuracy) • [Service](#service) • [Workers](#workers) • [ZMQ Communication](#zmq-communication) • [Stream Profile](#stream-profile) ### [`plot`](#aiperf-plot) @@ -174,6 +182,95 @@ Path to an AIPerf YAML config to validate.
+## `aiperf history serve` + +Serve benchmark history, metric curves, and the bundled Vue frontend. + +#### `--artifact-root`, `--empty-artifact-root` `` + +Artifact root to scan; repeat for multiple roots. + +#### `--greptime-url` `` + +Required GreptimeDB HTTP endpoint. +
_Default: `http://127.0.0.1:4000`_ + +#### `--greptime-database` `` + +GreptimeDB database containing history tables. +
_Default: `public`_ + +#### `--greptime-username` `` + +Optional GreptimeDB basic-auth username. + +#### `--greptime-password` `` + +Optional GreptimeDB basic-auth password. + +#### `--table-prefix` `` + +Prefix for GreptimeDB history tables. +
_Default: `aiperf_history`_ + +#### `--scan-interval-seconds` `` + +Incremental scan interval; zero disables repeated scans. +
_Default: `60.0`_ + +#### `--host` `` + +History service bind host. +
_Default: `127.0.0.1`_ + +#### `--port` `` + +History service bind port. +
_Default: `8095`_ + +#### `--cors-origin`, `--empty-cors-origin` `` + +Allowed browser origin; repeat for multiple origins. + +
+ +## `aiperf history ingest` + +Import supported AIPerf artifacts into GreptimeDB once. + +#### `--artifact-root`, `--empty-artifact-root` `` _(Required)_ + +Artifact root to import; repeat for multiple roots. + +#### `--greptime-url` `` + +Required GreptimeDB HTTP endpoint. +
_Default: `http://127.0.0.1:4000`_ + +#### `--greptime-database` `` + +GreptimeDB database containing history tables. +
_Default: `public`_ + +#### `--greptime-username` `` + +Optional GreptimeDB basic-auth username. + +#### `--greptime-password` `` + +Optional GreptimeDB basic-auth password. + +#### `--table-prefix` `` + +Prefix for GreptimeDB history tables. +
_Default: `aiperf_history`_ + +#### `--force`, `--no-force` + +Replace runs even when their artifact digest is unchanged. + +
+ ## `aiperf profile` Run the Profile subcommand. @@ -203,6 +300,9 @@ aiperf profile --model your_model --url localhost:8000 --public-dataset sharegpt # Goodput measurement with SLOs aiperf profile --model your_model --url localhost:8000 --goodput "request_latency:250 inter_token_latency:10" + +# Long-running WebRTC or WebSocket stream-world benchmark +aiperf profile --stream-config stream-profile.yaml ``` ### Endpoint @@ -210,6 +310,7 @@ aiperf profile --model your_model --url localhost:8000 --goodput "request_latenc #### `-m`, `--model-names`, `--model` `` Model name(s) to be benchmarked. Can be a comma-separated list or a single model name. +
_Default: `[]`_ #### `--model-selection-strategy` `` @@ -1210,6 +1311,7 @@ Deprecated and ignored. The bayesian preset and the optuna expert mode both use #### `--variant`, `--sweep-variant` `` Repeatable: each occurrence describes one sweep variation. Format: '[name:] key=value, key=value, ...'. Keys are CLI flag names with the leading '--' stripped, in either kebab-case or snake_case (isl, osl, concurrency, request-rate / request_rate, request-count / request_count, benchmark-duration / benchmark_duration, ...). Multi-occurrence emits a ScenarioSweep. Mutually exclusive with magic-list flags, --search-recipe, and YAML-declared sweeps. Single-occurrence is rejected -- use the standalone --isl / --osl / --concurrency flags for a one-off. +
_Default: `[]`_ #### `--search-sla` `` @@ -1420,6 +1522,37 @@ Directory path for ZMQ IPC (Inter-Process Communication) socket files. When usin Select the ZMQ dual-bind communication backend (IPC + TCP). All dual-bind knobs are cluster-managed; this flag only selects the discriminator and the converter routes downstream to the default.
_Flag (no value required)_ +### Stream Profile + +#### `--stream-config` `` + +Run a stream-world profile instead of the request/response benchmark pipeline. + +#### `--stream-server-url` `` + +Override the stream target base URL from --stream-config. + +#### `--stream-artifacts-dir` `` + +Override the stream artifact root from --stream-config. + +#### `--stream-ice-host-ip`, `--empty-stream-ice-host-ip` `` + +Allow one WebRTC ICE host IP, or use 'auto' for route-based selection. Repeat for multiple explicit addresses. + +#### `--stream-server-metrics-url`, `--empty-stream-server-metrics-url` `` + +Override Prometheus URLs from --stream-config. + +#### `--stream-resource-history-url` `` + +Actively upload one-second target resource samples to AIPerf history. + +#### `--stream-resource-target-pid` `` + +Monitor this target PID and all descendants during the stream run. +
_Constraints: > 0_ +
## `aiperf plot` @@ -1555,6 +1688,7 @@ HTTP port for health endpoints (/healthz, /readyz). Required for Kubernetes live #### `-m`, `--model-names`, `--model` `` Model name(s) to be benchmarked. Can be a comma-separated list or a single model name. +
_Default: `[]`_ #### `--model-selection-strategy` `` @@ -2555,6 +2689,7 @@ Deprecated and ignored. The bayesian preset and the optuna expert mode both use #### `--variant`, `--sweep-variant` `` Repeatable: each occurrence describes one sweep variation. Format: '[name:] key=value, key=value, ...'. Keys are CLI flag names with the leading '--' stripped, in either kebab-case or snake_case (isl, osl, concurrency, request-rate / request_rate, request-count / request_count, benchmark-duration / benchmark_duration, ...). Multi-occurrence emits a ScenarioSweep. Mutually exclusive with magic-list flags, --search-recipe, and YAML-declared sweeps. Single-occurrence is rejected -- use the standalone --isl / --osl / --concurrency flags for a one-off. +
_Default: `[]`_ #### `--search-sla` `` diff --git a/docs/dev/history-service-design.md b/docs/dev/history-service-design.md new file mode 100644 index 0000000000..2cfcb47d7b --- /dev/null +++ b/docs/dev/history-service-design.md @@ -0,0 +1,394 @@ +--- +sidebar-title: Benchmark History Service Design +--- + + + +# Benchmark History Service Design + +This document defines the implementation boundary for `aiperf history`. The service +indexes completed benchmark artifacts, accepts versioned active resource batches, and +provides the shared GreptimeDB API and UI used for standard AIPerf profiles, TeleFuser +stream runs, SGLang-Diffusion stream runs, and future adapters. + +The deployment and command guide is +[Benchmark History Dashboard with GreptimeDB](../tutorials/history-dashboard.md). The +multimodal artifact and metric contracts are defined in +[Multimodal Generation and Observability Contracts](../benchmark-modes/multimodal-generation.md). + +## Goals and non-goals + +The history service has six goals: + +1. Provide one cross-run view over profile and stream benchmark artifacts. +2. Preserve metric meaning through explicit metric, statistic, scope, phase, session, + device, source, and observation-state dimensions. +3. Make artifact import replayable and idempotent. +4. Keep API queries bounded and independent of target-specific frontend code. +5. Require one production-oriented time-series backend: GreptimeDB. +6. Preserve source-timestamped process-tree and machine resource curves while a run is + active. + +The service does not execute workloads, remotely scrape target processes, recompute +benchmark aggregates from events, mutate source artifacts, expose arbitrary SQL, or +provide an alternative local database. Resource sampling runs beside the target under +AIPerf ownership and actively sends bounded batches. Authentication and GreptimeDB +retention, replication, backup, and disaster recovery remain deployment responsibilities. + +## Ownership boundary + +| Component | Owns | Does not own | +|---|---|---| +| Target service | Bounded raw target phase, chunk, runtime, and metrics facts | Cross-run aggregation, history storage, or UI | +| AIPerf profile/stream runner | Client timeline, benchmark aggregation, semantic mapping, and canonical artifacts | Long-term history queries | +| AIPerf resource agent | One-second PID-tree, target-cgroup, host, GPU, and GPU-memory sampling; source timestamps; bounded retry and final flush | Benchmark aggregation, guessed container values, or storage fallback | +| History importer | Artifact discovery, parsing, scalar normalization, revision digests, and GreptimeDB writes | Benchmark execution or target-specific RPCs | +| GreptimeDB | Persistent run and metric-point tables and time-series query execution | Artifact parsing or presentation logic | +| History API | Validated resource-batch ingestion, filters, pagination, catalog, facets, and bounded series reads | Arbitrary SQL or artifact writes | +| Vue application | Framework-neutral filtering, comparison curves, and run details | Reading artifacts or calling target services directly | + +Target repositories may provide contracts, workload data, and thin launchers. Database +clients, history routes, aggregation rules, and frontend components remain in AIPerf. + +## Architecture + +```mermaid +flowchart LR + ROOTS[Artifact roots] --> DISCOVER[Candidate discovery] + DISCOVER --> PROFILE[Profile parser] + DISCOVER --> STREAM[Stream parser] + PROFILE --> MODEL[Canonical run and points] + STREAM --> MODEL + AGENT[AIPerf target resource agent] -->|POST versioned batches| REPO + MODEL --> DIGEST{Revision changed?} + DIGEST -->|yes or force| REPO[History repository] + DIGEST -->|no| SKIP[Skip unchanged run] + REPO --> RUNS[(GreptimeDB runs)] + REPO --> POINTS[(GreptimeDB metric points)] + RUNS --> API[FastAPI history API] + POINTS --> API + API --> UI[Vue 3 and ECharts] +``` + +`aiperf history ingest` performs one import and exits. `aiperf history serve` verifies +GreptimeDB connectivity, creates missing tables, performs an initial scan when roots are +configured, starts the periodic scanner, and then serves the API and bundled frontend. +The scanner and explicit ingest command use the same parser and repository path. + +The history service is outside the per-run ZMQ process graph. Completed results arrive +through artifact import. During a configured stream run, an AIPerf resource agent on the +target host posts directly to the service. The service may be deployed elsewhere when it +can read configured artifact roots, receive agent HTTP requests, and reach GreptimeDB. + +Resource wire schema `1.2` adds optional container observations while keeping `1.1` +requests valid. The agent resolves the monitored PID's cgroup v1/v2 hierarchy and emits +`container_used`/`container_total` only for finite or reliably visible boundaries. CPU +capacity is the minimum finite ancestor quota and effective restricted cpuset; memory +capacity is the minimum finite ancestor limit. Usage comes from cgroup counters. GPU +container points require a resolved target-visible physical device and aggregate only +NVML processes in the target cgroup subtree. Unresolved facts are listed as unavailable; +the agent never substitutes machine values. Generic cgroups expose no portable network +bandwidth limit, so no container network series is synthesized. + +## Artifact discovery and normalization + +Discovery recognizes these canonical entry points: + +| Artifact family | Entry point | Additional files consumed when present | +|---|---|---| +| `profile` | `profile_export_aiperf.json` | timeslices, GPU telemetry, aggregate and raw server-metrics exports | +| `stream` | `observability_manifest.json`, otherwise `summary.json` | sessions, normalized metrics, server metrics, GPU telemetry, contract, stream config, and target metadata | + +When both stream entry points exist in one directory, the manifest takes precedence and +the directory is imported once. Files are loaded through AIPerf's path-safety helper. +Malformed optional data is ignored only where the artifact contract declares it +optional; invalid required JSON or JSONL fails that candidate and is visible in the +import result. + +Both parsers emit the same two typed records: + +- `HistoryRunRecord`: identity, comparison facets, provenance, retained configuration, + artifact integrity, and counts. +- `HistoryMetricSample`: one finite scalar plus its semantic and time-series dimensions. + +The importer intentionally retains rather than merges these scopes: + +| Scope | Meaning | +|---|---| +| `run` | Profile or stream aggregate | +| `session` | One streaming session observation | +| `control` | One control acknowledgement or next-frame observation | +| `phase` | Target initialization or runtime phase fact | +| `chunk` | Target chunk timing, volume, throughput, or allocator fact | +| `timeslice` | Time-windowed profile observation | +| `gpu`, `gpu_summary` | Raw and aggregate GPU telemetry | +| `server`, `server_summary` | Raw and aggregate Prometheus observations | +| `normalized` | Framework-neutral semantic mapping result | +| `resource` | Source-timestamped process-tree, whole-machine, and capacity sample | + +Warmup and profiling points retain different `phase` values. Client delivery +`stream_fps`, per-chunk `chunk_compute_fps`, and run-level +`chunk_compute_fps_weighted` remain distinct metrics. A missing target fact is not +reconstructed from a client metric and is never converted to numeric zero. + +## GreptimeDB schema + +The selected table prefix, `aiperf_history` by default, produces two Mito tables: + +### Runs table + +`_runs` stores one logical row per `run_id`. Its time index is `started_at` and +its primary key is `run_id`. Columns cover: + +- benchmark and artifact family identity; +- status, implementation, model family, mode, scene, task, and transport; +- hardware and AIPerf/contract provenance; +- artifact path, revision digest, metric count, and session count; +- deterministic JSON for tags, resolved configuration, and retained metadata. + +The absolute artifact path is retained for operator inspection and may disclose +filesystem layout. Do not expose the service to untrusted users without an access +control layer. + +### Metric-points table + +`_metric_points` stores one finite scalar per row. Its time index is +`recorded_at` and its primary key is `(run_id, point_id)`. In addition to run comparison +facets, each point stores: + +- `metric_name`, `statistic`, `metric_scope`, numeric value, and unit; +- phase, session, sample index, device, and source; +- observation state and deterministic JSON labels; +- the owning artifact digest. + +Artifact `point_id` values and active resource point IDs are deterministic SHA-256 +digests. Resource IDs include run, source nanosecond timestamp, sample index, canonical +metric, subject, and device, so retrying one batch replaces the same rows. Boolean and +non-finite values are not indexed. Label keys and values are converted to bounded strings +before storage. + +The schema duplicates bounded run facets on metric rows so cross-run series do not need +a join. The tradeoff is additional storage in exchange for predictable time-series +queries and simpler filtering. + +## Revision and recovery semantics + +The importer hashes the supported file names and contents together with +`ARTIFACT_INDEX_VERSION`. A run is skipped when its stored digest matches the current +digest; `--force` bypasses this check. Incrementing the importer index version forces a +replay when parsing semantics change even if source files do not. + +Replacing an artifact-backed run performs these steps: + +1. Read and retain existing `resource` points for the `run_id`. +2. Delete the old revision, merge retained resource points with parsed artifact points, + and insert the result in bounded batches. +3. Insert current artifact metadata and digest, also updating denormalized resource-point + facets to the terminal run identity. + +An interrupted or failed replacement remains recoverable from the source artifacts. +Because the new digest is written only after point insertion, a later scan retries an +incomplete revision and begins by deleting partial points. Operators should rerun +ingest after correcting a malformed artifact or restoring database availability. + +Artifact candidates are isolated from each other. Import results count discovered, +imported, skipped, and failed runs and retain up to 50 bounded failure descriptions. +The one-shot command exits nonzero when any candidate fails. The serving process keeps +the last scan result available from the status endpoint. + +Run identifiers must be stable and unique within a history deployment. Reusing one +`benchmark_id` for unrelated experiments intentionally replaces that logical run. + +## Query and frontend model + +The API queries GreptimeDB through `HistoryRepository`. Its read side supports: + +- paginated run lists and exact implementation, model, scene, mode, status, kind, and + time-range filters; +- bounded case-insensitive search over run identity and comparison fields; +- run detail and bounded run-local metric values; +- a live metric/statistic/scope catalog; +- ordered cross-run or within-run series; +- distinct facets used by frontend filters. + +Request models cap run pages at 500 rows, run-detail metrics at 20,000 points, and series +at 50,000 points. `POST /api/v1/history/resource-batches` is the only live write route. +Its strict schema allows no more than 120 samples per request and does not accept raw SQL +or remote artifacts. + +The frontend discovers metrics and facets from the API, but storage identity is not UI +identity. A framework-neutral semantic taxonomy projects catalog rows into the five +design domains: Request and Session, Scheduling and Queue, Resources, Cache, and Media +Stream. The Resources branch has five active canonical leaves: `resource.cpu`, +`resource.memory`, `resource.gpu`, `resource.gpu_memory`, and `resource.network`. +Canonical metrics are +selectable leaves. Implementation, source metric, and +statistic are series/provenance dimensions beneath the leaf, so TeleFuser and SGLang do +not become separate core entries and avg, P50, P90, P95, and P99 remain together. +Both the selector tree and the chart workspace derive their category and metric order +directly from `taxonomy.core`. Selected keys act only as a membership set. The workspace +filters each ordered category's metric list without sorting by selection or storage +order, renders a heading for every non-empty selected category, and omits categories +with no enabled metric. The two surfaces therefore cannot drift into different design +groupings or orders. + +The taxonomy selects compatible preferred scopes for historical comparison: run-level +aggregates for client and target benchmark outcomes, normalized scope for mapped server +semantics, and the active `resource` scope for resource timelines. A resource card +projects its raw subjects into three views. The main timeline contains `process_used`, +optional `container_used`, and `machine_used`; a compact, independently scaled timeline +contains timestamp-aggregated `machine_total`; and the latest finite `container_total` +for each run is a numeric badge. Capacity and usage are never plotted on the same axis. + +GPU points retain the physical device. GPU utilization and GPU-memory usage series stack +physical devices only when `run_id`, `metric_name`, and `resource_subject` are identical. +The capacity projection sums devices into one `machine_total` line per run. Stack keys +never cross runs or add process, container, and machine-used semantics together. The +network usage view overlays Ethernet/RDMA receive/transmit lines. Its capacity view +deduplicates receive/transmit values within each transport kind and sums the resulting +kind capacities into one line per run. A sample scope and a run timeline or incompatible +units are never merged merely because their names are similar. + +Display transforms do not alter stored facts. The Vue layer converts CPU core values +with `1 core = 100%` and deliberately permits multi-core or multi-GPU totals above 100%. +It selects shared decimal SI byte scales for the left/right panes of each usage or +capacity subview and formats tooltip values independently as B, KB, MB, GB, or TB, with +`/s` for bandwidth. Vertical ranges are calculated independently after unit conversion +and device stacking, so an eight-GPU capacity is visible at 800% without flattening the +usage curves beneath it. +Continuous line series suppress persistent symbols and reveal their node on hover; a +single-point series retains its symbol. This presentation rule reduces dense one-second +resource plots without hiding aggregate observations. +Native-to-canonical equivalence continues to come from versioned AIPerf semantic mapping +rules. The Vue layer contains no TeleFuser/SGLang alias table. + +Unassigned catalog groups are not projected into frontend tree nodes. Other run +aggregates, Session/Control, Chunk, Phase, GPU, raw server, normalized, and timeslice +facts remain stored and queryable through the bounded query API, while the sidebar +is reserved for canonical benchmark outcomes. This preserves access to replayed facts +without presenting raw Prometheus names or multiple granularities as independent core +benchmark dimensions. Empty core domains show unavailable coverage rather than +synthetic values. + +Implementation, scene, model, status, run kind, and free-text search are shared across +all visible panels. Run identifiers are assigned to independent left and right +comparison groups. A Run ID is mutually exclusive between groups, while each group can +contain multiple runs. Each panel owns its loading, empty, and error state so one failed +statistic query does not hide successful cards. + +The browser enables a bounded core set on first use, permits at most 16 simultaneous +semantic metrics, and persists the selected semantic keys in `localStorage`. That +preference is UI state only and is never written to GreptimeDB. Catalog refresh removes +unavailable and non-core keys, while a saved empty list remains empty instead of +silently restoring defaults. Legacy per-statistic and metric-group keys are mapped once +through the taxonomy so existing browser preferences survive the UI schema change. +Each statistic variant uses the existing bounded series API; variants are queried +concurrently and combined only after unit validation in the browser. + +Comparison groups are also bounded browser presentation state. The first visit assigns +the two newest completed runs to opposite sides when available. Subsequent assignments, +including empty groups, are normalized for uniqueness and persisted in `localStorage`. +The frontend issues one bounded series request per available statistic in a visible +metric group and partitions the combined points by Run ID; it does not duplicate API +requests per comparison side. A non-resource card computes one finite-value vertical +range across every statistic and both comparison groups. A resource card computes +independent shared ranges for its left/right usage panes and left/right capacity panes; +container limits are not plotted. The desktop dashboard places the five-domain canonical +tree in a sticky left sidebar whose height is derived directly from the viewport with a +12-pixel bottom gap. +The right workspace renders the same non-empty taxonomy categories in the same order as +the sidebar, with compact metric cards retaining taxonomy leaf order in a two-column +grid. Left and right comparison charts stay aligned inside each card. This design adds +no endpoint, query parameter, table, or GreptimeDB write. + +ECharts HTML tooltips are appended to a persistent manual Popover in the browser Top +Layer rather than an ordinary document stacking context. A viewport-aware position +callback flips and clamps each popup at the window edges. Metric cards keep visible +overflow and raise the hovered card above its grid siblings as a structural safeguard, +so a tooltip cannot be clipped even if a chart instance has not yet attached to the Top +Layer. Tooltip placement is presentation state and does not change series queries. + +The bundled presentation language is Simplified Chinese. Localization maps metric, +statistic, scope, unit, status, scene, and transport values to display labels without +changing canonical API values. Metric cards keep the English metric key as a secondary +identifier so operators can correlate a chart with artifacts and API requests. + +Series are grouped by semantic metric, statistic, and stored implementation using the +same rendering path for TeleFuser, SGLang-Diffusion, and standard profiles. Adding a +target adapter therefore requires an artifact parser or versioned semantic mapping +change, not a target-specific Vue branch or another core-tree leaf. + +## Required-backend and failure semantics + +GreptimeDB is mandatory for every history read and write: + +| Failure | Behavior | +|---|---| +| Initial connection or `SELECT 1` fails | Startup or one-shot ingest fails | +| Table creation fails | Startup or ingest fails | +| API query fails | The API returns HTTP 503 with `backend: greptimedb` | +| GreptimeDB becomes unavailable | `/healthz` and `/readyz` return 503 | +| Active registration, upload, or final flush is not acknowledged | The resource-enabled benchmark fails; no local store substitutes for GreptimeDB | +| Agent sample buffer fills | Sampling fails visibly and the terminal batch reports dropped samples when delivery remains possible | +| Bundled frontend is missing | Application construction fails with an explicit build error | +| One artifact is invalid | The candidate is reported as failed; no alternate parser or store is used | + +There is no SQLite backend, in-memory index, direct-filesystem query path, or cached +response fallback. Source JSON and JSONL remain the replayable facts used for recovery, +but the API and UI never query them at request time. + +This fail-closed storage rule is separate from optional Prometheus observability. A live +server-metrics scrape may be fail-open so it does not terminate the measured workload. +Active resource telemetry is explicitly configured as required: the agent samples every +second, uploads every 15 seconds, flushes immediately on completion/cancellation, and +propagates unacknowledged delivery as a benchmark error. Neither mode creates a storage +fallback. + +## Security and operations + +- Bind to loopback by default and place shared deployments behind an authenticated + reverse proxy. +- Use GreptimeDB basic authentication when required; username and password must be + configured together. +- Treat retained configuration, labels, source endpoints, and artifact paths as + potentially sensitive benchmark metadata. +- Configure CORS only for trusted frontend origins. +- Protect the resource-batch POST route with the same authenticated reverse proxy as the + dashboard; a batch contains host, PID, model, and utilization metadata. +- The table prefix is validated as an SQL identifier; user query values are escaped and + all result sizes are bounded. +- Pin a GreptimeDB version and configure persistent volumes, retention, backup, + replication, and monitoring outside AIPerf. +- The service does not delete source artifacts or implement automatic database + retention. + +## Packaging and extension rules + +The frontend source is under `web/history-ui`. Its Vite build writes hashed assets to +`src/aiperf/history/static`, which is included in the Python wheel. Production users do +not need Node.js. + +To add another artifact family: + +1. Define one canonical entry point and a bounded supported-file list. +2. Parse it into `HistoryRunRecord` and `HistoryMetricSample` without changing API + response semantics. +3. Preserve raw and derived metrics under separate scopes or names. +4. Add the parser to discovery and bump `ARTIFACT_INDEX_VERSION` when existing import + results must be rebuilt. +5. Add parser, idempotency, repository, API, and cross-target UI tests. + +Do not add target-name conditionals to the repository, API, or frontend. Framework +differences belong in artifact adapters and versioned semantic mappings. + +## Verification boundary + +Unit coverage is organized under `tests/unit/history` and +`tests/unit/resource_telemetry`. It verifies profile/stream parsing, PID-tree/NVML +attribution, source timestamps, final flush, deterministic replacement, resource-point +preservation, GreptimeDB SQL behavior, bounded API responses, and required-backend +behavior. CLI coverage includes history and stream-resource flags. The frontend build +runs Vue type checking before Vite emits production assets. diff --git a/docs/index.yml b/docs/index.yml index 2f184bfbf5..bcabd35d1b 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -138,6 +138,8 @@ navigation: path: tutorials/working-with-profile-exports.md - page: Visualization and Plotting with AIPerf path: tutorials/plot.md + - page: Benchmark History Dashboard with GreptimeDB + path: tutorials/history-dashboard.md - page: Auto-Plot After `aiperf profile` path: tutorials/auto-plot.md - page: User-Centric Timing for KV Cache Benchmarking @@ -181,6 +183,8 @@ navigation: path: benchmark-modes/trace-replay.md - page: Conversation DAG Benchmarks path: benchmark-modes/dag.md + - page: Multimodal Generation and Observability Contracts + path: benchmark-modes/multimodal-generation.md - section: Accuracy collapsed: true @@ -249,6 +253,8 @@ navigation: contents: - page: Architecture of AIPerf path: architecture.md + - page: Benchmark History Service Design + path: dev/history-service-design.md - page: Metrics Flow path: diagrams/metrics-flow.md - page: Mixins diff --git a/docs/metrics-reference.md b/docs/metrics-reference.md index b58a921048..5b804ef26c 100644 --- a/docs/metrics-reference.md +++ b/docs/metrics-reference.md @@ -40,6 +40,7 @@ This document provides a comprehensive reference of all metrics available in AIP - [Video Metrics](#video-metrics) - [Video Inference Time](#video-inference-time) - [Video Peak Memory](#video-peak-memory) + - [Stream-World Metrics](#stream-world-metrics) - [Audio Metrics](#audio-metrics) - [Audio Duration](#audio-duration) - [Inverse Real-Time Factor (RTFx)](#inverse-real-time-factor-rtfx) @@ -591,6 +592,100 @@ video_peak_memory = response.data.peak_memory_mb --- +## Stream-World Metrics + +Stream-world profiles started with `aiperf profile --stream-config` keep client delivery +and target compute metrics separate. Client metrics use the AIPerf process monotonic +clock. Target metrics are accepted only when they are measured inside the target and +reported as bounded phase or chunk facts. + +| Metric | Unit | Source and formula | +|---|---|---| +| `first_frame_latency_ms` | ms | Client session start to first received media frame | +| `stream_fps` | frames/s | Client-received frames over the first-to-last-frame window | +| `control_ack_latency_ms` | ms | Client control send to target acknowledgement/sample | +| `control_to_next_frame_latency_ms` | ms | Client control send to associated next frame | +| `chunk_request_prepare_seconds` | s | Optional target request-preparation duration | +| `chunk_compute_seconds` | s | Target generation compute excluding output encoding | +| `chunk_encode_seconds` | s | Optional target media conversion or serialization duration | +| `chunk_output_pacing_seconds` | s | Optional intentional target output wait | +| `chunk_output_header_write_seconds` | s | Optional target transport-header write duration | +| `chunk_output_payload_write_seconds` | s | Optional target media-payload write duration | +| `chunk_output_write_seconds` | s | Optional total target transport-write duration | +| `chunk_total_seconds` | s | Optional complete target-side chunk duration | +| `chunk_raw_output_bytes` | bytes | Optional unencoded media bytes represented by a chunk | +| `chunk_wire_output_bytes` | bytes | Optional media plus framing bytes written to the transport | +| `chunk_output_batches` | batches | Optional transport batch count per generated chunk | +| `chunk_compute_fps` | frames/s | `chunk.frames / chunk.compute_seconds` | +| `chunk_peak_allocated_bytes` | bytes | Optional target allocator peak allocated bytes after a chunk-boundary reset | +| `chunk_peak_reserved_bytes` | bytes | Optional target allocator peak reserved bytes after a chunk-boundary reset | + +`warmup_chunks` leading chunks are excluded independently for each measured session. +Every remaining numeric chunk field reports count, min, mean, p50, p90, p99, max, and +population standard deviation. The steady-state compute rate is weighted: + +```python +steady_state_fps = sum(chunk.frames) / sum(chunk.compute_seconds) +``` + +Raw chunks, including excluded warmup chunks, remain in `sessions.jsonl`. See +[Multimodal Generation and Observability Contracts](benchmark-modes/multimodal-generation.md) +for adapter mappings and availability rules. + +### Active Resource Metrics + +When `resource_telemetry.enabled` is true, AIPerf samples the configured target PID and +all recursive descendants once per second. It actively uploads samples to the required +GreptimeDB history service at most every 15 seconds and flushes immediately when the run +ends. Values use the source host's UTC timestamp and the `resource` scope. + +| Metric | Unit | `resource_subject` series | +|---|---|---| +| `resource.cpu` | logical cores | process, optional finite container, and machine used/total | +| `resource.memory` | bytes | process RSS, optional container charged/limit, machine used/total | +| `resource.gpu` | percent | process SM, optional visible-container SM/capacity, device SM/capacity | +| `resource.gpu_memory` | bytes | process VRAM, optional visible-container VRAM/capacity, device used/total | +| `resource.network` | bytes/second | Ethernet/RDMA receive and transmit, machine used and link capacity | + +These are storage units, not fixed dashboard units. Each resource card projects the raw +subjects into three separate presentations: the main timeline contains only +`process_used`, optional `container_used`, and `machine_used`; `machine_total` is a +separate compact capacity timeline; and the latest finite `container_total` for each run +is a value badge rather than a curve. Usage and capacity therefore never share a vertical +scale. The history UI renders CPU with `1 logical core = 100%`, without clamping +multi-core totals. It stacks physical GPU or GPU-memory devices only within the same +`(run_id, metric_name, resource_subject)` and keeps runs and usage subjects separate. +The capacity projection aggregates all physical GPU devices into one `machine_total` +line per run. Byte and byte-rate values use decimal B/KB/MB/GB/TB and +B/s/KB/s/MB/s/GB/s/TB/s presentation scales; GreptimeDB values remain unchanged. + +Resource wire schema `1.2` adds optional container facts while the ingestion API remains +compatible with schema `1.1`. On Linux, a finite `container_total` comes from the target +PID's cgroup v1/v2 hierarchy: CPU is the effective ancestor quota/cpuset capacity and +memory is the effective finite ancestor limit. `container_used` comes from cgroup CPU and +memory counters. GPU container subjects are emitted only for target-visible devices and +NVML PIDs in the target cgroup subtree. A missing or ambiguous signal is recorded as +unavailable; machine values are never copied into a container subject. + +GPU resource points also retain the physical `device`, GPU UUID, and GPU name. Process +GPU values come only from NVML per-process APIs filtered to the monitored PID tree. If a +driver cannot expose a process value, that series is absent and explicitly listed as +unavailable in live-run metadata; whole-device utilization is never copied into the +process series. These time-series values are distinct from target allocator peaks such +as `chunk_peak_reserved_bytes`. + +Network points use `network_kind=ethernet|rdma` and +`network_direction=receive|transmit` labels. Ethernet rates come from monotonic +interface byte counters; RDMA rates come from active InfiniBand/RoCE port data counters, +whose 32-bit-word values are converted to bytes. `machine_total` is the aggregate +one-direction link capacity reported by selected interfaces or active RDMA ports. The +capacity projection takes one value per transport kind at each timestamp before summing +Ethernet and RDMA, so equal receive/transmit capacities are not counted twice. These +kernel counters are machine-level, so AIPerf does not manufacture a process or container +network series. Generic cgroups have no portable network-bandwidth limit. + +--- + ## Audio Metrics > [!NOTE] diff --git a/docs/server-metrics/server-metrics.md b/docs/server-metrics/server-metrics.md index 279169d9a1..f88e30427a 100644 --- a/docs/server-metrics/server-metrics.md +++ b/docs/server-metrics/server-metrics.md @@ -8,6 +8,11 @@ sidebar-title: Server Metrics Collection AIPerf automatically collects metrics from Prometheus-compatible endpoints exposed by LLM inference servers and serving frontends (vLLM, SGLang, TRT-LLM, Dynamo, Triton, etc.). +For multimodal and streaming comparisons, native server metrics can be converted into +versioned cross-framework semantics without changing the raw exports. See +[Multimodal Generation and Observability Contracts](../benchmark-modes/multimodal-generation.md) +for mapping rules, availability states, lifecycle correlation, and manifest provenance. + ## Quick Reference | Feature | Description | Default | @@ -656,4 +661,3 @@ with open('server_metrics_export.json') as f: latency = data['metrics']['vllm:e2e_request_latency_seconds']['series'][0]['stats'] assert latency['p99_estimate'] < 5.0, f"P99 latency too high: {latency['p99_estimate']}" ``` - diff --git a/docs/tutorials/history-dashboard.md b/docs/tutorials/history-dashboard.md new file mode 100644 index 0000000000..a6d2c086e7 --- /dev/null +++ b/docs/tutorials/history-dashboard.md @@ -0,0 +1,362 @@ +--- +sidebar-title: Benchmark History Dashboard +--- + + + +# Benchmark History Dashboard with GreptimeDB + +`aiperf history` provides a long-running FastAPI service and a bundled Vue 3, +TypeScript, and ECharts application for comparing benchmark runs over time. The same +UI reads standard AIPerf profile exports, TeleFuser stream artifacts, and SGLang stream +artifacts through one framework-neutral metric schema. + +GreptimeDB is required. The service never falls back to SQLite, an in-memory index, or +direct artifact queries. JSON and JSONL files remain replayable source artifacts, while +all API and UI reads come from GreptimeDB. + +For component ownership, schema rationale, idempotency, recovery, and extension rules, +see [Benchmark History Service Design](../dev/history-service-design.md). + +## Architecture + +```mermaid +flowchart LR + A[profile and stream artifacts] --> I[aiperf history ingest] + I --> R[aiperf history importer] + R --> G[(GreptimeDB)] + T[AIPerf target resource agent] -->|1 s samples, 15 s batches| API + G --> API[FastAPI history API] + API --> UI[Vue and ECharts UI] + W[periodic artifact watcher] --> R +``` + +The importer computes a revision digest over the source files and the importer schema. +Repeated scans skip unchanged runs. A parser-schema revision forces a deterministic +re-import even when the source artifact is unchanged. + +## Start GreptimeDB + +The following local container command exposes only the GreptimeDB HTTP endpoint used by +AIPerf: + +```bash +docker run --rm -d --name aiperf-greptime \ + -p 127.0.0.1:4000:4000 \ + greptime/greptimedb:latest \ + standalone start --http-addr 0.0.0.0:4000 +``` + +Use a pinned GreptimeDB image and persistent storage for production. The AIPerf service +creates two Mito tables in the selected database: + +- `aiperf_history_runs`: run identity, status, scene, implementation, model, hardware, + artifact provenance, retained configuration, and integrity metadata. +- `aiperf_history_metric_points`: finite scalar points with run, metric, statistic, + scope, phase, session, sample, device, source, labels, and wall-clock dimensions. + +Change both names with `--table-prefix`. AIPerf validates the prefix before using it in +SQL. + +## Import existing artifacts + +Run a one-shot import before starting a service, or use the same artifact roots during +service startup: + +```bash +aiperf history ingest \ + --greptime-url http://127.0.0.1:4000 \ + --greptime-database public \ + --artifact-root ./artifacts \ + --artifact-root /shared/aiperf-results +``` + +The command exits nonzero when any discovered run fails to parse or write. `--force` +replaces an indexed revision even when its digest is unchanged. + +## Serve the API and UI + +```bash +aiperf history serve \ + --greptime-url http://127.0.0.1:4000 \ + --greptime-database public \ + --artifact-root ./artifacts \ + --scan-interval-seconds 60 \ + --host 127.0.0.1 \ + --port 8095 +``` + +Open `http://127.0.0.1:8095/`. The page provides: + +- searchable and filterable run history; +- a Grafana-style grid of independent ECharts panels; +- a fixed left sidebar containing a searchable metric tree organized by the five + design domains, with canonical metrics as selectable leaves; +- a right chart workspace divided into those same design domains, in the same category + and canonical-leaf order regardless of the order in which metrics were enabled; +- left and right comparison groups, each accepting multiple runs selected from the Run + table; +- aligned left/right charts for every enabled metric; non-resource charts share one + vertical scale, while resource usage and capacity use separate scales; +- metric, statistic, and scope discovery from the live catalog; +- run details, retained configuration, artifact integrity, and provenance; +- profiling chunk curves separated from aggregate delivery FPS; +- source-timestamped CPU, memory, GPU, GPU-memory, and network curves across process, + finite container, and machine boundaries; GPU and GPU-memory devices stack within one + run and one usage subject, while network usage and link capacity remain in separate + subviews; +- human-readable chart units: CPU cores render as percentages with one core equal to + 100%, and byte or byte-rate values automatically render as KB/MB/GB/TB or + KB/s/MB/s/GB/s/TB/s; +- uncluttered lines whose nodes appear on hover; a one-point series keeps its node visible + so aggregate comparisons never look empty. + +The first visit enables the available subset of weighted chunk compute FPS, client +delivery FPS, the five canonical resource metrics, first-frame latency, control +acknowledgement latency, control-to-next-frame latency, request throughput, and request +latency. A dashboard can display at most 16 metric cards so one browser action cannot +issue an unbounded number of series queries. + +The selector does not present every stored metric name as a core metric. Its top level +matches the observability design: + +| Core domain | Meaning | +|---|---| +| Request and Session | End-to-end latency, throughput, success, and lifecycle | +| Scheduling and Queue | Running, waiting, preemption, and rejection | +| Resources | CPU, memory, GPU, GPU memory, and Ethernet/RDMA bandwidth timelines | +| Cache | Capacity, usage, hit rate, reuse, and migration | +| Media Stream | Frame delivery, target compute throughput, jitter, and control loop | + +Only canonical metrics are selectable leaves. Implementation is a series dimension, so +TeleFuser and SGLang observations for `stream_fps` appear in the same metric card. +Statistic is another series dimension: selecting request latency displays its available +avg, P50, P90, P95, P99, min, max, standard deviation, count, and other curves together. +The tree reports unavailable design coverage without manufacturing zero values. + +The metric tree and chart workspace consume the same ordered taxonomy. The right side +renders a visible section for each domain that has an enabled metric, keeps sections in +the tree's top-level order, and keeps cards in that domain's leaf order. Enabling a card +changes membership only; it cannot move the card ahead of an earlier taxonomy leaf. +Domains with no enabled metrics are omitted from the chart workspace. + +Raw Session, Control, Chunk, Phase, GPU, and Prometheus names are intentionally absent +from the dashboard tree. They remain queryable through the bounded history query API and +run-detail endpoints, but do not compete with benchmark outcomes for sidebar space. A +native metric that has a semantic mapping is not also promoted to a separate core leaf. +For example, `telefuser_queue_size` remains a stored source while its derived +`scheduler.queue_depth` observation is the canonical Scheduling and Queue metric. +Framework mapping belongs to AIPerf's versioned semantic mapping layer, not to +target-specific Vue conditions. + +Semantic metric choices are saved in browser `localStorage`, including an intentionally +empty selection, and restored on the next visit. They are local presentation +preferences; they do not alter artifacts or GreptimeDB. Legacy per-statistic and +metric/scope/unit choices are migrated to their corresponding semantic leaves. + +The Run table has separate Left and Right checkboxes. Each side accepts multiple runs, +and a run can belong to only one side: assigning it to one side automatically removes +it from the other. On the first visit, the two newest completed runs are assigned one +per side when available. Both groups, including deliberately empty groups, are saved +in browser `localStorage` and restored on the next visit. Group summary cards include +the implementation, model, short Run ID, and start time so similar runs remain +distinguishable. + +Every enabled non-resource metric is rendered as an aligned left/right chart pair, with +all of its statistics overlaid and identified in the legend. Both panes use one vertical +range computed from their combined finite values. Resource cards instead align the two +usage panes on one range and the two capacity panes on another; container limits remain +numeric badges. On desktop, the core metric tree is a sticky left sidebar +whose height is calculated from the viewport, leaving a 12-pixel bottom gap while +maximizing the scrollable tree area. The comparison controls plus domain-grouped compact +two-column card grids occupy the right workspace; four metric cards occupy approximately +the area previously used by one full-width card. The global implementation, scene, model, +status, run-kind, and search filters apply to both groups. Grouping is pure UI state: it +does not change the series API, artifacts, or GreptimeDB tables. + +Chart tooltips use a persistent manual Popover in the browser Top Layer instead of +remaining inside the chart card or an ordinary body stacking context. Their position +flips and clamps against the browser viewport, so long multi-series tooltips stay above +neighboring cards, sidebars, and drawers without being clipped at card or screen edges. + +The bundled dashboard uses a Simplified Chinese interface. Human-readable Chinese +metric names are presentation aliases; canonical English metric keys remain visible as +secondary labels and continue to define API queries and saved panel selections. + +## Actively report target resources + +Run the profile command on the same machine as the target process and provide the root +service PID. AIPerf recursively includes all descendants, so TeleFuser replica workers +and SGLang worker processes remain attributed to the same benchmark: + +```bash +aiperf profile \ + --stream-config stream-profile.yaml \ + --stream-resource-history-url http://history-host:8095 \ + --stream-resource-target-pid 12345 +``` + +The TeleFuser and SGLang thin launchers accept the same environment contract: + +```bash +export AIPERF_HISTORY_URL=http://history-host:8095 +export AIPERF_RESOURCE_TARGET_PID=12345 +bash benchmarks/telefuser_aiperf/scripts/run_stream_bench.sh + +# Use the same variables for the baseline. +bash benchmarks/baseline/sglang_lingbot_stream/scripts/run_stream_bench.sh +``` + +In the TeleFuser integration, `scripts/setup_aiperf_repo.sh` always clones AIPerf to +`/benchmarks/aiperf`, and every benchmark launcher uses that location. Repo +URL, branch, and ref remain selectable, but checkout-path environment variables and a +directory CLI override are intentionally unsupported. + +The defaults are fixed at one-second sampling and a maximum 15-second upload interval. +Each sample carries the source host's UTC Unix timestamp; scheduling uses a monotonic +clock. Completion, failure, cancellation, and termination trigger an immediate final +flush. Upload retries are bounded, the sample queue is bounded, and an unacknowledged +registration/batch/final flush fails the resource-enabled benchmark. There is no local, +SQLite, or in-memory storage fallback. + +New agents send resource wire schema `1.2`; the History API continues to accept and echo +`1.1` batches. Version `1.2` adds optional target-cgroup and per-GPU container fields, so +older agents remain valid and simply have no container subjects. + +The active schema emits only five canonical metric names: + +| Metric | Unit | Series | +|---|---|---| +| `resource.cpu` | cores | process used, finite container used/limit, machine used/total | +| `resource.memory` | bytes | process RSS, finite container used/limit, machine used/total | +| `resource.gpu` | percent | process SM, visible-container used/capacity, device used/capacity | +| `resource.gpu_memory` | bytes | process VRAM, visible-container used/capacity, device used/total | +| `resource.network` | bytes/second | Ethernet/RDMA RX/TX machine used and link capacity | + +The storage definitions and availability rules in +[Metrics Reference](../metrics-reference.md#active-resource-metrics) are authoritative; +the remainder of this section describes only dashboard presentation. + +The API and GreptimeDB retain those raw units. The dashboard does not put usage and +capacity on the same plot. Its main resource timeline contains only `process_used`, +optional `container_used`, and `machine_used`. A compact second timeline contains the +timestamp-aggregated `machine_total`, with one capacity line per selected run. The latest +finite `container_total` is shown as a per-run value badge and is not plotted. Usage and +capacity have independent vertical ranges, while the left/right comparison panes share +the range within the same presentation. + +Presentation converts `resource.cpu` cores to percentages using `1 core = 100%`, so +process or whole-machine curves are not clamped at 100%. GPU and GPU-memory usage series +use the stack identity `(run_id, metric_name, resource_subject)`; physical devices in +that one identity add together. Capacity aggregation likewise sums physical devices into +one `machine_total` line per run, allowing an eight-GPU total to reach 800%. Runs and +`process_used`, `container_used`, and `machine_used` subjects are never added together. +Memory usage subjects remain overlaid comparison lines rather than additive stacks. + +Byte axes choose one decimal SI scale independently for the usage and capacity subviews; +the left and right panes of the same subview stay aligned. Tooltips format each raw point +independently, so a GB/s capacity axis can still report a small observed value in KB/s +or MB/s. + +GPU points retain a physical device and UUID. If the installed NVML/driver cannot +attribute per-process SM utilization or VRAM, that series is omitted and recorded as +unavailable; AIPerf never substitutes whole-device utilization for a process value. +On Linux, the agent follows the target PID into cgroup v1 or v2. CPU usage comes from +`cpu.stat` or `cpuacct.usage`; the effective limit is the smallest finite ancestor quota +or restricted cpuset. Memory uses the smallest finite ancestor limit and the target +cgroup's charged usage. Container GPU subjects require a reliably resolved target-visible +device set and aggregate NVML values only for PIDs in the target cgroup subtree. Missing +or ambiguous cgroup/GPU facts remain unavailable instead of falling back to host values. +Network labels retain the transport family, direction, and contributing interfaces. +Ethernet uses monotonic interface byte counters; RDMA uses active port data counters. +The usage plot keeps the Ethernet/RDMA receive/transmit lines. The capacity plot selects +one capacity per transport kind at each timestamp and then sums the kinds, preventing +equal receive/transmit link capacities from being counted twice. Those sources have no +portable process attribution, so the network metric intentionally contains +`machine_used` and `machine_total` without fabricated process or container curves. +Generic Linux cgroups do not expose a portable network-bandwidth limit. + +The default loopback bind prevents accidental network exposure. Configure basic-auth +credentials with `--greptime-username` and `--greptime-password` when the database +requires them. Place the service behind an authenticated reverse proxy before binding +it to a shared network. + +## Imported metric scopes + +The importer keeps raw observations separate from derived aggregates: + +| Scope | Source | Examples | +|---|---|---| +| `run` | Profile or stream summary | mean/p90 latency, throughput, `stream_fps`, weighted chunk compute FPS | +| `session` | `sessions.jsonl` | connected latency, first frame, frames received, delivery FPS | +| `control` | `sessions.jsonl` | control acknowledgement and next-frame latency | +| `phase` | target measurements | initialization/runtime duration and allocator peaks | +| `chunk` | target measurements | compute, encode, output stages, bytes, batches, frames, allocator peaks | +| `timeslice` | profile timeslice JSON | time-windowed request metrics | +| `gpu` / `gpu_summary` | GPU telemetry | utilization, memory, power, temperature | +| `server` / `server_summary` | Prometheus exports | raw scrapes, histograms, counters, and profiling aggregates | +| `normalized` | semantic mapping | framework-neutral metrics with availability state | +| `resource` | active AIPerf resource agent | source-timestamped process, finite container, and machine curves | + +Warmup and profiling points share metric names but retain different `phase` values. +Client delivery FPS (`stream_fps`) and target compute FPS (`chunk_compute_fps` or +`chunk_compute_fps_weighted`) remain separate metrics. + +## API endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /api/v1/history/health` | Verify GreptimeDB connectivity and table selection | +| `GET /api/v1/history/status` | Show watched roots and the latest import result | +| `GET /api/v1/history/runs` | Filter and paginate indexed runs | +| `GET /api/v1/history/runs/{run_id}` | Read one run's metadata and provenance | +| `GET /api/v1/history/runs/{run_id}/metrics` | Read bounded metric values for one run | +| `GET /api/v1/history/metrics/catalog` | Discover metric/statistic/scope combinations | +| `GET /api/v1/history/metrics/series` | Query a bounded history or within-run curve | +| `GET /api/v1/history/facets` | Populate exact-value UI filters | +| `POST /api/v1/history/resource-batches` | Idempotently ingest a strict active resource batch | + +The service exposes OpenAPI documentation at `/docs`. It does not expose arbitrary SQL +or an HTTP artifact-import endpoint. The resource route accepts the versioned typed +batch only; it is not a general metric or artifact write API. + +## Failure and recovery behavior + +The history service treats GreptimeDB as a required dependency: + +- startup first verifies the configured database and creates missing tables; +- a connection, authentication, or DDL failure stops startup instead of serving stale + or file-backed data; +- query failures return HTTP 503 with `backend: greptimedb`; +- `/healthz` and `/readyz` return 503 while GreptimeDB is unavailable; +- the one-shot ingest command exits nonzero when any discovered artifact fails. +- required active resource registration/upload/final flush failures propagate to the + benchmark instead of switching stores. + +There is no SQLite, in-memory, direct-filesystem, or cached-response query path. After a +database interruption, restore GreptimeDB and rerun `aiperf history ingest`; unchanged +runs are skipped by digest and incomplete revisions are replayed. Source artifacts are +never deleted by the history service. + +This required-backend rule is independent of live benchmark collection. A Prometheus +scrape can be fail-open during a measured workload while recording a partial state, but +the post-run history API still never substitutes another database. + +## Frontend development + +The source lives in `web/history-ui`; the production build is packaged under +`src/aiperf/history/static` and served by FastAPI. A production installation therefore +does not require Node.js. + +```bash +cd web/history-ui +npm ci +npm run typecheck +npm run build +``` + +For local frontend iteration, start the Python service on port 8095 and run `npm run +dev`. Vite proxies `/api` and `/healthz` to the Python service. diff --git a/pyproject.toml b/pyproject.toml index fb280abde8..e7bc24a2ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ dependencies = [ "transformers>=4.56.0", # Lowest compatible version for dynamo backends "uvicorn[standard]>=0.34,<1", "uvloop>=0.22.1; platform_system != 'Windows'", + "websockets>=13,<17", "zstandard>=0.25.0", "crick~=0.0.8", ] @@ -77,6 +78,9 @@ aiperf = "aiperf.cli:app" aiperf = "aiperf.plugin:plugins.yaml" [project.optional-dependencies] +streaming-webrtc = [ + "aiortc>=1.9.0,<2.0.0", +] mlflow = [ "mlflow>=3.10.0,<4.0.0", ] @@ -254,5 +258,5 @@ filterwarnings = [ ] [tool.codespell] -skip = "*.pyc,*build*,tests/unit/transports/test_aiohttp_sse.py,tests/integration/assets/canary_reference_inputs.json,src/aiperf/server_metrics/units.py,src/aiperf/api/static/dashboard.html,src/aiperf/config/schema/aiperf-config.schema.json" +skip = "*.pyc,*build*,tests/unit/transports/test_aiohttp_sse.py,tests/integration/assets/canary_reference_inputs.json,src/aiperf/server_metrics/units.py,src/aiperf/api/static/dashboard.html,src/aiperf/history/static/*,src/aiperf/config/schema/aiperf-config.schema.json" ignore-words-list = "timeslice,timeslices,optiona,disjointness,concurency" diff --git a/src/aiperf/cli.py b/src/aiperf/cli.py index 37206ade90..df1823f489 100644 --- a/src/aiperf/cli.py +++ b/src/aiperf/cli.py @@ -31,6 +31,7 @@ def _get_help_text() -> str: # NOTE: The order here determines the order they will appear in docs/cli-options.md app.command("aiperf.cli_commands.analyze_trace:app", name="analyze-trace") app.command("aiperf.cli_commands.config:app", name="config") +app.command("aiperf.cli_commands.history:app", name="history") app.command("aiperf.cli_commands.profile:app", name="profile") app.command("aiperf.cli_commands.plot:app", name="plot") app.command("aiperf.cli_commands.plugins:app", name="plugins") diff --git a/src/aiperf/cli_commands/history.py b/src/aiperf/cli_commands/history.py new file mode 100644 index 0000000000..53cc7b66f1 --- /dev/null +++ b/src/aiperf/cli_commands/history.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CLI commands for the GreptimeDB-backed benchmark history service.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Annotated + +import orjson +from cyclopts import App, Parameter +from pydantic import SecretStr + +from aiperf.history.config import GreptimeConfig, HistoryServiceConfig + +app = App(name="history") + + +@app.command +def serve( + *, + artifact_roots: Annotated[ + list[Path] | None, + Parameter( + name="--artifact-root", + help="Artifact root to scan; repeat for multiple roots.", + ), + ] = None, + greptime_url: Annotated[ + str, + Parameter(help="Required GreptimeDB HTTP endpoint."), + ] = "http://127.0.0.1:4000", + greptime_database: Annotated[ + str, + Parameter(help="GreptimeDB database containing history tables."), + ] = "public", + greptime_username: Annotated[ + str | None, + Parameter(help="Optional GreptimeDB basic-auth username."), + ] = None, + greptime_password: Annotated[ + str | None, + Parameter(help="Optional GreptimeDB basic-auth password."), + ] = None, + table_prefix: Annotated[ + str, + Parameter(help="Prefix for GreptimeDB history tables."), + ] = "aiperf_history", + scan_interval_seconds: Annotated[ + float, + Parameter(help="Incremental scan interval; zero disables repeated scans."), + ] = 60.0, + host: Annotated[str, Parameter(help="History service bind host.")] = "127.0.0.1", + port: Annotated[int, Parameter(help="History service bind port.")] = 8095, + cors_origins: Annotated[ + list[str] | None, + Parameter( + name="--cors-origin", + help="Allowed browser origin; repeat for multiple origins.", + ), + ] = None, +) -> None: + """Serve benchmark history, metric curves, and the bundled Vue frontend.""" + + from aiperf.history.service import run_history_service + + config = _service_config( + artifact_roots=artifact_roots, + greptime_url=greptime_url, + greptime_database=greptime_database, + greptime_username=greptime_username, + greptime_password=greptime_password, + table_prefix=table_prefix, + scan_interval_seconds=scan_interval_seconds, + host=host, + port=port, + cors_origins=cors_origins, + ) + run_history_service(config) + + +@app.command +def ingest( + *, + artifact_roots: Annotated[ + list[Path], + Parameter( + name="--artifact-root", + help="Artifact root to import; repeat for multiple roots.", + ), + ], + greptime_url: Annotated[ + str, + Parameter(help="Required GreptimeDB HTTP endpoint."), + ] = "http://127.0.0.1:4000", + greptime_database: Annotated[ + str, + Parameter(help="GreptimeDB database containing history tables."), + ] = "public", + greptime_username: Annotated[ + str | None, + Parameter(help="Optional GreptimeDB basic-auth username."), + ] = None, + greptime_password: Annotated[ + str | None, + Parameter(help="Optional GreptimeDB basic-auth password."), + ] = None, + table_prefix: Annotated[ + str, + Parameter(help="Prefix for GreptimeDB history tables."), + ] = "aiperf_history", + force: Annotated[ + bool, + Parameter(help="Replace runs even when their artifact digest is unchanged."), + ] = False, +) -> None: + """Import supported AIPerf artifacts into GreptimeDB once.""" + + from aiperf.history.service import ingest_history_once + + config = _service_config( + artifact_roots=artifact_roots, + greptime_url=greptime_url, + greptime_database=greptime_database, + greptime_username=greptime_username, + greptime_password=greptime_password, + table_prefix=table_prefix, + scan_interval_seconds=0, + host="127.0.0.1", + port=8095, + cors_origins=None, + ) + result = asyncio.run(ingest_history_once(config, force=force)) + sys.stdout.write( + orjson.dumps( + result.model_dump(mode="json"), option=orjson.OPT_INDENT_2 + ).decode() + + "\n" + ) + if result.failed: + raise SystemExit(1) + + +def _service_config( + *, + artifact_roots: list[Path] | None, + greptime_url: str, + greptime_database: str, + greptime_username: str | None, + greptime_password: str | None, + table_prefix: str, + scan_interval_seconds: float, + host: str, + port: int, + cors_origins: list[str] | None, +) -> HistoryServiceConfig: + password = SecretStr(greptime_password) if greptime_password is not None else None + return HistoryServiceConfig( + greptime=GreptimeConfig( + url=greptime_url, + database=greptime_database, + username=greptime_username, + password=password, + table_prefix=table_prefix, + ), + artifact_roots=artifact_roots or [], + scan_interval_seconds=scan_interval_seconds, + host=host, + port=port, + cors_origins=cors_origins or [], + ) diff --git a/src/aiperf/cli_commands/profile.py b/src/aiperf/cli_commands/profile.py index f926f89e06..763153827b 100644 --- a/src/aiperf/cli_commands/profile.py +++ b/src/aiperf/cli_commands/profile.py @@ -4,17 +4,180 @@ from __future__ import annotations -from cyclopts import App +from pathlib import Path +from typing import Annotated, Any +from cyclopts import App, Parameter +from pydantic import ConfigDict, Field + +from aiperf.common.models import AIPerfBaseModel +from aiperf.config.cli_parameter import Groups from aiperf.config.flags import CLIConfig app = App(name="profile") +class StreamProfileCLIConfig(AIPerfBaseModel): + """Cyclopts inputs that select and override stream-world profiling.""" + + model_config = ConfigDict(extra="forbid") + + config: Annotated[ + Path | None, + Field(description="AIPerf stream profile configuration file."), + Parameter( + name="--stream-config", + group=Groups.STREAM_PROFILE, + help=( + "Run a stream-world profile instead of the request/response " + "benchmark pipeline." + ), + ), + ] = None + server_url: Annotated[ + str | None, + Field(description="Stream target base URL override."), + Parameter( + name="--stream-server-url", + group=Groups.STREAM_PROFILE, + help="Override the stream target base URL from --stream-config.", + ), + ] = None + artifacts_dir: Annotated[ + Path | None, + Field(description="Stream artifact root override."), + Parameter( + name="--stream-artifacts-dir", + group=Groups.STREAM_PROFILE, + help="Override the stream artifact root from --stream-config.", + ), + ] = None + ice_host_ips: Annotated[ + list[str] | None, + Field(description="WebRTC ICE host candidate address override."), + Parameter( + name="--stream-ice-host-ip", + group=Groups.STREAM_PROFILE, + help=( + "Allow one WebRTC ICE host IP, or use 'auto' for route-based " + "selection. Repeat for multiple explicit addresses." + ), + ), + ] = None + server_metrics_urls: Annotated[ + list[str] | None, + Field(description="Prometheus endpoint URL overrides."), + Parameter( + name="--stream-server-metrics-url", + group=Groups.STREAM_PROFILE, + help="Override Prometheus URLs from --stream-config.", + ), + ] = None + resource_history_url: Annotated[ + str | None, + Field(description="Required history-service URL for active resource uploads."), + Parameter( + name="--stream-resource-history-url", + group=Groups.STREAM_PROFILE, + help="Actively upload one-second target resource samples to AIPerf history.", + ), + ] = None + resource_target_pid: Annotated[ + int | None, + Field(gt=0, description="Local root PID monitored for active resources."), + Parameter( + name="--stream-resource-target-pid", + group=Groups.STREAM_PROFILE, + help="Monitor this target PID and all descendants during the stream run.", + ), + ] = None + + def overrides(self) -> dict[str, Any]: + """Build nested stream config overrides from explicitly supplied values.""" + + overrides: dict[str, Any] = {} + if self.server_url is not None: + overrides["server_url"] = self.server_url + if self.artifacts_dir is not None: + overrides["artifacts_dir"] = str(self.artifacts_dir) + if self.ice_host_ips is not None: + overrides["transport"] = {"ice_host_ips": self.ice_host_ips} + if self.server_metrics_urls is not None: + overrides["server_metrics"] = { + "enabled": True, + "urls": self.server_metrics_urls, + } + if ( + self.resource_history_url is not None + or self.resource_target_pid is not None + ): + overrides["resource_telemetry"] = { + "enabled": True, + **( + {"history_url": self.resource_history_url} + if self.resource_history_url is not None + else {} + ), + **( + {"target_pid": self.resource_target_pid} + if self.resource_target_pid is not None + else {} + ), + } + return overrides + + +def _run_stream_profile_command(stream_cli: StreamProfileCLIConfig) -> None: + from aiperf.cli_utils import exit_on_error + + if stream_cli.config is None: + raise ValueError("--stream-config is required for stream profile overrides") + with exit_on_error( + title="Error Running AIPerf Stream Profile", + show_traceback=False, + ): + from aiperf.streaming.profile import run_stream_profile_from_path + + result = run_stream_profile_from_path( + stream_cli.config, + overrides=stream_cli.overrides(), + ) + profile_summary = result.summary["profile"] + print( + "Stream profile sessions: " + f"{profile_summary['successful_sessions']}/" + f"{profile_summary['attempted_sessions']} succeeded" + ) + print(f"Artifacts: {result.artifacts_dir}") + if report_path := result.artifacts.get("stream_report"): + print(f"Stream report: {report_path}") + + +def _run_request_profile(cli_config: CLIConfig) -> None: + from aiperf.cli_utils import exit_on_error + from aiperf.config.loader.errors import ConfigurationError + + with exit_on_error(title="Error Running AIPerf System", show_traceback=False): + from aiperf.config.flags.resolver import resolve_config + from aiperf.config.loader import build_benchmark_plan + + config = resolve_config(cli_config, cli_config.config_file) + plan = build_benchmark_plan(config) + + with exit_on_error( + title="Error Running AIPerf System", + quiet_for=(ConfigurationError,), + ): + from aiperf.cli_runner import run_benchmark + + run_benchmark(plan) + + @app.default def profile( *, - cli_config: CLIConfig, + cli_config: CLIConfig | None = None, + stream_cli: StreamProfileCLIConfig | None = None, ) -> None: """Run the Profile subcommand. @@ -43,30 +206,15 @@ def profile( # Goodput measurement with SLOs aiperf profile --model your_model --url localhost:8000 --goodput "request_latency:250 inter_token_latency:10" + # Long-running WebRTC or WebSocket stream-world benchmark + aiperf profile --stream-config stream-profile.yaml + Args: cli_config: Cyclopts-populated CLIConfig DTO carrying every CLI flag (benchmark inputs and service-runtime knobs). + stream_cli: Cyclopts-populated stream profile selector and overrides. """ - from aiperf.cli_utils import exit_on_error - from aiperf.config.loader.errors import ConfigurationError - - with exit_on_error(title="Error Running AIPerf System", show_traceback=False): - from aiperf.config.flags.resolver import resolve_config - from aiperf.config.loader import build_benchmark_plan - - # ``resolve_config`` handles both paths: CLI-only (no config_file) - # and YAML+CLI merge (YAML is the base, explicitly-set CLI flags like - # ``--search-recipe`` / ``--ttft-sla-ms`` / ``--ui`` overlay on top). - # The merge order matters: a CLI-supplied recipe must reach the - # converter even when the YAML omits one. - config_file = cli_config.config_file - config = resolve_config(cli_config, config_file) - plan = build_benchmark_plan(config) - - with exit_on_error( - title="Error Running AIPerf System", - quiet_for=(ConfigurationError,), - ): - from aiperf.cli_runner import run_benchmark - - run_benchmark(plan) + if stream_cli is not None: + _run_stream_profile_command(stream_cli) + return + _run_request_profile(cli_config or CLIConfig()) diff --git a/src/aiperf/common/path_safety.py b/src/aiperf/common/path_safety.py index 379500e90b..2d20dae4bb 100644 --- a/src/aiperf/common/path_safety.py +++ b/src/aiperf/common/path_safety.py @@ -16,6 +16,26 @@ from pathlib import Path +def safe_resolve_regular_file_path(ts: str) -> Path | None: + """Resolve a user path without following symlinked components.""" + + try: + path = Path(ts).expanduser() + except (TypeError, ValueError, RuntimeError): + return None + try: + for candidate in (path, *path.parents): + if candidate.is_symlink(): + return None + except OSError: + return None + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + return None + return resolved if resolved.is_file() else None + + def safe_read_template_path(ts: str) -> str | None: """Return file contents if ``ts`` safely resolves to a regular file, else ``None``. @@ -33,21 +53,8 @@ def safe_read_template_path(ts: str) -> str | None: Returning ``None`` signals the caller to treat ``ts`` as a literal value (the existing "inline template body" fallback in both call sites). """ - try: - path = Path(ts).expanduser() - except (TypeError, ValueError, RuntimeError): - return None - try: - for candidate in (path, *path.parents): - if candidate.is_symlink(): - return None - except OSError: - return None - try: - resolved = path.resolve(strict=True) - except (OSError, RuntimeError, ValueError): - return None - if not resolved.is_file(): + resolved = safe_resolve_regular_file_path(ts) + if resolved is None: return None try: return resolved.read_text(encoding="utf-8") diff --git a/src/aiperf/config/cli_parameter.py b/src/aiperf/config/cli_parameter.py index 09df0c937e..ad82d40ddd 100644 --- a/src/aiperf/config/cli_parameter.py +++ b/src/aiperf/config/cli_parameter.py @@ -57,3 +57,4 @@ class Groups: ZMQ_COMMUNICATION = Group.create_ordered("ZMQ Communication") ACCURACY = Group.create_ordered("Accuracy") MULTI_RUN = Group.create_ordered("Multi-Run") + STREAM_PROFILE = Group.create_ordered("Stream Profile") diff --git a/src/aiperf/config/flags/resolver.py b/src/aiperf/config/flags/resolver.py index faacd0f0ac..7de2130b42 100644 --- a/src/aiperf/config/flags/resolver.py +++ b/src/aiperf/config/flags/resolver.py @@ -159,6 +159,7 @@ def build_cli_overrides( _apply_input_overrides(out, cli) _apply_recipe_and_multirun(out, cli, benchmark_config=benchmark_config) _apply_artifacts_overrides(out, cli) + _apply_server_metrics_overrides(out, cli) _apply_optional_section(out, "tokenizer", build_tokenizer(cli)) _apply_optional_section(out, "accuracy", build_accuracy(cli)) wandb_base_enabled = benchmark_config is not None and benchmark_config.wandb.enabled @@ -180,6 +181,35 @@ def build_cli_overrides( return out +def _apply_server_metrics_overrides(out: dict[str, Any], cli: CLIConfig) -> None: + """Overlay explicitly supplied server-metrics flags onto YAML config. + + ``build_server_metrics`` intentionally emits CLI-only defaults, so copying + its entire result here would replace YAML values the user did not mention. + Select only fields represented by explicit CLI flags instead. + """ + fields_set = cli.model_fields_set & { + "server_metrics", + "no_server_metrics", + "server_metrics_formats", + } + if not fields_set: + return + + from aiperf.config.flags._converter_telemetry import build_server_metrics + + built = build_server_metrics(cli) + server_metrics: dict[str, Any] = {} + if "server_metrics" in fields_set: + server_metrics["enabled"] = True + server_metrics["urls"] = built["urls"] + if "no_server_metrics" in fields_set: + server_metrics["enabled"] = built["enabled"] + if "server_metrics_formats" in fields_set: + server_metrics["formats"] = built["formats"] + out["server_metrics"] = server_metrics + + def _apply_optional_section( out: dict[str, Any], key: str, value: dict[str, Any] | None ) -> None: diff --git a/src/aiperf/endpoints/openai_video_generation.py b/src/aiperf/endpoints/openai_video_generation.py index 65cf111b2f..c9393dea7a 100644 --- a/src/aiperf/endpoints/openai_video_generation.py +++ b/src/aiperf/endpoints/openai_video_generation.py @@ -9,6 +9,11 @@ VideoResponseData, ) from aiperf.endpoints.base_endpoint import BaseEndpoint +from aiperf.endpoints.openai_image_edit import ImageEditEndpoint + +_RESERVED_PAYLOAD_KEYS: frozenset[str] = frozenset( + {"prompt", "model", "input_reference", "reference_url"} +) class VideoGenerationEndpoint(BaseEndpoint): @@ -60,20 +65,80 @@ def format_payload(self, request_info: RequestInfo) -> dict[str, Any]: prompt = turn.texts[0].contents[0] - payload = { + payload: dict[str, Any] = { "prompt": prompt, "model": turn.model or model_endpoint.primary_model_name, } - if model_endpoint.endpoint.extra: - payload.update(model_endpoint.endpoint.extra) - - if turn.extra_body: - payload.update(turn.extra_body) + if turn.images and turn.images[0].contents: + image_content = turn.images[0].contents[0] + if image_content.lower().startswith(("http://", "https://")): + payload["reference_url"] = image_content + else: + payload["input_reference"] = ImageEditEndpoint._build_image_field( + image_content + ) + + if turn.videos and turn.videos[0].contents: + video_content = turn.videos[0].contents[0] + if video_content.lower().startswith(("http://", "https://")): + payload["reference_url"] = video_content + else: + payload["input_reference"] = self._build_video_field(video_content) + + self._merge_with_reserved_filter( + payload, model_endpoint.endpoint.extra or [], "--extra-inputs" + ) + self._merge_with_reserved_filter( + payload, (turn.extra_body or {}).items(), "extra_body" + ) - self.trace(lambda: f"Formatted payload: {payload}") + self.trace(lambda: f"Formatted video payload keys: {list(payload)}") return payload + def _merge_with_reserved_filter( + self, + payload: dict[str, Any], + items: Any, + label: str, + ) -> None: + """Merge request overrides while keeping media fields endpoint-managed.""" + for key, value in items: + if key in _RESERVED_PAYLOAD_KEYS: + self.warning( + f"{label} {key!r} is managed by the endpoint and was ignored." + ) + continue + payload[key] = value + + @staticmethod + def _build_video_field(content: str) -> dict[str, Any]: + """Decode a data URL or raw base64 string into a multipart file descriptor.""" + if content.startswith("data:"): + try: + header, b64 = content.split(",", 1) + except ValueError as exc: + raise ValueError( + "Malformed data URL for video content (missing comma)." + ) from exc + mime = "video/mp4" + if ";" in header: + candidate = header.removeprefix("data:").split(";", 1)[0] + if candidate.startswith("video/"): + mime = candidate + ext = mime.split("/", 1)[1].split("+", 1)[0] + return { + "b64_data": b64, + "filename": f"reference.{ext}", + "content_type": mime, + } + + return { + "b64_data": content, + "filename": "reference.mp4", + "content_type": "video/mp4", + } + def parse_response( self, response: InferenceServerResponse ) -> ParsedResponse | None: diff --git a/src/aiperf/history/__init__.py b/src/aiperf/history/__init__.py new file mode 100644 index 0000000000..91a4bb4be4 --- /dev/null +++ b/src/aiperf/history/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""GreptimeDB-backed benchmark history service.""" + +from aiperf.history.config import GreptimeConfig, HistoryServiceConfig +from aiperf.history.models import HistoryMetricSample, HistoryRunRecord + +__all__ = [ + "GreptimeConfig", + "HistoryMetricSample", + "HistoryRunRecord", + "HistoryServiceConfig", +] diff --git a/src/aiperf/history/api.py b/src/aiperf/history/api.py new file mode 100644 index 0000000000..9b89e6b8d2 --- /dev/null +++ b/src/aiperf/history/api.py @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""FastAPI routes for GreptimeDB-backed benchmark history and live ingestion.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from aiperf.history.api_models import ( + FacetsResponse, + HistoryHealthResponse, + HistoryStatusResponse, + MetricCatalogItem, + MetricCatalogResponse, + MetricSeriesResponse, + MetricSeriesSample, + MetricValuesResponse, + RunDetailResponse, + RunListResponse, +) +from aiperf.history.config import HistoryServiceConfig +from aiperf.history.repository import HistoryRepository +from aiperf.resource_telemetry.models import ( + ResourceTelemetryAck, + ResourceTelemetryBatch, +) + +history_router = APIRouter(prefix="/api/v1/history", tags=["History"]) + + +def get_history_repository(request: Request) -> HistoryRepository: + """Resolve the initialized GreptimeDB repository from application state.""" + + return request.app.state.history_repository + + +def get_history_config(request: Request) -> HistoryServiceConfig: + """Resolve immutable history service settings from application state.""" + + return request.app.state.history_config + + +RepositoryDep = Annotated[HistoryRepository, Depends(get_history_repository)] +ConfigDep = Annotated[HistoryServiceConfig, Depends(get_history_config)] + + +@history_router.post( + "/resource-batches", + response_model=ResourceTelemetryAck, +) +async def ingest_resource_batch( + batch: ResourceTelemetryBatch, + repository: RepositoryDep, +) -> ResourceTelemetryAck: + """Persist one independently retryable target-resource telemetry batch.""" + + accepted_points = await repository.ingest_resource_batch(batch) + return ResourceTelemetryAck( + schema_version=batch.schema_version, + batch_id=batch.batch_id, + sequence=batch.sequence, + run_id=batch.run.benchmark_id, + accepted_samples=len(batch.samples), + accepted_points=accepted_points, + final=batch.final, + ) + + +@history_router.get("/health", response_model=HistoryHealthResponse) +async def history_health( + repository: RepositoryDep, + config: ConfigDep, +) -> HistoryHealthResponse: + """Verify that the configured GreptimeDB backend is queryable.""" + + await repository.ping() + return HistoryHealthResponse( + status="ok", + database=config.greptime.database, + runs_table=config.greptime.runs_table, + metrics_table=config.greptime.metrics_table, + ) + + +@history_router.get("/status", response_model=HistoryStatusResponse) +async def history_status(request: Request, config: ConfigDep) -> HistoryStatusResponse: + """Return watcher settings and the latest incremental import result.""" + + return HistoryStatusResponse( + database=config.greptime.database, + artifact_roots=[str(path) for path in config.artifact_roots], + scan_interval_seconds=config.scan_interval_seconds, + last_import=getattr(request.app.state, "last_import", None), + ) + + +@history_router.get("/runs", response_model=RunListResponse) +async def list_runs( + repository: RepositoryDep, + *, + limit: Annotated[int, Query(ge=1, le=500, description="Page size.")] = 50, + offset: Annotated[int, Query(ge=0, description="Zero-based page offset.")] = 0, + implementation: Annotated[ + str | None, Query(description="Exact target implementation filter.") + ] = None, + model: Annotated[str | None, Query(description="Exact model filter.")] = None, + scene: Annotated[str | None, Query(description="Exact scene filter.")] = None, + mode: Annotated[ + str | None, Query(description="Exact benchmark mode filter.") + ] = None, + status: Annotated[str | None, Query(description="Exact run status filter.")] = None, + run_kind: Annotated[ + str | None, Query(description="Exact artifact family filter.") + ] = None, + search: Annotated[ + str | None, + Query(min_length=1, max_length=256, description="Case-insensitive run search."), + ] = None, + started_after: Annotated[ + datetime | None, Query(description="Inclusive UTC start-time lower bound.") + ] = None, + started_before: Annotated[ + datetime | None, Query(description="Inclusive UTC start-time upper bound.") + ] = None, +) -> RunListResponse: + """List benchmark runs from GreptimeDB with bounded filtering and pagination.""" + + items, total = await repository.list_runs( + limit=limit, + offset=offset, + implementation=implementation, + model=model, + scene=scene, + mode=mode, + status=status, + run_kind=run_kind, + search=search, + started_after=started_after, + started_before=started_before, + ) + return RunListResponse(items=items, total=total, limit=limit, offset=offset) + + +@history_router.get("/runs/{run_id}", response_model=RunDetailResponse) +async def get_run(run_id: str, repository: RepositoryDep) -> RunDetailResponse: + """Return retained metadata and configuration for one benchmark run.""" + + run = await repository.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail=f"History run not found: {run_id}") + return RunDetailResponse(run=run) + + +@history_router.get( + "/runs/{run_id}/metrics", + response_model=MetricValuesResponse, +) +async def get_run_metrics( + run_id: str, + repository: RepositoryDep, + *, + scope: Annotated[ + str | None, Query(description="Optional exact metric scope filter.") + ] = None, + phase: Annotated[ + str | None, Query(description="Optional exact benchmark phase filter.") + ] = None, + limit: Annotated[ + int, Query(ge=1, le=20000, description="Maximum returned metric points.") + ] = 5000, +) -> MetricValuesResponse: + """Return bounded metric values for one run-detail page.""" + + if await repository.get_run(run_id) is None: + raise HTTPException(status_code=404, detail=f"History run not found: {run_id}") + rows = await repository.run_metric_values( + run_id, + scope=scope, + phase=phase, + limit=limit, + ) + return MetricValuesResponse( + items=[MetricSeriesSample.model_validate(row) for row in rows] + ) + + +@history_router.get("/metrics/catalog", response_model=MetricCatalogResponse) +async def metric_catalog( + repository: RepositoryDep, + run_id: Annotated[str | None, Query(description="Optional run filter.")] = None, + implementation: Annotated[ + str | None, Query(description="Optional implementation filter.") + ] = None, + scene: Annotated[str | None, Query(description="Optional scene filter.")] = None, +) -> MetricCatalogResponse: + """List metric dimensions available for curve queries.""" + + rows = await repository.metric_catalog( + run_id=run_id, + implementation=implementation, + scene=scene, + ) + return MetricCatalogResponse( + items=[MetricCatalogItem.model_validate(row) for row in rows] + ) + + +@history_router.get("/metrics/series", response_model=MetricSeriesResponse) +async def metric_series( + repository: RepositoryDep, + *, + metric_name: Annotated[str, Query(min_length=1, max_length=256)], + statistic: Annotated[str, Query(min_length=1, max_length=64)] = "value", + scope: Annotated[str, Query(min_length=1, max_length=64)] = "run", + limit: Annotated[int, Query(ge=1, le=50000)] = 10000, + run_id: Annotated[str | None, Query()] = None, + implementation: Annotated[str | None, Query()] = None, + model: Annotated[str | None, Query()] = None, + scene: Annotated[str | None, Query()] = None, + mode: Annotated[str | None, Query()] = None, + status: Annotated[str | None, Query()] = None, + run_kind: Annotated[str | None, Query()] = None, + search: Annotated[str | None, Query(min_length=1, max_length=256)] = None, + phase: Annotated[str | None, Query()] = None, + started_after: Annotated[datetime | None, Query()] = None, + started_before: Annotated[datetime | None, Query()] = None, +) -> MetricSeriesResponse: + """Return an ordered cross-run or within-run metric curve.""" + + rows = await repository.metric_series( + metric_name=metric_name, + statistic=statistic, + scope=scope, + limit=limit, + run_id=run_id, + implementation=implementation, + model=model, + scene=scene, + mode=mode, + status=status, + run_kind=run_kind, + search=search, + phase=phase, + started_after=started_after, + started_before=started_before, + ) + return MetricSeriesResponse( + metric_name=metric_name, + statistic=statistic, + scope=scope, + items=[MetricSeriesSample.model_validate(row) for row in rows], + ) + + +@history_router.get("/facets", response_model=FacetsResponse) +async def facets(repository: RepositoryDep) -> FacetsResponse: + """Return distinct run metadata values for UI filters.""" + + return FacetsResponse(facets=await repository.facets()) diff --git a/src/aiperf/history/api_models.py b/src/aiperf/history/api_models.py new file mode 100644 index 0000000000..df175c840c --- /dev/null +++ b/src/aiperf/history/api_models.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public response models for the benchmark history API.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import ConfigDict, Field + +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel +from aiperf.history.models import HistoryRunRecord, ImportResult + + +class _StrictAPIModel(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid") + + +class HistoryHealthResponse(_StrictAPIModel): + """GreptimeDB-backed service health response.""" + + status: str = Field(description="Service health state.") + database: str = Field(description="Configured GreptimeDB database name.") + runs_table: str = Field(description="GreptimeDB run table name.") + metrics_table: str = Field(description="GreptimeDB metric-point table name.") + + +class HistoryStatusResponse(_StrictAPIModel): + """History service state and most recent artifact import result.""" + + database: str = Field(description="Configured GreptimeDB database name.") + artifact_roots: list[str] = Field( + default_factory=list, + description="Artifact roots watched by this service instance.", + ) + scan_interval_seconds: FiniteFloat = Field( + ge=0, + description="Seconds between automatic incremental scans.", + ) + last_import: ImportResult | None = Field( + default=None, + description="Most recent import result, or null before the first scan.", + ) + + +class RunListResponse(_StrictAPIModel): + """One paginated history run list.""" + + items: list[HistoryRunRecord] = Field(description="Runs in this page.") + total: int = Field(ge=0, description="Total runs matching the filters.") + limit: int = Field(ge=1, description="Requested page size.") + offset: int = Field(ge=0, description="Requested zero-based page offset.") + + +class RunDetailResponse(_StrictAPIModel): + """Metadata and retained configuration for one run.""" + + run: HistoryRunRecord = Field(description="Indexed run metadata.") + + +class MetricCatalogItem(_StrictAPIModel): + """One available metric/statistic/scope combination.""" + + metric_name: str = Field(description="Stable metric name.") + statistic: str = Field(description="Available statistic name.") + scope: str = Field(description="Available metric granularity.") + unit: str = Field(default="", description="Metric display unit.") + point_count: int = Field(ge=0, description="Number of matching stored points.") + + +class MetricCatalogResponse(_StrictAPIModel): + """Catalog of queryable metrics.""" + + items: list[MetricCatalogItem] = Field(description="Available metric dimensions.") + + +class MetricSeriesSample(_StrictAPIModel): + """One point returned to a history or within-run chart.""" + + run_id: str = Field(description="Run owning this point.") + recorded_at: datetime = Field(description="Point wall-clock timestamp.") + value: FiniteFloat = Field(description="Finite point value.") + unit: str = Field(default="", description="Point display unit.") + statistic: str = Field(description="Statistic represented by this point.") + scope: str = Field(description="Metric granularity.") + phase: str = Field(default="", description="Benchmark phase dimension.") + session_id: str = Field(default="", description="Stream session dimension.") + sample_index: int = Field( + default=-1, + ge=-1, + description="Ordered within-run sample index.", + ) + device: str = Field(default="", description="Device dimension.") + source: str = Field( + default="", description="Source artifact or endpoint dimension." + ) + state: str = Field(default="observed", description="Observation state.") + labels: dict[str, str] = Field( + default_factory=dict, + description="Source labels retained for filtering and tooltips.", + ) + artifact_digest: str = Field(description="Artifact revision owning this point.") + run_kind: str = Field(description="Artifact family owning this point.") + status: str = Field(description="Run status.") + implementation: str = Field(description="Target implementation.") + model: str = Field(default="", description="Target model.") + model_family: str = Field(default="", description="Comparable model family.") + mode: str = Field(default="", description="Benchmark mode.") + scene: str = Field(default="", description="Comparable workload scene.") + task: str = Field(default="", description="Benchmark task.") + transport: str = Field(default="", description="Client transport.") + metric_name: str = Field(description="Stable metric name.") + + +class MetricSeriesResponse(_StrictAPIModel): + """Bounded metric series ready for chart rendering.""" + + metric_name: str = Field(description="Requested metric name.") + statistic: str = Field(description="Requested statistic.") + scope: str = Field(description="Requested metric scope.") + items: list[MetricSeriesSample] = Field(description="Ordered series points.") + + +class MetricValuesResponse(_StrictAPIModel): + """Bounded metric values for one run.""" + + items: list[MetricSeriesSample] = Field(description="Metric values for the run.") + + +class FacetsResponse(_StrictAPIModel): + """Distinct values for supported history filters.""" + + facets: dict[str, list[str]] = Field(description="Filter name to sorted values.") diff --git a/src/aiperf/history/artifact_io.py b/src/aiperf/history/artifact_io.py new file mode 100644 index 0000000000..25442f9a7c --- /dev/null +++ b/src/aiperf/history/artifact_io.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Safe asynchronous discovery and loading of benchmark artifacts.""" + +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path +from typing import Any + +import orjson + +from aiperf.common.path_safety import safe_read_template_path +from aiperf.history.models import ArtifactCandidate + +STREAM_FILE_NAMES = ( + "observability_manifest.json", + "summary.json", + "sessions.jsonl", + "normalized_metrics.json", + "server_metrics_export.json", + "server_metrics_export.jsonl", + "gpu_telemetry_export.jsonl", + "benchmark_contract.json", + "stream_config.json", + "target_metadata.json", +) + +PROFILE_FILE_NAMES = ( + "profile_export_aiperf.json", + "profile_export_aiperf_timeslices.json", + "gpu_telemetry_export.jsonl", + "server_metrics_export.json", + "server_metrics_export.jsonl", +) + +ARTIFACT_INDEX_VERSION = "1" + + +async def discover_artifacts(roots: list[Path]) -> list[ArtifactCandidate]: + """Discover supported run entry points without blocking the event loop.""" + + return await asyncio.to_thread(_discover_artifacts, roots) + + +def _discover_artifacts(roots: list[Path]) -> list[ArtifactCandidate]: + candidates: dict[Path, ArtifactCandidate] = {} + for root in roots: + resolved = root.resolve(strict=True) + if not resolved.is_dir(): + raise NotADirectoryError(f"Artifact root is not a directory: {resolved}") + for path in resolved.rglob("observability_manifest.json"): + candidates[path.parent] = ArtifactCandidate(kind="stream", main_path=path) + for path in resolved.rglob("summary.json"): + if path.parent not in candidates: + candidates[path.parent] = ArtifactCandidate( + kind="stream", main_path=path + ) + for path in resolved.rglob("profile_export_aiperf.json"): + candidates[path.parent] = ArtifactCandidate(kind="profile", main_path=path) + return sorted(candidates.values(), key=lambda item: str(item.main_path)) + + +async def read_run_files( + directory: Path, + names: tuple[str, ...], +) -> dict[str, str]: + """Read supported regular files through the shared path-safety sanitizer.""" + + loaded: dict[str, str] = {} + for name in names: + path = directory / name + text = await asyncio.to_thread(safe_read_template_path, str(path)) + if text is not None: + loaded[name] = text + return loaded + + +def artifact_digest(files: dict[str, str]) -> str: + """Return a stable digest over every loaded source artifact.""" + + digest = hashlib.sha256() + digest.update(f"aiperf-history-index:{ARTIFACT_INDEX_VERSION}\0".encode()) + for name in sorted(files): + digest.update(name.encode()) + digest.update(b"\0") + digest.update(files[name].encode()) + digest.update(b"\0") + return digest.hexdigest() + + +def load_json(files: dict[str, str], name: str, *, required: bool = False) -> Any: + """Decode one loaded JSON document.""" + + text = files.get(name) + if text is None: + if required: + raise FileNotFoundError(f"Missing required benchmark artifact: {name}") + return None + try: + return orjson.loads(text) + except orjson.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in benchmark artifact: {name}") from exc + + +def load_jsonl(files: dict[str, str], name: str) -> list[Any]: + """Decode all non-empty lines from one loaded JSONL document.""" + + text = files.get(name) + if text is None: + return [] + records: list[Any] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + try: + records.append(orjson.loads(line)) + except orjson.JSONDecodeError as exc: + raise ValueError(f"Invalid JSONL in {name} at line {line_number}") from exc + return records diff --git a/src/aiperf/history/config.py b/src/aiperf/history/config.py new file mode 100644 index 0000000000..5f3f67de27 --- /dev/null +++ b/src/aiperf/history/config.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Configuration for the benchmark history service.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from pydantic import Field, HttpUrl, SecretStr, field_validator, model_validator + +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel + +_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$") + + +class GreptimeConfig(AIPerfBaseModel): + """Connection and schema settings for a required GreptimeDB backend.""" + + url: HttpUrl = Field( + default="http://127.0.0.1:4000", + description="GreptimeDB HTTP endpoint used for every history read and write.", + ) + database: str = Field( + default="public", + min_length=1, + max_length=128, + description="GreptimeDB database containing the AIPerf history tables.", + ) + username: str | None = Field( + default=None, + min_length=1, + max_length=256, + description="Optional GreptimeDB HTTP basic-auth username.", + ) + password: SecretStr | None = Field( + default=None, + description="Optional GreptimeDB HTTP basic-auth password.", + ) + timeout_seconds: FiniteFloat = Field( + default=30.0, + gt=0, + le=300, + description="Timeout in seconds for one GreptimeDB HTTP request.", + ) + table_prefix: str = Field( + default="aiperf_history", + description="Validated prefix for the run and metric-point tables.", + ) + + @field_validator("table_prefix") + @classmethod + def validate_table_prefix(cls, value: str) -> str: + """Reject identifiers that cannot be safely interpolated into DDL.""" + + if not _IDENTIFIER_PATTERN.fullmatch(value): + raise ValueError( + "table_prefix must start with a letter or underscore and contain " + "only letters, digits, or underscores" + ) + return value + + @model_validator(mode="after") + def require_complete_credentials(self) -> GreptimeConfig: + """Require both halves of basic authentication when either is set.""" + + if (self.username is None) != (self.password is None): + raise ValueError("GreptimeDB username and password must be set together") + return self + + @property + def runs_table(self) -> str: + """Return the validated run table name.""" + + return f"{self.table_prefix}_runs" + + @property + def metrics_table(self) -> str: + """Return the validated metric-point table name.""" + + return f"{self.table_prefix}_metric_points" + + +class HistoryServiceConfig(AIPerfBaseModel): + """Runtime settings for the standalone AIPerf history web service.""" + + greptime: GreptimeConfig = Field( + default_factory=GreptimeConfig, + description="Required GreptimeDB connection and table settings.", + ) + artifact_roots: list[Path] = Field( + default_factory=list, + description="Artifact directory roots scanned and imported into GreptimeDB.", + ) + scan_interval_seconds: FiniteFloat = Field( + default=60.0, + ge=0, + le=86400, + description=( + "Seconds between incremental artifact scans, or zero to scan only once " + "during startup." + ), + ) + host: str = Field( + default="127.0.0.1", + min_length=1, + max_length=255, + description="Network interface on which the history HTTP service listens.", + ) + port: int = Field( + default=8095, + ge=1, + le=65535, + description="TCP port on which the history HTTP service listens.", + ) + cors_origins: list[str] = Field( + default_factory=list, + description="Optional browser origins allowed to call the history API.", + ) + + @field_validator("artifact_roots") + @classmethod + def expand_artifact_roots(cls, values: list[Path]) -> list[Path]: + """Expand user-home components without touching the filesystem.""" + + return [value.expanduser() for value in values] diff --git a/src/aiperf/history/greptime.py b/src/aiperf/history/greptime.py new file mode 100644 index 0000000000..3367f76b23 --- /dev/null +++ b/src/aiperf/history/greptime.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Minimal asynchronous GreptimeDB HTTP SQL client.""" + +from __future__ import annotations + +from typing import Any + +import aiohttp +import orjson + +from aiperf.history.config import GreptimeConfig + + +class GreptimeError(RuntimeError): + """Raised when GreptimeDB rejects a request or returns an invalid response.""" + + +class GreptimeClient: + """Execute SQL against the required GreptimeDB history backend.""" + + def __init__(self, config: GreptimeConfig) -> None: + self.config = config + self._session: aiohttp.ClientSession | None = None + + async def start(self) -> None: + """Create the shared HTTP session and verify GreptimeDB connectivity.""" + + if self._session is not None: + return + timeout = aiohttp.ClientTimeout(total=float(self.config.timeout_seconds)) + self._session = aiohttp.ClientSession(timeout=timeout) + try: + await self.execute("SELECT 1 AS ready") + except Exception: + await self.close() + raise + + async def close(self) -> None: + """Close the shared HTTP session.""" + + if self._session is not None: + await self._session.close() + self._session = None + + async def execute(self, sql: str) -> list[dict[str, Any]]: + """Execute one SQL statement and return all record rows as dictionaries.""" + + if self._session is None: + raise GreptimeError("GreptimeDB client has not been started") + auth = self._basic_auth() + endpoint = f"{str(self.config.url).rstrip('/')}/v1/sql" + try: + async with self._session.post( + endpoint, + params={"db": self.config.database}, + data={"sql": sql}, + auth=auth, + ) as response: + payload_bytes = await response.read() + if response.status >= 400: + detail = payload_bytes.decode("utf-8", errors="replace")[:1000] + raise GreptimeError(f"GreptimeDB HTTP {response.status}: {detail}") + except (aiohttp.ClientError, TimeoutError) as exc: + raise GreptimeError(f"GreptimeDB request failed: {exc}") from exc + + try: + payload = orjson.loads(payload_bytes) + except orjson.JSONDecodeError as exc: + raise GreptimeError("GreptimeDB returned invalid JSON") from exc + if not isinstance(payload, dict): + raise GreptimeError("GreptimeDB returned a non-object response") + if int(payload.get("code", 0)) != 0: + message = payload.get("error") or payload.get("message") or "unknown error" + raise GreptimeError(f"GreptimeDB SQL failed: {message}") + return _extract_record_rows(payload) + + def _basic_auth(self) -> aiohttp.BasicAuth | None: + if self.config.username is None or self.config.password is None: + return None + return aiohttp.BasicAuth( + self.config.username, + self.config.password.get_secret_value(), + ) + + +def _extract_record_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + outputs = payload.get("output", []) + if not isinstance(outputs, list): + return rows + for output in outputs: + if not isinstance(output, dict): + continue + records = output.get("records") + if not isinstance(records, dict): + continue + names = _column_names(records.get("schema")) + raw_rows = records.get("rows", []) + if not isinstance(raw_rows, list): + continue + for row in raw_rows: + if isinstance(row, dict): + rows.append(row) + elif isinstance(row, list) and len(row) == len(names): + rows.append(dict(zip(names, row, strict=True))) + return rows + + +def _column_names(schema: Any) -> list[str]: + if isinstance(schema, dict): + columns = schema.get("column_schemas", []) + elif isinstance(schema, list): + columns = schema + else: + columns = [] + names: list[str] = [] + for column in columns: + if isinstance(column, dict) and isinstance(column.get("name"), str): + names.append(column["name"]) + return names diff --git a/src/aiperf/history/importer.py b/src/aiperf/history/importer.py new file mode 100644 index 0000000000..8764ca1dfe --- /dev/null +++ b/src/aiperf/history/importer.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Incremental artifact import into GreptimeDB.""" + +from __future__ import annotations + +from pathlib import Path + +from aiperf.history.artifact_io import discover_artifacts +from aiperf.history.models import ( + ArtifactCandidate, + ImportFailure, + ImportResult, + ParsedHistoryRun, +) +from aiperf.history.profile_parser import parse_profile_artifact +from aiperf.history.repository import HistoryRepository +from aiperf.history.stream_parser import parse_stream_artifact + + +class ArtifactImporter: + """Discover, parse, deduplicate, and write supported benchmark runs.""" + + def __init__(self, repository: HistoryRepository) -> None: + self.repository = repository + + async def import_roots( + self, + roots: list[Path], + *, + force: bool = False, + ) -> ImportResult: + """Import all supported artifacts beneath the supplied roots.""" + + candidates = await discover_artifacts(roots) + imported = 0 + skipped = 0 + metric_points = 0 + failures: list[ImportFailure] = [] + for candidate in candidates: + try: + parsed = await _parse_candidate(candidate) + existing = await self.repository.artifact_digest(parsed.run.run_id) + if not force and existing == parsed.run.artifact_digest: + skipped += 1 + continue + await self.repository.replace_run(parsed) + imported += 1 + metric_points += len(parsed.points) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + if len(failures) < 50: + failures.append( + ImportFailure( + path=str(candidate.main_path), + error=f"{type(exc).__name__}: {exc}"[:1000], + ) + ) + return ImportResult( + discovered=len(candidates), + imported=imported, + skipped=skipped, + failed=len(candidates) - imported - skipped, + metric_points=metric_points, + failures=failures, + ) + + +async def _parse_candidate(candidate: ArtifactCandidate) -> ParsedHistoryRun: + if candidate.kind == "stream": + return await parse_stream_artifact(candidate.main_path) + if candidate.kind == "profile": + return await parse_profile_artifact(candidate.main_path) + raise ValueError(f"Unsupported artifact kind: {candidate.kind}") diff --git a/src/aiperf/history/models.py b/src/aiperf/history/models.py new file mode 100644 index 0000000000..491c80d154 --- /dev/null +++ b/src/aiperf/history/models.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed records shared by artifact import, GreptimeDB, and the history API.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +from pydantic import ConfigDict, Field + +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel + + +class _StrictHistoryModel(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid") + + +class ArtifactCandidate(_StrictHistoryModel): + """One canonical artifact entry point discovered under a configured root.""" + + kind: str = Field( + min_length=1, + description="Artifact parser family selected for this candidate.", + ) + main_path: Path = Field( + description="Canonical manifest, summary, or profile JSON entry point." + ) + + +class HistoryRunRecord(_StrictHistoryModel): + """One benchmark run indexed in GreptimeDB.""" + + run_id: str = Field( + min_length=1, description="Stable unique history run identifier." + ) + benchmark_id: str = Field( + min_length=1, + description="Benchmark identifier emitted by AIPerf or derived from the artifact.", + ) + run_kind: str = Field( + min_length=1, + description="Artifact family, such as stream or profile.", + ) + started_at: datetime = Field(description="UTC wall-clock start time of the run.") + ended_at: datetime | None = Field( + default=None, + description="UTC wall-clock end time of the run when available.", + ) + ingested_at: datetime = Field( + description="UTC wall-clock time at which this artifact revision was imported." + ) + status: str = Field( + min_length=1, + description="Terminal benchmark status derived from the canonical artifact.", + ) + implementation: str = Field( + min_length=1, + description="Framework or serving implementation under test.", + ) + model: str = Field(description="Model identifier reported by the benchmark.") + model_family: str = Field( + description="Framework-neutral model family used to group comparable runs." + ) + mode: str = Field(description="Benchmark mode, such as stream_world or chat.") + scene: str = Field( + description="Comparable benchmark scene or workload family identifier." + ) + task: str = Field(description="Task name exercised by the benchmark workload.") + transport: str = Field(description="Client transport used by the benchmark.") + hardware: str = Field(description="Bounded human-readable target hardware summary.") + aiperf_version: str = Field(description="AIPerf version recorded by the artifact.") + aiperf_commit: str = Field( + description="AIPerf source commit recorded by the artifact." + ) + contract_digest: str = Field( + description="Benchmark contract digest used for reproducibility." + ) + artifact_path: str = Field( + min_length=1, + description="Absolute source artifact directory used for replay and inspection.", + ) + artifact_digest: str = Field( + min_length=1, + description=( + "Source-content and importer-schema digest used to make repeated " + "imports idempotent." + ), + ) + metric_count: int = Field( + ge=0, + description="Number of finite metric points imported for this revision.", + ) + session_count: int = Field( + ge=0, + description="Number of profiled stream sessions represented by this run.", + ) + tags: dict[str, str] = Field( + default_factory=dict, + description="Searchable provenance tags retained with the run.", + ) + config: dict[str, Any] = Field( + default_factory=dict, + description="Canonical benchmark configuration retained as JSON.", + ) + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Target and artifact-integrity metadata retained as JSON.", + ) + + +class HistoryMetricSample(_StrictHistoryModel): + """One finite scalar metric point stored in the history time-series table.""" + + point_id: str = Field( + min_length=1, + description="Deterministic identifier for idempotent point replacement.", + ) + run_id: str = Field(min_length=1, description="Run owning this metric point.") + metric_name: str = Field( + min_length=1, + description="Stable machine-readable metric name.", + ) + statistic: str = Field( + min_length=1, + description="Statistic represented by the value, such as mean, p90, or value.", + ) + scope: str = Field( + min_length=1, + description="Metric granularity, such as run, session, chunk, or server.", + ) + recorded_at: datetime = Field( + description="Wall-clock timestamp associated with the metric point." + ) + value: FiniteFloat = Field(description="Finite numeric metric value.") + unit: str = Field(description="Unit emitted or inferred for the metric value.") + phase: str = Field( + default="", + description="Benchmark phase associated with the point when applicable.", + ) + session_id: str = Field( + default="", + description="Stream session identifier associated with the point.", + ) + sample_index: int = Field( + default=-1, + ge=-1, + description="Ordered sample index, or -1 for aggregate points.", + ) + device: str = Field( + default="", + description="Device identifier associated with the point when applicable.", + ) + source: str = Field( + default="", + description="Raw artifact or endpoint that supplied the point.", + ) + state: str = Field( + default="observed", + description="Observation state retained from normalized metrics.", + ) + labels: dict[str, str] = Field( + default_factory=dict, + description="Bounded source labels retained as JSON.", + ) + + +class ParsedHistoryRun(_StrictHistoryModel): + """A fully parsed run and all metric points ready for database import.""" + + run: HistoryRunRecord = Field(description="Parsed run metadata.") + points: list[HistoryMetricSample] = Field( + default_factory=list, + description="Finite metric points extracted from canonical artifacts.", + ) + + +class ImportFailure(_StrictHistoryModel): + """One artifact that could not be imported.""" + + path: str = Field(description="Artifact path that failed to import.") + error: str = Field(description="Bounded failure description.") + + +class ImportResult(_StrictHistoryModel): + """Summary of one incremental artifact import pass.""" + + discovered: int = Field(ge=0, description="Number of candidate runs discovered.") + imported: int = Field(ge=0, description="Number of new or changed runs imported.") + skipped: int = Field(ge=0, description="Number of unchanged runs skipped.") + failed: int = Field(ge=0, description="Number of candidates that failed.") + metric_points: int = Field( + ge=0, + description="Number of metric points written during the pass.", + ) + failures: list[ImportFailure] = Field( + default_factory=list, + description="Bounded details for failed candidates.", + ) diff --git a/src/aiperf/history/parsing.py b/src/aiperf/history/parsing.py new file mode 100644 index 0000000000..f17fa80c62 --- /dev/null +++ b/src/aiperf/history/parsing.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Small parsing helpers shared by supported artifact families.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any + + +def as_mapping(value: Any) -> Mapping[str, Any]: + """Return a mapping view or an empty mapping for malformed optional data.""" + + return value if isinstance(value, Mapping) else {} + + +def mapping_list(value: Any) -> list[Mapping[str, Any]]: + """Return only mapping members from an optional list.""" + + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, Mapping)] + + +def parse_datetime(value: Any, *, fallback: datetime | None = None) -> datetime: + """Parse an ISO timestamp and normalize it to UTC.""" + + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + parsed = fallback or datetime.now(tz=timezone.utc) + else: + parsed = fallback or datetime.now(tz=timezone.utc) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def manifest_end_time(manifest: Mapping[str, Any]) -> datetime | None: + """Return the latest phase wall-clock end timestamp from a manifest.""" + + end_values = [ + as_mapping(phase.get("end")).get("wall_time_ns") + for phase in mapping_list(manifest.get("phases")) + ] + valid = [value for value in end_values if isinstance(value, int) and value >= 0] + if not valid: + return None + return datetime.fromtimestamp(max(valid) / 1_000_000_000, tz=timezone.utc) + + +def hardware_summary(metadata: Mapping[str, Any]) -> str: + """Build a bounded hardware label from target metadata when available.""" + + environment = as_mapping(metadata.get("environment")) + gpus = mapping_list(environment.get("gpus")) + names = [str(item.get("name")) for item in gpus if item.get("name")] + if names: + if len(set(names)) == 1 and len(names) > 1: + return f"{len(names)}x {names[0]}"[:512] + return ", ".join(names)[:512] + + models = mapping_list(metadata.get("data")) + counts = [item.get("num_gpus") for item in models if item.get("num_gpus")] + if counts: + return f"{max(int(value) for value in counts)} GPU" + return "" + + +def server_metric_units(payload: Any) -> dict[str, str]: + """Extract source-declared units from an aggregate server-metrics export.""" + + metrics = as_mapping(as_mapping(payload).get("metrics")) + return { + str(name): str(as_mapping(metric).get("unit") or "") + for name, metric in metrics.items() + } diff --git a/src/aiperf/history/points.py b/src/aiperf/history/points.py new file mode 100644 index 0000000000..d954dff6c4 --- /dev/null +++ b/src/aiperf/history/points.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Framework-neutral construction of finite history metric points.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any + +import orjson + +from aiperf.common.finite import is_finite_value +from aiperf.history.models import HistoryMetricSample + + +class MetricPointBuilder: + """Build deterministic finite metric points for one parsed run.""" + + def __init__(self, run_id: str, started_at: datetime) -> None: + self.run_id = run_id + self.started_at = started_at + self.points: list[HistoryMetricSample] = [] + + def add( + self, + metric_name: str, + value: Any, + *, + unit: str = "", + statistic: str = "value", + scope: str = "run", + recorded_at: datetime | None = None, + phase: str = "", + session_id: str = "", + sample_index: int = -1, + device: str = "", + source: str = "", + state: str = "observed", + labels: Mapping[str, Any] | None = None, + ) -> None: + """Append a point when the supplied value is numeric and finite.""" + + if isinstance(value, bool) or not is_finite_value(value): + return + normalized_labels = _string_labels(labels) + timestamp = _utc(recorded_at or self.started_at) + dimensions = ( + metric_name, + statistic, + scope, + timestamp.isoformat(), + phase, + session_id, + str(sample_index), + device, + source, + state, + orjson.dumps(normalized_labels, option=orjson.OPT_SORT_KEYS).decode(), + ) + point_id = hashlib.sha256("\x1f".join(dimensions).encode()).hexdigest() + self.points.append( + HistoryMetricSample( + point_id=point_id, + run_id=self.run_id, + metric_name=metric_name, + statistic=statistic, + scope=scope, + recorded_at=timestamp, + value=float(value), + unit=unit or infer_unit(metric_name), + phase=phase, + session_id=session_id, + sample_index=sample_index, + device=device, + source=source, + state=state, + labels=normalized_labels, + ) + ) + + +def infer_unit(metric_name: str) -> str: + """Infer a display unit only when the source artifact omits one.""" + + name = metric_name.lower() + if name.endswith("_ms") or "latency_ms" in name: + return "milliseconds" + if name.endswith("_seconds") or name.endswith("_s"): + return "seconds" + if name.endswith("_bytes"): + return "bytes" + if "_fps" in name or name.endswith("frames_per_second"): + return "frames/second" + if name.endswith("_frames") or name == "frames_received": + return "frames" + if name.endswith("_rate") or name == "success_rate": + return "ratio" + if name.endswith("_count") or name.endswith("_sessions"): + return "count" + return "" + + +def add_server_metric_records( + builder: MetricPointBuilder, + records: list[Any], + *, + units: Mapping[str, str] | None = None, +) -> None: + """Import raw Prometheus scrapes with their original timestamps and labels.""" + + metric_units = units or {} + for sample_index, record in enumerate(records): + if not isinstance(record, Mapping): + continue + timestamp = _from_ns(record.get("timestamp_ns")) or builder.started_at + endpoint = str(record.get("endpoint_url") or "") + latency_ns = record.get("endpoint_latency_ns") + if is_finite_value(latency_ns): + builder.add( + "server_scrape_latency_ms", + float(latency_ns) / 1_000_000, + unit="milliseconds", + scope="server", + recorded_at=timestamp, + sample_index=sample_index, + source=endpoint, + ) + metrics = record.get("metrics") + if not isinstance(metrics, Mapping): + continue + for metric_name, series in metrics.items(): + if isinstance(series, list): + _add_server_series( + builder, + str(metric_name), + series, + timestamp=timestamp, + sample_index=sample_index, + endpoint=endpoint, + unit=metric_units.get(str(metric_name), ""), + ) + + +def _add_server_series( + builder: MetricPointBuilder, + metric_name: str, + series: list[Any], + *, + timestamp: datetime, + sample_index: int, + endpoint: str, + unit: str, +) -> None: + for item in series: + if not isinstance(item, Mapping): + continue + labels = item.get("labels") if isinstance(item.get("labels"), Mapping) else {} + builder.add( + metric_name, + item.get("value"), + unit=unit, + scope="server", + recorded_at=timestamp, + sample_index=sample_index, + source=endpoint, + labels=labels, + ) + for statistic in ("sum", "count"): + builder.add( + metric_name, + item.get(statistic), + unit=unit, + statistic=statistic, + scope="server", + recorded_at=timestamp, + sample_index=sample_index, + source=endpoint, + labels=labels, + ) + buckets = item.get("buckets") + if isinstance(buckets, Mapping): + for boundary, value in buckets.items(): + builder.add( + metric_name, + value, + unit="count", + statistic="bucket", + scope="server", + recorded_at=timestamp, + sample_index=sample_index, + source=endpoint, + labels={**labels, "le": boundary}, + ) + + +def add_gpu_telemetry_records( + builder: MetricPointBuilder, + records: list[Any], +) -> None: + """Import raw AIPerf GPU telemetry JSONL records.""" + + for sample_index, record in enumerate(records): + if not isinstance(record, Mapping): + continue + metrics = record.get("telemetry_data") + if not isinstance(metrics, Mapping): + continue + timestamp = _from_ns(record.get("timestamp_ns")) or builder.started_at + device = str(record.get("gpu_uuid") or record.get("gpu_index") or "") + source = str(record.get("dcgm_url") or "") + labels = { + "hostname": record.get("hostname", ""), + "gpu_model_name": record.get("gpu_model_name", ""), + } + for metric_name, value in metrics.items(): + builder.add( + str(metric_name), + value, + scope="gpu", + recorded_at=timestamp, + sample_index=sample_index, + device=device, + source=source, + labels=labels, + ) + + +def _string_labels(labels: Mapping[str, Any] | None) -> dict[str, str]: + if labels is None: + return {} + return { + str(key)[:128]: str(value)[:512] + for key, value in sorted(labels.items(), key=lambda item: str(item[0])) + if value is not None + } + + +def _from_ns(value: Any) -> datetime | None: + if not isinstance(value, int) or value < 0: + return None + return datetime.fromtimestamp(value / 1_000_000_000, tz=timezone.utc) + + +def _utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/src/aiperf/history/profile_parser.py b/src/aiperf/history/profile_parser.py new file mode 100644 index 0000000000..449e4eb9a7 --- /dev/null +++ b/src/aiperf/history/profile_parser.py @@ -0,0 +1,308 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Parser for standard AIPerf profile exports and their time-series companions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from aiperf.history.artifact_io import ( + PROFILE_FILE_NAMES, + artifact_digest, + load_json, + load_jsonl, + read_run_files, +) +from aiperf.history.models import HistoryRunRecord, ParsedHistoryRun +from aiperf.history.parsing import ( + as_mapping, + mapping_list, + parse_datetime, + server_metric_units, +) +from aiperf.history.points import ( + MetricPointBuilder, + add_gpu_telemetry_records, + add_server_metric_records, +) + +_PROFILE_METADATA_KEYS = { + "aiperf_version", + "benchmark_id", + "end_time", + "error_summary", + "input_config", + "run_info", + "schema_version", + "start_time", + "telemetry_data", + "was_cancelled", +} + + +async def parse_profile_artifact(main_path: Path) -> ParsedHistoryRun: + """Parse one aggregate profile export and its optional time-series files.""" + + directory = main_path.parent + files = await read_run_files(directory, PROFILE_FILE_NAMES) + profile = as_mapping(load_json(files, "profile_export_aiperf.json", required=True)) + digest = artifact_digest(files) + run_info = as_mapping(profile.get("run_info")) + benchmark_id = str( + profile.get("benchmark_id") or run_info.get("benchmark_id") or digest + ) + epoch = datetime.fromtimestamp(0, tz=timezone.utc) + started_at = parse_datetime(profile.get("start_time"), fallback=epoch) + builder = MetricPointBuilder(benchmark_id, started_at) + + _add_profile_metrics(builder, profile) + _add_timeslices( + builder, + load_json(files, "profile_export_aiperf_timeslices.json"), + ) + telemetry = as_mapping(profile.get("telemetry_data")) + _add_gpu_summary(builder, telemetry) + server_export = load_json(files, "server_metrics_export.json") + _add_server_summary(builder, server_export) + add_server_metric_records( + builder, + load_jsonl(files, "server_metrics_export.jsonl"), + units=server_metric_units(server_export), + ) + add_gpu_telemetry_records( + builder, + load_jsonl(files, "gpu_telemetry_export.jsonl"), + ) + + run = _build_profile_run( + directory=directory, + digest=digest, + profile=profile, + started_at=started_at, + metric_count=len(builder.points), + files=files, + ) + return ParsedHistoryRun(run=run, points=builder.points) + + +def _build_profile_run( + *, + directory: Path, + digest: str, + profile: Mapping[str, Any], + started_at: datetime, + metric_count: int, + files: Mapping[str, str], +) -> HistoryRunRecord: + config = dict(as_mapping(profile.get("input_config"))) + endpoint = as_mapping(config.get("endpoint")) + run_info = as_mapping(profile.get("run_info")) + benchmark_id = str( + profile.get("benchmark_id") or run_info.get("benchmark_id") or digest + ) + model = _model_name(endpoint) or _configured_model_name(config) + mode = str(endpoint.get("type") or config.get("mode") or "request") + implementation = str( + endpoint.get("implementation") + or as_mapping(config.get("metadata")).get("implementation") + or _legacy_profile_implementation(directory, model) + or "unknown" + ) + return HistoryRunRecord( + run_id=benchmark_id, + benchmark_id=benchmark_id, + run_kind="profile", + started_at=started_at, + ended_at=_optional_datetime(profile.get("end_time")), + ingested_at=datetime.now(tz=timezone.utc), + status="cancelled" if profile.get("was_cancelled") is True else "completed", + implementation=implementation, + model=model, + model_family=str( + as_mapping(config.get("metadata")).get("model_family") or model + ), + mode=mode, + scene=str(as_mapping(config.get("metadata")).get("scene") or mode), + task=mode, + transport="http", + hardware=_profile_hardware(as_mapping(profile.get("telemetry_data"))), + aiperf_version=str(profile.get("aiperf_version") or ""), + aiperf_commit=str(run_info.get("aiperf_commit") or ""), + contract_digest="", + artifact_path=str(directory), + artifact_digest=digest, + metric_count=metric_count, + session_count=0, + tags={ + "sweep_id": str(run_info.get("sweep_id") or ""), + "variation_label": str(run_info.get("variation_label") or ""), + "run_label": str(run_info.get("run_label") or ""), + }, + config=config, + metadata={ + "run_info": dict(run_info), + "errors": profile.get("error_summary", []), + "artifact_integrity": { + "present_files": sorted(files), + "missing_optional_files": sorted(set(PROFILE_FILE_NAMES) - set(files)), + }, + }, + ) + + +def _add_profile_metrics( + builder: MetricPointBuilder, + profile: Mapping[str, Any], +) -> None: + for metric_name, value in profile.items(): + if metric_name in _PROFILE_METADATA_KEYS: + continue + statistics = as_mapping(value) + if not statistics or "unit" not in statistics: + continue + unit = str(statistics.get("unit") or "") + for statistic, scalar in statistics.items(): + if statistic == "unit": + continue + builder.add( + str(metric_name), + scalar, + unit=unit, + statistic=str(statistic), + scope="run", + source="profile_export_aiperf.json", + ) + + +def _add_timeslices(builder: MetricPointBuilder, payload: Any) -> None: + for timeslice in mapping_list(as_mapping(payload).get("timeslices")): + index = int(timeslice.get("timeslice_index") or 0) + recorded_at = builder.started_at + timedelta(seconds=index) + for metric_name, value in timeslice.items(): + if metric_name == "timeslice_index": + continue + statistics = as_mapping(value) + unit = str(statistics.get("unit") or "") + for statistic, scalar in statistics.items(): + if statistic == "unit": + continue + builder.add( + str(metric_name), + scalar, + unit=unit, + statistic=str(statistic), + scope="timeslice", + recorded_at=recorded_at, + sample_index=index, + source="profile_export_aiperf_timeslices.json", + ) + + +def _add_gpu_summary( + builder: MetricPointBuilder, + telemetry: Mapping[str, Any], +) -> None: + endpoints = as_mapping(telemetry.get("endpoints")) + for endpoint_name, endpoint in endpoints.items(): + gpus = as_mapping(as_mapping(endpoint).get("gpus")) + for gpu_name, gpu in gpus.items(): + device = str(as_mapping(gpu).get("gpu_uuid") or gpu_name) + metrics = as_mapping(as_mapping(gpu).get("metrics")) + for metric_name, value in metrics.items(): + statistics = as_mapping(value) + unit = str(statistics.get("unit") or "") + for statistic, scalar in statistics.items(): + if statistic == "unit": + continue + builder.add( + str(metric_name), + scalar, + unit=unit, + statistic=str(statistic), + scope="gpu_summary", + device=device, + source=str(endpoint_name), + ) + + +def _add_server_summary(builder: MetricPointBuilder, payload: Any) -> None: + metrics = as_mapping(as_mapping(payload).get("metrics")) + for metric_name, metric in metrics.items(): + unit = str(as_mapping(metric).get("unit") or "") + for series in mapping_list(as_mapping(metric).get("series")): + source = str(series.get("endpoint_url") or "") + labels = as_mapping(series.get("labels")) + for statistic, value in as_mapping(series.get("stats")).items(): + builder.add( + str(metric_name), + value, + unit=unit, + statistic=str(statistic), + scope="server_summary", + source=source, + labels=labels, + ) + for boundary, value in as_mapping(series.get("buckets")).items(): + builder.add( + str(metric_name), + value, + unit="count", + statistic="bucket", + scope="server_summary", + source=source, + labels={**labels, "le": boundary}, + ) + + +def _model_name(endpoint: Mapping[str, Any]) -> str: + models = endpoint.get("model_names") + if isinstance(models, list): + return ", ".join(str(model) for model in models) + return str(endpoint.get("model") or "") + + +def _configured_model_name(config: Mapping[str, Any]) -> str: + """Read the first resolved model from legacy profile configuration.""" + + items = mapping_list(as_mapping(config.get("models")).get("items")) + names = [str(item.get("name")) for item in items if item.get("name")] + return ", ".join(names) + + +def _legacy_profile_implementation(directory: Path, model: str) -> str: + """Infer implementation only for profiles that predate explicit metadata.""" + + identity = f"{'/'.join(part.lower() for part in directory.parts)} {model.lower()}" + if "telefuser" in identity: + return "telefuser" + if "sglang" in identity: + return "sglang" + if "diffusers" in identity: + return "diffusers" + return "" + + +def _profile_hardware(telemetry: Mapping[str, Any]) -> str: + names: list[str] = [] + for endpoint in as_mapping(telemetry.get("endpoints")).values(): + for gpu in as_mapping(as_mapping(endpoint).get("gpus")).values(): + name = as_mapping(gpu).get("gpu_name") or as_mapping(gpu).get( + "gpu_model_name" + ) + if name: + names.append(str(name)) + if not names: + return "" + if len(set(names)) == 1 and len(names) > 1: + return f"{len(names)}x {names[0]}"[:512] + return ", ".join(names)[:512] + + +def _optional_datetime(value: Any) -> datetime | None: + if value is None or value == "": + return None + return parse_datetime(value) diff --git a/src/aiperf/history/repository.py b/src/aiperf/history/repository.py new file mode 100644 index 0000000000..cc62376274 --- /dev/null +++ b/src/aiperf/history/repository.py @@ -0,0 +1,383 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""GreptimeDB writes and bounded history queries.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any + +from aiperf.history.config import GreptimeConfig +from aiperf.history.greptime import GreptimeClient +from aiperf.history.models import ( + HistoryMetricSample, + HistoryRunRecord, + ParsedHistoryRun, +) +from aiperf.history.repository_sql import ( + FACET_COLUMNS, + METRIC_COLUMNS, + RUN_COLUMNS, + batched, + insert_sql, + metric_row, + metric_sample_from_row, + metric_where, + metrics_ddl, + normalize_metric_row, + run_from_row, + run_row, + run_where, + runs_ddl, + sql_literal, +) +from aiperf.history.resource_ingest import ( + RESOURCE_LIVE_DIGEST_PREFIX, + RESOURCE_SCOPE, + resource_batch_to_history, +) +from aiperf.resource_telemetry.models import ResourceTelemetryBatch + + +class HistoryRepository: + """Store and query history data exclusively through GreptimeDB.""" + + def __init__(self, client: GreptimeClient, config: GreptimeConfig) -> None: + self.client = client + self.config = config + self._write_lock = asyncio.Lock() + + async def initialize(self) -> None: + """Create the GreptimeDB tables when they do not exist.""" + + await self.client.execute(runs_ddl(self.config.runs_table)) + await self.client.execute(metrics_ddl(self.config.metrics_table)) + + async def ping(self) -> None: + """Fail unless the configured GreptimeDB database is queryable.""" + + await self.client.execute("SELECT 1 AS ready") + + async def artifact_digest(self, run_id: str) -> str | None: + """Return the currently indexed artifact digest for a run.""" + + rows = await self.client.execute( + f"SELECT artifact_digest FROM {self.config.runs_table} " + f"WHERE run_id = {sql_literal(run_id)} " + "ORDER BY ingested_at DESC LIMIT 1" + ) + if not rows: + return None + value = rows[0].get("artifact_digest") + return str(value) if value is not None else None + + async def replace_run(self, parsed: ParsedHistoryRun) -> None: + """Replace artifact points while retaining active resource observations.""" + + async with self._write_lock: + existing = await self._get_run(parsed.run.run_id) + resource_points = await self._resource_samples(parsed.run.run_id) + merged_points = _merge_points(parsed.points, resource_points) + run = _preserve_resource_metadata(parsed.run, existing).model_copy( + update={"metric_count": len(merged_points)} + ) + merged = ParsedHistoryRun(run=run, points=merged_points) + run_id = sql_literal(run.run_id) + await self.client.execute( + f"DELETE FROM {self.config.metrics_table} WHERE run_id = {run_id}" + ) + await self._insert_points(merged) + await self._insert_run(run) + + async def ingest_resource_batch(self, batch: ResourceTelemetryBatch) -> int: + """Idempotently ingest one active resource batch into GreptimeDB.""" + + parsed = resource_batch_to_history(batch) + async with self._write_lock: + existing = await self._get_run(parsed.run.run_id) + run = _select_resource_run(parsed.run, existing) + current_points = parsed.points + if batch.final: + retained = await self._resource_samples(run.run_id) + current_points = _merge_points(retained, current_points) + run_id = sql_literal(run.run_id) + await self.client.execute( + f"DELETE FROM {self.config.metrics_table} " + f"WHERE run_id = {run_id} " + f"AND metric_scope = {sql_literal(RESOURCE_SCOPE)}" + ) + if current_points: + await self._insert_points( + ParsedHistoryRun(run=run, points=current_points) + ) + metric_count = await self._metric_count(run.run_id) + run = run.model_copy(update={"metric_count": metric_count}) + await self._insert_run(run) + return len(parsed.points) + + async def _insert_points(self, parsed: ParsedHistoryRun) -> None: + metric_rows = [metric_row(parsed, index) for index in range(len(parsed.points))] + for batch in batched(metric_rows, 200): + await self.client.execute( + insert_sql(self.config.metrics_table, METRIC_COLUMNS, batch) + ) + + async def _insert_run(self, run: HistoryRunRecord) -> None: + await self.client.execute( + insert_sql( + self.config.runs_table, + RUN_COLUMNS, + [run_row(run)], + ) + ) + + async def _resource_samples(self, run_id: str) -> list[HistoryMetricSample]: + rows = await self.client.execute( + f"SELECT {', '.join(METRIC_COLUMNS)} " + f"FROM {self.config.metrics_table} " + f"WHERE run_id = {sql_literal(run_id)} " + f"AND metric_scope = {sql_literal(RESOURCE_SCOPE)}" + ) + return [metric_sample_from_row(row) for row in rows] + + async def _metric_count(self, run_id: str) -> int: + rows = await self.client.execute( + f"SELECT COUNT(*) AS total FROM {self.config.metrics_table} " + f"WHERE run_id = {sql_literal(run_id)}" + ) + return int(rows[0].get("total", 0)) if rows else 0 + + async def _get_run(self, run_id: str) -> HistoryRunRecord | None: + select = ", ".join(RUN_COLUMNS) + rows = await self.client.execute( + f"SELECT {select} FROM {self.config.runs_table} " + f"WHERE run_id = {sql_literal(run_id)} " + "ORDER BY ingested_at DESC LIMIT 1" + ) + return run_from_row(rows[0]) if rows else None + + async def list_runs( + self, + *, + limit: int, + offset: int, + implementation: str | None = None, + model: str | None = None, + scene: str | None = None, + mode: str | None = None, + status: str | None = None, + run_kind: str | None = None, + search: str | None = None, + started_after: datetime | None = None, + started_before: datetime | None = None, + ) -> tuple[list[HistoryRunRecord], int]: + """Return one filtered page of runs and the matching total count.""" + + where = run_where( + implementation=implementation, + model=model, + scene=scene, + mode=mode, + status=status, + run_kind=run_kind, + search=search, + started_after=started_after, + started_before=started_before, + ) + select = ", ".join(RUN_COLUMNS) + rows_sql = ( + f"SELECT {select} FROM {self.config.runs_table}{where} " + f"ORDER BY started_at DESC LIMIT {limit} OFFSET {offset}" + ) + count_sql = f"SELECT COUNT(*) AS total FROM {self.config.runs_table}{where}" + rows, count_rows = await asyncio.gather( + self.client.execute(rows_sql), + self.client.execute(count_sql), + ) + total = int(count_rows[0].get("total", 0)) if count_rows else 0 + return [run_from_row(row) for row in rows], total + + async def get_run(self, run_id: str) -> HistoryRunRecord | None: + """Return one indexed run by identifier.""" + + return await self._get_run(run_id) + + async def metric_catalog( + self, + *, + run_id: str | None = None, + implementation: str | None = None, + scene: str | None = None, + ) -> list[dict[str, Any]]: + """Return available metric/statistic/scope combinations.""" + + where = metric_where( + run_id=run_id, + implementation=implementation, + scene=scene, + ) + rows = await self.client.execute( + "SELECT metric_name, statistic, metric_scope, unit, " + "COUNT(*) AS point_count " + f"FROM {self.config.metrics_table}{where} " + "GROUP BY metric_name, statistic, metric_scope, unit " + "ORDER BY metric_name, metric_scope, statistic" + ) + return [normalize_metric_row(row) for row in rows] + + async def metric_series( + self, + *, + metric_name: str, + statistic: str, + scope: str, + limit: int, + run_id: str | None = None, + implementation: str | None = None, + model: str | None = None, + scene: str | None = None, + mode: str | None = None, + status: str | None = None, + run_kind: str | None = None, + search: str | None = None, + phase: str | None = None, + started_after: datetime | None = None, + started_before: datetime | None = None, + ) -> list[dict[str, Any]]: + """Return one bounded metric series for history or within-run charts.""" + + where = metric_where( + metric_name=metric_name, + statistic=statistic, + scope=scope, + run_id=run_id, + implementation=implementation, + model=model, + scene=scene, + mode=mode, + status=status, + run_kind=run_kind, + search=search, + phase=phase, + started_after=started_after, + started_before=started_before, + ) + columns = ", ".join(METRIC_COLUMNS[1:]) + rows = await self.client.execute( + f"SELECT {columns} FROM {self.config.metrics_table}{where} " + f"ORDER BY recorded_at, run_id, sample_index LIMIT {limit}" + ) + return [normalize_metric_row(row) for row in rows] + + async def run_metric_values( + self, + run_id: str, + *, + scope: str | None, + phase: str | None, + limit: int, + ) -> list[dict[str, Any]]: + """Return bounded metric values for one run-detail view.""" + + where = metric_where(run_id=run_id, scope=scope, phase=phase) + columns = ", ".join(METRIC_COLUMNS[1:]) + rows = await self.client.execute( + f"SELECT {columns} FROM {self.config.metrics_table}{where} " + f"ORDER BY metric_name, statistic, sample_index LIMIT {limit}" + ) + return [normalize_metric_row(row) for row in rows] + + async def facets(self) -> dict[str, list[str]]: + """Return distinct values for all supported run-list filters.""" + + async def values(key: str, column: str) -> tuple[str, list[str]]: + rows = await self.client.execute( + f"SELECT DISTINCT {column} AS facet_value FROM {self.config.runs_table} " + f"WHERE {column} != '' ORDER BY facet_value LIMIT 500" + ) + return key, [ + str(row["facet_value"]) for row in rows if row.get("facet_value") + ] + + results = await asyncio.gather( + *(values(key, column) for key, column in FACET_COLUMNS) + ) + return dict(results) + + +def _merge_points( + first: list[HistoryMetricSample], + second: list[HistoryMetricSample], +) -> list[HistoryMetricSample]: + points = {point.point_id: point for point in first} + points.update({point.point_id: point for point in second}) + return list(points.values()) + + +def _select_resource_run( + incoming: HistoryRunRecord, + existing: HistoryRunRecord | None, +) -> HistoryRunRecord: + if existing is None: + return incoming + existing_is_artifact = not existing.artifact_digest.startswith( + RESOURCE_LIVE_DIGEST_PREFIX + ) + existing_is_terminal = existing.status != "running" + if existing_is_artifact or existing_is_terminal and incoming.status == "running": + return existing.model_copy(update={"ingested_at": incoming.ingested_at}) + existing_agent = existing.metadata.get("resource_agent", {}) + incoming_agent = incoming.metadata.get("resource_agent", {}) + if not isinstance(existing_agent, dict) or not isinstance(incoming_agent, dict): + return incoming + unavailable = sorted( + { + str(value) + for value in [ + *existing_agent.get("unavailable", []), + *incoming_agent.get("unavailable", []), + ] + } + ) + merged_agent = { + **existing_agent, + **incoming_agent, + "dropped_samples": max( + int(existing_agent.get("dropped_samples", 0)), + int(incoming_agent.get("dropped_samples", 0)), + ), + "unavailable": unavailable, + } + metadata = { + **existing.metadata, + **incoming.metadata, + "resource_agent": merged_agent, + } + hardware = incoming.hardware + if "gpu:" not in hardware and "gpu:" in existing.hardware: + hardware = existing.hardware + return incoming.model_copy(update={"metadata": metadata, "hardware": hardware}) + + +def _preserve_resource_metadata( + incoming: HistoryRunRecord, + existing: HistoryRunRecord | None, +) -> HistoryRunRecord: + if existing is None: + return incoming + existing_agent = existing.metadata.get("resource_agent") + if not isinstance(existing_agent, dict): + return incoming + incoming_agent = incoming.metadata.get("resource_agent") + merged_agent = { + **(incoming_agent if isinstance(incoming_agent, dict) else {}), + **existing_agent, + } + metadata = {**incoming.metadata, "resource_agent": merged_agent} + tags = dict(incoming.tags) + telemetry_source = existing.tags.get("telemetry_source") + if telemetry_source: + tags["telemetry_source"] = telemetry_source + return incoming.model_copy(update={"metadata": metadata, "tags": tags}) diff --git a/src/aiperf/history/repository_sql.py b/src/aiperf/history/repository_sql.py new file mode 100644 index 0000000000..222a3b2438 --- /dev/null +++ b/src/aiperf/history/repository_sql.py @@ -0,0 +1,441 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validated GreptimeDB schema and SQL rendering helpers for history data.""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from datetime import datetime, timezone +from typing import Any + +import orjson + +from aiperf.history.models import ( + HistoryMetricSample, + HistoryRunRecord, + ParsedHistoryRun, +) + +RUN_COLUMNS = ( + "run_id", + "benchmark_id", + "run_kind", + "started_at", + "ended_at", + "ingested_at", + "run_status", + "implementation", + "model", + "model_family", + "benchmark_mode", + "scene", + "benchmark_task", + "transport", + "hardware", + "aiperf_version", + "aiperf_commit", + "contract_digest", + "artifact_path", + "artifact_digest", + "metric_count", + "session_count", + "tags_json", + "config_json", + "metadata_json", +) + +METRIC_COLUMNS = ( + "point_id", + "run_id", + "artifact_digest", + "run_kind", + "run_status", + "implementation", + "model", + "model_family", + "benchmark_mode", + "scene", + "benchmark_task", + "transport", + "metric_name", + "statistic", + "metric_scope", + "recorded_at", + "metric_value", + "unit", + "phase", + "session_id", + "sample_index", + "device", + "source_id", + "observation_state", + "labels_json", +) + +FACET_COLUMNS = ( + ("implementation", "implementation"), + ("model", "model"), + ("model_family", "model_family"), + ("mode", "benchmark_mode"), + ("scene", "scene"), + ("task", "benchmark_task"), + ("transport", "transport"), + ("status", "run_status"), + ("run_kind", "run_kind"), + ("hardware", "hardware"), +) + + +def runs_ddl(table: str) -> str: + """Render the run-table DDL using a prevalidated table name.""" + + return f"""CREATE TABLE IF NOT EXISTS {table} ( + run_id STRING NOT NULL, + benchmark_id STRING NOT NULL, + run_kind STRING NOT NULL, + started_at TIMESTAMP(3) NOT NULL, + ended_at TIMESTAMP(3), + ingested_at TIMESTAMP(3) NOT NULL, + run_status STRING NOT NULL, + implementation STRING NOT NULL, + model STRING, + model_family STRING, + benchmark_mode STRING, + scene STRING, + benchmark_task STRING, + transport STRING, + hardware STRING, + aiperf_version STRING, + aiperf_commit STRING, + contract_digest STRING, + artifact_path STRING NOT NULL, + artifact_digest STRING NOT NULL, + metric_count BIGINT, + session_count BIGINT, + tags_json STRING, + config_json STRING, + metadata_json STRING, + TIME INDEX(started_at), + PRIMARY KEY(run_id) + ) ENGINE=mito""" + + +def metrics_ddl(table: str) -> str: + """Render the metric-table DDL using a prevalidated table name.""" + + return f"""CREATE TABLE IF NOT EXISTS {table} ( + point_id STRING NOT NULL, + run_id STRING NOT NULL, + artifact_digest STRING NOT NULL, + run_kind STRING NOT NULL, + run_status STRING NOT NULL, + implementation STRING NOT NULL, + model STRING, + model_family STRING, + benchmark_mode STRING, + scene STRING, + benchmark_task STRING, + transport STRING, + metric_name STRING NOT NULL, + statistic STRING NOT NULL, + metric_scope STRING NOT NULL, + recorded_at TIMESTAMP(3) NOT NULL, + metric_value DOUBLE NOT NULL, + unit STRING, + phase STRING, + session_id STRING, + sample_index BIGINT, + device STRING, + source_id STRING, + observation_state STRING, + labels_json STRING, + TIME INDEX(recorded_at), + PRIMARY KEY(run_id, point_id) + ) ENGINE=mito""" + + +def run_row(run: HistoryRunRecord) -> tuple[Any, ...]: + """Project one typed run into the stable GreptimeDB column order.""" + + return ( + run.run_id, + run.benchmark_id, + run.run_kind, + run.started_at, + run.ended_at, + run.ingested_at, + run.status, + run.implementation, + run.model, + run.model_family, + run.mode, + run.scene, + run.task, + run.transport, + run.hardware, + run.aiperf_version, + run.aiperf_commit, + run.contract_digest, + run.artifact_path, + run.artifact_digest, + run.metric_count, + run.session_count, + json_text(run.tags), + json_text(run.config), + json_text(run.metadata), + ) + + +def metric_row(parsed: ParsedHistoryRun, index: int) -> tuple[Any, ...]: + """Project one metric sample into the stable GreptimeDB column order.""" + + run = parsed.run + sample = parsed.points[index] + return ( + sample.point_id, + sample.run_id, + run.artifact_digest, + run.run_kind, + run.status, + run.implementation, + run.model, + run.model_family, + run.mode, + run.scene, + run.task, + run.transport, + sample.metric_name, + sample.statistic, + sample.scope, + sample.recorded_at, + sample.value, + sample.unit, + sample.phase, + sample.session_id, + sample.sample_index, + sample.device, + sample.source, + sample.state, + json_text(sample.labels), + ) + + +def insert_sql( + table: str, + columns: tuple[str, ...], + rows: list[tuple[Any, ...]], +) -> str: + """Render a bounded multi-row insert with escaped scalar literals.""" + + if not rows: + raise ValueError("at least one row is required") + rendered_rows = ", ".join( + "(" + ", ".join(sql_literal(value) for value in row) + ")" for row in rows + ) + return f"INSERT INTO {table} ({', '.join(columns)}) VALUES {rendered_rows}" + + +def sql_literal(value: Any) -> str: + """Render a finite scalar as a GreptimeDB SQL literal.""" + + if value is None: + return "NULL" + if isinstance(value, bool): + return "TRUE" if value else "FALSE" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("non-finite values cannot be stored in GreptimeDB") + return repr(value) + if isinstance(value, datetime): + normalized = ( + value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + ) + rendered = normalized.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[ + :-3 + ] + return f"'{rendered}'" + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + + +def json_text(value: Any) -> str: + """Serialize retained metadata with deterministic key ordering.""" + + return orjson.dumps(value, option=orjson.OPT_SORT_KEYS).decode() + + +def json_object(value: Any) -> dict[str, Any]: + """Decode a retained JSON object and tolerate corrupt optional metadata.""" + + if not isinstance(value, str) or not value: + return {} + try: + decoded = orjson.loads(value) + except orjson.JSONDecodeError: + return {} + return decoded if isinstance(decoded, dict) else {} + + +def run_from_row(row: dict[str, Any]) -> HistoryRunRecord: + """Restore a typed run from one GreptimeDB record row.""" + + payload = dict(row) + payload["status"] = payload.pop("run_status", "unknown") + payload["mode"] = payload.pop("benchmark_mode", "") + payload["task"] = payload.pop("benchmark_task", "") + payload["tags"] = json_object(payload.pop("tags_json", "")) + payload["config"] = json_object(payload.pop("config_json", "")) + payload["metadata"] = json_object(payload.pop("metadata_json", "")) + return HistoryRunRecord.model_validate(payload) + + +def normalize_metric_row(row: dict[str, Any]) -> dict[str, Any]: + """Map storage-safe column names back to public API dimensions.""" + + payload = dict(row) + if "labels_json" in payload: + payload["labels"] = json_object(payload.pop("labels_json", "")) + for public, stored in ( + ("value", "metric_value"), + ("scope", "metric_scope"), + ("source", "source_id"), + ("state", "observation_state"), + ("status", "run_status"), + ("mode", "benchmark_mode"), + ("task", "benchmark_task"), + ): + if stored in payload: + payload[public] = payload.pop(stored) + return payload + + +def metric_sample_from_row(row: dict[str, Any]) -> HistoryMetricSample: + """Restore the sample fields from one denormalized GreptimeDB metric row.""" + + payload = normalize_metric_row(row) + sample_payload = { + field_name: payload[field_name] + for field_name in HistoryMetricSample.model_fields + if field_name in payload + } + return HistoryMetricSample.model_validate(sample_payload) + + +def run_where( + *, + implementation: str | None = None, + model: str | None = None, + scene: str | None = None, + mode: str | None = None, + status: str | None = None, + run_kind: str | None = None, + search: str | None = None, + started_after: datetime | None = None, + started_before: datetime | None = None, +) -> str: + """Render the bounded run-list predicate set.""" + + filters = equality_filters( + implementation=implementation, + model=model, + scene=scene, + benchmark_mode=mode, + run_status=status, + run_kind=run_kind, + ) + if search: + pattern = sql_literal(f"%{search.lower()}%") + columns = ( + "run_id", + "implementation", + "model", + "scene", + "benchmark_task", + ) + filters.append( + "(" + + " OR ".join(f"LOWER({column}) LIKE {pattern}" for column in columns) + + ")" + ) + if started_after is not None: + filters.append(f"started_at >= {sql_literal(started_after)}") + if started_before is not None: + filters.append(f"started_at <= {sql_literal(started_before)}") + return f" WHERE {' AND '.join(filters)}" if filters else "" + + +def metric_where( + *, + metric_name: str | None = None, + statistic: str | None = None, + scope: str | None = None, + run_id: str | None = None, + implementation: str | None = None, + model: str | None = None, + scene: str | None = None, + mode: str | None = None, + status: str | None = None, + run_kind: str | None = None, + search: str | None = None, + phase: str | None = None, + started_after: datetime | None = None, + started_before: datetime | None = None, +) -> str: + """Render the bounded metric-catalog or series predicate set.""" + + filters = equality_filters( + metric_name=metric_name, + statistic=statistic, + metric_scope=scope, + run_id=run_id, + implementation=implementation, + model=model, + scene=scene, + benchmark_mode=mode, + run_status=status, + run_kind=run_kind, + phase=phase, + ) + if search: + pattern = sql_literal(f"%{search.lower()}%") + columns = ( + "run_id", + "implementation", + "model", + "scene", + "benchmark_task", + ) + filters.append( + "(" + + " OR ".join(f"LOWER({column}) LIKE {pattern}" for column in columns) + + ")" + ) + if started_after is not None: + filters.append(f"recorded_at >= {sql_literal(started_after)}") + if started_before is not None: + filters.append(f"recorded_at <= {sql_literal(started_before)}") + return f" WHERE {' AND '.join(filters)}" if filters else "" + + +def equality_filters(**values: str | None) -> list[str]: + """Render exact-match predicates for non-null public filters.""" + + return [ + f"{column} = {sql_literal(value)}" + for column, value in values.items() + if value is not None + ] + + +def batched( + rows: list[tuple[Any, ...]], + size: int, +) -> Iterable[list[tuple[Any, ...]]]: + """Yield bounded SQL insert batches.""" + + for index in range(0, len(rows), size): + yield rows[index : index + size] diff --git a/src/aiperf/history/resource_ingest.py b/src/aiperf/history/resource_ingest.py new file mode 100644 index 0000000000..bd47cc43d1 --- /dev/null +++ b/src/aiperf/history/resource_ingest.py @@ -0,0 +1,376 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Normalize active resource batches into canonical history records.""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timedelta, timezone + +from aiperf.history.models import ( + HistoryMetricSample, + HistoryRunRecord, + ParsedHistoryRun, +) +from aiperf.resource_telemetry.models import ( + ResourceContainerSample, + ResourceGPUSample, + ResourceNetworkSample, + ResourceSample, + ResourceTelemetryBatch, +) + +RESOURCE_SCOPE = "resource" +RESOURCE_SOURCE = "aiperf_resource_agent_v2" +RESOURCE_LIVE_DIGEST_PREFIX = "resource-live:" + + +def resource_batch_to_history(batch: ResourceTelemetryBatch) -> ParsedHistoryRun: + """Convert one validated wire batch into a provisional run and finite points.""" + + points: list[HistoryMetricSample] = [] + for sample in batch.samples: + points.extend(_system_points(batch, sample)) + for gpu in sample.gpus: + points.extend(_gpu_points(batch, sample, gpu)) + for network in sample.networks: + points.extend(_network_points(batch, sample, network)) + run = _run_record(batch, metric_count=len(points)) + return ParsedHistoryRun(run=run, points=points) + + +def _run_record( + batch: ResourceTelemetryBatch, + *, + metric_count: int, +) -> HistoryRunRecord: + descriptor = batch.run + ended_at = None + if batch.final: + ended_ns = max( + [batch.sent_at_ns, *(sample.sampled_at_ns for sample in batch.samples)] + ) + ended_at = _datetime_from_ns(ended_ns) + gpu_labels = { + f"{gpu.device} {gpu.name}".strip() + for sample in batch.samples + for gpu in sample.gpus + } + hardware_parts = [descriptor.hostname, *sorted(gpu_labels)] + unavailable = sorted( + {item for sample in batch.samples for item in sample.unavailable} + ) + metadata = dict(descriptor.metadata) + metadata["resource_agent"] = { + "schema_version": batch.schema_version, + "root_pid": descriptor.root_pid, + "hostname": descriptor.hostname, + "last_batch_id": batch.batch_id, + "last_sequence": batch.sequence, + "final": batch.final, + "dropped_samples": batch.dropped_samples, + "unavailable": unavailable, + } + container = next( + (sample.container for sample in reversed(batch.samples) if sample.container), + None, + ) + if container is not None: + metadata["resource_agent"]["container"] = { + "cgroup_version": container.cgroup_version, + "cgroup_path": container.cgroup_path, + "gpu_visibility_source": container.gpu_visibility_source, + "visible_gpu_devices": container.visible_gpu_devices, + } + tags = dict(descriptor.tags) + tags["telemetry_source"] = RESOURCE_SOURCE + return HistoryRunRecord( + run_id=descriptor.benchmark_id, + benchmark_id=descriptor.benchmark_id, + run_kind=descriptor.run_kind, + started_at=_datetime_from_ns(descriptor.started_at_ns), + ended_at=ended_at, + ingested_at=datetime.now(tz=timezone.utc), + status=batch.status, + implementation=descriptor.implementation, + model=descriptor.model, + model_family=descriptor.model_family, + mode=descriptor.mode, + scene=descriptor.scene or descriptor.model_family, + task=descriptor.task, + transport=descriptor.transport, + hardware="; ".join(part for part in hardware_parts if part), + aiperf_version=descriptor.aiperf_version, + aiperf_commit=descriptor.aiperf_commit, + contract_digest=descriptor.contract_digest, + artifact_path=( + descriptor.artifact_path + or f"resource-agent://{descriptor.hostname}/{descriptor.root_pid}" + ), + artifact_digest=(f"{RESOURCE_LIVE_DIGEST_PREFIX}{descriptor.benchmark_id}"), + metric_count=metric_count, + session_count=0, + tags=tags, + config=descriptor.config, + metadata=metadata, + ) + + +def _system_points( + batch: ResourceTelemetryBatch, + sample: ResourceSample, +) -> list[HistoryMetricSample]: + values: list[tuple[str, int | float | None, str, str]] = [ + ("resource.cpu", sample.process_cpu_cores, "cores", "process_used"), + ("resource.cpu", sample.machine_cpu_used_cores, "cores", "machine_used"), + ( + "resource.cpu", + sample.machine_cpu_total_cores, + "cores", + "machine_total", + ), + ( + "resource.memory", + sample.process_memory_used_bytes, + "bytes", + "process_used", + ), + ( + "resource.memory", + sample.machine_memory_used_bytes, + "bytes", + "machine_used", + ), + ( + "resource.memory", + sample.machine_memory_total_bytes, + "bytes", + "machine_total", + ), + ] + container = sample.container + labels: dict[str, str] = {} + if container is not None: + labels = _container_labels(container) + values.extend( + ( + ("resource.cpu", container.cpu_used_cores, "cores", "container_used"), + ("resource.cpu", container.cpu_limit_cores, "cores", "container_total"), + ( + "resource.memory", + container.memory_used_bytes, + "bytes", + "container_used", + ), + ( + "resource.memory", + container.memory_limit_bytes, + "bytes", + "container_total", + ), + ) + ) + return [ + _point( + batch, + sample, + metric_name=metric_name, + value=value, + unit=unit, + subject=subject, + extra_labels=( + labels if subject in {"container_used", "container_total"} else None + ), + ) + for metric_name, value, unit, subject in values + if value is not None + ] + + +def _gpu_points( + batch: ResourceTelemetryBatch, + sample: ResourceSample, + gpu: ResourceGPUSample, +) -> list[HistoryMetricSample]: + values: list[tuple[str, int | float | None, str, str]] = [ + ( + "resource.gpu", + gpu.process_utilization_percent, + "percent", + "process_used", + ), + ( + "resource.gpu", + gpu.device_utilization_percent, + "percent", + "machine_used", + ), + ("resource.gpu", 100.0, "percent", "machine_total"), + ( + "resource.gpu_memory", + gpu.process_memory_used_bytes, + "bytes", + "process_used", + ), + ( + "resource.gpu_memory", + gpu.device_memory_used_bytes, + "bytes", + "machine_used", + ), + ( + "resource.gpu_memory", + gpu.device_memory_total_bytes, + "bytes", + "machine_total", + ), + ] + if sample.container is not None and gpu.container_visible is True: + values.extend( + ( + ( + "resource.gpu", + gpu.container_utilization_percent, + "percent", + "container_used", + ), + ("resource.gpu", 100.0, "percent", "container_total"), + ( + "resource.gpu_memory", + gpu.container_memory_used_bytes, + "bytes", + "container_used", + ), + ( + "resource.gpu_memory", + gpu.device_memory_total_bytes, + "bytes", + "container_total", + ), + ) + ) + labels = {"gpu_uuid": gpu.uuid, "gpu_name": gpu.name} + if sample.container is not None: + labels.update(_container_labels(sample.container)) + return [ + _point( + batch, + sample, + metric_name=metric_name, + value=value, + unit=unit, + subject=subject, + device=gpu.device, + extra_labels=labels, + ) + for metric_name, value, unit, subject in values + if value is not None + ] + + +def _container_labels(container: ResourceContainerSample) -> dict[str, str]: + cgroup_version = str(container.cgroup_version) + cgroup_path = container.cgroup_path + visibility_source = str(container.gpu_visibility_source or "") + return { + "cgroup_version": cgroup_version, + "cgroup_path": cgroup_path, + "gpu_visibility_source": visibility_source, + } + + +def _network_points( + batch: ResourceTelemetryBatch, + sample: ResourceSample, + network: ResourceNetworkSample, +) -> list[HistoryMetricSample]: + points: list[HistoryMetricSample] = [] + labels = { + "network_kind": network.kind, + "network_interfaces": ",".join(network.interfaces), + } + for direction, value in ( + ("receive", network.receive_bytes_per_second), + ("transmit", network.transmit_bytes_per_second), + ): + direction_labels = {**labels, "network_direction": direction} + device = f"network:{network.kind}:{direction}" + points.append( + _point( + batch, + sample, + metric_name="resource.network", + value=value, + unit="bytes/second", + subject="machine_used", + device=device, + extra_labels=direction_labels, + ) + ) + if network.capacity_bytes_per_second is not None: + points.append( + _point( + batch, + sample, + metric_name="resource.network", + value=network.capacity_bytes_per_second, + unit="bytes/second", + subject="machine_total", + device=device, + extra_labels=direction_labels, + ) + ) + return points + + +def _point( + batch: ResourceTelemetryBatch, + sample: ResourceSample, + *, + metric_name: str, + value: int | float, + unit: str, + subject: str, + device: str = "", + extra_labels: dict[str, str] | None = None, +) -> HistoryMetricSample: + point_key = "\0".join( + ( + batch.run.benchmark_id, + str(sample.sampled_at_ns), + str(sample.sample_index), + metric_name, + subject, + device, + ) + ) + labels: dict[str, str] = { + "resource_subject": subject, + "hostname": batch.run.hostname, + "root_pid": str(batch.run.root_pid), + "source_timestamp_ns": str(sample.sampled_at_ns), + } + labels.update({key: value for key, value in (extra_labels or {}).items() if value}) + return HistoryMetricSample( + point_id=hashlib.sha256(point_key.encode()).hexdigest(), + run_id=batch.run.benchmark_id, + metric_name=metric_name, + statistic="value", + scope=RESOURCE_SCOPE, + recorded_at=_datetime_from_ns(sample.sampled_at_ns), + value=float(value), + unit=unit, + phase="profiling", + sample_index=sample.sample_index, + device=device, + source=RESOURCE_SOURCE, + labels=labels, + ) + + +def _datetime_from_ns(value: int) -> datetime: + seconds, nanoseconds = divmod(value, 1_000_000_000) + return datetime.fromtimestamp(seconds, tz=timezone.utc) + timedelta( + microseconds=nanoseconds // 1_000 + ) diff --git a/src/aiperf/history/service.py b/src/aiperf/history/service.py new file mode 100644 index 0000000000..74b2089aae --- /dev/null +++ b/src/aiperf/history/service.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Standalone FastAPI application for benchmark history and the Vue UI.""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager, suppress +from pathlib import Path +from typing import TYPE_CHECKING + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from starlette.middleware.cors import CORSMiddleware +from starlette_compress import CompressMiddleware + +from aiperf import __version__ as aiperf_version +from aiperf.history.api import history_router +from aiperf.history.config import HistoryServiceConfig +from aiperf.history.greptime import GreptimeClient, GreptimeError +from aiperf.history.importer import ArtifactImporter +from aiperf.history.models import ImportResult +from aiperf.history.repository import HistoryRepository + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +LOGGER = logging.getLogger(__name__) +STATIC_DIR = Path(__file__).with_name("static") + + +def create_history_app(config: HistoryServiceConfig) -> FastAPI: + """Create the long-running history API and bundled Vue application.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + client = GreptimeClient(config.greptime) + await client.start() + repository = HistoryRepository(client, config.greptime) + await repository.initialize() + importer = ArtifactImporter(repository) + app.state.history_repository = repository + app.state.history_config = config + app.state.last_import = None + scan_task: asyncio.Task[None] | None = None + try: + if config.artifact_roots: + app.state.last_import = await importer.import_roots( + config.artifact_roots + ) + _log_import(app.state.last_import) + if config.scan_interval_seconds > 0: + scan_task = asyncio.create_task( + _scan_forever(app, importer, config) + ) + yield + finally: + if scan_task is not None: + scan_task.cancel() + with suppress(asyncio.CancelledError): + await scan_task + await client.close() + + app = FastAPI( + title="AIPerf Benchmark History", + description="GreptimeDB-backed benchmark history and metric curves", + version=aiperf_version, + lifespan=lifespan, + ) + app.add_middleware(CompressMiddleware) + if config.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=config.cors_origins, + allow_methods=["GET", "HEAD", "POST", "OPTIONS"], + allow_headers=["*"], + ) + app.include_router(history_router) + _register_operational_routes(app) + _register_error_handler(app) + _mount_frontend(app) + return app + + +async def ingest_history_once( + config: HistoryServiceConfig, + *, + force: bool = False, +) -> ImportResult: + """Initialize GreptimeDB and perform one explicit artifact import.""" + + client = GreptimeClient(config.greptime) + await client.start() + try: + repository = HistoryRepository(client, config.greptime) + await repository.initialize() + return await ArtifactImporter(repository).import_roots( + config.artifact_roots, + force=force, + ) + finally: + await client.close() + + +def run_history_service(config: HistoryServiceConfig) -> None: + """Run the standalone history service until interrupted.""" + + uvicorn.run( + create_history_app(config), + host=config.host, + port=config.port, + log_level="info", + ) + + +async def _scan_forever( + app: FastAPI, + importer: ArtifactImporter, + config: HistoryServiceConfig, +) -> None: + while True: + await asyncio.sleep(float(config.scan_interval_seconds)) + result = await importer.import_roots(config.artifact_roots) + app.state.last_import = result + _log_import(result) + + +def _log_import(result: ImportResult) -> None: + LOGGER.info( + "History import: discovered=%d imported=%d skipped=%d failed=%d points=%d", + result.discovered, + result.imported, + result.skipped, + result.failed, + result.metric_points, + ) + for failure in result.failures: + LOGGER.warning("History import failed for %s: %s", failure.path, failure.error) + + +def _register_operational_routes(app: FastAPI) -> None: + @app.get("/healthz", include_in_schema=False) + async def healthz(request: Request) -> Response: + repository: HistoryRepository = request.app.state.history_repository + try: + await repository.ping() + except GreptimeError: + return Response(status_code=503, content="unhealthy") + return Response(status_code=200, content="ok") + + @app.get("/readyz", include_in_schema=False) + async def readyz(request: Request) -> Response: + repository: HistoryRepository = request.app.state.history_repository + try: + await repository.ping() + except GreptimeError: + return Response(status_code=503, content="not ready") + return Response(status_code=200, content="ready") + + +def _register_error_handler(app: FastAPI) -> None: + @app.exception_handler(GreptimeError) + async def greptime_error_handler( + _: Request, + exc: GreptimeError, + ) -> JSONResponse: + return JSONResponse( + status_code=503, + content={"detail": str(exc), "backend": "greptimedb"}, + ) + + +def _mount_frontend(app: FastAPI) -> None: + index_path = STATIC_DIR / "index.html" + assets_path = STATIC_DIR / "assets" + if not index_path.is_file() or not assets_path.is_dir(): + raise RuntimeError( + "AIPerf history frontend is missing; build web/history-ui before serving" + ) + app.mount("/assets", StaticFiles(directory=assets_path), name="history-assets") + + @app.api_route("/", methods=["GET", "HEAD"], include_in_schema=False) + async def frontend_index() -> FileResponse: + return FileResponse(index_path) + + @app.get("/{frontend_path:path}", include_in_schema=False) + async def frontend_route(frontend_path: str) -> FileResponse: + if frontend_path.startswith("api/") or frontend_path in {"healthz", "readyz"}: + raise HTTPException(status_code=404, detail="Not found") + return FileResponse(index_path) diff --git a/src/aiperf/history/static/assets/charts-z0pGtXSN.js b/src/aiperf/history/static/assets/charts-z0pGtXSN.js new file mode 100644 index 0000000000..41fce10a83 --- /dev/null +++ b/src/aiperf/history/static/assets/charts-z0pGtXSN.js @@ -0,0 +1,40 @@ +/*! SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 *//*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */var rf=function(r,t){return rf=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,i){e.__proto__=i}||function(e,i){for(var n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n])},rf(r,t)};function B(r,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");rf(r,t);function e(){this.constructor=r}r.prototype=t===null?Object.create(t):(e.prototype=t.prototype,new e)}var mh=12,a1="sans-serif",Ar=mh+"px "+a1,o1=20,s1=100,l1="007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N";function u1(r){var t={};if(typeof JSON>"u")return t;for(var e=0;e=0)s=o*e.length;else for(var l=0;l=d1&&(Al=0),Al++}function Sh(){for(var r=[],t=0;t"u"&&typeof self<"u"?et.worker=!0:!et.hasGlobalWindow||"Deno"in window||typeof navigator<"u"&&typeof navigator.userAgent=="string"&&navigator.userAgent.indexOf("Node.js")>-1?(et.node=!0,et.svgSupported=!0):D1(navigator.userAgent,et);function D1(r,t){var e=t.browser,i=r.match(/Firefox\/([\d.]+)/),n=r.match(/MSIE\s([\d.]+)/)||r.match(/Trident\/.+?rv:(([\d.]+))/),a=r.match(/Edge?\/([\d.]+)/),o=/micromessenger/i.test(r);i&&(e.firefox=!0,e.version=i[1]),n&&(e.ie=!0,e.version=n[1]),a&&(e.edge=!0,e.version=a[1],e.newEdge=+a[1].split(".")[0]>18),o&&(e.weChat=!0),t.svgSupported=typeof SVGRect<"u",t.touchEventsSupported="ontouchstart"in window&&!e.ie&&!e.edge,t.pointerEventsSupported="onpointerdown"in window&&(e.edge||e.ie&&+e.version>=11);var s=t.domSupported=typeof document<"u";if(s){var l=document.documentElement.style;t.transform3dSupported=(e.ie&&"transition"in l||e.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),t.transformSupported=t.transform3dSupported||e.ie&&+e.version>=9}}var A1=".",Nr="___EC__COMPONENT__CONTAINER___",qg="___EC__EXTENDED_CLASS___";function He(r){var t={main:"",sub:""};if(r){var e=r.split(A1);t.main=e[0]||"",t.sub=e[1]||""}return t}function I1(r){nr(/^[a-zA-Z0-9_]+([.][a-zA-Z0-9_]+)?$/.test(r),'componentType "'+r+'" illegal')}function L1(r){return!!(r&&r[qg])}function Th(r,t){r.$constructor=r,r.extend=function(e){var i=this,n;return P1(i)?n=(function(a){B(o,a);function o(){return a.apply(this,arguments)||this}return o})(i):(n=function(){(e.$constructor||i).apply(this,arguments)},g1(n,this)),O(n.prototype,e),n[qg]=!0,n.extend=this.extend,n.superCall=O1,n.superApply=k1,n.superClass=i,n}}function P1(r){return J(r)&&/^class\s/.test(Function.prototype.toString.call(r))}function Kg(r,t){r.extend=t.extend}var E1=Math.round(Math.random()*10);function R1(r){var t=["__\0is_clz",E1++].join("_");r.prototype[t]=!0,r.isInstance=function(e){return!!(e&&e[t])}}function O1(r,t){for(var e=[],i=2;i=0||a&<(a,l)<0)){var u=i.getShallow(l,t);u!=null&&(o[r[s][0]]=u)}}return o}}var B1=[["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]],N1=ma(B1),F1=(function(){function r(){}return r.prototype.getAreaStyle=function(t,e){return N1(this,t,e)},r})(),Qg=(function(){function r(t){this.value=t}return r})(),z1=(function(){function r(){this._len=0}return r.prototype.insert=function(t){var e=new Qg(t);return this.insertEntry(e),e},r.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},r.prototype.remove=function(t){var e=t.prev,i=t.next;e?e.next=i:this.head=i,i?i.prev=e:this.tail=e,t.next=t.prev=null,this._len--},r.prototype.len=function(){return this._len},r.prototype.clear=function(){this.head=this.tail=null,this._len=0},r})(),ln=(function(){function r(t){this._list=new z1,this._maxSize=10,this._map={},this._maxSize=t}return r.prototype.put=function(t,e){var i=this._list,n=this._map,a=null;if(n[t]==null){var o=i.len(),s=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var l=i.head;i.remove(l),delete n[l.key],a=l.value,this._lastRemovedEntry=l}s?s.value=e:s=new Qg(e),s.key=t,i.insertEntry(s),n[t]=s}return a},r.prototype.get=function(t){var e=this._map[t],i=this._list;if(e!=null)return e!==i.tail&&(i.remove(e),i.insertEntry(e)),e.value},r.prototype.clear=function(){this._list.clear(),this._map={}},r.prototype.len=function(){return this._list.len()},r})(),of=new ln(50);function H1(r){if(typeof r=="string"){var t=of.get(r);return t&&t.image}else return r}function Jg(r,t,e,i,n){if(r)if(typeof r=="string"){if(t&&t.__zrImageSrc===r||!e)return t;var a=of.get(r),o={hostEl:e,cb:i,cbPayload:n};return a?(t=a.image,!tl(t)&&a.pending.push(o)):(t=ae.loadImage(r,Vv,Vv),t.__zrImageSrc=r,of.put(r,t.__cachedImgObj={image:t,pending:[o]})),t}else return r;else return t}function Vv(){var r=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var t=0;tf||h>v||c>d||p>m)return!1;var g=!(f=t.x&&e<=t.x+t.width&&i>=t.y&&i<=t.y+t.height},r.prototype.contain=function(t,e){return r.contain(this,t,e)},r.prototype.clone=function(){return new r(this.x,this.y,this.width,this.height)},r.prototype.copy=function(t){Wv(this,t)},r.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},r.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},r.prototype.isZero=function(){return this.width===0||this.height===0},r.create=function(t){return new r(t?t.x:0,t?t.y:0,t?t.width:0,t?t.height:0)},r.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},r.applyTransform=function(t,e,i){if(!i){t!==e&&Wv(t,e);return}if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var n=i[0],a=i[3],o=i[4],s=i[5];t.x=e.x*n+o,t.y=e.y*a+s,t.width=e.width*n,t.height=e.height*a,t.width<0&&(t.x+=t.width,t.width=-t.width),t.height<0&&(t.y+=t.height,t.height=-t.height);return}Fr.x=Hr.x=e.x,Fr.y=Vr.y=e.y,zr.x=Vr.x=e.x+e.width,zr.y=Hr.y=e.y+e.height,Fr.transform(i),Vr.transform(i),zr.transform(i),Hr.transform(i),t.x=fi(Fr.x,zr.x,Hr.x,Vr.x),t.y=fi(Fr.y,zr.y,Hr.y,Vr.y);var l=qi(Fr.x,zr.x,Hr.x,Vr.x),u=qi(Fr.y,zr.y,Hr.y,Vr.y);t.width=l-t.x,t.height=u-t.y},r.calculateTransform=function(t,e,i){var n=i.width/e.width,a=i.height/e.height;return t=Ba(t||[]),sf(t,t,Il(El,-e.x,-e.y)),V1(t,t,Il(El,n,a)),sf(t,t,Il(El,i.x,i.y)),t},r})();j.create;var Pl=j.set,Wv=j.copy,K1=j.calculateTransform;j.applyTransform;j.contain;var Q1=new j(0,0,0,0),J1=new j(0,0,0,0),El=[];function Yv(r,t,e,i,n,a,o,s){var l=uf(t-e),u=uf(i-r),f=fi(l,u),h=Uv[n],v=Uv[1-n],c=q1[n];t=u||!ne.bidirectional)&&($n[h]=-u,$n[v]=0,ne.useDir&&ne.calcDirMTV())))}function jg(){var r=0,t=new pt,e=new pt,i={minTv:new pt,maxTv:new pt,useDir:!1,dirMinTv:new pt,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(a,o){i.touchThreshold=0,a&&a.touchThreshold!=null&&(i.touchThreshold=qi(0,a.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,a&&a.direction!=null&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),e.copy(i.minTv),r=a.direction,i.bidirectional=a.bidirectional==null||!!a.bidirectional,i.bidirectional||t.set(Math.cos(r),Math.sin(r))))},calcDirMTV:function(){var a=i.minTv,o=i.dirMinTv,s=a.y*a.y+a.x*a.x,l=Math.sin(r),u=Math.cos(r),f=l*a.y+u*a.x;if(n(f)){n(a.x)&&n(a.y)&&o.set(0,0);return}if(e.x=s*u/f,e.y=s*l/f,n(e.x)&&n(e.y)){o.set(0,0);return}(i.bidirectional||t.dot(e)>0)&&e.len()=Zv)){r=r||Ar;for(var t=[],e=+new Date,i=0;i<=127;i++)t[i]=ae.measureText(String.fromCharCode(i),r).width;var n=+new Date-e;return n>16?Rl=Zv:n>2&&Rl++,t}}var Rl=0,Zv=5;function tm(r,t){return r.asciiWidthMapTried||(r.asciiWidthMap=j1(r.font),r.asciiWidthMapTried=!0),0<=t&&t<=127?r.asciiWidthMap!=null?r.asciiWidthMap[t]:r.asciiCharWidth:r.stWideCharWidth}function Ue(r,t){var e=r.strWidthCache,i=e.get(t);return i==null&&(i=ae.measureText(t,r.font).width,e.put(t,i)),i}function Xv(r,t,e,i){var n=Ue(Ge(t),r),a=el(t),o=un(0,n,e),s=di(0,a,i),l=new j(o,s,n,a);return l}function em(r,t,e,i){var n=((r||"")+"").split(` +`),a=n.length;if(a===1)return Xv(n[0],t,e,i);for(var o=new j(0,0,0,0),s=0;s=0?parseFloat(r)/100*t:parseFloat(r):r}function rm(r,t,e){var i=t.position||"inside",n=t.distance!=null?t.distance:5,a=e.height,o=e.width,s=a/2,l=e.x,u=e.y,f="left",h="top";if(i instanceof Array)l+=fn(i[0],e.width),u+=fn(i[1],e.height),f=null,h=null;else switch(i){case"left":l-=n,u+=s,f="right",h="middle";break;case"right":l+=n+o,u+=s,h="middle";break;case"top":l+=o/2,u-=n,f="center",h="bottom";break;case"bottom":l+=o/2,u+=a+n,f="center";break;case"inside":l+=o/2,u+=s,f="center",h="middle";break;case"insideLeft":l+=n,u+=s,h="middle";break;case"insideRight":l+=o-n,u+=s,f="right",h="middle";break;case"insideTop":l+=o/2,u+=n,f="center";break;case"insideBottom":l+=o/2,u+=a-n,f="center",h="bottom";break;case"insideTopLeft":l+=n,u+=n;break;case"insideTopRight":l+=o-n,u+=n,f="right";break;case"insideBottomLeft":l+=n,u+=a-n,h="bottom";break;case"insideBottomRight":l+=o-n,u+=a-n,f="right",h="bottom";break}return r=r||{},r.x=l,r.y=u,r.align=f,r.verticalAlign=h,r}var Ol=/\{([a-zA-Z0-9_]+)\|([^}]*)\}/g;function tS(r,t,e,i,n,a){if(!e){r.text="",r.isTruncated=!1;return}var o=(t+"").split(` +`);a=im(e,i,n,a);for(var s=!1,l={},u=0,f=o.length;u=s;u++)l-=s;var f=Ue(o,e);return f>l&&(e="",f=0),l=r-f,n.ellipsis=e,n.ellipsisWidth=f,n.contentWidth=l,n.containerWidth=r,n}function nm(r,t,e){var i=e.containerWidth,n=e.contentWidth,a=e.fontMeasureInfo;if(!i){r.textLine="",r.isTruncated=!1;return}var o=Ue(a,t);if(o<=i){r.textLine=t,r.isTruncated=!1;return}for(var s=0;;s++){if(o<=n||s>=e.maxIterations){t+=e.ellipsis;break}var l=s===0?eS(t,n,a):o>0?Math.floor(t.length*n/o):0;t=t.substr(0,l),o=Ue(a,t)}t===""&&(t=e.placeholder),r.textLine=t,r.isTruncated=!0}function eS(r,t,e){for(var i=0,n=0,a=r.length;nm&&c){var _=Math.floor(m/v);d=d||g.length>_,g=g.slice(0,_),y=g.length*v}if(n&&f&&p!=null)for(var S=im(p,u,t.ellipsis,{minChar:t.truncateMinChar,placeholder:t.placeholder}),b={},w=0;wd&&kl(a,o.substring(d,m),t,c),kl(a,p[2],t,c,p[1]),d=Ol.lastIndex}dh){var U=a.lines.length;L>0?(C.tokens=C.tokens.slice(0,L),T(C,A,M),a.lines=a.lines.slice(0,D+1)):a.lines=a.lines.slice(0,D),a.isTruncated=a.isTruncated||a.lines.length0&&d+i.accumWidth>i.width&&(f=t.split(` +`),u=!0),i.accumWidth=d}else{var p=am(t,l,i.width,i.breakAll,i.accumWidth);i.accumWidth=p.accumWidth+c,h=p.linesWidths,f=p.lines}}f||(f=t.split(` +`));for(var m=Ge(l),g=0;g=32&&t<=591||t>=880&&t<=4351||t>=4608&&t<=5119||t>=7680&&t<=8303}var sS=yn(",&?/;] ".split(""),function(r,t){return r[t]=!0,r},{});function lS(r){return oS(r)?!!sS[r]:!0}function am(r,t,e,i,n){for(var a=[],o=[],s="",l="",u=0,f=0,h=Ge(t),v=0;ve:n+f+d>e){f?(s||l)&&(p?(s||(s=l,l="",u=0,f=u),a.push(s),o.push(f-u),l+=c,u+=d,s="",f=u):(l&&(s+=l,l="",u=0),a.push(s),o.push(f),s=c,f=d)):p?(a.push(l),o.push(u),l=c,u=d):(a.push(c),o.push(d));continue}f+=d,p?(l+=c,u+=d):(l&&(s+=l,l="",u=0),s+=c)}return l&&(s+=l),s&&(a.push(s),o.push(f)),a.length===1&&(f+=n),{accumWidth:f,lines:a,linesWidths:o}}function qv(r,t,e,i,n,a){if(r.baseX=e,r.baseY=i,r.outerWidth=r.outerHeight=null,!!t){var o=t.width*2,s=t.height*2;j.set(Kv,un(e,o,n),di(i,s,a),o,s),j.intersect(t,Kv,null,Qv);var l=Qv.outIntersectRect;r.outerWidth=l.width,r.outerHeight=l.height,r.baseX=un(l.x,l.width,n,!0),r.baseY=di(l.y,l.height,a,!0)}}var Kv=new j(0,0,0,0),Qv={outIntersectRect:{},clamp:!0};function Mh(r){return r!=null?r+="":r=""}function uS(r){var t=Mh(r.text),e=r.font,i=Ue(Ge(e),t),n=el(e);return hf(r,i,n,null)}function hf(r,t,e,i){var n=new j(un(r.x||0,t,r.textAlign),di(r.y||0,e,r.textBaseline),t,e),a=i??(om(r)?r.lineWidth:0);return a>0&&(n.x-=a/2,n.y-=a/2,n.width+=a,n.height+=a),n}function om(r){var t=r.stroke;return t!=null&&t!=="none"&&r.lineWidth>0}var Jv=Ba,jv=5e-5;function Gr(r){return r>jv||r<-jv}var Ur=[],Ai=[],Bl=Ve(),Nl=Math.abs,Fa=(function(){function r(){}return r.prototype.getLocalTransform=function(t){return fS(this,t)},r.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},r.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},r.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},r.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},r.prototype.needLocalTransform=function(){return Gr(this.rotation)||Gr(this.x)||Gr(this.y)||Gr(this.scaleX-1)||Gr(this.scaleY-1)||Gr(this.skewX)||Gr(this.skewY)},r.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),i=this.transform;if(!(e||t)){i&&(Jv(i),this.invTransform=null);return}i=i||Ve(),e?this.getLocalTransform(i):Jv(i),t&&(e?ra(i,t,i):xh(i,t)),this.transform=i,this._resolveGlobalScaleRatio(i),this.invTransform=this.invTransform||Ve(),Na(this.invTransform,i)},r.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(e!=null&&e!==1){this.getGlobalScale(Ur);var i=Ur[0]<0?-1:1,n=Ur[1]<0?-1:1,a=((Ur[0]-i)*e+i)/Ur[0]||0,o=((Ur[1]-n)*e+n)/Ur[1]||0;t[0]*=a,t[1]*=a,t[2]*=o,t[3]*=o}},r.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},r.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],i=t[2]*t[2]+t[3]*t[3],n=Math.atan2(t[1],t[0]),a=Math.PI/2+n-Math.atan2(t[3],t[2]);i=Math.sqrt(i)*Math.cos(a),e=Math.sqrt(e),this.skewX=a,this.skewY=0,this.rotation=-n,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=i,this.originX=0,this.originY=0}},r.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||Ve(),ra(Ai,t.invTransform,e),e=Ai);var i=this.originX,n=this.originY;(i||n)&&(Bl[4]=i,Bl[5]=n,ra(Ai,e,Bl),Ai[4]-=i,Ai[5]-=n,e=Ai),this.setLocalTransform(e)}},r.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},r.prototype.transformCoordToLocal=function(t,e){var i=[t,e],n=this.invTransform;return n&&Se(i,i,n),i},r.prototype.transformCoordToGlobal=function(t,e){var i=[t,e],n=this.transform;return n&&Se(i,i,n),i},r.prototype.getLineScale=function(){var t=this.transform;return t&&Nl(t[0]-1)>1e-10&&Nl(t[3]-1)>1e-10?Math.sqrt(Nl(t[0]*t[3]-t[2]*t[1])):1},r.prototype.copyTransform=function(t){us(this,t)},r.getLocalTransform=function(t,e){e=e||[];var i=t.originX||0,n=t.originY||0,a=t.scaleX,o=t.scaleY,s=t.anchorX,l=t.anchorY,u=t.rotation||0,f=t.x,h=t.y,v=t.skewX?Math.tan(t.skewX):0,c=t.skewY?Math.tan(-t.skewY):0;if(i||n||s||l){var d=i+s,p=n+l;e[4]=-d*a-v*p*o,e[5]=-p*o-c*d*a}else e[4]=e[5]=0;return e[0]=a,e[3]=o,e[1]=c*a,e[2]=v*o,u&&Ch(e,e,u),e[4]+=i+f,e[5]+=n+h,e},r.initDefaultProps=(function(){var t=r.prototype;t.scaleX=t.scaleY=t.globalScaleRatio=1,t.x=t.y=t.originX=t.originY=t.skewX=t.skewY=t.rotation=t.anchorX=t.anchorY=0})(),r})(),fS=Fa.getLocalTransform,rl=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function us(r,t){return p1(r,t,rl)}var na={linear:function(r){return r},quadraticIn:function(r){return r*r},quadraticOut:function(r){return r*(2-r)},quadraticInOut:function(r){return(r*=2)<1?.5*r*r:-.5*(--r*(r-2)-1)},cubicIn:function(r){return r*r*r},cubicOut:function(r){return--r*r*r+1},cubicInOut:function(r){return(r*=2)<1?.5*r*r*r:.5*((r-=2)*r*r+2)},quarticIn:function(r){return r*r*r*r},quarticOut:function(r){return 1- --r*r*r*r},quarticInOut:function(r){return(r*=2)<1?.5*r*r*r*r:-.5*((r-=2)*r*r*r-2)},quinticIn:function(r){return r*r*r*r*r},quinticOut:function(r){return--r*r*r*r*r+1},quinticInOut:function(r){return(r*=2)<1?.5*r*r*r*r*r:.5*((r-=2)*r*r*r*r+2)},sinusoidalIn:function(r){return 1-Math.cos(r*Math.PI/2)},sinusoidalOut:function(r){return Math.sin(r*Math.PI/2)},sinusoidalInOut:function(r){return .5*(1-Math.cos(Math.PI*r))},exponentialIn:function(r){return r===0?0:Math.pow(1024,r-1)},exponentialOut:function(r){return r===1?1:1-Math.pow(2,-10*r)},exponentialInOut:function(r){return r===0?0:r===1?1:(r*=2)<1?.5*Math.pow(1024,r-1):.5*(-Math.pow(2,-10*(r-1))+2)},circularIn:function(r){return 1-Math.sqrt(1-r*r)},circularOut:function(r){return Math.sqrt(1- --r*r)},circularInOut:function(r){return(r*=2)<1?-.5*(Math.sqrt(1-r*r)-1):.5*(Math.sqrt(1-(r-=2)*r)+1)},elasticIn:function(r){var t,e=.1,i=.4;return r===0?0:r===1?1:(!e||e<1?(e=1,t=i/4):t=i*Math.asin(1/e)/(2*Math.PI),-(e*Math.pow(2,10*(r-=1))*Math.sin((r-t)*(2*Math.PI)/i)))},elasticOut:function(r){var t,e=.1,i=.4;return r===0?0:r===1?1:(!e||e<1?(e=1,t=i/4):t=i*Math.asin(1/e)/(2*Math.PI),e*Math.pow(2,-10*r)*Math.sin((r-t)*(2*Math.PI)/i)+1)},elasticInOut:function(r){var t,e=.1,i=.4;return r===0?0:r===1?1:(!e||e<1?(e=1,t=i/4):t=i*Math.asin(1/e)/(2*Math.PI),(r*=2)<1?-.5*(e*Math.pow(2,10*(r-=1))*Math.sin((r-t)*(2*Math.PI)/i)):e*Math.pow(2,-10*(r-=1))*Math.sin((r-t)*(2*Math.PI)/i)*.5+1)},backIn:function(r){var t=1.70158;return r*r*((t+1)*r-t)},backOut:function(r){var t=1.70158;return--r*r*((t+1)*r+t)+1},backInOut:function(r){var t=2.5949095;return(r*=2)<1?.5*(r*r*((t+1)*r-t)):.5*((r-=2)*r*((t+1)*r+t)+2)},bounceIn:function(r){return 1-na.bounceOut(1-r)},bounceOut:function(r){return r<1/2.75?7.5625*r*r:r<2/2.75?7.5625*(r-=1.5/2.75)*r+.75:r<2.5/2.75?7.5625*(r-=2.25/2.75)*r+.9375:7.5625*(r-=2.625/2.75)*r+.984375},bounceInOut:function(r){return r<.5?na.bounceIn(r*2)*.5:na.bounceOut(r*2-1)*.5+.5}},Qa=Math.pow,xr=Math.sqrt,fs=1e-8,sm=1e-4,tc=xr(3),Ja=1/3,Fe=_n(),ce=_n(),rn=_n();function _r(r){return r>-fs&&rfs||r<-fs}function Ot(r,t,e,i,n){var a=1-n;return a*a*(a*r+3*n*t)+n*n*(n*i+3*a*e)}function ec(r,t,e,i,n){var a=1-n;return 3*(((t-r)*a+2*(e-t)*n)*a+(i-e)*n*n)}function hs(r,t,e,i,n,a){var o=i+3*(t-e)-r,s=3*(e-t*2+r),l=3*(t-r),u=r-n,f=s*s-3*o*l,h=s*l-9*o*u,v=l*l-3*s*u,c=0;if(_r(f)&&_r(h))if(_r(s))a[0]=0;else{var d=-l/s;d>=0&&d<=1&&(a[c++]=d)}else{var p=h*h-4*f*v;if(_r(p)){var m=h/f,d=-s/o+m,g=-m/2;d>=0&&d<=1&&(a[c++]=d),g>=0&&g<=1&&(a[c++]=g)}else if(p>0){var y=xr(p),_=f*s+1.5*o*(-h+y),S=f*s+1.5*o*(-h-y);_<0?_=-Qa(-_,Ja):_=Qa(_,Ja),S<0?S=-Qa(-S,Ja):S=Qa(S,Ja);var d=(-s-(_+S))/(3*o);d>=0&&d<=1&&(a[c++]=d)}else{var b=(2*f*s-3*o*h)/(2*xr(f*f*f)),w=Math.acos(b)/3,T=xr(f),D=Math.cos(w),d=(-s-2*T*D)/(3*o),g=(-s+T*(D+tc*Math.sin(w)))/(3*o),C=(-s+T*(D-tc*Math.sin(w)))/(3*o);d>=0&&d<=1&&(a[c++]=d),g>=0&&g<=1&&(a[c++]=g),C>=0&&C<=1&&(a[c++]=C)}}return c}function um(r,t,e,i,n){var a=6*e-12*t+6*r,o=9*t+3*i-3*r-9*e,s=3*t-3*r,l=0;if(_r(o)){if(lm(a)){var u=-s/a;u>=0&&u<=1&&(n[l++]=u)}}else{var f=a*a-4*o*s;if(_r(f))n[0]=-a/(2*o);else if(f>0){var h=xr(f),u=(-a+h)/(2*o),v=(-a-h)/(2*o);u>=0&&u<=1&&(n[l++]=u),v>=0&&v<=1&&(n[l++]=v)}}return l}function vs(r,t,e,i,n,a){var o=(t-r)*n+r,s=(e-t)*n+t,l=(i-e)*n+e,u=(s-o)*n+o,f=(l-s)*n+s,h=(f-u)*n+u;a[0]=r,a[1]=o,a[2]=u,a[3]=h,a[4]=h,a[5]=f,a[6]=l,a[7]=i}function hS(r,t,e,i,n,a,o,s,l,u,f){var h,v=.005,c=1/0,d,p,m,g;Fe[0]=l,Fe[1]=u;for(var y=0;y<1;y+=.05)ce[0]=Ot(r,e,n,o,y),ce[1]=Ot(t,i,a,s,y),m=en(Fe,ce),m=0&&m=0&&u<=1&&(n[l++]=u)}}else{var f=o*o-4*a*s;if(_r(f)){var u=-o/(2*a);u>=0&&u<=1&&(n[l++]=u)}else if(f>0){var h=xr(f),u=(-o+h)/(2*a),v=(-o-h)/(2*a);u>=0&&u<=1&&(n[l++]=u),v>=0&&v<=1&&(n[l++]=v)}}return l}function fm(r,t,e){var i=r+e-2*t;return i===0?.5:(r-t)/i}function cs(r,t,e,i,n){var a=(t-r)*i+r,o=(e-t)*i+t,s=(o-a)*i+a;n[0]=r,n[1]=a,n[2]=s,n[3]=s,n[4]=o,n[5]=e}function dS(r,t,e,i,n,a,o,s,l){var u,f=.005,h=1/0;Fe[0]=o,Fe[1]=s;for(var v=0;v<1;v+=.05){ce[0]=qt(r,e,n,v),ce[1]=qt(t,i,a,v);var c=en(Fe,ce);c=0&&c=1?1:hs(0,i,a,1,l,s)&&Ot(0,n,o,1,s[0])}}}var mS=(function(){function r(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||kt,this.ondestroy=t.ondestroy||kt,this.onrestart=t.onrestart||kt,t.easing&&this.setEasing(t.easing)}return r.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),this._paused){this._pausedTime+=e;return}var i=this._life,n=t-this._startTime-this._pausedTime,a=n/i;a<0&&(a=0),a=Math.min(a,1);var o=this.easingFunc,s=o?o(a):a;if(this.onframe(s),a===1)if(this.loop){var l=n%i;this._startTime=t-l,this._pausedTime=0,this.onrestart()}else return!0;return!1},r.prototype.pause=function(){this._paused=!0},r.prototype.resume=function(){this._paused=!1},r.prototype.setEasing=function(t){this.easing=t,this.easingFunc=J(t)?t:na[t]||hm(t)},r})(),ic={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Cr(r){return r=Math.round(r),r<0?0:r>255?255:r}function vf(r){return r<0?0:r>1?1:r}function Fl(r){var t=r;return t.length&&t.charAt(t.length-1)==="%"?Cr(parseFloat(t)/100*255):Cr(parseInt(t,10))}function pi(r){var t=r;return t.length&&t.charAt(t.length-1)==="%"?vf(parseFloat(t)/100):vf(parseFloat(t))}function zl(r,t,e){return e<0?e+=1:e>1&&(e-=1),e*6<1?r+(t-r)*e*6:e*2<1?t:e*3<2?r+(t-r)*(2/3-e)*6:r}function ja(r,t,e){return r+(t-r)*e}function le(r,t,e,i,n){return r[0]=t,r[1]=e,r[2]=i,r[3]=n,r}function cf(r,t){return r[0]=t[0],r[1]=t[1],r[2]=t[2],r[3]=t[3],r}var vm=new ln(20),to=null;function Ii(r,t){to&&cf(to,t),to=vm.put(r,to||t.slice())}function We(r,t){if(r){t=t||[];var e=vm.get(r);if(e)return cf(t,e);r=r+"";var i=r.replace(/ /g,"").toLowerCase();if(i in ic)return cf(t,ic[i]),Ii(r,t),t;var n=i.length;if(i.charAt(0)==="#"){if(n===4||n===5){var a=parseInt(i.slice(1,4),16);if(!(a>=0&&a<=4095)){le(t,0,0,0,1);return}return le(t,(a&3840)>>4|(a&3840)>>8,a&240|(a&240)>>4,a&15|(a&15)<<4,n===5?parseInt(i.slice(4),16)/15:1),Ii(r,t),t}else if(n===7||n===9){var a=parseInt(i.slice(1,7),16);if(!(a>=0&&a<=16777215)){le(t,0,0,0,1);return}return le(t,(a&16711680)>>16,(a&65280)>>8,a&255,n===9?parseInt(i.slice(7),16)/255:1),Ii(r,t),t}return}var o=i.indexOf("("),s=i.indexOf(")");if(o!==-1&&s+1===n){var l=i.substr(0,o),u=i.substr(o+1,s-(o+1)).split(","),f=1;switch(l){case"rgba":if(u.length!==4)return u.length===3?le(t,+u[0],+u[1],+u[2],1):le(t,0,0,0,1);f=pi(u.pop());case"rgb":if(u.length>=3)return le(t,Fl(u[0]),Fl(u[1]),Fl(u[2]),u.length===3?f:pi(u[3])),Ii(r,t),t;le(t,0,0,0,1);return;case"hsla":if(u.length!==4){le(t,0,0,0,1);return}return u[3]=pi(u[3]),df(u,t),Ii(r,t),t;case"hsl":if(u.length!==3){le(t,0,0,0,1);return}return df(u,t),Ii(r,t),t;default:return}}le(t,0,0,0,1)}}function df(r,t){var e=(parseFloat(r[0])%360+360)%360/360,i=pi(r[1]),n=pi(r[2]),a=n<=.5?n*(i+1):n+i-n*i,o=n*2-a;return t=t||[],le(t,Cr(zl(o,a,e+1/3)*255),Cr(zl(o,a,e)*255),Cr(zl(o,a,e-1/3)*255),1),r.length===4&&(t[3]=r[3]),t}function yS(r){if(r){var t=r[0]/255,e=r[1]/255,i=r[2]/255,n=Math.min(t,e,i),a=Math.max(t,e,i),o=a-n,s=(a+n)/2,l,u;if(o===0)l=0,u=0;else{s<.5?u=o/(a+n):u=o/(2-a-n);var f=((a-t)/6+o/2)/o,h=((a-e)/6+o/2)/o,v=((a-i)/6+o/2)/o;t===a?l=v-h:e===a?l=1/3+f-v:i===a&&(l=2/3+h-f),l<0&&(l+=1),l>1&&(l-=1)}var c=[l*360,u,s];return r[3]!=null&&c.push(r[3]),c}}function nc(r,t){var e=We(r);if(e){for(var i=0;i<3;i++)e[i]=e[i]*(1-t)|0,e[i]>255?e[i]=255:e[i]<0&&(e[i]=0);return za(e,e.length===4?"rgba":"rgb")}}function _S(r,t,e){if(!(!(t&&t.length)||!(r>=0&&r<=1))){var i=r*(t.length-1),n=Math.floor(i),a=Math.ceil(i),o=We(t[n]),s=We(t[a]),l=i-n,u=za([Cr(ja(o[0],s[0],l)),Cr(ja(o[1],s[1],l)),Cr(ja(o[2],s[2],l)),vf(ja(o[3],s[3],l))],"rgba");return e?{color:u,leftIndex:n,rightIndex:a,value:i}:u}}function pf(r,t,e,i){var n=We(r);if(r)return n=yS(n),e!=null&&(n[1]=pi(J(e)?e(n[1]):e)),i!=null&&(n[2]=pi(J(i)?i(n[2]):i)),za(df(n),"rgba")}function za(r,t){if(!(!r||!r.length)){var e=r[0]+","+r[1]+","+r[2];return(t==="rgba"||t==="hsva"||t==="hsla")&&(e+=","+r[3]),t+"("+e+")"}}function ds(r,t){var e=We(r);return e?(.299*e[0]+.587*e[1]+.114*e[2])*e[3]/255+(1-e[3])*t:0}var ac=new ln(100);function oc(r){if(V(r)){var t=ac.get(r);return t||(t=nc(r,-.1),ac.put(r,t)),t}else if(Qs(r)){var e=O({},r);return e.colorStops=q(r.colorStops,function(i){return{offset:i.offset,color:nc(i.color,-.1)}}),e}return r}function SS(r){return r.type==="linear"}function bS(r){return r.type==="radial"}(function(){return typeof Buffer<"u"&&typeof Buffer.from=="function"?function(r){return Buffer.from(r).toString("base64")}:typeof btoa=="function"&&typeof unescape=="function"&&typeof encodeURIComponent=="function"?function(r){return btoa(unescape(encodeURIComponent(r)))}:function(r){return null}})();var gf=Array.prototype.slice;function rr(r,t,e){return(t-r)*e+r}function Hl(r,t,e,i){for(var n=t.length,a=0;ai?t:r,a=Math.min(e,i),o=n[a-1]||{color:[0,0,0,0],offset:0},s=a;so;if(s)i.length=o;else for(var l=a;l=1},r.prototype.getAdditiveTrack=function(){return this._additiveTrack},r.prototype.addKeyframe=function(t,e,i){this._needsSort=!0;var n=this.keyframes,a=n.length,o=!1,s=lc,l=e;if(te(e)){var u=CS(e);s=u,(u===1&&!bt(e[0])||u===2&&!bt(e[0][0]))&&(o=!0)}else if(bt(e)&&!ga(e))s=ro;else if(V(e))if(!isNaN(+e))s=ro;else{var f=We(e);f&&(l=f,s=qn)}else if(Qs(e)){var h=O({},l);h.colorStops=q(e.colorStops,function(c){return{offset:c.offset,color:We(c.color)}}),SS(e)?s=mf:bS(e)&&(s=yf),l=h}a===0?this.valType=s:(s!==this.valType||s===lc)&&(o=!0),this.discrete=this.discrete||o;var v={time:t,value:l,rawValue:e,percent:0};return i&&(v.easing=i,v.easingFunc=J(i)?i:na[i]||hm(i)),n.push(v),v},r.prototype.prepare=function(t,e){var i=this.keyframes;this._needsSort&&i.sort(function(p,m){return p.time-m.time});for(var n=this.valType,a=i.length,o=i[a-1],s=this.discrete,l=io(n),u=uc(n),f=0;f=0&&!(o[f].percent<=e);f--);f=v(f,s-2)}else{for(f=h;fe);f++);f=v(f-1,s-2)}d=o[f+1],c=o[f]}if(c&&d){this._lastFr=f,this._lastFrP=e;var m=d.percent-c.percent,g=m===0?1:v((e-c.percent)/m,1);d.easingFunc&&(g=d.easingFunc(g));var y=i?this._additiveValue:u?xn:t[l];if((io(a)||u)&&!y&&(y=this._additiveValue=[]),this.discrete)t[l]=g<1?c.rawValue:d.rawValue;else if(io(a))a===Go?Hl(y,c[n],d[n],g):wS(y,c[n],d[n],g);else if(uc(a)){var _=c[n],S=d[n],b=a===mf;t[l]={type:b?"linear":"radial",x:rr(_.x,S.x,g),y:rr(_.y,S.y,g),colorStops:q(_.colorStops,function(T,D){var C=S.colorStops[D];return{offset:rr(T.offset,C.offset,g),color:Vo(Hl([],T.color,C.color,g))}}),global:S.global},b?(t[l].x2=rr(_.x2,S.x2,g),t[l].y2=rr(_.y2,S.y2,g)):t[l].r=rr(_.r,S.r,g)}else if(u)Hl(y,c[n],d[n],g),i||(t[l]=Vo(y));else{var w=rr(c[n],d[n],g);i?this._additiveValue=w:t[l]=w}i&&this._addToTarget(t)}}},r.prototype._addToTarget=function(t){var e=this.valType,i=this.propName,n=this._additiveValue;e===ro?t[i]=t[i]+n:e===qn?(We(t[i],xn),eo(xn,xn,n,1),t[i]=Vo(xn)):e===Go?eo(t[i],t[i],n,1):e===cm&&sc(t[i],t[i],n,1)},r})(),Dh=(function(){function r(t,e,i,n){if(this._tracks={},this._trackKeys=[],this._maxTime=0,this._started=0,this._clip=null,this._target=t,this._loop=e,e&&n){Sh("Can' use additive animation on looped animation.");return}this._additiveAnimators=n,this._allowDiscrete=i}return r.prototype.getMaxTime=function(){return this._maxTime},r.prototype.getDelay=function(){return this._delay},r.prototype.getLoop=function(){return this._loop},r.prototype.getTarget=function(){return this._target},r.prototype.changeTarget=function(t){this._target=t},r.prototype.when=function(t,e,i){return this.whenWithKeys(t,e,St(e),i)},r.prototype.whenWithKeys=function(t,e,i,n){for(var a=this._tracks,o=0;o0&&l.addKeyframe(0,Ho(u),n),this._trackKeys.push(s)}l.addKeyframe(t,Ho(e[s]),n)}return this._maxTime=Math.max(this._maxTime,t),this},r.prototype.pause=function(){this._clip.pause(),this._paused=!0},r.prototype.resume=function(){this._clip.resume(),this._paused=!1},r.prototype.isPaused=function(){return!!this._paused},r.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},r.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,i=0;i0)){this._started=1;for(var e=this,i=[],n=this._maxTime||0,a=0;a1){var s=o.pop();a.addKeyframe(s.time,t[n]),a.prepare(this._maxTime,a.getAdditiveTrack())}}}},r})(),Ie=(function(){function r(t){t&&(this._$eventProcessor=t)}return r.prototype.on=function(t,e,i,n){this._$handlers||(this._$handlers={});var a=this._$handlers;if(typeof e=="function"&&(n=i,i=e,e=null),!i||!t)return this;var o=this._$eventProcessor;e!=null&&o&&o.normalizeQuery&&(e=o.normalizeQuery(e)),a[t]||(a[t]=[]);for(var s=0;s=0:i.inside,S=void 0,b=void 0,w=void 0;_&&this.canBeInsideText()?(S=i.insideFill,b=i.insideStroke,(S==null||S==="auto")&&(S=this.getInsideTextFill()),(b==null||b==="auto")&&(b=this.getInsideTextStroke(S),w=!0)):(S=i.outsideFill,b=i.outsideStroke,(S==null||S==="auto")&&(S=this.getOutsideFill()),(b==null||b==="auto")&&(b=this.getOutsideStroke(S),w=!0)),S=S||"#000",(S!==g.fill||b!==g.stroke||w!==g.autoStroke||o!==g.align||s!==g.verticalAlign)&&(l=!0,g.fill=S,g.stroke=b,g.autoStroke=w,g.align=o,g.verticalAlign=s,e.setDefaultTextStyle(g)),e.__dirty|=Jt,l&&e.dirtyStyle(!0)}},r.prototype.canBeInsideText=function(){return!0},r.prototype.getInsideTextFill=function(){return"#fff"},r.prototype.getInsideTextStroke=function(t){return"#000"},r.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?bf:Sf},r.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),i=typeof e=="string"&&We(e);i||(i=[255,255,255,1]);for(var n=i[3],a=this.__zr.isDarkMode(),o=0;o<3;o++)i[o]=i[o]*n+(a?0:255)*(1-n);return i[3]=1,za(i,"rgba")},r.prototype.traverse=function(t,e){},r.prototype.attrKV=function(t,e){t==="textConfig"?this.setTextConfig(e):t==="textContent"?this.setTextContent(e):t==="clipPath"?this.setClipPath(e):t==="extra"?(this.extra=this.extra||{},O(this.extra,e)):this[t]=e},r.prototype.hide=function(){this.ignore=!0,this.markRedraw()},r.prototype.show=function(){this.ignore=!1,this.markRedraw()},r.prototype.attr=function(t,e){if(typeof t=="string")this.attrKV(t,e);else if(Z(t))for(var i=t,n=St(i),a=0;a0},r.prototype.getState=function(t){return this.states[t]},r.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},r.prototype.clearStates=function(t){this.useState(Vl,!1,t)},r.prototype.useState=function(t,e,i,n){var a=t===Vl,o=this.hasState();if(!(!o&&a)){var s=this.currentStates,l=this.stateTransition;if(!(lt(s,t)>=0&&(e||s.length===1))){var u;if(this.stateProxy&&!a&&(u=this.stateProxy(t)),u||(u=this.states&&this.states[t]),!u&&!a){Sh("State "+t+" not exists.");return}a||this.saveCurrentToNormalState(u);var f=this._textContent,h=fc(this,f,u,n);h&&!this.__inHover&&(this.__inHover=h),this._applyStateObj(t,u,this._normalState,e,vc(this,i,l),l);var v=this._textGuide;return f&&f.useState(t,e,i,!!h),v&&v.useState(t,e,i,!!h),a?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!h&&this.__inHover&&(this.__inHover=Uo,this.__dirty&=~Jt),u}}},r.prototype.useStates=function(t,e,i){if(!t.length)this.clearStates();else{var n=[],a=this.currentStates,o=t.length,s=o===a.length;if(s){for(var l=0;l=0){var i=this.currentStates.slice();i.splice(e,1),this.useStates(i)}},r.prototype.replaceState=function(t,e,i){var n=this.currentStates.slice(),a=lt(n,t),o=lt(n,e)>=0;a>=0?o?n.splice(a,1):n[a]=e:i&&!o&&n.push(e),this.useStates(n)},r.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},r.prototype._mergeStates=function(t){for(var e={},i,n=0;n=0&&a.splice(o,1)}),this.animators.push(t),i&&i.animation.addAnimator(t),i&&i.wakeUp()},r.prototype.updateDuringAnimation=function(t){this.markRedraw()},r.prototype.stopAnimation=function(t,e){for(var i=this.animators,n=i.length,a=[],o=0;o0&&e.during&&a[0].during(function(d,p){e.during(p)});for(var v=0;v0||n.force&&!o.length){var D=void 0,C=void 0,M=void 0;if(s){C={},v&&(D={});for(var S=0;S<_;S++){var g=p[S];C[g]=e[g],v?D[g]=i[g]:e[g]=i[g]}}else if(v){M={};for(var S=0;S<_;S++){var g=p[S];M[g]=Ho(e[g]),PS(e,i,g)}}var b=new Dh(e,!1,!1,h?zt(d,function(L){return L.targetName===t}):null);b.targetName=t,n.scope&&(b.scope=n.scope),v&&D&&b.whenWithKeys(0,D,p),M&&b.whenWithKeys(0,M,p),b.whenWithKeys(u??500,s?C:i,p).delay(f||0),r.addAnimator(b,t),o.push(b)}}function fc(r,t,e,i){return!(e&&e.hoverLayer||i)||hc(r)||t&&hc(t)?Uo:il}function hc(r){return r.type==="text"||r.type==="tspan"}function vc(r,t,e){return!t&&!r.__inHover&&e&&e.duration>0}var wf="__zr_style_"+Math.round(Math.random()*10),gi={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},al={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};gi[wf]=!0;var cc=["z","z2","invisible"],OS=["invisible"],Ha=(function(r){B(t,r);function t(e){return r.call(this,e)||this}return t.prototype._init=function(e){for(var i=St(e),n=0;n1e-4){s[0]=r-e,s[1]=t-i,l[0]=r+e,l[1]=t+i;return}if(ao[0]=$l(n)*e+r,ao[1]=Xl(n)*i+t,oo[0]=$l(a)*e+r,oo[1]=Xl(a)*i+t,u(s,ao,oo),f(l,ao,oo),n=n%Wr,n<0&&(n=n+Wr),a=a%Wr,a<0&&(a=a+Wr),n>a&&!o?a+=Wr:nn&&(so[0]=$l(c)*e+r,so[1]=Xl(c)*i+t,u(s,so,s),f(l,so,l))}var ht={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Yr=[],Zr=[],Re=[],ur=[],Oe=[],ke=[],ql=Math.min,Kl=Math.max,Xr=Math.cos,$r=Math.sin,Je=Math.abs,Tf=Math.PI,mr=Tf*2,Ql=typeof Float32Array<"u",Cn=[];function Jl(r){var t=Math.round(r/Tf*1e8)/1e8;return t%2*Tf}function zS(r,t){var e=Jl(r[0]);e<0&&(e+=mr);var i=e-r[0],n=r[1];n+=i,!t&&n-e>=mr?n=e+mr:t&&e-n>=mr?n=e-mr:!t&&e>n?n=e+(mr-Jl(e-n)):t&&e0&&(this._ux=Je(i/ps/t)||0,this._uy=Je(i/ps/e)||0)},r.prototype.setDPR=function(t){this.dpr=t},r.prototype.setContext=function(t){this._ctx=t},r.prototype.getContext=function(){return this._ctx},r.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},r.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},r.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(ht.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},r.prototype.lineTo=function(t,e){var i=Je(t-this._xi),n=Je(e-this._yi),a=i>this._ux||n>this._uy;if(this.addData(ht.L,t,e),this._ctx&&a&&this._ctx.lineTo(t,e),a)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=i*i+n*n;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},r.prototype.bezierCurveTo=function(t,e,i,n,a,o){return this._drawPendingPt(),this.addData(ht.C,t,e,i,n,a,o),this._ctx&&this._ctx.bezierCurveTo(t,e,i,n,a,o),this._xi=a,this._yi=o,this},r.prototype.quadraticCurveTo=function(t,e,i,n){return this._drawPendingPt(),this.addData(ht.Q,t,e,i,n),this._ctx&&this._ctx.quadraticCurveTo(t,e,i,n),this._xi=i,this._yi=n,this},r.prototype.arc=function(t,e,i,n,a,o){this._drawPendingPt(),Cn[0]=n,Cn[1]=a,zS(Cn,o),n=Cn[0],a=Cn[1];var s=a-n;return this.addData(ht.A,t,e,i,i,n,s,0,o?0:1),this._ctx&&this._ctx.arc(t,e,i,n,a,o),this._xi=Xr(a)*i+t,this._yi=$r(a)*i+e,this},r.prototype.arcTo=function(t,e,i,n,a){return this._drawPendingPt(),this._ctx&&this._ctx.arcTo(t,e,i,n,a),this},r.prototype.rect=function(t,e,i,n){return this._drawPendingPt(),this._ctx&&this._ctx.rect(t,e,i,n),this.addData(ht.R,t,e,i,n),this},r.prototype.closePath=function(){this._drawPendingPt(),this.addData(ht.Z);var t=this._ctx,e=this._x0,i=this._y0;return t&&t.closePath(),this._xi=e,this._yi=i,this},r.prototype.fill=function(t){t&&t.fill(),this.toStatic()},r.prototype.stroke=function(t){t&&t.stroke(),this.toStatic()},r.prototype.len=function(){return this._len},r.prototype.setData=function(t){if(this._saveData){var e=t.length;!(this.data&&this.data.length===e)&&Ql&&(this.data=new Float32Array(e));for(var i=0;i0&&o))for(var s=0;sf.length&&(this._expandData(),f=this.data);for(var h=0;h0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},r.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},r.prototype.getBoundingRect=function(){Re[0]=Re[1]=Oe[0]=Oe[1]=Number.MAX_VALUE,ur[0]=ur[1]=ke[0]=ke[1]=-Number.MAX_VALUE;var t=this.data,e=0,i=0,n=0,a=0,o;for(o=0;oi||Je(_)>n||v===e-1)&&(p=Math.sqrt(y*y+_*_),a=m,o=g);break}case ht.C:{var S=t[v++],b=t[v++],m=t[v++],g=t[v++],w=t[v++],T=t[v++];p=vS(a,o,S,b,m,g,w,T,10),a=w,o=T;break}case ht.Q:{var S=t[v++],b=t[v++],m=t[v++],g=t[v++];p=pS(a,o,S,b,m,g,10),a=m,o=g;break}case ht.A:var D=t[v++],C=t[v++],M=t[v++],A=t[v++],L=t[v++],I=t[v++],P=I+L;v+=1,d&&(s=Xr(L)*M+D,l=$r(L)*A+C),p=Kl(M,A)*ql(mr,Math.abs(I)),a=Xr(P)*M+D,o=$r(P)*A+C;break;case ht.R:{s=a=t[v++],l=o=t[v++];var R=t[v++],E=t[v++];p=R*2+E*2;break}case ht.Z:{var y=s-a,_=l-o;p=Math.sqrt(y*y+_*_),a=s,o=l;break}}p>=0&&(u[h++]=p,f+=p)}return this._pathLen=f,f},r.prototype.rebuildPath=function(t,e){var i=this.data,n=this._ux,a=this._uy,o=this._len,s,l,u,f,h,v,c=e<1,d,p,m=0,g=0,y,_=0,S,b;if(!(c&&(this._pathSegLen||this._calculateLength(),d=this._pathSegLen,p=this._pathLen,y=e*p,!y)))t:for(var w=0;w0&&(t.lineTo(S,b),_=0),T){case ht.M:s=u=i[w++],l=f=i[w++],t.moveTo(u,f);break;case ht.L:{h=i[w++],v=i[w++];var C=Je(h-u),M=Je(v-f);if(C>n||M>a){if(c){var A=d[g++];if(m+A>y){var L=(y-m)/A;t.lineTo(u*(1-L)+h*L,f*(1-L)+v*L);break t}m+=A}t.lineTo(h,v),u=h,f=v,_=0}else{var I=C*C+M*M;I>_&&(S=h,b=v,_=I)}break}case ht.C:{var P=i[w++],R=i[w++],E=i[w++],F=i[w++],G=i[w++],U=i[w++];if(c){var A=d[g++];if(m+A>y){var L=(y-m)/A;vs(u,P,E,G,L,Yr),vs(f,R,F,U,L,Zr),t.bezierCurveTo(Yr[1],Zr[1],Yr[2],Zr[2],Yr[3],Zr[3]);break t}m+=A}t.bezierCurveTo(P,R,E,F,G,U),u=G,f=U;break}case ht.Q:{var P=i[w++],R=i[w++],E=i[w++],F=i[w++];if(c){var A=d[g++];if(m+A>y){var L=(y-m)/A;cs(u,P,E,L,Yr),cs(f,R,F,L,Zr),t.quadraticCurveTo(Yr[1],Zr[1],Yr[2],Zr[2]);break t}m+=A}t.quadraticCurveTo(P,R,E,F),u=E,f=F;break}case ht.A:var K=i[w++],$=i[w++],tt=i[w++],X=i[w++],H=i[w++],it=i[w++],at=i[w++],Vt=!i[w++],Te=tt>X?tt:X,_t=Je(tt-X)>.001,It=H+it,rt=!1;if(c){var A=d[g++];m+A>y&&(It=H+it*(y-m)/A,rt=!0),m+=A}if(_t&&t.ellipse?t.ellipse(K,$,tt,X,at,H,It,Vt):t.arc(K,$,Te,H,It,Vt),rt)break t;D&&(s=Xr(H)*tt+K,l=$r(H)*X+$),u=Xr(It)*tt+K,f=$r(It)*X+$;break;case ht.R:s=u=i[w],l=f=i[w+1],h=i[w++],v=i[w++];var ot=i[w++],Br=i[w++];if(c){var A=d[g++];if(m+A>y){var Gt=y-m;t.moveTo(h,v),t.lineTo(h+ql(Gt,ot),v),Gt-=ot,Gt>0&&t.lineTo(h+ot,v+ql(Gt,Br)),Gt-=Br,Gt>0&&t.lineTo(h+Kl(ot-Gt,0),v+Br),Gt-=ot,Gt>0&&t.lineTo(h,v+Kl(Br-Gt,0));break t}m+=A}t.rect(h,v,ot,Br);break;case ht.Z:if(c){var A=d[g++];if(m+A>y){var L=(y-m)/A;t.lineTo(u*(1-L)+s*L,f*(1-L)+l*L);break t}m+=A}t.closePath(),u=s,f=l}}},r.prototype.clone=function(){var t=new r,e=this.data;return t.data=e.slice?e.slice():Array.prototype.slice.call(e),t._len=this._len,t},r.prototype.canSave=function(){return!!this._saveData},r.CMD=ht,r.initDefaultProps=(function(){var t=r.prototype;t._saveData=!0,t._ux=0,t._uy=0,t._pendingPtDist=0,t._version=0})(),r})();function Pi(r,t,e,i,n,a,o){if(n===0)return!1;var s=n,l=0,u=r;if(o>t+s&&o>i+s||or+s&&a>e+s||at+h&&f>i+h&&f>a+h&&f>s+h||fr+h&&u>e+h&&u>n+h&&u>o+h||ut+u&&l>i+u&&l>a+u||lr+u&&s>e+u&&s>n+u||se||f+un&&(n+=Mn);var v=Math.atan2(l,s);return v<0&&(v+=Mn),v>=i&&v<=n||v+Mn>=i&&v+Mn<=n}function qr(r,t,e,i,n,a){if(a>t&&a>i||an?s:0}var fr=_i.CMD,Kr=Math.PI*2,US=1e-4;function WS(r,t){return Math.abs(r-t)t&&u>i&&u>a&&u>s||u1&&YS(),c=Ot(t,i,a,s,ve[0]),v>1&&(d=Ot(t,i,a,s,ve[1]))),v===2?mt&&s>i&&s>a||s=0&&u<=1){for(var f=0,h=qt(t,i,a,u),v=0;ve||s<-e)return 0;var l=Math.sqrt(e*e-s*s);Ut[0]=-l,Ut[1]=l;var u=Math.abs(i-n);if(u<1e-4)return 0;if(u>=Kr-1e-4){i=0,n=Kr;var f=a?1:-1;return o>=Ut[0]+r&&o<=Ut[1]+r?f:0}if(i>n){var h=i;i=n,n=h}i<0&&(i+=Kr,n+=Kr);for(var v=0,c=0;c<2;c++){var d=Ut[c];if(d+r>o){var p=Math.atan2(s,d),f=a?1:-1;p<0&&(p=Kr+p),(p>=i&&p<=n||p+Kr>=i&&p+Kr<=n)&&(p>Math.PI/2&&p1&&(e||(s+=qr(l,u,f,h,i,n))),m&&(l=a[d],u=a[d+1],f=l,h=u),p){case fr.M:f=a[d++],h=a[d++],l=f,u=h;break;case fr.L:if(e){if(Pi(l,u,a[d],a[d+1],t,i,n))return!0}else s+=qr(l,u,a[d],a[d+1],i,n)||0;l=a[d++],u=a[d++];break;case fr.C:if(e){if(HS(l,u,a[d++],a[d++],a[d++],a[d++],a[d],a[d+1],t,i,n))return!0}else s+=ZS(l,u,a[d++],a[d++],a[d++],a[d++],a[d],a[d+1],i,n)||0;l=a[d++],u=a[d++];break;case fr.Q:if(e){if(VS(l,u,a[d++],a[d++],a[d],a[d+1],t,i,n))return!0}else s+=XS(l,u,a[d++],a[d++],a[d],a[d+1],i,n)||0;l=a[d++],u=a[d++];break;case fr.A:var g=a[d++],y=a[d++],_=a[d++],S=a[d++],b=a[d++],w=a[d++];d+=1;var T=!!(1-a[d++]);v=Math.cos(b)*_+g,c=Math.sin(b)*S+y,m?(f=v,h=c):s+=qr(l,u,v,c,i,n);var D=(i-g)*S/_+g;if(e){if(GS(g,y,S,b,b+w,T,t,D,n))return!0}else s+=$S(g,y,S,b,b+w,T,D,n);l=Math.cos(b+w)*_+g,u=Math.sin(b+w)*S+y;break;case fr.R:f=l=a[d++],h=u=a[d++];var C=a[d++],M=a[d++];if(v=f+C,c=h+M,e){if(Pi(f,h,v,h,t,i,n)||Pi(v,h,v,c,t,i,n)||Pi(v,c,f,c,t,i,n)||Pi(f,c,f,h,t,i,n))return!0}else s+=qr(v,h,v,c,i,n),s+=qr(f,c,f,h,i,n);break;case fr.Z:if(e){if(Pi(l,u,f,h,t,i,n))return!0}else s+=qr(l,u,f,h,i,n);l=f,u=h;break}}return!e&&!WS(u,h)&&(s+=qr(l,u,f,h,i,n)||0),s!==0}function qS(r,t,e){return gm(r,0,!1,t,e)}function KS(r,t,e,i){return gm(r,t,!0,e,i)}var mm=vt({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},gi),QS={style:vt({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},al.style)},jl=rl.concat(["invisible","culling","z","z2","zlevel","parent"]),mt=(function(r){B(t,r);function t(e){return r.call(this,e)||this}return t.prototype.update=function(){var e=this;r.prototype.update.call(this);var i=this.style;if(i.decal){var n=this._decalEl=this._decalEl||new t;n.buildPath===t.prototype.buildPath&&(n.buildPath=function(l){e.buildPath(l,e.shape)}),n.silent=!0;var a=n.style;for(var o in i)a[o]!==i[o]&&(a[o]=i[o]);a.fill=i.fill?i.decal:null,a.decal=null,a.shadowColor=null,i.strokeFirst&&(a.stroke=null);for(var s=0;s.5?Sf:i>.2?DS:bf}else if(e)return bf}return Sf},t.prototype.getInsideTextStroke=function(e){var i=this.style.fill;if(V(i)){var n=this.__zr,a=!!(n&&n.isDarkMode()),o=ds(e,0)<_f;if(a===o)return i}},t.prototype.buildPath=function(e,i,n){},t.prototype.pathUpdated=function(){this.__dirty&=~Yi},t.prototype.getUpdatedPathProxy=function(e){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,e),this.path},t.prototype.createPathProxy=function(){this.path=new _i(!1)},t.prototype.hasStroke=function(){var e=this.style,i=e.stroke;return!(i==null||i==="none"||!(e.lineWidth>0))},t.prototype.hasFill=function(){var e=this.style,i=e.fill;return i!=null&&i!=="none"},t.prototype.getBoundingRect=function(){var e=this._rect,i=this.style,n=!e;if(n){var a=!1;this.path||(a=!0,this.createPathProxy());var o=this.path;(a||this.__dirty&Yi)&&(o.beginPath(),this.buildPath(o,this.shape,!1),this.pathUpdated()),e=o.getBoundingRect()}if(this._rect=e,this.hasStroke()&&this.path&&this.path.len()>0){var s=this._rectStroke||(this._rectStroke=e.clone());if(this.__dirty||n){s.copy(e);var l=i.strokeNoScale?this.getLineScale():1,u=i.lineWidth;if(!this.hasFill()){var f=this.strokeContainThreshold;u=Math.max(u,f??4)}l>1e-10&&(s.width+=u/l,s.height+=u/l,s.x-=u/l/2,s.y-=u/l/2)}return s}return e},t.prototype.contain=function(e,i){var n=this.transformCoordToLocal(e,i),a=this.getBoundingRect(),o=this.style;if(e=n[0],i=n[1],a.contain(e,i)){var s=this.path;if(this.hasStroke()){var l=o.lineWidth,u=o.strokeNoScale?this.getLineScale():1;if(u>1e-10&&(this.hasFill()||(l=Math.max(l,this.strokeContainThreshold)),KS(s,l/u,e,i)))return!0}if(this.hasFill())return qS(s,e,i)}return!1},t.prototype.dirtyShape=function(){this.__dirty|=Yi,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},t.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},t.prototype.animateShape=function(e){return this.animate("shape",e)},t.prototype.updateDuringAnimation=function(e){e==="style"?this.dirtyStyle():e==="shape"?this.dirtyShape():this.markRedraw()},t.prototype.attrKV=function(e,i){e==="shape"?this.setShape(i):r.prototype.attrKV.call(this,e,i)},t.prototype.setShape=function(e,i){var n=this.shape;return n||(n=this.shape={}),typeof e=="string"?n[e]=i:O(n,e),this.dirtyShape(),this},t.prototype.shapeChanged=function(){return!!(this.__dirty&Yi)},t.prototype.createStyle=function(e){return Js(mm,e)},t.prototype._innerSaveToNormal=function(e){r.prototype._innerSaveToNormal.call(this,e);var i=this._normalState;e.shape&&!i.shape&&(i.shape=O({},this.shape))},t.prototype._applyStateObj=function(e,i,n,a,o,s){if(r.prototype._applyStateObj.call(this,e,i,n,a,o,s),this.__inHover!==il){var l=!(i&&a),u;if(i&&i.shape?o?a?u=i.shape:(u=O({},n.shape),O(u,i.shape)):(u=O({},a?this.shape:n.shape),O(u,i.shape)):l&&(u=n.shape),u)if(o){this.shape=O({},this.shape);for(var f={},h=St(u),v=0;vn&&(h=s+l,s*=n/h,l*=n/h),u+f>n&&(h=u+f,u*=n/h,f*=n/h),l+u>a&&(h=l+u,l*=a/h,u*=a/h),s+f>a&&(h=s+f,s*=a/h,f*=a/h),r.moveTo(e+s,i),r.lineTo(e+n-l,i),l!==0&&r.arc(e+n-l,i+l,l,-Math.PI/2,0),r.lineTo(e+n,i+a-u),u!==0&&r.arc(e+n-u,i+a-u,u,0,Math.PI/2),r.lineTo(e+f,i+a),f!==0&&r.arc(e+f,i+a-f,f,Math.PI/2,Math.PI),r.lineTo(e,i+s),s!==0&&r.arc(e+s,i+s,s,Math.PI,Math.PI*1.5),r.closePath()}var Ki=Math.round;function ym(r,t,e){if(t){var i=t.x1,n=t.x2,a=t.y1,o=t.y2;r.x1=i,r.x2=n,r.y1=a,r.y2=o;var s=e&&e.lineWidth;return s&&(Ki(i*2)===Ki(n*2)&&(r.x1=r.x2=hi(i,s,!0)),Ki(a*2)===Ki(o*2)&&(r.y1=r.y2=hi(a,s,!0))),r}}function _m(r,t,e){if(t){var i=t.x,n=t.y,a=t.width,o=t.height;r.x=i,r.y=n,r.width=a,r.height=o;var s=e&&e.lineWidth;return s&&(r.x=hi(i,s,!0),r.y=hi(n,s,!0),r.width=Math.max(hi(i+a,s,!1)-r.x,a===0?0:1),r.height=Math.max(hi(n+o,s,!1)-r.y,o===0?0:1)),r}}function hi(r,t,e){if(!t)return r;var i=Ki(r*2);return(i+Ki(t))%2===0?i/2:(i+(e?1:-1))/2}var ib=(function(){function r(){this.x=0,this.y=0,this.width=0,this.height=0}return r})(),nb={},Tt=(function(r){B(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new ib},t.prototype.buildPath=function(e,i){var n,a,o,s;if(this.subPixelOptimize){var l=_m(nb,i,this.style);n=l.x,a=l.y,o=l.width,s=l.height,l.r=i.r,i=l}else n=i.x,a=i.y,o=i.width,s=i.height;i.r?rb(e,i):e.rect(n,a,o,s)},t.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},t})(mt);Tt.prototype.type="rect";var yc={fill:"#000"},_c=2,Be={},ab={style:vt({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},al.style)},Xt=(function(r){B(t,r);function t(e){var i=r.call(this)||this;return i.type="text",i._children=[],i._defaultStyle=yc,i.attr(e),i}return t.prototype.childrenRef=function(){return this._children},t.prototype.update=function(){r.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;e0,L=0;L=0&&(P=w[I],P.align==="right");)this._placeToken(P,e,D,g,L,"right",_),C-=P.width,L-=P.width,I--;for(A+=(f-(A-m)-(y-L)-C)/2;M<=I;)P=w[M],this._placeToken(P,e,D,g,A+P.width/2,"center",_),A+=P.width,M++;g+=D}},t.prototype._placeToken=function(e,i,n,a,o,s,l){var u=i.rich[e.styleName]||{};u.text=e.text;var f=e.verticalAlign,h=a+n/2;f==="top"?h=a+e.height/2:f==="bottom"&&(h=a+n-e.height/2);var v=!e.isLineHolder&&tu(u);v&&this._renderBackground(u,i,s==="right"?o-e.width:s==="center"?o-e.width/2:o,h-e.height/2,e.width,e.height);var c=!!u.backgroundColor,d=e.textPadding;d&&(o=Cc(o,s,d),h-=e.height/2-d[0]-e.innerHeight/2);var p=this._getOrCreateChild(gs),m=p.createStyle();p.useStyle(m);var g=this._defaultStyle,y=!1,_=0,S=!1,b=xc("fill"in u?u.fill:"fill"in i?i.fill:(y=!0,g.fill)),w=Tc("stroke"in u?u.stroke:"stroke"in i?i.stroke:!c&&!l&&(!g.autoStroke||y)?(_=_c,S=!0,g.stroke):null),T=u.textShadowBlur>0||i.textShadowBlur>0;m.text=e.text,m.x=o,m.y=h,T&&(m.shadowBlur=u.textShadowBlur||i.textShadowBlur||0,m.shadowColor=u.textShadowColor||i.textShadowColor||"transparent",m.shadowOffsetX=u.textShadowOffsetX||i.textShadowOffsetX||0,m.shadowOffsetY=u.textShadowOffsetY||i.textShadowOffsetY||0),m.textAlign=s,m.textBaseline="middle",m.font=e.font||Ar,m.opacity=tn(u.opacity,i.opacity,1),bc(m,u),w&&(m.lineWidth=tn(u.lineWidth,i.lineWidth,_),m.lineDash=Y(u.lineDash,i.lineDash),m.lineDashOffset=i.lineDashOffset||0,m.stroke=w),b&&(m.fill=b),p.setBoundingRect(hf(m,e.contentWidth,e.contentHeight,S?0:null))},t.prototype._renderBackground=function(e,i,n,a,o,s){var l=e.backgroundColor,u=e.borderWidth,f=e.borderColor,h=l&&l.image,v=l&&!h,c=e.borderRadius,d=this,p,m;if(v||e.lineHeight||u&&f){p=this._getOrCreateChild(Tt),p.useStyle(p.createStyle()),p.style.fill=null;var g=p.shape;g.x=n,g.y=a,g.width=o,g.height=s,g.r=c,p.dirtyShape()}if(v){var y=p.style;y.fill=l||null,y.fillOpacity=Y(e.fillOpacity,1)}else if(h){m=this._getOrCreateChild(kr),m.onload=function(){d.dirtyStyle()};var _=m.style;_.image=l.image,_.x=n,_.y=a,_.width=o,_.height=s}if(u&&f){var y=p.style;y.lineWidth=u,y.stroke=f,y.strokeOpacity=Y(e.strokeOpacity,1),y.lineDash=e.borderDash,y.lineDashOffset=e.borderDashOffset||0,p.strokeContainThreshold=0,p.hasFill()&&p.hasStroke()&&(y.strokeFirst=!0,y.lineWidth*=2)}var S=(p||m).style;S.shadowBlur=e.shadowBlur||0,S.shadowColor=e.shadowColor||"transparent",S.shadowOffsetX=e.shadowOffsetX||0,S.shadowOffsetY=e.shadowOffsetY||0,S.opacity=tn(e.opacity,i.opacity,1)},t.makeFont=function(e){var i="";return ub(e)&&(i=[e.fontStyle,e.fontWeight,lb(e.fontSize),e.fontFamily||"sans-serif"].join(" ")),i&&ze(i)||e.textFont||e.font},t})(Ha),ob={left:!0,right:1,center:1},sb={top:1,bottom:1,middle:1},Sc=["fontStyle","fontWeight","fontSize","fontFamily"];function lb(r){return typeof r=="string"&&(r.indexOf("px")!==-1||r.indexOf("rem")!==-1||r.indexOf("em")!==-1)?r:isNaN(+r)?mh+"px":r+"px"}function bc(r,t){for(var e=0;e0){if(r<=n)return o;if(r>=a)return s}else{if(r>=n)return o;if(r<=a)return s}else{if(r===n)return o;if(r===a)return s}return(r-n)/l*u+o}var ge=db;function db(r,t,e){switch(r){case"center":case"middle":r="50%";break;case"left":case"top":r="0%";break;case"right":case"bottom":r="100%";break}return Cf(r,t,e)}function Cf(r,t,e){return V(r)?pb(r)?parseFloat(r)/100*t+(e||0):parseFloat(r):r==null?NaN:+r}function pb(r){return!!hb(r).match(/%$/)}function st(r,t,e){return isNaN(t)?e?""+r:+r:(t=re(dt(0,t),Sm),r=(+r).toFixed(t),e?r:+r)}function Sr(r){return r.sort(function(t,e){return t-e}),r}function br(r){if(r=+r,isNaN(r))return 0;if(r>1e-14){for(var t=1,e=0;e<15;e++,t*=10)if(ar(r*t)/t===r)return e}return gb(r)}function gb(r){var t=r.toString().toLowerCase(),e=t.indexOf("e"),i=e>0?+t.slice(e+1):0,n=e>0?e:t.length,a=t.indexOf("."),o=a<0?0:n-1-a;return dt(0,o-i)}function bm(r,t,e){var i=Lt(r[1]-r[0]);if(!isFinite(i)||i===0)return NaN;var n=ya(2*Lt(e||1)*Lt(i))/xf,a=ya(Lt(t))/xf,o=dt(0,Sn(-n+a));return isFinite(o)||(o=NaN),o}function si(r,t){var e=dt(br(r),br(t)),i=r+t;return e>Sm?i:st(i,e)}var Ac=bn(2,53)-1;function wm(r){var t=vb*2;return(r%t+t)%t}function ms(r){return r>-Dc&&r=10&&t++,t}var xm=2;function Ih(r,t){var e=Ah(r),i=bn(10,e),n=r/i,a;return t===xm?a=1:t?n<1.5?a=1:n<2.5?a=2:n<4?a=3:n<7?a=5:a=10:n<1?a=1:n<2?a=2:n<3?a=3:n<5?a=5:a=10,r=a*i,st(r,-e)}function ys(r){var t=parseFloat(r);return t==r&&(t!==0||!V(r)||r.indexOf("x")<=0)?t:NaN}function yb(r){return!isNaN(ys(r))}function Lh(){return ar(cb()*9)}function Cm(r,t){return t===0?r:Cm(t,r%t)}function Ic(r,t){return r==null?t:t==null?r:r*t/Cm(r,t)}function $e(r){return r!=null&&isFinite(r)}var _b="[ECharts] ",Sb=typeof console<"u"&&console.warn&&console.log;function bb(r,t,e){Sb&&console[r](_b+t)}function Mm(r,t){bb("error",r)}function Kt(r){throw new Error(r)}function Lc(r,t,e){return(t-r)*e+r}var Dm="series\0",wb="\0_ec_\0";function Zt(r){return r instanceof Array?r:r==null?[]:[r]}function Pc(r,t,e){if(r){r[t]=r[t]||{},r.emphasis=r.emphasis||{},r.emphasis[t]=r.emphasis[t]||{};for(var i=0,n=e.length;ir[1]&&(r[1]=t))}function Lm(r,t){or(t)&&tr[1]&&(r[1]=t)}function zb(r,t){hn(t[0],t[1])&&(t[0]r[1]&&(r[1]=t[1]))}function or(r){return r!=null&&isFinite(r)}function hn(r,t){return or(r)&&or(t)&&r<=t}function Hb(r){var t=r[1]-r[0];return isFinite(t)&&t>=0}function Wo(r){hn(r[0],r[1])&&r[0]>r[1]&&(r[0]=r[1])}function Em(){var r="__ec_once_"+Vb++;return function(t,e){jt(t,r)||(t[r]=1,e())}}var Vb=Lh();function Rh(r,t,e){var i=W(),n=0;x(r,function(a){var o=t(a),s=i.get(o)||0;e&&e(a,s),!s&&!e&&(r[n++]=a),i.set(o,s+1)}),e||(r.length=n)}function Gb(r){return r.value+""}function Ub(r){return r+""}function Wb(r,t,e){var i=r.getData().count();return{progressiveRender:e.progressiveEnabled&&t.incrementalPrepareRender&&i>=e.threshold,large:r.get("large")&&i>=r.get("largeThreshold"),modDataCount:r.get("progressiveChunkMode")==="mod"?r.getData().count():null}}function Oh(r){return{overallReset:r}}var gt=ft(),Yb=function(r,t,e,i){if(i){var n=gt(i);n.dataIndex=e,n.dataType=t,n.seriesIndex=r,n.ssrType="chart",i.type==="group"&&i.traverse(function(a){var o=gt(a);o.seriesIndex=r,o.dataIndex=e,o.dataType=t,o.ssrType="chart"})}},Ua="undefined",Rm="series",Om=W(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),oe="original",Ht="arrayRows",Le="objectRows",Qe="keyedColumns",Mr="typedArray",km="unknown",Ze="column",Ci="row",Zb=["getDom","getZr","getWidth","getHeight","getDevicePixelRatio","dispatchAction","isSSR","isDisposed","on","off","getDataURL","getConnectedDataURL","getOption","getId","updateLabelLayout"],Bm=(function(){function r(t){x(Zb,function(e){this[e]=Q(t[e],t)},this)}return r})();function Xb(r,t){return t.mainType===Rm?r.getViewOfSeriesModel(t):r.getViewOfComponentModel(t)}var Rc=1,Oc={},Nm=ft(),kh=ft(),Bh=0,ol=1,sl=2,qe=["emphasis","blur","select"],kc=["normal","emphasis","blur","select"],$b=10,qb=9,mi="highlight",Yo="downplay",_s="select",Df="unselect",Ss="toggleSelect",Nh="selectchanged";function Ei(r){return r!=null&&r!=="none"}function ll(r,t,e){r.onHoverStateChange&&(r.hoverState||0)!==e&&r.onHoverStateChange(t),r.hoverState=e}function Fm(r){ll(r,"emphasis",sl)}function zm(r){r.hoverState===sl&&ll(r,"normal",Bh)}function Fh(r){ll(r,"blur",ol)}function Hm(r){r.hoverState===ol&&ll(r,"normal",Bh)}function Kb(r){r.selected=!0}function Qb(r){r.selected=!1}function Bc(r,t,e){t(r,e)}function lr(r,t,e){Bc(r,t,e),r.isGroup&&r.traverse(function(i){Bc(i,t,e)})}function Nc(r,t){switch(t){case"emphasis":r.hoverState=sl;break;case"normal":r.hoverState=Bh;break;case"blur":r.hoverState=ol;break;case"select":r.selected=!0}}function Jb(r,t,e,i){for(var n=r.style,a={},o=0;o=0,a=!1;if(r instanceof mt){var o=Nm(r),s=n&&o.selectFill||o.normalFill,l=n&&o.selectStroke||o.normalStroke;if(Ei(s)||Ei(l)){i=i||{};var u=i.style||{};u.fill==="inherit"?(a=!0,i=O({},i),u=O({},u),u.fill=s):!Ei(u.fill)&&Ei(s)?(a=!0,i=O({},i),u=O({},u),u.fill=oc(s)):!Ei(u.stroke)&&Ei(l)&&(a||(i=O({},i),u=O({},u)),u.stroke=oc(l)),i.style=u}}if(i&&i.z2==null){a||(i=O({},i));var f=r.z2EmphasisLift;i.z2=r.z2+(f??$b)}return i}function tw(r,t,e){if(e&&e.z2==null){e=O({},e);var i=r.z2SelectLift;e.z2=r.z2+(i??qb)}return e}function ew(r,t,e){var i=lt(r.currentStates,t)>=0,n=r.style.opacity,a=i?null:Jb(r,["opacity"],t,{opacity:1});e=e||{};var o=e.style||{};return o.opacity==null&&(e=O({},e),o=O({opacity:i?n:a.opacity*.1},o),e.style=o),e}function ru(r,t){var e=this.states[r];if(this.style){if(r==="emphasis")return jb(this,r,t,e);if(r==="blur")return ew(this,r,e);if(r==="select")return tw(this,r,e)}return e}function rw(r){r.stateProxy=ru;var t=r.getTextContent(),e=r.getTextGuideLine();t&&(t.stateProxy=ru),e&&(e.stateProxy=ru)}function Fc(r,t){!Wm(r,t)&&!r.__highByOuter&&lr(r,Fm)}function zc(r,t){!Wm(r,t)&&!r.__highByOuter&&lr(r,zm)}function bs(r,t){r.__highByOuter|=1<<(t||0),lr(r,Fm)}function ws(r,t){!(r.__highByOuter&=~(1<<(t||0)))&&lr(r,zm)}function iw(r){lr(r,Fh)}function Vm(r){lr(r,Hm)}function Gm(r){lr(r,Kb)}function Um(r){lr(r,Qb)}function Wm(r,t){return r.__highDownSilentOnTouch&&t.zrByTouch}function Ym(r){var t=r.getModel(),e=[],i=[];t.eachComponent(function(n,a){var o=kh(a),s=Xb(r,a),l=n==="series";!l&&i.push(s),o.isBlured&&(s.group.traverse(function(u){Hm(u)}),l&&e.push(a)),o.isBlured=!1}),x(i,function(n){n&&n.toggleBlurSeries&&n.toggleBlurSeries(e,!1,t)})}function Af(r,t,e,i){var n=i.getModel();e=e||"coordinateSystem";function a(u,f){for(var h=0;h0){var s={dataIndex:o,seriesIndex:e.seriesIndex};a!=null&&(s.dataType=a),t.push(s)}})}),t}function Ts(r,t,e){Zm(r,!0),lr(r,rw),fw(r,t,e)}function uw(r){Zm(r,!1)}function Lf(r,t,e,i){i?uw(r):Ts(r,t,e)}function fw(r,t,e){var i=gt(r);t!=null?(i.focus=t,i.blurScope=e):i.focus&&(i.focus=null)}var Vc=["emphasis","blur","select"],hw={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function Gc(r,t,e,i){e=e||"itemStyle";for(var n=0;n1&&(o*=iu(d),s*=iu(d));var p=(n===a?-1:1)*iu((o*o*(s*s)-o*o*(c*c)-s*s*(v*v))/(o*o*(c*c)+s*s*(v*v)))||0,m=p*o*c/s,g=p*-s*v/o,y=(r+e)/2+fo(h)*m-uo(h)*g,_=(t+i)/2+uo(h)*m+fo(h)*g,S=Zc([1,0],[(v-m)/o,(c-g)/s]),b=[(v-m)/o,(c-g)/s],w=[(-1*v-m)/o,(-1*c-g)/s],T=Zc(b,w);if(Rf(b,w)<=-1&&(T=Dn),Rf(b,w)>=1&&(T=0),T<0){var D=Math.round(T/Dn*1e6)/1e6;T=Dn*2+D%2*Dn}f.addData(u,y,_,o,s,S,T,h,a)}var mw=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/ig,yw=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;function _w(r){var t=new _i;if(!r)return t;var e=0,i=0,n=e,a=i,o,s=_i.CMD,l=r.match(mw);if(!l)return t;for(var u=0;u=0&&(n.splice(a,0,e),this._doAdd(e))}return this},t.prototype.replace=function(e,i){var n=lt(this._children,e);return n>=0&&this.replaceAt(i,n),this},t.prototype.replaceAt=function(e,i){var n=this._children,a=n[i];if(e&&e!==this&&e.parent!==this&&e!==a){n[i]=e,a.parent=null;var o=this.__zr;o&&a.removeSelfFromZr(o),this._doAdd(e)}return this},t.prototype._doAdd=function(e){e.parent&&e.parent.remove(e),e.parent=this;var i=this.__zr;i&&i!==e.__zr&&e.addSelfToZr(i),i&&i.refresh()},t.prototype.remove=function(e){var i=this.__zr,n=this._children,a=lt(n,e);return a<0?this:(n.splice(a,1),e.parent=null,i&&e.removeSelfFromZr(i),i&&i.refresh(),this)},t.prototype.removeAll=function(){for(var e=this._children,i=this.__zr,n=0;nP*P+R*R&&(D=M,C=A),{cx:D,cy:C,x0:-f,y0:-h,x1:D*(n/b-1),y1:C*(n/b-1)}}function Dw(r){var t;if(N(r)){var e=r.length;if(!e)return r;e===1?t=[r[0],r[0],0,0]:e===2?t=[r[0],r[0],r[1],r[1]]:e===3?t=r.concat(r[2]):t=r}else t=[r,r,r,r];return t}function Aw(r,t){var e,i=Qn(t.r,0),n=Qn(t.r0||0,0),a=i>0,o=n>0;if(!(!a&&!o)){if(a||(i=n,n=0),n>i){var s=i;i=n,n=s}var l=t.startAngle,u=t.endAngle;if(!(isNaN(l)||isNaN(u))){var f=t.cx,h=t.cy,v=!!t.clockwise,c=$c(u-l),d=c>nu&&c%nu;if(d>Ce&&(c=d),!(i>Ce))r.moveTo(f,h);else if(c>nu-Ce)r.moveTo(f+i*Oi(l),h+i*Qr(l)),r.arc(f,h,i,l,u,!v),n>Ce&&(r.moveTo(f+n*Oi(u),h+n*Qr(u)),r.arc(f,h,n,u,l,v));else{var p=void 0,m=void 0,g=void 0,y=void 0,_=void 0,S=void 0,b=void 0,w=void 0,T=void 0,D=void 0,C=void 0,M=void 0,A=void 0,L=void 0,I=void 0,P=void 0,R=i*Oi(l),E=i*Qr(l),F=n*Oi(u),G=n*Qr(u),U=c>Ce;if(U){var K=t.cornerRadius;K&&(e=Dw(K),p=e[0],m=e[1],g=e[2],y=e[3]);var $=$c(i-n)/2;if(_=Ne($,g),S=Ne($,y),b=Ne($,p),w=Ne($,m),C=T=Qn(_,S),M=D=Qn(b,w),(T>Ce||D>Ce)&&(A=i*Oi(u),L=i*Qr(u),I=n*Oi(l),P=n*Qr(l),cCe){var _t=Ne(g,C),It=Ne(y,C),rt=ho(I,P,R,E,i,_t,v),ot=ho(A,L,F,G,i,It,v);r.moveTo(f+rt.cx+rt.x0,h+rt.cy+rt.y0),C0&&r.arc(f+rt.cx,h+rt.cy,_t,Bt(rt.y0,rt.x0),Bt(rt.y1,rt.x1),!v),r.arc(f,h,i,Bt(rt.cy+rt.y1,rt.cx+rt.x1),Bt(ot.cy+ot.y1,ot.cx+ot.x1),!v),It>0&&r.arc(f+ot.cx,h+ot.cy,It,Bt(ot.y1,ot.x1),Bt(ot.y0,ot.x0),!v))}else r.moveTo(f+R,h+E),r.arc(f,h,i,l,u,!v);if(!(n>Ce)||!U)r.lineTo(f+F,h+G);else if(M>Ce){var _t=Ne(p,M),It=Ne(m,M),rt=ho(F,G,A,L,n,-It,v),ot=ho(R,E,I,P,n,-_t,v);r.lineTo(f+rt.cx+rt.x0,h+rt.cy+rt.y0),M0&&r.arc(f+rt.cx,h+rt.cy,It,Bt(rt.y0,rt.x0),Bt(rt.y1,rt.x1),!v),r.arc(f,h,n,Bt(rt.cy+rt.y1,rt.cx+rt.x1),Bt(ot.cy+ot.y1,ot.cx+ot.x1),v),_t>0&&r.arc(f+ot.cx,h+ot.cy,_t,Bt(ot.y1,ot.x1),Bt(ot.y0,ot.x0),!v))}else r.lineTo(f+F,h+G),r.arc(f,h,n,u,l,v)}r.closePath()}}}var Iw=(function(){function r(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=Math.PI*2,this.clockwise=!0,this.cornerRadius=0}return r})(),fl=(function(r){B(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new Iw},t.prototype.buildPath=function(e,i){Aw(e,i)},t.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},t})(mt);fl.prototype.type="sector";var Lw=(function(){function r(){this.cx=0,this.cy=0,this.r=0,this.r0=0}return r})(),Vh=(function(r){B(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new Lw},t.prototype.buildPath=function(e,i){var n=i.cx,a=i.cy,o=Math.PI*2;e.moveTo(n+i.r,a),e.arc(n,a,i.r,0,o,!1),e.moveTo(n+i.r0,a),e.arc(n,a,i.r0,0,o,!0)},t})(mt);Vh.prototype.type="ring";function Pw(r,t,e,i){var n=[],a=[],o=[],s=[],l,u,f,h;if(i){f=[1/0,1/0],h=[-1/0,-1/0];for(var v=0,c=r.length;v=2){if(i){var a=Pw(n,i,e,t.smoothConstraint);r.moveTo(n[0][0],n[0][1]);for(var o=n.length,s=0;s<(e?o:o-1);s++){var l=a[s*2],u=a[s*2+1],f=n[(s+1)%o];r.bezierCurveTo(l[0],l[1],u[0],u[1],f[0],f[1])}}else{r.moveTo(n[0][0],n[0][1]);for(var s=1,h=n.length;sjr[1]){if(a=!1,Rt.negativeSize||i)return a;var l=vo(jr[0]-Jr[1]),u=vo(Jr[0]-jr[1]);au(l,u)>po.len()&&(l=u||!Rt.bidirectional)&&(pt.scale(co,s,-u*n),Rt.useDir&&Rt.calcDirMTV()))}}return a},r.prototype._getProjMinMaxOnAxis=function(t,e,i){for(var n=this._axes[t],a=this._origin,o=e[0].dot(n)+a[t],s=o,l=o,u=1;u0){var h=f.duration,v=f.delay,c=f.easing,d={duration:h,delay:v||0,easing:c,done:a,force:!!a||!!o,setToFinal:!u,scope:r,during:o};s?t.animateFrom(e,d):t.animateTo(e,d)}else t.stopAnimation(),!s&&t.attr(e),o&&o(1),a&&a()}function Lr(r,t,e,i,n,a){Uh("update",r,t,e,i,n,a)}function Wa(r,t,e,i,n,a){Uh("enter",r,t,e,i,n,a)}function sa(r){if(!r.__zr)return!0;for(var t=0;tLt(a[1])?a[0]>0?"right":"left":a[1]>0?"bottom":"top"}function Qc(r){return!r.isGroup}function iT(r){return r.shape!=null}function uy(r,t,e){if(!r||!t)return;function i(o){var s={};return o.traverse(function(l){Qc(l)&&l.anid&&(s[l.anid]=l)}),s}function n(o){var s={x:o.x,y:o.y,rotation:o.rotation};return iT(o)&&(s.shape=nt(o.shape)),s}var a=i(r);t.traverse(function(o){if(Qc(o)&&o.anid){var s=a[o.anid];if(s){var l=n(o);o.attr(n(s)),Lr(o,l,e,gt(o).dataIndex)}}})}function nT(r,t){return q(r,function(e){var i=e[0];i=dt(i,t.x),i=re(i,t.x+t.width);var n=e[1];return n=dt(n,t.y),n=re(n,t.y+t.height),[i,n]})}function aT(r,t){var e=dt(r.x,t.x),i=re(r.x+r.width,t.x+t.width),n=dt(r.y,t.y),a=re(r.y+r.height,t.y+t.height);if(i>=e&&a>=n)return{x:e,y:n,width:i-e,height:a-n}}function Zh(r,t,e){var i=O({rectHover:!0},t),n=i.style={strokeNoScale:!0};if(e=e||{x:-1,y:-1,width:2,height:2},r)return r.indexOf("image://")===0?(n.image=r.slice(8),vt(n,e),new kr(i)):Yh(r.replace("path://",""),i,e,"center")}function oT(r,t,e,i,n){for(var a=0,o=n[n.length-1];a1)return!1;var m=ou(c,d,f,h)/v;return!(m<0||m>1)}function ou(r,t,e,i){return r*i-e*t}function sT(r){return r<=1e-6&&r>=-1e-6}function Cs(r,t,e,i,n){return t==null||(bt(t)?yt[0]=yt[1]=yt[2]=yt[3]=t:(yt[0]=t[0],yt[1]=t[1],yt[2]=t[2],yt[3]=t[3]),i&&(yt[0]=dt(0,yt[0]),yt[1]=dt(0,yt[1]),yt[2]=dt(0,yt[2]),yt[3]=dt(0,yt[3])),e&&(yt[0]=-yt[0],yt[1]=-yt[1],yt[2]=-yt[2],yt[3]=-yt[3]),Jc(r,yt,"x","width",3,1,n&&n[0]||0),Jc(r,yt,"y","height",0,2,n&&n[1]||0)),r}var yt=[0,0,0,0];function Jc(r,t,e,i,n,a,o){var s=t[a]+t[n],l=r[i];r[i]+=s,o=dt(0,re(o,l)),r[i]=0?-t[n]:t[a]>=0?l+t[a]:Lt(s)>1e-8?(l-o)*t[n]/s:0):r[e]-=t[n]}function dl(r){var t=r.itemTooltipOption,e=r.componentModel,i=r.itemName,n=V(t)?{formatter:t}:t,a=e.mainType,o=e.componentIndex,s={componentType:a,name:i,$vars:["name"]};s[a+"Index"]=o;var l=r.formatterParamsExtra;l&&x(St(l),function(f){jt(s,f)||(s[f]=l[f],s.$vars.push(f))});var u=gt(r.el);u.componentMainType=a,u.componentIndex=o,u.tooltipConfig={name:i,option:vt({content:i,encodeHTMLContent:!0,formatterParams:s},n)}}function kf(r,t){var e;r.isGroup&&(e=t(r)),e||r.traverse(t)}function Xh(r,t){if(r)if(N(r))for(var e=0;et&&(t=o),ot&&(e=t=0),{min:e,max:t}}function hy(r,t,e){vy(r,t,e,-1/0)}function vy(r,t,e,i){if(r.ignoreModelZ)return i;var n=r.getTextContent(),a=r.getTextGuideLine(),o=r.isGroup;if(o)for(var s=r.childrenRef(),l=0;l1){var f=l.shift();l.length===1&&(i[s]=l[0]),this._update&&this._update(f,o)}else u===1?(i[s]=null,this._update&&this._update(l,o)):this._remove&&this._remove(o)}this._performRestAdd(a,i)},r.prototype._executeMultiple=function(){var t=this._old,e=this._new,i={},n={},a=[],o=[];this._initIndexMap(t,i,a,"_oldKeyGetter"),this._initIndexMap(e,n,o,"_newKeyGetter");for(var s=0;s1&&v===1)this._updateManyToOne&&this._updateManyToOne(f,u),n[l]=null;else if(h===1&&v>1)this._updateOneToMany&&this._updateOneToMany(f,u),n[l]=null;else if(h===1&&v===1)this._update&&this._update(f,u),n[l]=null;else if(h>1&&v>1)this._updateManyToMany&&this._updateManyToMany(f,u),n[l]=null;else if(h>1)for(var c=0;c1)for(var s=0;sp&&(p=_)}c[0]=d,c[1]=p}},n=function(){return this._data?this._data.length/this._dimSize:0};ld=(t={},t[Ht+"_"+Ze]={pure:!0,appendData:a},t[Ht+"_"+Ci]={pure:!0,appendData:function(){throw new Error('Do not support appendData when set seriesLayoutBy: "row".')}},t[Le]={pure:!0,appendData:a},t[Qe]={pure:!0,appendData:function(o){var s=this._data;x(o,function(l,u){for(var f=s[u]||(s[u]=[]),h=0;h<(l||[]).length;h++)f.push(l[h])})}},t[oe]={appendData:a},t[Mr]={persistent:!1,pure:!0,appendData:function(o){this._data=o},clean:function(){this._offset+=this.count(),this._data=null}},t);function a(o){for(var s=0;sn?-this._resultLT:0},r})();function zT(r){var t="",e=-1/0,i=-1/0,n=1/0,a=1/0;return r&&(r.g!=null&&(t+="G"+r.g,e=r.g),r.ge!=null&&(t+="GE"+r.ge,i=r.ge),r.l!=null&&(t+="L"+r.l,n=r.l),r.le!=null&&(t+="LE"+r.le,a=r.le)),{key:t,g:e,ge:i,l:n,le:a}}function HT(r,t){return t>r.g&&t>=r.ge&&t65535?VT:GT}function UT(r){var t=r.constructor;return t===Array?r.slice():new t(r)}function cd(r,t,e,i,n){var a=Dy[e||"float"];if(n){var o=r[t],s=o&&o.length;if(s!==i){for(var l=new a(i),u=0;um[1]&&(m[1]=p)}return this._rawCount=this._count=l,{start:s,end:l}},r.prototype._initDataFromProvider=function(t,e,i){for(var n=this._provider,a=this._chunks,o=this._dimensions,s=o.length,l=this._rawExtent,u=q(o,function(y){return y.property}),f=0;fg[1]&&(g[1]=m)}}!n.persistent&&n.clean&&n.clean(),this._rawCount=this._count=e,this._extent=[]},r.prototype.count=function(){return this._count},r.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,i=e[t];if(i!=null&&it)a=o-1;else return o}return-1},r.prototype.getIndices=function(){var t,e=this._indices;if(e){var i=e.constructor,n=this._count;if(i===Array){t=new i(n);for(var a=0;a=h&&y<=v||isNaN(y))&&(l[u++]=p),p++}d=!0}else if(a===2){for(var m=c[n[0]],_=c[n[1]],S=t[n[1]][0],b=t[n[1]][1],g=0;g=h&&y<=v||isNaN(y))&&(w>=S&&w<=b||isNaN(w))&&(l[u++]=p),p++}d=!0}}if(!d)if(a===1)for(var g=0;g=h&&y<=v||isNaN(y))&&(l[u++]=T)}else for(var g=0;gt[M][1])&&(D=!1)}D&&(l[u++]=e.getRawIndex(g))}return ug[1]&&(g[1]=m)}}}},r.prototype.lttbDownSample=function(t,e){var i=this.clone([t],!0),n=i._chunks,a=n[t],o=this.count(),s=0,l=Math.floor(1/e),u=this.getRawIndex(0),f,h,v,c=new(ki(this._rawCount))(Math.min((Math.ceil(o/l)+2)*2,o));c[s++]=u;for(var d=1;df&&(f=h,v=S)}A>0&&As&&(p=s-f);for(var m=0;md&&(d=y,c=f+m)}var _=this.getRawIndex(h),S=this.getRawIndex(c);hf-d&&(l=f-d,s.length=l);for(var p=0;ph[1]&&(h[1]=g),v[c++]=y}return a._count=c,a._indices=v,a._updateGetRawIdx(),a},r.prototype.each=function(t,e){if(this._count)for(var i=t.length,n=this._chunks,a=0,o=this.count();ac&&(c=m))}return l[f]=[v,c]},r.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var i=[],n=this._chunks,a=0;a=0?this._indices[t]:-1},r.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},r.internalField=(function(){function t(e,i,n,a){return $o(e[a],this._dimensions[a])}hu={arrayRows:t,objectRows:function(e,i,n,a){return $o(e[i],this._dimensions[a])},keyedColumns:t,original:function(e,i,n,a){var o=e&&(e.value==null?e:e.value);return $o(o instanceof Array?o[a]:o,this._dimensions[a])},typedArray:function(e,i,n,a){return e[a]}}})(),r})(),WT=ft(),YT={float:"f",int:"i",ordinal:"o",number:"n",time:"t"},Ay=(function(){function r(t){this.dimensions=t.dimensions,this._dimOmitted=t.dimensionOmitted,this.source=t.source,this._fullDimCount=t.fullDimensionCount,this._updateDimOmitted(t.dimensionOmitted)}return r.prototype.isDimensionOmitted=function(){return this._dimOmitted},r.prototype._updateDimOmitted=function(t){this._dimOmitted=t,t&&(this._dimNameMap||(this._dimNameMap=Ly(this.source)))},r.prototype.getSourceDimensionIndex=function(t){return Y(this._dimNameMap.get(t),-1)},r.prototype.getSourceDimension=function(t){var e=this.source.dimensionsDefine;if(e)return e[t]},r.prototype.makeStoreSchema=function(){for(var t=this._fullDimCount,e=by(this.source),i=!Py(t),n="",a=[],o=0,s=0;o30}var In=Z,hr=q,ZT=typeof Int32Array>"u"?Array:Int32Array,XT="e\0\0",dd=-1,$T=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],qT=["_approximateExtent"],pd,yo,Ln,Pn,vu,En,cu,KT=(function(){function r(t,e){this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i,n=!1;Iy(t)?(i=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(n=!0,i=t),i=i||["x","y"];for(var a={},o=[],s={},l=!1,u={},f=0;f=e)){var i=this._store,n=i.getProvider();this._updateOrdinalMeta();var a=this._nameList,o=this._idList,s=n.getSource().sourceFormat,l=s===oe;if(l&&!n.pure)for(var u=[],f=t;f0},r.prototype.ensureUniqueItemVisual=function(t,e){var i=this._itemVisuals,n=i[t];n||(n=i[t]={});var a=n[e];return a==null&&(a=this.getVisual(e),N(a)?a=a.slice():In(a)&&(a=O({},a)),n[e]=a),a},r.prototype.setItemVisual=function(t,e,i){var n=this._itemVisuals[t]||{};this._itemVisuals[t]=n,In(e)?O(n,e):n[e]=i},r.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},r.prototype.setLayout=function(t,e){In(t)?O(this._layout,t):this._layout[t]=e},r.prototype.getLayout=function(t){return this._layout[t]},r.prototype.getItemLayout=function(t){return this._itemLayouts[t]},r.prototype.setItemLayout=function(t,e,i){this._itemLayouts[t]=i?O(this._itemLayouts[t]||{},e):e},r.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},r.prototype.setItemGraphicEl=function(t,e){var i=this.hostModel&&this.hostModel.seriesIndex;Yb(i,this.dataType,t,e),this._graphicEls[t]=e},r.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},r.prototype.eachItemGraphicEl=function(t,e){x(this._graphicEls,function(i,n){i&&t&&t.call(e,i,n)})},r.prototype.cloneShallow=function(t){return t||(t=new r(this._schema?this._schema:hr(this.dimensions,this._getDimInfo,this),this.hostModel)),vu(t,this),t._store=this._store,t},r.prototype.wrapMethod=function(t,e){var i=this[t];J(i)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var n=i.apply(this,arguments);return e.apply(this,[n].concat(bh(arguments)))})},r.internalField=(function(){pd=function(t){var e=t._invertedIndicesMap;x(e,function(i,n){var a=t._dimInfos[n],o=a.ordinalMeta,s=t._store;if(o){i=e[n]=new ZT(o.categories.length);for(var l=0;l1&&(l+="__ec__"+f),n[e]=l}}})(),r})();function QT(r,t){Jh(r)||(r=_y(r)),t=t||{};var e=t.coordDimensions||[],i=t.dimensionsDefine||r.dimensionsDefine||[],n=W(),a=[],o=JT(r,e,i,t.dimensionsCount),s=t.canOmitUnusedDimensions&&Py(o),l=i===r.dimensionsDefine,u=l?Ly(r):tv(i),f=t.encodeDefine;!f&&t.encodeDefaulter&&(f=t.encodeDefaulter(r,o));for(var h=W(f),v=new My(o),c=0;c0&&(C.name=C.name+(M-1))}),new Ay({source:r,dimensions:a,fullDimensionCount:o,dimensionOmitted:s})}function JT(r,t,e,i){var n=Math.max(r.dimensionsDetectedCount||1,t.length,e.length,i||0);return x(t,function(a){var o;Z(a)&&(o=a.dimsDef)&&(n=Math.max(n,o.length))}),n}function jT(r,t,e){if(e||t.hasKey(r)){for(var i=0;t.hasKey(r+i);)i++;r+=i}return t.set(r,!0),r}var qo={},du={},ev=(function(){function r(){this._normalMasterList=[],this._nonSeriesBoxMasterList=[]}return r.prototype.create=function(t,e){this._nonSeriesBoxMasterList=i(qo),this._normalMasterList=i(du);function i(n,a){var o=[];return x(n,function(s,l){var u=s.create(t,e);o=o.concat(u||[])}),o}},r.prototype.update=function(t,e){x(this._normalMasterList,function(i){i.update&&i.update(t,e)})},r.prototype.getCoordinateSystems=function(){return this._normalMasterList.concat(this._nonSeriesBoxMasterList)},r.register=function(t,e){if(t==="matrix"||t==="calendar"){qo[t]=e;return}du[t]=e},r.get=function(t){return du[t]||qo[t]},r})();function tx(r){return!!qo[r]}var ex=1,rx=2,ix=W();function Ey(r){var t=r.getShallow("coord",!0),e=ex;if(t==null){var i=ix.get(r.type);i&&i.getCoord2&&(e=rx,t=i.getCoord2(r))}return{coord:t,from:e}}var nn=0,Ko=1,nx=2;function ax(r,t){var e=r.getShallow("coordinateSystem"),i=r.getShallow("coordinateSystemUsage",!0),n=nn;if(e){var a=r.mainType==="series";i==null&&(i=a?"data":"box"),i==="data"?(n=Ko,a||(n=nn)):i==="box"&&(n=nx,!a&&!tx(e)&&(n=nn))}return{coordSysType:e,kind:n}}function ox(r){var t=r.targetModel,e=r.coordSysType,i=r.coordSysProvider,n=r.isDefaultDataCoordSys,a=ax(t),o=a.kind,s=a.coordSysType;if(n&&o!==Ko&&(o=Ko,s=e),o===nn||s!==e)return nn;var l=i(e,t);return l?(o===Ko?t.coordinateSystem=l:t.boxCoordinateSystem=l,o):nn}var sx=(function(){function r(t){this.coordSysDims=[],this.axisMap=W(),this.categoryAxisMap=W(),this.coordSysName=t}return r})();function lx(r){var t=r.get("coordinateSystem"),e=new sx(t),i=ux[t];if(i)return i(r,e,e.axisMap,e.categoryAxisMap),e}var ux={cartesian2d:function(r,t,e,i){var n=r.getReferringComponents("xAxis",Wt).models[0],a=r.getReferringComponents("yAxis",Wt).models[0];t.coordSysDims=["x","y"],e.set("x",n),e.set("y",a),Bi(n)&&(i.set("x",n),t.firstCategoryDimIndex=0),Bi(a)&&(i.set("y",a),t.firstCategoryDimIndex==null&&(t.firstCategoryDimIndex=1))},singleAxis:function(r,t,e,i){var n=r.getReferringComponents("singleAxis",Wt).models[0];t.coordSysDims=["single"],e.set("single",n),Bi(n)&&(i.set("single",n),t.firstCategoryDimIndex=0)},polar:function(r,t,e,i){var n=r.getReferringComponents("polar",Wt).models[0],a=n.findAxisModel("radiusAxis"),o=n.findAxisModel("angleAxis");t.coordSysDims=["radius","angle"],e.set("radius",a),e.set("angle",o),Bi(a)&&(i.set("radius",a),t.firstCategoryDimIndex=0),Bi(o)&&(i.set("angle",o),t.firstCategoryDimIndex==null&&(t.firstCategoryDimIndex=1))},geo:function(r,t,e,i){t.coordSysDims=["lng","lat"]},parallel:function(r,t,e,i){var n=r.ecModel,a=n.getComponent("parallel",r.get("parallelIndex")),o=t.coordSysDims=a.dimensions.slice();x(a.parallelAxisIndex,function(s,l){var u=n.getComponent("parallelAxis",s),f=o[l];e.set(f,u),Bi(u)&&(i.set(f,u),t.firstCategoryDimIndex==null&&(t.firstCategoryDimIndex=l))})},matrix:function(r,t,e,i){var n=r.getReferringComponents("matrix",Wt).models[0];t.coordSysDims=["x","y"];var a=n.getDimensionModel("x"),o=n.getDimensionModel("y");e.set("x",a),e.set("y",o),i.set("x",a),i.set("y",o)}};function Bi(r){return r.get("type")==="category"}function fx(r,t,e){e=e||{};var i=e.byIndex,n=e.stackedCoordDimension,a,o,s;hx(t)?a=t:(o=t.schema,a=o.dimensions,s=t.store);var l=!!(r&&r.get("stack")),u,f,h,v,c=!0;function d(S){return S.type!=="ordinal"&&S.type!=="time"}if(x(a,function(S,b){V(S)&&(a[b]=S={name:S}),d(S)||(c=!1)}),x(a,function(S,b){l&&!S.isExtraCoord&&(!i&&!u&&S.ordinalMeta&&(u=S),!f&&d(S)&&(!c||S.coordDim!=="x"&&S.coordDim!=="angle")&&(!n||n===S.coordDim)&&(f=S))}),f&&!i&&!u&&(i=!0),f){h="__\0ecstackresult_"+r.id,v="__\0ecstackedover_"+r.id,u&&(u.createInvertedIndices=!0);var p=f.coordDim,m=f.type,g=0;x(a,function(S){S.coordDim===p&&g++});var y={name:h,coordDim:p,coordDimIndex:g,type:m,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:a.length},_={name:v,coordDim:v,coordDimIndex:g+1,type:m,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:a.length+1};o?(s&&(y.storeDimIndex=s.ensureCalculationDimension(v,m),_.storeDimIndex=s.ensureCalculationDimension(h,m)),o.appendCalculationDimension(y),o.appendCalculationDimension(_)):(a.push(y),a.push(_))}return{stackedDimension:f&&f.name,stackedByDimension:u&&u.name,isStackedByIndex:i,stackedOverDimension:v,stackResultDimension:h}}function hx(r){return!Iy(r.schema)}function Ca(r,t){return!!t&&t===r.getCalculationInfo("stackedDimension")}function vx(r,t){return Ca(r,t)?r.getCalculationInfo("stackResultDimension"):t}function cx(r,t){var e=r.get("coordinateSystem"),i=ev.get(e),n;return t&&t.coordSysDims&&(n=q(t.coordSysDims,function(a){var o={name:a},s=t.axisMap.get(a);if(s){var l=s.get("type");o.type=BT(l)}return o})),n||(n=i&&(i.getDimensionsInfo?i.getDimensionsInfo():i.dimensions.slice())||["x","y"]),n}function dx(r,t,e){var i,n;return e&&x(r,function(a,o){var s=a.coordDim,l=e.categoryAxisMap.get(s);l&&(i==null&&(i=o),a.ordinalMeta=l.getOrdinalMeta(),t&&(a.createInvertedIndices=!0)),a.otherDims.itemName!=null&&(n=!0)}),!n&&i!=null&&(r[i].otherDims.itemName=0),i}function px(r,t,e){e=e||{};var i=t.getSourceManager(),n,a=!1;n=i.getSource(),a=n.sourceFormat===oe;var o=lx(t),s=cx(t,o),l=e.useEncodeDefaulter,u=J(l)?l:l?Mt(CT,s,t):null,f={coordDimensions:s,generateCoord:e.generateCoord,encodeDefine:t.getEncode(),encodeDefaulter:u,canOmitUnusedDimensions:!a},h=QT(n,f),v=dx(h.dimensions,e.createInvertedIndices,o),c=a?null:i.getSharedDataStore(h),d=fx(t,{schema:h,store:c}),p=new KT(h,t);p.setCalculationInfo(d);var m=v!=null&&gx(n)?function(g,y,_,S){return S===v?_:this.defaultDimValueGetter(g,y,_,S)}:null;return p.hasItemOption=!1,p.initData(a?n:c,null,m),p}function gx(r){if(r.sourceFormat===oe){var t=mx(r.data||[]);return!N(Va(t))}}function mx(r){for(var t=0;t=0&&s.push(l)}),s}}function rv(r,t){return ut(ut({},r,!0),t,!0)}var bx=Math.log(2);function Ff(r,t,e,i,n,a){var o=i+"-"+n,s=r.length;if(a.hasOwnProperty(o))return a[o];if(t===1){var l=Math.round(Math.log((1<>1)%2;s.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[l]+":0",n[u]+":0",i[1-l]+":auto",n[1-u]+":auto",""].join("!important;"),r.appendChild(o),e.push(o)}return t.clearMarkers=function(){x(e,function(f){f.parentNode&&f.parentNode.removeChild(f)})},e}function Mx(r,t,e){for(var i=e?"invTrans":"trans",n=t[i],a=t.srcCoords,o=[],s=[],l=!0,u=0;u<4;u++){var f=r[u].getBoundingClientRect(),h=2*u,v=f.left,c=f.top;o.push(v,c),l=l&&a&&v===a[h]&&c===a[h+1],s.push(r[u].offsetLeft,r[u].offsetTop)}return l&&n?n:(t.srcCoords=o,t[i]=e?gd(s,o):gd(o,s))}function Ry(r){return r.nodeName.toUpperCase()==="CANVAS"}var Dx=/([&<>"'])/g,Ax={"&":"&","<":"<",">":">",'"':""","'":"'"};function Qt(r){return r==null?"":(r+"").replace(Dx,function(t,e){return Ax[e]})}const Ix={time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}},Lx={time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}};var Ds="ZH",iv="EN",an=iv,Qo={},nv={},Oy=et.domSupported?(function(){var r=(document.documentElement.lang||navigator.language||navigator.browserLanguage||an).toUpperCase();return r.indexOf(Ds)>-1?Ds:an})():an;function ky(r,t){r=r.toUpperCase(),nv[r]=new wt(t),Qo[r]=t}function Px(r){if(V(r)){var t=Qo[r.toUpperCase()]||{};return r===Ds||r===iv?nt(t):ut(nt(t),nt(Qo[an]),!1)}else return ut(nt(r),nt(Qo[an]),!1)}function Ex(r){return nv[r]}function Rx(){return nv[an]}ky(iv,Ix);ky(Ds,Lx);var Ox=null;function yl(){return Ox}function By(r,t){t.breakOption;var e=t.breakParsed;return e}function av(r){var t=r.brk;return t?t.breaks:[]}function As(r){var t=r.brk;return t?t.hasBreaks():!1}var ov=1e3,sv=ov*60,la=sv*60,ye=la*24,md=ye*365,kx={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Jo={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Bx="{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}",_o="{yyyy}-{MM}-{dd}",yd={year:"{yyyy}",month:"{yyyy}-{MM}",day:_o,hour:_o+" "+Jo.hour,minute:_o+" "+Jo.minute,second:_o+" "+Jo.second,millisecond:Bx},yi=["year","month","day","hour","minute","second","millisecond"],Nx=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function Fx(r){return!V(r)&&!J(r)?zx(r):r}function zx(r){r=r||{};var t={},e=!0;return x(yi,function(i){e&&(e=r[i]==null)}),x(yi,function(i,n){var a=r[i];t[i]={};for(var o=null,s=n;s>=0;s--){var l=yi[s],u=Z(a)&&!N(a)?a[l]:a,f=void 0;N(u)?(f=u.slice(),o=f[0]||""):V(u)?(o=u,f=[o]):(o==null?o=Jo[i]:kx[l].test(o)||(o=t[l][l][0]+" "+o),f=[o],e&&(f[1]="{primary|"+o+"}")),t[i][l]=f}}),t}function vr(r,t){return r+="","0000".substr(0,t-r.length)+r}function ua(r){switch(r){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return r}}function Hx(r){return r===ua(r)}function Vx(r){switch(r){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}function _l(r,t,e,i){var n=wn(r),a=n[Ny(e)](),o=n[lv(e)]()+1,s=Math.floor((o-1)/3)+1,l=n[uv(e)](),u=n["get"+(e?"UTC":"")+"Day"](),f=n[fv(e)](),h=(f-1)%12+1,v=n[hv(e)](),c=n[vv(e)](),d=n[cv(e)](),p=f>=12?"pm":"am",m=p.toUpperCase(),g=i instanceof wt?i:Ex(i||Oy)||Rx(),y=g.getModel("time"),_=y.get("month"),S=y.get("monthAbbr"),b=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(t||"").replace(/{a}/g,p+"").replace(/{A}/g,m+"").replace(/{yyyy}/g,a+"").replace(/{yy}/g,vr(a%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[o-1]).replace(/{MMM}/g,S[o-1]).replace(/{MM}/g,vr(o,2)).replace(/{M}/g,o+"").replace(/{dd}/g,vr(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,b[u]).replace(/{ee}/g,w[u]).replace(/{e}/g,u+"").replace(/{HH}/g,vr(f,2)).replace(/{H}/g,f+"").replace(/{hh}/g,vr(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,vr(v,2)).replace(/{m}/g,v+"").replace(/{ss}/g,vr(c,2)).replace(/{s}/g,c+"").replace(/{SSS}/g,vr(d,3)).replace(/{S}/g,d+"")}function Gx(r,t,e,i,n){var a=null;if(V(e))a=e;else if(J(e)){var o={time:r.time,level:r.time?r.time.level:0},s=yl();s&&s.makeAxisLabelFormatterParamBreak(o,r.break),a=e(r.value,t,o)}else{var l=r.time;if(l){var u=e[l.lowerTimeUnit][l.upperTimeUnit];a=u[Math.min(l.level,u.length-1)]||""}else{var f=jo(r.value,n);a=e[f][f][0]}}return _l(new Date(r.value),a,n,i)}function jo(r,t){var e=wn(r),i=e[lv(t)]()+1,n=e[uv(t)](),a=e[fv(t)](),o=e[hv(t)](),s=e[vv(t)](),l=e[cv(t)](),u=l===0,f=u&&s===0,h=f&&o===0,v=h&&a===0,c=v&&n===1,d=c&&i===1;return d?"year":c?"month":v?"day":h?"hour":f?"minute":u?"second":"millisecond"}function Hf(r,t,e){switch(t){case"year":r[Fy(e)](0);case"month":r[zy(e)](1);case"day":r[Hy(e)](0);case"hour":r[Vy(e)](0);case"minute":r[Gy(e)](0);case"second":r[Uy(e)](0)}return r}function Ny(r){return r?"getUTCFullYear":"getFullYear"}function lv(r){return r?"getUTCMonth":"getMonth"}function uv(r){return r?"getUTCDate":"getDate"}function fv(r){return r?"getUTCHours":"getHours"}function hv(r){return r?"getUTCMinutes":"getMinutes"}function vv(r){return r?"getUTCSeconds":"getSeconds"}function cv(r){return r?"getUTCMilliseconds":"getMilliseconds"}function Ux(r){return r?"setUTCFullYear":"setFullYear"}function Fy(r){return r?"setUTCMonth":"setMonth"}function zy(r){return r?"setUTCDate":"setDate"}function Hy(r){return r?"setUTCHours":"setHours"}function Vy(r){return r?"setUTCMinutes":"setMinutes"}function Gy(r){return r?"setUTCSeconds":"setSeconds"}function Uy(r){return r?"setUTCMilliseconds":"setMilliseconds"}function Wy(r){if(!yb(r))return V(r)?r:"-";var t=(r+"").split(".");return t[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(t.length>1?"."+t[1]:"")}function Yy(r,t){return r=(r||"").toLowerCase().replace(/-(.)/g,function(e,i){return i.toUpperCase()}),t&&r&&(r=r.charAt(0).toUpperCase()+r.slice(1)),r}var Sl=wh;function Vf(r,t,e){var i="{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}";function n(f){return f&&ze(f)?f:"-"}function a(f){return $e(f)}var o=t==="time",s=r instanceof Date;if(o||s){var l=o?wn(r):r;if(isNaN(+l)){if(s)return"-"}else return _l(l,i,e)}if(t==="ordinal")return nf(r)?n(r):bt(r)&&a(r)?r+"":"-";var u=ys(r);return a(u)?Wy(u):nf(r)?n(r):typeof r=="boolean"?r+"":"-"}var _d=["a","b","c","d","e","f","g"],gu=function(r,t){return"{"+r+(t??"")+"}"};function Zy(r,t,e){N(t)||(t=[t]);var i=t.length;if(!i)return"";for(var n=t[0].$vars||[],a=0;a':'';var o=e.markerId||"markerX";return{renderMode:a,content:"{"+o+"|} ",style:n==="subItem"?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}}function Ti(r,t){return t=t||"transparent",V(r)?r:Z(r)&&r.colorStops&&(r.colorStops[0]||{}).color||t}var ts=x,Yx=["left","right","top","bottom","width","height"],So=[["width","left","right"],["height","top","bottom"]];function dv(r,t,e,i,n){var a=0,o=0;i==null&&(i=1/0),n==null&&(n=1/0);var s=0;t.eachChild(function(l,u){var f=l.getBoundingRect(),h=t.childAt(u+1),v=h&&h.getBoundingRect(),c,d;if(r==="horizontal"){var p=f.width+(v?-v.x+f.x:0);c=a+p,c>i||l.newline?(a=0,c=p,o+=s+e,s=f.height):s=Math.max(s,f.height)}else{var m=f.height+(v?-v.y+f.y:0);d=o+m,d>n||l.newline?(a+=s+e,o=0,d=m,s=f.width):s=Math.max(s,f.width)}l.newline||(l.x=a,l.y=o,l.markRedraw(),r==="horizontal"?a=c+e:o=d+e)})}var fa=dv;Mt(dv,"vertical");Mt(dv,"horizontal");function Zx(r,t){return{left:r.getShallow("left",t),top:r.getShallow("top",t),right:r.getShallow("right",t),bottom:r.getShallow("bottom",t),width:r.getShallow("width",t),height:r.getShallow("height",t)}}function cn(r,t,e){e=Sl(e||0);var i=t.width,n=t.height,a=ge(r.left,i),o=ge(r.top,n),s=ge(r.right,i),l=ge(r.bottom,n),u=ge(r.width,i),f=ge(r.height,n),h=e[2]+e[0],v=e[1]+e[3],c=r.aspect;switch(isNaN(u)&&(u=i-s-v-a),isNaN(f)&&(f=n-l-h-o),c!=null&&(isNaN(u)&&isNaN(f)&&(c>i/n?u=i*.8:f=n*.8),isNaN(u)&&(u=c*f),isNaN(f)&&(f=u/c)),isNaN(a)&&(a=i-s-u-v),isNaN(o)&&(o=n-l-f-h),r.left||r.right){case"center":a=i/2-u/2-e[3];break;case"right":a=i-u-v;break}switch(r.top||r.bottom){case"middle":case"center":o=n/2-f/2-e[0];break;case"bottom":o=n-f-h;break}a=a||0,o=o||0,isNaN(u)&&(u=i-v-a-(s||0)),isNaN(f)&&(f=n-h-o-(l||0));var d=new j((t.x||0)+a+e[3],(t.y||0)+o+e[0],u,f);return d.margin=e,d}var mu={rect:1};function pv(r,t,e){var i,n,a,o=r.boxCoordinateSystem,s;if(o){var l=Ey(r),u=l.coord,f=l.from;if(o.dataToLayout){a=mu.rect,s=f;var h=o.dataToLayout(u);i=h.contentRect||h.rect}}return a==null&&(a=mu.rect),a===mu.rect&&(i||(i={x:0,y:0,width:t.getWidth(),height:t.getHeight()}),n=[i.x+i.width/2,i.y+i.height/2]),{type:a,refContainer:i,refPoint:n,boxCoordFrom:s}}function Ma(r){var t=r.layoutMode||r.constructor.layoutMode;return Z(t)?t:t?{type:t}:null}function Pr(r,t,e){var i=e&&e.ignoreSize;!N(i)&&(i=[i,i]);var n=o(So[0],0),a=o(So[1],1);l(So[0],r,n),l(So[1],r,a);function o(u,f){var h={},v=0,c={},d=0,p=2;if(ts(u,function(y){c[y]=r[y]}),ts(u,function(y){jt(t,y)&&(h[y]=c[y]=t[y]),s(h,y)&&v++,s(c,y)&&d++}),i[f])return s(t,u[1])?c[u[2]]=null:s(t,u[2])&&(c[u[1]]=null),c;if(d===p||!v)return c;if(v>=p)return h;for(var m=0;m=0;l--)s=ut(s,n[l],!0);i.defaultOption=s}return i.defaultOption},t.prototype.getReferringComponents=function(e,i){var n=e+"Index",a=e+"Id";return Ga(this.ecModel,e,{index:this.get(n,!0),id:this.get(a,!0)},i)},t.prototype.getBoxLayoutParams=function(){return Zx(this,!1)},t.prototype.getZLevelKey=function(){return""},t.prototype.setZLevel=function(e){this.option.zlevel=e},t.protoInitialize=(function(){var e=t.prototype;e.type="component",e.id="",e.name="",e.mainType="",e.subType="",e.componentIndex=0})(),t})(wt);Kg(ct,wt);js(ct);_x(ct);Sx(ct,qx);function qx(r){var t=[];return x(ct.getClassesByMainType(r),function(e){t=t.concat(e.dependencies||e.prototype.dependencies||[])}),t=q(t,function(e){return He(e).main}),r!=="dataset"&<(t,"dataset")<=0&&t.unshift("dataset"),t}var Sd=ft();ft();var gv=(function(){function r(){}return r.prototype.getColorFromPalette=function(t,e,i){var n=Zt(this.get("color",!0)),a=this.get("colorLayer",!0);return Qx(this,Sd,n,a,t,e,i)},r.prototype.clearColorPalette=function(){Jx(this,Sd)},r})();function Kx(r,t){for(var e=r.length,i=0;it)return r[i];return r[e-1]}function Qx(r,t,e,i,n,a,o){a=a||r;var s=t(a),l=s.paletteIdx||0,u=s.paletteNameMap=s.paletteNameMap||{};if(u.hasOwnProperty(n))return u[n];var f=o==null||!i?e:Kx(i,o);if(f=f||e,!(!f||!f.length)){var h=f[l];return n&&(u[n]=h),s.paletteIdx=(l+1)%f.length,h}}function Jx(r,t){t(r).paletteIdx=0,t(r).paletteNameMap={}}var jx=/\{@(.+?)\}/g,tC=(function(){function r(){}return r.prototype.getDataParams=function(t,e){var i=this.getData(e),n=this.getRawValue(t,e),a=i.getRawIndex(t),o=i.getName(t),s=i.getRawDataItem(t),l=i.getItemVisual(t,"style"),u=l&&l[i.getItemVisual(t,"drawType")||"fill"],f=l&&l.stroke,h=this.mainType,v=h==="series",c=i.userOutput&&i.userOutput.get();return{componentType:h,componentSubType:this.subType,componentIndex:this.componentIndex,seriesType:v?this.subType:null,seriesIndex:this.seriesIndex,seriesId:v?this.id:null,seriesName:v?this.name:null,name:o,dataIndex:a,data:s,dataType:e,value:n,color:u,borderColor:f,dimensionNames:c?c.fullDimensions:null,encode:c?c.encode:null,$vars:["seriesName","name","value"]}},r.prototype.getFormattedLabel=function(t,e,i,n,a,o){e=e||"normal";var s=this.getData(i),l=this.getDataParams(t,i);if(o&&(l.value=o.interpolatedValue),n!=null&&N(l.value)&&(l.value=l.value[n]),!a){var u=s.getItemModel(t);a=u.get(e==="normal"?["label","formatter"]:[e,"label","formatter"])}if(J(a))return l.status=e,l.dimensionIndex=n,a(l);if(V(a)){var f=Zy(a,l);return f.replace(jx,function(h,v){var c=v.length,d=v;d.charAt(0)==="["&&d.charAt(c-1)==="]"&&(d=+d.slice(1,c-1));var p=vn(s,t,d);if(o&&N(o.interpolatedValue)){var m=s.getDimensionIndex(d);m>=0&&(p=o.interpolatedValue[m])}return p!=null?p+"":""})}},r.prototype.getRawValue=function(t,e){return vn(this.getData(e),t)},r.prototype.formatTooltip=function(t,e,i){},r})();function bd(r){var t,e;return Z(r)?r.type&&(e=r):t=r,{text:t,frag:e}}function ha(r){return new eC(r)}var eC=(function(){function r(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return r.prototype.perform=function(t){var e=this._upstream,i=t&&t.skip;if(this._dirty&&e){var n=this.context;n.data=n.outputData=e.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this);var a;this._plan&&!i&&(a=this._plan(this.context));var o=f(this._modBy),s=this._modDataCount||0,l=f(t&&t.modBy),u=t&&t.modDataCount||0;(o!==l||s!==u)&&(a="reset");function f(y){return!(y>=1)&&(y=1),y}var h;(this._dirty||a==="reset")&&(this._dirty=!1,h=this._doReset(i)),this._modBy=l,this._modDataCount=u;var v=t&&t.step;if(e?this._dueEnd=e._outputDueEnd:this._dueEnd=this._count?this._count(this.context):1/0,this._progress){var c=this._dueIndex,d=Math.min(v!=null?this._dueIndex+v:1/0,this._dueEnd);if(!i&&(h||c1&&i>0?s:o}};return a;function o(){return t=r?null:l9e10&&(this._versionSignBase=0)},r.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},r.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},r.prototype._createSource=function(){this._setLocalSource([],[]);var t=this._sourceHost,e=this._getUpstreamSourceManagers(),i=!!e.length,n,a;if(bo(t)){var o=t,s=void 0,l=void 0,u=void 0;if(i){var f=e[0];f.prepareSource(),u=f.getSource(),s=u.data,l=u.sourceFormat,a=[f._getVersionSign()]}else s=o.get("data",!0),l=ee(s)?Mr:oe,a=[];var h=this._getSourceMetaRawOption()||{},v=u&&u.metaRawOption||{},c=Y(h.seriesLayoutBy,v.seriesLayoutBy)||null,d=Y(h.sourceHeader,v.sourceHeader),p=Y(h.dimensions,v.dimensions),m=c!==v.seriesLayoutBy||!!d!=!!v.sourceHeader||p;n=m?[Bf(s,{seriesLayoutBy:c,sourceHeader:d,dimensions:p},l)]:[]}else{var g=t;if(i){var y=this._applyTransform(e);n=y.sourceList,a=y.upstreamSignList}else{var _=g.get("source",!0);n=[Bf(_,this._getSourceMetaRawOption(),null)],a=[]}}this._setLocalSource(n,a)},r.prototype._applyTransform=function(t){var e=this._sourceHost,i=e.get("transform",!0),n=e.get("fromTransformResult",!0);if(n!=null){var a="";t.length!==1&&Td(a)}var o,s=[],l=[];return x(t,function(u){u.prepareSource();var f=u.getSource(n||0),h="";n!=null&&!f&&Td(h),s.push(f),l.push(u._getVersionSign())}),i?o=uC(i,s,{datasetIndex:e.componentIndex}):n!=null&&(o=[AT(s[0])]),{sourceList:o,upstreamSignList:l}},r.prototype._isDirty=function(){if(this._dirty)return!0;for(var t=this._getUpstreamSourceManagers(),e=0;e1||e>0&&!r.noHeader;return x(r.blocks,function(n){var a=Qy(n);a>=t&&(t=a+ +(i&&(!a||Gf(n)&&!n.noHeader)))}),t}return 0}function pC(r,t,e,i){var n=t.noHeader,a=mC(Qy(t)),o=[],s=t.blocks||[];nr(!s||N(s)),s=s||[];var l=r.orderMode;if(t.sortBlocks&&l){s=s.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(jt(u,l)){var f=new FT(u[l],null);s.sort(function(p,m){return f.evaluate(p.sortParam,m.sortParam)})}else l==="seriesDesc"&&s.reverse()}x(s,function(p,m){var g=t.valueFormatter,y=Ky(p)(g?O(O({},r),{valueFormatter:g}):r,p,m>0?a.html:0,i);y!=null&&o.push(y)});var h=r.renderMode==="richText"?o.join(a.richText):Uf(i,o.join(""),n?e:a.html);if(n)return h;var v=Vf(t.header,"ordinal",r.useUTC),c=qy(i,r.renderMode).nameStyle,d=$y(i);return r.renderMode==="richText"?Jy(r,v,c)+a.richText+h:Uf(i,'
'+Qt(v)+"
"+h,e)}function gC(r,t,e,i){var n=r.renderMode,a=t.noName,o=t.noValue,s=!t.markerType,l=t.name,u=r.useUTC,f=t.valueFormatter||r.valueFormatter||function(S){return S=N(S)?S:[S],q(S,function(b,w){return Vf(b,N(c)?c[w]:c,u)})};if(!(a&&o)){var h=s?"":r.markupStyleCreator.makeTooltipMarker(t.markerType,t.markerColor||z.color.secondary,n),v=a?"":Vf(l,"ordinal",u),c=t.valueType,d=o?[]:f(t.value,t.rawDataIndex),p=!s||!a,m=!s&&a,g=qy(i,n),y=g.nameStyle,_=g.valueStyle;return n==="richText"?(s?"":h)+(a?"":Jy(r,v,y))+(o?"":SC(r,d,p,m,_)):Uf(i,(s?"":h)+(a?"":yC(v,!s,y))+(o?"":_C(d,p,m,_)),e)}}function Cd(r,t,e,i,n,a){if(r){var o=Ky(r),s={useUTC:n,renderMode:e,orderMode:i,markupStyleCreator:t,valueFormatter:r.valueFormatter};return o(s,r,0,a)}}function mC(r){return{html:cC[r],richText:dC[r]}}function Uf(r,t,e){var i='
',n="margin: "+e+"px 0 0",a=$y(r);return'
'+t+i+"
"}function yC(r,t,e){var i=t?"margin-left:2px":"";return''+Qt(r)+""}function _C(r,t,e,i){var n=e?"10px":"20px",a=t?"float:right;margin-left:"+n:"";return r=N(r)?r:[r],''+q(r,function(o){return Qt(o)}).join("  ")+""}function Jy(r,t,e){return r.markupStyleCreator.wrapRichTextStyle(t,e)}function SC(r,t,e,i,n){var a=[n],o=i?10:20;return e&&a.push({padding:[0,0,0,o],align:"right"}),r.markupStyleCreator.wrapRichTextStyle(N(t)?t.join(" "):t,a)}function bC(r,t){var e=r.getData().getItemVisual(t,"style"),i=e[r.visualDrawType];return Ti(i)}function jy(r,t){var e=r.get("padding");return e??(t==="richText"?[8,10]:10)}var yu=(function(){function r(){this.richTextStyles={},this._nextStyleNameId=Lh()}return r.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},r.prototype.makeTooltipMarker=function(t,e,i){var n=i==="richText"?this._generateStyleName():null,a=Wx({color:e,type:t,renderMode:i,markerId:n});return V(a)?a:(this.richTextStyles[n]=a.style,a.content)},r.prototype.wrapRichTextStyle=function(t,e){var i={};N(e)?x(e,function(a){return O(i,a)}):O(i,e);var n=this._generateStyleName();return this.richTextStyles[n]=i,"{"+n+"|"+t+"}"},r})();function wC(r){var t=r.series,e=r.dataIndex,i=r.multipleSeries,n=t.getData(),a=n.mapDimensionsAll("defaultedTooltip"),o=a.length,s=t.getRawValue(e),l=N(s),u=bC(t,e),f,h,v,c;if(o>1||l&&!o){var d=TC(s,t,e,a,u);f=d.inlineValues,h=d.inlineValueTypes,v=d.blocks,c=d.inlineValues[0]}else if(o){var p=n.getDimensionInfo(a[0]);c=f=vn(n,e,a[0]),h=p.type}else c=f=l?s[0]:s;var m=Ph(t),g=m&&t.name||"",y=n.getName(e),_=i?g:y;return Da("section",{header:g,noHeader:i||!m,sortParam:c,blocks:[Da("nameValue",{markerType:"item",markerColor:u,name:_,noName:!ze(_),value:f,valueType:h,rawDataIndex:n.getRawIndex(e)})].concat(v||[])})}function TC(r,t,e,i,n){var a=t.getData(),o=yn(r,function(h,v,c){var d=a.getDimensionInfo(c);return h=h||d&&d.tooltip!==!1&&d.displayName!=null},!1),s=[],l=[],u=[];i.length?x(i,function(h){f(vn(a,e,h),h)}):x(r,f);function f(h,v){var c=a.getDimensionInfo(v);!c||c.otherDims.tooltip===!1||(o?u.push(Da("nameValue",{markerType:"subItem",markerColor:n,name:c.displayName,value:h,valueType:c.type})):(s.push(h),l.push(c.type)))}return{inlineValues:s,inlineValueTypes:l,blocks:u}}var cr=ft();function wo(r,t){return r.getName(t)||r.getId(t)}var xC="__universalTransitionEnabled",Er=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}return t.prototype.init=function(e,i,n){this.seriesIndex=this.componentIndex,this.dataTask=ha({count:MC,reset:DC}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(e,n);var a=cr(this).sourceManager=new hC(this);a.prepareSource();var o=this.getInitialData(e,n);Dd(o,this),this.dataTask.context.data=o,cr(this).dataBeforeProcessed=o,Md(this),this._initSelectedMapFromData(o)},t.prototype.mergeDefaultAndTheme=function(e,i){var n=Ma(this),a=n?Tn(e):{},o=this.subType;ct.hasClass(o)&&(o+="Series"),ut(e,i.getTheme().get(this.subType)),ut(e,this.getDefaultOption()),Pc(e,"label",["show"]),this.fillDataTextStyle(e.data),n&&Pr(e,a,n)},t.prototype.mergeOption=function(e,i){e=ut(this.option,e,!0),this.fillDataTextStyle(e.data);var n=Ma(this);n&&Pr(this.option,e,n);var a=cr(this).sourceManager;a.dirty(),a.prepareSource();var o=this.getInitialData(e,i);Dd(o,this),this.dataTask.dirty(),this.dataTask.context.data=o,cr(this).dataBeforeProcessed=o,Md(this),this._initSelectedMapFromData(o)},t.prototype.fillDataTextStyle=function(e){if(e&&!ee(e))for(var i=["show"],n=0;n=0&&v<0)&&(h=b,v=S,c=0),S===v&&(f[c++]=m))}return f.length=c,f},t.prototype.formatTooltip=function(e,i,n){return wC({series:this,dataIndex:e,multipleSeries:i})},t.prototype.isAnimationEnabled=function(){var e=this.ecModel;if(et.node&&!(e&&e.ssr))return!1;var i=this.getShallow("animation");return i&&this.getData().count()>this.getShallow("animationThreshold")&&(i=!1),!!i},t.prototype.restoreData=function(){this.dataTask.dirty()},t.prototype.getColorFromPalette=function(e,i,n){var a=this.ecModel,o=gv.prototype.getColorFromPalette.call(this,e,i,n);return o||(o=a.getColorFromPalette(e,i,n)),o},t.prototype.coordDimToDataDim=function(e){return this.getRawData().mapDimensionsAll(e)},t.prototype.getProgressive=function(){return this.get("progressive")},t.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},t.prototype.select=function(e,i){this._innerSelect(this.getData(i),e)},t.prototype.unselect=function(e,i){var n=this.option.selectedMap;if(n){var a=this.option.selectedMode,o=this.getData(i);if(a==="series"||n==="all"){this.option.selectedMap={},this._selectedDataIndicesMap={};return}for(var s=0;s=0&&n.push(o)}return n},t.prototype.isSelected=function(e,i){var n=this.option.selectedMap;if(!n)return!1;var a=this.getData(i);return(n==="all"||n[wo(a,e)])&&!a.getItemModel(e).get(["select","disabled"])},t.prototype.isUniversalTransitionEnabled=function(){if(this[xC])return!0;var e=this.option.universalTransition;return e?e===!0?!0:e&&e.enabled:!1},t.prototype._innerSelect=function(e,i){var n,a,o=this.option,s=o.selectedMode,l=i.length;if(!(!s||!l)){if(s==="series")o.selectedMap="all";else if(s==="multiple"){Z(o.selectedMap)||(o.selectedMap={});for(var u=o.selectedMap,f=0;f0&&this._innerSelect(e,i)}},t.registerClass=function(e){return ct.registerClass(e)},t.protoInitialize=(function(){var e=t.prototype;e.type="series.__base__",e.seriesIndex=0,e.ignoreStyleOnData=!1,e.hasSymbolVisual=!1,e.defaultSymbol="circle",e.visualStyleAccessPath="itemStyle",e.visualDrawType="fill"})(),t})(ct);Ke(Er,tC);Ke(Er,gv);Kg(Er,ct);function Md(r){var t=r.name;Ph(r)||(r.name=CC(r)||t)}function CC(r){var t=r.getRawData(),e=t.mapDimensionsAll("seriesName"),i=[];return x(e,function(n){var a=t.getDimensionInfo(n);a.displayName&&i.push(a.displayName)}),i.join(" ")}function MC(r){return r.model.getRawData().count()}function DC(r){var t=r.model;return t.setData(t.getRawData().cloneShallow()),AC}function AC(r,t){t.outputData&&r.end>t.outputData.count()&&t.model.getRawData().cloneShallow(t.outputData)}function Dd(r,t){x(T1(r.CHANGABLE_METHODS,r.DOWNSAMPLE_METHODS),function(e){r.wrapMethod(e,Mt(IC,t))})}function IC(r,t){var e=Wf(r);return e&&e.setOutputEnd((t||this).count()),t}function Wf(r){var t=(r.ecModel||{}).scheduler,e=t&&t.getPipeline(r.uid);if(e){var i=e.currentTask;if(i){var n=i.agentStubMap;n&&(i=n.get(r.uid))}return i}}var LC=mt.extend({type:"triangle",shape:{cx:0,cy:0,width:0,height:0},buildPath:function(r,t){var e=t.cx,i=t.cy,n=t.width/2,a=t.height/2;r.moveTo(e,i-a),r.lineTo(e+n,i+a),r.lineTo(e-n,i+a),r.closePath()}}),PC=mt.extend({type:"diamond",shape:{cx:0,cy:0,width:0,height:0},buildPath:function(r,t){var e=t.cx,i=t.cy,n=t.width/2,a=t.height/2;r.moveTo(e,i-a),r.lineTo(e+n,i),r.lineTo(e,i+a),r.lineTo(e-n,i),r.closePath()}}),EC=mt.extend({type:"pin",shape:{x:0,y:0,width:0,height:0},buildPath:function(r,t){var e=t.x,i=t.y,n=t.width/5*3,a=Math.max(n,t.height),o=n/2,s=o*o/(a-o),l=i-a+o+s,u=Math.asin(s/o),f=Math.cos(u)*o,h=Math.sin(u),v=Math.cos(u),c=o*.6,d=o*.7;r.moveTo(e-f,l+s),r.arc(e,l,o,Math.PI-u,Math.PI*2+u),r.bezierCurveTo(e+f-h*c,l+s+v*c,e,i-d,e,i),r.bezierCurveTo(e,i-d,e-f+h*c,l+s+v*c,e-f,l+s),r.closePath()}}),RC=mt.extend({type:"arrow",shape:{x:0,y:0,width:0,height:0},buildPath:function(r,t){var e=t.height,i=t.width,n=t.x,a=t.y,o=i/3*2;r.moveTo(n,a),r.lineTo(n+o,a+e),r.lineTo(n,a+e/4*3),r.lineTo(n-o,a+e),r.lineTo(n,a),r.closePath()}}),OC={line:Ir,rect:Tt,roundRect:Tt,square:Tt,circle:ul,diamond:PC,pin:EC,arrow:RC,triangle:LC},kC={line:function(r,t,e,i,n){n.x1=r,n.y1=t+i/2,n.x2=r+e,n.y2=t+i/2},rect:function(r,t,e,i,n){n.x=r,n.y=t,n.width=e,n.height=i},roundRect:function(r,t,e,i,n){n.x=r,n.y=t,n.width=e,n.height=i,n.r=Math.min(e,i)/4},square:function(r,t,e,i,n){var a=Math.min(e,i);n.x=r,n.y=t,n.width=a,n.height=a},circle:function(r,t,e,i,n){n.cx=r+e/2,n.cy=t+i/2,n.r=Math.min(e,i)/2},diamond:function(r,t,e,i,n){n.cx=r+e/2,n.cy=t+i/2,n.width=e,n.height=i},pin:function(r,t,e,i,n){n.x=r+e/2,n.y=t+i/2,n.width=e,n.height=i},arrow:function(r,t,e,i,n){n.x=r+e/2,n.y=t+i/2,n.width=e,n.height=i},triangle:function(r,t,e,i,n){n.cx=r+e/2,n.cy=t+i/2,n.width=e,n.height=i}},Is={};x(OC,function(r,t){Is[t]=new r});var BC=mt.extend({type:"symbol",shape:{symbolType:"",x:0,y:0,width:0,height:0},calculateTextPosition:function(r,t,e){var i=rm(r,t,e),n=this.shape;return n&&n.symbolType==="pin"&&t.position==="inside"&&(i.y=e.y+e.height*.4),i},buildPath:function(r,t,e){var i=t.symbolType;if(i!=="none"){var n=Is[i];n||(i="rect",n=Is[i]),kC[i](t.x,t.y,t.width,t.height,n.shape),n.buildPath(r,n.shape,e)}}});function NC(r,t){if(this.type!=="image"){var e=this.style;this.__isEmptyBrush?(e.stroke=r,e.fill=t||z.color.neutral00,e.lineWidth=2):this.shape.symbolType==="line"?e.stroke=r:e.fill=r,this.markRedraw()}}function Rr(r,t,e,i,n,a,o){var s=r.indexOf("empty")===0;s&&(r=r.substr(5,1).toLowerCase()+r.substr(6));var l;return r.indexOf("image://")===0?l=ny(r.slice(8),new j(t,e,i,n),o?"center":"cover"):r.indexOf("path://")===0?l=Yh(r.slice(7),{},new j(t,e,i,n),o?"center":"cover"):l=new BC({shape:{symbolType:r,x:t,y:e,width:i,height:n}}),l.__isEmptyBrush=s,l.setColor=NC,a&&l.setColor(a),l}function FC(r){return N(r)||(r=[+r,+r]),[r[0]||0,r[1]||0]}function t0(r,t){if(r!=null)return N(r)||(r=[r,r]),[ge(r[0],t[0])||0,ge(Y(r[1],r[0]),t[1])||0]}var zC=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.hasSymbolVisual=!0,e}return t.prototype.getInitialData=function(e){return px(null,this,{useEncodeDefaulter:!0})},t.prototype.getLegendIcon=function(e){var i=new Pt,n=Rr("line",0,e.itemHeight/2,e.itemWidth,0,e.lineStyle.stroke,!1);i.add(n),n.setStyle(e.lineStyle);var a=this.getData().getVisual("symbol"),o=this.getData().getVisual("symbolRotate"),s=a==="none"?"circle":a,l=e.itemHeight*.8,u=Rr(s,(e.itemWidth-l)/2,(e.itemHeight-l)/2,l,l,e.itemStyle.fill);i.add(u),u.setStyle(e.itemStyle);var f=e.iconRotate==="inherit"?o:e.iconRotate||0;return u.rotation=f*Math.PI/180,u.setOrigin([e.itemWidth/2,e.itemHeight/2]),s.indexOf("empty")>-1&&(u.style.stroke=u.style.fill,u.style.fill=z.color.neutral00,u.style.lineWidth=2),i},t.type="series.line",t.dependencies=["grid","polar"],t.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1,triggerEvent:!1},t})(Er);function e0(r,t){var e=r.mapDimensionsAll("defaultedLabel"),i=e.length;if(i===1){var n=vn(r,t,e[0]);return n!=null?n+"":null}else if(i){for(var a=[],o=0;o=0&&i.push(t[a])}return i.join(" ")}var yv=(function(r){B(t,r);function t(e,i,n,a){var o=r.call(this)||this;return o.updateData(e,i,n,a),o}return t.prototype._createSymbol=function(e,i,n,a,o,s){this.removeAll();var l=Rr(e,-1,-1,2,2,null,s);l.attr({z2:Y(o,100),culling:!0,scaleX:a[0]/2,scaleY:a[1]/2}),l.drift=VC,this._symbolType=e,this.add(l)},t.prototype.stopSymbolAnimation=function(e){this.childAt(0).stopAnimation(null,e)},t.prototype.getSymbolType=function(){return this._symbolType},t.prototype.getSymbolPath=function(){return this.childAt(0)},t.prototype.highlight=function(){bs(this.childAt(0))},t.prototype.downplay=function(){ws(this.childAt(0))},t.prototype.setZ=function(e,i){var n=this.childAt(0);n.zlevel=e,n.z=i},t.prototype.setDraggable=function(e,i){var n=this.childAt(0);n.draggable=e,n.cursor=!i&&e?"move":n.cursor},t.prototype.updateData=function(e,i,n,a){this.silent=!1;var o=e.getItemVisual(i,"symbol")||"circle",s=e.hostModel,l=t.getSymbolSize(e,i),u=t.getSymbolZ2(e,i),f=o!==this._symbolType,h=a&&a.disableAnimation;if(f){var v=e.getItemVisual(i,"symbolKeepAspect");this._createSymbol(o,e,i,l,u,v)}else{var c=this.childAt(0);c.silent=!1;var d={scaleX:l[0]/2,scaleY:l[1]/2};h?c.attr(d):Lr(c,d,s,i),qw(c)}if(this._updateCommon(e,i,l,n,a),f){var c=this.childAt(0);if(!h){var d={scaleX:this._sizeX,scaleY:this._sizeY,style:{opacity:c.style.opacity}};c.scaleX=c.scaleY=0,c.style.opacity=0,Wa(c,d,s,i)}}h&&this.childAt(0).stopAnimation("leave")},t.prototype._updateCommon=function(e,i,n,a,o){var s=this.childAt(0),l=e.hostModel,u,f,h,v,c,d,p,m,g;if(a&&(u=a.emphasisItemStyle,f=a.blurItemStyle,h=a.selectItemStyle,v=a.focus,c=a.blurScope,p=a.labelStatesModels,m=a.hoverScale,g=a.cursorStyle,d=a.emphasisDisabled),!a||e.hasItemOption){var y=a&&a.itemModel?a.itemModel:e.getItemModel(i),_=y.getModel("emphasis");u=_.getModel("itemStyle").getItemStyle(),h=y.getModel(["select","itemStyle"]).getItemStyle(),f=y.getModel(["blur","itemStyle"]).getItemStyle(),v=_.get("focus"),c=_.get("blurScope"),d=_.get("disabled"),p=Qh(y),m=_.getShallow("scale"),g=y.getShallow("cursor")}var S=e.getItemVisual(i,"symbolRotate");s.attr("rotation",(S||0)*Math.PI/180||0);var b=t0(e.getItemVisual(i,"symbolOffset"),n);b&&(s.x=b[0],s.y=b[1]),g&&s.attr("cursor",g);var w=e.getItemVisual(i,"style"),T=w.fill;if(s instanceof kr){var D=s.style;s.useStyle(O({image:D.image,x:D.x,y:D.y,width:D.width,height:D.height},w))}else s.__isEmptyBrush?s.useStyle(O({},w)):s.useStyle(w),s.style.decal=null,s.setColor(T,o&&o.symbolInnerColor),s.style.strokeNoScale=!0;var C=e.getItemVisual(i,"liftZ"),M=this._z2;C!=null?M==null&&(this._z2=s.z2,s.z2+=C):M!=null&&(s.z2=M,this._z2=null);var A=o&&o.useNameLabel;Kh(s,p,{labelFetcher:l,labelDataIndex:i,defaultText:L,inheritColor:T,defaultOpacity:w.opacity});function L(R){return A?e.getName(R):e0(e,R)}this._sizeX=n[0]/2,this._sizeY=n[1]/2;var I=s.ensureState("emphasis");I.style=u,s.ensureState("select").style=h,s.ensureState("blur").style=f;var P=m==null||m===!0?Math.max(1.1,3/this._sizeY):isFinite(m)&&m>0?+m:1;I.scaleX=this._sizeX*P,I.scaleY=this._sizeY*P,this.setSymbolScale(1),Lf(this,v,c,d)},t.prototype.setSymbolScale=function(e){this.scaleX=this.scaleY=e},t.prototype.fadeOut=function(e,i,n){var a=this.childAt(0),o=gt(this).dataIndex,s=n&&n.animation;if(this.silent=a.silent=!0,n&&n.fadeLabel){var l=a.getTextContent();l&&xs(l,{style:{opacity:0}},i,{dataIndex:o,removeOpt:s,cb:function(){a.removeTextContent()}})}else a.removeTextContent();xs(a,{style:{opacity:0},scaleX:0,scaleY:0},i,{dataIndex:o,cb:e,removeOpt:s})},t.getSymbolSize=function(e,i){return FC(e.getItemVisual(i,"symbolSize"))},t.getSymbolZ2=function(e,i){return e.getItemVisual(i,"z2")},t})(Pt);function VC(r,t){this.parent.drift(r,t)}function To(r,t,e,i){return t&&!isNaN(t[0])&&!isNaN(t[1])&&!(i&&i.isIgnore&&i.isIgnore(e))&&!(i&&i.clipShape&&!i.clipShape.contain(t[0],t[1]))&&r.getItemVisual(e,"symbol")!=="none"}function Ad(r){return r!=null&&!Z(r)&&(r={isIgnore:r}),r||{}}function Id(r){var t=r.hostModel,e=t.getModel("emphasis");return{emphasisItemStyle:e.getModel("itemStyle").getItemStyle(),blurItemStyle:t.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:t.getModel(["select","itemStyle"]).getItemStyle(),focus:e.get("focus"),blurScope:e.get("blurScope"),emphasisDisabled:e.get("disabled"),hoverScale:e.get("scale"),labelStatesModels:Qh(t),cursorStyle:t.get("cursor")}}function Ld(r,t,e,i,n,a,o){var s=new r(t,e,i,n);return s.setPosition(a),t.setItemGraphicEl(e,s),o.add(s),s}var GC=(function(){function r(t){this.group=new Pt,this._SymbolCtor=t||yv}return r.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ad(e);var i=this.group,n=t.hostModel,a=this._data,o=this._SymbolCtor,s=e.disableAnimation,l=this._seriesScope=Id(t),u={disableAnimation:s},f=e.getSymbolPoint||function(h){return t.getItemLayout(h)};a||i.removeAll(),t.diff(a).add(function(h){var v=f(h);To(t,v,h,e)&&Ld(o,t,h,l,u,v,i)}).update(function(h,v){var c=a.getItemGraphicEl(v),d=f(h);if(!To(t,d,h,e)){i.remove(c);return}var p=t.getItemVisual(h,"symbol")||"circle",m=c&&c.getSymbolType&&c.getSymbolType();if(!c||m&&m!==p)i.remove(c),c=new o(t,h,l,u),c.setPosition(d);else{c.updateData(t,h,l,u);var g={x:d[0],y:d[1]};s?c.attr(g):Lr(c,g,n)}i.add(c),t.setItemGraphicEl(h,c)}).remove(function(h){var v=a.getItemGraphicEl(h);v&&v.fadeOut(function(){i.remove(v)},n)}).execute(),this._getSymbolPoint=f,this._data=t},r.prototype.updateLayout=function(t){var e=this._data;if(e)for(var i=this,n=e.getStore(),a=0,o=n.count();a0?e=i[0]:i[1]<0&&(e=i[1]),e}function i0(r,t,e,i){var n=NaN;r.stacked&&(n=e.get(e.getCalculationInfo("stackedOverDimension"),i)),isNaN(n)&&(n=r.valueStart);var a=r.baseDataOffset,o=[];return o[a]=e.get(r.baseDim,i),o[1-a]=n,t.dataToPoint(o)}function be(r,t){return!isFinite(r)||!isFinite(t)}var WC=typeof Float32Array!==Ua?Float32Array:void 0;function Ji(r){return YC({ctor:WC},r).arr}function YC(r,t){var e=r.arr,i=r.ctor;if(t>Ac&&(t=Ac),!e||r.typed&&e.length=n||p<0)break;if(be(g,y)){if(l){p+=a;continue}break}if(p===e)r[a>0?"moveTo":"lineTo"](g,y),h=g,v=y;else{var _=g-u,S=y-f;if(_*_+S*S<.5){p+=a;continue}if(o>0){for(var b=p+a,w=t[b*2],T=t[b*2+1];w===g&&T===y&&m=i||be(w,T))c=g,d=y;else{M=w-u,A=T-f;var P=g-u,R=w-g,E=y-f,F=T-y,G=void 0,U=void 0;if(s==="x"){G=Math.abs(P),U=Math.abs(R);var K=M>0?1:-1;c=g-K*G*o,d=y,L=g+K*U*o,I=y}else if(s==="y"){G=Math.abs(E),U=Math.abs(F);var $=A>0?1:-1;c=g,d=y-$*G*o,L=g,I=y+$*U*o}else G=Math.sqrt(P*P+E*E),U=Math.sqrt(R*R+F*F),C=U/(U+G),c=g-M*o*(1-C),d=y-A*o*(1-C),L=g+M*o*C,I=y+A*o*C,L=dr(L,pr(w,g)),I=dr(I,pr(T,y)),L=pr(L,dr(w,g)),I=pr(I,dr(T,y)),M=L-g,A=I-y,c=g-M*G/U,d=y-A*G/U,c=dr(c,pr(u,g)),d=dr(d,pr(f,y)),c=pr(c,dr(u,g)),d=pr(d,dr(f,y)),M=g-c,A=y-d,L=g+M*U/G,I=y+A*U/G}r.bezierCurveTo(h,v,c,d,g,y),h=L,v=I}else r.lineTo(g,y)}u=g,f=y,p+=a}return m}var n0=(function(){function r(){this.smooth=0,this.smoothConstraint=!0}return r})(),$C=(function(r){B(t,r);function t(e){var i=r.call(this,e)||this;return i.type="ec-polyline",i}return t.prototype.getDefaultStyle=function(){return{stroke:z.color.neutral99,fill:null}},t.prototype.getDefaultShape=function(){return new n0},t.prototype.buildPath=function(e,i){var n=i.points,a=0,o=n.length/2;if(i.connectNulls){for(;o>0&&be(n[o*2-2],n[o*2-1]);o--);for(;a=0){var S=u?(d-l)*_+l:(c-s)*_+s;return u?[e,S]:[S,e]}s=c,l=d;break;case o.C:c=a[h++],d=a[h++],p=a[h++],m=a[h++],g=a[h++],y=a[h++];var b=u?hs(s,c,p,g,e,f):hs(l,d,m,y,e,f);if(b>0)for(var w=0;w=0){var S=u?Ot(l,d,m,y,T):Ot(s,c,p,g,T);return u?[e,S]:[S,e]}}s=g,l=y;break}}},t})(mt),qC=(function(r){B(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t})(n0),KC=(function(r){B(t,r);function t(e){var i=r.call(this,e)||this;return i.type="ec-polygon",i}return t.prototype.getDefaultShape=function(){return new qC},t.prototype.buildPath=function(e,i){var n=i.points,a=i.stackedOnPoints,o=0,s=n.length/2,l=i.smoothMonotone;if(i.connectNulls){for(;s>0&&be(n[s*2-2],n[s*2-1]);s--);for(;o=t[0]&&r<=t[1]},getExtent:function(){return this._extents[_e].slice()},getExtentUnsafe:function(r){return this._extents[r]},setExtent:function(r,t){Rd(this._extents,_e,r,t)},setExtent2:function(r,t,e){var i=this._extents;i[r]||(i[r]=i[_e].slice()),Rd(i,r,t,e)},freeze:function(){}};function Rd(r,t,e,i){hn(e,i)&&(r[t][0]=e,r[t][1]=i)}function f0(r){return Es(r)||dn(r)}function Es(r){return r.type==="interval"}function Ya(r){return r.type==="time"}function dn(r){return r.type==="log"}function we(r){return r.type==="ordinal"}function hM(r){var t=Ah(r),e=bn(10,t),i=ar(r/e);return i?i===2?i=3:i===3?i=5:i*=2:i=1,st(i*e,-t)}function xi(r){return br(r)+2}function xo(r,t){return ya(r)/ya(t)}function _u(r,t,e){var i=e&&e.lookup;if(i){for(var n=0;n1&&a/o>2&&(n=Math.round(Math.ceil(n/o)*o)),n!==i[0]&&l(i[0],!0,!0);for(var s=n;s<=i[1];s+=o)l(s,!1,s===i[0]||s===i[1]);s-o!==i[1]&&l(i[1],!0,!0);function l(u,f,h){e({value:u,offInterval:f},h)}}var c0=(function(r){B(t,r);function t(e){var i=r.call(this)||this;i.type="ordinal",i.parse=t.parse,l0(i,t.decoratedMethods);var n=e.ordinalMeta;n||(n=new Zf({})),N(n)&&(n=new Zf({categories:q(n,function(o){return Z(o)?o.value:o})})),i._ordinalMeta=n;var a=_v(null,null,e.extent||[0,n.categories.length-1]);return i._mapper=a.mapper,u0(i),i}return t.parse=function(e){return e==null?e=NaN:V(e)?(e=this._ordinalMeta.getOrdinal(e),e==null&&(e=NaN)):e=ar(e),e},t.prototype.getTicks=function(){var e=[];return v0(this,0,function(i){e.push(i)}),e},t.prototype.getMinorTicks=function(e){},t.prototype.setSortInfo=function(e){if(e==null){this._ordinalNumbersByTick=this._ticksByOrdinalNumber=null;return}for(var i=e.ordinalNumbers,n=this._ordinalNumbersByTick=[],a=this._ticksByOrdinalNumber=[],o=0,s=this._ordinalMeta.categories.length,l=re(s,i.length);o=0&&e=0&&e=0&&eo[0]&&po[1]||!isFinite(p)||!isFinite(o[1]))break}else{if(m>d)break;p=re(p,o[1]),m===d&&(p=o[1])}if(h.push({value:p}),p=st(p+n,s),u){var g=u.calcNiceTickMultiple(p,c);g>=0&&(p=st(p+g*n,s))}if(h.length>0&&p===h[h.length-1].value)break;if(h.length>v)return[]}var y=h.length?h[h.length-1].value:o[1];return a[1]>y&&h.push({value:e.expandToNicedExtent?st(y+n,s):a[1]}),h},t.prototype.getMinorTicks=function(e){return bv(this,e,av(this),this._cfg.interval)},t.prototype.getLabel=function(e,i){if(e==null)return"";var n=i&&i.precision;n==null?n=br(e.value)||0:n==="auto"&&(n=this._cfg.intervalPrecision);var a=st(e.value,n,!0);return Wy(a)},t.type="interval",t})(Ee);Ee.registerClass(on);var cM=function(r,t,e,i){for(;e>>1;r[n][1]16?16:r>7.5?7:r>3.5?4:r>1.5?2:1}function gM(r){var t=30*ye;return r/=t,r>6?6:r>3?3:r>2?2:1}function mM(r){return r/=la,r>12?12:r>6?6:r>3.5?4:r>2?2:1}function Od(r,t){return r/=t?sv:ov,r>30?30:r>20?20:r>15?15:r>10?10:r>5?5:r>2?2:1}function yM(r){return dt(Ih(r,!0),1)}function _M(r,t,e){var i=Math.max(0,lt(yi,t)-1);return Hf(new Date(r),yi[i],e).getTime()}function SM(r,t){var e=new Date(0);e[r](1);var i=e.getTime();e[r](1+t);var n=e.getTime()-i;return function(a,o){return Math.max(0,Math.round((o-a)/n))}}function bM(r,t,e,i,n,a){var o=3e3,s=Nx,l=0;function u(E,F,G,U,K,$,tt){for(var X=SM(K,E),H=F,it=new Date(H);Ho));)if(it[K](it[U]()+E),H=it.getTime(),a){var at=a.calcNiceTickMultiple(H,X);at>0&&(it[K](it[U]()+at*E),H=it.getTime())}tt.push({value:H,notAdd:H>i[1]})}function f(E,F,G){var U=[],K=!F.length;if(!dM(ua(E),i[0],i[1],e)){K&&(F=[{value:_M(i[0],E,e)},{value:i[1]}]);for(var $=0;$=i[0]&&tt<=i[1]&&u(H,tt,X,it,at,Vt,U),E==="year"&&G.length>1&&$===0&&G.unshift({value:G[0].value-H})}}for(var $=0;$=i[0]&&S<=i[1]&&c++)}var b=n/t;if(c>b*1.5&&d>b/1.5||(h.push(y),c>b||r===s[p]))break}v=[]}}}for(var w=zt(q(h,function(E){return zt(E,function(F){return F.value>=i[0]&&F.value<=i[1]&&!F.notAdd})}),function(E){return E.length>0}),T=w.length-1,D=[],p=0;pi[0])&&D.unshift({value:i[0],time:{level:0,upperTimeUnit:P,lowerTimeUnit:P},notNice:!0}),(!I||I.values&&(a=s);var l=Co.length,u=Math.min(cM(Co,a,0,l),l-1),f=Co[u][1],h=Co[Math.max(u-1,0)][0];r.setTimeInterval({approxInterval:a,interval:f,minLevelUnit:h})};Ee.registerClass(d0);var Mo=0,Do=1,p0=(function(r){B(t,r);function t(e){var i=r.call(this)||this;i.type="log",i.parse=on.parse,i.base=e.logBase||10;var n=[],a=[];i._lookup={from:n,to:a},n[Mo]=n[Do]=a[Mo]=a[Do]=NaN,l0(i,t.mapperMethods),e.breakOption;var o={};return i.powStub=new on({breakParsed:o.original}),i.intervalStub=new on({breakParsed:o.transformed}),u0(i,i.intervalStub),i}return t.prototype.getTicks=function(e){var i=this.base,n=this.powStub,a=this.intervalStub,o=a.getExtent(),s=n.getExtent(),l={lookup:{from:o,to:s}};return q(a.getTicks(e||{}),function(u){var f=u.value,h=_u(f,i,l),v;return{value:h,break:v}},this)},t.prototype.getMinorTicks=function(e){return bv(this,e,av(this.powStub),this.intervalStub.getConfig().interval)},t.prototype.getLabel=function(e,i){return this.intervalStub.getLabel(e,i)},t.type="log",t.mapperMethods={needTransform:function(){return!0},normalize:function(e){return this.intervalStub.normalize(xo(e,this.base))},scale:function(e){return _u(this.intervalStub.scale(e),this.base,null)},transformIn:function(e,i){return e=xo(e,this.base),i&&i.depth===Ls?e:this.intervalStub.transformIn(e,i)},transformOut:function(e,i){var n=i?i.depth:null;return kd.depth=n,Bd.lookup=this._lookup,_u(n===Ls?e:this.intervalStub.transformOut(e,kd),this.base,Bd)},contain:function(e){return this.powStub.contain(e)},setExtent:function(e,i){this.setExtent2(_e,e,i)},setExtent2:function(e,i,n){if(!(!hn(i,n)||i<=0||n<=0)){var a=Nd,o=Nd;if(e===_e){var s=this._lookup;a=s.to,o=s.from}this.powStub.setExtent2(e,a[Mo]=i,a[Do]=n);var l=this.base;this.intervalStub.setExtent2(e,o[Mo]=xo(i,l),o[Do]=xo(n,l))}},getFilter:function(){return{g:0}},sanitize:function(e,i){return hn(i[0],i[1])&&$e(e)&&e<=0&&(e=i[0]),e},getDefaultStartValue:function(){return 1},getExtent:function(){return this.powStub.getExtent()},getExtentUnsafe:function(e,i){return i===null?this.powStub.getExtentUnsafe(e,null):this.intervalStub.getExtentUnsafe(e,i)}},t})(Ee);Ee.registerClass(p0);var kd={},Bd={},Nd=[],g0={value:1,category:1,time:1,log:1},m0=ft();function TM(r){var t=r.get("type");return(t==null||!jt(g0,t)&&!Ee.getClass(t))&&(t="value"),t}function xM(r,t,e){var i;switch(t){case"category":return new c0({ordinalMeta:r.getOrdinalMeta?r.getOrdinalMeta():r.getCategories(),extent:me()});case"time":return new d0({locale:r.ecModel.getLocaleModel(),useUTC:r.ecModel.get("useUTC"),breakOption:i});case"log":return new p0({logBase:r.get("logBase"),breakOption:i});case"value":return new on({breakOption:i});default:return new(Ee.getClass(t)||on)({})}}function CM(r,t,e){var i=r.getExtentUnsafe(_e,null),n=i[0],a=i[1];return hn(n,a)?n===t||a===t?DM:nt?MM:Xf:Xf}var MM=1,DM=2,Xf=3;function AM(r){m0(r).noOnMyZero=!0}function IM(r){return m0(r).noOnMyZero}function wl(r){var t=r.getLabelModel().get("formatter");if(r.type==="time"){var e=Fx(t);return function(n,a){return r.scale.getFormattedLabel(n,a,e)}}else{if(V(t))return function(n){var a=r.scale.getLabel(n),o=t.replace("{value}",a??"");return o};if(J(t)){if(r.type==="category")return function(n,a){return t(Rs(r,n),n.value-r.scale.getExtent()[0],null)};var i=yl();return function(n,a){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),t(Rs(r,n),a,o)}}else return function(n){return r.scale.getLabel(n)}}}function Rs(r,t){var e=r.scale;return we(e)?e.getLabel(t):t.value}function wv(r){var t=r.get("interval");return t??"auto"}function LM(r){return r.type==="category"&&wv(r.getLabelModel())===0}function PM(r,t){var e={};return x(r.mapDimensionsAll(t),function(i){e[vx(r,i)]=!0}),St(e)}function pn(r){return r==="middle"||r==="center"}function La(r){return r.getShallow("show")}function EM(r,t,e){var i=r.get("breaks",!0);i==null}function y0(r,t,e,i,n,a){var o=dn(r),s=o?r.intervalStub:r;if(s.setExtent(i[0],i[1]),o){var l=r.powStub,u={depth:Ls},f=r.transformOut(i[0],u),h=r.transformOut(i[1],u),v=vM(e,i);t[0]&&!v[0]&&(f=n[0]),t[1]&&!v[1]&&(h=n[1]),l.setExtent(f,h)}s.setConfig(a)}function Za(r,t){return we(r)?r.getRawOrdinalNumber(t.value):t.value}function _0(r,t){return we(r)&&!!t.get("boundaryGap")}function Fd(r,t){if(r.length===t.length){for(var e=0;et){a?e.push(o(a,l,t)):n&&e.push(o(n,l,0),o(n,l,t));break}else n&&(e.push(o(n,l,0)),n=null),e.push(l),a=l}return e}function kM(r,t,e){var i=r.getVisual("visualMeta");if(!(!i||!i.length||!r.count())&&t.type==="cartesian2d"){for(var n,a,o=i.length-1;o>=0;o--){var s=r.getDimensionInfo(i[o].dimension);if(n=s&&s.coordDim,n==="x"||n==="y"){a=i[o];break}}if(a){var l=t.getAxis(n),u=q(a.stops,function(_){return{coord:l.toGlobalCoord(l.dataToCoord(_.value)),color:_.color}}),f=u.length,h=a.outerColors.slice();f&&u[0].coord>u[f-1].coord&&(u.reverse(),h.reverse());var v=OM(u,n==="x"?e.getWidth():e.getHeight()),c=v.length;if(!c&&f)return u[0].coord<0?h[1]?h[1]:u[f-1].color:h[0]?h[0]:u[0].color;var d=10,p=v[0].coord-d,m=v[c-1].coord+d,g=m-p;if(g<.001)return"transparent";x(v,function(_){_.offset=(_.coord-p)/g}),v.push({offset:c?v[c-1].offset:.5,color:h[1]||"transparent"}),v.unshift({offset:c?v[0].offset:.5,color:h[0]||"transparent"});var y=new jm(0,0,0,0,v,!0);return y[n]=p,y[n+"2"]=m,y}}}function BM(r,t,e){var i=r.get("showAllSymbol"),n=i==="auto";if(!(i&&!n)){var a=e.getAxesByScale("ordinal")[0];if(a&&!(n&&NM(a,t))){var o=t.mapDimension(a.dim),s={};return x(a.getViewLabels(),function(l){l.tick.offInterval||(s[Za(a.scale,l.tick)]=1)}),function(l){return!s.hasOwnProperty(t.get(o,l))}}}}function NM(r,t){var e=r.getExtent(),i=Math.abs(e[1]-e[0])/r.scale.count();isNaN(i)&&(i=0);for(var n=t.count(),a=Math.max(1,Math.round(n/5)),o=0;oi)return!1;return!0}function FM(r){for(var t=r.length/2;t>0&&be(r[t*2-2],r[t*2-1]);t--);return t-1}function Gd(r,t){return[r[t*2],r[t*2+1]]}function zM(r,t,e){for(var i=r.length/2,n=e==="x"?0:1,a,o,s=0,l=-1,u=0;u=t||a>=t&&o<=t){l=u;break}s=u,a=o}return{range:[s,l],t:(t-a)/(o-a)}}function S0(r){if(r.get(["endLabel","show"]))return!0;for(var t=0;t0&&e.get(["emphasis","lineStyle","width"])==="bolder"){var U=d.getState("emphasis").style;U.lineWidth=+d.style.lineWidth+1}gt(d).seriesIndex=e.seriesIndex,Lf(d,E,F,G);var K=Vd(e.get("smooth")),$=e.get("smoothMonotone");if(d.setShape({smooth:K,smoothMonotone:$,connectNulls:T}),p){var tt=s.getCalculationInfo("stackedOnSeries"),X=0;p.useStyle(vt(u.getAreaStyle(),{fill:L,opacity:.7,lineJoin:"bevel",decal:s.getVisual("style").decal})),tt&&(X=Vd(tt.get("smooth"))),p.setShape({smooth:K,stackedOnSmooth:X,smoothMonotone:$,connectNulls:T}),Gc(p,e,"areaStyle"),gt(p).seriesIndex=e.seriesIndex,Lf(p,E,F,G)}var H=this._changePolyState;s.eachItemGraphicEl(function(_t){_t&&(_t.onHoverStateChange=H)}),this._polyline.onHoverStateChange=H,this._data=s,this._coordSys=a,this._stackedOnPoints=b,this._points=f,this._step=M,this._valueOrigin=_;var it=e.get("triggerEvent"),at=e.get("triggerLineEvent"),Vt=at===!0||it===!0||it==="line",Te=at===!0||it===!0||it==="area";this.packEventData(e,d,Vt),p&&this.packEventData(e,p,Te)},t.prototype.packEventData=function(e,i,n){gt(i).eventData=n?{componentType:"series",componentSubType:"line",componentIndex:e.componentIndex,seriesIndex:e.seriesIndex,seriesName:e.name,seriesType:"line",selfType:i===this._polygon?"area":"line"}:null},t.prototype.highlight=function(e,i,n,a){var o=e.getData(),s=bi(o,a);if(this._changePolyState("emphasis"),!(s instanceof Array)&&s!=null&&s>=0){var l=o.getLayout("points"),u=o.getItemGraphicEl(s);if(!u){var f=l[s*2],h=l[s*2+1];if(be(f,h)||this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(f,h))return;var v=e.get("zlevel")||0,c=e.get("z")||0;u=new yv(o,s),u.x=f,u.y=h,u.setZ(v,c);var d=u.getSymbolPath().getTextContent();d&&(d.zlevel=v,d.z=c,d.z2=this._polyline.z2+1),u.__temp=!0,o.setItemGraphicEl(s,u),u.stopSymbolAnimation(!0),this.group.add(u)}u.highlight()}else Xe.prototype.highlight.call(this,e,i,n,a)},t.prototype.downplay=function(e,i,n,a){var o=e.getData(),s=bi(o,a);if(this._changePolyState("normal"),s!=null&&s>=0){var l=o.getItemGraphicEl(s);l&&(l.__temp?(o.setItemGraphicEl(s,null),this.group.remove(l)):l.downplay())}else Xe.prototype.downplay.call(this,e,i,n,a)},t.prototype._changePolyState=function(e){var i=this._polygon;Nc(this._polyline,e),i&&Nc(i,e)},t.prototype._newPolyline=function(e){var i=this._polyline;return i&&this._lineGroup.remove(i),i=new $C({shape:{points:e},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(i),this._polyline=i,i},t.prototype._newPolygon=function(e,i){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new KC({shape:{points:e,stackedOnPoints:i},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},t.prototype._initSymbolLabelAnimation=function(e,i,n){var a,o,s=i.getBaseAxis(),l=s.inverse;i.type==="cartesian2d"?(a=s.isHorizontal(),o=!1):i.type==="polar"&&(a=s.dim==="angle",o=!0);var u=e.hostModel,f=u.get("animationDuration");J(f)&&(f=f(null));var h=u.get("animationDelay")||0,v=J(h)?h(null):h;e.eachItemGraphicEl(function(c,d){var p=c;if(p){var m=[c.x,c.y],g=void 0,y=void 0,_=void 0;if(n)if(o){var S=n,b=i.pointToCoord(m);a?(g=S.startAngle,y=S.endAngle,_=-b[1]/180*Math.PI):(g=S.r0,y=S.r,_=b[0])}else{var w=n;a?(g=w.x,y=w.x+w.width,_=c.x):(g=w.y+w.height,y=w.y,_=c.y)}var T=y===g?0:(_-g)/(y-g);l&&(T=1-T);var D=J(h)?h(d):f*T+v,C=p.getSymbolPath(),M=C.getTextContent();p.attr({scaleX:0,scaleY:0}),p.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:D}),M&&M.animateFrom({style:{opacity:0}},{duration:300,delay:D}),C.disableLabelAnimation=!0}})},t.prototype._initOrUpdateEndLabel=function(e,i,n){var a=e.getModel("endLabel");if(S0(e)){var o=e.getData(),s=this._polyline,l=o.getLayout("points");if(!l){s.removeTextContent(),this._endLabel=null;return}var u=this._endLabel;u||(u=this._endLabel=new Xt({z2:200}),u.ignoreClip=!0,s.setTextContent(this._endLabel),s.disableLabelAnimation=!0);var f=FM(l);f>=0&&(Kh(s,Qh(e,"endLabel"),{inheritColor:n,labelFetcher:e,labelDataIndex:f,defaultText:function(h,v,c){return c!=null?HC(o,c):e0(o,h)},enableTextSetter:!0},HM(a,i)),s.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},t.prototype._endLabelOnDuring=function(e,i,n,a,o,s,l){var u=this._endLabel,f=this._polyline;if(u){e<1&&a.originalX==null&&(a.originalX=u.x,a.originalY=u.y);var h=n.getLayout("points"),v=n.hostModel,c=v.get("connectNulls"),d=s.get("precision"),p=s.get("distance")||0,m=l.getBaseAxis(),g=m.isHorizontal(),y=m.inverse,_=i.shape,S=y?g?_.x:_.y+_.height:g?_.x+_.width:_.y,b=(g?p:0)*(y?-1:1),w=(g?0:-p)*(y?-1:1),T=g?"x":"y",D=zM(h,S,T),C=D.range,M=C[1]-C[0],A=void 0;if(M>=1){if(M>1&&!c){var L=Gd(h,C[0]);u.attr({x:L[0]+b,y:L[1]+w}),o&&(A=v.getRawValue(C[0]))}else{var L=f.getPointOn(S,T);L&&u.attr({x:L[0]+b,y:L[1]+w});var I=v.getRawValue(C[0]),P=v.getRawValue(C[1]);o&&(A=Fb(n,d,I,P,D.t))}a.lastFrameIndex=C[0]}else{var R=e===1||a.lastFrameIndex>0?C[0]:0,L=Gd(h,R);o&&(A=v.getRawValue(R)),u.attr({x:L[0]+b,y:L[1]+w})}if(o){var E=cy(u);typeof E.setLabelText=="function"&&E.setLabelText(A)}}},t.prototype._doUpdateAnimation=function(e,i,n,a,o,s,l){var u=this._polyline,f=this._polygon,h=e.hostModel,v=XC(this._data,e,this._stackedOnPoints,i,this._coordSys,n,this._valueOrigin),c=v.current,d=v.stackedOnCurrent,p=v.next,m=v.stackedOnNext;if(o&&(d=gr(v.stackedOnCurrent,v.current,n,o,l),c=gr(v.current,null,n,o,l),m=gr(v.stackedOnNext,v.next,n,o,l),p=gr(v.next,null,n,o,l)),Hd(c,p)>3e3||f&&Hd(d,m)>3e3){u.stopAnimation(),u.setShape({points:p}),f&&(f.stopAnimation(),f.setShape({points:p,stackedOnPoints:m}));return}u.shape.__points=v.current,u.shape.points=c;var g={shape:{points:p}};v.current!==c&&(g.shape.__points=v.next),u.stopAnimation(),Lr(u,g,h),f&&(f.setShape({points:c,stackedOnPoints:d}),f.stopAnimation(),Lr(f,{shape:{stackedOnPoints:m}},h),u.shape.points!==f.shape.points&&(f.shape.points=u.shape.points));for(var y=[],_=v.status,S=0;S<_.length;S++){var b=_[S].cmd;if(b==="="){var w=e.getItemGraphicEl(_[S].idx1);w&&y.push({el:w,ptIdx:S})}}u.animators&&u.animators.length&&u.animators[0].during(function(){f&&f.dirtyShape();for(var T=u.shape.__points,D=0;Dt&&(t=r[e]);return isFinite(t)?t:NaN},min:function(r){for(var t=1/0,e=0;e10&&o.type==="cartesian2d"&&a){var l=o.getBaseAxis(),u=o.getOtherAxis(l),f=l.getExtent(),h=i.getDevicePixelRatio(),v=Math.abs(f[1]-f[0])*(h||1),c=Math.round(s/v);if(isFinite(c)&&c>1){a==="lttb"?t.setData(n.lttbDownSample(n.mapDimension(u.dim),1/c)):a==="minmax"&&t.setData(n.minmaxDownSample(n.mapDimension(u.dim),1/c));var d=void 0;V(a)?d=UM[a]:J(a)&&(d=a),d&&t.setData(n.downSample(n.mapDimension(u.dim),1/c,d,WM))}}}}}function OP(r){r.registerChartView(VM),r.registerSeriesModel(zC),r.registerLayout(GM("line")),r.registerVisual({seriesType:"line",reset:function(t){var e=t.getData(),i=t.getModel("lineStyle").getLineStyle();i&&!i.stroke&&(i.stroke=e.getVisual("style").fill),e.setVisual("legendLineStyle",i)}}),r.registerProcessor(r.PRIORITY.PROCESSOR.STATISTIC,YM("line"))}var ZM=ft(),va=ft(),De={estimate:1,determine:2};function Os(r){return{out:{noPxChangeTryDetermine:[]},kind:r}}function XM(r,t){var e=r.getLabelModel().get("customValues");if(e){var i=r.scale;return{labels:q(b0(e,i),function(n,a){return{formattedLabel:wl(r)(n,a),rawLabel:i.getLabel(n),tick:n}})}}return r.type==="category"?qM(r,t):QM(r)}function $M(r,t,e){var i=r.scale,n=r.getTickModel().get("customValues");return n?{ticks:b0(n,i)}:r.type==="category"?KM(r,t):{ticks:i.getTicks(e)}}function b0(r,t){var e=t.getExtent(),i=[];return x(r,function(n){n=t.parse(n),n>=e[0]&&n<=e[1]&&i.push(n)}),Rh(i,Ub,null),Sr(i),q(i,function(n){return{value:n}})}function qM(r,t){var e=r.getLabelModel(),i=w0(r,e,t);return!e.get("show")||r.scale.isBlank()?{labels:[]}:i}function w0(r,t,e){var i=jM(r),n=wv(t),a=e.kind===De.estimate;if(!a){var o=x0(i,n);if(o)return o}var s,l;J(n)?s=ks(r,n,!1):(l=n==="auto"?tD(r,e):n,s=ks(r,l,!1));var u={labels:s,labelCategoryInterval:l};return a?e.out.noPxChangeTryDetermine.push(function(){return $f(i,n,u),!0}):$f(i,n,u),u}function KM(r,t){var e=JM(r),i=wv(t),n=x0(e,i);if(n)return n;var a,o;if((!t.get("show")||r.scale.isBlank())&&(a=[]),J(i))a=ks(r,i,!0);else if(i==="auto"){var s=w0(r,r.getLabelModel(),Os(De.determine));o=s.labelCategoryInterval,a=q(s.labels,function(l){return l.tick})}else o=i,a=ks(r,o,!0);return $f(e,i,{ticks:a,tickCategoryInterval:o})}function QM(r){var t=r.scale.getTicks(),e=wl(r);return{labels:q(t,function(i,n){return{formattedLabel:e(i,n),rawLabel:r.scale.getLabel(i),tick:i}})}}var JM=T0("axisTick"),jM=T0("axisLabel");function T0(r){return function(e){return va(e)[r]||(va(e)[r]={list:[]})}}function x0(r,t){for(var e=0;ef&&(u=Math.max(1,Math.floor(l/f)));for(var h=s[0],v=r.dataToCoord(h+1)-r.dataToCoord(h),c=Math.abs(v*Math.cos(a)),d=Math.abs(v*Math.sin(a)),p=0,m=0;h<=s[1];h+=u){var g=0,y=0,_=em(n({value:h}),i.font,"center","top");g=_.width*1.3,y=_.height*1.3,p=Math.max(p,g,7),m=Math.max(m,y,7)}var S=p/c,b=m/d;isNaN(S)&&(S=1/0),isNaN(b)&&(b=1/0);var w=Math.max(0,Math.floor(Math.min(S,b)));if(e===De.estimate)return t.out.noPxChangeTryDetermine.push(Q(rD,null,r,w,l)),w;var T=C0(r,w,l);return T??w}function rD(r,t,e){return C0(r,t,e)==null}function C0(r,t,e){var i=ZM(r.model),n=r.getExtent(),a=i.lastAutoInterval,o=i.lastTickCount;if(a!=null&&o!=null&&Math.abs(a-t)<=1&&Math.abs(o-e)<=1&&a>t&&i.axisExtent0===n[0]&&i.axisExtent1===n[1])return a;i.lastTickCount=e,i.lastAutoInterval=t,i.axisExtent0=n[0],i.axisExtent1=n[1]}function iD(r){var t=r.getLabelModel();return{axisRotate:r.getRotate?r.getRotate():r.isHorizontal&&!r.isHorizontal()?90:0,labelRotate:t.get("rotate")||0,font:t.getFont()}}function ks(r,t,e){var i=wl(r),n=r.scale,a=[],o=J(t);return v0(n,o?0:t,function(s,l){var u=n.getLabel(s);if(o){var f=!!t(s.value,u);if(s.offInterval=!f,!f&&!l)return}a.push(e?s:{formattedLabel:i(s),rawLabel:u,tick:s})}),a}var Tl=ft();function nD(r){Tl(r).prepare={}}function aD(r){Tl(r).fullUpdate={}}function oD(r){return Tl(r).prepare}function Xa(r){return Tl(r).fullUpdate}Em();var Ud="|&",$a=ft(),sD=-2;ft();function lD(r,t){var e=r.model,i=$a(Xa(e.ecModel)).keyed,n=i&&i.get(t);return n&&n.get(e.uid)}function uD(r,t){return M0(lD(r,t))}function fD(r,t){var e=[];return hD(r.model.ecModel,function(i){for(var n=0;n0?(l>o&&(o=l),a=!1):l===sD&&(a=!0))}),$e(e)&&e>0&&$e(o)?(r.w=i/e*o,r.w2=o):a&&(r.w=i*dD,r.w2=r.w*e/i)}var Xd=[0,1],mD=(function(){function r(t,e,i){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=i||[0,0]}return r.prototype.contain=function(t){var e=this._extent,i=Math.min(e[0],e[1]),n=Math.max(e[0],e[1]);return t>=i&&t<=n},r.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},r.prototype.getExtent=function(){return this._extent.slice()},r.prototype.setExtent=function(t,e){var i=this._extent;i[0]=t,i[1]=e},r.prototype.dataToCoord=function(t,e){var i=this.scale;return t=i.normalize(i.parse(t)),At(t,Xd,$d(this),e)},r.prototype.coordToData=function(t,e){var i=At(t,$d(this),Xd,e);return this.scale.scale(i)},r.prototype.pointToData=function(t,e){},r.prototype.getTicksCoords=function(t){t=t||{};var e=t.tickModel||this.getTickModel(),i=$M(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}),n=q(i.ticks,function(s){return{coord:this.dataToCoord(Za(this.scale,s)),tick:s}},this),a=e.get("alignWithLabel"),o=yD(this,n,a);return q(n,function(s){return{coord:s.coord,tickValue:s.tick.value,onBand:o}})},r.prototype.getMinorTicksCoords=function(){if(we(this.scale))return[];var t=this.model.getModel("minorTick"),e=t.get("splitNumber");e>0&&e<100||(e=5);var i=this.scale.getMinorTicks(e),n=q(i,function(a){return q(a,function(o){return{coord:this.dataToCoord(o),tickValue:o}},this)},this);return n},r.prototype.getViewLabels=function(t){return t=t||Os(De.determine),XM(this,t).labels},r.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},r.prototype.getTickModel=function(){return this.model.getModel("axisTick")},r.prototype.getBandWidth=function(){return xl(this,{min:1}).w},r.prototype.calculateCategoryInterval=function(t){return t=t||Os(De.determine),eD(this,t)},r})();function $d(r){var t=r.getExtent();if(r.onBand){var e=t[1]-t[0],i=e/r.scale.count()/2;t[0]+=i,t[1]-=i}return t}function yD(r,t,e){var i=t.length;if(!r.onBand||e||!i)return!1;var n=xl(r).w;if(!n)return!1;x(t,function(s){s.coord-=n/2});var a=r.scale.getExtent(),o=t[i-1];return o.tick.offInterval&&t.pop(),t.push({coord:o.coord+n,tick:{value:a[1]+1}}),!0}var _D=(function(r){B(t,r);function t(e,i,n,a,o){var s=r.call(this,e,i,n)||this;return s.index=0,s.type=a||"value",s.position=o||"bottom",s}return t.prototype.isHorizontal=function(){var e=this.position;return e==="top"||e==="bottom"},t.prototype.getGlobalExtent=function(e){var i=this.getExtent();return i[0]=this.toGlobalCoord(i[0]),i[1]=this.toGlobalCoord(i[1]),e&&i[0]>i[1]&&i.reverse(),i},t.prototype.pointToData=function(e,i){return this.coordToData(this.toLocalCoord(e[this.dim==="x"?0:1]),i)},t.prototype.setCategorySortInfo=function(e){if(this.type!=="category")return!1;this.model.option.categorySortInfo=e,this.scale.setSortInfo(e)},t})(mD),qd=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"],SD=1,Bs=2,A0=SD|Bs;function Ns(r,t,e){e=e||A0,t?r.dirty|=e:r.dirty&=~e}function I0(r,t){return t=t||A0,r.dirty==null||!!(r.dirty&t)}function Or(r){if(r)return I0(r)&&bD(r,r.label,r),r}function bD(r,t,e){var i=t.getComputedTransform();r.transform=qh(r.transform,i);var n=r.localRect=Ta(r.localRect,t.getBoundingRect()),a=t.style,o=a.margin,s=e&&e.marginForce,l=e&&e.minMarginForce,u=e&&e.marginDefault,f=a.__marginType;f==null&&u&&(o=u,f=Qi.textMargin);for(var h=0;h<4;h++)bu[h]=f===Qi.minMargin&&l&&l[h]!=null?l[h]:s&&s[h]!=null?s[h]:o?o[h]:0;f===Qi.textMargin&&Cs(n,bu,!1,!1);var v=r.rect=Ta(r.rect,n);return i&&v.applyTransform(i),f===Qi.minMargin&&Cs(v,bu,!1,!1),r.axisAligned=$h(i),(r.label=r.label||{}).ignore=t.ignore,Ns(r,!1),Ns(r,!0,Bs),r}var bu=[0,0,0,0];function wD(r,t,e){return r.transform=qh(r.transform,e),r.localRect=Ta(r.localRect,t),r.rect=Ta(r.rect,t),e&&r.rect.applyTransform(e),r.axisAligned=$h(e),r.obb=void 0,(r.label=r.label||{}).ignore=!1,r}function TD(r,t){if(r){r.label.x+=t.x,r.label.y+=t.y,r.label.markRedraw();var e=r.transform;e&&(e[4]+=t.x,e[5]+=t.y);var i=r.rect;i&&(i.x+=t.x,i.y+=t.y);var n=r.obb;n&&n.fromBoundingRect(r.localRect,e)}}function Kd(r,t){for(var e=0;e.1?"x":"y",f=a.transGroup[u];if(o.sort(function(d,p){return Math.abs(d.label[u]-f)-Math.abs(p.label[u]-f)}),l&&s){var h=n.getExtent(),v=Math.min(h[0],h[1]),c=Math.max(h[0],h[1])-v;s.union(new j(v,0,c,1))}a.stOccupiedRect=s,a.labelInfoList=o}var Rn=Ve(),Ao=new j(0,0,0,0),E0=function(r,t,e,i,n,a){if(pn(r.nameLocation)){var o=a.stOccupiedRect;o&&R0(wD({},o,a.transGroup.transform),i,n)}else O0(a.labelInfoList,a.dirVec,i,n)};function R0(r,t,e){var i=new pt;Tv(r,t,i,{direction:Math.atan2(e.y,e.x),bidirectional:!1,touchThreshold:.05})&&TD(t,i)}function O0(r,t,e,i){for(var n=pt.dot(i,t)>=0,a=0,o=r.length;a0?"top":"bottom",a="center"):ms(n-wr)?(o=i>0?"bottom":"top",a="center"):(o="middle",n>0&&n0?"right":"left":a=i>0?"left":"right"),{rotation:n,textAlign:a,textVerticalAlign:o}},r.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},r.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},r})(),PD=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],ED={axisLine:function(r,t,e,i,n,a,o){var s=i.get(["axisLine","show"]);if(s==="auto"&&(s=!0,r.raw.axisLineAutoShow!=null&&(s=!!r.raw.axisLineAutoShow)),!!s){var l=i.axis.getExtent(),u=a.transform,f=[l[0],0],h=[l[1],0],v=f[0]>h[0];u&&(Se(f,f,u),Se(h,h,u));var c=O({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),d={strokeContainThreshold:r.raw.strokeContainThreshold||5,silent:!0,z2:1,style:c};if(i.get(["axisLine","breakLine"])&&As(i.axis.scale))MD().buildAxisBreakLine(i,n,a,d);else{var p=new Ir(O({shape:{x1:f[0],y1:f[1],x2:h[0],y2:h[1]}},d));ba(p.shape,p.style.lineWidth),p.anid="line",n.add(p)}var m=i.get(["axisLine","symbol"]);if(m!=null){var g=i.get(["axisLine","symbolSize"]);V(m)&&(m=[m,m]),(V(g)||bt(g))&&(g=[g,g]);var y=t0(i.get(["axisLine","symbolOffset"])||0,g),_=g[0],S=g[1];x([{rotate:r.rotation+Math.PI/2,offset:y[0],r:0},{rotate:r.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((f[0]-h[0])*(f[0]-h[0])+(f[1]-h[1])*(f[1]-h[1]))}],function(b,w){if(m[w]!=="none"&&m[w]!=null){var T=Rr(m[w],-_/2,-S/2,_,S,c.stroke,!0),D=b.r+b.offset,C=v?h:f;T.attr({rotation:b.rotate,x:C[0]+D*Math.cos(r.rotation),y:C[1]-D*Math.sin(r.rotation),silent:!0,z2:11}),n.add(T)}})}}},axisTickLabelEstimate:function(r,t,e,i,n,a,o,s){var l=jd(t,n,s);l&&Jd(r,t,e,i,n,a,o,De.estimate)},axisTickLabelDetermine:function(r,t,e,i,n,a,o,s){var l=jd(t,n,s);l&&Jd(r,t,e,i,n,a,o,De.determine);var u=BD(r,n,a,i);kD(r,t.labelLayoutList,u),ND(r,n,a,i,r.tickDirection)},axisName:function(r,t,e,i,n,a,o,s){var l=e.ensureRecord(i);t.nameEl&&(n.remove(t.nameEl),t.nameEl=l.nameLayout=l.nameLocation=null);var u=r.axisName;if(xv(u)){var f=r.nameLocation,h=r.nameDirection,v=i.getModel("nameTextStyle"),c=i.get("nameGap")||0,d=i.axis.getExtent(),p=i.axis.inverse?-1:1,m=new pt(0,0),g=new pt(0,0);f==="start"?(m.x=d[0]-p*c,g.x=-p):f==="end"?(m.x=d[1]+p*c,g.x=p):(m.x=(d[0]+d[1])/2,m.y=r.labelOffset+h*c,g.y=h);var y=Ve();g.transform(Ch(y,y,r.rotation));var _=i.get("nameRotate");_!=null&&(_=_*wr/180);var S,b;pn(f)?S=Dr.innerTextLayout(r.rotation,_??r.rotation,h):(S=RD(r.rotation,f,_||0,d),b=r.raw.axisNameAvailableWidth,b!=null&&(b=Math.abs(b/Math.sin(S.rotation)),!isFinite(b)&&(b=null)));var w=v.getFont(),T=i.get("nameTruncate",!0)||{},D=T.ellipsis,C=ls(r.raw.nameTruncateMaxWidth,T.maxWidth,b),M=s.nameMarginLevel||0,A=new Xt({x:m.x,y:m.y,rotation:S.rotation,silent:Dr.isLabelSilent(i),style:wi(v,{text:u,font:w,overflow:"truncate",width:C,ellipsis:D,fill:v.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:v.get("align")||S.textAlign,verticalAlign:v.get("verticalAlign")||S.textVerticalAlign}),z2:1});if(dl({el:A,componentModel:i,itemName:u}),A.__fullText=u,A.anid="name",i.get("triggerEvent")){var L=Dr.makeAxisEventDataBase(i);L.targetType="axisName",L.name=u,gt(A).eventData=L}a.add(A),A.updateTransform(),t.nameEl=A;var I=l.nameLayout=Or({label:A,priority:A.z2,defaultAttr:{ignore:A.ignore},marginDefault:pn(f)?AD[M]:ID[M]});if(l.nameLocation=f,n.add(A),A.decomposeTransform(),r.shouldNameMoveOverlap&&I){var P=e.ensureRecord(i);e.resolveAxisNameOverlap(r,e,i,I,g,P)}}}};function Jd(r,t,e,i,n,a,o,s){B0(t)||FD(r,t,n,s,i,o);var l=t.labelLayoutList;zD(r,i,l,a),r.rotation;var u=r.optionHideOverlap;OD(i,l,u),u&&xD(zt(l,function(f){return f&&!f.label.ignore})),LD(r,e,i,l)}function RD(r,t,e,i){var n=wm(e-r),a,o,s=i[0]>i[1],l=t==="start"&&!s||t!=="start"&&s;return ms(n-wr/2)?(o=l?"bottom":"top",a="center"):ms(n-wr*1.5)?(o=l?"top":"bottom",a="center"):(o="middle",nwr/2?a=l?"left":"right":a=l?"right":"left"),{rotation:n,textAlign:a,textVerticalAlign:o}}function OD(r,t,e){var i=r.axis,n=r.get(["axisLabel","customValues"]);if(LM(i))return;function a(u,f,h){var v=Or(t[f]),c=Or(t[h]),d=i.scale;if(!(!v||!c)){if(u==null){if(!e&&n)return;var p=gn(v.label).labelInfo.tick;if(Ya(d)&&p.notNice||we(d)&&p.offInterval){Zi(v.label);return}}if(u===!1||v.suggestIgnore){Zi(v.label);return}if(c.suggestIgnore){Zi(c.label);return}var m=.1;if(!e){var g=[0,0,0,0];v=Kd({marginForce:g},v),c=Kd({marginForce:g},c)}Tv(v,c,null,{touchThreshold:m})&&Zi(u?c.label:v.label)}}var o=r.get(["axisLabel","showMinLabel"]),s=r.get(["axisLabel","showMaxLabel"]),l=t.length;a(o,0,1),a(s,l-1,l-2)}function kD(r,t,e){r.showMinorTicks||x(t,function(i){if(i&&i.label.ignore)for(var n=0;n0&&h[1]>0&&!v[0]&&(h[0]=0),h[0]<0&&h[1]<0&&!v[1]&&(h[1]=0));var S=!1;h[0]>h[1]&&(h.reverse(),S=!0);var b=On(t,e.get("startValue",!0)),w=b!=null;!$e(b)&&n&&(b=t.getDefaultStartValue?t.getDefaultStartValue():0),$e(b)&&(w||!y||_)&&(bh[1]&&!v[1]&&(h[1]=b,v[1]=!0));var T=this._i={scale:t,dataMM:f,noZoomEffMM:h,zoomMM:[],fixMM:v,zoomFixMM:[!1,!1],startValue:b,isBlank:g,incl0:_,tggAxInv:S,ctnShp:a};tp(T,h)}return r.prototype.makeNoZoom=function(){return this._i.noZoomEffMM.slice()},r.prototype.makeFinal=function(){var t=this._i,e=t.zoomMM,i=t.noZoomEffMM,n=t.zoomFixMM,a=t.fixMM,o={fixMM:a,zoomFixMM:n,isBlank:t.isBlank,incl0:t.incl0,tggAxInv:t.tggAxInv,ctnShp:t.ctnShp,effMM:i.slice()},s=o.effMM;return e[0]!=null&&(s[0]=e[0],a[0]=n[0]=!0),e[1]!=null&&(s[1]=e[1],a[1]=n[1]=!0),tp(t,s),o},r.prototype.makeRenderInfo=function(){return{startValue:this._i.startValue}},r.prototype.setZoomMM=function(t,e){this._i.zoomMM[t]=e},r})();function tp(r,t){var e=r.scale,i=r.dataMM;e.sanitize&&(t[0]=e.sanitize(t[0],i),t[1]=e.sanitize(t[1],i),Wo(t))}function On(r,t){return t==null?null:ga(t)?NaN:r.parse(t)}function qD(r,t){var e;if(we(r))e=[0,0];else{var i=t.get("boundaryGap");typeof i=="boolean"&&(i=null),e=N(i)?i:[i,i]}return[ep(e[0]),ep(e[1])]}function ep(r){return fn(typeof r=="boolean"?0:r,1)||0}function F0(r){var t=YD(r.scale);return t.extent||(t.extent=me()),t}function KD(r,t){F0(r).dimIdxInCoord=t.get(r.dim)}function z0(r,t){var e=r.scale,i=r.model,n=r.dim;e.rawExtentInfo||QD(e,r,n,i,t)}function QD(r,t,e,i,n){var a=F0(t),o=a.extent,s=!1;vD(t,function(f){if(f.boxCoordinateSystem){var h=Ey(f).coord,v=a.dimIdxInCoord;if(v>=0){if(N(h)){var c=h[v];c!=null&&!N(c)&&Mf(o,r.parse(c))}}}else if(f.coordinateSystem){var d=f.getData();if(d){var p=r.getFilter?r.getFilter():null;x(PM(d,e),function(m){zb(o,d.getApproximateExtent(m,p))})}f.__requireStartValue&&f.__requireStartValue(t)&&(s=!0)}});var l=jD(r,t,i),u=new N0(r,i,o,s,l);H0(r,u,n),a.extent=null}function JD(r,t){var e=r.scale;H0(e,new N0(e,r.model,t,!1,!1),$D)}function H0(r,t,e){r.rawExtentInfo=t,t.from=e}var V0=W();function G0(r,t,e,i,n){r.rawExtentInfo||JD({scale:r,model:t},me());var a=r.rawExtentInfo.makeFinal(),o=a.effMM;return r.setExtent(o[0],o[1]),r.setBlank(a.isBlank),i&&a.tggAxInv&&e&&!e.get("legacyMinMaxDontInverseAxis")&&(i.inverse=!i.inverse),a}function jD(r,t,e){var i=_0(r,e),n=e.get("containShape",!0);if(n==null&&!i&&(n=!0),!n)return!1;var a=!1;return D0(t,function(o){a=!!V0.get(o)||a}),a}function tA(r,t,e,i){if(e.ctnShp){var n;if(D0(r,function(s){var l=V0.get(s);if(l){var u=l(r,i);u&&(n=n||[0,0],Lm(n,u[0]),Pm(n,u[1]),AM(r))}}),!!n){var a=t.getExtent();if(we(t))r.onBand||t.setExtent2(Aa,re(a[0],a[0]+n[0]),dt(a[1],a[1]+n[1]));else{var o=a.slice();e.zoomFixMM[0]||(o[0]=re(o[0],t.transformOut(t.transformIn(o[0],null)+n[0],null))),e.zoomFixMM[1]||(o[1]=dt(o[1],t.transformOut(t.transformIn(o[1],null)+n[1],null))),(o[0]a[1])&&t.setExtent2(Aa,o[0],o[1])}}}}var U0={left:0,right:0,top:0,bottom:0},zs=["25%","25%"],es="cartesian2d",eA=(function(r){B(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.mergeDefaultAndTheme=function(e,i){var n=Tn(e.outerBounds);r.prototype.mergeDefaultAndTheme.apply(this,arguments),n&&e.outerBounds&&Pr(e.outerBounds,n)},t.prototype.mergeOption=function(e,i){r.prototype.mergeOption.apply(this,arguments),this.option.outerBounds&&e.outerBounds&&Pr(this.option.outerBounds,e.outerBounds)},t.type="grid",t.dependencies=["xAxis","yAxis"],t.layoutMode="box",t.defaultOption={show:!1,z:0,left:"15%",top:65,right:"10%",bottom:80,containLabel:!1,outerBoundsMode:"auto",outerBounds:U0,outerBoundsContain:"all",outerBoundsClampWidth:zs[0],outerBoundsClampHeight:zs[1],backgroundColor:z.color.transparent,borderWidth:1,borderColor:z.color.neutral30},t})(ct),Hs="\0__throttleOriginMethod",rp="\0__throttleRate",ip="\0__throttleType";function W0(r,t,e){var i,n=0,a=0,o=null,s,l,u,f;t=t||0;function h(){a=new Date().getTime(),o=null,r.apply(l,u||[])}var v=function(){for(var c=[],d=0;d=0?h():o=setTimeout(h,-s),n=i};return v.clear=function(){o&&(clearTimeout(o),o=null)},v.debounceNextCall=function(c){f=c},v}function Cl(r,t,e,i){var n=r[t];if(n){var a=n[Hs]||n,o=n[ip],s=n[rp];if(s!==e||o!==i){if(e==null||!i)return r[t]=a;n=r[t]=W0(a,e,i==="debounce"),n[Hs]=a,n[ip]=i,n[rp]=e}return n}}function Vs(r,t){var e=r[t];e&&e[Hs]&&(e.clear&&e.clear(),r[t]=e[Hs])}function Ni(r,t,e,i,n){var a=r+t;e.isSilent(a)||i.eachComponent({mainType:"series",subType:"pie"},function(o){for(var s=o.seriesIndex,l=o.option.selectedMap,u=n.selected,f=0;f=0;if(n){var o=i!=="touchend"?t.targetTouches[0]:t.changedTouches[0];o&&Kf(r,o,t,e)}else{Kf(r,t,t,e);var a=oA(t);t.zrDelta=a?a/120:-(t.detail||0)/3}var s=t.button;return t.which==null&&s!==void 0&&nA.test(t.type)&&(t.which=s&1?1:s&2?3:s&4?2:0),t}function oA(r){var t=r.wheelDelta;if(t)return t;var e=r.deltaX,i=r.deltaY;if(e==null||i==null)return t;var n=Math.abs(i!==0?i:e),a=i>0?-1:i<0?1:e>0?-1:1;return 3*n*a}function sA(r,t,e,i){r.addEventListener(t,e,i)}function lA(r,t,e,i){r.removeEventListener(t,e,i)}var mn=function(r){r.preventDefault(),r.stopPropagation(),r.cancelBubble=!0};function ap(r){return r.which===2||r.which===3}var uA=(function(){function r(){this._track=[]}return r.prototype.recognize=function(t,e,i){return this._doTrack(t,e,i),this._recognize(t)},r.prototype.clear=function(){return this._track.length=0,this},r.prototype._doTrack=function(t,e,i){var n=t.touches;if(n){for(var a={points:[],touches:[],target:e,event:t},o=0,s=n.length;o1&&i&&i.length>1){var a=op(i)/op(n);!isFinite(a)&&(a=1),t.pinchScale=a;var o=fA(i);return t.pinchX=o[0],t.pinchY=o[1],{type:"pinch",target:r[0].target,event:t}}}}},Y0="silent";function hA(r,t,e){return{type:r,event:e,target:t.target,topTarget:t.topTarget,cancelBubble:!1,offsetX:e.zrX,offsetY:e.zrY,gestureEvent:e.gestureEvent,pinchX:e.pinchX,pinchY:e.pinchY,pinchScale:e.pinchScale,wheelDelta:e.zrDelta,zrByTouch:e.zrByTouch,which:e.which,stop:vA}}function vA(){mn(this.event)}var cA=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.handler=null,e}return t.prototype.dispose=function(){},t.prototype.setCursor=function(){},t})(Ie),kn=(function(){function r(t,e){this.x=t,this.y=e}return r})(),dA=["click","dblclick","mousewheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],xu=new j(0,0,0,0),Z0=(function(r){B(t,r);function t(e,i,n,a,o){var s=r.call(this)||this;return s._hovered=new kn(0,0),s.storage=e,s.painter=i,s.painterRoot=a,s._pointerSize=o,n=n||new cA,s.proxy=null,s.setHandlerProxy(n),s._draggingMgr=new iA(s),s}return t.prototype.setHandlerProxy=function(e){this.proxy&&this.proxy.dispose(),e&&(x(dA,function(i){e.on&&e.on(i,this[i],this)},this),e.handler=this),this.proxy=e},t.prototype.mousemove=function(e){var i=e.zrX,n=e.zrY,a=X0(this,i,n),o=this._hovered,s=o.target;s&&!s.__zr&&(o=this.findHover(o.x,o.y),s=o.target);var l=this._hovered=a?new kn(i,n):this.findHover(i,n),u=l.target,f=this.proxy;f.setCursor&&f.setCursor(u?u.cursor:"default"),s&&u!==s&&this.dispatchToElement(o,"mouseout",e),this.dispatchToElement(l,"mousemove",e),u&&u!==s&&this.dispatchToElement(l,"mouseover",e)},t.prototype.mouseout=function(e){var i=e.zrEventControl;i!=="only_globalout"&&this.dispatchToElement(this._hovered,"mouseout",e),i!=="no_globalout"&&this.trigger("globalout",{type:"globalout",event:e})},t.prototype.resize=function(){this._hovered=new kn(0,0)},t.prototype.dispatch=function(e,i){var n=this[e];n&&n.call(this,i)},t.prototype.dispose=function(){this.proxy.dispose(),this.storage=null,this.proxy=null,this.painter=null},t.prototype.setCursorStyle=function(e){var i=this.proxy;i.setCursor&&i.setCursor(e)},t.prototype.dispatchToElement=function(e,i,n){e=e||{};var a=e.target;if(!(a&&a.silent)){for(var o="on"+i,s=hA(i,e,n);a&&(a[o]&&(s.cancelBubble=!!a[o].call(a,s)),a.trigger(i,s),a=a.__hostTarget?a.__hostTarget:a.parent,!s.cancelBubble););s.cancelBubble||(this.trigger(i,s),this.painter&&this.painter.eachOtherLayer&&this.painter.eachOtherLayer(function(l){typeof l[o]=="function"&&l[o].call(l,s),l.trigger&&l.trigger(i,s)}))}},t.prototype.findHover=function(e,i,n){var a=this.storage.getDisplayList(),o=new kn(e,i);if(sp(a,o,e,i,n),this._pointerSize&&!o.target){for(var s=[],l=this._pointerSize,u=l/2,f=new j(e-u,i-u,l,l),h=a.length-1;h>=0;h--){var v=a[h];v!==n&&!v.ignore&&!v.ignoreCoarsePointer&&(!v.parent||!v.parent.ignoreCoarsePointer)&&(xu.copy(v.getBoundingRect()),v.transform&&xu.applyTransform(v.transform),xu.intersect(f)&&s.push(v))}if(s.length)for(var c=4,d=Math.PI/12,p=Math.PI*2,m=0;m4)return;this._downPoint=null}this.dispatchToElement(a,r,t)}});function pA(r,t,e){if(r[r.rectHover?"rectContain":"contain"](t,e)){for(var i=r,n=void 0,a=!1;i;){if(i.ignoreClip&&(a=!0),!a){var o=i.getClipPath();if(o&&!o.contain(t,e))return!1}i.silent&&(n=!0);var s=i.__hostTarget;i=s?i.ignoreHostSilent?null:s:i.parent}return n?Y0:!0}return!1}function sp(r,t,e,i,n){for(var a=r.length-1;a>=0;a--){var o=r[a],s=void 0;if(o!==n&&!o.ignore&&(s=pA(o,e,i))&&(!t.topTarget&&(t.topTarget=o),s!==Y0)){t.target=o;break}}}function X0(r,t,e){var i=r.painter;return t<0||t>i.getWidth()||e<0||e>i.getHeight()}var $0=32,Bn=7;function gA(r){for(var t=0;r>=$0;)t|=r&1,r>>=1;return r+t}function lp(r,t,e,i){var n=t+1;if(n===e)return 1;if(i(r[n++],r[t])<0){for(;n=0;)n++;return n-t}function mA(r,t,e){for(e--;t>>1,n(a,r[l])<0?s=l:o=l+1;var u=i-o;switch(u){case 3:r[o+3]=r[o+2];case 2:r[o+2]=r[o+1];case 1:r[o+1]=r[o];break;default:for(;u>0;)r[o+u]=r[o+u-1],u--}r[o]=a}}function Cu(r,t,e,i,n,a){var o=0,s=0,l=1;if(a(r,t[e+n])>0){for(s=i-n;l0;)o=l,l=(l<<1)+1,l<=0&&(l=s);l>s&&(l=s),o+=n,l+=n}else{for(s=n+1;ls&&(l=s);var u=o;o=n-l,l=n-u}for(o++;o>>1);a(r,t[e+f])>0?o=f+1:l=f}return l}function Mu(r,t,e,i,n,a){var o=0,s=0,l=1;if(a(r,t[e+n])<0){for(s=n+1;ls&&(l=s);var u=o;o=n-l,l=n-u}else{for(s=i-n;l=0;)o=l,l=(l<<1)+1,l<=0&&(l=s);l>s&&(l=s),o+=n,l+=n}for(o++;o>>1);a(r,t[e+f])<0?l=f:o=f+1}return l}function yA(r,t){var e=Bn,i,n,a=0,o=[];i=[],n=[];function s(c,d){i[a]=c,n[a]=d,a+=1}function l(){for(;a>1;){var c=a-2;if(c>=1&&n[c-1]<=n[c]+n[c+1]||c>=2&&n[c-2]<=n[c]+n[c-1])n[c-1]n[c+1])break;f(c)}}function u(){for(;a>1;){var c=a-2;c>0&&n[c-1]=Bn||T>=Bn);if(D)break;b<0&&(b=0),b+=2}if(e=b,e<1&&(e=1),d===1){for(g=0;g=0;g--)r[w+g]=r[b+g];r[S]=o[_];return}for(var T=e;;){var D=0,C=0,M=!1;do if(t(o[_],r[y])<0){if(r[S--]=r[y--],D++,C=0,--d===0){M=!0;break}}else if(r[S--]=o[_--],C++,D=0,--m===1){M=!0;break}while((D|C)=0;g--)r[w+g]=r[b+g];if(d===0){M=!0;break}}if(r[S--]=o[_--],--m===1){M=!0;break}if(C=m-Cu(r[y],o,0,m,m-1,t),C!==0){for(S-=C,_-=C,m-=C,w=S+1,b=_+1,g=0;g=Bn||C>=Bn);if(M)break;T<0&&(T=0),T+=2}if(e=T,e<1&&(e=1),m===1){for(S-=d,y-=d,w=S+1,b=y+1,g=d-1;g>=0;g--)r[w+g]=r[b+g];r[S]=o[_]}else{if(m===0)throw new Error;for(b=S-(m-1),g=0;gs&&(l=s),up(r,e,e+l,e+a,t),a=l}o.pushRun(e,a),o.mergeRuns(),n-=a,e+=a}while(n!==0);o.forceMergeRuns()}}var fp=!1;function Du(){fp||(fp=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function hp(r,t){return r.zlevel===t.zlevel?r.z===t.z?r.z2-t.z2:r.z-t.z:r.zlevel-t.zlevel}var _A=(function(){function r(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=hp}return r.prototype.traverse=function(t,e){for(var i=0;i=0&&this._roots.splice(n,1)},r.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},r.prototype.getRoots=function(){return this._roots},r.prototype.dispose=function(){this._displayList=null,this._roots=null},r})(),Gs;Gs=et.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(r){return setTimeout(r,16)};function ji(){return new Date().getTime()}var SA=(function(r){B(t,r);function t(e){var i=r.call(this)||this;return i._running=!1,i._time=0,i._pausedTime=0,i._pauseStart=0,i._paused=!1,e=e||{},i.stage=e.stage||{},i}return t.prototype.addClip=function(e){e.animation&&this.removeClip(e),this._head?(this._tail.next=e,e.prev=this._tail,e.next=null,this._tail=e):this._head=this._tail=e,e.animation=this},t.prototype.addAnimator=function(e){e.animation=this;var i=e.getClip();i&&this.addClip(i)},t.prototype.removeClip=function(e){if(e.animation){var i=e.prev,n=e.next;i?i.next=n:this._head=n,n?n.prev=i:this._tail=i,e.next=e.prev=e.animation=null}},t.prototype.removeAnimator=function(e){var i=e.getClip();i&&this.removeClip(i),e.animation=null},t.prototype.update=function(e){for(var i=ji()-this._pausedTime,n=i-this._time,a=this._head;a;){var o=a.next,s=a.step(i,n);s&&(a.ondestroy(),this.removeClip(a)),a=o}this._time=i,e||(this.trigger("frame",n),this.stage.update&&this.stage.update())},t.prototype._startLoop=function(){var e=this;this._running=!0;function i(){e._running&&(Gs(i),!e._paused&&e.update())}Gs(i)},t.prototype.start=function(){this._running||(this._time=ji(),this._pausedTime=0,this._startLoop())},t.prototype.stop=function(){this._running=!1},t.prototype.pause=function(){this._paused||(this._pauseStart=ji(),this._paused=!0)},t.prototype.resume=function(){this._paused&&(this._pausedTime+=ji()-this._pauseStart,this._paused=!1)},t.prototype.clear=function(){for(var e=this._head;e;){var i=e.next;e.prev=e.next=e.animation=null,e=i}this._head=this._tail=null},t.prototype.isFinished=function(){return this._head==null},t.prototype.animate=function(e,i){i=i||{},this.start();var n=new Dh(e,i.loop);return this.addAnimator(n),n},t})(Ie),bA=300,Au=et.domSupported,Iu=(function(){var r=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],t=["touchstart","touchend","touchmove"],e={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},i=q(r,function(n){var a=n.replace("mouse","pointer");return e.hasOwnProperty(a)?a:n});return{mouse:r,touch:t,pointer:i}})(),vp={mouse:["mousemove","mouseup"],pointer:["pointermove","pointerup"]},cp=!1;function Qf(r){var t=r.pointerType;return t==="pen"||t==="touch"}function wA(r){r.touching=!0,r.touchTimer!=null&&(clearTimeout(r.touchTimer),r.touchTimer=null),r.touchTimer=setTimeout(function(){r.touching=!1,r.touchTimer=null},700)}function Lu(r){r&&(r.zrByTouch=!0)}function TA(r,t){return fe(r.dom,new xA(r,t),!0)}function q0(r,t){for(var e=t,i=!1;e&&e.nodeType!==9&&!(i=e.domBelongToZr||e!==t&&e===r.painterRoot);)e=e.parentNode;return i}var xA=(function(){function r(t,e){this.stopPropagation=kt,this.stopImmediatePropagation=kt,this.preventDefault=kt,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY}return r})(),Me={mousedown:function(r){r=fe(this.dom,r),this.__mayPointerCapture=[r.zrX,r.zrY],this.trigger("mousedown",r)},mousemove:function(r){r=fe(this.dom,r);var t=this.__mayPointerCapture;t&&(r.zrX!==t[0]||r.zrY!==t[1])&&this.__togglePointerCapture(!0),this.trigger("mousemove",r)},mouseup:function(r){r=fe(this.dom,r),this.__togglePointerCapture(!1),this.trigger("mouseup",r)},mouseout:function(r){r=fe(this.dom,r);var t=r.toElement||r.relatedTarget;q0(this,t)||(this.__pointerCapturing&&(r.zrEventControl="no_globalout"),this.trigger("mouseout",r))},wheel:function(r){cp=!0,r=fe(this.dom,r),this.trigger("mousewheel",r)},mousewheel:function(r){cp||(r=fe(this.dom,r),this.trigger("mousewheel",r))},touchstart:function(r){r=fe(this.dom,r),Lu(r),this.__lastTouchMoment=new Date,this.handler.processGesture(r,"start"),Me.mousemove.call(this,r),Me.mousedown.call(this,r)},touchmove:function(r){r=fe(this.dom,r),Lu(r),this.handler.processGesture(r,"change"),Me.mousemove.call(this,r)},touchend:function(r){r=fe(this.dom,r),Lu(r),this.handler.processGesture(r,"end"),Me.mouseup.call(this,r),+new Date-+this.__lastTouchMoment0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},r.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},r.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},r.prototype.refreshHover=function(){this._needsRefreshHover=!0},r.prototype.refreshHoverImmediately=function(){this._disposed||this._refresh({animUpdate:!1,refresh:!1,refreshHover:!0})},r.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},r.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},r.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},r.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},r.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},r.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},r.prototype.on=function(t,e,i){return this._disposed||this.handler.on(t,e,i),this},r.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},r.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},r.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e=0;l--)a[l]&&!_a(a[l])?s=!0:(a[l]=null,!s&&o--);a.length=o,e[n]=a}}),delete e[mp],e},t.prototype.setTheme=function(e){this._theme=new wt(e),this._resetOption("recreate",null)},t.prototype.getTheme=function(){return this._theme},t.prototype.getLocaleModel=function(){return this._locale},t.prototype.setUpdatePayload=function(e){this._payload=e},t.prototype.getUpdatePayload=function(){return this._payload},t.prototype.getComponent=function(e,i){var n=this._componentsMap.get(e);if(n){var a=n[i||0];if(a)return a;if(i==null){for(var o=0;o=t:e==="max"?r<=t:r===t}function UA(r,t){return r.join(",")===t.join(",")}var xe=x,Pa=Z,bp=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Eu(r){var t=r&&r.itemStyle;if(t)for(var e=0,i=bp.length;e0?e[o-1].seriesModel:null)}),jA(e)}})}function jA(r){x(r,function(t,e){var i=[],n=[NaN,NaN],a=[t.stackResultDimension,t.stackedOverDimension],o=t.data,s=t.isStackedByIndex,l=t.seriesModel.get("stackStrategy")||"samesign";o.modify(a,function(u,f,h){var v=o.get(t.stackedDimension,h);if(isNaN(v))return n;var c,d;s?d=o.getRawIndex(h):c=o.get(t.stackedByDimension,h);for(var p=NaN,m=e-1;m>=0;m--){var g=r[m];if(s||(d=g.data.rawIndexOf(g.stackedByDimension,c)),d>=0){var y=g.data.getByRawIndex(g.stackResultDimension,d);if(l==="all"||l==="positive"&&y>0||l==="negative"&&y<0||l==="samesign"&&v>=0&&y>0||l==="samesign"&&v<=0&&y<0){v=si(v,y),p=y;break}}}return i[0]=v,i[1]=p,i})})}var Ae=(function(){function r(){this.group=new Pt,this.uid=ml("viewComponent")}return r.prototype.init=function(t,e){},r.prototype.render=function(t,e,i,n){},r.prototype.dispose=function(t,e){},r.prototype.updateView=function(t,e,i,n){},r.prototype.updateLayout=function(t,e,i,n){},r.prototype.updateVisual=function(t,e,i,n){},r.prototype.toggleBlurSeries=function(t,e,i){},r.prototype.eachRendered=function(t){var e=this.group;e&&e.traverse(t)},r})();Th(Ae);js(Ae);var Mp=ft(),Dp={itemStyle:ma(py,!0),lineStyle:ma(dy,!0)},tI={lineStyle:"stroke",itemStyle:"fill"};function r_(r,t){var e=r.visualStyleMapper||Dp[t];return e||(console.warn("Unknown style type '"+t+"'."),Dp.itemStyle)}function i_(r,t){var e=r.visualDrawType||tI[t];return e||(console.warn("Unknown style type '"+t+"'."),"fill")}var eI={createOnAllSeries:!0,performRawSeries:!0,reset:function(r,t){var e=r.getData(),i=r.visualStyleAccessPath||"itemStyle",n=r.getModel(i),a=r_(r,i),o=a(n),s=n.getShallow("decal");s&&(e.setVisual("decal",s),s.dirty=!0);var l=i_(r,i),u=o[l],f=J(u)?u:null,h=o.fill==="auto"||o.stroke==="auto";if(!o[l]||f||h){var v=r.getColorFromPalette(r.name,null,t.getSeriesCount());o[l]||(o[l]=v,e.setVisual("colorFromPalette",!0)),o.fill=o.fill==="auto"||J(o.fill)?v:o.fill,o.stroke=o.stroke==="auto"||J(o.stroke)?v:o.stroke}if(e.setVisual("style",o),e.setVisual("drawType",l),!t.isSeriesFiltered(r)&&f)return e.setVisual("colorFromPalette",!1),{dataEach:function(c,d){var p=r.getDataParams(d),m=O({},o);m[l]=f(p),c.setItemVisual(d,"style",m)}}}},zn=new wt,rI={createOnAllSeries:!0,reset:function(r,t){if(!r.ignoreStyleOnData){var e=r.getData(),i=r.visualStyleAccessPath||"itemStyle",n=r_(r,i),a=e.getVisual("drawType");return{dataEach:e.hasItemOption?function(o,s){var l=o.getRawDataItem(s);if(l&&l[i]){zn.option=l[i];var u=n(zn),f=o.ensureUniqueItemVisual(s,"style");O(f,u),zn.option.decal&&(o.setItemVisual(s,"decal",zn.option.decal),zn.option.decal.dirty=!0),a in u&&o.setItemVisual(s,"colorFromPalette",!1)}}:null}}}},iI={performRawSeries:!0,overallReset:function(r){var t=W();r.eachSeries(function(e){if(!e.isColorBySeries()){var i=e.type+"-"+e.getColorBy();Mp(e).scope=t.get(i)||t.set(i,{})}}),r.eachSeries(function(e){if(!e.isColorBySeries()){var i=e.getRawData(),n={},a=e.getData(),o=Mp(e).scope,s=e.visualStyleAccessPath||"itemStyle",l=i_(e,s);a.each(function(u){var f=a.getRawIndex(u);n[f]=u}),i.each(function(u){var f=n[u],h=a.getItemVisual(f,"colorFromPalette");if(h){var v=a.ensureUniqueItemVisual(f,"style"),c=i.getName(u)||u+"",d=i.count();v[l]=e.getColorFromPalette(c,o,d)}})}})}},Lo=Math.PI;function nI(r,t){t=t||{},vt(t,{text:"loading",textColor:z.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:z.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var e=new Pt,i=new Tt({style:{fill:t.maskColor},zlevel:t.zlevel,z:1e4});e.add(i);var n=new Xt({style:{text:t.text,fill:t.textColor,fontSize:t.fontSize,fontWeight:t.fontWeight,fontStyle:t.fontStyle,fontFamily:t.fontFamily},zlevel:t.zlevel,z:10001}),a=new Tt({style:{fill:"none"},textContent:n,textConfig:{position:"right",distance:10},zlevel:t.zlevel,z:10001});e.add(a);var o;return t.showSpinner&&(o=new cl({shape:{startAngle:-Lo/2,endAngle:-Lo/2+.1,r:t.spinnerRadius},style:{stroke:t.color,lineCap:"round",lineWidth:t.lineWidth},zlevel:t.zlevel,z:10001}),o.animateShape(!0).when(1e3,{endAngle:Lo*3/2}).start("circularInOut"),o.animateShape(!0).when(1e3,{startAngle:Lo*3/2}).delay(300).start("circularInOut"),e.add(o)),e.resize=function(){var s=n.getBoundingRect().width,l=t.showSpinner?t.spinnerRadius:0,u=(r.getWidth()-l*2-(t.showSpinner&&s?10:0)-s)/2-(t.showSpinner&&s?0:5+s/2)+(t.showSpinner?0:s/2)+(s?0:l),f=r.getHeight()/2;t.showSpinner&&o.setShape({cx:u,cy:f}),a.setShape({x:u-l,y:f-l,width:l*2,height:l*2}),i.setShape({x:0,y:0,width:r.getWidth(),height:r.getHeight()})},e.resize(),e}var n_=(function(){function r(t,e,i,n){this._stageTaskMap=W(),this.ecInstance=t,this.api=e,i=this._dataProcessorHandlers=i.slice(),n=this._visualHandlers=n.slice(),this._allHandlers=i.concat(n)}return r.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(i){var n=i.overallTask;n&&n.dirty()})},r.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var i=this._pipelineMap.get(t.__pipeline.id),n=i.context,a=!e&&i.progressiveEnabled&&(!n||n.progressiveRender)&&t.__idxInPipeline>i.blockIndex,o=a?i.step:null,s=n&&n.modDataCount,l=s!=null?Math.ceil(s/o):null;return{step:o,modBy:l,modDataCount:s}}},r.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},r.prototype.updateStreamModes=function(t,e){var i=this._pipelineMap.get(t.uid),n=t.__preparePipelineContext?t.__preparePipelineContext(e,i):Wb(t,e,i);t.pipelineContext=i.context=n},r.prototype.restorePipelines=function(t,e){var i=this,n=i._pipelineMap=W();e.eachSeries(function(a){var o=t.painter.type==="canvas"&&a.getProgressive(),s=a.uid;n.set(s,{id:s,head:null,tail:null,threshold:a.getProgressiveThreshold(),progressiveEnabled:o&&!(a.preventIncremental&&a.preventIncremental()),blockIndex:-1,step:Math.round(o||700),count:0}),i._pipe(a,a.dataTask)})},r.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),i=this.api;x(this._allHandlers,function(n){var a=t.get(n.uid)||t.set(n.uid,{}),o="";nr(!(n.reset&&n.overallReset),o),n.reset&&this._createSeriesStageTask(n,a,e,i),n.overallReset&&this._createOverallStageTask(n,a,e,i)},this)},r.prototype.prepareView=function(t,e,i,n){var a=t.renderTask,o=a.context;o.model=e,o.ecModel=i,o.api=n,a.__block=!t.incrementalPrepareRender,this._pipe(e,a)},r.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},r.prototype.performVisualTasks=function(t,e,i){this._performStageTasks(this._visualHandlers,t,e,i)},r.prototype._performStageTasks=function(t,e,i,n){n=n||{};var a=!1,o=this;x(t,function(l,u){if(!(n.visualType&&n.visualType!==l.visualType)){var f=o._stageTaskMap.get(l.uid),h=f.seriesTaskMap,v=f.overallTask;if(v){var c,d=v.agentStubMap;d.each(function(m){s(n,m)&&(m.dirty(),c=!0)}),c&&v.dirty(),o.updatePayload(v,i);var p=o.getPerformArgs(v,n.block);d.each(function(m){m.perform(p)}),v.perform(p)&&(a=!0)}else h&&h.each(function(m,g){s(n,m)&&m.dirty();var y=o.getPerformArgs(m,n.block);y.skip=!l.performRawSeries&&e.isSeriesFiltered(m.context.model),o.updatePayload(m,i),m.perform(y)&&(a=!0)})}});function s(l,u){return l.setDirty&&(!l.dirtyMap||l.dirtyMap.get(u.__pipeline.id))}this.unfinished=a||this.unfinished},r.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(i){e=i.dataTask.perform()||e}),this.unfinished=e||this.unfinished},r.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},r.prototype.updatePayload=function(t,e){e!=="remain"&&(t.context.payload=e)},r.prototype._createSeriesStageTask=function(t,e,i,n){var a=this,o=e.seriesTaskMap,s=e.seriesTaskMap=W(),l=t.seriesType,u=t.getTargetSeries;t.createOnAllSeries?i.eachRawSeries(f):l?i.eachRawSeriesByType(l,f):u&&u(i,n).each(f);function f(h){var v=h.uid,c=s.set(v,o&&o.get(v)||ha({plan:uI,reset:fI,count:vI}));c.context={model:h,ecModel:i,api:n,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:a},a._pipe(h,c)}},r.prototype._createOverallStageTask=function(t,e,i,n){var a=this,o=e.overallTask=e.overallTask||ha({reset:aI});o.context={ecModel:i,api:n,overallReset:t.overallReset,scheduler:a};var s=o.agentStubMap,l=o.agentStubMap=W(),u=t.seriesType,f=t.getTargetSeries,h=t.dirtyOnOverallProgress,v=!1,c="";nr(!t.createOnAllSeries,c),u?i.eachRawSeriesByType(u,d):f?f(i,n).each(d):x(i.getSeries(),d);function d(p){var m=p.uid,g=l.set(m,s&&s.get(m)||(v=!0,ha({reset:oI,onDirty:lI})));g.context={model:p,dirtyOnOverallProgress:h},g.agent=o,g.__block=h,a._pipe(p,g)}v&&o.dirty()},r.prototype._pipe=function(t,e){var i=t.uid,n=this._pipelineMap.get(i);!n.head&&(n.head=e),n.tail&&n.tail.pipe(e),n.tail=e,e.__idxInPipeline=n.count++,e.__pipeline=n},r.wrapStageHandler=function(t,e){return J(t)&&(t={overallReset:t,seriesType:cI(t)}),t.uid=ml("stageHandler"),e&&(t.visualType=e),t},r})();function aI(r){r.overallReset(r.ecModel,r.api,r.payload)}function oI(r){return r.dirtyOnOverallProgress&&sI}function sI(){this.agent.dirty(),this.getDownstream().dirty()}function lI(){this.agent&&this.agent.dirty()}function uI(r){return r.plan?r.plan(r.model,r.ecModel,r.api,r.payload):null}function fI(r){r.useClearVisual&&r.data.clearAllVisual();var t=r.resetDefines=Zt(r.reset(r.model,r.ecModel,r.api,r.payload));return t.length>1?q(t,function(e,i){return a_(i)}):hI}var hI=a_(0);function a_(r){return function(t,e){var i=e.data,n=e.resetDefines[r];if(n&&n.dataEach)for(var a=t.start;a0&&c===u.length-v.length){var d=u.slice(0,c);d!=="data"&&(e.mainType=d,e[v.toLowerCase()]=l,f=!0)}}s.hasOwnProperty(u)&&(i[u]=l,f=!0),f||(n[u]=l)})}return{cptQuery:e,dataQuery:i,otherQuery:n}},r.prototype.filter=function(t,e){var i=this.eventInfo;if(!i)return!0;var n=i.targetEl,a=i.packedEvent,o=i.model,s=i.view;if(!o||!s)return!0;var l=e.cptQuery,u=e.dataQuery;return f(l,o,"mainType")&&f(l,o,"subType")&&f(l,o,"index","componentIndex")&&f(l,o,"name")&&f(l,o,"id")&&f(u,a,"name")&&f(u,a,"dataIndex")&&f(u,a,"dataType")&&(!s.filterForExposedEvent||s.filterForExposedEvent(t,e.otherQuery,n,a));function f(h,v,c,d){return h[c]==null||v[d||c]===h[c]}},r.prototype.afterTrigger=function(){this.eventInfo=null},r})(),jf=["symbol","symbolSize","symbolRotate","symbolOffset"],Lp=jf.concat(["symbolKeepAspect"]),pI={createOnAllSeries:!0,performRawSeries:!0,reset:function(r,t){var e=r.getData();if(r.legendIcon&&e.setVisual("legendIcon",r.legendIcon),!r.hasSymbolVisual)return;for(var i={},n={},a=!1,o=0;o=0&&vi(l)?l:.5;var u=r.createRadialGradient(o,s,0,o,s,l);return u}function th(r,t,e){for(var i=t.type==="radial"?CI(r,t,e):xI(r,t,e),n=t.colorStops,a=0;a0)?null:r==="dashed"?[4*t,2*t]:r==="dotted"?[t]:bt(r)?[r]:N(r)?r:null}function f_(r){var t=r.style,e=t.lineDash&&t.lineWidth>0&&DI(t.lineDash,t.lineWidth),i=t.lineDashOffset;if(e){var n=t.strokeNoScale&&r.getLineScale?r.getLineScale():1;n&&n!==1&&(e=q(e,function(a){return a/n}),i/=n)}return[e,i]}var AI=new _i(!0);function Ws(r){var t=r.stroke;return!(t==null||t==="none"||!(r.lineWidth>0))}function Pp(r){return typeof r=="string"&&r!=="none"}function Ys(r){var t=r.fill;return t!=null&&t!=="none"}function Ep(r,t){if(t.fillOpacity!=null&&t.fillOpacity!==1){var e=r.globalAlpha;r.globalAlpha=t.fillOpacity*t.opacity,r.fill(),r.globalAlpha=e}else r.fill()}function Rp(r,t){if(t.strokeOpacity!=null&&t.strokeOpacity!==1){var e=r.globalAlpha;r.globalAlpha=t.strokeOpacity*t.opacity,r.stroke(),r.globalAlpha=e}else r.stroke()}function eh(r,t,e){var i=Jg(t.image,t.__image,e);if(tl(i)){var n=r.createPattern(i,t.repeat||"repeat");if(typeof DOMMatrix=="function"&&n&&n.setTransform){var a=new DOMMatrix;a.translateSelf(t.x||0,t.y||0),a.rotateSelf(0,0,(t.rotation||0)*x1),a.scaleSelf(t.scaleX||1,t.scaleY||1),n.setTransform(a)}return n}}function II(r,t,e,i,n){var a,o=Ws(e),s=Ys(e),l=e.strokePercent,u=l<1,f=!t.path;(!t.silent||u)&&f&&t.createPathProxy();var h=t.path||AI,v=t.__dirty;if(!i){var c=e.fill,d=e.stroke,p=s&&!!c.colorStops,m=o&&!!d.colorStops,g=s&&!!c.image,y=o&&!!d.image,_=void 0,S=void 0,b=void 0,w=void 0,T=void 0;(p||m)&&(T=t.getBoundingRect()),p&&(_=v?th(r,c,T):t.__canvasFillGradient,t.__canvasFillGradient=_),m&&(S=v?th(r,d,T):t.__canvasStrokeGradient,t.__canvasStrokeGradient=S),g&&(b=v||!t.__canvasFillPattern?eh(r,c,t):t.__canvasFillPattern,t.__canvasFillPattern=b),y&&(w=v||!t.__canvasStrokePattern?eh(r,d,t):t.__canvasStrokePattern,t.__canvasStrokePattern=w),p?r.fillStyle=_:g&&(b?r.fillStyle=b:s=!1),m?r.strokeStyle=S:y&&(w?r.strokeStyle=w:o=!1)}var D=t.getGlobalScale();h.setScale(D[0],D[1],t.segmentIgnoreThreshold);var C,M;r.setLineDash&&e.lineDash&&(a=f_(t),C=a[0],M=a[1]);var A=!0;(f||v&Yi)&&(h.setDPR(r.dpr),u?h.setContext(null):(h.setContext(r),A=!1),h.reset(),t.buildPath(h,t.shape,i),h.toStatic(),t.pathUpdated()),A&&h.rebuildPath(r,u?l:1),C&&(r.setLineDash(C),r.lineDashOffset=M),i?(n.batchFill=s,n.batchStroke=o):e.strokeFirst?(o&&Rp(r,e),s&&Ep(r,e)):(s&&Ep(r,e),o&&Rp(r,e)),C&&r.setLineDash([])}function LI(r,t,e){var i=t.__image=Jg(e.image,t.__image,t,t.onload);if(!(!i||!tl(i))){var n=e.x||0,a=e.y||0,o=t.getWidth(),s=t.getHeight(),l=i.width/i.height;if(o==null&&s!=null?o=s*l:s==null&&o!=null?s=o/l:o==null&&s==null&&(o=i.width,s=i.height),e.sWidth&&e.sHeight){var u=e.sx||0,f=e.sy||0;r.drawImage(i,u,f,e.sWidth,e.sHeight,n,a,o,s)}else if(e.sx&&e.sy){var u=e.sx,f=e.sy,h=o-u,v=s-f;r.drawImage(i,u,f,h,v,n,a,o,s)}else r.drawImage(i,n,a,o,s)}}function PI(r,t,e){var i,n=e.text;if(n!=null&&(n+=""),n){r.font=e.font||Ar,r.textAlign=e.textAlign,r.textBaseline=e.textBaseline;var a=void 0,o=void 0;r.setLineDash&&e.lineDash&&(i=f_(t),a=i[0],o=i[1]),a&&(r.setLineDash(a),r.lineDashOffset=o),e.strokeFirst?(Ws(e)&&r.strokeText(n,e.x,e.y),Ys(e)&&r.fillText(n,e.x,e.y)):(Ys(e)&&r.fillText(n,e.x,e.y),Ws(e)&&r.strokeText(n,e.x,e.y)),a&&r.setLineDash([])}}var Op=["shadowBlur","shadowOffsetX","shadowOffsetY"],kp=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function h_(r,t,e,i,n){var a=!1;if(!i&&(e=e||{},t===e))return!1;if(i||t.opacity!==e.opacity){Yt(r,n),a=!0;var o=Math.max(Math.min(t.opacity,1),0);r.globalAlpha=isNaN(o)?gi.opacity:o}(i||t.blend!==e.blend)&&(a||(Yt(r,n),a=!0),r.globalCompositeOperation=t.blend||gi.blend);for(var s=0;s0&&e.unfinished);e.unfinished||this._zr.flush()}}},t.prototype.getDom=function(){return this._dom},t.prototype.getId=function(){return this.id},t.prototype.getZr=function(){return this._zr},t.prototype.isSSR=function(){return this._ssr},t.prototype.setOption=function(e,i,n){if(!this[Dt]){if(this._disposed){this.id;return}var a,o,s;if(Z(i)&&(n=i.lazyUpdate,a=i.silent,o=i.replaceMerge,s=i.transition,i=i.notMerge),this[Dt]=!0,Gi(this),!this._model||i){var l=new zA(this._api),u=this._theme,f=this._model=new Mv;f.scheduler=this._scheduler,f.ssr=this._ssr,f.init(null,null,null,u,this._locale,l)}this._model.setOption(e,{replaceMerge:o},oh);var h={seriesTransition:s,optionChanged:!0};if(n)this[Et]={silent:a,updateParams:h},this[Dt]=!1,this.getZr().wakeUp();else{try{ai(this),er.update.call(this,null,h)}catch(v){throw this[Et]=null,this[Dt]=!1,v}this._ssr||this._zr.flush(),this[Et]=null,this[Dt]=!1,Hi.call(this,a),Vi.call(this,a)}}},t.prototype.setTheme=function(e,i){if(!this[Dt]){if(this._disposed){this.id;return}var n=this._model;if(n){var a=i&&i.silent,o=null;this[Et]&&(a==null&&(a=this[Et].silent),o=this[Et].updateParams,this[Et]=null),this[Dt]=!0,Gi(this);try{this._updateTheme(e),n.setTheme(this._theme),ai(this),er.update.call(this,{type:"setTheme"},o)}catch(s){throw this[Dt]=!1,s}this[Dt]=!1,Hi.call(this,a),Vi.call(this,a)}}},t.prototype._updateTheme=function(e){V(e)&&(e=D_[e]),e&&(e=nt(e),e&&e_(e,!0),this._theme=e)},t.prototype.getModel=function(){return this._model},t.prototype.getOption=function(){return this._model&&this._model.getOption()},t.prototype.getWidth=function(){return this._zr.getWidth()},t.prototype.getHeight=function(){return this._zr.getHeight()},t.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||et.hasGlobalWindow&&window.devicePixelRatio||1},t.prototype.getRenderedCanvas=function(e){return this.renderToCanvas(e)},t.prototype.renderToCanvas=function(e){e=e||{};var i=this._zr.painter;return i.getRenderedCanvas({backgroundColor:e.backgroundColor||this._model.get("backgroundColor"),pixelRatio:e.pixelRatio||this.getDevicePixelRatio()})},t.prototype.renderToSVGString=function(e){e=e||{};var i=this._zr.painter;return i.renderToString({useViewBox:e.useViewBox})},t.prototype.getSvgDataURL=function(){var e=this._zr,i=e.storage.getDisplayList();return x(i,function(n){n.stopAnimation(null,!0)}),e.painter.toDataURL()},t.prototype.getDataURL=function(e){if(this._disposed){this.id;return}e=e||{};var i=e.excludeComponents,n=this._model,a=[],o=this;x(i,function(l){n.eachComponent({mainType:l},function(u){var f=o._componentsMap[u.__viewId];f.group.ignore||(a.push(f),f.group.ignore=!0)})});var s=this._zr.painter.getType()==="svg"?this.getSvgDataURL():this.renderToCanvas(e).toDataURL("image/"+(e&&e.type||"png"));return x(a,function(l){l.group.ignore=!1}),s},t.prototype.getConnectedDataURL=function(e){if(this._disposed){this.id;return}var i=e.type==="svg",n=this.group,a=Math.min,o=Math.max,s=1/0;if(Qp[n]){var l=s,u=s,f=-s,h=-s,v=[],c=e&&e.pixelRatio||this.getDevicePixelRatio();x(ca,function(_,S){if(_.group===n){var b=i?_.getZr().painter.getSvgDom().innerHTML:_.renderToCanvas(nt(e)),w=_.getDom().getBoundingClientRect();l=a(w.left,l),u=a(w.top,u),f=o(w.right,f),h=o(w.bottom,h),v.push({dom:b,left:w.left,top:w.top})}}),l*=c,u*=c,f*=c,h*=c;var d=f-l,p=h-u,m=ae.createCanvas(),g=pp(m,{renderer:i?"svg":"canvas"});if(g.resize({width:d,height:p}),i){var y="";return x(v,function(_){var S=_.left-l,b=_.top-u;y+=''+_.dom+""}),g.painter.getSvgRoot().innerHTML=y,e.connectedBackgroundColor&&g.painter.setBackgroundColor(e.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}else return e.connectedBackgroundColor&&g.add(new Tt({shape:{x:0,y:0,width:d,height:p},style:{fill:e.connectedBackgroundColor}})),x(v,function(_){var S=new kr({style:{x:_.left*c-l,y:_.top*c-u,image:_.dom}});g.add(S)}),g.refreshImmediately(),m.toDataURL("image/"+(e&&e.type||"png"))}else return this.getDataURL(e)},t.prototype.convertToPixel=function(e,i,n){return ko(this,"convertToPixel",e,i,n)},t.prototype.convertToLayout=function(e,i,n){return ko(this,"convertToLayout",e,i,n)},t.prototype.convertFromPixel=function(e,i,n){return ko(this,"convertFromPixel",e,i,n)},t.prototype.containPixel=function(e,i){if(this._disposed){this.id;return}var n=this._model,a,o=eu(n,e);return x(o,function(s,l){l.indexOf("Models")>=0&&x(s,function(u){var f=u.coordinateSystem;if(f&&f.containPoint)a=a||!!f.containPoint(i);else if(l==="seriesModels"){var h=this._chartsMap[u.__viewId];h&&h.containPoint&&(a=a||h.containPoint(i,u))}},this)},this),!!a},t.prototype.getVisual=function(e,i){var n=this._model,a=eu(n,e,{defaultMainType:"series"}),o=a.seriesModel,s=o.getData(),l=a.hasOwnProperty("dataIndexInside")?a.dataIndexInside:a.hasOwnProperty("dataIndex")?s.indexOfRawIndex(a.dataIndex):null;return l!=null?mI(s,l,i):yI(s,i)},t.prototype.getViewOfComponentModel=function(e){return this._componentsMap[e.__viewId]},t.prototype.getViewOfSeriesModel=function(e){return this._chartsMap[e.__viewId]},t.prototype._initEvents=function(){var e=this;x(eL,function(n){var a=function(o){var s=e.getModel(),l=o.target,u,f=n==="globalout";if(f?u={}:l&&ta(l,function(p){var m=gt(p);if(m&&m.dataIndex!=null){var g=m.dataModel||s.getSeriesByIndex(m.seriesIndex);return u=g&&g.getDataParams(m.dataIndex,m.dataType,l)||{},!0}else if(m.eventData)return u=O({},m.eventData),!0},!0),u){var h=u.componentType,v=u.componentIndex;(h==="markLine"||h==="markPoint"||h==="markArea")&&(h="series",v=u.seriesIndex);var c=h&&v!=null&&s.getComponent(h,v),d=c&&e[c.mainType==="series"?"_chartsMap":"_componentsMap"][c.__viewId];u.event=o,u.type=n,e._$eventProcessor.eventInfo={targetEl:l,packedEvent:u,model:c,view:d},e.trigger(n,u)}};a.zrEventfulCallAtLast=!0,e._zr.on(n,a,e)});var i=this._messageCenter;x(nh,function(n,a){i.on(a,function(o){e.trigger(a,o)})}),rA(i,this,this._api)},t.prototype.isDisposed=function(){return this._disposed},t.prototype.clear=function(){if(this._disposed){this.id;return}this.setOption({series:[]},!0)},t.prototype.dispose=function(){if(this._disposed){this.id;return}this._disposed=!0;var e=this.getDom();e&&Im(this.getDom(),Iv,"");var i=this,n=i._api,a=i._model;x(i._componentsViews,function(o){o.dispose(a,n)}),x(i._chartsViews,function(o){o.dispose(a,n)}),i._zr.dispose(),i._dom=i._model=i._chartsMap=i._componentsMap=i._chartsViews=i._componentsViews=i._scheduler=i._api=i._zr=i._throttledZrFlush=i._theme=i._coordSysMgr=i._messageCenter=null,delete ca[i.id]},t.prototype.resize=function(e){if(!this[Dt]){if(this._disposed){this.id;return}this._zr.resize(e);var i=this._model;if(this._loadingFX&&this._loadingFX.resize(),!!i){var n=i.resetOption("media"),a=e&&e.silent;this[Et]&&(a==null&&(a=this[Et].silent),n=!0,this[Et]=null),this[Dt]=!0,Gi(this);try{n&&ai(this),er.update.call(this,{type:"resize",animation:O({duration:0},e&&e.animation)})}catch(o){throw this[Dt]=!1,o}this[Dt]=!1,Hi.call(this,a),Vi.call(this,a)}}},t.prototype.showLoading=function(e,i){if(this._disposed){this.id;return}if(Z(e)&&(i=e,e=""),e=e||"default",this.hideLoading(),!!sh[e]){var n=sh[e](this._api,i),a=this._zr;this._loadingFX=n,a.add(n)}},t.prototype.hideLoading=function(){if(this._disposed){this.id;return}this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null},t.prototype.makeActionFromEvent=function(e){var i=O({},e);return i.type=ih[e.type],i},t.prototype.dispatchAction=function(e,i){if(this._disposed){this.id;return}if(Z(i)||(i={silent:!!i}),!!Zs[e.type]&&this._model){if(this[Dt]){this._pendingActions.push(e);return}var n=i.silent;zu.call(this,e,n);var a=i.flush;a?this._zr.flush():a!==!1&&et.browser.weChat&&this._throttledZrFlush(),Hi.call(this,n),Vi.call(this,n)}},t.prototype.updateLabelLayout=function(){ue.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},t.prototype.appendData=function(e){if(this._disposed){this.id;return}var i=e.seriesIndex,n=this.getModel(),a=n.getSeriesByIndex(i);a.appendData(e),this._scheduler.unfinished=!0,this.getZr().wakeUp()},t.internalField=(function(){ai=function(h){nD(h._model);var v=h._scheduler;v.restorePipelines(h._zr,h._model),v.prepareStageTasks(),Nu(h,!0),Nu(h,!1),v.plan()},Nu=function(h,v){for(var c=h._model,d=h._scheduler,p=v?h._componentsViews:h._chartsViews,m=v?h._componentsMap:h._chartsMap,g=h._zr,y=h._api,_=0;_Y(v.get("hoverLayerThreshold"),j0.hoverLayerThreshold)&&!et.node&&!et.worker;(h._usingTHL||m)&&(v.eachSeries(function(g){if(!g.preventUsingHoverLayer){var y=h._chartsMap[g.__viewId];y.__alive&&y.eachRendered(function(_){var S=_.states.emphasis;S&&S.hoverLayer!==Wh&&(S.hoverLayer=m?iy:ry)})}}),h._usingTHL=m)}}function s(h,v){var c=h.get("blendMode")||null;v.eachRendered(function(d){d.isGroup||(d.style.blend=c)})}function l(h,v){if(!h.preventAutoZ){var c=xa(h);v.eachRendered(function(d){return hy(d,c.z,c.zlevel),!0})}}function u(h,v){v.eachRendered(function(c){if(!sa(c)){var d=c.getTextContent(),p=c.getTextGuideLine();c.stateTransition&&(c.stateTransition=null),d&&d.stateTransition&&(d.stateTransition=null),p&&p.stateTransition&&(p.stateTransition=null),c.hasState()?(c.prevStates=c.currentStates,c.clearStates()):c.prevStates&&(c.prevStates=null)}})}function f(h,v){var c=h.getModel("stateAnimation"),d=h.isAnimationEnabled(),p=c.get("duration"),m=p>0?{duration:p,delay:c.get("delay"),easing:c.get("easing")}:null;v.eachRendered(function(g){if(g.states&&g.states.emphasis){if(sa(g))return;if(g instanceof mt&&cw(g),g.__dirty){var y=g.prevStates;y&&g.useStates(y)}if(d){g.stateTransition=m;var _=g.getTextContent(),S=g.getTextGuideLine();_&&(_.stateTransition=m),S&&(S.stateTransition=m)}g.__dirty&&a(g)}})}qp=function(h){return new((function(v){B(c,v);function c(){return v!==null&&v.apply(this,arguments)||this}return c.prototype.getCoordinateSystems=function(){return h._coordSysMgr.getCoordinateSystems()},c.prototype.getComponentByElement=function(d){for(;d;){var p=d.__ecComponentInfo;if(p!=null)return h._model.getComponent(p.mainType,p.index);d=d.parent}},c.prototype.enterEmphasis=function(d,p){bs(d,p),se(h)},c.prototype.leaveEmphasis=function(d,p){ws(d,p),se(h)},c.prototype.enterBlur=function(d){iw(d),se(h)},c.prototype.leaveBlur=function(d){Vm(d),se(h)},c.prototype.enterSelect=function(d){Gm(d),se(h)},c.prototype.leaveSelect=function(d){Um(d),se(h)},c.prototype.getModel=function(){return h.getModel()},c.prototype.getViewOfComponentModel=function(d){return h.getViewOfComponentModel(d)},c.prototype.getViewOfSeriesModel=function(d){return h.getViewOfSeriesModel(d)},c.prototype.getECUpdateCycleVersion=function(){return h[Ro]},c.prototype.usingTHL=function(){return h._usingTHL},c})(Bm))(h)},C_=function(h){function v(c,d){for(var p=0;p=0)){Jp.push(e);var o=n_.wrapStageHandler(e,n);o.__prio=t,o.__raw=e,r.push(o)}}function P_(r,t){sh[r]=t}function lL(r,t,e){var i=SI("registerMap");i&&i(r,t,e)}var uL=lC;Di(Dv,eI);Di(Ml,rI);Di(Ml,iI);Di(Dv,pI);Di(Ml,gI);Di(__,zI);I_(e_);L_(UI,QA);P_("default",nI);Mi({type:mi,event:mi,update:mi},kt);Mi({type:Yo,event:Yo,update:Yo},kt);Mi({type:_s,event:Nh,update:_s,action:kt,refineEvent:Ev,publishNonRefinedEvent:!0});Mi({type:Df,event:Nh,update:Df,action:kt,refineEvent:Ev,publishNonRefinedEvent:!0});Mi({type:Ss,event:Nh,update:Ss,action:kt,refineEvent:Ev,publishNonRefinedEvent:!0});function Ev(r,t,e,i){return{eventContent:{selected:lw(e),isFromClick:t.isFromClick||!1}}}A_("default",{});A_("dark",l_);var jp=[],fL={registerPreprocessor:I_,registerProcessor:L_,registerPostInit:nL,registerPostUpdate:aL,registerUpdateLifecycle:Lv,registerAction:Mi,registerCoordinateSystem:oL,registerLayout:sL,registerVisual:Di,registerTransform:uL,registerLoading:P_,registerMap:lL,registerImpl:_I,PRIORITY:JI,ComponentModel:ct,ComponentView:Ae,SeriesModel:Er,ChartView:Xe,registerComponentModel:function(r){ct.registerClass(r)},registerComponentView:function(r){Ae.registerClass(r)},registerSeriesModel:function(r){Er.registerClass(r)},registerChartView:function(r){Xe.registerClass(r)},registerCustomSeries:function(r,t){},registerSubTypeDefaulter:function(r,t){ct.registerSubTypeDefaulter(r,t)},registerPainter:function(r,t){PA(r,t)}};function sr(r){if(N(r)){x(r,function(t){sr(t)});return}lt(jp,r)>=0||(jp.push(r),J(r)&&(r={install:r}),r.install(fL))}var hL=(function(){function r(){}return r.prototype.needIncludeZero=function(){return!this.option.scale},r.prototype.getCoordSysModel=function(){},r})(),lh=(function(r){B(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.getCoordSysModel=function(){return this.getReferringComponents("grid",Wt).models[0]},t.type="cartesian2dAxis",t})(ct);Ke(lh,hL);var E_={show:!0,z:0,inverse:!1,name:"",nameLocation:"end",nameRotate:null,nameTruncate:{maxWidth:null,ellipsis:"...",placeholder:"."},nameTextStyle:{},nameGap:15,silent:!1,triggerEvent:!1,tooltip:{show:!1},axisPointer:{},axisLine:{show:!0,onZero:"auto",onZeroAxisIndex:null,lineStyle:{color:z.color.axisLine,width:1,type:"solid"},symbol:["none","none"],symbolSize:[10,15],breakLine:!0},axisTick:{show:!0,inside:!1,length:5,lineStyle:{width:1}},axisLabel:{show:!0,inside:!1,rotate:0,showMinLabel:null,showMaxLabel:null,margin:8,fontSize:12,color:z.color.axisLabel,textMargin:[0,3]},splitLine:{show:!0,showMinLine:!0,showMaxLine:!0,lineStyle:{color:z.color.axisSplitLine,width:1,type:"solid"}},splitArea:{show:!1,areaStyle:{color:[z.color.backgroundTint,z.color.backgroundTransparent]}},breakArea:{show:!0,itemStyle:{color:z.color.neutral00,borderColor:z.color.border,borderWidth:1,borderType:[3,3],opacity:.6},zigzagAmplitude:4,zigzagMinSpan:4,zigzagMaxSpan:20,zigzagZ:100,expandOnClick:!0},breakLabelLayout:{moveOverlap:"auto"}},vL=ut({boundaryGap:!0,deduplication:null,jitter:0,jitterOverlap:!0,jitterMargin:2,splitLine:{show:!1},axisTick:{alignWithLabel:!1,interval:"auto",show:"auto"},axisLabel:{interval:"auto"}},E_),Rv=ut({boundaryGap:[0,0],axisLine:{show:"auto"},axisTick:{show:"auto"},splitNumber:5,minorTick:{show:!1,splitNumber:5,length:3,lineStyle:{}},minorSplitLine:{show:!1,lineStyle:{color:z.color.axisMinorSplitLine,width:1}}},E_),cL=ut({splitNumber:6,axisLabel:{rich:{primary:{fontWeight:"bold"}}},splitLine:{show:!1}},Rv),dL=vt({logBase:10},Rv);const pL={category:vL,value:Rv,time:cL,log:dL};function tg(r,t,e,i){x(g0,function(n,a){var o=ut(ut({},pL[a],!0),i,!0),s=(function(l){B(u,l);function u(){var f=l!==null&&l.apply(this,arguments)||this;return f.type=t+"Axis."+a,f}return u.prototype.mergeDefaultAndTheme=function(f,h){var v=Ma(this),c=v?Tn(f):{},d=h.getTheme();ut(f,d.get(a+"Axis")),ut(f,this.getDefaultOption()),f.type=eg(f),v&&Pr(f,c,v)},u.prototype.optionUpdated=function(){var f=this.option;f.type==="category"&&(this.__ordinalMeta=Zf.createByAxisModel(this))},u.prototype.getCategories=function(f){var h=this.option;if(h.type==="category")return f?h.data:this.__ordinalMeta.categories},u.prototype.getOrdinalMeta=function(){return this.__ordinalMeta},u.prototype.updateAxisBreaks=function(f){return{breaks:[]}},u.type=t+"Axis."+a,u.defaultOption=o,u})(e);r.registerComponentModel(s)}),r.registerSubTypeDefaulter(t+"Axis",eg)}function eg(r){return r.type||(r.data?"category":"value")}var gL=(function(){function r(t){this.type="cartesian",this._dimList=[],this._axes={},this.name=t||""}return r.prototype.getAxis=function(t){return this._axes[t]},r.prototype.getAxes=function(){return q(this._dimList,function(t){return this._axes[t]},this)},r.prototype.getAxesByScale=function(t){return t=t.toLowerCase(),zt(this.getAxes(),function(e){return e.scale.type===t})},r.prototype.addAxis=function(t){var e=t.dim;this._axes[e]=t,this._dimList.push(e)},r})(),as=["x","y"];function rg(r){return(r.type==="interval"||r.type==="time")&&!As(r)}var mL=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=es,e.dimensions=as,e}return t.prototype.calcAffineTransform=function(){this._transform=this._invTransform=null;var e=this.getAxis("x").scale,i=this.getAxis("y").scale;if(!(!rg(e)||!rg(i))){var n=Ps(e,null),a=Ps(i,null),o=this.dataToPoint([n[0],a[0]]),s=this.dataToPoint([n[1],a[1]]),l=n[1]-n[0],u=a[1]-a[0];if(!(!l||!u)){var f=(s[0]-o[0])/l,h=(s[1]-o[1])/u,v=o[0]-n[0]*f,c=o[1]-a[0]*h,d=this._transform=[f,0,0,h,v,c];this._invTransform=Na([],d)}}},t.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAxis("x")},t.prototype.containPoint=function(e){var i=this.getAxis("x"),n=this.getAxis("y");return i.contain(i.toLocalCoord(e[0]))&&n.contain(n.toLocalCoord(e[1]))},t.prototype.containData=function(e){return this.getAxis("x").containData(e[0])&&this.getAxis("y").containData(e[1])},t.prototype.containZone=function(e,i){var n=this.dataToPoint(e),a=this.dataToPoint(i),o=this.getArea(),s=new j(n[0],n[1],a[0]-n[0],a[1]-n[1]);return o.intersect(s)},t.prototype.dataToPoint=function(e,i,n){n=n||[];var a=e[0],o=e[1];if(this._transform&&a!=null&&isFinite(a)&&o!=null&&isFinite(o))return Se(n,e,this._transform);var s=this.getAxis("x"),l=this.getAxis("y");return n[0]=s.toGlobalCoord(s.dataToCoord(a,i)),n[1]=l.toGlobalCoord(l.dataToCoord(o,i)),n},t.prototype.clampData=function(e,i){var n=this.getAxis("x").scale,a=this.getAxis("y").scale,o=n.getExtent(),s=a.getExtent(),l=n.parse(e[0]),u=a.parse(e[1]);return i=i||[],i[0]=Math.min(Math.max(Math.min(o[0],o[1]),l),Math.max(o[0],o[1])),i[1]=Math.min(Math.max(Math.min(s[0],s[1]),u),Math.max(s[0],s[1])),i},t.prototype.pointToData=function(e,i,n){if(n=n||[],this._invTransform)return Se(n,e,this._invTransform);var a=this.getAxis("x"),o=this.getAxis("y");return n[0]=a.coordToData(a.toLocalCoord(e[0]),i),n[1]=o.coordToData(o.toLocalCoord(e[1]),i),n},t.prototype.getOtherAxis=function(e){return this.getAxis(e.dim==="x"?"y":"x")},t.prototype.getArea=function(e){e=e||0;var i=this.getAxis("x").getGlobalExtent(),n=this.getAxis("y").getGlobalExtent(),a=Math.min(i[0],i[1])-e,o=Math.min(n[0],n[1])-e,s=Math.max(i[0],i[1])-a+e,l=Math.max(n[0],n[1])-o+e;return new j(a,o,s,l)},t})(gL);function yL(r,t){var e=r.scale,i=r.model,n=G0(e,i,i.ecModel,r),a=dn(e),o=dn(t)?t.intervalStub:t,s=a?e.intervalStub:e,l=e.base,u=o.getTicks(),f=o.getTicks({expandToNicedExtent:!0}),h=u.length-1,v,c,d;if(h===1)v=c=0,d=1;else if(h===2){var p=Lt(u[0].value-u[1].value),m=Lt(u[1].value-u[2].value);v=c=0,p===m?d=2:(d=1,p=T[1])return!0})):S[1]?(C=T[1],P(function(){if(G(),I=st(L-M*d,A),R(),D<=T[0])return!0})):P(function(){I=st(Sn(T[0]/M)*M,A),L=st(Si(T[1]/M)*M,A);var tt=ar((L-I)/M);if(tt<=d){var X=d-tt,H=void 0,it=n.incl0||a;if(it&&T[0]===0)H=[0,X];else if(it&&T[1]===0)H=[X,0];else{var at=Si(X/2);H=X%2===0?[at,at]:D+C=T[1])return!0}})}y0(e,S,w,[D,C],b,{interval:M,intervalCount:d,intervalPrecision:A,niceExtent:[I,L]})}function ig(r,t){var e=dn(r),i=e?r.intervalStub:r,n=t.fixMinMax||[],a=e?r.getExtent():null,o=i.getExtent(),s=h0(o,n,t.rawExtentResult);i.setExtent(s[0],s[1]),s=i.getExtent();var l=e?SL(i,t):_L(i,t),u=l.intervalPrecision,f=l.interval,h=t.userInterval;h!=null&&(l.interval=h,l.intervalPrecision=xi(h)),n[0]||(s[0]=st(Si(s[0]/f)*f,u)),n[1]||(s[1]=st(Sn(s[1]/f)*f,u)),h!=null&&(l.niceExtent=s.slice()),y0(r,n,o,s,a,l)}function _L(r,t){var e=Sv(t.splitNumber,5),i=bl(r),n=t.minInterval,a=t.maxInterval,o=Ih(i/e,!0);n!=null&&oa&&(o=a);var s=xi(o),l=r.getExtent(),u=[st(Sn(l[0]/o)*o,s),st(Si(l[1]/o)*o,s)];return{interval:o,intervalPrecision:s,niceExtent:u}}function SL(r,t){var e=Sv(t.splitNumber,10),i=r.getExtent(),n=bl(r),a=dt(Tm(n),1),o=e/n*a;o<=.5&&(a*=10);var s=xi(a),l=[st(Sn(i[0]/a)*a,s),st(Si(i[1]/a)*a,s)];return{intervalPrecision:s,interval:a,niceExtent:l}}function ng(r){var t=r.scale,e=r.model,i=e.axis,n=e.ecModel;bL(t,e,i,n)}function bL(r,t,e,i,n){var a=G0(r,t,i,e),o=Es(r)||Ya(r);wL(r,{splitNumber:t.get("splitNumber"),fixMinMax:a.fixMM,userInterval:t.get("interval"),minInterval:o?t.get("minInterval"):null,maxInterval:o?t.get("maxInterval"):null,rawExtentResult:a}),e&&i&&tA(e,r,a,i)}function wL(r,t){TL[r.type](r,t)}var TL={interval:ig,log:ig,time:wM,ordinal:kt},ag=[[3,1],[0,2]],xL=(function(){function r(t,e,i){this.type="grid",this._coordsMap={},this._coordsList=[],this._axesMap={},this._axesList=[],this.axisPointerEnabled=!0,this.dimensions=as,this._initCartesian(t,e,i),this.model=t}return r.prototype.getRect=function(){return this._rect},r.prototype.update=function(t,e){var i=this._axesMap;x(this._axesList,function(o){z0(o,ZD);var s=o.scale;we(s)&&s.setSortInfo(o.model.get("categorySortInfo"))});function n(o){for(var s=St(o),l=[],u=s.length-1;u>=0;u--){var f=o[+s[u]];f.__alignTo?l.push(f):ng(f)}x(l,function(h){ML(h,h.__alignTo)?ng(h):yL(h,h.__alignTo.scale)})}n(i.x),n(i.y);var a={};x(i.x,function(o){og(i,"y",o,a)}),x(i.y,function(o){og(i,"x",o,a)}),this.resize(this.model,e)},r.prototype.resize=function(t,e,i){var n=pv(t,e),a=this._rect=cn(t.getBoxLayoutParams(),n.refContainer),o=this._axesMap,s=this._coordsList,l=t.get("containLabel");if(R_(o,a),!i){var u=AL(a,s,o,l,e),f=void 0;if(l)f=fg(a.clone(),"axisLabel",null,a,o,u,n);else{var h=IL(t,a,n),v=h.outerBoundsRect,c=h.parsedOuterBoundsContain,d=h.outerBoundsClamp;v&&(f=fg(v,c,d,a,o,u,n))}O_(a,o,De.determine,null,f,n),x(this._coordsList,function(p){p.calcAffineTransform()})}},r.prototype.getAxis=function(t,e){var i=this._axesMap[t];if(i!=null)return i[e||0]},r.prototype.getAxes=function(){return this._axesList.slice()},r.prototype.getCartesian=function(t,e){if(t!=null&&e!=null){var i="x"+t+"y"+e;return this._coordsMap[i]}Z(t)&&(e=t.yAxisIndex,t=t.xAxisIndex);for(var n=0,a=this._coordsList;n=0;n--){var a=r[+t[n]];f0(a.scale)&&EM(a.model,a.type)==null&&(a.model.get("alignTicks")&&a.model.get("interval")==null?i.push(a):e=a)}e||(e=i.pop()),e&&x(i,function(o){o.__alignTo=e})}function ML(r,t){return As(r.scale)||As(t.scale)||t.scale.getTicks().length<2}function DL(r,t){var e=r.getExtent(),i=e[0]+e[1];r.toGlobalCoord=r.dim==="x"?function(n){return n+t}:function(n){return i-n+t},r.toLocalCoord=r.dim==="x"?function(n){return n-t}:function(n){return i-n+t}}function R_(r,t){x(r.x,function(e){return ug(e,t.x,t.width)}),x(r.y,function(e){return ug(e,t.y,t.height)})}function ug(r,t,e){var i=[0,e],n=r.inverse?1:0;r.setExtent(i[n],i[1-n]),DL(r,t)}function fg(r,t,e,i,n,a,o){O_(i,n,De.estimate,t,!1,o);var s=[0,0,0,0];u(0),u(1),f(i,0,NaN),f(i,1,NaN);var l=m1(s,function(v){return v>0})==null;return Cs(i,s,!0,!0,e),R_(n,i),l;function u(v){x(n[li[v]],function(c){if(La(c.model)){var d=a.ensureRecord(c.model),p=d.labelInfoList;if(p)for(var m=0;m0&&!ga(c)&&c>1e-4&&(v/=c),v}}function AL(r,t,e,i,n){var a=new P0(LL);return x(e,function(o){return x(o,function(s){if(La(s.model)){var l=!i;s.axisBuilder=UD(r,t,s.model,n,a,l)}})}),a}function O_(r,t,e,i,n,a){var o=e===De.determine;x(t,function(u){return x(u,function(f){La(f.model)&&(WD(f.axisBuilder,r,f.model),f.axisBuilder.build(o?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:n}))})});var s={x:0,y:0};l(0),l(1);function l(u){s[li[1-u]]=r[Sa[u]]<=a.refContainer[Sa[u]]*.5?0:1-u===1?2:1}x(t,function(u,f){return x(u,function(h){La(h.model)&&((i==="all"||o)&&h.axisBuilder.build({axisName:!0},{nameMarginLevel:s[f]}),o&&h.axisBuilder.build({axisLine:!0}))})})}function IL(r,t,e){var i,n=r.get("outerBoundsMode",!0);n==="same"?i=t.clone():(n==null||n==="auto")&&(i=cn(r.get("outerBounds",!0)||U0,e.refContainer));var a=r.get("outerBoundsContain",!0),o;a==null||a==="auto"||lt(["all","axisLabel"],a)<0?o="all":o=a;var s=[Cf(Y(r.get("outerBoundsClampWidth",!0),zs[0]),t.width),Cf(Y(r.get("outerBoundsClampHeight",!0),zs[1]),t.height)];return{outerBoundsRect:i,parsedOuterBoundsContain:o,outerBoundsClamp:s}}var LL=function(r,t,e,i,n,a){var o=e.axis.dim==="x"?"y":"x";E0(r,t,e,i,n,a),pn(r.nameLocation)||x(t.recordMap[o],function(s){s&&s.labelInfoList&&s.dirVec&&O0(s.labelInfoList,s.dirVec,i,n)})};function PL(r,t){var e={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return EL(e,r,t),e.seriesInvolved&&OL(e,r),e}function EL(r,t,e){var i=t.getComponent("tooltip"),n=t.getComponent("axisPointer"),a=n.get("link",!0)||[],o=[];x(e.getCoordinateSystems(),function(s){if(!s.axisPointerEnabled)return;var l=Ra(s.model),u=r.coordSysAxesInfo[l]={};r.coordSysMap[l]=s;var f=s.model,h=f.getModel("tooltip",i);if(x(s.getAxes(),Mt(p,!1,null)),s.getTooltipAxes&&i&&h.get("show")){var v=h.get("trigger")==="axis",c=h.get(["axisPointer","type"])==="cross",d=s.getTooltipAxes(h.get(["axisPointer","axis"]));(v||c)&&x(d.baseAxes,Mt(p,c?"cross":!0,v)),c&&x(d.otherAxes,Mt(p,"cross",!1))}function p(m,g,y){var _=y.model.getModel("axisPointer",n),S=_.get("show");if(!(!S||S==="auto"&&!m&&!uh(_))){g==null&&(g=_.get("triggerTooltip")),_=m?RL(y,h,n,t,m,g):_;var b=_.get("snap"),w=_.get("triggerEmphasis"),T=Ra(y.model),D=g||b||y.type==="category",C=r.axesInfo[T]={key:T,axis:y,coordSys:s,axisPointerModel:_,triggerTooltip:g,triggerEmphasis:w,involveSeries:D,snap:b,useHandle:uh(_),seriesModels:[],linkGroup:null};u[T]=C,r.seriesInvolved=r.seriesInvolved||D;var M=kL(a,y);if(M!=null){var A=o[M]||(o[M]={axesInfo:{}});A.axesInfo[T]=C,A.mapper=a[M].mapper,C.linkGroup=A}}}})}function RL(r,t,e,i,n,a){var o=t.getModel("axisPointer"),s=["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],l={};x(s,function(v){l[v]=nt(o.get(v))}),l.snap=r.type!=="category"&&!!a,o.get("type")==="cross"&&(l.type="line");var u=l.label||(l.label={});if(u.show==null&&(u.show=!1),n==="cross"){var f=o.get(["label","show"]);if(u.show=f??!0,!a){var h=l.lineStyle=o.get("crossStyle");h&&vt(u,h.textStyle)}}return r.model.getModel("axisPointer",new wt(l,e,i))}function OL(r,t){t.eachSeries(function(e){var i=e.coordinateSystem,n=e.get(["tooltip","trigger"],!0),a=e.get(["tooltip","show"],!0);!i||!i.model||n==="none"||n===!1||n==="item"||a===!1||e.get(["axisPointer","show"],!0)===!1||x(r.coordSysAxesInfo[Ra(i.model)],function(o){var s=o.axis;i.getAxis(s.dim)===s&&(o.seriesModels.push(e),o.seriesDataCount==null&&(o.seriesDataCount=0),o.seriesDataCount+=e.getData().count())})})}function kL(r,t){for(var e=t.model,i=t.dim,n=0;n=0||r===t}function BL(r){var t=Ov(r);if(t){var e=t.axisPointerModel,i=t.axis.scale,n=e.option,a=e.get("status"),o=e.get("value");o!=null&&(o=i.parse(o));var s=uh(e);a==null&&(n.status=s?"show":"hide");var l=i.getExtent();(o==null||o>l[1])&&(o=l[1]),o3?1.4:o>1?1.2:1.1,f=a>0?u:1/u;this._checkTriggerMoveZoom(this,"zoom","zoomOnMouseWheel",e,{scale:f,originX:s,originY:l,isAvailableBehavior:null})}if(n){var h=Math.abs(a),v=(a>0?1:-1)*(h>3?.4:h>1?.15:.05);this._checkTriggerMoveZoom(this,"scrollMove","moveOnMouseWheel",e,{scrollDelta:v,originX:s,originY:l,isAvailableBehavior:null})}}}},t.prototype._pinchHandler=function(e){if(!(cg(this._zr,"globalPan")||Vn(e))){var i=e.pinchScale>1?1.1:1/1.1;this._checkTriggerMoveZoom(this,"zoom",null,e,{scale:i,originX:e.pinchX,originY:e.pinchY,isAvailableBehavior:null})}},t.prototype._checkTriggerMoveZoom=function(e,i,n,a,o){e._checkPointer(a,o.originX,o.originY)&&(mn(a.event),a.__ecRoamConsumed=!0,dg(e,i,n,a,o))},t})(Ie);function Vn(r){return r.__ecRoamConsumed}var qL=ft();function Dl(r){var t=qL(r);return t.roam=t.roam||{},t.uniform=t.uniform||{},t}function Gn(r,t,e,i){for(var n=Dl(r),a=n.roam,o=a[t]=a[t]||[],s=0;sa&&(t[1-i]=si(t[i],h.sign*a)),t}function Gu(r,t){var e=r[t]-r[1-t];return{span:Math.abs(e),sign:e>0?-1:e<0?1:t?-1:1}}function Ui(r,t){return Math.min(t[1]!=null?t[1]:1/0,Math.max(t[0]!=null?t[0]:-1/0,r))}var ui=ft(),pg=nt,Uu=Q,JL=(function(){function r(){this._dragging=!1,this.animationThreshold=15}return r.prototype.render=function(t,e,i,n){var a=e.get("value"),o=e.get("status");if(this._axisModel=t,this._axisPointerModel=e,this._api=i,!(!n&&this._lastValue===a&&this._lastStatus===o)){this._lastValue=a,this._lastStatus=o;var s=this._group,l=this._handle;if(!o||o==="hide"){s&&s.hide(),l&&l.hide();return}s&&s.show(),l&&l.show();var u={};this.makeElOption(u,a,t,e,i);var f=u.graphicKey;f!==this._lastGraphicKey&&this.clear(i),this._lastGraphicKey=f;var h=this._moveAnimation=this.determineAnimation(t,e);if(!s)s=this._group=new Pt,this.createPointerEl(s,u,t,e),this.createLabelEl(s,u,t,e),i.getZr().add(s);else{var v=Mt(gg,e,h);this.updatePointerEl(s,u,v),this.updateLabelEl(s,u,v,e)}yg(s,e,!0),this._renderHandle(a)}},r.prototype.remove=function(t){this.clear(t)},r.prototype.dispose=function(t){this.clear(t)},r.prototype.determineAnimation=function(t,e){var i=e.get("animation"),n=t.axis,a=n.type==="category",o=e.get("snap");if(!o&&!a)return!1;if(i==="auto"||i==null){var s=this.animationThreshold;if(a&&xl(n).w>s)return!0;if(o){var l=Ov(t).seriesDataCount,u=n.getExtent();return Math.abs(u[0]-u[1])/l>s}return!1}return i===!0},r.prototype.makeElOption=function(t,e,i,n,a){},r.prototype.createPointerEl=function(t,e,i,n){var a=e.pointer;if(a){var o=ui(t).pointerEl=new vT[a.type](pg(e.pointer));t.add(o)}},r.prototype.createLabelEl=function(t,e,i,n){if(e.label){var a=ui(t).labelEl=new Xt(pg(e.label));t.add(a),mg(a,n)}},r.prototype.updatePointerEl=function(t,e,i){var n=ui(t).pointerEl;n&&e.pointer&&(n.setStyle(e.pointer.style),i(n,{shape:e.pointer.shape}))},r.prototype.updateLabelEl=function(t,e,i,n){var a=ui(t).labelEl;a&&(a.setStyle(e.label.style),i(a,{x:e.label.x,y:e.label.y}),mg(a,n))},r.prototype._renderHandle=function(t){if(!(this._dragging||!this.updateHandleTransform)){var e=this._axisPointerModel,i=this._api.getZr(),n=this._handle,a=e.getModel("handle"),o=e.get("status");if(!a.get("show")||!o||o==="hide"){n&&i.remove(n),this._handle=null;return}var s;this._handle||(s=!0,n=this._handle=Zh(a.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(u){mn(u.event)},onmousedown:Uu(this._onHandleDragMove,this,0,0),drift:Uu(this._onHandleDragMove,this),ondragend:Uu(this._onHandleDragEnd,this)}),i.add(n)),yg(n,e,!1),n.setStyle(a.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var l=a.get("size");N(l)||(l=[l,l]),n.scaleX=l[0]/2,n.scaleY=l[1]/2,Cl(this,"_doDispatchAxisPointer",a.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,s)}},r.prototype._moveHandleToValue=function(t,e){gg(this._axisPointerModel,!e&&this._moveAnimation,this._handle,Wu(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},r.prototype._onHandleDragMove=function(t,e){var i=this._handle;if(i){this._dragging=!0;var n=this.updateHandleTransform(Wu(i),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=n,i.stopAnimation(),i.attr(Wu(n)),ui(i).lastProp=null,this._doDispatchAxisPointer()}},r.prototype._doDispatchAxisPointer=function(){var t=this._handle;if(t){var e=this._payloadInfo,i=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:e.cursorPoint[0],y:e.cursorPoint[1],tooltipOption:e.tooltipOption,axesInfo:[{axisDim:i.axis.dim,axisIndex:i.componentIndex}]})}},r.prototype._onHandleDragEnd=function(){this._dragging=!1;var t=this._handle;if(t){var e=this._axisPointerModel.get("value");this._moveHandleToValue(e),this._api.dispatchAction({type:"hideTip"})}},r.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),i=this._group,n=this._handle;e&&i&&(this._lastGraphicKey=null,i&&e.remove(i),n&&e.remove(n),this._group=null,this._handle=null,this._payloadInfo=null),Vs(this,"_doDispatchAxisPointer")},r.prototype.doClear=function(){},r.prototype.buildLabel=function(t,e,i){return i=i||0,{x:t[i],y:t[1-i],width:e[i],height:e[1-i]}},r})();function gg(r,t,e,i){F_(ui(e).lastProp,i)||(ui(e).lastProp=i,t?Lr(e,i,r):(e.stopAnimation(),e.attr(i)))}function F_(r,t){if(Z(r)&&Z(t)){var e=!0;return x(t,function(i,n){e=e&&F_(r[n],i)}),!!e}else return r===t}function mg(r,t){r[t.get(["label","show"])?"show":"hide"]()}function Wu(r){return{x:r.x||0,y:r.y||0,rotation:r.rotation||0}}function yg(r,t,e){var i=t.get("z"),n=t.get("zlevel");r&&r.traverse(function(a){a.type!=="group"&&(i!=null&&(a.z=i),n!=null&&(a.zlevel=n),a.silent=e)})}function jL(r){var t=r.get("type"),e=r.getModel(t+"Style"),i;return t==="line"?(i=e.getLineStyle(),i.fill=null):t==="shadow"&&(i=e.getAreaStyle(),i.stroke=null),i}function t2(r,t,e,i,n){var a=e.get("value"),o=z_(a,t.axis,t.ecModel,e.get("seriesDataIndices"),{precision:e.get(["label","precision"]),formatter:e.get(["label","formatter"])}),s=e.getModel("label"),l=Sl(s.get("padding")||0),u=s.getFont(),f=em(o,u),h=n.position,v=f.width+l[1]+l[3],c=f.height+l[0]+l[2],d=n.align;d==="right"&&(h[0]-=v),d==="center"&&(h[0]-=v/2);var p=n.verticalAlign;p==="bottom"&&(h[1]-=c),p==="middle"&&(h[1]-=c/2),e2(h,v,c,i);var m=s.get("backgroundColor");(!m||m==="auto")&&(m=t.get(["axisLine","lineStyle","color"])),r.label={x:h[0],y:h[1],style:wi(s,{text:o,font:u,fill:s.getTextColor(),padding:l,backgroundColor:m}),z2:10}}function e2(r,t,e,i){var n=i.getWidth(),a=i.getHeight();r[0]=Math.min(r[0]+t,n)-t,r[1]=Math.min(r[1]+e,a)-e,r[0]=Math.max(r[0],0),r[1]=Math.max(r[1],0)}function z_(r,t,e,i,n){r=t.scale.parse(r);var a=t.scale.getLabel({value:r},{precision:n.precision}),o=n.formatter;if(o){var s={value:Rs(t,{value:r}),axisDimension:t.dim,axisIndex:t.index,seriesData:[]};x(i,function(l){var u=e.getSeriesByIndex(l.seriesIndex),f=l.dataIndexInside,h=u&&u.getDataParams(f);h&&s.seriesData.push(h)}),V(o)?a=o.replace("{value}",a):J(o)&&(a=o(s))}return a}function H_(r,t,e){var i=Ve();return Ch(i,i,e.rotation),sf(i,i,e.position),wa([r.dataToCoord(t),(e.labelOffset||0)+(e.labelDirection||1)*(e.labelMargin||0)],i)}function r2(r,t,e,i,n,a){var o=Dr.innerTextLayout(e.rotation,0,e.labelDirection);e.labelMargin=n.get(["label","margin"]),t2(t,i,n,a,{position:H_(i.axis,r,e),align:o.textAlign,verticalAlign:o.textVerticalAlign})}function i2(r,t,e){return e=e||0,{x1:r[e],y1:r[1-e],x2:t[e],y2:t[1-e]}}function n2(r,t,e){return e=e||0,{x:r[e],y:r[1-e],width:t[e],height:t[1-e]}}function a2(r,t,e){return xl(r,{fromStat:{sers:q(t,function(i){return e.getSeriesByIndex(i.seriesIndex)})},min:1}).w}function o2(r,t,e){return[dt(re(t[0],t[1]),r-e/2),re(r+e/2,dt(t[0],t[1]))]}var s2=(function(r){B(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.makeElOption=function(e,i,n,a,o){var s=n.axis,l=s.grid,u=a.get("type"),f=s.getGlobalExtent(),h=_g(l,s).getOtherAxis(s).getGlobalExtent(),v=s.toGlobalCoord(s.dataToCoord(i,!0));if(u&&u!=="none"){var c=jL(a),d=l2[u](s,v,f,h,a.get("seriesDataIndices"),a.ecModel);d.style=c,e.graphicKey=d.type,e.pointer=d}var p=Fs(l.getRect(),n);r2(i,e,p,n,a,o)},t.prototype.getHandleTransform=function(e,i,n){var a=Fs(i.axis.grid.getRect(),i,{labelInside:!1});a.labelMargin=n.get(["handle","margin"]);var o=H_(i.axis,e,a);return{x:o[0],y:o[1],rotation:a.rotation+(a.labelDirection<0?Math.PI:0)}},t.prototype.updateHandleTransform=function(e,i,n,a){var o=n.axis,s=o.grid,l=o.getGlobalExtent(!0),u=_g(s,o).getOtherAxis(o).getGlobalExtent(),f=o.dim==="x"?0:1,h=[e.x,e.y];h[f]+=i[f],h[f]=re(l[1],h[f]),h[f]=dt(l[0],h[f]);var v=(u[1]+u[0])/2,c=[v,v];c[f]=h[f];var d=[{verticalAlign:"middle"},{align:"center"}];return{x:h[0],y:h[1],rotation:e.rotation,cursorPoint:c,tooltipOption:d[f]}},t})(JL);function _g(r,t){var e={};return e[t.dim+"AxisIndex"]=t.index,r.getCartesian(e)}var l2={line:function(r,t,e,i){var n=i2([t,i[0]],[t,i[1]],Sg(r));return{type:"Line",subPixelOptimize:!0,shape:n}},shadow:function(r,t,e,i,n,a){var o=a2(r,n,a),s=i[1]-i[0],l=o2(t,e,o),u=l[0],f=l[1];return{type:"Rect",shape:n2([u,i[0]],[f-u,s],Sg(r))}}};function Sg(r){return r.dim==="x"?0:1}var u2=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="axisPointer",t.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:z.color.border,width:1,type:"dashed"},shadowStyle:{color:z.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:z.color.neutral00,padding:[5,7,5,7],backgroundColor:z.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:z.color.accent40,throttle:40}},t})(ct),ir=ft(),f2=x;function V_(r,t,e){if(!et.node){var i=t.getZr();ir(i).records||(ir(i).records={}),h2(i,t);var n=ir(i).records[r]||(ir(i).records[r]={});n.handler=e}}function h2(r,t){if(ir(r).initialized)return;ir(r).initialized=!0,e("click",Mt(Yu,"click")),e("mousemove",Mt(Yu,"mousemove")),e("mousewheel",Mt(Yu,"mousewheel")),e("globalout",c2);function e(i,n){r.on(i,function(a){var o=d2(t);f2(ir(r).records,function(s){s&&n(s,a,o.dispatchAction)}),v2(o.pendings,t)})}}function v2(r,t){var e=r.showTip.length,i=r.hideTip.length,n;e?n=r.showTip[e-1]:i&&(n=r.hideTip[i-1]),n&&(n.dispatchAction=null,t.dispatchAction(n))}function c2(r,t,e){r.handler("leave",null,e)}function Yu(r,t,e,i){t.handler(r,e,i)}function d2(r){var t={showTip:[],hideTip:[]},e=function(i){var n=t[i.type];n?n.push(i):(i.dispatchAction=e,r.dispatchAction(i))};return{dispatchAction:e,pendings:t}}function hh(r,t){if(!et.node){var e=t.getZr(),i=(ir(e).records||{})[r];i&&(ir(e).records[r]=null)}}var p2=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,i,n){var a=i.getComponent("tooltip"),o=e.get("triggerOn")||a&&a.get("triggerOn")||"mousemove|click|mousewheel";V_("axisPointer",n,function(s,l,u){o!=="none"&&(s==="leave"||o.indexOf(s)>=0)&&u({type:"updateAxisPointer",currTrigger:s,x:l&&l.offsetX,y:l&&l.offsetY})})},t.prototype.remove=function(e,i){hh("axisPointer",i)},t.prototype.dispose=function(e,i){hh("axisPointer",i)},t.type="axisPointer",t})(Ae);function G_(r,t){var e=[],i=r.seriesIndex,n;if(i==null||!(n=t.getSeriesByIndex(i)))return{point:[]};var a=n.getData(),o=bi(a,r);if(o==null||o<0||N(o))return{point:[]};var s=a.getItemGraphicEl(o),l=n.coordinateSystem;if(n.getTooltipPosition)e=n.getTooltipPosition(o)||[];else if(l&&l.dataToPoint)if(r.isStacked){var u=l.getBaseAxis(),f=l.getOtherAxis(u),h=f.dim,v=u.dim,c=h==="x"||h==="radius"?1:0,d=a.mapDimension(v),p=[];p[c]=a.get(d,o),p[1-c]=a.get(a.getCalculationInfo("stackResultDimension"),o),e=l.dataToPoint(p)||[]}else e=l.dataToPoint(a.getValues(q(l.dimensions,function(g){return a.mapDimension(g)}),o))||[];else if(s){var m=s.getBoundingRect().clone();m.applyTransform(s.transform),e=[m.x+m.width/2,m.y+m.height/2]}return{point:e,el:s}}var bg=ft();function g2(r,t,e){var i=r.currTrigger,n=[r.x,r.y],a=r,o=r.dispatchAction||Q(e.dispatchAction,e),s=t.getComponent("axisPointer").coordSysAxesInfo;if(s){ss(n)&&(n=G_({seriesIndex:a.seriesIndex,dataIndex:a.dataIndex},t).point);var l=ss(n),u=a.axesInfo,f=s.axesInfo,h=i==="leave"||ss(n),v={},c={},d={list:[],map:{}},p={showPointer:Mt(y2,c),showTooltip:Mt(_2,d)};x(s.coordSysMap,function(g,y){var _=l||g.containPoint(n);x(s.coordSysAxesInfo[y],function(S,b){var w=S.axis,T=T2(u,S);if(!h&&_&&(!u||T)){var D=T&&T.value;D==null&&!l&&(D=w.pointToData(n)),D!=null&&wg(S,D,p,!1,v)}})});var m={};return x(f,function(g,y){var _=g.linkGroup;_&&!c[y]&&x(_.axesInfo,function(S,b){var w=c[b];if(S!==g&&w){var T=w.value;_.mapper&&(T=g.axis.scale.parse(_.mapper(T,Tg(S),Tg(g)))),m[g.key]=T}})}),x(m,function(g,y){wg(f[y],g,p,!0,v)}),S2(c,f,v),b2(d,n,r,o),w2(f,o,e),v}}function wg(r,t,e,i,n){var a=r.axis;if(!(a.scale.isBlank()||!a.containData(t))){if(!r.involveSeries){e.showPointer(r,t);return}var o=m2(t,r),s=o.payloadBatch,l=o.snapToValue;s[0]&&n.seriesIndex==null&&O(n,s[0]),!i&&r.snap&&a.containData(l)&&l!=null&&(t=l),e.showPointer(r,t,s),e.showTooltip(r,o,l)}}function m2(r,t){var e=t.axis,i=e.dim,n=r,a=[],o=Number.MAX_VALUE,s=-1;return x(t.seriesModels,function(l,u){var f=l.getData().mapDimensionsAll(i),h,v;if(l.getAxisTooltipData){var c=l.getAxisTooltipData(f,r,e);v=c.dataIndices,h=c.nestestValue}else{if(v=l.indicesOfNearest(i,f[0],r,e.type==="category"?.5:null),!v.length)return;h=l.getData().get(f[0],v[0])}if($e(h)){var d=r-h,p=Math.abs(d);p<=o&&((p=0&&s<0)&&(o=p,s=d,n=h,a.length=0),x(v,function(m){a.push({seriesIndex:l.seriesIndex,dataIndexInside:m,dataIndex:l.getData().getRawIndex(m)})}))}}),{payloadBatch:a,snapToValue:n}}function y2(r,t,e,i){r[t.key]={value:e,payloadBatch:i}}function _2(r,t,e,i){var n=e.payloadBatch,a=t.axis,o=a.model,s=t.axisPointerModel;if(!(!t.triggerTooltip||!n.length)){var l=t.coordSys.model,u=Ra(l),f=r.map[u];f||(f=r.map[u]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},r.list.push(f)),f.dataByAxis.push({axisDim:a.dim,axisIndex:o.componentIndex,axisType:o.type,axisId:o.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:n.slice()})}}function S2(r,t,e){var i=e.axesInfo=[];x(t,function(n,a){var o=n.axisPointerModel.option,s=r[a];s?(!n.useHandle&&(o.status="show"),o.value=s.value,o.seriesDataIndices=(s.payloadBatch||[]).slice()):!n.useHandle&&(o.status="hide"),o.status==="show"&&i.push({axisDim:n.axis.dim,axisIndex:n.axis.model.componentIndex,value:o.value})})}function b2(r,t,e,i){if(ss(t)||!r.list.length){i({type:"hideTip"});return}var n=((r.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:t[0],y:t[1],tooltipOption:e.tooltipOption,position:e.position,dataIndexInside:n.dataIndexInside,dataIndex:n.dataIndex,seriesIndex:n.seriesIndex,dataByCoordSys:r.list})}function w2(r,t,e){var i=e.getZr(),n="axisPointerLastHighlights",a=bg(i)[n]||{},o=bg(i)[n]={};x(r,function(f,h){var v=f.axisPointerModel.option;v.status==="show"&&f.triggerEmphasis&&x(v.seriesDataIndices,function(c){o[c.seriesIndex+"|"+c.dataIndex]=c})});var s=[],l=[];function u(f){return{seriesIndex:f.seriesIndex,dataIndex:f.dataIndex}}x(a,function(f,h){!o[h]&&l.push(u(f))}),x(o,function(f,h){!a[h]&&s.push(u(f))}),l.length&&e.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&e.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}function T2(r,t){for(var e=0;e<(r||[]).length;e++){var i=r[e];if(t.axis.dim===i.axisDim&&t.axis.model.componentIndex===i.axisIndex)return i}}function Tg(r){var t=r.axis.model,e={},i=e.axisDim=r.axis.dim;return e.axisIndex=e[i+"AxisIndex"]=t.componentIndex,e.axisName=e[i+"AxisName"]=t.name,e.axisId=e[i+"AxisId"]=t.id,e}function ss(r){return!r||r[0]==null||isNaN(r[0])||r[1]==null||isNaN(r[1])}function U_(r){k_.registerAxisPointerClass("CartesianAxisPointer",s2),r.registerComponentModel(u2),r.registerComponentView(p2),r.registerPreprocessor(function(t){if(t){(!t.axisPointer||t.axisPointer.length===0)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!N(e)&&(t.axisPointer.link=[e])}}),r.registerProcessor(r.PRIORITY.PROCESSOR.STATISTIC,{overallReset:function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=PL(t,e)}}),r.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},g2)}function BP(r){sr(WL),sr(U_)}var xg=["x","y","radius","angle","single"],x2=ft(),C2=["cartesian2d","polar","singleAxis"];function M2(r){var t=r.get("coordinateSystem");return lt(C2,t)>=0}function Tr(r){return r+"Axis"}function D2(r,t){var e=W(),i=[],n=W();r.eachComponent({mainType:"dataZoom",query:t},function(f){n.get(f.uid)||s(f)});var a;do a=!1,r.eachComponent("dataZoom",o);while(a);function o(f){!n.get(f.uid)&&l(f)&&(s(f),a=!0)}function s(f){n.set(f.uid,!0),i.push(f),u(f)}function l(f){var h=!1;return f.eachTargetAxis(function(v,c){var d=e.get(v);d&&d[c]&&(h=!0)}),h}function u(f){f.eachTargetAxis(function(h,v){(e.get(h)||e.set(h,[]))[v]=!0})}return i}function W_(r){var t=r.ecModel,e={infoList:[],infoMap:W()};return r.eachTargetAxis(function(i,n){var a=t.getComponent(Tr(i),n);if(a){var o=a.getCoordSysModel();if(o){var s=o.uid,l=e.infoMap.get(s);l||(l={model:o,axisModels:[]},e.infoList.push(l),e.infoMap.set(s,l)),l.axisModels.push(a)}}}),e}function Y_(r){var t=x2(oD(r));return t.axisProxyMap||(t.axisProxyMap=W())}function $s(r){if(r)return Y_(r.ecModel).get(r.uid)}function A2(r,t){Y_(r.ecModel).set(r.uid,t)}function Z_(r,t){var e=t.getAxisModel().axis.__alignTo;return e&&r.getAxisProxy(e.dim,e.model.componentIndex)?$s(e.model):null}var Zu=(function(){function r(){this.indexList=[],this.indexMap=[]}return r.prototype.add=function(t){this.indexMap[t]||(this.indexList.push(t),this.indexMap[t]=!0)},r})(),qs=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._autoThrottle=!0,e._noTarget=!0,e._rangePropMode=["percent","percent"],e}return t.prototype.init=function(e,i,n){var a=Cg(e);this.settledOption=a,this.mergeDefaultAndTheme(e,n),this._doInit(a)},t.prototype.mergeOption=function(e){var i=Cg(e);ut(this.option,e,!0),ut(this.settledOption,i,!0),this._doInit(i)},t.prototype._doInit=function(e){var i=this.option;this._setDefaultThrottle(e),this._updateRangeUse(e);var n=this.settledOption;x([["start","startValue"],["end","endValue"]],function(a,o){this._rangePropMode[o]==="value"&&(i[a[0]]=n[a[0]]=null)},this),this._resetTarget()},t.prototype._resetTarget=function(){var e=this.get("orient",!0),i=this._targetAxisInfoMap=W(),n=this._fillSpecifiedTargetAxis(i);n?this._orient=e||this._makeAutoOrientByTargetAxis():(this._orient=e||"horizontal",this._fillAutoTargetAxisByOrient(i,this._orient)),this._noTarget=!0,i.each(function(a){a.indexList.length&&(this._noTarget=!1)},this)},t.prototype._fillSpecifiedTargetAxis=function(e){var i=!1;return x(xg,function(n){var a=this.getReferringComponents(Tr(n),Ob);if(a.specified){i=!0;var o=new Zu;x(a.models,function(s){o.add(s.componentIndex)}),e.set(n,o)}},this),i},t.prototype._fillAutoTargetAxisByOrient=function(e,i){var n=this.ecModel,a=!0;if(a){var o=i==="vertical"?"y":"x",s=n.findComponents({mainType:o+"Axis"});l(s,o)}if(a){var s=n.findComponents({mainType:"singleAxis",filter:function(f){return f.get("orient",!0)===i}});l(s,"single")}function l(u,f){var h=u[0];if(h){var v=new Zu;if(v.add(h.componentIndex),e.set(f,v),a=!1,f==="x"||f==="y"){var c=h.getReferringComponents("grid",Wt).models[0];c&&x(u,function(d){h.componentIndex!==d.componentIndex&&c===d.getReferringComponents("grid",Wt).models[0]&&v.add(d.componentIndex)})}}}a&&x(xg,function(u){if(a){var f=n.findComponents({mainType:Tr(u),filter:function(v){return v.get("type",!0)==="category"}});if(f[0]){var h=new Zu;h.add(f[0].componentIndex),e.set(u,h),a=!1}}},this)},t.prototype._makeAutoOrientByTargetAxis=function(){var e;return this.eachTargetAxis(function(i){!e&&(e=i)},this),e==="y"?"vertical":"horizontal"},t.prototype._setDefaultThrottle=function(e){if(e.hasOwnProperty("throttle")&&(this._autoThrottle=!1),this._autoThrottle){var i=this.ecModel.option;this.option.throttle=i.animation&&i.animationDurationUpdate>0?100:20}},t.prototype._updateRangeUse=function(e){var i=this._rangePropMode,n=this.get("rangeMode");x([["start","startValue"],["end","endValue"]],function(a,o){var s=e[a[0]]!=null,l=e[a[1]]!=null;s&&!l?i[o]="percent":!s&&l?i[o]="value":n?i[o]=n[o]:s&&(i[o]="percent")})},t.prototype.noTarget=function(){return this._noTarget},t.prototype.getFirstTargetAxisModel=function(){var e;return this.eachTargetAxis(function(i,n){e==null&&(e=this.ecModel.getComponent(Tr(i),n))},this),e},t.prototype.eachTargetAxis=function(e,i){this._targetAxisInfoMap.each(function(n,a){x(n.indexList,function(o){e.call(i,a,o)})})},t.prototype.getAxisProxy=function(e,i){return $s(this.getAxisModel(e,i))},t.prototype.getAxisModel=function(e,i){var n=this._targetAxisInfoMap.get(e);if(n&&n.indexMap[i])return this.ecModel.getComponent(Tr(e),i)},t.prototype.setRawRange=function(e){var i=this.option,n=this.settledOption;x([["start","startValue"],["end","endValue"]],function(a){(e[a[0]]!=null||e[a[1]]!=null)&&(i[a[0]]=n[a[0]]=e[a[0]],i[a[1]]=n[a[1]]=e[a[1]])},this),this._updateRangeUse(e)},t.prototype.setCalculatedRange=function(e){var i=this.option;x(["start","startValue","end","endValue"],function(n){i[n]=e[n]})},t.prototype.getPercentRange=function(){var e=this.findRepresentativeAxisProxy();if(e)return e.getWindow().percent},t.prototype.getValueRange=function(e,i){if(e==null&&i==null){var n=this.findRepresentativeAxisProxy();if(n)return n.getWindow().value}else return this.getAxisProxy(e,i).getWindow().value},t.prototype.findRepresentativeAxisProxy=function(e){if(e)return $s(e);for(var i,n=this._targetAxisInfoMap.keys(),a=0;ao[1];if(_&&!S&&!b)return!0;_&&(m=!0),S&&(d=!0),b&&(p=!0)}return m&&d&&p})}else x(f,function(c){if(a==="empty")l.setData(u=u.map(c,function(p){return s(p)?p:NaN}));else{var d={};d[c]=o,u.selectRange(d)}});x(f,function(c){u.setApproximateExtent(o,c)})}});function s(l){return l>=o[0]&&l<=o[1]}},r.prototype._updateMinMaxSpan=function(){var t=this._minMaxSpan={},e=this._dataZoomModel,i=this._extent;x(["min","max"],function(n){var a=e.get(n+"Span"),o=e.get(n+"ValueSpan");o!=null&&(o=this.getAxisModel().axis.scale.parse(o)),o!=null?a=At(i[0]+o,i,[0,100],!0):a!=null&&(o=At(a,[0,100],i,!0)-i[0]),t[n+"Span"]=a,t[n+"ValueSpan"]=o},this)},r})(),L2={dirtyOnOverallProgress:!0,getTargetSeries:function(r){function t(n){r.eachComponent("dataZoom",function(a){a.eachTargetAxis(function(o,s){var l=r.getComponent(Tr(o),s);n(o,s,l,a)})})}var e=[];t(function(n,a,o,s){if(!$s(o)){var l=new I2(n,a,s,r);e.push(l),A2(o,l)}});var i=W();return x(e,function(n){x(n.getTargetSeriesModels(),function(a){i.set(a.uid,a)})}),i},overallReset:function(r,t){r.eachComponent("dataZoom",function(e){var i=[];e.eachTargetAxis(function(n,a){var o=e.getAxisProxy(n,a),s=Z_(e,o);s?i.push([o,s]):o.reset(e,null)}),x(i,function(n){n[0].reset(e,n[1].getWindow().percentInverted)}),e.eachTargetAxis(function(n,a){e.getAxisProxy(n,a).filterData(e,t)})}),r.eachComponent("dataZoom",function(e){var i=e.findRepresentativeAxisProxy();if(i){var n=i.getWindow(),a=n.percent,o=n.value;e.setCalculatedRange({start:a[0],end:a[1],startValue:o[0],endValue:o[1]})}})}};function P2(r){r.registerAction("dataZoom",function(t,e){var i=D2(e,t);x(i,function(n){n.setRawRange({start:t.start,end:t.end,startValue:t.startValue,endValue:t.endValue})})})}var E2=Em();function $_(r){E2(r,function(){r.registerProcessor(r.PRIORITY.PROCESSOR.FILTER,L2),P2(r),r.registerSubTypeDefaulter("dataZoom",function(){return"slider"})})}function R2(r,t){var e=Sl(t.get("padding")),i=t.getItemStyle(["color","opacity"]);i.fill=t.get("backgroundColor");var n=new Tt({shape:{x:r.x-e[3],y:r.y-e[0],width:r.width+e[1]+e[3],height:r.height+e[0]+e[2],r:t.get("borderRadius")},style:i,silent:!0,z2:-1});return n}var O2=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="tooltip",t.dependencies=["axisPointer"],t.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click|mousewheel",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:z.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:z.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:z.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:z.color.tertiary,fontSize:14}},t})(ct);function q_(r){var t=r.get("confine");return t!=null?!!t:r.get("renderMode")==="richText"}function K_(r){if(et.domSupported){for(var t=document.documentElement.style,e=0,i=r.length;e-1?(s+="top:50%",l+="translateY(-50%) rotate("+(u=a==="left"?-225:-45)+"deg)"):(s+="left:50%",l+="translateX(-50%) rotate("+(u=a==="top"?225:45)+"deg)");var f=u*Math.PI/180,h=o+n,v=h*Math.abs(Math.cos(f))+h*Math.abs(Math.sin(f)),c=Math.round(((v-Math.SQRT2*n)/2+Math.SQRT2*n-(v-h)/2)*100)/100;s+=";"+a+":-"+c+"px";var d=t+" solid "+n+"px;",p=["position:absolute;width:"+o+"px;height:"+o+"px;z-index:-1;",s+";"+l+";","border-bottom:"+d,"border-right:"+d,"background-color:"+i+";"];return'
'}function V2(r,t,e){var i="cubic-bezier(0.23,1,0.32,1)",n="",a="";return e&&(n=" "+r/2+"s "+i,a="opacity"+n+",visibility"+n),t||(n=" "+r+"s "+i,a+=(a.length?",":"")+(et.transformSupported?""+kv+n:",left"+n+",top"+n)),N2+":"+a}function Mg(r,t,e){var i=r.toFixed(0)+"px",n=t.toFixed(0)+"px";if(!et.transformSupported)return e?"top:"+n+";left:"+i+";":[["top",n],["left",i]];var a=et.transform3dSupported,o="translate"+(a?"3d":"")+"("+i+","+n+(a?",0":"")+")";return e?"top:0;left:0;"+kv+":"+o+";":[["top",0],["left",0],[Q_,o]]}function G2(r){var t=[],e=r.get("fontSize"),i=r.getTextColor();i&&t.push("color:"+i),t.push("font:"+r.getFont());var n=Y(r.get("lineHeight"),Math.round(e*3/2));e&&t.push("line-height:"+n+"px");var a=r.get("textShadowColor"),o=r.get("textShadowBlur")||0,s=r.get("textShadowOffsetX")||0,l=r.get("textShadowOffsetY")||0;return a&&o&&t.push("text-shadow:"+s+"px "+l+"px "+o+"px "+a),x(["decoration","align"],function(u){var f=r.get(u);f&&t.push("text-"+u+":"+f)}),t.join(";")}function U2(r,t,e,i){var n=[],a=r.get("transitionDuration"),o=r.get("backgroundColor"),s=r.get("shadowBlur"),l=r.get("shadowColor"),u=r.get("shadowOffsetX"),f=r.get("shadowOffsetY"),h=r.getModel("textStyle"),v=jy(r,"html"),c=u+"px "+f+"px "+s+"px "+l;return n.push("box-shadow:"+c),t&&a>0&&n.push(V2(a,e,i)),o&&n.push("background-color:"+o),x(["width","color","radius"],function(d){var p="border-"+d,m=Yy(p),g=r.get(m);g!=null&&n.push(p+":"+g+(d==="color"?"":"px"))}),n.push(G2(h)),v!=null&&n.push("padding:"+Sl(v).join("px ")+"px"),n.join(";")+";"}function Dg(r,t,e,i,n){var a=t&&t.painter;if(e){var o=a&&a.getViewportRoot();o&&Tx(r,o,e,i,n)}else{r[0]=i,r[1]=n;var s=a&&a.getViewportRootOffset();s&&(r[0]+=s.offsetLeft,r[1]+=s.offsetTop)}r[2]=r[0]/t.getWidth(),r[3]=r[1]/t.getHeight()}var W2=(function(){function r(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,et.wxa)return null;var i=document.createElement("div");i.domBelongToZr=!0,this.el=i;var n=this._zr=t.getZr(),a=e.appendTo,o=a&&(V(a)?document.querySelector(a):pa(a)?a:J(a)&&a(t.getDom()));Dg(this._styleCoord,n,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(i),this._api=t,this._container=o;var s=this;i.onmouseenter=function(){s._enterable&&(clearTimeout(s._hideTimeout),s._show=!0),s._inContent=!0},i.onmousemove=function(l){if(l=l||window.event,!s._enterable){var u=n.handler,f=n.painter.getViewportRoot();fe(f,l,!0),u.dispatch("mousemove",l)}},i.onmouseleave=function(){s._inContent=!1,s._enterable&&s._show&&s.hideLater(s._hideDelay)}}return r.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),i=B2(e,"position"),n=e.style;n.position!=="absolute"&&i!=="absolute"&&(n.position="relative")}var a=t.get("alwaysShowContent");a&&this._moveIfResized(),this._alwaysShowContent=a,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},r.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var i=this.el,n=i.style,a=this._styleCoord;i.innerHTML?n.cssText=F2+U2(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+Mg(a[0],a[1],!0)+("border-color:"+Ti(e)+";")+(t.get("extraCssText")||"")+(";pointer-events:"+(this._enterable?"auto":"none")):n.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},r.prototype.setContent=function(t,e,i,n,a){var o=this.el;if(t==null){o.innerHTML="";return}var s="";if(V(a)&&i.get("trigger")==="item"&&!q_(i)&&(s=H2(i,n,a)),V(t))o.innerHTML=t+s;else if(t){o.innerHTML="",N(t)||(t=[t]);for(var l=0;l=0?this._tryShow(a,o):n==="leave"&&this._hide(o))},this))},t.prototype._keepShow=function(){var e=this._tooltipModel,i=this._ecModel,n=this._api,a=e.get("triggerOn");if(e.get("trigger")!=="axis"&&(this._lastDataByCoordSys=null,this._cbParamsList=null),this._lastX!=null&&this._lastY!=null&&a!=="none"&&a!=="click"){var o=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&o.manuallyShowTip(e,i,n,{x:o._lastX,y:o._lastY,dataByCoordSys:o._lastDataByCoordSys})})}},t.prototype.manuallyShowTip=function(e,i,n,a){if(!(a.from===this.uid||et.node||!n.getDom())){var o=Lg(a,n);this._ticket="";var s=a.dataByCoordSys,l=Q2(a,i,n);if(l){var u=l.el.getBoundingRect().clone();u.applyTransform(l.el.transform),this._tryShow({offsetX:u.x+u.width/2,offsetY:u.y+u.height/2,target:l.el,position:a.position,positionDefault:"bottom"},o)}else if(a.tooltip&&a.x!=null&&a.y!=null){var f=Z2;f.x=a.x,f.y=a.y,f.update(),gt(f).tooltipConfig={name:null,option:a.tooltip},this._tryShow({offsetX:a.x,offsetY:a.y,target:f},o)}else if(s)this._tryShow({offsetX:a.x,offsetY:a.y,position:a.position,dataByCoordSys:s,tooltipOption:a.tooltipOption},o);else if(a.seriesIndex!=null){if(this._manuallyAxisShowTip(e,i,n,a))return;var h=G_(a,i),v=h.point[0],c=h.point[1];v!=null&&c!=null&&this._tryShow({offsetX:v,offsetY:c,target:h.el,position:a.position,positionDefault:"bottom"},o)}else a.x!=null&&a.y!=null&&(n.dispatchAction({type:"updateAxisPointer",x:a.x,y:a.y}),this._tryShow({offsetX:a.x,offsetY:a.y,position:a.position,target:n.getZr().findHover(a.x,a.y).target},o))}},t.prototype.manuallyHideTip=function(e,i,n,a){var o=this._tooltipContent;this._tooltipModel&&o.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,this._cbParamsList=null,a.from!==this.uid&&this._hide(Lg(a,n))},t.prototype._manuallyAxisShowTip=function(e,i,n,a){var o=a.seriesIndex,s=a.dataIndex,l=i.getComponent("axisPointer").coordSysAxesInfo;if(!(o==null||s==null||l==null)){var u=i.getSeriesByIndex(o);if(u){var f=u.getData(),h=Wn([f.getItemModel(s),u,(u.coordinateSystem||{}).model],this._tooltipModel);if(h.get("trigger")==="axis")return n.dispatchAction({type:"updateAxisPointer",seriesIndex:o,dataIndex:s,position:a.position}),!0}}},t.prototype._tryShow=function(e,i){var n=e.target,a=this._tooltipModel;if(a){this._lastX=e.offsetX,this._lastY=e.offsetY;var o=e.dataByCoordSys;if(o&&o.length)this._showAxisTooltip(o,e);else if(n){var s=gt(n);if(s.ssrType==="legend")return;this._lastDataByCoordSys=null,this._cbParamsList=null;var l,u;ta(n,function(f){if(f.tooltipDisabled)return l=u=null,!0;l||u||(gt(f).dataIndex!=null?l=f:gt(f).tooltipConfig!=null&&(u=f))},!0),l?this._showSeriesItemTooltip(e,l,i):u?this._showComponentItemTooltip(e,u,i):this._hide(i)}else this._lastDataByCoordSys=null,this._cbParamsList=null,this._hide(i)}},t.prototype._showOrMove=function(e,i){var n=e.get("showDelay");i=Q(i,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(i,n):i()},t.prototype._showAxisTooltip=function(e,i){var n=this._ecModel,a=this._tooltipModel,o=[i.offsetX,i.offsetY],s=Wn([i.tooltipOption],a),l=this._renderMode,u=[],f=Da("section",{blocks:[],noHeader:!0}),h=[],v=new yu;x(e,function(y){x(y.dataByAxis,function(_){var S=n.getComponent(_.axisDim+"Axis",_.axisIndex),b=_.value,w=S.axis,T=w.scale.parse(b);if(!(!S||b==null)){var D=z_(b,w,n,_.seriesDataIndices,_.valueLabelOpt),C=Da("section",{header:D,noHeader:!ze(D),sortBlocks:!0,blocks:[]});f.blocks.push(C),x(_.seriesDataIndices,function(M){var A=n.getSeriesByIndex(M.seriesIndex),L=M.dataIndexInside,I=A.getDataParams(L);if(!(I.dataIndex<0)){I.axisDim=_.axisDim,I.axisIndex=_.axisIndex,I.axisType=_.axisType,I.axisId=_.axisId,I.axisValue=Rs(S.axis,{value:T}),I.axisValueLabel=D,I.marker=v.makeTooltipMarker("item",Ti(I.color),l);var P=bd(A.formatTooltip(L,!0,null)),R=P.frag;if(R){var E=Wn([A],a).get("valueFormatter");C.blocks.push(E?O({valueFormatter:E},R):R)}P.text&&h.push(P.text),u.push(I)}})}})}),f.blocks.reverse(),h.reverse();var c=i.position,d=s.get("order"),p=Cd(f,v,l,d,n.get("useUTC"),s.get("textStyle"));p&&h.unshift(p);var m=l==="richText"?` + +`:"
",g=h.join(m);this._showOrMove(s,function(){this._updateContentNotChangedOnAxis(e,u)?this._updatePosition(s,c,o[0],o[1],this._tooltipContent,u):this._showTooltipContent(s,g,u,Math.random()+"",o[0],o[1],c,null,v)})},t.prototype._showSeriesItemTooltip=function(e,i,n){var a=this._ecModel,o=gt(i),s=o.seriesIndex,l=a.getSeriesByIndex(s),u=o.dataModel||l,f=o.dataIndex,h=o.dataType,v=u.getData(h),c=this._renderMode,d=e.positionDefault,p=Wn([v.getItemModel(f),u,l&&(l.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),m=p.get("trigger");if(!(m!=null&&m!=="item")){var g=u.getDataParams(f,h),y=new yu;g.marker=y.makeTooltipMarker("item",Ti(g.color),c);var _=bd(u.formatTooltip(f,!1,h)),S=p.get("order"),b=p.get("valueFormatter"),w=_.frag,T=w?Cd(b?O({valueFormatter:b},w):w,y,c,S,a.get("useUTC"),p.get("textStyle")):_.text,D="item_"+u.name+"_"+f;this._showOrMove(p,function(){this._showTooltipContent(p,T,g,D,e.offsetX,e.offsetY,e.position,e.target,y)}),n({type:"showTip",dataIndexInside:f,dataIndex:v.getRawIndex(f),seriesIndex:s,from:this.uid})}},t.prototype._showComponentItemTooltip=function(e,i,n){var a=this._renderMode==="html",o=gt(i),s=o.tooltipConfig,l=s.option||{},u=l.encodeHTMLContent;if(V(l)){var f=l;l={content:f,formatter:f},u=!0}u&&a&&l.content&&(l=nt(l),l.content=Qt(l.content));var h=[l],v=this._ecModel.getComponent(o.componentMainType,o.componentIndex);v&&h.push(v),h.push({formatter:l.content});var c=e.positionDefault,d=Wn(h,this._tooltipModel,c?{position:c}:null),p=d.get("content"),m=Math.random()+"",g=new yu;this._showOrMove(d,function(){var y=nt(d.get("formatterParams")||{});this._showTooltipContent(d,p,y,m,e.offsetX,e.offsetY,e.position,i,g)}),n({type:"showTip",from:this.uid})},t.prototype._showTooltipContent=function(e,i,n,a,o,s,l,u,f){if(this._ticket="",!(!e.get("showContent")||!e.get("show"))){var h=this._tooltipContent;h.setEnterable(e.get("enterable"));var v=e.get("formatter");l=l||e.get("position");var c=i,d=this._getNearestPoint([o,s],n,e.get("trigger"),e.get("borderColor"),e.get("defaultBorderColor",!0)),p=d.color;if(v)if(V(v)){var m=e.ecModel.get("useUTC"),g=N(n)?n[0]:n,y=g&&g.axisType&&g.axisType.indexOf("time")>=0;c=v,y&&(c=_l(g.axisValue,c,m)),c=Zy(c,n,!0)}else if(J(v)){var _=Q(function(S,b){S===this._ticket&&(h.setContent(b,f,e,p,l),this._updatePosition(e,l,o,s,h,n,u))},this);this._ticket=a,c=v(n,a,_)}else c=v;h.setContent(c,f,e,p,l),h.show(e,p),this._updatePosition(e,l,o,s,h,n,u)}},t.prototype._getNearestPoint=function(e,i,n,a,o){if(n==="axis"||N(i))return{color:a||o};if(!N(i))return{color:a||i.color||i.borderColor}},t.prototype._updatePosition=function(e,i,n,a,o,s,l){var u=this._api.getWidth(),f=this._api.getHeight();i=i||e.get("position");var h=o.getSize(),v=e.get("align"),c=e.get("verticalAlign"),d=l&&l.getBoundingRect().clone();if(l&&d.applyTransform(l.transform),J(i)&&(i=i([n,a],s,o.el,d,{viewSize:[u,f],contentSize:h.slice()})),N(i))n=ge(i[0],u),a=ge(i[1],f);else if(Z(i)){var p=i;p.width=h[0],p.height=h[1];var m=cn(p,{width:u,height:f});n=m.x,a=m.y,v=null,c=null}else if(V(i)&&l){var g=K2(i,d,h,e.get("borderWidth"));n=g[0],a=g[1]}else{var g=$2(n,a,o,u,f,v?null:20,c?null:20);n=g[0],a=g[1]}if(v&&(n-=Pg(v)?h[0]/2:v==="right"?h[0]:0),c&&(a-=Pg(c)?h[1]/2:c==="bottom"?h[1]:0),q_(e)){var g=q2(n,a,o,u,f);n=g[0],a=g[1]}o.moveTo(n,a)},t.prototype._updateContentNotChangedOnAxis=function(e,i){var n=this._lastDataByCoordSys,a=this._cbParamsList,o=!!n&&n.length===e.length;return o&&x(n,function(s,l){var u=s.dataByAxis||[],f=e[l]||{},h=f.dataByAxis||[];o=o&&u.length===h.length,o&&x(u,function(v,c){var d=h[c]||{},p=v.seriesDataIndices||[],m=d.seriesDataIndices||[];o=o&&v.value===d.value&&v.axisType===d.axisType&&v.axisId===d.axisId&&p.length===m.length,o&&x(p,function(g,y){var _=m[y];o=o&&g.seriesIndex===_.seriesIndex&&g.dataIndex===_.dataIndex}),a&&x(v.seriesDataIndices,function(g){var y=g.seriesIndex,_=i[y],S=a[y];_&&S&&S.data!==_.data&&(o=!1)})})}),this._lastDataByCoordSys=e,this._cbParamsList=i,!!o},t.prototype._hide=function(e){this._lastDataByCoordSys=null,this._cbParamsList=null,e({type:"hideTip",from:this.uid})},t.prototype.dispose=function(e,i){et.node||!i.getDom()||(Vs(this,"_updatePosition"),this._tooltipContent.dispose(),hh("itemTooltip",i),this._tooltipContent=null,this._tooltipModel=null,this._lastDataByCoordSys=null,this._cbParamsList=null)},t.type="tooltip",t})(Ae);function Wn(r,t,e){var i=t.ecModel,n;e?(n=new wt(e,i,i),n=new wt(t.option,n,i)):n=t;for(var a=r.length-1;a>=0;a--){var o=r[a];o&&(o instanceof wt&&(o=o.get("tooltip",!0)),V(o)&&(o={formatter:o}),o&&(n=new wt(o,n,i)))}return n}function Lg(r,t){return r.dispatchAction||Q(t.dispatchAction,t)}function $2(r,t,e,i,n,a,o){var s=e.getSize(),l=s[0],u=s[1];return a!=null&&(r+l+a+2>i?r-=l+a:r+=a),o!=null&&(t+u+o>n?t-=u+o:t+=o),[r,t]}function q2(r,t,e,i,n){var a=e.getSize(),o=a[0],s=a[1];return r=Math.min(r+o,i)-o,t=Math.min(t+s,n)-s,r=Math.max(r,0),t=Math.max(t,0),[r,t]}function K2(r,t,e,i){var n=e[0],a=e[1],o=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,u=t.width,f=t.height;switch(r){case"inside":s=t.x+u/2-n/2,l=t.y+f/2-a/2;break;case"top":s=t.x+u/2-n/2,l=t.y-a-o;break;case"bottom":s=t.x+u/2-n/2,l=t.y+f+o;break;case"left":s=t.x-n-o,l=t.y+f/2-a/2;break;case"right":s=t.x+u+o,l=t.y+f/2-a/2}return[s,l]}function Pg(r){return r==="center"||r==="middle"}function Q2(r,t,e){var i=Eh(r).queryOptionMap,n=i.keys()[0];if(!(!n||n==="series")){var a=Ga(t,n,i.get(n),{useDefault:!1,enableAll:!1,enableNone:!1}),o=a.models[0];if(o){var s=e.getViewOfComponentModel(o),l;if(s.group.traverse(function(u){var f=gt(u).tooltipConfig;if(f&&f.name===r.name)return l=u,!0}),l)return{componentMainType:n,componentIndex:o.componentIndex,el:l}}}}function NP(r){sr(U_),r.registerComponentModel(O2),r.registerComponentView(X2),r.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},kt),r.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},kt)}var J2=function(r,t){if(t==="all")return{type:"all",title:r.getLocaleModel().get(["legend","selector","all"])};if(t==="inverse")return{type:"inverse",title:r.getLocaleModel().get(["legend","selector","inverse"])}},vh=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.layoutMode={type:"box",ignoreSize:!0},e}return t.prototype.init=function(e,i,n){this.mergeDefaultAndTheme(e,n),e.selected=e.selected||{},this._updateSelector(e)},t.prototype.mergeOption=function(e,i){r.prototype.mergeOption.call(this,e,i),this._updateSelector(e)},t.prototype._updateSelector=function(e){var i=e.selector,n=this.ecModel;i===!0&&(i=e.selector=["all","inverse"]),N(i)&&x(i,function(a,o){V(a)&&(a={type:a}),i[o]=ut(a,J2(n,a.type))})},t.prototype.optionUpdated=function(){this._updateData(this.ecModel);var e=this._data;if(e[0]&&this.get("selectedMode")==="single"){for(var i=!1,n=0;n=0},t.prototype.getOrient=function(){return this.get("orient")==="vertical"?{index:1,name:"vertical"}:{index:0,name:"horizontal"}},t.type="legend.plain",t.dependencies=["series"],t.defaultOption={z:4,show:!0,orient:"horizontal",left:"center",bottom:z.size.m,align:"auto",backgroundColor:z.color.transparent,borderColor:z.color.border,borderRadius:0,borderWidth:0,padding:5,itemGap:8,itemWidth:25,itemHeight:14,symbolRotate:"inherit",symbolKeepAspect:!0,inactiveColor:z.color.disabled,inactiveBorderColor:z.color.disabled,inactiveBorderWidth:"auto",itemStyle:{color:"inherit",opacity:"inherit",borderColor:"inherit",borderWidth:"auto",borderCap:"inherit",borderJoin:"inherit",borderDashOffset:"inherit",borderMiterLimit:"inherit"},lineStyle:{width:"auto",color:"inherit",inactiveColor:z.color.disabled,inactiveWidth:2,opacity:"inherit",type:"inherit",cap:"inherit",join:"inherit",dashOffset:"inherit",miterLimit:"inherit"},textStyle:{color:z.color.secondary},selectedMode:!0,selector:!1,selectorLabel:{show:!0,borderRadius:10,padding:[3,5,3,5],fontSize:12,fontFamily:"sans-serif",color:z.color.tertiary,borderWidth:1,borderColor:z.color.border},emphasis:{selectorLabel:{show:!0,color:z.color.quaternary}},selectorPosition:"auto",selectorItemGap:7,selectorButtonGap:10,tooltip:{show:!1},triggerEvent:!1},t})(ct),Wi=Mt,ch=x,No=Pt,j_=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.newlineDisabled=!1,e}return t.prototype.init=function(){this.group.add(this._contentGroup=new No),this.group.add(this._selectorGroup=new No),this._isFirstRender=!0},t.prototype.getContentGroup=function(){return this._contentGroup},t.prototype.getSelectorGroup=function(){return this._selectorGroup},t.prototype.render=function(e,i,n){var a=this._isFirstRender;if(this._isFirstRender=!1,this.resetInner(),!!e.get("show",!0)){var o=e.get("align"),s=e.get("orient");(!o||o==="auto")&&(o=e.get("left")==="right"&&s==="vertical"?"right":"left");var l=e.get("selector",!0),u=e.get("selectorPosition",!0);l&&(!u||u==="auto")&&(u=s==="horizontal"?"end":"start"),this.renderInner(o,e,i,n,l,s,u);var f=pv(e,n).refContainer,h=e.getBoxLayoutParams(),v=e.get("padding"),c=cn(h,f,v),d=this.layoutInner(e,o,c,a,l,u),p=cn(vt({width:d.width,height:d.height},h),f,v);this.group.x=p.x-d.x,this.group.y=p.y-d.y,this.group.markRedraw(),this.group.add(this._backgroundEl=R2(d,e))}},t.prototype.resetInner=function(){this.getContentGroup().removeAll(),this._backgroundEl&&this.group.remove(this._backgroundEl),this.getSelectorGroup().removeAll()},t.prototype.renderInner=function(e,i,n,a,o,s,l){var u=this.getContentGroup(),f=W(),h=i.get("selectedMode"),v=i.get("triggerEvent"),c=[];n.eachRawSeries(function(d){!d.get("legendHoverLink")&&c.push(d.id)}),ch(i.getData(),function(d,p){var m=this,g=d.get("name");if(!this.newlineDisabled&&(g===""||g===` +`)){var y=new No;y.newline=!0,u.add(y);return}var _=n.getSeriesByName(g)[0];if(!f.get(g))if(_){var S=_.getData(),b=S.getVisual("legendLineStyle")||{},w=S.getVisual("legendIcon"),T=S.getVisual("style"),D=this._createItem(_,g,p,d,i,e,b,T,w,h,a);D.on("click",Wi(Eg,g,null,a,c)).on("mouseover",Wi(dh,_.name,null,a,c)).on("mouseout",Wi(ph,_.name,null,a,c)),n.ssr&&D.eachChild(function(C){var M=gt(C);M.seriesIndex=_.seriesIndex,M.dataIndex=p,M.ssrType="legend"}),v&&D.eachChild(function(C){m.packEventData(C,i,_,p,g)}),f.set(g,!0)}else n.eachRawSeries(function(C){var M=this;if(!f.get(g)&&C.legendVisualProvider){var A=C.legendVisualProvider;if(!A.containName(g))return;var L=A.indexOfName(g),I=A.getItemVisual(L,"style"),P=A.getItemVisual(L,"legendIcon"),R=We(I.fill);R&&R[3]===0&&(R[3]=.2,I=O(O({},I),{fill:za(R,"rgba")}));var E=this._createItem(C,g,p,d,i,e,{},I,P,h,a);E.on("click",Wi(Eg,null,g,a,c)).on("mouseover",Wi(dh,null,g,a,c)).on("mouseout",Wi(ph,null,g,a,c)),n.ssr&&E.eachChild(function(F){var G=gt(F);G.seriesIndex=C.seriesIndex,G.dataIndex=p,G.ssrType="legend"}),v&&E.eachChild(function(F){M.packEventData(F,i,C,p,g)}),f.set(g,!0)}},this)},this),o&&this._createSelector(o,i,a,s,l)},t.prototype.packEventData=function(e,i,n,a,o){var s={componentType:"legend",componentIndex:i.componentIndex,dataIndex:a,value:o,seriesIndex:n.seriesIndex};gt(e).eventData=s},t.prototype._createSelector=function(e,i,n,a,o){var s=this.getSelectorGroup();ch(e,function(u){var f=u.type,h=new Xt({style:{x:0,y:0,align:"center",verticalAlign:"middle"},onclick:function(){n.dispatchAction({type:f==="all"?"legendAllSelect":"legendInverseSelect",legendId:i.id})}});s.add(h);var v=i.getModel("selectorLabel"),c=i.getModel(["emphasis","selectorLabel"]);Kh(h,{normal:v,emphasis:c},{defaultText:u.title}),Ts(h)})},t.prototype._createItem=function(e,i,n,a,o,s,l,u,f,h,v){var c=e.visualDrawType,d=o.get("itemWidth"),p=o.get("itemHeight"),m=o.isSelected(i),g=a.get("symbolRotate"),y=a.get("symbolKeepAspect"),_=a.get("icon");f=_||f||"roundRect";var S=j2(f,a,l,u,c,m,v),b=new No,w=a.getModel("textStyle");if(J(e.getLegendIcon)&&(!_||_==="inherit"))b.add(e.getLegendIcon({itemWidth:d,itemHeight:p,icon:f,iconRotate:g,itemStyle:S.itemStyle,lineStyle:S.lineStyle,symbolKeepAspect:y}));else{var T=_==="inherit"&&e.getData().getVisual("symbol")?g==="inherit"?e.getData().getVisual("symbolRotate"):g:0;b.add(tP({itemWidth:d,itemHeight:p,icon:f,iconRotate:T,itemStyle:S.itemStyle,symbolKeepAspect:y}))}var D=s==="left"?d+5:-5,C=s,M=o.get("formatter"),A=i;V(M)&&M?A=M.replace("{name}",i??""):J(M)&&(A=M(i));var L=m?w.getTextColor():a.get("inactiveColor");b.add(new Xt({style:wi(w,{text:A,x:D,y:p/2,fill:L,align:C,verticalAlign:"middle"},{inheritColor:L})}));var I=new Tt({shape:b.getBoundingRect(),style:{fill:"transparent"}}),P=a.getModel("tooltip");return P.get("show")&&dl({el:I,componentModel:o,itemName:i,itemTooltipOption:P.option}),b.add(I),b.eachChild(function(R){R.silent=!0}),I.silent=!h,this.getContentGroup().add(b),Ts(b),b.__legendDataIndex=n,b},t.prototype.layoutInner=function(e,i,n,a,o,s){var l=this.getContentGroup(),u=this.getSelectorGroup();fa(e.get("orient"),l,e.get("itemGap"),n.width,n.height);var f=l.getBoundingRect(),h=[-f.x,-f.y];if(u.markRedraw(),l.markRedraw(),o){fa("horizontal",u,e.get("selectorItemGap",!0));var v=u.getBoundingRect(),c=[-v.x,-v.y],d=e.get("selectorButtonGap",!0),p=e.getOrient().index,m=p===0?"width":"height",g=p===0?"height":"width",y=p===0?"y":"x";s==="end"?c[p]+=f[m]+d:h[p]+=v[m]+d,c[1-p]+=f[g]/2-v[g]/2,u.x=c[0],u.y=c[1],l.x=h[0],l.y=h[1];var _={x:0,y:0};return _[m]=f[m]+d+v[m],_[g]=Math.max(f[g],v[g]),_[y]=Math.min(0,v[y]+c[1-p]),_}else return l.x=h[0],l.y=h[1],this.group.getBoundingRect()},t.prototype.remove=function(){this.getContentGroup().removeAll(),this._isFirstRender=!0},t.type="legend.plain",t})(Ae);function j2(r,t,e,i,n,a,o){function s(m,g){m.lineWidth==="auto"&&(m.lineWidth=g.lineWidth>0?2:0),ch(m,function(y,_){m[_]==="inherit"&&(m[_]=g[_])})}var l=t.getModel("itemStyle"),u=l.getItemStyle(),f=r.lastIndexOf("empty",0)===0?"fill":"stroke",h=l.getShallow("decal");u.decal=!h||h==="inherit"?i.decal:rh(h,o),u.fill==="inherit"&&(u.fill=i[n]),u.stroke==="inherit"&&(u.stroke=i[f]),u.opacity==="inherit"&&(u.opacity=(n==="fill"?i:e).opacity),s(u,i);var v=t.getModel("lineStyle"),c=v.getLineStyle();if(s(c,e),u.fill==="auto"&&(u.fill=i.fill),u.stroke==="auto"&&(u.stroke=i.fill),c.stroke==="auto"&&(c.stroke=i.fill),!a){var d=t.get("inactiveBorderWidth"),p=u[f];u.lineWidth=d==="auto"?i.lineWidth>0&&p?2:0:u.lineWidth,u.fill=t.get("inactiveColor"),u.stroke=t.get("inactiveBorderColor"),c.stroke=v.get("inactiveColor"),c.lineWidth=v.get("inactiveWidth")}return{itemStyle:u,lineStyle:c}}function tP(r){var t=r.icon||"roundRect",e=Rr(t,0,0,r.itemWidth,r.itemHeight,r.itemStyle.fill,r.symbolKeepAspect);return e.setStyle(r.itemStyle),e.rotation=(r.iconRotate||0)*Math.PI/180,e.setOrigin([r.itemWidth/2,r.itemHeight/2]),t.indexOf("empty")>-1&&(e.style.stroke=e.style.fill,e.style.fill=z.color.neutral00,e.style.lineWidth=2),e}function Eg(r,t,e,i){ph(r,t,e,i),e.dispatchAction({type:"legendToggleSelect",name:r??t}),dh(r,t,e,i)}function dh(r,t,e,i){e.usingTHL()||e.dispatchAction({type:"highlight",seriesName:r,name:t,excludeSeriesId:i})}function ph(r,t,e,i){e.usingTHL()||e.dispatchAction({type:"downplay",seriesName:r,name:t,excludeSeriesId:i})}function Yn(r,t,e){var i=r==="allSelect"||r==="inverseSelect",n={},a=[];e.eachComponent({mainType:"legend",query:t},function(s){i?s[r]():s[r](t.name),Rg(s,n),a.push(s.componentIndex)});var o={};return e.eachComponent("legend",function(s){x(n,function(l,u){s[l?"select":"unSelect"](u)}),Rg(s,o)}),i?{selected:o,legendIndex:a}:{name:t.name,selected:o}}function Rg(r,t){var e=t||{};return x(r.getData(),function(i){var n=i.get("name");if(!(n===` +`||n==="")){var a=r.isSelected(n);jt(e,n)?e[n]=e[n]&&a:e[n]=a}}),e}function eP(r){r.registerAction("legendToggleSelect","legendselectchanged",Mt(Yn,"toggleSelected")),r.registerAction("legendAllSelect","legendselectall",Mt(Yn,"allSelect")),r.registerAction("legendInverseSelect","legendinverseselect",Mt(Yn,"inverseSelect")),r.registerAction("legendSelect","legendselected",Mt(Yn,"select")),r.registerAction("legendUnSelect","legendunselected",Mt(Yn,"unSelect"))}var rP=Oh(iP);function iP(r){var t=r.findComponents({mainType:"legend"});t&&t.length&&r.filterSeries(function(e){for(var i=0;in[o],m=[-c.x,-c.y];i||(m[a]=f[u]);var g=[0,0],y=[-d.x,-d.y],_=Y(e.get("pageButtonGap",!0),e.get("itemGap",!0));if(p){var S=e.get("pageButtonPosition",!0);S==="end"?y[a]+=n[o]-d[o]:g[a]+=d[o]+_}y[1-a]+=c[s]/2-d[s]/2,f.setPosition(m),h.setPosition(g),v.setPosition(y);var b={x:0,y:0};if(b[o]=p?n[o]:c[o],b[s]=Math.max(c[s],d[s]),b[l]=Math.min(0,d[l]+y[1-a]),h.__rectSize=n[o],p){var w={x:0,y:0};w[o]=Math.max(n[o]-d[o]-_,0),w[s]=b[s],h.setClipPath(new Tt({shape:w})),h.__rectSize=w[o]}else v.eachChild(function(D){D.attr({invisible:!0,silent:!0})});var T=this._getPageInfo(e);return T.pageIndex!=null&&Lr(f,{x:T.contentPosition[0],y:T.contentPosition[1]},p?e:null),this._updatePageInfoView(e,T),b},t.prototype._pageGo=function(e,i,n){var a=this._getPageInfo(i)[e];a!=null&&n.dispatchAction({type:"legendScroll",scrollDataIndex:a,legendId:i.id})},t.prototype._updatePageInfoView=function(e,i){var n=this._controllerGroup;x(["pagePrev","pageNext"],function(f){var h=f+"DataIndex",v=i[h]!=null,c=n.childOfName(f);c&&(c.setStyle("fill",v?e.get("pageIconColor",!0):e.get("pageIconInactiveColor",!0)),c.cursor=v?"pointer":"default")});var a=n.childOfName("pageText"),o=e.get("pageFormatter"),s=i.pageIndex,l=s!=null?s+1:0,u=i.pageCount;a&&o&&a.setStyle("text",V(o)?o.replace("{current}",l==null?"":l+"").replace("{total}",u==null?"":u+""):o({current:l,total:u}))},t.prototype._getPageInfo=function(e){var i=e.get("scrollDataIndex",!0),n=this.getContentGroup(),a=this._containerGroup.__rectSize,o=e.getOrient().index,s=Xu[o],l=$u[o],u=this._findTargetItemIndex(i),f=n.children(),h=f[u],v=f.length,c=v?1:0,d={contentPosition:[n.x,n.y],pageCount:c,pageIndex:c-1,pagePrevDataIndex:null,pageNextDataIndex:null};if(!h)return d;var p=S(h);d.contentPosition[o]=-p.s;for(var m=u+1,g=p,y=p,_=null;m<=v;++m)_=S(f[m]),(!_&&y.e>g.s+a||_&&!b(_,g.s))&&(y.i>g.i?g=y:g=_,g&&(d.pageNextDataIndex==null&&(d.pageNextDataIndex=g.i),++d.pageCount)),y=_;for(var m=u-1,g=p,y=p,_=null;m>=-1;--m)_=S(f[m]),(!_||!b(y,_.s))&&g.i=T&&w.s<=T+a}},t.prototype._findTargetItemIndex=function(e){if(!this._showController)return 0;var i,n=this.getContentGroup(),a;return n.eachChild(function(o,s){var l=o.__legendDataIndex;a==null&&l!=null&&(a=s),l===e&&(i=s)}),i??a},t.type="legend.scroll",t})(j_);function oP(r){r.registerAction("legendScroll","legendscroll",function(t,e){var i=t.scrollDataIndex;i!=null&&e.eachComponent({mainType:"legend",subType:"scroll",query:t},function(n){n.setScrollDataIndex(i)})})}function sP(r){sr(t1),r.registerComponentModel(nP),r.registerComponentView(aP),oP(r)}function FP(r){sr(t1),sr(sP)}var lP=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="dataZoom.inside",t.defaultOption=rv(qs.defaultOption,{disabled:!1,zoomLock:!1,zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!1,preventDefaultMouseMove:!0}),t})(qs),Bv=ft();function uP(r,t,e){Bv(r).coordSysRecordMap.each(function(i){var n=i.dataZoomInfoMap.get(t.uid);n&&(n.getRange=e)})}function fP(r,t){for(var e=Bv(r).coordSysRecordMap,i=e.keys(),n=0;na[n+i]&&(i=h),o=o&&f.get("preventDefaultMouseMove",!0),s=Y(f.get("cursorGrab",!0),s),l=Y(f.get("cursorGrabbing",!0),l)}),{controlType:i,opt:{zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!0,preventDefaultMouseMove:!!o,api:e,zInfo:{component:t.model},triggerInfo:{roamTrigger:null,isInSelf:t.containsPoint},cursorGrab:s,cursorGrabbing:l}}}function pP(r){r.registerUpdateLifecycle("coordsys:aftercreate",function(t,e){var i=Bv(e),n=i.coordSysRecordMap||(i.coordSysRecordMap=W());n.each(function(a){a.dataZoomInfoMap=null}),t.eachComponent({mainType:"dataZoom",subType:"inside"},function(a){var o=W_(a);x(o.infoList,function(s){var l=s.model.uid,u=n.get(l)||n.set(l,hP(e,s.model)),f=u.dataZoomInfoMap||(u.dataZoomInfoMap=W());f.set(a.uid,{dzReferCoordSysInfo:s,model:a,getRange:null})})}),n.each(function(a){var o=a.controller,s,l=a.dataZoomInfoMap;if(l){var u=l.keys()[0];u!=null&&(s=l.get(u))}if(!s){e1(n,a);return}var f=dP(l,a,e);o.enable(f.controlType,f.opt),Cl(a,"dispatchAction",s.model.get("throttle",!0),"fixRate")})})}var gP=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type="dataZoom.inside",e}return t.prototype.render=function(e,i,n){if(r.prototype.render.apply(this,arguments),e.noTarget()){this._clear();return}this.range=e.getPercentRange(),uP(n,e,{pan:Q(qu.pan,this),zoom:Q(qu.zoom,this),scrollMove:Q(qu.scrollMove,this)})},t.prototype.dispose=function(){this._clear(),r.prototype.dispose.apply(this,arguments)},t.prototype._clear=function(){fP(this.api,this.dataZoomModel),this.range=null},t.type="dataZoom.inside",t})(X_),qu={zoom:function(r,t,e,i){var n=this.range,a=n.slice(),o=r.axisModels[0];if(o){var s=Ku[t](null,[i.originX,i.originY],o,e,r),l=(s.signal>0?s.pixelStart+s.pixelLength-s.pixel:s.pixel-s.pixelStart)/s.pixelLength*(a[1]-a[0])+a[0],u=Math.max(1/i.scale,0);a[0]=(a[0]-l)*u+l,a[1]=(a[1]-l)*u+l;var f=this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();if(Oa(0,a,[0,100],0,f.minSpan,f.maxSpan),this.range=a,n[0]!==a[0]||n[1]!==a[1])return a}},pan:Bg(function(r,t,e,i,n,a){var o=Ku[i]([a.oldX,a.oldY],[a.newX,a.newY],t,n,e);return o.signal*(r[1]-r[0])*o.pixel/o.pixelLength}),scrollMove:Bg(function(r,t,e,i,n,a){var o=Ku[i]([0,0],[a.scrollDelta,a.scrollDelta],t,n,e);return o.signal*(r[1]-r[0])*a.scrollDelta})};function Bg(r){return function(t,e,i,n){var a=this.range,o=a.slice(),s=t.axisModels[0];if(s){var l=r(o,s,t,e,i,n);if(Oa(l,o,[0,100],"all"),this.range=o,a[0]!==o[0]||a[1]!==o[1])return o}}}var Ku={grid:function(r,t,e,i,n){var a=e.axis,o={},s=n.model.coordinateSystem.getRect();return r=r||[0,0],a.dim==="x"?(o.pixel=t[0]-r[0],o.pixelLength=s.width,o.pixelStart=s.x,o.signal=a.inverse?1:-1):(o.pixel=t[1]-r[1],o.pixelLength=s.height,o.pixelStart=s.y,o.signal=a.inverse?-1:1),o},polar:function(r,t,e,i,n){var a=e.axis,o={},s=n.model.coordinateSystem,l=s.getRadiusAxis().getExtent(),u=s.getAngleAxis().getExtent();return r=r?s.pointToCoord(r):[0,0],t=s.pointToCoord(t),e.mainType==="radiusAxis"?(o.pixel=t[0]-r[0],o.pixelLength=l[1]-l[0],o.pixelStart=l[0],o.signal=a.inverse?1:-1):(o.pixel=t[1]-r[1],o.pixelLength=u[1]-u[0],o.pixelStart=u[0],o.signal=a.inverse?-1:1),o},singleAxis:function(r,t,e,i,n){var a=e.axis,o=n.model.coordinateSystem.getRect(),s={};return r=r||[0,0],a.orient==="horizontal"?(s.pixel=t[0]-r[0],s.pixelLength=o.width,s.pixelStart=o.x,s.signal=a.inverse?1:-1):(s.pixel=t[1]-r[1],s.pixelLength=o.height,s.pixelStart=o.y,s.signal=a.inverse?-1:1),s}};function mP(r){$_(r),r.registerComponentModel(lP),r.registerComponentView(gP),pP(r)}var yP=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="dataZoom.slider",t.layoutMode="box",t.defaultOption=rv(qs.defaultOption,{show:!0,right:"ph",top:"ph",width:"ph",height:"ph",left:null,bottom:null,borderColor:z.color.accent10,borderRadius:0,backgroundColor:z.color.transparent,dataBackground:{lineStyle:{color:z.color.accent30,width:.5},areaStyle:{color:z.color.accent20,opacity:.2}},selectedDataBackground:{lineStyle:{color:z.color.accent40,width:.5},areaStyle:{color:z.color.accent20,opacity:.3}},fillerColor:"rgba(135,175,274,0.2)",handleIcon:"path://M-9.35,34.56V42m0-40V9.5m-2,0h4a2,2,0,0,1,2,2v21a2,2,0,0,1-2,2h-4a2,2,0,0,1-2-2v-21A2,2,0,0,1-11.35,9.5Z",handleSize:"100%",handleStyle:{color:z.color.neutral00,borderColor:z.color.accent20},moveHandleSize:7,moveHandleIcon:"path://M-320.9-50L-320.9-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-348-41-339-50-320.9-50z M-212.3-50L-212.3-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-239.4-41-230.4-50-212.3-50z M-103.7-50L-103.7-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-130.9-41-121.8-50-103.7-50z",moveHandleStyle:{color:z.color.accent40,opacity:.5},showDetail:!0,showDataShadow:"auto",realtime:!0,zoomLock:!1,textStyle:{color:z.color.tertiary},brushSelect:!0,brushStyle:{color:z.color.accent30,opacity:.3},emphasis:{handleLabel:{show:!0},handleStyle:{borderColor:z.color.accent40},moveHandleStyle:{opacity:.8}},defaultLocationEdgeGap:15}),t})(qs),Zn=Tt,_P=1,Qu=30,SP=7,Xn="horizontal",Ng="vertical",bP=5,wP=["line","bar","candlestick","scatter"],TP={easing:"cubicOut",duration:100,delay:0},xP=(function(r){B(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._displayables={},e}return t.prototype.init=function(e,i){this.api=i,this._onBrush=Q(this._onBrush,this),this._onBrushEnd=Q(this._onBrushEnd,this)},t.prototype.render=function(e,i,n,a){if(r.prototype.render.apply(this,arguments),Cl(this,"_dispatchZoomAction",e.get("throttle"),"fixRate"),this._orient=e.getOrient(),e.get("show")===!1){this.group.removeAll();return}if(e.noTarget()){this._clear(),this.group.removeAll();return}(!a||a.type!=="dataZoom"||a.from!==this.uid)&&this._buildView(),this._updateView()},t.prototype.dispose=function(){this._clear(),r.prototype.dispose.apply(this,arguments)},t.prototype._clear=function(){Vs(this,"_dispatchZoomAction");var e=this.api.getZr();e.off("mousemove",this._onBrush),e.off("mouseup",this._onBrushEnd)},t.prototype._buildView=function(){var e=this.group;e.removeAll(),this._brushing=!1,this._displayables.brushRect=null,this._resetLocation(),this._resetInterval();var i=this._displayables.sliderGroup=new Pt;this._renderBackground(),this._renderHandle(),this._renderDataShadow(),e.add(i),this._positionGroup()},t.prototype._resetLocation=function(){var e=this.dataZoomModel,i=this.api,n=e.get("brushSelect"),a=n?SP:0,o=pv(e,i).refContainer,s=this._findCoordRect(),l=e.get("defaultLocationEdgeGap",!0)||0,u=this._orient===Xn?{right:o.width-s.x-s.width,top:o.height-Qu-l-a,width:s.width,height:Qu}:{right:l,top:s.y,width:Qu,height:s.height},f=Tn(e.option);x(["right","top","width","height"],function(v){f[v]==="ph"&&(f[v]=u[v])});var h=cn(f,o);this._location={x:h.x,y:h.y},this._size=[h.width,h.height],this._orient===Ng&&this._size.reverse()},t.prototype._positionGroup=function(){var e=this.group,i=this._location,n=this._orient,a=this.dataZoomModel.getFirstTargetAxisModel(),o=a&&a.get("inverse"),s=this._displayables.sliderGroup,l=(this._dataShadowInfo||{}).otherAxisInverse;s.attr(n===Xn&&!o?{scaleY:l?1:-1,scaleX:1}:n===Xn&&o?{scaleY:l?1:-1,scaleX:-1}:n===Ng&&!o?{scaleY:l?-1:1,scaleX:1,rotation:Math.PI/2}:{scaleY:l?-1:1,scaleX:-1,rotation:Math.PI/2});var u=e.getBoundingRect([s]),f=isNaN(u.x)?0:u.x,h=isNaN(u.y)?0:u.y;e.x=i.x-f,e.y=i.y-h,e.markRedraw()},t.prototype._getViewExtent=function(){return[0,this._size[0]]},t.prototype._renderBackground=function(){var e=this.dataZoomModel,i=this._size,n=this._displayables.sliderGroup,a=e.get("brushSelect");n.add(new Zn({silent:!0,shape:{x:0,y:0,width:i[0],height:i[1]},style:{fill:e.get("backgroundColor")},z2:-40}));var o=new Zn({shape:{x:0,y:0,width:i[0],height:i[1]},style:{fill:"transparent"},z2:0,onclick:Q(this._onClickPanel,this)}),s=this.api.getZr();a?(o.on("mousedown",this._onBrushStart,this),o.cursor="crosshair",s.on("mousemove",this._onBrush),s.on("mouseup",this._onBrushEnd)):(s.off("mousemove",this._onBrush),s.off("mouseup",this._onBrushEnd)),n.add(o)},t.prototype._renderDataShadow=function(){var e=this._dataShadowInfo=this._prepareDataShadowInfo();if(this._displayables.dataShadowSegs=[],!e)return;var i=this._size,n=this._shadowSize||[],a=e.series,o=a.getRawData(),s=a.getShadowDim&&a.getShadowDim(),l=s&&o.getDimensionInfo(s)?a.getShadowDim():e.otherDim;if(l==null)return;var u=this._shadowPolygonPts,f=this._shadowPolylinePts;if(o!==this._shadowData||l!==this._shadowDim||i[0]!==n[0]||i[1]!==n[1]){var h=o.getDataExtent(e.thisDim),v=o.getDataExtent(l),c=(v[1]-v[0])*.3;v=[v[0]-c,v[1]+c];var d=[0,i[1]],p=[0,i[0]],m=[[i[0],0],[0,0]],g=[],y=p[1]/Math.max(1,o.count()-1),_=i[0]/(h[1]-h[0]),S=e.thisAxis.type==="time",b=-y,w=Math.round(o.count()/i[0]),T;o.each([e.thisDim,l],function(L,I,P){if(w>0&&P%w){S||(b+=y);return}b=S?(+L-h[0])*_:b+y;var R=I==null||isNaN(I)||I==="",E=R?0:At(I,v,d,!0);R&&!T&&P?(m.push([m[m.length-1][0],0]),g.push([g[g.length-1][0],0])):!R&&T&&(m.push([b,0]),g.push([b,0])),R||(m.push([b,E]),g.push([b,E])),T=R}),u=this._shadowPolygonPts=m,f=this._shadowPolylinePts=g}this._shadowData=o,this._shadowDim=l,this._shadowSize=[i[0],i[1]];var D=this.dataZoomModel;function C(L){var I=D.getModel(L?"selectedDataBackground":"dataBackground"),P=new Pt,R=new hl({shape:{points:u},segmentIgnoreThreshold:1,style:I.getModel("areaStyle").getAreaStyle(),silent:!0,z2:-20}),E=new vl({shape:{points:f},segmentIgnoreThreshold:1,style:I.getModel("lineStyle").getLineStyle(),silent:!0,z2:-19});return P.add(R),P.add(E),P}for(var M=0;M<3;M++){var A=C(M===1);this._displayables.sliderGroup.add(A),this._displayables.dataShadowSegs.push(A)}},t.prototype._prepareDataShadowInfo=function(){var e=this.dataZoomModel,i=e.get("showDataShadow");if(i!==!1){var n,a=this.ecModel;return e.eachTargetAxis(function(o,s){var l=e.getAxisProxy(o,s).getTargetSeriesModels();x(l,function(u){if(!n&&!(i!==!0&<(wP,u.get("type"))<0)){var f=a.getComponent(Tr(o),s).axis,h=CP(o),v,c=u.coordinateSystem;h!=null&&c.getOtherAxis&&(v=c.getOtherAxis(f).inverse),h=u.getData().mapDimension(h);var d=u.getData().mapDimension(o);n={thisAxis:f,series:u,thisDim:d,otherDim:h,otherAxisInverse:v}}},this)},this),n}},t.prototype._renderHandle=function(){var e=this.group,i=this._displayables,n=i.handles=[null,null],a=i.handleLabels=[null,null],o=this._displayables.sliderGroup,s=this._size,l=this.dataZoomModel,u=this.api,f=l.get("borderRadius")||0,h=l.get("brushSelect"),v=i.filler=new Zn({silent:h,style:{fill:l.get("fillerColor")},textConfig:{position:"inside"}});o.add(v),o.add(new Zn({silent:!0,subPixelOptimize:!0,shape:{x:0,y:0,width:s[0],height:s[1],r:f},style:{stroke:l.get("dataBackgroundColor")||l.get("borderColor"),lineWidth:_P,fill:z.color.transparent}})),x([0,1],function(_){var S=l.get("handleIcon");!Is[S]&&S.indexOf("path://")<0&&S.indexOf("image://")<0&&(S="path://"+S);var b=Rr(S,-1,0,2,2,null,!0);b.attr({cursor:MP(this._orient),draggable:!0,drift:Q(this._onDragMove,this,_),ondragend:Q(this._onDragEnd,this),onmouseover:Q(this._onOverDataInfoTriggerArea,this,!0),onmouseout:Q(this._onOverDataInfoTriggerArea,this,!1),z2:5});var w=b.getBoundingRect(),T=l.get("handleSize");this._handleHeight=ge(T,this._size[1]),this._handleWidth=w.width/w.height*this._handleHeight,b.setStyle(l.getModel("handleStyle").getItemStyle()),b.style.strokeNoScale=!0,b.rectHover=!0,b.ensureState("emphasis").style=l.getModel(["emphasis","handleStyle"]).getItemStyle(),Ts(b);var D=l.get("handleColor");D!=null&&(b.style.fill=D),o.add(n[_]=b);var C=l.getModel("textStyle"),M=l.get("handleLabel")||{},A=M.show||!1;e.add(a[_]=new Xt({silent:!0,invisible:!A,style:wi(C,{x:0,y:0,text:"",verticalAlign:"middle",align:"center",fill:C.getTextColor(),font:C.getFont()}),z2:10}))},this);var c=v;if(h){var d=ge(l.get("moveHandleSize"),s[1]),p=i.moveHandle=new Tt({style:l.getModel("moveHandleStyle").getItemStyle(),silent:!0,shape:{r:[0,0,2,2],y:s[1]-.5,height:d}}),m=d*.8,g=i.moveHandleIcon=Rr(l.get("moveHandleIcon"),-m/2,-m/2,m,m,z.color.neutral00,!0);g.silent=!0,g.y=s[1]+d/2-.5,p.ensureState("emphasis").style=l.getModel(["emphasis","moveHandleStyle"]).getItemStyle();var y=Math.min(s[1]/2,Math.max(d,10));c=i.moveZone=new Tt({invisible:!0,shape:{y:s[1]-y,height:d+y}}),c.on("mouseover",function(){u.enterEmphasis(p)}).on("mouseout",function(){u.leaveEmphasis(p)}),o.add(p),o.add(g),o.add(c)}c.attr({draggable:!0,cursor:"grab",drift:Q(this._onActualMoveZoneDrift,this),ondragstart:Q(this._onActualMoveZoneDragStart,this),ondragend:Q(this._onActualMoveZoneDragEnd,this),onmouseover:Q(this._onOverDataInfoTriggerArea,this,!0),onmouseout:Q(this._onOverDataInfoTriggerArea,this,!1)})},t.prototype._resetInterval=function(){var e=this._range=this.dataZoomModel.getPercentRange(),i=this._getViewExtent();this._handleEnds=[At(e[0],[0,100],i,!0),At(e[1],[0,100],i,!0)]},t.prototype._updateInterval=function(e,i){var n=this.dataZoomModel,a=this._handleEnds,o=this._getViewExtent(),s=n.findRepresentativeAxisProxy().getMinMaxSpan(),l=[0,100];Oa(i,a,o,n.get("zoomLock")?"all":e,s.minSpan!=null?At(s.minSpan,l,o,!0):null,s.maxSpan!=null?At(s.maxSpan,l,o,!0):null);var u=this._range,f=this._range=Sr([At(a[0],o,l,!0),At(a[1],o,l,!0)]);return!u||u[0]!==f[0]||u[1]!==f[1]},t.prototype._updateView=function(e){var i=this._displayables,n=this._handleEnds,a=Sr(n.slice()),o=this._size;x([0,1],function(c){var d=i.handles[c],p=this._handleHeight;d.attr({scaleX:p/2,scaleY:p/2,x:n[c]+(c?-1:1),y:o[1]/2-p/2})},this),i.filler.setShape({x:a[0],y:0,width:a[1]-a[0],height:o[1]});var s={x:a[0],width:a[1]-a[0]};i.moveHandle&&(i.moveHandle.setShape(s),i.moveZone.setShape(s),i.moveZone.getBoundingRect(),i.moveHandleIcon&&i.moveHandleIcon.attr("x",s.x+s.width/2));for(var l=i.dataShadowSegs,u=[0,a[0],a[1],o[0]],f=0;fi[0]||n[1]<0||n[1]>i[1])){var a=this._handleEnds,o=(a[0]+a[1])/2,s=this._updateInterval("all",n[0]-o);this._updateView(),s&&this._dispatchZoomAction(!1)}},t.prototype._onBrushStart=function(e){var i=e.offsetX,n=e.offsetY;this._brushStart=new pt(i,n),this._brushing=!0,this._brushStartTime=+new Date},t.prototype._onBrushEnd=function(e){if(this._brushing){var i=this._displayables.brushRect;if(this._brushing=!1,!!i){i.attr("ignore",!0);var n=i.shape,a=+new Date;if(!(a-this._brushStartTime<200&&Math.abs(n.width)<5)){var o=this._getViewExtent(),s=[0,100],l=this._handleEnds=[n.x,n.x+n.width],u=this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();Oa(0,l,o,0,u.minSpan!=null?At(u.minSpan,s,o,!0):null,u.maxSpan!=null?At(u.maxSpan,s,o,!0):null),this._range=Sr([At(l[0],o,s,!0),At(l[1],o,s,!0)]),this._updateView(),this._dispatchZoomAction(!1)}}}},t.prototype._onBrush=function(e){this._brushing&&(mn(e.event),this._updateBrushRect(e.offsetX,e.offsetY))},t.prototype._updateBrushRect=function(e,i){var n=this._displayables,a=this.dataZoomModel,o=n.brushRect;o||(o=n.brushRect=new Zn({silent:!0,style:a.getModel("brushStyle").getItemStyle()}),n.sliderGroup.add(o)),o.attr("ignore",!1);var s=this._brushStart,l=this._displayables.sliderGroup,u=l.transformCoordToLocal(e,i),f=l.transformCoordToLocal(s.x,s.y),h=this._size;u[0]=Math.max(Math.min(h[0],u[0]),0),o.setShape({x:f[0],y:0,width:u[0]-f[0],height:h[1]})},t.prototype._dispatchZoomAction=function(e){var i=this._range;this.api.dispatchAction({type:"dataZoom",from:this.uid,dataZoomId:this.dataZoomModel.id,animation:e?TP:null,start:i[0],end:i[1]})},t.prototype._findCoordRect=function(){var e,i=W_(this.dataZoomModel).infoList;if(!e&&i.length){var n=i[0].model.coordinateSystem;e=n.getRect&&n.getRect()}if(!e){var a=this.api.getWidth(),o=this.api.getHeight();e={x:a*.2,y:o*.2,width:a*.6,height:o*.6}}return e},t.type="dataZoom.slider",t})(X_);function Fg(r,t,e,i){var n=r.get("labelFormatter"),a=r.get("labelPrecision");(a==null||a==="auto")&&(a=e.valuePrecision);var o=e.value[t],s=o==null||isNaN(o)?"":we(i)||Ya(i)?i.getLabel({value:Math.round(o)}):isFinite(a)?st(o,a,!0):o+"";return J(n)?n(o,s):V(n)?n.replace("{value}",s):s}function CP(r){var t={x:"y",y:"x",radius:"angle",angle:"radius"};return t[r]}function MP(r){return r==="vertical"?"ns-resize":"ew-resize"}function DP(r){r.registerComponentModel(yP),r.registerComponentView(xP),$_(r)}function zP(r){sr(mP),sr(DP)}function zg(r,t,e){var i=ae.createCanvas(),n=t.getWidth(),a=t.getHeight(),o=i.style;return o&&(o.position="absolute",o.left="0",o.top="0",o.width=n+"px",o.height=a+"px",i.setAttribute("data-zr-dom-id",r)),i.width=n*e,i.height=a*e,i}function Ju(r){return!r.__cursors.get(ey)}function Hg(r){var t=r.__cursors.get(ey);return{startIdx:t?t.startIdx:0,endIdx:t?t.endIdx:0}}var r1=(function(r){B(t,r);function t(e,i,n){var a=r.call(this)||this;a.motionBlur=!1,a.lastFrameAlpha=.7,a.dpr=1,a.virtual=!1,a.config={},a.zlevel=0,a.zlevel2=Zo,a.maxRepaintRectCount=5,a.__dirty=!0,a.__firstTimePaint=!0,a.__prevIdx={startIdx:0,endIdx:0};var o;n=n||ps,typeof e=="string"?o=zg(e,i,n):Z(e)&&(o=e,e=o.id),a.id=e,a.dom=o;var s=o.style;return s&&($g(o),o.onselectstart=function(){return!1},s.padding="0",s.margin="0",s.borderWidth="0"),a.painter=i,a.dpr=n,a}return t.prototype.afterBrush=function(){this.__prevIdx=Hg(this)},t.prototype.initContext=function(){this.ctx=this.dom.getContext("2d"),this.ctx.dpr=this.dpr},t.prototype.setUnpainted=function(){this.__firstTimePaint=!0},t.prototype.createBackBuffer=function(){var e=this.dpr;this.domBack=zg("back-"+this.id,this.painter,e),this.ctxBack=this.domBack.getContext("2d"),e!==1&&this.ctxBack.scale(e,e)},t.prototype.createRepaintRects=function(e,i,n,a){if(this.__firstTimePaint)return this.__firstTimePaint=!1,null;var o=[],s=this.maxRepaintRectCount,l=!1,u=new j(0,0,0,0);function f(S){if(!(!S.isFinite()||S.isZero()))if(o.length===0){var b=new j(0,0,0,0);b.copy(S),o.push(b)}else{for(var w=!1,T=1/0,D=0,C=0;C=s)}}for(var h=Hg(this),v=h.startIdx;v=0)&&(o=!0)}),!(!o&&!a.__dirty)){var s=i._opts.useDirtyRect&&!Ju(a)?a.createRepaintRects(t,e,i._width,i._height):null,l=i._i.layerStack[0],u=!0;if(a.__dirty){u=!1,a.__dirty=!1;var f=a.zlevel===l.zl&&a.zlevel2===l.zl2?i._backgroundColor:null;a.clear(!1,f,s)}Fo(a,function(h){var v=i._paintPerCursor(a,h,t,s,u);n=n&&v})}},zo),et.wxa&&Nt(this._i,function(a){a&&a.ctx&&a.ctx.draw&&a.ctx.draw()}),n},r.prototype._paintPerCursor=function(t,e,i,n,a){var o=t.ctx;if(n)if(!n.length)e.drawIdx=e.endIdx;else for(var s=this.dpr,l=0;l=e.endIdx},r.prototype._paintPerCursorInRect=function(t,e,i,n,a){for(var o={inHover:!1,allClipped:!1,prevEl:null,viewWidth:this._width,viewHeight:this._height,beforeBrushParam:{contentRetained:a}},s=t.ctx,l=Ju(t),u=l&&ae.getTime(),f=e.drawIdx,h=e.notClearIdx,v=h>=0?Math.min(h,f):f;v15){v++;break}}}}sn(s,o),e.drawIdx=Math.max(v,f)},r.prototype.getLayer=function(t,e){return this._ensureLayer(t,0,e)},r.prototype._ensureLayer=function(t,e,i){e=e||0;var n=this._singleCanvas;n&&!this._needsManuallyCompositing&&(t=oi,e=0);var a=ef(this._i,t)[e];return a||(a=Gg("zr_"+t+"."+e,this,t,e),this._layerConfig[t]&&ut(a,this._layerConfig[t],!0),(i||n&&t!==oi)&&(a.virtual=!0),this._insertLayer(a,t,e,!1),a.initContext()),a},r.prototype.insertLayer=function(t,e){this._insertLayer(e,t,0,!1)},r.prototype._insertLayer=function(t,e,i,n){var a=this._i,o=a.layers,s=a.layerStack,l=this._domRoot,u=null;if(!(o[e]&&o[e][i])&&IP(t)){for(var f=s.length,h=0;h0&&(u=ef(a,s[h-1].zl)[s[h-1].zl2]),s.splice(h,0,{zl:e,zl2:i}),ef(a,e)[i]=t,!n&&!t.virtual)if(u){var v=u.dom;v.nextSibling?l.insertBefore(t.dom,v.nextSibling):l.appendChild(t.dom)}else l.firstChild?l.insertBefore(t.dom,l.firstChild):l.appendChild(t.dom);t.painter||(t.painter=this)}},r.prototype.eachLayer=function(t,e){return Nt(this._i,function(i,n){t.call(e,i,n)})},r.prototype.eachBuiltinLayer=function(t,e){return Nt(this._i,function(i,n){t.call(e,i,n)},da)},r.prototype.eachOtherLayer=function(t,e){return Nt(this._i,function(i,n){t.call(e,i,n)},gh)},r.prototype.getLayers=function(){var t={};return Nt(this._i,function(e,i,n){t[e.id]=e}),t},r.prototype._updateLayerStatus=function(t,e){var i=this;if(i._singleCanvas)for(var n=1;n=0;_--){var S=y.get(g[_]);if(!S.used)m.__dirty=!0,y.removeKey(g[_]),g.splice(_,1);else{var b=S.endIdxNew;(Ju(m)?b=0;n--){var a=e[n];if(a.zl===t){var o=i[t][a.zl2];if(o.__builtin__)continue;if(e.splice(n,1),i[t][a.zl2]=void 0,!o.virtual){var s=o.dom.parentNode;s&&s.removeChild(o.dom)}}}},r.prototype.resize=function(t,e){if(this._domRoot.style){var i=this._domRoot;i.style.display="none";var n=this._opts,a=this.root;t!=null&&(n.width=t),e!=null&&(n.height=e),t=Eo(a,0,n),e=Eo(a,1,n),i.style.display="",(this._width!==t||e!==this._height)&&(i.style.width=t+"px",i.style.height=e+"px",Nt(this._i,function(o){o.resize(t,e)}),this.refresh({paintAll:!0})),this._width=t,this._height=e}else{if(t==null||e==null)return;this._width=t,this._height=e,this._ensureLayer(oi).resize(t,e)}return this},r.prototype.clearLayer=function(t){x(this._i.layers[t],function(e){e&&!e.__builtin__&&e.clear()})},r.prototype.dispose=function(){this.root.innerHTML="",this.root=this.storage=this._domRoot=this._i=null},r.prototype.getRenderedCanvas=function(t){if(t=t||{},this._singleCanvas&&!this._compositeManually)return this._i.layers[oi][0].dom;var e=new r1("image",this,t.pixelRatio||this.dpr);e.initContext(),e.clear(!1,t.backgroundColor||this._backgroundColor);var i=e.ctx;if(t.pixelRatio<=this.dpr){this.refresh();var n=e.dom.width,a=e.dom.height;Nt(this._i,function(h){h.__builtin__?i.drawImage(h.dom,0,0,n,a):h.renderToCanvas&&(i.save(),h.renderToCanvas(i),i.restore())})}else{for(var o={inHover:!1,viewWidth:this._width,viewHeight:this._height,beforeBrushParam:{}},s=this.storage.getDisplayList(!0),l=0,u=s.length;l{for(const p of a)if(p.type==="childList")for(const c of p.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&o(c)}).observe(document,{childList:!0,subtree:!0});function n(a){const p={};return a.integrity&&(p.integrity=a.integrity),a.referrerPolicy&&(p.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?p.credentials="include":a.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function o(a){if(a.ep)return;a.ep=!0;const p=n(a);fetch(a.href,p)}})();async function ae(t,s){const n=new URL(t,window.location.origin);for(const[a,p]of Object.entries(s??{}))p!=null&&p!==""&&n.searchParams.set(a,String(p));const o=await fetch(`${n.pathname}${n.search}`,{headers:{Accept:"application/json"}});if(!o.ok){const a=await o.json().catch(()=>null);throw new Error(a?.detail??`历史 API 返回 HTTP ${o.status}`)}return await o.json()}async function gt(t,s,n){return ae("/api/v1/history/runs",{...t,limit:s,offset:n})}async function yt(){return(await ae("/api/v1/history/facets")).facets}async function bt(){return ae("/api/v1/history/status")}async function kt(t){return(await ae("/api/v1/history/metrics/catalog",{run_id:t?.runId,implementation:t?.implementation,scene:t?.scene})).items}async function $t(t){return(await ae("/api/v1/history/metrics/series",{metric_name:t.metricName,statistic:t.statistic,scope:t.scope,run_id:t.runId,implementation:t.implementation,model:t.model,scene:t.scene,status:t.status,run_kind:t.runKind,search:t.search,phase:t.phase})).items}async function De(t,s,n){return(await ae(`/api/v1/history/runs/${encodeURIComponent(t)}/metrics`,{scope:s,phase:n,limit:2e4})).items}const He="aiperf.history.comparison.groups.v1";function Ge(t){return Array.isArray(t)?[...new Set(t.filter(s=>typeof s=="string"&&s.length>0))]:[]}function wt(t){if(typeof t!="object"||t===null)return null;const s=t,n=Ge(s.left),o=new Set(n),a=Ge(s.right).filter(p=>!o.has(p));return{left:n,right:a}}function xt(){const t=window.localStorage.getItem(He);if(t===null)return null;try{return wt(JSON.parse(t))}catch{return null}}function St(t){try{window.localStorage.setItem(He,JSON.stringify(t))}catch{}}const Ct={benchmark_duration:"基准测试耗时","cache.hit_rate":"缓存命中率","cache.transfer_bytes":"缓存迁移量","cache.usage":"缓存使用率",chunk_compute_fps:"分块计算帧率",chunk_compute_fps_weighted:"加权分块计算帧率",chunk_compute_seconds:"分块计算耗时",chunk_encode_seconds:"分块编码耗时",chunk_frames:"分块帧数",chunk_output_batches:"分块输出批次数",chunk_output_header_write_seconds:"分块输出头写入耗时",chunk_output_pacing_seconds:"分块输出节流耗时",chunk_output_payload_write_seconds:"分块载荷写入耗时",chunk_output_write_seconds:"分块输出写入耗时",chunk_peak_allocated_bytes:"分块峰值已分配显存",chunk_peak_reserved_bytes:"分块峰值保留显存",chunk_raw_output_bytes:"分块原始输出大小",chunk_request_prepare_seconds:"分块请求准备耗时",chunk_steady_state_count:"稳态分块数量",chunk_steady_state_total_compute_seconds:"稳态总计算耗时",chunk_steady_state_total_frames:"稳态总帧数",chunk_steady_state_total_output_batches:"稳态总输出批次数",chunk_steady_state_total_raw_output_bytes:"稳态原始输出总量",chunk_steady_state_total_wire_output_bytes:"稳态传输输出总量",chunk_steady_state_warmup_chunks_skipped:"稳态排除的预热分块数",chunk_total_seconds:"分块总耗时",chunk_wire_output_bytes:"分块传输输出大小",connected_latency_ms:"连接建立时延",control_ack_latency_ms:"控制确认时延",control_to_next_frame_latency_ms:"控制到下一帧时延",first_frame_latency_ms:"首帧时延",first_metadata_latency_ms:"首条元数据时延",frames_received:"接收帧数",gpu_memory_used:"GPU 显存使用量",gpu_power_usage:"GPU 功耗",gpu_temperature:"GPU 温度",gpu_utilization:"GPU 利用率",input_sequence_length:"输入序列长度",metadata_messages:"元数据消息数",offer_rtt_ms:"Offer 往返时延",output_sequence_length:"输出序列长度",pipeline_init_peak_allocated_bytes:"流水线初始化峰值已分配显存",pipeline_init_peak_reserved_bytes:"流水线初始化峰值保留显存",pipeline_init_seconds:"流水线初始化耗时",profile_attempted_sessions:"正式测量尝试会话数",profile_failed_sessions:"正式测量失败会话数",profile_successful_sessions:"正式测量成功会话数",request_count:"请求数",request_latency:"请求时延",request_throughput:"请求吞吐量","resource.cpu":"CPU 使用","resource.gpu":"GPU 使用","resource.gpu_memory":"GPU 显存","resource.memory":"内存使用","resource.network":"网络带宽","requests.completed":"已完成请求数","requests.server_execution_latency":"服务端执行时延",runtime_creation_peak_allocated_bytes:"运行时创建峰值已分配显存",runtime_creation_peak_reserved_bytes:"运行时创建峰值保留显存",runtime_creation_seconds:"运行时创建耗时","scheduler.queue_depth":"调度器队列深度","scheduler.preemptions":"调度抢占数","scheduler.rejections":"调度拒绝数","scheduler.running_tasks":"调度器运行任务数",server_scrape_latency_ms:"服务端指标抓取时延",session_runtime_s:"会话运行时长",session_success:"会话成功状态",status_messages:"状态消息数",stream_fps:"客户端流式帧率",success_rate:"成功率",cpu_utilization:"CPU 利用率",network_bandwidth:"网络带宽",pcie_bandwidth:"PCIe 带宽",system_memory_used:"系统内存使用量",telefuser_http_request_duration_seconds:"TeleFuser HTTP 请求耗时",telefuser_http_requests:"TeleFuser HTTP 请求数",telefuser_http_requests_inflight:"TeleFuser 进行中 HTTP 请求数",telefuser_queue_pending:"TeleFuser 队列等待数",telefuser_queue_processing:"TeleFuser 队列处理数",telefuser_queue_size:"TeleFuser 队列大小",telefuser_task_duration_seconds:"TeleFuser 任务耗时",telefuser_task_queue_wait_seconds:"TeleFuser 任务排队耗时",telefuser_tasks_cancelled:"TeleFuser 已取消任务数",telefuser_tasks_completed:"TeleFuser 已完成任务数",telefuser_tasks_failed:"TeleFuser 失败任务数",total_isl:"输入 Token 总数"},Mt={value:"值",avg:"平均值",mean:"平均值",min:"最小值",max:"最大值",count:"数量",sum:"总和",std:"标准差"},Tt={run:"任务汇总",session:"会话",control:"控制事件",phase:"运行阶段",chunk:"生成分块",timeslice:"时间片",gpu:"GPU 采样",gpu_summary:"GPU 汇总",server:"服务端采样",server_summary:"服务端汇总",normalized:"归一化指标",resource:"运行期资源"},Lt={"":"无单位",unitless:"无单位",ms:"毫秒",milliseconds:"毫秒",s:"秒",sec:"秒",seconds:"秒",bytes:"字节","bytes/second":"字节/秒",cores:"逻辑核",GB:"GB",gigabytes:"GB",frames:"帧","frames/second":"帧/秒",count:"个",ratio:"比例",boolean:"布尔值",tokens:"Token",requests:"请求","requests/sec":"请求/秒","requests/second":"请求/秒",percent:"百分比",tasks:"任务",watts:"瓦",celsius:"摄氏度"},Nt={completed:"已完成",failed:"失败",partial:"部分完成",cancelled:"已取消",running:"运行中",pending:"等待中",disabled:"已禁用",invalid:"无效"},Pt={stream:"流式任务",profile:"性能测试",batch:"批处理任务"},Rt={warmup:"预热",profiling:"正式测量",initialization:"初始化",init:"初始化",runtime:"运行时",all:"全部阶段"},Dt={telefuser:"TeleFuser",sglang_diffusion:"SGLang-Diffusion",diffusers:"Diffusers"},Gt={lingbot_world_fast:"LingBot 世界模型",video_generation:"视频生成",stream_world:"流式世界模型"},zt={webrtc:"WebRTC",websocket:"WebSocket",http:"HTTP"};function Z(t){return Ct[t]??t}function pe(t){return/^p\d+$/i.test(t)?t.toUpperCase():Mt[t]??t}function Kt(t){return Tt[t]??t}function ue(t){return Lt[t]??t}function be(t){return Nt[t]??t}function We(t){return Pt[t]??t}function At(t){return Rt[t]??t}function Y(t){return Dt[t]??t}function ke(t){return Gt[t]??t}function qt(t){return zt[t]??t}const Bt={process_used:"目标进程",container_used:"容器使用",container_total:"容器上限",machine_used:"整机使用",machine_total:"整机总量"};function ze(t){return Bt[t]??t}const Ot={ethernet:"Ethernet",rdma:"RDMA"},Ut={receive:"接收",transmit:"发送"};function Vt(t){return Ot[t]??t}function jt(t){return Ut[t]??t}const Ke=[{threshold:1e12,divisor:1e12,prefix:"TB"},{threshold:1e9,divisor:1e9,prefix:"GB"},{threshold:1e6,divisor:1e6,prefix:"MB"},{threshold:1e3,divisor:1e3,prefix:"KB"},{threshold:0,divisor:1,prefix:"B"}],Ft=new Set(["resource.gpu","resource.gpu_memory"]);function Et(t){return t.trim().toLowerCase()}function $e(t){const s=Et(t);return s==="bytes"||s==="byte"?"bytes":["bytes/second","bytes/sec","byte/second","b/s"].includes(s)?"bytes_per_second":s==="cores"||s==="core"?"cores":["percent","percentage","%"].includes(s)?"percent":"plain"}function Ye(t){const s=Math.abs(t);return Ke.find(n=>s>=n.threshold)??Ke.at(-1)}function It(t){let s=0;const n=new Map;for(const o of t){if(!Number.isFinite(o.value))continue;const a=fe(o);if(!a){s=Math.max(s,Math.abs(o.value));continue}const p=`${a}${o.recorded_at}`,c=(n.get(p)??0)+o.value;n.set(p,c),s=Math.max(s,Math.abs(c))}return s}function de(t,s=""){const n=[...new Set(t.map(c=>c.unit).filter(Boolean))],o=n.length===1?n[0]:s,a=n.length>1?"plain":$e(o),p=It(t);if(a==="cores")return{family:a,sourceUnit:o,divisor:1,multiplier:100,label:"%"};if(a==="percent")return{family:a,sourceUnit:o,divisor:1,multiplier:1,label:"%"};if(a==="bytes"||a==="bytes_per_second"){const c=Ye(p);return{family:a,sourceUnit:o,divisor:c.divisor,multiplier:1,label:`${c.prefix}${a==="bytes_per_second"?"/s":""}`}}return{family:a,sourceUnit:o,divisor:1,multiplier:1,label:n.length>1?"多单位":ue(o)}}function Je(t,s,n){return $e(s)!==n.family&&n.family!=="plain"?t:t*n.multiplier/n.divisor}function ie(t){const s=Math.abs(t),n=s>0&&s<1?3:2;return new Intl.NumberFormat("zh-CN",{maximumFractionDigits:n}).format(t)}function ce(t,s){const n=$e(s);if(n==="cores")return`${ie(t*100)}%`;if(n==="percent")return`${ie(t)}%`;if(n==="bytes"||n==="bytes_per_second"){const o=Ye(t),a=n==="bytes_per_second"?"/s":"";return`${ie(t/o.divisor)} ${o.prefix}${a}`}return`${ie(t)} ${ue(s)}`.trim()}function Ht(t){return ie(t)}function we(t){return Ft.has(t)}function fe(t){const s=t.labels.resource_subject;if(!(t.scope!=="resource"||!we(t.metric_name)||!t.run_id||!s||!t.device.startsWith("gpu:")))return`${t.run_id}${t.metric_name}${s}`}function Ae(t,s){const n=new Map;for(const o of t){if(o.scope!=="resource"||o.labels.resource_subject!==s)continue;const a=[o.run_id,o.metric_name,o.unit,o.recorded_at].join("");n.set(a,[...n.get(a)??[],o])}return[...n.values()].map(o=>{const a=o[0],p=new Map(o.filter(y=>y.device).map(y=>[y.device,y.value])),c=we(a.metric_name)&&p.size>0,m=new Map;if(a.metric_name==="resource.network")for(const y of o){const P=y.labels.network_kind??"network";m.set(P,Math.max(m.get(P)??0,y.value))}const b=m.size>0,k=c?[...p.values()].reduce((y,P)=>y+P,0):b?[...m.values()].reduce((y,P)=>y+P,0):Math.max(...o.map(y=>y.value));return{...a,value:k,device:"",labels:{...a.labels,resource_aggregate:s,aggregated_series_count:String(c?p.size:b?m.size:o.length)}}}).sort((o,a)=>o.run_id.localeCompare(a.run_id)||new Date(o.recorded_at).getTime()-new Date(a.recorded_at).getTime())}function Wt(t){const s=new Map;for(const n of t){const o=s.get(n.run_id);(!o||new Date(n.recorded_at).getTime()>=new Date(o.recorded_at).getTime())&&s.set(n.run_id,n)}return[...s.values()].sort((n,o)=>n.run_id.localeCompare(o.run_id))}function qe(t){const s=t.filter(a=>a.scope==="resource"&&a.labels.resource_subject?.endsWith("_used")),n=Ae(t,"machine_total"),o=Wt(Ae(t,"container_total"));return{usage:s,machineTotal:n,containerLimits:o}}function Yt(t,s){const n=[],o=new Map;for(const a of t){if(!Number.isFinite(a.value))continue;const p=Je(a.value,a.unit,s),c=fe(a);if(!c){n.push(p);continue}const m=`${c}${a.recorded_at}`;o.set(m,(o.get(m)??0)+p)}if(n.push(...o.values()),!!n.length)return[Math.min(...n),Math.max(...n)]}const Jt={key:0,class:"chart-empty"},Be="aiperf-tooltip-top-layer",te=12,H=12,X=le({__name:"MetricChart",props:{points:{},sampleAxis:{type:Boolean,default:!1},emptyText:{default:"当前指标选择下没有可显示的数据点。"},yRange:{},compact:{type:Boolean,default:!1},minimal:{type:Boolean,default:!1},displayScale:{}},setup(t){const s=t,n=R(null);ct([mt,pt,ft,_t,vt,ht]);let o=null,a=null;const p=M(()=>s.displayScale??de(s.points));function c(r){const g=pe(r.statistic),$=r.implementation?Y(r.implementation):"未知实现";if(r.scope==="resource"){const C=r.run_id?r.run_id.slice(0,8):"未知任务",w=ze(r.labels.resource_subject??"unknown");if(r.labels.resource_aggregate==="machine_total")return`${w} · ${$} · ${C}`;if(r.metric_name==="resource.network"){const L=Vt(r.labels.network_kind??"network"),G=jt(r.labels.network_direction??"unknown");return`${L} ${G} · ${w} · ${$} · ${C}`}const S=r.device?` · ${r.device}`:"";return`${w} · ${$} · ${C}${S}`}if(!s.sampleAxis)return`${g} · ${$}`;const N=r.run_id?r.run_id.slice(0,8):"未知任务",B=r.session_id?r.session_id.slice(-8):"汇总",A=r.device?` · ${r.device}`:"";return`${g} · ${$} · ${N} · ${At(r.phase||"all")} · ${B}${A}`}const m={value:"#bddc72",avg:"#66e3c4",mean:"#66e3c4",p1:"#4cc9f0",p5:"#43aa8b",p10:"#90be6d",p25:"#577590",p50:"#70a7ff",p75:"#8d8bff",p90:"#ffb45d",p95:"#d891ff",p99:"#f06b7b",min:"#55b9e6",max:"#ff5d8f",std:"#a7b0ad",count:"#f3d36a",sum:"#e98a4b",bucket:"#2ec4b6"},b=["#66e3c4","#ffb45d","#70a7ff","#d891ff","#f06b7b","#bddc72"];function k(r){const g=m[r.toLowerCase()];if(g)return g;let $=0;for(const N of r)$=$*31+N.charCodeAt(0)|0;return b[Math.abs($)%b.length]}function y(r){return s.sampleAxis?r.sample_index:new Date(r.recorded_at).getTime()}function P(){let r=document.getElementById(Be);return r||(r=document.createElement("div"),r.id=Be,r.setAttribute("popover","manual"),r.setAttribute("aria-hidden","true"),document.body.append(r)),r.matches(":popover-open")||r.showPopover(),r}const V=(r,g,$,N,B)=>{const A=n.value?.getBoundingClientRect();if(!A)return[r[0]+te,r[1]+te];const[C,w]=B.contentSize,S=A.left+r[0],L=A.top+r[1];let G=S+te,O=L+te;G+C>window.innerWidth-H&&(G=S-C-te),O+w>window.innerHeight-H&&(O=L-w-te);const I=Math.max(H,window.innerWidth-H-C),J=Math.max(H,window.innerHeight-H-w);return G=Math.min(Math.max(G,H),I),O=Math.min(Math.max(O,H),J),[G-A.left,O-A.top]};function K(){if(!o)return;const r=new Map;for(const $ of s.points){const N=c($);r.set(N,[...r.get(N)??[],$])}const g=[...r.entries()].map(([$,N])=>{const B=k(N[0]?.scope==="resource"?$:N[0]?.statistic??$),A=N[0]?fe(N[0]):void 0;return{name:$,type:"line",smooth:N.length>4?.22:!1,showSymbol:N.length<=1,symbolSize:s.minimal?3:s.compact?4:7,lineStyle:{width:s.minimal?1.5:s.compact?1.7:2.4,color:B},itemStyle:{color:B},emphasis:{focus:"series",scale:!0},...A?{stack:A,stackStrategy:"all",areaStyle:{color:B,opacity:.08}}:{},data:[...N].sort((C,w)=>y(C)-y(w)).map(C=>[y(C),Je(C.value,C.unit,p.value),C])}});o.setOption({animationDuration:s.minimal?180:s.compact?250:450,grid:s.minimal?{left:46,right:10,top:10,bottom:12,containLabel:!1}:s.compact?{left:46,right:10,top:32,bottom:28,containLabel:!1}:{left:58,right:24,top:44,bottom:48,containLabel:!1},legend:{show:!s.minimal,type:"scroll",top:s.compact?2:4,right:s.compact?2:6,itemWidth:s.compact?13:25,itemHeight:s.compact?7:14,textStyle:{color:"#9baaa6",fontSize:s.compact?9:11},pageTextStyle:{color:"#9baaa6"}},tooltip:{trigger:"axis",renderMode:"html",appendTo:P,className:"aiperf-chart-tooltip-layer",confine:!1,position:V,enterable:!1,transitionDuration:0,backgroundColor:"rgba(7, 17, 15, 0.96)",borderColor:"#29433d",textStyle:{color:"#edf8f5",fontSize:12},extraCssText:"z-index:2147483000;max-width:min(640px,calc(100vw - 24px));overflow-wrap:anywhere;white-space:normal;box-shadow:0 12px 36px rgba(0,0,0,.55);",formatter:x},xAxis:{type:s.sampleAxis?"value":"time",name:s.sampleAxis?"样本":"",nameTextStyle:{color:"#738680"},axisLine:{lineStyle:{color:"#2b413c"}},axisLabel:{show:!s.minimal,color:"#82938e",fontSize:s.compact?9:12,hideOverlap:!0},splitLine:{show:!1}},yAxis:{type:"value",name:s.minimal?"":p.value.label,scale:!0,min:s.yRange?.[0],max:s.yRange?.[1],nameTextStyle:{color:"#738680",align:"left"},axisLabel:{color:"#82938e",fontSize:s.compact?9:12,formatter:Ht},splitLine:{lineStyle:{color:"rgba(101, 137, 128, 0.13)"}}},dataZoom:!s.minimal&&s.points.length>20?s.compact?[{type:"inside"}]:[{type:"inside"},{type:"slider",height:16}]:[],series:g},!0)}function x(r){const g=Array.isArray(r)?r:[r];if(!g.length||!g[0]?.data)return"";const $=g[0].data[2],N=s.sampleAxis?`样本 ${$.sample_index}`:new Date($.recorded_at).toLocaleString("zh-CN"),B=g.filter(S=>S.data).map(S=>{const L=S.data[2];return`${S.marker??""}${h(S.seriesName??"")}: ${h(d(L.value,L.unit))}`}).join("
"),A=new Map;for(const S of g){if(!S.data)continue;const L=S.data[2],G=fe(L);if(!G)continue;const O=A.get(G)??{point:L,total:0,count:0};O.total+=L.value,O.count+=1,A.set(G,O)}const C=[...A.values()].filter(S=>S.count>1).map(S=>{const L=S.point,G=ze(L.labels.resource_subject??"unknown"),O=Y(L.implementation),I=L.run_id.slice(0,8),J=`跨设备合计 · ${G} · ${O} · ${I}`;return`${h(J)}: ${h(ce(S.total,L.unit))}`}).join("
"),w=C?`${C}`:"";return`
${h(N)}
${B}${w}
`}function d(r,g){return ce(r,g)}function h(r){return r.replace(/[&<>'"]/g,g=>({"&":"&","<":"<",">":">","'":"'",'"':"""})[g]??g)}return Ee(()=>{n.value&&(o=dt(n.value,void 0,{renderer:"canvas"}),a=new ResizeObserver(()=>o?.resize()),a.observe(n.value),K())}),Ie(()=>[s.points,s.sampleAxis,s.yRange,s.compact,s.minimal,s.displayScale],K,{deep:!0}),lt(()=>{a?.disconnect(),o?.dispose()}),(r,g)=>(_(),v("div",{class:Q(["chart-shell",{minimal:t.minimal}])},[t.points.length?U("",!0):(_(),v("div",Jt,u(t.emptyText),1)),e("div",{ref_key:"element",ref:n,class:Q(["chart-canvas",{hidden:!t.points.length}])},null,2)],2))}}),Xt={class:"metric-chart-card"},Zt=["title"],Qt=["aria-label"],es={key:0,class:"loading-line"},ts={key:1,class:"chart-error comparison-chart-error"},ss={class:"comparison-chart-pane left-chart-pane"},ns={class:"comparison-pane-heading"},ls={class:"resource-limit-row"},as={key:0,class:"resource-limit-values"},os=["title"],rs={key:1,class:"resource-limit-unavailable"},is={class:"resource-chart-block"},us={class:"resource-chart-heading"},cs={class:"resource-chart-block resource-total-block"},ds={class:"resource-chart-heading"},ms={class:"comparison-chart-pane right-chart-pane"},ps={class:"comparison-pane-heading"},fs={class:"resource-limit-row"},_s={key:0,class:"resource-limit-values"},vs=["title"],hs={key:1,class:"resource-limit-unavailable"},gs={class:"resource-chart-block"},ys={class:"resource-chart-heading"},bs={class:"resource-chart-block resource-total-block"},ks={class:"resource-chart-heading"},$s=le({__name:"MetricChartCard",props:{metric:{},leftPoints:{},rightPoints:{},loading:{type:Boolean},error:{},leftCount:{},rightCount:{}},emits:["remove"],setup(t,{emit:s}){const n=t,o=s,a=M(()=>[...new Set(n.metric.variants.map(C=>pe(C.statistic)))]),p=M(()=>a.value.join(" / ")),c=M(()=>n.metric.scope==="resource"),m=M(()=>[...n.leftPoints,...n.rightPoints]),b=M(()=>de(m.value,n.metric.unit)),k=M(()=>qe(n.leftPoints)),y=M(()=>qe(n.rightPoints)),P=M(()=>[...k.value.usage,...y.value.usage]),V=M(()=>[...k.value.machineTotal,...y.value.machineTotal]),K=M(()=>de(P.value,n.metric.unit)),x=M(()=>de(V.value,n.metric.unit)),d=M(()=>n.metric.metric_name==="resource.cpu"&&n.metric.unit==="cores"?"单核 = 100%,多核累计可超过 100%":we(n.metric.metric_name)?"同一任务、同一资源主体跨设备堆叠":""),h=M(()=>[p.value,d.value].filter(Boolean).join(";"));function r(C,w){const S=Yt(C,w);if(!S)return;const[L,G]=S,O=L===G?Math.abs(L)*.05||1:(G-L)*.06;return[L>=0?Math.max(0,L-O):L-O,G+O]}const g=M(()=>r(m.value,b.value)),$=M(()=>r(P.value,K.value)),N=M(()=>r(V.value,x.value));function B(C){return["core","cores"].includes(C.unit.trim().toLowerCase())?`${C.value.toLocaleString("zh-CN",{maximumFractionDigits:2})} 核(${ce(C.value,C.unit)})`:ce(C.value,C.unit)}function A(C){return`${Y(C.implementation)} · Run ${C.run_id} · ${B(C)}`}return(C,w)=>(_(),v("article",Xt,[e("header",null,[e("div",null,[e("strong",null,u(D(Z)(t.metric.metric_name)),1),e("span",{title:h.value},[ne(u(t.metric.metric_name)+" · "+u(D(Kt)(t.metric.scope))+" · "+u(b.value.label||D(ue)(t.metric.unit))+" · "+u(a.value.length)+" 项统计 ",1),d.value?(_(),v(q,{key:0},[ne(" · "+u(d.value),1)],64)):U("",!0)],8,Zt)]),e("button",{class:"chart-close-button",type:"button","aria-label":`关闭${D(Z)(t.metric.metric_name)}图表`,onClick:w[0]||(w[0]=S=>o("remove",n.metric.key))}," × ",8,Qt)]),t.loading?(_(),v("div",es)):U("",!0),t.error?(_(),v("div",ts,"图表加载失败:"+u(t.error),1)):(_(),v("div",{key:2,class:Q(["comparison-chart-grid",{"resource-comparison-grid":c.value}])},[e("section",ss,[e("div",ns,[w[1]||(w[1]=e("span",null,"左组",-1)),e("small",null,u(t.leftCount)+" 条记录",1)]),c.value?(_(),v(q,{key:0},[e("div",ls,[w[2]||(w[2]=e("span",null,"容器上限",-1)),k.value.containerLimits.length?(_(),v("div",as,[(_(!0),v(q,null,j(k.value.containerLimits,S=>(_(),v("small",{key:`${S.run_id}:${S.metric_name}`,title:A(S)},[e("b",null,u(S.run_id.slice(0,8)),1),ne(" "+u(B(S)),1)],8,os))),128))])):(_(),v("small",rs,"未探测到有限上限"))]),e("div",is,[e("div",us,[w[3]||(w[3]=e("span",null,"用量叠加",-1)),e("small",null,u(K.value.label),1)]),W(X,{class:"resource-usage-chart",points:k.value.usage,"y-range":$.value,"display-scale":K.value,compact:"","empty-text":"左组没有运行期用量数据。"},null,8,["points","y-range","display-scale"])]),e("div",cs,[e("div",ds,[w[4]||(w[4]=e("span",null,"整机总量",-1)),e("small",null,"每个任务一条聚合线 · "+u(x.value.label),1)]),W(X,{class:"resource-total-chart",points:k.value.machineTotal,"y-range":N.value,"display-scale":x.value,minimal:"","empty-text":"左组没有整机总量数据。"},null,8,["points","y-range","display-scale"])])],64)):(_(),me(X,{key:1,points:t.leftPoints,"sample-axis":t.metric.scope!=="run","y-range":g.value,"display-scale":b.value,compact:"","empty-text":"请从任务表选择左组记录,或当前筛选下没有数据。"},null,8,["points","sample-axis","y-range","display-scale"]))]),e("section",ms,[e("div",ps,[w[5]||(w[5]=e("span",null,"右组",-1)),e("small",null,u(t.rightCount)+" 条记录",1)]),c.value?(_(),v(q,{key:0},[e("div",fs,[w[6]||(w[6]=e("span",null,"容器上限",-1)),y.value.containerLimits.length?(_(),v("div",_s,[(_(!0),v(q,null,j(y.value.containerLimits,S=>(_(),v("small",{key:`${S.run_id}:${S.metric_name}`,title:A(S)},[e("b",null,u(S.run_id.slice(0,8)),1),ne(" "+u(B(S)),1)],8,vs))),128))])):(_(),v("small",hs,"未探测到有限上限"))]),e("div",gs,[e("div",ys,[w[7]||(w[7]=e("span",null,"用量叠加",-1)),e("small",null,u(K.value.label),1)]),W(X,{class:"resource-usage-chart",points:y.value.usage,"y-range":$.value,"display-scale":K.value,compact:"","empty-text":"右组没有运行期用量数据。"},null,8,["points","y-range","display-scale"])]),e("div",bs,[e("div",ks,[w[8]||(w[8]=e("span",null,"整机总量",-1)),e("small",null,"每个任务一条聚合线 · "+u(x.value.label),1)]),W(X,{class:"resource-total-chart",points:y.value.machineTotal,"y-range":N.value,"display-scale":x.value,minimal:"","empty-text":"右组没有整机总量数据。"},null,8,["points","y-range","display-scale"])])],64)):(_(),me(X,{key:1,points:t.rightPoints,"sample-axis":t.metric.scope!=="run","y-range":g.value,"display-scale":b.value,compact:"","empty-text":"请从任务表选择右组记录,或当前筛选下没有数据。"},null,8,["points","sample-axis","y-range","display-scale"]))])],2))]))}}),ws={class:"metric-selector panel"},xs={class:"metric-sidebar-header"},Ss={class:"metric-sidebar-tools"},Cs={class:"metric-search"},Ms={class:"selected-only-toggle"},Ts={class:"metric-selection-actions"},Ls=["disabled"],Ns={class:"metric-tree",role:"tree","aria-label":"核心指标树"},Ps=["aria-expanded","onClick"],Rs={key:0,class:"metric-tree-children",role:"group"},Ds={key:0,class:"metric-tree-empty"},Gs=["checked","disabled","onChange"],zs=["title"],Ks={key:1,class:"metric-tree-availability"},As={key:0,class:"metric-tree-no-results"},qs=le({__name:"MetricSelector",props:{taxonomy:{},selectedKeys:{},maxSelected:{}},emits:["toggle","restoreDefaults","clear"],setup(t,{emit:s}){const n=t,o=s,a=R(""),p=R(!1),c=R(n.taxonomy.core.map(h=>h.id)),m=M(()=>new Set(n.selectedKeys)),b=M(()=>a.value.trim().toLowerCase());function k(h){return`${h.metric_name} ${Z(h.metric_name)} ${h.unit} ${ue(h.unit)}`.toLowerCase()}function y(h){const r=`${h.label} ${h.description}`.toLowerCase().includes(b.value);let g=h.metrics.filter($=>!b.value||r||k($).includes(b.value));return p.value&&(g=g.filter($=>m.value.has($.key))),(b.value||p.value)&&!g.length?null:{...h,metrics:g}}const P=M(()=>n.taxonomy.core.flatMap(h=>{const r=y(h);return r?[r]:[]}));function V(h){return!!b.value||c.value.includes(h)}function K(h){c.value=c.value.includes(h)?c.value.filter(r=>r!==h):[...c.value,h]}function x(h){return h.metrics.filter(r=>m.value.has(r.key)).length}function d(h){return!m.value.has(h.key)&&n.selectedKeys.length>=n.maxSelected}return(h,r)=>(_(),v("div",ws,[e("header",xs,[r[4]||(r[4]=e("div",null,[e("span",{class:"eyebrow"},"指标目录"),e("h2",null,"核心指标"),e("p",null,"按五个设计维度组织")],-1)),e("strong",null,u(t.selectedKeys.length)+"/"+u(t.maxSelected),1)]),e("div",Ss,[e("label",Cs,[r[5]||(r[5]=e("span",null,"搜索核心指标",-1)),F(e("input",{"onUpdate:modelValue":r[0]||(r[0]=g=>a.value=g),placeholder:"中文名或 canonical key"},null,512),[[ye,a.value]])]),e("label",Ms,[F(e("input",{"onUpdate:modelValue":r[1]||(r[1]=g=>p.value=g),type:"checkbox"},null,512),[[at,p.value]]),r[6]||(r[6]=e("span",null,"仅显示已开启",-1))]),e("div",Ts,[e("button",{class:"text-button",type:"button",onClick:r[2]||(r[2]=g=>o("restoreDefaults"))}," 恢复默认 "),e("button",{class:"text-button",type:"button",disabled:!t.selectedKeys.length,onClick:r[3]||(r[3]=g=>o("clear"))}," 全部关闭 ",8,Ls)])]),e("nav",Ns,[(_(!0),v(q,null,j(P.value,g=>(_(),v("section",{key:g.id,class:"metric-tree-category"},[e("button",{class:"metric-tree-category-row",type:"button",role:"treeitem","aria-expanded":V(g.id),onClick:$=>K(g.id)},[e("i",{class:Q({expanded:V(g.id)})},"›",2),e("span",null,[e("strong",null,u(g.label),1),e("small",null,u(g.description),1)]),e("em",null,u(x(g)?`${x(g)}/`:"")+u(g.availableCount),1)],8,Ps),V(g.id)?(_(),v("div",Rs,[g.metrics.length?U("",!0):(_(),v("div",Ds," 当前记录尚未采集 ")),(_(!0),v(q,null,j(g.metrics,$=>(_(),v("label",{key:$.key,class:Q(["metric-tree-leaf",{checked:m.value.has($.key),disabled:d($)}]),role:"treeitem"},[e("input",{type:"checkbox",checked:m.value.has($.key),disabled:d($),onChange:N=>o("toggle",$.key)},null,40,Gs),r[7]||(r[7]=e("i",{"aria-hidden":"true"},null,-1)),e("span",null,[e("strong",null,u(D(Z)($.metric_name)),1),e("small",{title:$.metric_name},u($.metric_name)+" · "+u(D(ue)($.unit)),9,zs)])],2))),128)),g.availableCount[...new Set(o.value.map(x=>x.metric_name))].sort()),y=M(()=>o.value.filter(x=>x.metric_name===a.value&&x.statistic==="value")),P=M(()=>{const x=p.value.trim().toLowerCase();return n.value.filter(d=>!x||`${d.metric_name} ${Z(d.metric_name)} ${d.statistic} ${pe(d.statistic)}`.toLowerCase().includes(x)).slice(0,250)});Ie(()=>s.run.run_id,async()=>{const x=++b;c.value=!0,m.value="";try{const[d,h]=await Promise.all([De(s.run.run_id,"run"),De(s.run.run_id,"chunk","profiling")]);if(x!==b)return;n.value=d,o.value=h,k.value.includes(a.value)||(a.value=k.value[0]??"")}catch(d){x===b&&(m.value=d instanceof Error?d.message:String(d))}finally{x===b&&(c.value=!1)}},{immediate:!0});function V(x){return ce(x.value,x.unit)}function K(x){return JSON.stringify(x,null,2)}return(x,d)=>(_(),v("aside",Bs,[e("header",Os,[e("div",null,[d[3]||(d[3]=e("span",{class:"eyebrow"},"任务详情",-1)),e("h2",null,u(D(Y)(t.run.implementation))+" / "+u(t.run.model||"未命名模型"),1),e("p",Us,u(t.run.run_id),1)]),e("button",{class:"icon-button",type:"button","aria-label":"关闭任务详情",onClick:d[0]||(d[0]=h=>x.$emit("close"))},"×")]),e("div",Vs,[m.value?(_(),v("div",js,"指标加载失败:"+u(m.value),1)):U("",!0),e("section",Fs,[e("article",null,[d[4]||(d[4]=e("span",null,"状态",-1)),e("strong",null,u(D(be)(t.run.status)),1)]),e("article",null,[d[5]||(d[5]=e("span",null,"场景",-1)),e("strong",null,u(D(ke)(t.run.scene)||"—"),1)]),e("article",null,[d[6]||(d[6]=e("span",null,"硬件",-1)),e("strong",null,u(t.run.hardware||"未知"),1)]),e("article",null,[d[7]||(d[7]=e("span",null,"指标点",-1)),e("strong",null,u(t.run.metric_count.toLocaleString("zh-CN")),1)])]),e("section",Es,[e("div",Is,[d[8]||(d[8]=e("div",null,[e("span",{class:"eyebrow"},"目标端事实"),e("h3",null,"正式测量分块曲线")],-1)),F(e("select",{"onUpdate:modelValue":d[1]||(d[1]=h=>a.value=h),disabled:!k.value.length},[(_(!0),v(q,null,j(k.value,h=>(_(),v("option",{key:h,value:h},u(D(Z)(h))+"("+u(h)+") ",9,Ws))),128))],8,Hs),[[se,a.value]])]),W(X,{points:y.value,"sample-axis":"","empty-text":"该任务不包含正式测量阶段的分块指标。"},null,8,["points"])]),e("section",Ys,[e("div",Js,[d[9]||(d[9]=e("div",null,[e("span",{class:"eyebrow"},"汇总指标"),e("h3",null,"任务指标值")],-1)),F(e("input",{"onUpdate:modelValue":d[2]||(d[2]=h=>p.value=h),class:"compact-input",placeholder:"筛选指标"},null,512),[[ye,p.value]])]),e("div",Xs,[c.value?(_(),v("div",Zs,"正在加载指标…")):(_(),v(q,{key:1},[P.value.length?U("",!0):(_(),v("div",Qs,"没有符合条件的汇总指标。")),(_(!0),v(q,null,j(P.value,h=>(_(),v("div",{key:h.metric_name+h.statistic,class:"metric-row"},[e("div",null,[e("strong",null,u(D(Z)(h.metric_name)),1),e("small",null,u(h.metric_name)+" · "+u(D(pe)(h.statistic)),1)]),e("span",en,u(V(h)),1)]))),128))],64))])]),e("section",tn,[d[15]||(d[15]=e("span",{class:"eyebrow"},"任务产物",-1)),d[16]||(d[16]=e("h3",null,"来源与完整性",-1)),e("dl",null,[d[10]||(d[10]=e("dt",null,"路径",-1)),e("dd",sn,u(t.run.artifact_path),1),d[11]||(d[11]=e("dt",null,"摘要",-1)),e("dd",nn,u(t.run.artifact_digest),1),d[12]||(d[12]=e("dt",null,"AIPerf",-1)),e("dd",null,u(t.run.aiperf_version||"未知版本")+" · "+u(t.run.aiperf_commit||"未知提交"),1)]),e("details",null,[d[13]||(d[13]=e("summary",null,"配置 JSON",-1)),e("pre",null,u(K(t.run.config)),1)]),e("details",null,[d[14]||(d[14]=e("summary",null,"元数据 JSON",-1)),e("pre",null,u(K(t.run.metadata)),1)])])])]))}}),an={class:"table-scroll"},on={class:"runs-table"},rn={key:0},un={key:1},cn=["onClick"],dn=["title"],mn=["checked","aria-label","onChange"],pn=["title"],fn=["checked","aria-label","onChange"],_n={class:"nowrap"},vn={class:"mono"},hn={class:"mono"},gn={class:"mono run-id"},yn=le({__name:"RunsTable",props:{runs:{},selectedId:{},leftIds:{},rightIds:{},loading:{type:Boolean}},emits:["select","toggleGroup"],setup(t,{emit:s}){const n=s;function o(p){return new Date(p).toLocaleString("zh-CN",{month:"short",day:"2-digit",hour:"2-digit",minute:"2-digit"})}function a(p){if(!p.ended_at)return"—";const c=(new Date(p.ended_at).getTime()-new Date(p.started_at).getTime())/1e3;return c<60?`${c.toFixed(1)} 秒`:`${Math.floor(c/60)} 分 ${Math.round(c%60)} 秒`}return(p,c)=>(_(),v("div",an,[e("table",on,[c[6]||(c[6]=e("thead",null,[e("tr",null,[e("th",{class:"comparison-column left-comparison-column"},"左组"),e("th",{class:"comparison-column right-comparison-column"},"右组"),e("th",null,"开始时间"),e("th",null,"实现框架"),e("th",null,"模型 / 场景"),e("th",null,"状态"),e("th",null,"耗时"),e("th",null,"指标点"),e("th",null,"任务 ID")])],-1)),e("tbody",null,[t.loading&&!t.runs.length?(_(),v("tr",rn,[...c[2]||(c[2]=[e("td",{colspan:"9",class:"empty-row"},"正在从 GreptimeDB 加载历史数据…",-1)])])):t.runs.length?U("",!0):(_(),v("tr",un,[...c[3]||(c[3]=[e("td",{colspan:"9",class:"empty-row"},"没有符合当前筛选条件的基准任务。",-1)])])),(_(!0),v(q,null,j(t.runs,m=>(_(),v("tr",{key:m.run_id,class:Q({active:t.selectedId===m.run_id,"left-assigned":t.leftIds.includes(m.run_id),"right-assigned":t.rightIds.includes(m.run_id)}),onClick:b=>n("select",m)},[e("td",{class:"comparison-column left-comparison-column",onClick:c[0]||(c[0]=Pe(()=>{},["stop"]))},[e("label",{class:"check-wrap left-check",title:`将任务 ${m.run_id} 分配到左组`},[e("input",{type:"checkbox",checked:t.leftIds.includes(m.run_id),"aria-label":`将任务 ${m.run_id} 分配到左组`,onChange:b=>n("toggleGroup",m.run_id,"left")},null,40,mn),c[4]||(c[4]=e("span",null,null,-1))],8,dn)]),e("td",{class:"comparison-column right-comparison-column",onClick:c[1]||(c[1]=Pe(()=>{},["stop"]))},[e("label",{class:"check-wrap right-check",title:`将任务 ${m.run_id} 分配到右组`},[e("input",{type:"checkbox",checked:t.rightIds.includes(m.run_id),"aria-label":`将任务 ${m.run_id} 分配到右组`,onChange:b=>n("toggleGroup",m.run_id,"right")},null,40,fn),c[5]||(c[5]=e("span",null,null,-1))],8,pn)]),e("td",_n,u(o(m.started_at)),1),e("td",null,[e("strong",null,u(D(Y)(m.implementation)),1),e("small",null,u(D(qt)(m.transport)||"未知协议")+" · "+u(D(We)(m.run_kind)),1)]),e("td",null,[e("strong",null,u(m.model||"未命名模型"),1),e("small",null,u(D(ke)(m.scene||m.mode))+" · "+u(m.hardware||"硬件未知"),1)]),e("td",null,[e("span",{class:Q(["status-pill",m.status])},u(D(be)(m.status)),3)]),e("td",vn,u(a(m)),1),e("td",hn,u(m.metric_count.toLocaleString("zh-CN")),1),e("td",gn,u(m.run_id.slice(0,12)),1)],10,cn))),128))])])]))}}),Oe=["value","avg","mean","p50","p75","p90","p95","p99","min","max","std","count","sum","bucket"];function xe(t){return`${t.metric_name}${t.scope}${t.unit}`}function Ue(t){const s=Oe.indexOf(t.toLowerCase());return s===-1?Oe.length:s}function bn(t){const s=new Map;for(const n of t){const o=xe(n),a=s.get(o)??{metric_name:n.metric_name,scope:n.scope,unit:n.unit,variants:[],point_count:0};a.variants.push(n),a.point_count+=n.point_count,s.set(o,a)}return[...s.values()].map(n=>({...n,variants:n.variants.sort((o,a)=>Ue(o.statistic)-Ue(a.statistic)||o.statistic.localeCompare(a.statistic))})).sort((n,o)=>+(o.scope==="run")-+(n.scope==="run")||n.metric_name.localeCompare(o.metric_name)||n.scope.localeCompare(o.scope)||n.unit.localeCompare(o.unit))}const E=16,Xe="aiperf.history.dashboard.semantic-metrics.v5",kn="aiperf.history.dashboard.metric-groups.v2",$n="aiperf.history.dashboard.metrics.v1",wn=[{id:"request_session",label:"请求与 Session",description:"端到端时延、吞吐、成功率和生命周期",metrics:[{id:"request_latency",metricNames:["request_latency"],scopes:["run"]},{id:"request_throughput",metricNames:["request_throughput"],scopes:["run"]},{id:"success_rate",metricNames:["success_rate"],scopes:["run"]},{id:"connected_latency",metricNames:["connected_latency_ms"],scopes:["run"]},{id:"session_runtime",metricNames:["session_runtime_s"],scopes:["run"]},{id:"completed_requests",metricNames:["requests.completed"],scopes:["normalized"]},{id:"server_execution_latency",metricNames:["requests.server_execution_latency"],scopes:["normalized"]}]},{id:"scheduling_queue",label:"调度与队列",description:"运行中、等待中、抢占和拒绝",metrics:[{id:"queue_depth",metricNames:["scheduler.queue_depth"],scopes:["normalized"]},{id:"running_tasks",metricNames:["scheduler.running_tasks"],scopes:["normalized"]},{id:"preemptions",metricNames:["scheduler.preemptions"],scopes:["normalized"]},{id:"rejections",metricNames:["scheduler.rejections"],scopes:["normalized"]}]},{id:"resources",label:"资源",description:"进程、容器、整机与 Ethernet/RDMA 的运行期曲线",metrics:[{id:"cpu_usage",metricNames:["resource.cpu","cpu_utilization"],scopes:["resource","server","server_summary"]},{id:"memory_usage",metricNames:["resource.memory","system_memory_used"],scopes:["resource","server","server_summary"]},{id:"gpu_usage",metricNames:["resource.gpu","gpu_utilization","amd_gpu_utilization"],scopes:["resource","gpu","gpu_summary"]},{id:"gpu_memory_usage",metricNames:["resource.gpu_memory","gpu_memory_used","amd_memory_used","chunk_peak_reserved_bytes","chunk_peak_allocated_bytes"],scopes:["resource","gpu","gpu_summary","run"]},{id:"network_usage",metricNames:["resource.network","network_bandwidth"],scopes:["resource","server","server_summary"]}]},{id:"cache",label:"缓存",description:"容量、使用率、命中率、复用和迁移",metrics:[{id:"cache_usage",metricNames:["cache.usage"],scopes:["normalized"]},{id:"cache_hit_rate",metricNames:["cache.hit_rate"],scopes:["normalized"]},{id:"cache_transfer",metricNames:["cache.transfer_bytes"],scopes:["normalized"]}]},{id:"media_stream",label:"媒体流",description:"帧输出、目标计算吞吐和控制闭环",metrics:[{id:"delivery_fps",metricNames:["stream_fps"],scopes:["run"]},{id:"target_compute_fps",metricNames:["chunk_compute_fps_weighted"],scopes:["run"]},{id:"first_frame_latency",metricNames:["first_frame_latency_ms"],scopes:["run"]},{id:"first_metadata_latency",metricNames:["first_metadata_latency_ms"],scopes:["run"]},{id:"control_ack_latency",metricNames:["control_ack_latency_ms"],scopes:["run"]},{id:"control_feedback_latency",metricNames:["control_to_next_frame_latency_ms"],scopes:["run"]}]}],xn=["target_compute_fps","delivery_fps","cpu_usage","memory_usage","gpu_usage","gpu_memory_usage","network_usage","first_frame_latency","control_ack_latency","control_feedback_latency","request_throughput","request_latency"];function Ve(t,s){const n=s.indexOf(t);return n===-1?s.length:n}function je(t,s){if(!s?.length)return 0;const n=s.indexOf(t);return n===-1?s.length:n}function Sn(t,s,n){return n.filter(o=>o.metric_name===t&&s.scopes.includes(o.scope)).sort((o,a)=>Ve(o.scope,s.scopes)-Ve(a.scope,s.scopes)||je(o.unit,s.units)-je(a.unit,s.units)||a.point_count-o.point_count)[0]}function Cn(t,s,n){const o=s.metricNames.flatMap(b=>{const k=Sn(b,s,n);return k?[k]:[]});if(!o.length)return null;const a=o[0],p=o.filter(b=>b.scope===a.scope&&b.unit===a.unit),c=[...new Set(p.map(b=>b.unit))],m=[...new Set(p.map(b=>b.scope))];return{key:`core:${t}:${s.id}`,metric_name:s.metricNames[0],scope:m.length===1?m[0]:"mixed",unit:c.length===1?c[0]:"mixed",units:c,variants:p.flatMap(b=>b.variants),sourceGroups:p,point_count:p.reduce((b,k)=>b+k.point_count,0)}}function Mn(t){const s=new Map,n=wn.map(a=>{const p=a.metrics.flatMap(c=>{const m=Cn(a.id,c,t);if(!m)return[];for(const b of m.sourceGroups)s.set(xe(b),m.key);return[m]});return{id:a.id,label:a.label,description:a.description,metrics:p,availableCount:p.length,expectedCount:a.metrics.length}}),o=n.flatMap(a=>a.metrics);return{core:n,allMetrics:o,metricByKey:new Map(o.map(a=>[a.key,a])),metricKeyByGroupKey:s}}function he(t){const s=t.core.flatMap(o=>o.metrics);return xn.flatMap(o=>{const a=s.find(p=>p.key.endsWith(`:${o}`));return a?[a.key]:[]}).slice(0,E)}function ge(t){try{const s=JSON.parse(t);return Array.isArray(s)?s.filter(n=>typeof n=="string"):null}catch{return null}}function Fe(t,s){return[...new Set(t.flatMap(n=>{const o=s.metricKeyByGroupKey.get(n);return o?[o]:[]}))]}function Tn(t,s){const n=window.localStorage.getItem(Xe);if(n!==null)return ge(n)?.slice(0,E)??null;const o=window.localStorage.getItem(kn);if(o!==null){const m=ge(o);return m===null?null:Fe(m,t).slice(0,E)}const a=window.localStorage.getItem($n);if(a===null)return null;const p=ge(a);if(p===null)return null;const c=p.flatMap(m=>{const[b,,k]=m.split(""),y=s.find(P=>P.metric_name===b&&P.scope===k);return y?[xe(y)]:[]});return Fe(c,t).slice(0,E)}function oe(t){try{window.localStorage.setItem(Xe,JSON.stringify(t.slice(0,E)))}catch{}}const Ln={class:"app-shell"},Nn={class:"topbar"},Pn={class:"topbar-meta"},Rn={class:"database-state"},Dn={key:0,class:"error-banner"},Gn={class:"summary-grid"},zn={class:"summary-time"},Kn={class:"panel filter-panel"},An={class:"filter-grid"},qn={class:"search-field"},Bn=["value"],On=["value"],Un=["value"],Vn=["value"],jn={class:"dashboard-workspace","aria-label":"历史指标工作区"},Fn={class:"metric-sidebar"},En={key:0,class:"metric-selection-notice"},In={class:"dashboard-content"},Hn={class:"panel dashboard-controls"},Wn={class:"panel-heading dashboard-heading"},Yn={class:"comparison-group-summary"},Jn={class:"comparison-group-summary-card left-group-summary"},Xn=["title"],Zn=["disabled"],Qn={class:"comparison-group-summary-card right-group-summary"},el=["title"],tl=["disabled"],sl={class:"metric-dashboard-sections","aria-label":"历史指标图表"},nl=["aria-labelledby"],ll={class:"metric-dashboard-section-heading"},al=["id"],ol={class:"metric-dashboard-grid"},rl={key:0,class:"panel dashboard-empty"},il={class:"panel runs-panel"},ul={class:"panel-heading runs-heading"},cl={class:"pagination"},dl=["disabled"],ml=["disabled"],re=25,pl=le({__name:"App",setup(t){const s=R([]),n=R(0),o=R(0),a=R({}),p=R([]),c=R([]),m=Re({}),b=R(null),k=R([]),y=R([]),P=R(null),V=R(!1),K=R(""),x=R(""),d=Re({search:"",implementation:"",model:"",scene:"",status:"",run_kind:""}),h=M(()=>bn(p.value)),r=M(()=>Mn(h.value)),g=M(()=>new Set(c.value)),$=M(()=>r.value.core.flatMap(f=>{const l=f.metrics.filter(i=>g.value.has(i.key));return l.length?[{...f,metrics:l}]:[]})),N=M(()=>$.value.flatMap(f=>f.metrics)),B=M(()=>Math.floor(o.value/re)+1),A=M(()=>Math.max(1,Math.ceil(n.value/re))),C=M(()=>s.value.filter(f=>f.status==="completed").length),w=M(()=>s.value[0]??null);function S(f){return m[f]||(m[f]={points:[],loading:!1,error:"",requestVersion:0}),m[f]}function L(f,l){const i=m[f]?.points??[];if(!l.length)return[];const T=new Set(l);return i.filter(z=>T.has(z.run_id))}function G(){St({left:k.value,right:y.value})}function O(f){return new Date(f).toLocaleString("zh-CN",{month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",hour12:!1})}function I(f){if(!f.length)return"请从下方任务表选择记录";const l=f.slice(0,2).map(T=>{const z=s.value.find(ee=>ee.run_id===T);return z?[Y(z.implementation),z.model,z.run_id.slice(0,8),O(z.started_at)].join(" · "):T.slice(0,8)}),i=f.length-l.length;return`${l.join(";")}${i>0?`;另 ${i} 条`:""}`}async function J(){V.value=!0;try{const f=await gt(d,re,o.value);s.value=f.items,n.value=f.total;const l=f.items.find(i=>i.run_id===b.value?.run_id);l&&(b.value=l)}finally{V.value=!1}}async function Se(f=!0){p.value=await kt();const l=new Set(r.value.allMetrics.map(z=>z.key)),i=f?c.value:Tn(r.value,h.value);let T;i===null?T=he(r.value):(T=i.filter(z=>l.has(z)).slice(0,E),i.length&&!T.length&&(T=he(r.value))),c.value=T,oe(T);for(const z of Object.keys(m))l.has(z)||delete m[z]}async function Ce(f){const l=f.key,i=S(l),T=++i.requestVersion;i.loading=!0,i.error="";try{const z=await Promise.all(f.variants.map(ee=>$t({metricName:ee.metric_name,statistic:ee.statistic,scope:f.scope,implementation:d.implementation||void 0,model:d.model||void 0,scene:d.scene||void 0,status:d.status||void 0,runKind:d.run_kind||void 0,search:d.search||void 0,phase:f.scope==="chunk"?"profiling":void 0})));if(T===i.requestVersion){const ee=new Set(f.units);i.points=z.flat().filter(nt=>ee.has(nt.unit))}}catch(z){T===i.requestVersion&&(i.error=z instanceof Error?z.message:String(z),i.points=[])}finally{T===i.requestVersion&&(i.loading=!1)}}async function _e(){await Promise.all(N.value.map(f=>Ce(f)))}async function ve(){o.value=0,K.value="";try{await Promise.all([J(),Se()]),await _e()}catch(f){K.value=f instanceof Error?f.message:String(f)}}async function Me(f){const l=Math.min(Math.max(0,o.value+f*re),(A.value-1)*re);if(l!==o.value){o.value=l;try{await J()}catch(i){K.value=i instanceof Error?i.message:String(i)}}}function Ze(f,l){l==="left"?(k.value=k.value.includes(f)?k.value.filter(i=>i!==f):[...k.value,f],y.value=y.value.filter(i=>i!==f)):(y.value=y.value.includes(f)?y.value.filter(i=>i!==f):[...y.value,f],k.value=k.value.filter(i=>i!==f)),G()}function Te(f){f==="left"?k.value=[]:y.value=[],G()}function Qe(){k.value=[],y.value=[],G()}function Le(f){if(x.value="",c.value.includes(f)){c.value=c.value.filter(i=>i!==f),oe(c.value);return}if(c.value.length>=E){x.value=`最多同时开启 ${E} 个指标。`;return}const l=r.value.metricByKey.get(f);l&&(c.value=[...c.value,f],oe(c.value),Ce(l))}function Ne(){x.value="",c.value=he(r.value),oe(c.value),_e()}function et(){x.value="",c.value=[],oe([])}function tt(){Object.assign(d,{search:"",implementation:"",model:"",scene:"",status:"",run_kind:""}),ve()}function st(f){const l=Math.max(0,(Date.now()-new Date(f).getTime())/1e3);return l<60?"刚刚":l<3600?`${Math.floor(l/60)} 分钟前`:l<86400?`${Math.floor(l/3600)} 小时前`:`${Math.floor(l/86400)} 天前`}return Ee(async()=>{K.value="";try{const f=xt(),[l,i]=await Promise.all([yt(),bt()]);if(a.value=l,P.value=i,await Promise.all([J(),Se(!1)]),f===null){const T=s.value.filter(z=>z.status==="completed").slice(0,2);k.value=T[0]?[T[0].run_id]:[],y.value=T[1]?[T[1].run_id]:[],G()}else k.value=f.left,y.value=f.right;await _e()}catch(f){K.value=f instanceof Error?f.message:String(f)}}),(f,l)=>(_(),v("div",Ln,[e("header",Nn,[l[15]||(l[15]=e("a",{class:"brand",href:"/","aria-label":"AIPerf 历史指标首页"},[e("span",{class:"brand-mark"},[e("i"),e("i"),e("i")]),e("span",null,[e("strong",null,"AIPerf"),e("small",null,"历史指标")])],-1)),e("div",Pn,[e("span",Rn,[l[13]||(l[13]=e("i",null,null,-1)),ne(" GreptimeDB · "+u(P.value?.database??"连接中"),1)]),l[14]||(l[14]=e("a",{href:"/docs",target:"_blank",rel:"noreferrer"},"API 文档 ↗",-1))])]),e("main",null,[e("section",{class:"hero-row"},[l[16]||(l[16]=e("div",null,[e("span",{class:"eyebrow"},"历史可观测性"),e("h1",null,"在一条时间线上比较所有基准任务。"),e("p",null,"统一查看 TeleFuser、SGLang 及后续目标系统的框架无关 AIPerf 指标。")],-1)),e("div",{class:"hero-actions"},[e("button",{class:"ghost-button",type:"button",onClick:tt},"重置筛选"),e("button",{class:"primary-button",type:"button",onClick:ve},"刷新历史")])]),K.value?(_(),v("div",Dn,[l[17]||(l[17]=e("strong",null,"历史查询失败。",-1)),ne(" "+u(K.value),1)])):U("",!0),e("section",Gn,[e("article",null,[l[18]||(l[18]=e("span",null,"任务总数",-1)),e("strong",null,u(n.value.toLocaleString("zh-CN")),1),l[19]||(l[19]=e("small",null,"符合当前筛选条件",-1))]),e("article",null,[l[20]||(l[20]=e("span",null,"已完成",-1)),e("strong",null,u(C.value)+"/"+u(s.value.length),1),l[21]||(l[21]=e("small",null,"当前页任务",-1))]),e("article",null,[l[22]||(l[22]=e("span",null,"实现框架",-1)),e("strong",null,u(a.value.implementation?.length??0),1),l[23]||(l[23]=e("small",null,"已索引目标",-1))]),e("article",null,[l[24]||(l[24]=e("span",null,"最新任务",-1)),e("strong",zn,u(w.value?st(w.value.started_at):"—"),1),e("small",null,u(w.value?D(Y)(w.value.implementation):"暂无数据"),1)])]),e("section",Kn,[e("div",An,[e("label",qn,[l[25]||(l[25]=e("span",null,"搜索任务",-1)),F(e("input",{"onUpdate:modelValue":l[0]||(l[0]=i=>d.search=i),placeholder:"任务 ID、模型、实现框架…",onKeyup:ot(ve,["enter"])},null,544),[[ye,d.search]])]),e("label",null,[l[27]||(l[27]=e("span",null,"实现框架",-1)),F(e("select",{"onUpdate:modelValue":l[1]||(l[1]=i=>d.implementation=i)},[l[26]||(l[26]=e("option",{value:""},"全部实现框架",-1)),(_(!0),v(q,null,j(a.value.implementation,i=>(_(),v("option",{key:i,value:i},u(D(Y)(i)),9,Bn))),128))],512),[[se,d.implementation]])]),e("label",null,[l[29]||(l[29]=e("span",null,"场景",-1)),F(e("select",{"onUpdate:modelValue":l[2]||(l[2]=i=>d.scene=i)},[l[28]||(l[28]=e("option",{value:""},"全部场景",-1)),(_(!0),v(q,null,j(a.value.scene,i=>(_(),v("option",{key:i,value:i},u(D(ke)(i)),9,On))),128))],512),[[se,d.scene]])]),e("label",null,[l[31]||(l[31]=e("span",null,"模型",-1)),F(e("select",{"onUpdate:modelValue":l[3]||(l[3]=i=>d.model=i)},[l[30]||(l[30]=e("option",{value:""},"全部模型",-1)),(_(!0),v(q,null,j(a.value.model,i=>(_(),v("option",{key:i},u(i),1))),128))],512),[[se,d.model]])]),e("label",null,[l[33]||(l[33]=e("span",null,"状态",-1)),F(e("select",{"onUpdate:modelValue":l[4]||(l[4]=i=>d.status=i)},[l[32]||(l[32]=e("option",{value:""},"全部状态",-1)),(_(!0),v(q,null,j(a.value.status,i=>(_(),v("option",{key:i,value:i},u(D(be)(i)),9,Un))),128))],512),[[se,d.status]])]),e("label",null,[l[35]||(l[35]=e("span",null,"任务类型",-1)),F(e("select",{"onUpdate:modelValue":l[5]||(l[5]=i=>d.run_kind=i)},[l[34]||(l[34]=e("option",{value:""},"全部任务类型",-1)),(_(!0),v(q,null,j(a.value.run_kind,i=>(_(),v("option",{key:i,value:i},u(D(We)(i)),9,Vn))),128))],512),[[se,d.run_kind]])])])]),e("section",jn,[e("aside",Fn,[W(qs,{taxonomy:r.value,"selected-keys":c.value,"max-selected":D(E),onToggle:Le,onRestoreDefaults:Ne,onClear:et},null,8,["taxonomy","selected-keys","max-selected"]),x.value?(_(),v("div",En,u(x.value),1)):U("",!0)]),e("div",In,[e("section",Hn,[e("div",Wn,[e("div",null,[l[36]||(l[36]=e("span",{class:"eyebrow"},"指标仪表盘",-1)),l[37]||(l[37]=e("h2",null,"左右分屏指标对比",-1)),e("p",null," 从左侧指标树开启图表;左组 "+u(k.value.length)+" 条,右组 "+u(y.value.length)+" 条记录。 ",1)]),k.value.length||y.value.length?(_(),v("button",{key:0,class:"text-button",type:"button",onClick:Qe}," 清空左右分组 ")):U("",!0)]),e("div",Yn,[e("article",Jn,[l[38]||(l[38]=e("span",{class:"comparison-group-badge"},"左组",-1)),e("div",null,[e("strong",null,u(k.value.length)+" 条记录",1),e("small",{title:I(k.value)},u(I(k.value)),9,Xn)]),e("button",{class:"text-button",type:"button",disabled:!k.value.length,onClick:l[6]||(l[6]=i=>Te("left"))},"清空",8,Zn)]),e("article",Qn,[l[39]||(l[39]=e("span",{class:"comparison-group-badge"},"右组",-1)),e("div",null,[e("strong",null,u(y.value.length)+" 条记录",1),e("small",{title:I(y.value)},u(I(y.value)),9,el)]),e("button",{class:"text-button",type:"button",disabled:!y.value.length,onClick:l[7]||(l[7]=i=>Te("right"))},"清空",8,tl)])])]),e("section",sl,[(_(!0),v(q,null,j($.value,i=>(_(),v("section",{key:i.id,class:"metric-dashboard-section","aria-labelledby":`dashboard-category-${i.id}`},[e("header",ll,[e("div",null,[l[40]||(l[40]=e("span",{class:"eyebrow"},"核心维度",-1)),e("h3",{id:`dashboard-category-${i.id}`},u(i.label),9,al)]),e("p",null,u(i.description),1),e("strong",null,u(i.metrics.length)+"/"+u(i.availableCount),1)]),e("div",ol,[(_(!0),v(q,null,j(i.metrics,T=>(_(),me($s,{key:T.key,metric:T,"left-points":L(T.key,k.value),"right-points":L(T.key,y.value),loading:m[T.key]?.loading??!1,error:m[T.key]?.error??"","left-count":k.value.length,"right-count":y.value.length,onRemove:Le},null,8,["metric","left-points","right-points","loading","error","left-count","right-count"]))),128))])],8,nl))),128)),N.value.length?U("",!0):(_(),v("div",rl,[l[41]||(l[41]=e("strong",null,"尚未开启指标图表。",-1)),l[42]||(l[42]=e("span",null,"请从左侧指标树选择一个或多个核心指标。",-1)),e("button",{class:"ghost-button",type:"button",onClick:Ne}," 开启默认指标 ")]))])])]),e("section",il,[e("div",ul,[l[43]||(l[43]=e("div",null,[e("span",{class:"eyebrow"},"产物索引"),e("h2",null,"基准测试任务")],-1)),e("div",cl,[e("button",{type:"button",disabled:B.value<=1,onClick:l[8]||(l[8]=i=>Me(-1))},"←",8,dl),e("span",null,"第 "+u(B.value)+" / "+u(A.value)+" 页",1),e("button",{type:"button",disabled:B.value>=A.value,onClick:l[9]||(l[9]=i=>Me(1))},"→",8,ml)])]),W(yn,{runs:s.value,"selected-id":b.value?.run_id??"","left-ids":k.value,"right-ids":y.value,loading:V.value,onSelect:l[10]||(l[10]=i=>b.value=i),onToggleGroup:Ze},null,8,["runs","selected-id","left-ids","right-ids","loading"])])]),W(it,{name:"drawer"},{default:rt(()=>[b.value?(_(),me(ln,{key:0,run:b.value,onClose:l[11]||(l[11]=i=>b.value=null)},null,8,["run"])):U("",!0)]),_:1}),b.value?(_(),v("div",{key:0,class:"drawer-scrim",onClick:l[12]||(l[12]=i=>b.value=null)})):U("",!0)]))}});ut(pl).mount("#app"); diff --git a/src/aiperf/history/static/assets/index-C_F3hdCl.css b/src/aiperf/history/static/assets/index-C_F3hdCl.css new file mode 100644 index 0000000000..702dce8810 --- /dev/null +++ b/src/aiperf/history/static/assets/index-C_F3hdCl.css @@ -0,0 +1,364 @@ +/*! SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 */ + +:root { + color-scheme: dark; + font-family: Inter, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, + -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e9f4f1; + background: #07110f; + font-synthesis: none; + text-rendering: optimizeLegibility; + --bg: #07110f; + --panel: #0d1a17; + --panel-2: #11211d; + --line: #213832; + --line-bright: #31564d; + --text: #e9f4f1; + --muted: #8fa39d; + --faint: #657a74; + --mint: #66e3c4; + --mint-dark: #183c34; + --amber: #ffb45d; + --danger: #ff7f8b; +} + +* { box-sizing: border-box; } +html { min-width: 320px; background: var(--bg); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: + radial-gradient(circle at 86% -8%, rgba(55, 143, 121, 0.16), transparent 31rem), + radial-gradient(circle at 6% 30%, rgba(255, 180, 93, 0.05), transparent 24rem), var(--bg); } +button, input, select { font: inherit; } +button, select { cursor: pointer; } +a { color: inherit; text-decoration: none; } + +#aiperf-tooltip-top-layer { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: visible; + pointer-events: none; +} +#aiperf-tooltip-top-layer::backdrop { background: transparent; pointer-events: none; } +.chart-tooltip-totals { display: block; margin-top: 6px; padding-top: 6px; border-top: 1px solid #29433d; + color: #bddc72; } + +.topbar { height: 68px; padding: 0 38px; display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid rgba(74, 111, 102, 0.28); background: rgba(7, 17, 15, 0.82); + backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; } +.brand { display: flex; gap: 12px; align-items: center; } +.brand > span:last-child { display: flex; flex-direction: column; } +.brand strong { font-size: 16px; letter-spacing: 0.02em; } +.brand small { color: var(--muted); font-size: 11px; margin-top: 1px; } +.brand-mark { width: 31px; height: 31px; display: flex; align-items: end; gap: 3px; padding: 7px; + border: 1px solid var(--line-bright); border-radius: 8px; background: var(--panel-2); } +.brand-mark i { display: block; width: 4px; border-radius: 3px; background: var(--mint); } +.brand-mark i:nth-child(1) { height: 7px; opacity: .65; } +.brand-mark i:nth-child(2) { height: 15px; } +.brand-mark i:nth-child(3) { height: 11px; opacity: .82; } +.topbar-meta { display: flex; align-items: center; gap: 24px; color: var(--muted); font-size: 12px; } +.topbar-meta a:hover { color: var(--mint); } +.database-state { display: inline-flex; align-items: center; gap: 8px; } +.database-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 12px var(--mint); } + +main { width: min(1540px, calc(100% - 64px)); margin: 0 auto; padding: 48px 0 80px; } +.hero-row { display: flex; justify-content: space-between; gap: 32px; align-items: end; margin-bottom: 30px; } +.hero-row h1 { margin: 7px 0 8px; font-size: clamp(29px, 4vw, 47px); line-height: 1.08; letter-spacing: -0.04em; font-weight: 620; } +.hero-row p { margin: 0; color: var(--muted); max-width: 710px; font-size: 15px; } +.eyebrow { display: block; color: var(--mint); font-size: 10px; font-weight: 700; letter-spacing: .17em; } +.hero-actions { display: flex; gap: 10px; flex-shrink: 0; } +.primary-button, .ghost-button { border-radius: 8px; padding: 10px 15px; border: 1px solid var(--line-bright); color: var(--text); } +.primary-button { background: var(--mint); color: #07110f; border-color: var(--mint); font-weight: 700; } +.primary-button:hover { background: #86ecd3; } +.ghost-button { background: var(--panel); } +.ghost-button:hover { border-color: var(--mint); } + +.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; } +.summary-grid article { min-height: 122px; padding: 19px 21px; border: 1px solid var(--line); border-radius: 11px; + background: linear-gradient(145deg, rgba(17, 34, 29, .96), rgba(11, 25, 21, .96)); } +.summary-grid span, .summary-grid small { display: block; color: var(--muted); } +.summary-grid span { font-size: 12px; } +.summary-grid strong { display: block; margin: 9px 0 5px; font-size: 29px; line-height: 1; font-weight: 590; } +.summary-grid small { font-size: 11px; } +.summary-grid .summary-time { font-size: 23px; } + +.panel { border: 1px solid var(--line); border-radius: 12px; background: rgba(12, 26, 22, .93); overflow: hidden; } +.filter-panel { padding: 17px; margin-bottom: 14px; } +.filter-grid { display: grid; grid-template-columns: 1.45fr repeat(5, minmax(125px, 1fr)); gap: 10px; } +label > span { display: block; margin: 0 0 6px 2px; color: var(--faint); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } +input, select { width: 100%; height: 39px; border-radius: 7px; border: 1px solid var(--line); background: #091512; + color: var(--text); padding: 0 11px; outline: none; } +input:focus, select:focus { border-color: var(--mint); box-shadow: 0 0 0 2px rgba(102, 227, 196, .09); } +input::placeholder { color: #52645f; } + +.panel-heading { min-height: 82px; display: flex; align-items: center; justify-content: space-between; gap: 24px; + padding: 17px 20px; border-bottom: 1px solid var(--line); } +.panel-heading h2, .detail-section h3 { margin: 4px 0 0; font-size: 17px; font-weight: 590; } +.panel-heading p { color: var(--amber); font-size: 11px; margin: 4px 0 0; } +.text-button { height: 38px; border: 0; color: var(--mint); background: transparent; white-space: nowrap; } +.text-button:disabled { color: var(--faint); cursor: default; } +.loading-line { height: 2px; background: linear-gradient(90deg, transparent, var(--mint), transparent); animation: load 1.2s linear infinite; } +@keyframes load { from { transform: translateX(-100%); } to { transform: translateX(100%); } } + +.dashboard-workspace { display: grid; grid-template-columns: 286px minmax(0, 1fr); gap: 12px; align-items: start; + margin-bottom: 14px; } +.metric-sidebar { min-width: 0; position: sticky; top: 82px; align-self: start; } +.dashboard-content { min-width: 0; } +.dashboard-controls { margin-bottom: 12px; } +.dashboard-heading p { color: var(--muted); } +.comparison-group-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px 20px; } +.comparison-group-summary-card { min-width: 0; display: flex; align-items: center; gap: 12px; padding: 12px 13px; + border: 1px solid var(--line); border-radius: 9px; background: #091512; } +.comparison-group-summary-card > div { min-width: 0; } +.comparison-group-summary-card strong, .comparison-group-summary-card small { display: block; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.comparison-group-summary-card strong { font-size: 12px; font-weight: 590; } +.comparison-group-summary-card small { margin-top: 4px; color: var(--faint); font-size: 10px; } +.comparison-group-summary-card .text-button { margin-left: auto; } +.comparison-group-badge { min-width: 43px; padding: 5px 7px; border-radius: 6px; text-align: center; font-size: 10px; + font-weight: 700; letter-spacing: .08em; } +.left-group-summary { border-color: rgba(102, 227, 196, .35); } +.left-group-summary .comparison-group-badge { color: var(--mint); background: rgba(102, 227, 196, .12); } +.right-group-summary { border-color: rgba(255, 180, 93, .35); } +.right-group-summary .comparison-group-badge { color: var(--amber); background: rgba(255, 180, 93, .12); } +.metric-selector { height: calc(100vh - 94px); min-height: 540px; display: flex; flex-direction: column; } +.metric-sidebar-header { min-height: 82px; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 15px 16px; border-bottom: 1px solid var(--line); } +.metric-sidebar-header h2 { margin: 4px 0 0; font-size: 17px; font-weight: 590; } +.metric-sidebar-header p { margin: 4px 0 0; color: var(--faint); font-size: 10px; } +.metric-sidebar-header > strong { min-width: 43px; padding: 6px 8px; border: 1px solid rgba(102, 227, 196, .3); + border-radius: 7px; color: var(--mint); background: rgba(102, 227, 196, .08); text-align: center; + font-size: 10px; font-weight: 650; } +.metric-sidebar-tools { padding: 12px 13px 10px; border-bottom: 1px solid var(--line); background: #0a1713; } +.metric-search { width: 100%; } +.metric-search input { height: 36px; font-size: 11px; } +.selected-only-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 39px; color: var(--muted); + font-size: 11px; white-space: nowrap; } +.selected-only-toggle input { width: 15px; height: 15px; margin: 0; accent-color: var(--mint); } +.selected-only-toggle span { margin: 0; color: inherit; font-size: inherit; font-weight: 500; text-transform: none; letter-spacing: 0; } +.metric-selection-actions { display: flex; align-items: center; gap: 4px; border-top: 1px solid rgba(49, 78, 70, .38); } +.metric-selection-actions .text-button { height: 31px; padding: 0 5px; font-size: 10px; } +.metric-tree { min-height: 0; flex: 1; overflow: auto; background: #081310; } +.metric-tree-category + .metric-tree-category { border-top: 1px solid rgba(49, 78, 70, .55); } +.metric-tree-category-row { width: 100%; min-height: 51px; display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border: 0; color: var(--text); background: #091512; text-align: left; } +.metric-tree-category-row:hover { background: #0d1d18; } +.metric-tree-category-row > i { width: 15px; color: var(--faint); font-size: 19px; + font-style: normal; line-height: 1; text-align: center; transform: rotate(0deg); transition: transform .16s ease; } +.metric-tree-category-row > i.expanded { transform: rotate(90deg); } +.metric-tree-category-row > span { min-width: 0; flex: 1; } +.metric-tree-category-row strong, .metric-tree-category-row small { display: block; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.metric-tree-category-row strong { font-size: 11px; font-weight: 590; } +.metric-tree-category-row small { margin-top: 3px; color: var(--faint); font-size: 8px; } +.metric-tree-category-row > em { min-width: 23px; color: var(--muted); font-size: 9px; font-style: normal; + text-align: right; white-space: nowrap; } +.metric-tree-children { display: flex; flex-direction: column; gap: 2px; margin-left: 17px; padding: 5px 7px 8px 14px; + border-top: 1px solid rgba(49, 78, 70, .26); border-left: 1px solid rgba(102, 227, 196, .24); + background: #07110f; } +.metric-tree-leaf { min-width: 0; min-height: 43px; display: flex; align-items: center; gap: 8px; position: relative; + padding: 5px 7px; border: 1px solid transparent; border-radius: 6px; background: transparent; cursor: pointer; } +.metric-tree-leaf:hover { border-color: var(--line); background: #0d1c18; } +.metric-tree-leaf.checked { border-color: rgba(102, 227, 196, .38); background: rgba(34, 85, 72, .25); } +.metric-tree-leaf.disabled { opacity: .42; cursor: not-allowed; } +.metric-tree-leaf > input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } +.metric-tree-leaf > i { width: 14px; height: 14px; flex: 0 0 14px; border: 1px solid var(--line-bright); border-radius: 4px; + background: #06100d; } +.metric-tree-leaf.checked > i { border-color: var(--mint); background: var(--mint); box-shadow: inset 0 0 0 4px #0b1b17; } +.metric-tree-leaf > span { display: block; min-width: 0; flex: 1; margin: 0; color: inherit; font-size: inherit; + font-weight: inherit; text-transform: none; letter-spacing: 0; } +.metric-tree-leaf strong, .metric-tree-leaf small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-tree-leaf strong { color: var(--text); font-size: 10px; font-weight: 570; } +.metric-tree-leaf small { margin-top: 3px; color: var(--faint); font-size: 8px; } +.metric-tree-empty { padding: 10px 7px; color: var(--faint); font-size: 9px; } +.metric-tree-availability { padding: 5px 7px 2px; color: var(--amber); font-size: 8px; } +.metric-tree-no-results { padding: 25px 14px; color: var(--muted); text-align: center; font-size: 10px; } +.metric-sidebar-footnote { padding: 9px 13px 11px; border-top: 1px solid var(--line); color: var(--faint); + background: #0a1713; font-size: 8px; line-height: 1.45; } +.metric-selection-notice { margin-top: 8px; padding: 8px 10px; border: 1px solid rgba(255, 180, 93, .28); + border-radius: 7px; color: var(--amber); background: rgba(101, 65, 25, .14); font-size: 10px; } + +.metric-dashboard-sections { min-width: 0; } +.metric-dashboard-section { min-width: 0; margin-bottom: 16px; } +.metric-dashboard-section-heading { min-height: 46px; display: grid; grid-template-columns: minmax(145px, auto) minmax(0, 1fr) auto; + align-items: center; gap: 14px; position: relative; padding: 7px 11px 7px 14px; border: 1px solid var(--line); + border-left: 3px solid rgba(102, 227, 196, .72); border-radius: 9px; background: linear-gradient(90deg, rgba(22, 55, 47, .46), #091512 42%); } +.metric-dashboard-section-heading h3 { margin: 2px 0 0; color: var(--text); font-size: 13px; font-weight: 610; } +.metric-dashboard-section-heading p { min-width: 0; margin: 0; overflow: hidden; color: var(--muted); font-size: 9px; + text-overflow: ellipsis; white-space: nowrap; } +.metric-dashboard-section-heading > strong { min-width: 39px; padding: 4px 6px; border: 1px solid rgba(102, 227, 196, .28); + border-radius: 6px; color: var(--mint); background: rgba(102, 227, 196, .08); text-align: center; font-size: 9px; + font-weight: 650; } +.metric-dashboard-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 8px; } +.metric-chart-card { min-width: 0; min-height: 220px; position: relative; overflow: visible; border: 1px solid var(--line); + border-radius: 12px; background: rgba(12, 26, 22, .93); } +.metric-chart-card:hover, .metric-chart-card:focus-within { z-index: 10; } +.metric-chart-card > header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 9px; + padding: 7px 9px 7px 12px; border-bottom: 1px solid var(--line); } +.metric-chart-card > header div { min-width: 0; } +.metric-chart-card > header strong, .metric-chart-card > header span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-chart-card > header strong { font-size: 12px; font-weight: 590; } +.metric-chart-card > header span { margin-top: 2px; color: var(--faint); font-size: 9px; } +.chart-close-button { width: 27px; height: 27px; flex: 0 0 27px; border: 0; border-radius: 6px; background: transparent; + color: var(--muted); font-size: 18px; line-height: 1; } +.chart-close-button:hover { background: rgba(102, 227, 196, .09); color: var(--mint); } +.comparison-chart-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.comparison-chart-pane { min-width: 0; } +.comparison-chart-pane + .comparison-chart-pane { border-left: 1px solid var(--line); } +.comparison-pane-heading { height: 26px; display: flex; align-items: center; justify-content: space-between; padding: 0 9px; + border-bottom: 1px solid rgba(49, 78, 70, .55); background: #091512; } +.comparison-pane-heading span { font-size: 9px; font-weight: 700; letter-spacing: .1em; } +.comparison-pane-heading small { color: var(--faint); font-size: 9px; } +.left-chart-pane .comparison-pane-heading span { color: var(--mint); } +.right-chart-pane .comparison-pane-heading span { color: var(--amber); } +.comparison-chart-pane .chart-shell { height: 146px; } +.resource-limit-row { min-height: 37px; display: flex; align-items: center; gap: 7px; padding: 5px 8px; + border-bottom: 1px solid rgba(49, 78, 70, .45); background: rgba(7, 17, 15, .58); } +.resource-limit-row > span { flex: 0 0 auto; color: var(--faint); font-size: 8px; font-weight: 700; + letter-spacing: .08em; } +.resource-limit-values { min-width: 0; display: flex; align-items: center; gap: 4px; overflow-x: auto; + scrollbar-width: thin; } +.resource-limit-values small { flex: 0 0 auto; padding: 3px 5px; border: 1px solid rgba(255, 180, 93, .25); + border-radius: 5px; color: #d9c3a2; background: rgba(83, 53, 21, .18); font-size: 8px; white-space: nowrap; } +.resource-limit-values b { color: var(--amber); font-weight: 650; } +.resource-limit-unavailable { min-width: 0; overflow: hidden; color: var(--faint); font-size: 8px; + text-overflow: ellipsis; white-space: nowrap; } +.resource-chart-block { min-width: 0; } +.resource-chart-heading { height: 20px; display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 0 8px; border-bottom: 1px solid rgba(49, 78, 70, .34); color: var(--muted); background: #0a1714; } +.resource-chart-heading span { color: #bccbc7; font-size: 8px; font-weight: 650; } +.resource-chart-heading small { overflow: hidden; color: var(--faint); font-size: 8px; text-overflow: ellipsis; + white-space: nowrap; } +.resource-total-block { border-top: 1px solid rgba(102, 227, 196, .12); } +.comparison-chart-pane .chart-shell.resource-usage-chart { height: 106px; } +.comparison-chart-pane .chart-shell.resource-total-chart { height: 62px; } +.resource-total-chart .chart-empty { padding: 4px 8px; font-size: 8px; text-align: center; } +.chart-error { height: 146px; display: grid; place-items: center; padding: 20px; color: var(--danger); text-align: center; + font-size: 11px; overflow-wrap: anywhere; } +.comparison-chart-error { height: 172px; } +.dashboard-empty { grid-column: 1 / -1; min-height: 230px; display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 9px; color: var(--muted); } +.dashboard-empty strong { color: var(--text); font-size: 15px; } +.dashboard-empty span { font-size: 12px; } +.dashboard-empty button { margin-top: 7px; } + +.chart-shell { height: 370px; position: relative; } +.chart-canvas { width: 100%; height: 100%; } +.chart-canvas.hidden { visibility: hidden; } +.chart-empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 13px; } + +.runs-panel { margin-bottom: 30px; } +.runs-heading { min-height: 74px; } +.pagination { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; } +.pagination button, .icon-button { border: 1px solid var(--line); background: #091512; color: var(--text); border-radius: 7px; } +.pagination button { width: 34px; height: 31px; } +.pagination button:disabled { opacity: .35; cursor: default; } +.table-scroll { overflow: auto; } +.runs-table { border-collapse: collapse; width: 100%; min-width: 1040px; } +.runs-table th { padding: 10px 13px; color: var(--faint); background: #091512; text-align: left; font-size: 10px; + text-transform: uppercase; letter-spacing: .08em; white-space: nowrap; } +.runs-table td { padding: 13px; border-top: 1px solid rgba(49, 78, 70, .55); color: #cddbd7; font-size: 12px; } +.runs-table tbody tr { transition: background .15s ease; cursor: pointer; } +.runs-table tbody tr:hover, .runs-table tbody tr.active { background: rgba(72, 135, 118, .12); } +.runs-table strong, .runs-table small { display: block; max-width: 290px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.runs-table strong { color: var(--text); font-weight: 560; } +.runs-table small { margin-top: 4px; color: var(--faint); } +.comparison-column { width: 58px; text-align: center !important; } +.left-comparison-column { border-right: 1px solid rgba(102, 227, 196, .12); } +.right-comparison-column { border-right: 1px solid rgba(255, 180, 93, .12); } +.check-wrap { display: inline-block; width: 17px; height: 17px; position: relative; } +.check-wrap input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.check-wrap span { display: block; width: 17px; height: 17px; border: 1px solid var(--line-bright); border-radius: 4px; background: #081310; } +.left-check input:checked + span { background: var(--mint); border-color: var(--mint); box-shadow: inset 0 0 0 4px #0b1b17; } +.right-check input:checked + span { background: var(--amber); border-color: var(--amber); box-shadow: inset 0 0 0 4px #21160b; } +.runs-table tbody tr.left-assigned { box-shadow: inset 3px 0 0 rgba(102, 227, 196, .72); } +.runs-table tbody tr.right-assigned { box-shadow: inset 3px 0 0 rgba(255, 180, 93, .72); } +.status-pill { display: inline-block; padding: 3px 8px; border-radius: 99px; color: var(--muted); background: #192723; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.status-pill.completed { color: var(--mint); background: rgba(45, 128, 106, .2); } +.status-pill.failed, .status-pill.cancelled { color: var(--danger); background: rgba(159, 55, 68, .2); } +.status-pill.partial { color: var(--amber); background: rgba(151, 98, 41, .2); } +.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } +.nowrap { white-space: nowrap; } +.run-id { color: var(--faint) !important; } +.empty-row { padding: 35px !important; color: var(--muted) !important; text-align: center !important; } + +.error-banner { margin: 0 0 14px; padding: 12px 15px; border: 1px solid rgba(255, 127, 139, .45); border-radius: 8px; + background: rgba(139, 42, 55, .14); color: #ffabb3; font-size: 12px; } +.drawer-scrim { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .42); backdrop-filter: blur(2px); } +.detail-panel { position: fixed; z-index: 40; inset: 0 0 0 auto; width: min(760px, 94vw); background: #091512; + border-left: 1px solid var(--line-bright); box-shadow: -28px 0 70px rgba(0, 0, 0, .4); overflow: auto; } +.detail-header { position: sticky; top: 0; z-index: 2; min-height: 108px; display: flex; justify-content: space-between; gap: 20px; + padding: 24px 26px; border-bottom: 1px solid var(--line); background: rgba(9, 21, 18, .94); backdrop-filter: blur(15px); } +.detail-header h2 { font-size: 20px; margin: 6px 0; } +.detail-header p { margin: 0; color: var(--faint); font-size: 10px; } +.icon-button { width: 34px; height: 34px; font-size: 22px; flex-shrink: 0; } +.detail-content { padding: 20px 26px 50px; } +.detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; } +.detail-grid article { padding: 13px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); min-width: 0; } +.detail-grid span, .detail-grid strong { display: block; overflow: hidden; text-overflow: ellipsis; } +.detail-grid span { color: var(--faint); font-size: 10px; text-transform: uppercase; } +.detail-grid strong { margin-top: 8px; font-size: 13px; white-space: nowrap; } +.detail-section { margin-top: 22px; padding: 18px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 15px; margin-bottom: 10px; } +.section-heading select, .compact-input { width: min(330px, 54%); } +.detail-section .chart-shell { height: 300px; margin: 0 -8px -8px; } +.metric-list { max-height: 390px; overflow: auto; border: 1px solid var(--line); border-radius: 7px; } +.metric-row { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 9px 11px; border-top: 1px solid var(--line); } +.metric-row:first-child { border-top: 0; } +.metric-row strong, .metric-row small { display: block; } +.metric-row strong { font-size: 11px; font-weight: 560; } +.metric-row small { color: var(--faint); margin-top: 2px; } +.metric-row > span { color: var(--mint); font-size: 11px; text-align: right; } +.artifact-block dl { display: grid; grid-template-columns: 74px 1fr; gap: 8px 12px; font-size: 11px; } +.artifact-block dt { color: var(--faint); } +.artifact-block dd { margin: 0; overflow-wrap: anywhere; } +details { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 10px; } +summary { color: var(--muted); cursor: pointer; font-size: 11px; } +pre { max-height: 330px; overflow: auto; padding: 12px; background: #06100d; border-radius: 6px; color: #b7cac4; font-size: 10px; } +.drawer-enter-active, .drawer-leave-active { transition: transform .24s ease, opacity .24s ease; } +.drawer-enter-from, .drawer-leave-to { transform: translateX(35px); opacity: 0; } + +@media (max-width: 1100px) { + .filter-grid { grid-template-columns: repeat(3, 1fr); } + .summary-grid { grid-template-columns: repeat(2, 1fr); } + .dashboard-workspace { grid-template-columns: 252px minmax(0, 1fr); } + .metric-dashboard-grid { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 960px) { + .dashboard-workspace { grid-template-columns: minmax(0, 1fr); } + .metric-sidebar { position: static; } +} +@media (max-width: 840px) { + .comparison-chart-grid { grid-template-columns: 1fr; } + .comparison-chart-pane + .comparison-chart-pane { border-left: 0; border-top: 1px solid var(--line); } +} +@media (max-width: 720px) { + .topbar { padding: 0 18px; } + .database-state { display: none; } + main { width: min(100% - 28px, 1540px); padding-top: 30px; } + .hero-row { align-items: start; flex-direction: column; } + .hero-actions { width: 100%; } + .hero-actions button { flex: 1; } + .summary-grid { grid-template-columns: 1fr 1fr; } + .summary-grid article { min-height: 105px; padding: 15px; } + .filter-grid { grid-template-columns: 1fr 1fr; } + .search-field { grid-column: span 2; } + .panel-heading { align-items: stretch; flex-direction: column; } + .comparison-group-summary { grid-template-columns: 1fr; padding-left: 14px; padding-right: 14px; } + .detail-grid { grid-template-columns: 1fr 1fr; } + .section-heading { align-items: stretch; flex-direction: column; } + .section-heading select, .compact-input { width: 100%; } +} +@media (max-width: 450px) { + .summary-grid, .filter-grid { grid-template-columns: 1fr; } + .search-field { grid-column: auto; } + .summary-grid article:nth-child(n + 3) { display: none; } + .detail-content, .detail-header { padding-left: 16px; padding-right: 16px; } +} diff --git a/src/aiperf/history/static/assets/vue-BPyS4wh6.js b/src/aiperf/history/static/assets/vue-BPyS4wh6.js new file mode 100644 index 0000000000..ef525f0322 --- /dev/null +++ b/src/aiperf/history/static/assets/vue-BPyS4wh6.js @@ -0,0 +1,18 @@ +/*! SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 *//** +* @vue/shared v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function zn(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const q={},pt=[],He=()=>{},sr=()=>!1,dn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),hn=e=>e.startsWith("onUpdate:"),ne=Object.assign,Xn=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},vi=Object.prototype.hasOwnProperty,j=(e,t)=>vi.call(e,t),O=Array.isArray,gt=e=>kt(e)==="[object Map]",vt=e=>kt(e)==="[object Set]",bs=e=>kt(e)==="[object Date]",R=e=>typeof e=="function",Q=e=>typeof e=="string",$e=e=>typeof e=="symbol",V=e=>e!==null&&typeof e=="object",rr=e=>(V(e)||R(e))&&R(e.then)&&R(e.catch),ir=Object.prototype.toString,kt=e=>ir.call(e),xi=e=>kt(e).slice(8,-1),or=e=>kt(e)==="[object Object]",Zn=e=>Q(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Pt=zn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),pn=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Si=/-\w/g,Te=pn(e=>e.replace(Si,t=>t.slice(1).toUpperCase())),Ci=/\B([A-Z])/g,tt=pn(e=>e.replace(Ci,"-$1").toLowerCase()),lr=pn(e=>e.charAt(0).toUpperCase()+e.slice(1)),wn=pn(e=>e?`on${lr(e)}`:""),Ne=(e,t)=>!Object.is(e,t),Qt=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},gn=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Ti=e=>{const t=Q(e)?Number(e):NaN;return isNaN(t)?e:t};let ys;const mn=()=>ys||(ys=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Qn(e){if(O(e)){const t={};for(let n=0;n{if(n){const s=n.split(Ei);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function es(e){let t="";if(Q(e))t=e;else if(O(e))for(let n=0;nxt(n,t))}const ur=e=>!!(e&&e.__v_isRef===!0),Ri=e=>Q(e)?e:e==null?"":O(e)||V(e)&&(e.toString===ir||!R(e.toString))?ur(e)?Ri(e.value):JSON.stringify(e,ar,2):String(e),ar=(e,t)=>ur(t)?ar(e,t.value):gt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],i)=>(n[En(s,i)+" =>"]=r,n),{})}:vt(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>En(n))}:$e(t)?En(t):V(t)&&!O(t)&&!or(t)?String(t):t,En=(e,t="")=>{var n;return $e(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ie;class Fi{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ie&&(ie.active?(this.parent=ie,this.index=(ie.scopes||(ie.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0){if(ie===this)ie=this.prevScope;else{let t=ie;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(Rt){let t=Rt;for(Rt=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;It;){let t=It;for(It=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function gr(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function mr(e){let t,n=e.depsTail,s=n;for(;s;){const r=s.prevDep;s.version===-1?(s===n&&(n=r),rs(s),Di(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=r}e.deps=t,e.depsTail=n}function $n(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(_r(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function _r(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Ht)||(e.globalVersion=Ht,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!$n(e))))return;e.flags|=2;const t=e.dep,n=J,s=we;J=e,we=!0;try{gr(e);const r=e.fn(e._value);(t.version===0||Ne(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{J=n,we=s,mr(e),e.flags&=-3}}function rs(e,t=!1){const{dep:n,prevSub:s,nextSub:r}=e;if(s&&(s.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let i=n.computed.deps;i;i=i.nextDep)rs(i,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Di(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let we=!0;const br=[];function je(){br.push(we),we=!1}function Ve(){const e=br.pop();we=e===void 0?!0:e}function vs(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=J;J=void 0;try{t()}finally{J=n}}}let Ht=0;class Ni{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class is{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!J||!we||J===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==J)n=this.activeLink=new Ni(J,this),J.deps?(n.prevDep=J.depsTail,J.depsTail.nextDep=n,J.depsTail=n):J.deps=J.depsTail=n,yr(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=J.depsTail,n.nextDep=void 0,J.depsTail.nextDep=n,J.depsTail=n,J.deps===n&&(J.deps=s)}return n}trigger(t){this.version++,Ht++,this.notify(t)}notify(t){ns();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{ss()}}}function yr(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)yr(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const jn=new WeakMap,ut=Symbol(""),Vn=Symbol(""),$t=Symbol("");function le(e,t,n){if(we&&J){let s=jn.get(e);s||jn.set(e,s=new Map);let r=s.get(n);r||(s.set(n,r=new is),r.map=s,r.key=n),r.track()}}function qe(e,t,n,s,r,i){const o=jn.get(e);if(!o){Ht++;return}const l=c=>{c&&c.trigger()};if(ns(),t==="clear")o.forEach(l);else{const c=O(e),d=c&&Zn(n);if(c&&n==="length"){const u=Number(s);o.forEach((h,v)=>{(v==="length"||v===$t||!$e(v)&&v>=u)&&l(h)})}else switch((n!==void 0||o.has(void 0))&&l(o.get(n)),d&&l(o.get($t)),t){case"add":c?d&&l(o.get("length")):(l(o.get(ut)),gt(e)&&l(o.get(Vn)));break;case"delete":c||(l(o.get(ut)),gt(e)&&l(o.get(Vn)));break;case"set":gt(e)&&l(o.get(ut));break}}ss()}function dt(e){const t=H(e);return t===e?t:(le(t,"iterate",$t),Se(e)?t:t.map(Ee))}function _n(e){return le(e=H(e),"iterate",$t),e}function Fe(e,t){return Ye(e)?bt(at(e)?Ee(t):t):Ee(t)}const Hi={__proto__:null,[Symbol.iterator](){return On(this,Symbol.iterator,e=>Fe(this,e))},concat(...e){return dt(this).concat(...e.map(t=>O(t)?dt(t):t))},entries(){return On(this,"entries",e=>(e[1]=Fe(this,e[1]),e))},every(e,t){return Be(this,"every",e,t,void 0,arguments)},filter(e,t){return Be(this,"filter",e,t,n=>n.map(s=>Fe(this,s)),arguments)},find(e,t){return Be(this,"find",e,t,n=>Fe(this,n),arguments)},findIndex(e,t){return Be(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Be(this,"findLast",e,t,n=>Fe(this,n),arguments)},findLastIndex(e,t){return Be(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Be(this,"forEach",e,t,void 0,arguments)},includes(...e){return Mn(this,"includes",e)},indexOf(...e){return Mn(this,"indexOf",e)},join(e){return dt(this).join(e)},lastIndexOf(...e){return Mn(this,"lastIndexOf",e)},map(e,t){return Be(this,"map",e,t,void 0,arguments)},pop(){return wt(this,"pop")},push(...e){return wt(this,"push",e)},reduce(e,...t){return xs(this,"reduce",e,t)},reduceRight(e,...t){return xs(this,"reduceRight",e,t)},shift(){return wt(this,"shift")},some(e,t){return Be(this,"some",e,t,void 0,arguments)},splice(...e){return wt(this,"splice",e)},toReversed(){return dt(this).toReversed()},toSorted(e){return dt(this).toSorted(e)},toSpliced(...e){return dt(this).toSpliced(...e)},unshift(...e){return wt(this,"unshift",e)},values(){return On(this,"values",e=>Fe(this,e))}};function On(e,t,n){const s=_n(e),r=s[t]();return s!==e&&!Se(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=n(i.value)),i}),r}const $i=Array.prototype;function Be(e,t,n,s,r,i){const o=_n(e),l=o!==e&&!Se(e),c=o[t];if(c!==$i[t]){const h=c.apply(e,i);return l?Ee(h):h}let d=n;o!==e&&(l?d=function(h,v){return n.call(this,Fe(e,h),v,e)}:n.length>2&&(d=function(h,v){return n.call(this,h,v,e)}));const u=c.call(o,d,s);return l&&r?r(u):u}function xs(e,t,n,s){const r=_n(e),i=r!==e&&!Se(e);let o=n,l=!1;r!==e&&(i?(l=s.length===0,o=function(d,u,h){return l&&(l=!1,d=Fe(e,d)),n.call(this,d,Fe(e,u),h,e)}):n.length>3&&(o=function(d,u,h){return n.call(this,d,u,h,e)}));const c=r[t](o,...s);return l?Fe(e,c):c}function Mn(e,t,n){const s=H(e);le(s,"iterate",$t);const r=s[t](...n);return(r===-1||r===!1)&&fs(n[0])?(n[0]=H(n[0]),s[t](...n)):r}function wt(e,t,n=[]){je(),ns();const s=H(e)[t].apply(e,n);return ss(),Ve(),s}const ji=zn("__proto__,__v_isRef,__isVue"),vr=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter($e));function Vi(e){$e(e)||(e=String(e));const t=H(this);return le(t,"has",e),t.hasOwnProperty(e)}class xr{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(n==="__v_isReactive")return!r;if(n==="__v_isReadonly")return r;if(n==="__v_isShallow")return i;if(n==="__v_raw")return s===(r?i?zi:wr:i?Tr:Cr).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const o=O(t);if(!r){let c;if(o&&(c=Hi[n]))return c;if(n==="hasOwnProperty")return Vi}const l=Reflect.get(t,n,ce(t)?t:s);if(($e(n)?vr.has(n):ji(n))||(r||le(t,"get",n),i))return l;if(ce(l)){const c=o&&Zn(n)?l:l.value;return r&&V(c)?Bn(c):c}return V(l)?r?Bn(l):ls(l):l}}class Sr extends xr{constructor(t=!1){super(!1,t)}set(t,n,s,r){let i=t[n];const o=O(t)&&Zn(n);if(!this._isShallow){const d=Ye(i);if(!Se(s)&&!Ye(s)&&(i=H(i),s=H(s)),!o&&ce(i)&&!ce(s))return d||(i.value=s),!0}const l=o?Number(n)e,zt=e=>Reflect.getPrototypeOf(e);function ki(e,t,n){return function(...s){const r=this.__v_raw,i=H(r),o=gt(i),l=e==="entries"||e===Symbol.iterator&&o,c=e==="keys"&&o,d=r[e](...s),u=n?Kn:t?bt:Ee;return!t&&le(i,"iterate",c?Vn:ut),ne(Object.create(d),{next(){const{value:h,done:v}=d.next();return v?{value:h,done:v}:{value:l?[u(h[0]),u(h[1])]:u(h),done:v}}})}}function Xt(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function qi(e,t){const n={get(r){const i=this.__v_raw,o=H(i),l=H(r);e||(Ne(r,l)&&le(o,"get",r),le(o,"get",l));const{has:c}=zt(o),d=t?Kn:e?bt:Ee;if(c.call(o,r))return d(i.get(r));if(c.call(o,l))return d(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&le(H(r),"iterate",ut),r.size},has(r){const i=this.__v_raw,o=H(i),l=H(r);return e||(Ne(r,l)&&le(o,"has",r),le(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,c=H(l),d=t?Kn:e?bt:Ee;return!e&&le(c,"iterate",ut),l.forEach((u,h)=>r.call(i,d(u),d(h),o))}};return ne(n,e?{add:Xt("add"),set:Xt("set"),delete:Xt("delete"),clear:Xt("clear")}:{add(r){const i=H(this),o=zt(i),l=H(r),c=!t&&!Se(r)&&!Ye(r)?l:r;return o.has.call(i,c)||Ne(r,c)&&o.has.call(i,r)||Ne(l,c)&&o.has.call(i,l)||(i.add(c),qe(i,"add",c,c)),this},set(r,i){!t&&!Se(i)&&!Ye(i)&&(i=H(i));const o=H(this),{has:l,get:c}=zt(o);let d=l.call(o,r);d||(r=H(r),d=l.call(o,r));const u=c.call(o,r);return o.set(r,i),d?Ne(i,u)&&qe(o,"set",r,i):qe(o,"add",r,i),this},delete(r){const i=H(this),{has:o,get:l}=zt(i);let c=o.call(i,r);c||(r=H(r),c=o.call(i,r)),l&&l.call(i,r);const d=i.delete(r);return c&&qe(i,"delete",r,void 0),d},clear(){const r=H(this),i=r.size!==0,o=r.clear();return i&&qe(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=ki(r,e,t)}),n}function os(e,t){const n=qi(e,t);return(s,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?s:Reflect.get(j(n,r)&&r in s?n:s,r,i)}const Gi={get:os(!1,!1)},Ji={get:os(!1,!0)},Yi={get:os(!0,!1)};const Cr=new WeakMap,Tr=new WeakMap,wr=new WeakMap,zi=new WeakMap;function Xi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function ls(e){return Ye(e)?e:cs(e,!1,Bi,Gi,Cr)}function Zi(e){return cs(e,!1,Wi,Ji,Tr)}function Bn(e){return cs(e,!0,Ui,Yi,wr)}function cs(e,t,n,s,r){if(!V(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=r.get(e);if(i)return i;const o=Xi(xi(e));if(o===0)return e;const l=new Proxy(e,o===2?s:n);return r.set(e,l),l}function at(e){return Ye(e)?at(e.__v_raw):!!(e&&e.__v_isReactive)}function Ye(e){return!!(e&&e.__v_isReadonly)}function Se(e){return!!(e&&e.__v_isShallow)}function fs(e){return e?!!e.__v_raw:!1}function H(e){const t=e&&e.__v_raw;return t?H(t):e}function Qi(e){return!j(e,"__v_skip")&&Object.isExtensible(e)&&cr(e,"__v_skip",!0),e}const Ee=e=>V(e)?ls(e):e,bt=e=>V(e)?Bn(e):e;function ce(e){return e?e.__v_isRef===!0:!1}function ic(e){return eo(e,!1)}function eo(e,t){return ce(e)?e:new to(e,t)}class to{constructor(t,n){this.dep=new is,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:H(t),this._value=n?t:Ee(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Se(t)||Ye(t);t=s?t:H(t),Ne(t,n)&&(this._rawValue=t,this._value=s?t:Ee(t),this.dep.trigger())}}function no(e){return ce(e)?e.value:e}const so={get:(e,t,n)=>t==="__v_raw"?e:no(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return ce(r)&&!ce(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function Er(e){return at(e)?e:new Proxy(e,so)}class ro{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new is(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Ht-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&J!==this)return pr(this,!0),!0}get value(){const t=this.dep.track();return _r(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function io(e,t,n=!1){let s,r;return R(e)?s=e:(s=e.get,r=e.set),new ro(s,r,n)}const Zt={},nn=new WeakMap;let ct;function oo(e,t=!1,n=ct){if(n){let s=nn.get(n);s||nn.set(n,s=[]),s.push(e)}}function lo(e,t,n=q){const{immediate:s,deep:r,once:i,scheduler:o,augmentJob:l,call:c}=n,d=A=>r?A:Se(A)||r===!1||r===0?Ge(A,1):Ge(A);let u,h,v,T,F=!1,P=!1;if(ce(e)?(h=()=>e.value,F=Se(e)):at(e)?(h=()=>d(e),F=!0):O(e)?(P=!0,F=e.some(A=>at(A)||Se(A)),h=()=>e.map(A=>{if(ce(A))return A.value;if(at(A))return d(A);if(R(A))return c?c(A,2):A()})):R(e)?t?h=c?()=>c(e,2):e:h=()=>{if(v){je();try{v()}finally{Ve()}}const A=ct;ct=u;try{return c?c(e,3,[T]):e(T)}finally{ct=A}}:h=He,t&&r){const A=h,W=r===!0?1/0:r;h=()=>Ge(A(),W)}const Y=Li(),K=()=>{u.stop(),Y&&Y.active&&Xn(Y.effects,u)};if(i&&t){const A=t;t=(...W)=>{const ee=A(...W);return K(),ee}}let D=P?new Array(e.length).fill(Zt):Zt;const $=A=>{if(!(!(u.flags&1)||!u.dirty&&!A))if(t){const W=u.run();if(A||r||F||(P?W.some((ee,fe)=>Ne(ee,D[fe])):Ne(W,D))){v&&v();const ee=ct;ct=u;try{const fe=[W,D===Zt?void 0:P&&D[0]===Zt?[]:D,T];D=W,c?c(t,3,fe):t(...fe)}finally{ct=ee}}}else u.run()};return l&&l($),u=new dr(h),u.scheduler=o?()=>o($,!1):$,T=A=>oo(A,!1,u),v=u.onStop=()=>{const A=nn.get(u);if(A){if(c)c(A,4);else for(const W of A)W();nn.delete(u)}},t?s?$(!0):D=u.run():o?o($.bind(null,!0),!0):u.run(),K.pause=u.pause.bind(u),K.resume=u.resume.bind(u),K.stop=K,K}function Ge(e,t=1/0,n){if(t<=0||!V(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,ce(e))Ge(e.value,t,n);else if(O(e))for(let s=0;s{Ge(s,t,n)});else if(or(e)){for(const s in e)Ge(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&Ge(e[s],t,n)}return e}/** +* @vue/runtime-core v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function qt(e,t,n,s){try{return s?e(...s):e()}catch(r){bn(r,t,n)}}function Ce(e,t,n,s){if(R(e)){const r=qt(e,t,n,s);return r&&rr(r)&&r.catch(i=>{bn(i,t,n)}),r}if(O(e)){const r=[];for(let i=0;i>>1,r=ae[s],i=jt(r);i=jt(n)?ae.push(e):ae.splice(fo(t),0,e),e.flags|=1,Mr()}}function Mr(){sn||(sn=Ar.then(Ir))}function uo(e){O(e)?mt.push(...e):Ze&&e.id===-1?Ze.splice(ht+1,0,e):e.flags&1||(mt.push(e),e.flags|=1),Mr()}function Ss(e,t,n=Re+1){for(;njt(n)-jt(s));if(mt.length=0,Ze){Ze.push(...t);return}for(Ze=t,ht=0;hte.id==null?e.flags&2?-1:1/0:e.id;function Ir(e){try{for(Re=0;Re{s._d&&cn(-1);const i=rn(t);let o;try{o=e(...r)}finally{rn(i),s._d&&cn(1)}return o};return s._n=!0,s._c=!0,s._d=!0,s}function oc(e,t){if(xe===null)return e;const n=Cn(xe),s=e.dirs||(e.dirs=[]);for(let r=0;r1)return n&&R(t)?t.call(s&&s.proxy):t}}const po=Symbol.for("v-scx"),go=()=>en(po);function Pn(e,t,n){return Fr(e,t,n)}function Fr(e,t,n=q){const{immediate:s,deep:r,flush:i,once:o}=n,l=ne({},n),c=t&&s||!t&&i!=="post";let d;if(Bt){if(i==="sync"){const T=go();d=T.__watcherHandles||(T.__watcherHandles=[])}else if(!c){const T=()=>{};return T.stop=He,T.resume=He,T.pause=He,T}}const u=he;l.call=(T,F,P)=>Ce(T,u,F,P);let h=!1;i==="post"?l.scheduler=T=>{ge(T,u&&u.suspense)}:i!=="sync"&&(h=!0,l.scheduler=(T,F)=>{F?T():us(T)}),l.augmentJob=T=>{t&&(T.flags|=4),h&&(T.flags|=2,u&&(T.id=u.uid,T.i=u))};const v=lo(e,t,l);return Bt&&(d?d.push(v):c&&v()),v}function mo(e,t,n){const s=this.proxy,r=Q(e)?e.includes(".")?Lr(s,e):()=>s[e]:e.bind(s,s);let i;R(t)?i=t:(i=t.handler,n=t);const o=Gt(this),l=Fr(r,i.bind(s),n);return o(),l}function Lr(e,t){const n=t.split(".");return()=>{let s=e;for(let r=0;re.__isTeleport,ve=Symbol("_leaveCb"),Et=Symbol("_enterCb");function bo(){const e={isMounted:!1,isLeaving:!1,isUnmounting:!1,leavingVNodes:new Map};return Ur(()=>{e.isMounted=!0}),Wr(()=>{e.isUnmounting=!0}),e}const ye=[Function,Array],Nr={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:ye,onEnter:ye,onAfterEnter:ye,onEnterCancelled:ye,onBeforeLeave:ye,onLeave:ye,onAfterLeave:ye,onLeaveCancelled:ye,onBeforeAppear:ye,onAppear:ye,onAfterAppear:ye,onAppearCancelled:ye},Hr=e=>{const t=e.subTree;return t.component?Hr(t.component):t},yo={name:"BaseTransition",props:Nr,setup(e,{slots:t}){const n=di(),s=bo();return()=>{const r=t.default&&Vr(t.default(),!0),i=r&&r.length?$r(r):n.subTree?ul():void 0;if(!i)return;const o=H(e),{mode:l}=o;if(s.isLeaving)return In(i);const c=Cs(i);if(!c)return In(i);let d=Un(c,o,s,n,h=>d=h);c.type!==de&&Vt(c,d);let u=n.subTree&&Cs(n.subTree);if(u&&u.type!==de&&!ft(u,c)&&Hr(n).type!==de){let h=Un(u,o,s,n);if(Vt(u,h),l==="out-in"&&c.type!==de)return s.isLeaving=!0,h.afterLeave=()=>{s.isLeaving=!1,n.job.flags&8||n.update(),delete h.afterLeave,u=void 0},In(i);l==="in-out"&&c.type!==de?h.delayLeave=(v,T,F)=>{const P=jr(s,u);P[String(u.key)]=u,v[ve]=()=>{T(),v[ve]=void 0,delete d.delayedLeave,u=void 0},d.delayedLeave=()=>{F(),delete d.delayedLeave,u=void 0}}:u=void 0}else u&&(u=void 0);return i}}};function $r(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==de){t=n;break}}return t}const vo=yo;function jr(e,t){const{leavingVNodes:n}=e;let s=n.get(t.type);return s||(s=Object.create(null),n.set(t.type,s)),s}function Un(e,t,n,s,r){const{appear:i,mode:o,persisted:l=!1,onBeforeEnter:c,onEnter:d,onAfterEnter:u,onEnterCancelled:h,onBeforeLeave:v,onLeave:T,onAfterLeave:F,onLeaveCancelled:P,onBeforeAppear:Y,onAppear:K,onAfterAppear:D,onAppearCancelled:$}=t,A=String(e.key),W=jr(n,e),ee=(L,B)=>{L&&Ce(L,s,9,B)},fe=(L,B)=>{const X=B[1];ee(L,B),O(L)?L.every(w=>w.length<=1)&&X():L.length<=1&&X()},pe={mode:o,persisted:l,beforeEnter(L){let B=c;if(!n.isMounted)if(i)B=Y||c;else return;L[ve]&&L[ve](!0);const X=W[A];X&&ft(e,X)&&X.el[ve]&&X.el[ve](),ee(B,[L])},enter(L){if(W[A]===e)return;let B=d,X=u,w=h;if(!n.isMounted)if(i)B=K||d,X=D||u,w=$||h;else return;let z=!1;L[Et]=Ke=>{z||(z=!0,Ke?ee(w,[L]):ee(X,[L]),pe.delayedLeave&&pe.delayedLeave(),L[Et]=void 0)};const oe=L[Et].bind(null,!1);B?fe(B,[L,oe]):oe()},leave(L,B){const X=String(e.key);if(L[Et]&&L[Et](!0),n.isUnmounting)return B();ee(v,[L]);let w=!1;L[ve]=oe=>{w||(w=!0,B(),oe?ee(P,[L]):ee(F,[L]),L[ve]=void 0,W[X]===e&&delete W[X])};const z=L[ve].bind(null,!1);W[X]=e,T?fe(T,[L,z]):z()},clone(L){const B=Un(L,t,n,s,r);return r&&r(B),B}};return pe}function In(e){if(yn(e))return e=et(e),e.children=null,e}function Cs(e){if(!yn(e))return Dr(e.type)&&e.children?$r(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&R(n.default))return n.default()}}function Vt(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Vt(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Vr(e,t=!1,n){let s=[],r=0;for(let i=0;i1)for(let i=0;iFt(P,t&&(O(t)?t[Y]:t),n,s,r));return}if(Lt(s)&&!r){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&Ft(e,t,n,s.component.subTree);return}const i=s.shapeFlag&4?Cn(s.component):s.el,o=r?null:i,{i:l,r:c}=e,d=t&&t.r,u=l.refs===q?l.refs={}:l.refs,h=l.setupState,v=H(h),T=h===q?sr:P=>Ts(u,P)?!1:j(v,P),F=(P,Y)=>!(Y&&Ts(u,Y));if(d!=null&&d!==c){if(ws(t),Q(d))u[d]=null,T(d)&&(h[d]=null);else if(ce(d)){const P=t;F(d,P.k)&&(d.value=null),P.k&&(u[P.k]=null)}}if(R(c)){je();try{qt(c,l,12,[o,u])}finally{Ve()}}else{const P=Q(c),Y=ce(c);if(P||Y){const K=()=>{if(e.f){const D=P?T(c)?h[c]:u[c]:F()||!e.k?c.value:u[e.k];if(r)O(D)&&Xn(D,i);else if(O(D))D.includes(i)||D.push(i);else if(P)u[c]=[i],T(c)&&(h[c]=u[c]);else{const $=[i];F(c,e.k)&&(c.value=$),e.k&&(u[e.k]=$)}}else P?(u[c]=o,T(c)&&(h[c]=o)):Y&&(F(c,e.k)&&(c.value=o),e.k&&(u[e.k]=o))};if(o){const D=()=>{K(),on.delete(e)};D.id=-1,on.set(e,D),ge(D,n)}else ws(e),K()}}}function ws(e){const t=on.get(e);t&&(t.flags|=8,on.delete(e))}mn().requestIdleCallback;mn().cancelIdleCallback;const Lt=e=>!!e.type.__asyncLoader,yn=e=>e.type.__isKeepAlive;function xo(e,t){Br(e,"a",t)}function So(e,t){Br(e,"da",t)}function Br(e,t,n=he){const s=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(vn(t,s,n),n){let r=n.parent;for(;r&&r.parent;)yn(r.parent.vnode)&&Co(s,t,n,r),r=r.parent}}function Co(e,t,n,s){const r=vn(t,e,s,!0);kr(()=>{Xn(s[t],r)},n)}function vn(e,t,n=he,s=!1){if(n){const r=n[e]||(n[e]=[]),i=t.__weh||(t.__weh=(...o)=>{je();const l=Gt(n),c=Ce(t,n,e,o);return l(),Ve(),c});return s?r.unshift(i):r.push(i),i}}const ze=e=>(t,n=he)=>{(!Bt||e==="sp")&&vn(e,(...s)=>t(...s),n)},To=ze("bm"),Ur=ze("m"),wo=ze("bu"),Eo=ze("u"),Wr=ze("bum"),kr=ze("um"),Ao=ze("sp"),Oo=ze("rtg"),Mo=ze("rtc");function Po(e,t=he){vn("ec",e,t)}const Io=Symbol.for("v-ndc");function cc(e,t,n,s){let r;const i=n,o=O(e);if(o||Q(e)){const l=o&&at(e);let c=!1,d=!1;l&&(c=!Se(e),d=Ye(e),e=_n(e)),r=new Array(e.length);for(let u=0,h=e.length;ut(l,c,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let c=0,d=l.length;ce?hi(e)?Cn(e):Wn(e.parent):null,Dt=ne(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Wn(e.parent),$root:e=>Wn(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Gr(e),$forceUpdate:e=>e.f||(e.f=()=>{us(e.update)}),$nextTick:e=>e.n||(e.n=Or.bind(e.proxy)),$watch:e=>mo.bind(e)}),Rn=(e,t)=>e!==q&&!e.__isScriptSetup&&j(e,t),Ro={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:r,props:i,accessCache:o,type:l,appContext:c}=e;if(t[0]!=="$"){const v=o[t];if(v!==void 0)switch(v){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return i[t]}else{if(Rn(s,t))return o[t]=1,s[t];if(r!==q&&j(r,t))return o[t]=2,r[t];if(j(i,t))return o[t]=3,i[t];if(n!==q&&j(n,t))return o[t]=4,n[t];kn&&(o[t]=0)}}const d=Dt[t];let u,h;if(d)return t==="$attrs"&&le(e.attrs,"get",""),d(e);if((u=l.__cssModules)&&(u=u[t]))return u;if(n!==q&&j(n,t))return o[t]=4,n[t];if(h=c.config.globalProperties,j(h,t))return h[t]},set({_:e},t,n){const{data:s,setupState:r,ctx:i}=e;return Rn(r,t)?(r[t]=n,!0):s!==q&&j(s,t)?(s[t]=n,!0):j(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,props:i,type:o}},l){let c;return!!(n[l]||e!==q&&l[0]!=="$"&&j(e,l)||Rn(t,l)||j(i,l)||j(s,l)||j(Dt,l)||j(r.config.globalProperties,l)||(c=o.__cssModules)&&c[l])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:j(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Es(e){return O(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let kn=!0;function Fo(e){const t=Gr(e),n=e.proxy,s=e.ctx;kn=!1,t.beforeCreate&&As(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:c,inject:d,created:u,beforeMount:h,mounted:v,beforeUpdate:T,updated:F,activated:P,deactivated:Y,beforeDestroy:K,beforeUnmount:D,destroyed:$,unmounted:A,render:W,renderTracked:ee,renderTriggered:fe,errorCaptured:pe,serverPrefetch:L,expose:B,inheritAttrs:X,components:w,directives:z,filters:oe}=t;if(d&&Lo(d,s,null),o)for(const Z in o){const G=o[Z];R(G)&&(s[Z]=G.bind(n))}if(r){const Z=r.call(n,n);V(Z)&&(e.data=ls(Z))}if(kn=!0,i)for(const Z in i){const G=i[Z],nt=R(G)?G.bind(n,n):R(G.get)?G.get.bind(n,n):He,Jt=!R(G)&&R(G.set)?G.set.bind(n):He,st=vl({get:nt,set:Jt});Object.defineProperty(s,Z,{enumerable:!0,configurable:!0,get:()=>st.value,set:Ae=>st.value=Ae})}if(l)for(const Z in l)qr(l[Z],s,n,Z);if(c){const Z=R(c)?c.call(n):c;Reflect.ownKeys(Z).forEach(G=>{ho(G,Z[G])})}u&&As(u,e,"c");function se(Z,G){O(G)?G.forEach(nt=>Z(nt.bind(n))):G&&Z(G.bind(n))}if(se(To,h),se(Ur,v),se(wo,T),se(Eo,F),se(xo,P),se(So,Y),se(Po,pe),se(Mo,ee),se(Oo,fe),se(Wr,D),se(kr,A),se(Ao,L),O(B))if(B.length){const Z=e.exposed||(e.exposed={});B.forEach(G=>{Object.defineProperty(Z,G,{get:()=>n[G],set:nt=>n[G]=nt,enumerable:!0})})}else e.exposed||(e.exposed={});W&&e.render===He&&(e.render=W),X!=null&&(e.inheritAttrs=X),w&&(e.components=w),z&&(e.directives=z),L&&Kr(e)}function Lo(e,t,n=He){O(e)&&(e=qn(e));for(const s in e){const r=e[s];let i;V(r)?"default"in r?i=en(r.from||s,r.default,!0):i=en(r.from||s):i=en(r),ce(i)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[s]=i}}function As(e,t,n){Ce(O(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function qr(e,t,n,s){let r=s.includes(".")?Lr(n,s):()=>n[s];if(Q(e)){const i=t[e];R(i)&&Pn(r,i)}else if(R(e))Pn(r,e.bind(n));else if(V(e))if(O(e))e.forEach(i=>qr(i,t,n,s));else{const i=R(e.handler)?e.handler.bind(n):t[e.handler];R(i)&&Pn(r,i,e)}}function Gr(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let c;return l?c=l:!r.length&&!n&&!s?c=t:(c={},r.length&&r.forEach(d=>ln(c,d,o,!0)),ln(c,t,o)),V(t)&&i.set(t,c),c}function ln(e,t,n,s=!1){const{mixins:r,extends:i}=t;i&&ln(e,i,n,!0),r&&r.forEach(o=>ln(e,o,n,!0));for(const o in t)if(!(s&&o==="expose")){const l=Do[o]||n&&n[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const Do={data:Os,props:Ms,emits:Ms,methods:Ot,computed:Ot,beforeCreate:ue,created:ue,beforeMount:ue,mounted:ue,beforeUpdate:ue,updated:ue,beforeDestroy:ue,beforeUnmount:ue,destroyed:ue,unmounted:ue,activated:ue,deactivated:ue,errorCaptured:ue,serverPrefetch:ue,components:Ot,directives:Ot,watch:Ho,provide:Os,inject:No};function Os(e,t){return t?e?function(){return ne(R(e)?e.call(this,this):e,R(t)?t.call(this,this):t)}:t:e}function No(e,t){return Ot(qn(e),qn(t))}function qn(e){if(O(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Te(t)}Modifiers`]||e[`${tt(t)}Modifiers`];function Ko(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||q;let r=n;const i=t.startsWith("update:"),o=i&&Vo(s,t.slice(7));o&&(o.trim&&(r=n.map(u=>Q(u)?u.trim():u)),o.number&&(r=n.map(gn)));let l,c=s[l=wn(t)]||s[l=wn(Te(t))];!c&&i&&(c=s[l=wn(tt(t))]),c&&Ce(c,e,6,r);const d=s[l+"Once"];if(d){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Ce(d,e,6,r)}}const Bo=new WeakMap;function Yr(e,t,n=!1){const s=n?Bo:t.emitsCache,r=s.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!R(e)){const c=d=>{const u=Yr(d,t,!0);u&&(l=!0,ne(o,u))};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}return!i&&!l?(V(e)&&s.set(e,null),null):(O(i)?i.forEach(c=>o[c]=null):ne(o,i),V(e)&&s.set(e,o),o)}function xn(e,t){return!e||!dn(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),j(e,t[0].toLowerCase()+t.slice(1))||j(e,tt(t))||j(e,t))}function Ps(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:c,render:d,renderCache:u,props:h,data:v,setupState:T,ctx:F,inheritAttrs:P}=e,Y=rn(e);let K,D;try{if(n.shapeFlag&4){const A=r||s,W=A;K=De(d.call(W,A,u,h,T,v,F)),D=l}else{const A=t;K=De(A.length>1?A(h,{attrs:l,slots:o,emit:c}):A(h,null)),D=t.props?l:Uo(l)}}catch(A){Nt.length=0,bn(A,e,1),K=be(de)}let $=K;if(D&&P!==!1){const A=Object.keys(D),{shapeFlag:W}=$;A.length&&W&7&&(i&&A.some(hn)&&(D=Wo(D,i)),$=et($,D,!1,!0))}return n.dirs&&($=et($,null,!1,!0),$.dirs=$.dirs?$.dirs.concat(n.dirs):n.dirs),n.transition&&Vt($,n.transition),K=$,rn(Y),K}const Uo=e=>{let t;for(const n in e)(n==="class"||n==="style"||dn(n))&&((t||(t={}))[n]=e[n]);return t},Wo=(e,t)=>{const n={};for(const s in e)(!hn(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function ko(e,t,n){const{props:s,children:r,component:i}=e,{props:o,children:l,patchFlag:c}=t,d=i.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return s?Is(s,o,d):!!o;if(c&8){const u=t.dynamicProps;for(let h=0;hObject.create(Xr),Qr=e=>Object.getPrototypeOf(e)===Xr;function Go(e,t,n,s=!1){const r={},i=Zr();e.propsDefaults=Object.create(null),ei(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);n?e.props=s?r:Zi(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function Jo(e,t,n,s){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=H(r),[c]=e.propsOptions;let d=!1;if((s||o>0)&&!(o&16)){if(o&8){const u=e.vnode.dynamicProps;for(let h=0;h{c=!0;const[v,T]=ti(h,t,!0);ne(o,v),T&&l.push(...T)};!n&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!i&&!c)return V(e)&&s.set(e,pt),pt;if(O(i))for(let u=0;ue==="_"||e==="_ctx"||e==="$stable",ds=e=>O(e)?e.map(De):[De(e)],zo=(e,t,n)=>{if(t._n)return t;const s=ao((...r)=>ds(t(...r)),n);return s._c=!1,s},ni=(e,t,n)=>{const s=e._ctx;for(const r in e){if(as(r))continue;const i=e[r];if(R(i))t[r]=zo(r,i,s);else if(i!=null){const o=ds(i);t[r]=()=>o}}},si=(e,t)=>{const n=ds(t);e.slots.default=()=>n},ri=(e,t,n)=>{for(const s in t)(n||!as(s))&&(e[s]=t[s])},Xo=(e,t,n)=>{const s=e.slots=Zr();if(e.vnode.shapeFlag&32){const r=t._;r?(ri(s,t,n),n&&cr(s,"_",r,!0)):ni(t,s)}else t&&si(e,t)},Zo=(e,t,n)=>{const{vnode:s,slots:r}=e;let i=!0,o=q;if(s.shapeFlag&32){const l=t._;l?n&&l===1?i=!1:ri(r,t,n):(i=!t.$stable,ni(t,r)),o=t}else t&&(si(e,t),o={default:1});if(i)for(const l in r)!as(l)&&o[l]==null&&delete r[l]},ge=sl;function Qo(e){return el(e)}function el(e,t){const n=mn();n.__VUE__=!0;const{insert:s,remove:r,patchProp:i,createElement:o,createText:l,createComment:c,setText:d,setElementText:u,parentNode:h,nextSibling:v,setScopeId:T=He,insertStaticContent:F}=e,P=(f,a,p,b=null,_=null,g=null,S=void 0,x=null,y=!!a.dynamicChildren)=>{if(f===a)return;f&&!ft(f,a)&&(b=Yt(f),Ae(f,_,g,!0),f=null),a.patchFlag===-2&&(y=!1,a.dynamicChildren=null);const{type:m,ref:M,shapeFlag:C}=a;switch(m){case Sn:Y(f,a,p,b);break;case de:K(f,a,p,b);break;case Ln:f==null&&D(a,p,b,S);break;case Le:w(f,a,p,b,_,g,S,x,y);break;default:C&1?W(f,a,p,b,_,g,S,x,y):C&6?z(f,a,p,b,_,g,S,x,y):(C&64||C&128)&&m.process(f,a,p,b,_,g,S,x,y,Ct)}M!=null&&_?Ft(M,f&&f.ref,g,a||f,!a):M==null&&f&&f.ref!=null&&Ft(f.ref,null,g,f,!0)},Y=(f,a,p,b)=>{if(f==null)s(a.el=l(a.children),p,b);else{const _=a.el=f.el;a.children!==f.children&&d(_,a.children)}},K=(f,a,p,b)=>{f==null?s(a.el=c(a.children||""),p,b):a.el=f.el},D=(f,a,p,b)=>{[f.el,f.anchor]=F(f.children,a,p,b,f.el,f.anchor)},$=({el:f,anchor:a},p,b)=>{let _;for(;f&&f!==a;)_=v(f),s(f,p,b),f=_;s(a,p,b)},A=({el:f,anchor:a})=>{let p;for(;f&&f!==a;)p=v(f),r(f),f=p;r(a)},W=(f,a,p,b,_,g,S,x,y)=>{if(a.type==="svg"?S="svg":a.type==="math"&&(S="mathml"),f==null)ee(a,p,b,_,g,S,x,y);else{const m=f.el&&f.el._isVueCE?f.el:null;try{m&&m._beginPatch(),L(f,a,_,g,S,x,y)}finally{m&&m._endPatch()}}},ee=(f,a,p,b,_,g,S,x)=>{let y,m;const{props:M,shapeFlag:C,transition:E,dirs:I}=f;if(y=f.el=o(f.type,g,M&&M.is,M),C&8?u(y,f.children):C&16&&pe(f.children,y,null,b,_,Fn(f,g),S,x),I&&rt(f,null,b,"created"),fe(y,f,f.scopeId,S,b),M){for(const k in M)k!=="value"&&!Pt(k)&&i(y,k,null,M[k],g,b);"value"in M&&i(y,"value",null,M.value,g),(m=M.onVnodeBeforeMount)&&Ie(m,b,f)}I&&rt(f,null,b,"beforeMount");const N=tl(_,E);N&&E.beforeEnter(y),s(y,a,p),((m=M&&M.onVnodeMounted)||N||I)&&ge(()=>{m&&Ie(m,b,f),N&&E.enter(y),I&&rt(f,null,b,"mounted")},_)},fe=(f,a,p,b,_)=>{if(p&&T(f,p),b)for(let g=0;g{for(let m=y;m{const x=a.el=f.el;let{patchFlag:y,dynamicChildren:m,dirs:M}=a;y|=f.patchFlag&16;const C=f.props||q,E=a.props||q;let I;if(p&&it(p,!1),(I=E.onVnodeBeforeUpdate)&&Ie(I,p,a,f),M&&rt(a,f,p,"beforeUpdate"),p&&it(p,!0),m&&(!f.dynamicChildren||f.dynamicChildren.length!==m.length)&&(y=0,S=!1,m=null),(C.innerHTML&&E.innerHTML==null||C.textContent&&E.textContent==null)&&u(x,""),m?B(f.dynamicChildren,m,x,p,b,Fn(a,_),g):S||G(f,a,x,null,p,b,Fn(a,_),g,!1),y>0){if(y&16)X(x,C,E,p,_);else if(y&2&&C.class!==E.class&&i(x,"class",null,E.class,_),y&4&&i(x,"style",C.style,E.style,_),y&8){const N=a.dynamicProps;for(let k=0;k{I&&Ie(I,p,a,f),M&&rt(a,f,p,"updated")},b)},B=(f,a,p,b,_,g,S)=>{for(let x=0;x{if(a!==p){if(a!==q)for(const g in a)!Pt(g)&&!(g in p)&&i(f,g,a[g],null,_,b);for(const g in p){if(Pt(g))continue;const S=p[g],x=a[g];S!==x&&g!=="value"&&i(f,g,x,S,_,b)}"value"in p&&i(f,"value",a.value,p.value,_)}},w=(f,a,p,b,_,g,S,x,y)=>{const m=a.el=f?f.el:l(""),M=a.anchor=f?f.anchor:l("");let{patchFlag:C,dynamicChildren:E,slotScopeIds:I}=a;I&&(x=x?x.concat(I):I),f==null?(s(m,p,b),s(M,p,b),pe(a.children||[],p,M,_,g,S,x,y)):C>0&&C&64&&E&&f.dynamicChildren&&f.dynamicChildren.length===E.length?(B(f.dynamicChildren,E,p,_,g,S,x),(a.key!=null||_&&a===_.subTree)&&ii(f,a,!0)):G(f,a,p,M,_,g,S,x,y)},z=(f,a,p,b,_,g,S,x,y)=>{a.slotScopeIds=x,f==null?a.shapeFlag&512?_.ctx.activate(a,p,b,S,y):oe(a,p,b,_,g,S,y):Ke(f,a,y)},oe=(f,a,p,b,_,g,S)=>{const x=f.component=pl(f,b,_);if(yn(f)&&(x.ctx.renderer=Ct),gl(x,!1,S),x.asyncDep){if(_&&_.registerDep(x,se,S),!f.el){const y=x.subTree=be(de);K(null,y,a,p),f.placeholder=y.el}}else se(x,f,a,p,_,g,S)},Ke=(f,a,p)=>{const b=a.component=f.component;if(ko(f,a,p))if(b.asyncDep&&!b.asyncResolved){Z(b,a,p);return}else b.next=a,b.update();else a.el=f.el,b.vnode=a},se=(f,a,p,b,_,g,S)=>{const x=()=>{if(f.isMounted){let{next:C,bu:E,u:I,parent:N,vnode:k}=f;{const Me=oi(f);if(Me){C&&(C.el=k.el,Z(f,C,S)),Me.asyncDep.then(()=>{ge(()=>{f.isUnmounted||m()},_)});return}}let U=C,te;it(f,!1),C?(C.el=k.el,Z(f,C,S)):C=k,E&&Qt(E),(te=C.props&&C.props.onVnodeBeforeUpdate)&&Ie(te,N,C,k),it(f,!0);const re=Ps(f),Oe=f.subTree;f.subTree=re,P(Oe,re,h(Oe.el),Yt(Oe),f,_,g),C.el=re.el,U===null&&qo(f,re.el),I&&ge(I,_),(te=C.props&&C.props.onVnodeUpdated)&&ge(()=>Ie(te,N,C,k),_)}else{let C;const{el:E,props:I}=a,{bm:N,m:k,parent:U,root:te,type:re}=f,Oe=Lt(a);it(f,!1),N&&Qt(N),!Oe&&(C=I&&I.onVnodeBeforeMount)&&Ie(C,U,a),it(f,!0);{te.ce&&te.ce._hasShadowRoot()&&te.ce._injectChildStyle(re,f.parent?f.parent.type:void 0);const Me=f.subTree=Ps(f);P(null,Me,p,b,f,_,g),a.el=Me.el}if(k&&ge(k,_),!Oe&&(C=I&&I.onVnodeMounted)){const Me=a;ge(()=>Ie(C,U,Me),_)}(a.shapeFlag&256||U&&Lt(U.vnode)&&U.vnode.shapeFlag&256)&&f.a&&ge(f.a,_),f.isMounted=!0,a=p=b=null}};f.scope.on();const y=f.effect=new dr(x);f.scope.off();const m=f.update=y.run.bind(y),M=f.job=y.runIfDirty.bind(y);M.i=f,M.id=f.uid,y.scheduler=()=>us(M),it(f,!0),m()},Z=(f,a,p)=>{a.component=f;const b=f.vnode.props;f.vnode=a,f.next=null,Jo(f,a.props,b,p),Zo(f,a.children,p),je(),Ss(f),Ve()},G=(f,a,p,b,_,g,S,x,y=!1)=>{const m=f&&f.children,M=f?f.shapeFlag:0,C=a.children,{patchFlag:E,shapeFlag:I}=a;if(E>0){if(E&128){Jt(m,C,p,b,_,g,S,x,y);return}else if(E&256){nt(m,C,p,b,_,g,S,x,y);return}}I&8?(M&16&&St(m,_,g),C!==m&&u(p,C)):M&16?I&16?Jt(m,C,p,b,_,g,S,x,y):St(m,_,g,!0):(M&8&&u(p,""),I&16&&pe(C,p,b,_,g,S,x,y))},nt=(f,a,p,b,_,g,S,x,y)=>{f=f||pt,a=a||pt;const m=f.length,M=a.length,C=Math.min(m,M);let E;for(E=0;EM?St(f,_,g,!0,!1,C):pe(a,p,b,_,g,S,x,y,C)},Jt=(f,a,p,b,_,g,S,x,y)=>{let m=0;const M=a.length;let C=f.length-1,E=M-1;for(;m<=C&&m<=E;){const I=f[m],N=a[m]=y?ke(a[m]):De(a[m]);if(ft(I,N))P(I,N,p,null,_,g,S,x,y);else break;m++}for(;m<=C&&m<=E;){const I=f[C],N=a[E]=y?ke(a[E]):De(a[E]);if(ft(I,N))P(I,N,p,null,_,g,S,x,y);else break;C--,E--}if(m>C){if(m<=E){const I=E+1,N=IE)for(;m<=C;)Ae(f[m],_,g,!0),m++;else{const I=m,N=m,k=new Map;for(m=N;m<=E;m++){const me=a[m]=y?ke(a[m]):De(a[m]);me.key!=null&&k.set(me.key,m)}let U,te=0;const re=E-N+1;let Oe=!1,Me=0;const Tt=new Array(re);for(m=0;m=re){Ae(me,_,g,!0);continue}let Pe;if(me.key!=null)Pe=k.get(me.key);else for(U=N;U<=E;U++)if(Tt[U-N]===0&&ft(me,a[U])){Pe=U;break}Pe===void 0?Ae(me,_,g,!0):(Tt[Pe-N]=m+1,Pe>=Me?Me=Pe:Oe=!0,P(me,a[Pe],p,null,_,g,S,x,y),te++)}const gs=Oe?nl(Tt):pt;for(U=gs.length-1,m=re-1;m>=0;m--){const me=N+m,Pe=a[me],ms=a[me+1],_s=me+1{const{el:g,type:S,transition:x,children:y,shapeFlag:m}=f;if(m&6){st(f.component.subTree,a,p,b);return}if(m&128){f.suspense.move(a,p,b);return}if(m&64){S.move(f,a,p,Ct);return}if(S===Le){s(g,a,p);for(let C=0;Cx.enter(g),_));else{const{leave:C,delayLeave:E,afterLeave:I}=x,N=()=>{f.ctx.isUnmounted?r(g):s(g,a,p)},k=()=>{const U=g._isLeaving||!!g[ve];g._isLeaving&&g[ve](!0),x.persisted&&!U?N():C(g,()=>{N(),I&&I()})};E?E(g,N,k):k()}else s(g,a,p)},Ae=(f,a,p,b=!1,_=!1)=>{const{type:g,props:S,ref:x,children:y,dynamicChildren:m,shapeFlag:M,patchFlag:C,dirs:E,cacheIndex:I,memo:N}=f;if(C===-2&&(_=!1),x!=null&&(je(),Ft(x,null,p,f,!0),Ve()),I!=null&&(a.renderCache[I]=void 0),M&256){a.ctx.deactivate(f);return}const k=M&1&&E,U=!Lt(f);let te;if(U&&(te=S&&S.onVnodeBeforeUnmount)&&Ie(te,a,f),M&6)yi(f.component,p,b);else{if(M&128){f.suspense.unmount(p,b);return}k&&rt(f,null,a,"beforeUnmount"),M&64?f.type.remove(f,a,p,Ct,b):m&&!m.hasOnce&&(g!==Le||C>0&&C&64)?St(m,a,p,!1,!0):(g===Le&&C&384||!_&&M&16)&&St(y,a,p),b&&hs(f)}const re=N!=null&&I==null;(U&&(te=S&&S.onVnodeUnmounted)||k||re)&&ge(()=>{te&&Ie(te,a,f),k&&rt(f,null,a,"unmounted"),re&&(f.el=null)},p)},hs=f=>{const{type:a,el:p,anchor:b,transition:_}=f;if(a===Le){bi(p,b);return}if(a===Ln){A(f);return}const g=()=>{r(p),_&&!_.persisted&&_.afterLeave&&_.afterLeave()};if(f.shapeFlag&1&&_&&!_.persisted){const{leave:S,delayLeave:x}=_,y=()=>S(p,g);x?x(f.el,g,y):y()}else g()},bi=(f,a)=>{let p;for(;f!==a;)p=v(f),r(f),f=p;r(a)},yi=(f,a,p)=>{const{bum:b,scope:_,job:g,subTree:S,um:x,m:y,a:m}=f;Fs(y),Fs(m),b&&Qt(b),_.stop(),g&&(g.flags|=8,Ae(S,f,a,p)),x&&ge(x,a),ge(()=>{f.isUnmounted=!0},a)},St=(f,a,p,b=!1,_=!1,g=0)=>{for(let S=g;S{if(f.shapeFlag&6)return Yt(f.component.subTree);if(f.shapeFlag&128)return f.suspense.next();const a=v(f.anchor||f.el),p=a&&a[_o];return p?v(p):a};let Tn=!1;const ps=(f,a,p)=>{let b;f==null?a._vnode&&(Ae(a._vnode,null,null,!0),b=a._vnode.component):P(a._vnode||null,f,a,null,null,null,p),a._vnode=f,Tn||(Tn=!0,Ss(b),Pr(),Tn=!1)},Ct={p:P,um:Ae,m:st,r:hs,mt:oe,mc:pe,pc:G,pbc:B,n:Yt,o:e};return{render:ps,hydrate:void 0,createApp:jo(ps)}}function Fn({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function it({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function tl(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function ii(e,t,n=!1){const s=e.children,r=t.children;if(O(s)&&O(r))for(let i=0;i>1,e[n[l]]0&&(t[s]=n[i-1]),n[i]=s)}}for(i=n.length,o=n[i-1];i-- >0;)n[i]=o,o=t[o];return n}function oi(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:oi(t)}function Fs(e){if(e)for(let t=0;te.__isSuspense;function sl(e,t){t&&t.pendingBranch?O(e)?t.effects.push(...e):t.effects.push(e):uo(e)}const Le=Symbol.for("v-fgt"),Sn=Symbol.for("v-txt"),de=Symbol.for("v-cmt"),Ln=Symbol.for("v-stc"),Nt=[];let _e=null;function rl(e=!1){Nt.push(_e=e?null:[])}function il(){Nt.pop(),_e=Nt[Nt.length-1]||null}let Kt=1;function cn(e,t=!1){Kt+=e,e<0&&_e&&t&&(_e.hasOnce=!0)}function fi(e){return e.dynamicChildren=Kt>0?_e||pt:null,il(),Kt>0&&_e&&_e.push(e),e}function fc(e,t,n,s,r,i){return fi(ai(e,t,n,s,r,i,!0))}function ol(e,t,n,s,r){return fi(be(e,t,n,s,r,!0))}function fn(e){return e?e.__v_isVNode===!0:!1}function ft(e,t){return e.type===t.type&&e.key===t.key}const ui=({key:e})=>e??null,tn=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?Q(e)||ce(e)||R(e)?{i:xe,r:e,k:t,f:!!n}:e:null);function ai(e,t=null,n=null,s=0,r=null,i=e===Le?0:1,o=!1,l=!1){const c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&ui(t),ref:t&&tn(t),scopeId:Rr,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:xe};return l?(un(c,n),i&128&&e.normalize(c)):n&&(c.shapeFlag|=Q(n)?8:16),Kt>0&&!o&&_e&&(c.patchFlag>0||i&6)&&c.patchFlag!==32&&_e.push(c),c}const be=ll;function ll(e,t=null,n=null,s=0,r=null,i=!1){if((!e||e===Io)&&(e=de),fn(e)){const l=et(e,t,!0);return n&&un(l,n),Kt>0&&!i&&_e&&(l.shapeFlag&6?_e[_e.indexOf(e)]=l:_e.push(l)),l.patchFlag=-2,l}if(yl(e)&&(e=e.__vccOpts),t){t=cl(t);let{class:l,style:c}=t;l&&!Q(l)&&(t.class=es(l)),V(c)&&(fs(c)&&!O(c)&&(c=ne({},c)),t.style=Qn(c))}const o=Q(e)?1:ci(e)?128:Dr(e)?64:V(e)?4:R(e)?2:0;return ai(e,t,n,s,r,o,i,!0)}function cl(e){return e?fs(e)||Qr(e)?ne({},e):e:null}function et(e,t,n=!1,s=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:c}=e,d=t?al(r||{},t):r,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:d,key:d&&ui(d),ref:t&&t.ref?n&&i?O(i)?i.concat(tn(t)):[i,tn(t)]:tn(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Le?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&et(e.ssContent),ssFallback:e.ssFallback&&et(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&s&&Vt(u,c.clone(u)),u}function fl(e=" ",t=0){return be(Sn,null,e,t)}function ul(e="",t=!1){return t?(rl(),ol(de,null,e)):be(de,null,e)}function De(e){return e==null||typeof e=="boolean"?be(de):O(e)?be(Le,null,e.slice()):fn(e)?ke(e):be(Sn,null,String(e))}function ke(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:et(e)}function un(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(O(t))n=16;else if(typeof t=="object")if(s&65){const r=t.default;r&&(r._c&&(r._d=!1),un(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!Qr(t)?t._ctx=xe:r===3&&xe&&(xe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(R(t)){if(s&65){un(e,{default:t});return}t={default:t,_ctx:xe},n=32}else t=String(t),s&64?(n=16,t=[fl(t)]):n=8;e.children=t,e.shapeFlag|=n}function al(...e){const t={};for(let n=0;nhe||xe;let an,Jn;{const e=mn(),t=(n,s)=>{let r;return(r=e[n])||(r=e[n]=[]),r.push(s),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};an=t("__VUE_INSTANCE_SETTERS__",n=>he=n),Jn=t("__VUE_SSR_SETTERS__",n=>Bt=n)}const Gt=e=>{const t=he;return an(e),e.scope.on(),()=>{e.scope.off(),an(t)}},Ls=()=>{he&&he.scope.off(),an(null)};function hi(e){return e.vnode.shapeFlag&4}let Bt=!1;function gl(e,t=!1,n=!1){t&&Jn(t);const{props:s,children:r}=e.vnode,i=hi(e);Go(e,s,i,t),Xo(e,r,n||t);const o=i?ml(e,t):void 0;return t&&Jn(!1),o}function ml(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Ro);const{setup:s}=n;if(s){je();const r=e.setupContext=s.length>1?bl(e):null,i=Gt(e),o=qt(s,e,0,[e.props,r]),l=rr(o);if(Ve(),i(),(l||e.sp)&&!Lt(e)&&Kr(e),l){if(o.then(Ls,Ls),t)return o.then(c=>{Ds(e,c)}).catch(c=>{bn(c,e,0)});e.asyncDep=o}else Ds(e,o)}else pi(e)}function Ds(e,t,n){R(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:V(t)&&(e.setupState=Er(t)),pi(e)}function pi(e,t,n){const s=e.type;e.render||(e.render=s.render||He);{const r=Gt(e);je();try{Fo(e)}finally{Ve(),r()}}}const _l={get(e,t){return le(e,"get",""),e[t]}};function bl(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,_l),slots:e.slots,emit:e.emit,expose:t}}function Cn(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Er(Qi(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Dt)return Dt[n](e)},has(t,n){return n in t||n in Dt}})):e.proxy}function yl(e){return R(e)&&"__vccOpts"in e}const vl=(e,t)=>io(e,t,Bt);function xl(e,t,n){try{cn(-1);const s=arguments.length;return s===2?V(t)&&!O(t)?fn(t)?be(e,null,[t]):be(e,t):be(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&fn(n)&&(n=[n]),be(e,t,n))}finally{cn(1)}}const Sl="3.5.39";/** +* @vue/runtime-dom v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Yn;const Ns=typeof window<"u"&&window.trustedTypes;if(Ns)try{Yn=Ns.createPolicy("vue",{createHTML:e=>e})}catch{}const gi=Yn?e=>Yn.createHTML(e):e=>e,Cl="http://www.w3.org/2000/svg",Tl="http://www.w3.org/1998/Math/MathML",We=typeof document<"u"?document:null,Hs=We&&We.createElement("template"),wl={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const r=t==="svg"?We.createElementNS(Cl,e):t==="mathml"?We.createElementNS(Tl,e):n?We.createElement(e,{is:n}):We.createElement(e);return e==="select"&&s&&s.multiple!=null&&r.setAttribute("multiple",s.multiple),r},createText:e=>We.createTextNode(e),createComment:e=>We.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>We.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,r,i){const o=n?n.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===i||!(r=r.nextSibling)););else{Hs.innerHTML=gi(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const l=Hs.content;if(s==="svg"||s==="mathml"){const c=l.firstChild;for(;c.firstChild;)l.appendChild(c.firstChild);l.removeChild(c)}t.insertBefore(l,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Xe="transition",At="animation",Ut=Symbol("_vtc"),mi={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},El=ne({},Nr,mi),Al=e=>(e.displayName="Transition",e.props=El,e),uc=Al((e,{slots:t})=>xl(vo,Ol(e),t)),ot=(e,t=[])=>{O(e)?e.forEach(n=>n(...t)):e&&e(...t)},$s=e=>e?O(e)?e.some(t=>t.length>1):e.length>1:!1;function Ol(e){const t={};for(const w in e)w in mi||(t[w]=e[w]);if(e.css===!1)return t;const{name:n="v",type:s,duration:r,enterFromClass:i=`${n}-enter-from`,enterActiveClass:o=`${n}-enter-active`,enterToClass:l=`${n}-enter-to`,appearFromClass:c=i,appearActiveClass:d=o,appearToClass:u=l,leaveFromClass:h=`${n}-leave-from`,leaveActiveClass:v=`${n}-leave-active`,leaveToClass:T=`${n}-leave-to`}=e,F=Ml(r),P=F&&F[0],Y=F&&F[1],{onBeforeEnter:K,onEnter:D,onEnterCancelled:$,onLeave:A,onLeaveCancelled:W,onBeforeAppear:ee=K,onAppear:fe=D,onAppearCancelled:pe=$}=t,L=(w,z,oe,Ke)=>{w._enterCancelled=Ke,lt(w,z?u:l),lt(w,z?d:o),oe&&oe()},B=(w,z)=>{w._isLeaving=!1,lt(w,h),lt(w,T),lt(w,v),z&&z()},X=w=>(z,oe)=>{const Ke=w?fe:D,se=()=>L(z,w,oe);ot(Ke,[z,se]),js(()=>{lt(z,w?c:i),Ue(z,w?u:l),$s(Ke)||Vs(z,s,P,se)})};return ne(t,{onBeforeEnter(w){ot(K,[w]),Ue(w,i),Ue(w,o)},onBeforeAppear(w){ot(ee,[w]),Ue(w,c),Ue(w,d)},onEnter:X(!1),onAppear:X(!0),onLeave(w,z){w._isLeaving=!0;const oe=()=>B(w,z);Ue(w,h),w._enterCancelled?(Ue(w,v),Us(w)):(Us(w),Ue(w,v)),js(()=>{w._isLeaving&&(lt(w,h),Ue(w,T),$s(A)||Vs(w,s,Y,oe))}),ot(A,[w,oe])},onEnterCancelled(w){L(w,!1,void 0,!0),ot($,[w])},onAppearCancelled(w){L(w,!0,void 0,!0),ot(pe,[w])},onLeaveCancelled(w){B(w),ot(W,[w])}})}function Ml(e){if(e==null)return null;if(V(e))return[Dn(e.enter),Dn(e.leave)];{const t=Dn(e);return[t,t]}}function Dn(e){return Ti(e)}function Ue(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[Ut]||(e[Ut]=new Set)).add(t)}function lt(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.remove(s));const n=e[Ut];n&&(n.delete(t),n.size||(e[Ut]=void 0))}function js(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let Pl=0;function Vs(e,t,n,s){const r=e._endId=++Pl,i=()=>{r===e._endId&&s()};if(n!=null)return setTimeout(i,n);const{type:o,timeout:l,propCount:c}=Il(e,t);if(!o)return s();const d=o+"end";let u=0;const h=()=>{e.removeEventListener(d,v),i()},v=T=>{T.target===e&&++u>=c&&h()};setTimeout(()=>{u(n[F]||"").split(", "),r=s(`${Xe}Delay`),i=s(`${Xe}Duration`),o=Ks(r,i),l=s(`${At}Delay`),c=s(`${At}Duration`),d=Ks(l,c);let u=null,h=0,v=0;t===Xe?o>0&&(u=Xe,h=o,v=i.length):t===At?d>0&&(u=At,h=d,v=c.length):(h=Math.max(o,d),u=h>0?o>d?Xe:At:null,v=u?u===Xe?i.length:c.length:0);const T=u===Xe&&/\b(?:transform|all)(?:,|$)/.test(s(`${Xe}Property`).toString());return{type:u,timeout:h,propCount:v,hasTransform:T}}function Ks(e,t){for(;e.lengthBs(n)+Bs(e[s])))}function Bs(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function Us(e){return(e?e.ownerDocument:document).body.offsetHeight}function Rl(e,t,n){const s=e[Ut];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Ws=Symbol("_vod"),Fl=Symbol("_vsh"),Ll=Symbol(""),Dl=/(?:^|;)\s*display\s*:/;function Nl(e,t,n){const s=e.style,r=Q(n);let i=!1;if(n&&!r){if(t)if(Q(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();n[l]==null&&Mt(s,l,"")}else for(const o in t)n[o]==null&&Mt(s,o,"");for(const o in n){o==="display"&&(i=!0);const l=n[o];l!=null?$l(e,o,!Q(t)&&t?t[o]:void 0,l)||Mt(s,o,l):Mt(s,o,"")}}else if(r){if(t!==n){const o=s[Ll];o&&(n+=";"+o),s.cssText=n,i=Dl.test(n)}}else t&&e.removeAttribute("style");Ws in e&&(e[Ws]=i?s.display:"",e[Fl]&&(s.display="none"))}const ks=/\s*!important$/;function Mt(e,t,n){if(O(n))n.forEach(s=>Mt(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=Hl(e,t);ks.test(n)?e.setProperty(tt(s),n.replace(ks,""),"important"):e[s]=n}}const qs=["Webkit","Moz","ms"],Nn={};function Hl(e,t){const n=Nn[t];if(n)return n;let s=Te(t);if(s!=="filter"&&s in e)return Nn[t]=s;s=lr(s);for(let r=0;rHn||(Wl.then(()=>Hn=0),Hn=Date.now());function ql(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const r=n.value;if(O(r)){const i=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{i.call(s),s._stopped=!0};const o=r.slice(),l=[s];for(let c=0;ce.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Gl=(e,t,n,s,r,i)=>{const o=r==="svg";t==="class"?Rl(e,s,o):t==="style"?Nl(e,n,s):dn(t)?hn(t)||Vl(e,t,n,s,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Jl(e,t,s,o))?(Ys(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Js(e,t,s,o,i,t!=="value")):e._isVueCE&&(Yl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Q(s)))?Ys(e,Te(t),s,i,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),Js(e,t,s,o))};function Jl(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&Xs(t)&&R(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Xs(t)&&Q(n)?!1:t in e}function Yl(e,t){const n=e._def.props;if(!n)return!1;const s=Te(t);return Array.isArray(n)?n.some(r=>Te(r)===s):Object.keys(n).some(r=>Te(r)===s)}const yt=e=>{const t=e.props["onUpdate:modelValue"]||!1;return O(t)?n=>Qt(t,n):t};function zl(e){e.target.composing=!0}function Zs(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Je=Symbol("_assign");function Qs(e,t,n){return t&&(e=e.trim()),n&&(e=gn(e)),e}const ac={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e[Je]=yt(r);const i=s||r.props&&r.props.type==="number";Qe(e,t?"change":"input",o=>{o.target.composing||e[Je](Qs(e.value,n,i))}),(n||i)&&Qe(e,"change",()=>{e.value=Qs(e.value,n,i)}),t||(Qe(e,"compositionstart",zl),Qe(e,"compositionend",Zs),Qe(e,"change",Zs))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:r,number:i}},o){if(e[Je]=yt(o),e.composing)return;const l=(i||e.type==="number")&&!/^0\d/.test(e.value)?gn(e.value):e.value,c=t??"";if(l===c)return;const d=e.getRootNode();(d instanceof Document||d instanceof ShadowRoot)&&d.activeElement===e&&e.type!=="range"&&(s&&t===n||r&&e.value.trim()===c)||(e.value=c)}},dc={deep:!0,created(e,t,n){e[Je]=yt(n),Qe(e,"change",()=>{const s=e._modelValue,r=Wt(e),i=e.checked,o=e[Je];if(O(s)){const l=ts(s,r),c=l!==-1;if(i&&!c)o(s.concat(r));else if(!i&&c){const d=[...s];d.splice(l,1),o(d)}}else if(vt(s)){const l=new Set(s);i?l.add(r):l.delete(r),o(l)}else o(_i(e,i))})},mounted:er,beforeUpdate(e,t,n){e[Je]=yt(n),er(e,t,n)}};function er(e,{value:t,oldValue:n},s){e._modelValue=t;let r;if(O(t))r=ts(t,s.props.value)>-1;else if(vt(t))r=t.has(s.props.value);else{if(t===n)return;r=xt(t,_i(e,!0))}e.checked!==r&&(e.checked=r)}const hc={deep:!0,created(e,{value:t,modifiers:{number:n}},s){const r=vt(t);Qe(e,"change",()=>{const i=Array.prototype.filter.call(e.options,o=>o.selected).map(o=>n?gn(Wt(o)):Wt(o));e[Je](e.multiple?r?new Set(i):i:i[0]),e._assigning=!0,Or(()=>{e._assigning=!1})}),e[Je]=yt(s)},mounted(e,{value:t}){tr(e,t)},beforeUpdate(e,t,n){e[Je]=yt(n)},updated(e,{value:t}){e._assigning||tr(e,t)}};function tr(e,t){const n=e.multiple,s=O(t);if(!(n&&!s&&!vt(t))){for(let r=0,i=e.options.length;rString(d)===String(l)):o.selected=ts(t,l)>-1}else o.selected=t.has(l);else if(xt(Wt(o),t)){e.selectedIndex!==r&&(e.selectedIndex=r);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Wt(e){return"_value"in e?e._value:e.value}function _i(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const Xl=["ctrl","shift","alt","meta"],Zl={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Xl.some(n=>e[`${n}Key`]&&!t.includes(n))},pc=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=((r,...i)=>{for(let o=0;o{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=(r=>{if(!("key"in r))return;const i=tt(r.key);if(t.some(o=>o===i||Ql[o]===i))return e(r)}))},ec=ne({patchProp:Gl},wl);let nr;function tc(){return nr||(nr=Qo(ec))}const mc=((...e)=>{const t=tc().createApp(...e),{mount:n}=t;return t.mount=s=>{const r=sc(s);if(!r)return;const i=t._component;!R(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=n(r,!1,nc(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t});function nc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function sc(e){return Q(e)?document.querySelector(e):e}export{Le as F,uc as T,Wr as a,rl as b,fc as c,lc as d,ul as e,ai as f,vl as g,fl as h,cc as i,be as j,ol as k,oc as l,dc as m,es as n,Ur as o,hc as p,pc as q,ic as r,gc as s,Ri as t,no as u,ac as v,Pn as w,ao as x,ls as y,mc as z}; diff --git a/src/aiperf/history/static/index.html b/src/aiperf/history/static/index.html new file mode 100644 index 0000000000..d0b1a10550 --- /dev/null +++ b/src/aiperf/history/static/index.html @@ -0,0 +1,20 @@ + + + + + + + + AIPerf 历史指标 + + + + + + +
+ + diff --git a/src/aiperf/history/stream_parser.py b/src/aiperf/history/stream_parser.py new file mode 100644 index 0000000000..6325b65208 --- /dev/null +++ b/src/aiperf/history/stream_parser.py @@ -0,0 +1,494 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Parser for canonical AIPerf stream benchmark artifacts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from aiperf.history.artifact_io import ( + STREAM_FILE_NAMES, + artifact_digest, + load_json, + load_jsonl, + read_run_files, +) +from aiperf.history.models import HistoryRunRecord, ParsedHistoryRun +from aiperf.history.parsing import ( + as_mapping, + hardware_summary, + manifest_end_time, + mapping_list, + parse_datetime, + server_metric_units, +) +from aiperf.history.points import ( + MetricPointBuilder, + add_gpu_telemetry_records, + add_server_metric_records, +) + +_SESSION_SCALARS = ( + "offer_rtt_ms", + "connected_latency_ms", + "first_frame_latency_ms", + "first_metadata_latency_ms", + "session_runtime_s", + "frames_received", + "metadata_messages", + "status_messages", + "stream_fps", +) + +_CHUNK_SCALARS = ( + "frames", + "request_prepare_seconds", + "compute_seconds", + "encode_seconds", + "output_pacing_seconds", + "output_header_write_seconds", + "output_payload_write_seconds", + "output_write_seconds", + "total_seconds", + "raw_output_bytes", + "wire_output_bytes", + "output_batches", +) + + +async def parse_stream_artifact(main_path: Path) -> ParsedHistoryRun: + """Parse one stream artifact directory into a run and scalar points.""" + + directory = main_path.parent + files = await read_run_files(directory, STREAM_FILE_NAMES) + summary = as_mapping(load_json(files, "summary.json", required=True)) + manifest = as_mapping(load_json(files, "observability_manifest.json")) + contract = as_mapping(load_json(files, "benchmark_contract.json")) + digest = artifact_digest(files) + identity = as_mapping(manifest.get("identity")) + benchmark_id = str(identity.get("benchmark_id") or digest) + started_at = parse_datetime(summary.get("started_at_utc")) + builder = MetricPointBuilder(benchmark_id, started_at) + + sessions = load_jsonl(files, "sessions.jsonl") + _add_summary_points(builder, summary) + _add_session_points(builder, sessions) + _add_target_phase_points(builder, as_mapping(summary.get("target_metadata"))) + _add_normalized_points(builder, load_json(files, "normalized_metrics.json")) + units = server_metric_units(load_json(files, "server_metrics_export.json")) + add_server_metric_records( + builder, + load_jsonl(files, "server_metrics_export.jsonl"), + units=units, + ) + add_gpu_telemetry_records( + builder, + load_jsonl(files, "gpu_telemetry_export.jsonl"), + ) + + run = _build_run( + directory=directory, + digest=digest, + identity=identity, + summary=summary, + manifest=manifest, + contract=contract, + started_at=started_at, + metric_count=len(builder.points), + files=files, + ) + return ParsedHistoryRun(run=run, points=builder.points) + + +def _build_run( + *, + directory: Path, + digest: str, + identity: Mapping[str, Any], + summary: Mapping[str, Any], + manifest: Mapping[str, Any], + contract: Mapping[str, Any], + started_at: datetime, + metric_count: int, + files: Mapping[str, str], +) -> HistoryRunRecord: + profile = as_mapping(summary.get("profile")) + config = dict(as_mapping(summary.get("config"))) + target_metadata = dict(as_mapping(summary.get("target_metadata"))) + legacy = _legacy_stream_identity(directory, config) + implementation = str( + identity.get("implementation") + or contract.get("implementation") + or legacy["implementation"] + or "unknown" + ) + model = str(identity.get("model") or contract.get("model") or legacy["model"] or "") + model_family = str( + contract.get("model_family") + or identity.get("model_family") + or legacy["model_family"] + or model + ) + workload = as_mapping(contract.get("workload")) + task = str(workload.get("task") or config.get("task") or "") + return HistoryRunRecord( + run_id=str(identity.get("benchmark_id") or digest), + benchmark_id=str(identity.get("benchmark_id") or digest), + run_kind="stream", + started_at=started_at, + ended_at=manifest_end_time(manifest), + ingested_at=datetime.now(tz=timezone.utc), + status=_stream_status(profile), + implementation=implementation, + model=model, + model_family=model_family, + mode=str( + identity.get("mode") or contract.get("mode") or legacy["mode"] or "stream" + ), + scene=str( + contract.get("scene") + or model_family + or contract.get("name") + or legacy["scene"] + or "" + ), + task=task, + transport=str(contract.get("transport") or legacy["transport"] or ""), + hardware=hardware_summary(target_metadata), + aiperf_version=str(identity.get("aiperf_version") or ""), + aiperf_commit=str(identity.get("aiperf_commit") or ""), + contract_digest=str(identity.get("contract_digest") or ""), + artifact_path=str(directory), + artifact_digest=digest, + metric_count=metric_count, + session_count=int(profile.get("attempted_sessions") or 0), + tags={ + "contract_name": str(contract.get("name") or ""), + "observability_status": str(manifest.get("status") or "unknown"), + }, + config=config, + metadata={ + "target": target_metadata, + "artifact_integrity": { + "present_files": sorted(files), + "missing_optional_files": sorted(set(STREAM_FILE_NAMES) - set(files)), + }, + "observability_errors": manifest.get("errors", []), + }, + ) + + +def _legacy_stream_identity( + directory: Path, + config: Mapping[str, Any], +) -> dict[str, str]: + """Recover bounded identity fields from pre-contract stream artifacts.""" + + path_text = "/".join(part.lower() for part in directory.parts) + configured_model = str(config.get("model") or "") + identity_text = f"{path_text} {configured_model.lower()}" + is_sglang = ( + "sglang" in identity_text + or "websocket_path" in config + or "health_path" in config + ) + is_telefuser = ( + "telefuser" in identity_text + or "offer_path" in config + or "delete_path_template" in config + ) + + implementation = "" + transport = "" + if is_sglang: + implementation = "sglang_diffusion" + transport = "websocket" + elif is_telefuser: + implementation = "telefuser" + transport = "webrtc" + + model_family = "" + if ( + "lingbot" in identity_text and "world" in identity_text + ) or "stream_lingbot" in identity_text: + model_family = "lingbot_world_fast" + + model = configured_model + if not model and implementation == "telefuser" and model_family: + model = "LingBot-World-Fast" + mode = "stream_world" if model_family else "" + return { + "implementation": implementation, + "model": model, + "model_family": model_family, + "mode": mode, + "scene": model_family, + "transport": transport, + } + + +def _stream_status(profile: Mapping[str, Any]) -> str: + attempted = int(profile.get("attempted_sessions") or 0) + successful = int(profile.get("successful_sessions") or 0) + failed = int(profile.get("failed_sessions") or 0) + if attempted > 0 and failed == 0 and successful == attempted: + return "completed" + if successful > 0: + return "partial" + return "failed" + + +def _add_summary_points( + builder: MetricPointBuilder, + summary: Mapping[str, Any], +) -> None: + profile = as_mapping(summary.get("profile")) + metrics = as_mapping(profile.get("metrics")) + for metric_name, statistics in metrics.items(): + for statistic, value in as_mapping(statistics).items(): + builder.add( + str(metric_name), + value, + statistic=str(statistic), + scope="run", + phase="profiling", + source="summary.json", + ) + for name in ("attempted_sessions", "successful_sessions", "failed_sessions"): + builder.add( + f"profile_{name}", + profile.get(name), + unit="count", + source="summary.json", + ) + builder.add( + "success_rate", + profile.get("success_rate"), + unit="ratio", + source="summary.json", + ) + _add_steady_state_points(builder, as_mapping(profile.get("chunk_steady_state"))) + + +def _add_steady_state_points( + builder: MetricPointBuilder, + steady: Mapping[str, Any], +) -> None: + for name, value in steady.items(): + metric_name = f"chunk_steady_state_{name}" + if name == "frames_per_second": + metric_name = "chunk_compute_fps_weighted" + builder.add( + metric_name, + value, + scope="run", + phase="profiling", + source="summary.json", + ) + + +def _add_session_points(builder: MetricPointBuilder, records: list[Any]) -> None: + for record in records: + session = as_mapping(record) + session_id = str( + session.get("session_id") or session.get("planned_session_id") or "" + ) + phase = str(session.get("phase") or "") + logical_index = int(session.get("logical_session_index") or 0) + for metric_name in _SESSION_SCALARS: + builder.add( + metric_name, + session.get(metric_name), + scope="session", + phase=phase, + session_id=session_id, + sample_index=logical_index, + source="sessions.jsonl", + ) + builder.add( + "session_success", + 1 if session.get("success") is True else 0, + unit="boolean", + scope="session", + phase=phase, + session_id=session_id, + sample_index=logical_index, + source="sessions.jsonl", + ) + _add_controls(builder, session, phase=phase, session_id=session_id) + _add_chunks(builder, session, phase=phase, session_id=session_id) + _add_session_phases(builder, session, phase=phase, session_id=session_id) + + +def _add_controls( + builder: MetricPointBuilder, + session: Mapping[str, Any], + *, + phase: str, + session_id: str, +) -> None: + for control in mapping_list(session.get("control_events")): + index = int(control.get("index") or 0) + for metric_name, field in ( + ("control_ack_latency_ms", "ack_latency_ms"), + ("control_to_next_frame_latency_ms", "next_frame_latency_ms"), + ): + builder.add( + metric_name, + control.get(field), + scope="control", + phase=phase, + session_id=session_id, + sample_index=index, + source="sessions.jsonl", + ) + + +def _add_chunks( + builder: MetricPointBuilder, + session: Mapping[str, Any], + *, + phase: str, + session_id: str, +) -> None: + for chunk in mapping_list(session.get("chunk_measurements")): + index = int(chunk.get("index") or 0) + for field in _CHUNK_SCALARS: + builder.add( + f"chunk_{field}", + chunk.get(field), + scope="chunk", + phase=phase, + session_id=session_id, + sample_index=index, + source="sessions.jsonl", + ) + compute = chunk.get("compute_seconds") + frames = chunk.get("frames") + if isinstance(compute, (int, float)) and compute > 0: + builder.add( + "chunk_compute_fps", + float(frames or 0) / float(compute), + unit="frames/second", + scope="chunk", + phase=phase, + session_id=session_id, + sample_index=index, + source="sessions.jsonl", + ) + _add_memory( + builder, + mapping_list(chunk.get("memory")), + prefix="chunk", + scope="chunk", + phase=phase, + session_id=session_id, + sample_index=index, + ) + + +def _add_session_phases( + builder: MetricPointBuilder, + session: Mapping[str, Any], + *, + phase: str, + session_id: str, +) -> None: + for index, measurement in enumerate( + mapping_list(session.get("phase_measurements")) + ): + name = str(measurement.get("name") or "phase") + builder.add( + f"{name}_seconds", + measurement.get("seconds"), + scope="phase", + phase=phase, + session_id=session_id, + sample_index=index, + source="sessions.jsonl", + ) + _add_memory( + builder, + mapping_list(measurement.get("memory")), + prefix=name, + scope="phase", + phase=phase, + session_id=session_id, + sample_index=index, + ) + + +def _add_target_phase_points( + builder: MetricPointBuilder, + target_metadata: Mapping[str, Any], +) -> None: + performance = as_mapping(target_metadata.get("performance")) + for index, measurement in enumerate(mapping_list(performance.get("phases"))): + name = str(measurement.get("name") or "phase") + builder.add( + f"{name}_seconds", + measurement.get("seconds"), + scope="phase", + phase="target", + sample_index=index, + source="target_metadata.json", + ) + _add_memory( + builder, + mapping_list(measurement.get("memory")), + prefix=name, + scope="phase", + phase="target", + session_id="", + sample_index=index, + ) + + +def _add_memory( + builder: MetricPointBuilder, + memory: list[Mapping[str, Any]], + *, + prefix: str, + scope: str, + phase: str, + session_id: str, + sample_index: int, +) -> None: + for item in memory: + device = str(item.get("device") or "") + for suffix in ("peak_allocated_bytes", "peak_reserved_bytes"): + builder.add( + f"{prefix}_{suffix}", + item.get(suffix), + unit="bytes", + scope=scope, + phase=phase, + session_id=session_id, + sample_index=sample_index, + device=device, + source="target_measurement", + ) + + +def _add_normalized_points(builder: MetricPointBuilder, payload: Any) -> None: + for result in mapping_list(as_mapping(payload).get("results")): + framework = str(result.get("framework") or "") + for observation in mapping_list(result.get("observations")): + metric_name = str(observation.get("metric_name") or "") + if not metric_name: + continue + for statistic, value in as_mapping(observation.get("values")).items(): + builder.add( + metric_name, + value, + unit=str(observation.get("unit") or ""), + statistic=str(statistic), + scope="normalized", + source=str(observation.get("source_endpoint_id") or framework), + state=str(observation.get("state") or "unknown"), + labels=as_mapping(observation.get("labels")), + ) diff --git a/src/aiperf/observability/__init__.py b/src/aiperf/observability/__init__.py new file mode 100644 index 0000000000..617ac0ce45 --- /dev/null +++ b/src/aiperf/observability/__init__.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Cross-system observability contracts for multimodal benchmark modes.""" + +from aiperf.observability.exporter import ( + NormalizedMetricsExporter, + ObservationManifestExporter, +) +from aiperf.observability.lifecycle import ObservationLifecycle +from aiperf.observability.mapping import ( + SemanticMetricMapper, + load_builtin_semantic_mapping, + load_semantic_mapping, +) +from aiperf.observability.models import ( + EndpointObservationStatus, + MetricCapability, + MetricObservationState, + MetricSupport, + NormalizedMetricObservation, + NormalizedMetricsArtifact, + ObservationClockAnchor, + ObservationEndpoint, + ObservationError, + ObservationManifest, + ObservationPhaseWindow, + ObservationRunIdentity, + ObservationRunStatus, + SemanticMappingResult, + SemanticMetricMapping, + SemanticMetricRule, + SourceMetricObservation, + StatisticMapping, +) +from aiperf.observability.protocols import ( + NormalizedMetricsExporterProtocol, + ObservationLifecycleProtocol, + ObservationManifestExporterProtocol, + SemanticMetricMapperProtocol, +) +from aiperf.observability.server_metrics import ( + server_metrics_export_to_observations, + server_metrics_results_to_export_data, +) + +__all__ = [ + "EndpointObservationStatus", + "MetricCapability", + "MetricObservationState", + "MetricSupport", + "NormalizedMetricObservation", + "NormalizedMetricsArtifact", + "NormalizedMetricsExporter", + "NormalizedMetricsExporterProtocol", + "ObservationClockAnchor", + "ObservationEndpoint", + "ObservationError", + "ObservationLifecycle", + "ObservationLifecycleProtocol", + "ObservationManifest", + "ObservationManifestExporter", + "ObservationManifestExporterProtocol", + "ObservationPhaseWindow", + "ObservationRunIdentity", + "ObservationRunStatus", + "SemanticMappingResult", + "SemanticMetricMapper", + "SemanticMetricMapperProtocol", + "SemanticMetricMapping", + "SemanticMetricRule", + "SourceMetricObservation", + "StatisticMapping", + "load_builtin_semantic_mapping", + "load_semantic_mapping", + "server_metrics_export_to_observations", + "server_metrics_results_to_export_data", +] diff --git a/src/aiperf/observability/exporter.py b/src/aiperf/observability/exporter.py new file mode 100644 index 0000000000..d8685c7522 --- /dev/null +++ b/src/aiperf/observability/exporter.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import aiofiles +import orjson + +from aiperf.common.finite import scrub_non_finite +from aiperf.observability.models import ( + NormalizedMetricsArtifact, + ObservationManifest, +) + + +async def _export_model( + model: ObservationManifest | NormalizedMetricsArtifact, + output_path: str | Path, +) -> Path: + path = Path(output_path) + payload = orjson.dumps( + scrub_non_finite(model.model_dump(mode="json")), + option=orjson.OPT_INDENT_2, + ) + async with aiofiles.open(path, "wb") as file: + await file.write(payload + b"\n") + return path + + +class ObservationManifestExporter: + """Write an observability manifest without blocking the event loop.""" + + async def export( + self, + manifest: ObservationManifest, + output_path: str | Path, + ) -> Path: + """Persist the manifest as formatted JSON and return its path.""" + + return await _export_model(manifest, output_path) + + +class NormalizedMetricsExporter: + """Write normalized metric observations without blocking the event loop.""" + + async def export( + self, + artifact: NormalizedMetricsArtifact, + output_path: str | Path, + ) -> Path: + """Persist normalized metrics as formatted JSON and return its path.""" + + return await _export_model(artifact, output_path) diff --git a/src/aiperf/observability/lifecycle.py b/src/aiperf/observability/lifecycle.py new file mode 100644 index 0000000000..5866b4ca52 --- /dev/null +++ b/src/aiperf/observability/lifecycle.py @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import time +from collections.abc import Callable + +from aiperf.common.models.server_metrics_models import ServerMetricsResults +from aiperf.common.redact import redact_string, redact_url +from aiperf.observability.models import ( + EndpointObservationStatus, + ObservationClockAnchor, + ObservationEndpoint, + ObservationError, + ObservationManifest, + ObservationPhaseWindow, + ObservationRunIdentity, + ObservationRunStatus, +) + + +class ObservationLifecycle: + """Capture shared phase windows and server-metrics completeness metadata.""" + + def __init__( + self, + identity: ObservationRunIdentity, + *, + wall_clock_ns: Callable[[], int] = time.time_ns, + monotonic_clock_ns: Callable[[], int] = time.monotonic_ns, + ) -> None: + self.identity = identity + self._wall_clock_ns = wall_clock_ns + self._monotonic_clock_ns = monotonic_clock_ns + self._phases: list[ObservationPhaseWindow] = [] + self._active_phase: ObservationPhaseWindow | None = None + self._endpoints: list[ObservationEndpoint] = [] + self._errors: list[ObservationError] = [] + self._disabled_reason: str | None = None + self._invalid_reason: str | None = None + self._cancelled = False + + def _anchor(self) -> ObservationClockAnchor: + return ObservationClockAnchor( + wall_time_ns=self._wall_clock_ns(), + monotonic_ns=self._monotonic_clock_ns(), + ) + + async def on_phase_start( + self, + *, + phase: str, + expected_units: int, + ) -> None: + """Start one workload phase using paired wall and monotonic clocks.""" + + if self._active_phase is not None: + raise RuntimeError( + f"Cannot start phase {phase!r}; phase " + f"{self._active_phase.phase!r} is still active" + ) + self._active_phase = ObservationPhaseWindow( + phase=phase, + start=self._anchor(), + expected_units=expected_units, + ) + self._phases.append(self._active_phase) + + async def on_phase_complete( + self, + *, + phase: str, + successful_units: int, + failed_units: int, + ) -> None: + """Complete the active phase and retain its result counts.""" + + if self._active_phase is None: + raise RuntimeError(f"Cannot complete inactive phase {phase!r}") + if self._active_phase.phase != phase: + raise RuntimeError( + f"Cannot complete phase {phase!r}; active phase is " + f"{self._active_phase.phase!r}" + ) + if successful_units < 0 or failed_units < 0: + raise ValueError("phase result counts must be non-negative") + if successful_units + failed_units > self._active_phase.expected_units: + raise ValueError("phase result counts cannot exceed expected_units") + self._active_phase.end = self._anchor() + self._active_phase.successful_units = successful_units + self._active_phase.failed_units = failed_units + self._active_phase.complete = True + self._active_phase = None + + async def on_run_cancelled(self, *, error: str) -> None: + """Close an active phase and mark the observation run partial.""" + + self._cancelled = True + if self._active_phase is not None: + self._active_phase.end = self._anchor() + self._active_phase = None + self._errors.append( + ObservationError( + component="workload", + message=redact_string(error), + count=1, + ) + ) + + def disable(self, reason: str) -> None: + """Record that observation was intentionally disabled for this run.""" + + self._disabled_reason = redact_string(reason) + + def invalidate(self, reason: str) -> None: + """Record a schema or protocol failure that invalidates observation.""" + + self._invalid_reason = redact_string(reason) + + def attach_server_metrics_results( + self, + results: ServerMetricsResults | None, + ) -> None: + """Attach endpoint and error state produced by ServerMetricsManager.""" + + if results is None: + return + if ( + results.benchmark_id is not None + and results.benchmark_id != self.identity.benchmark_id + ): + raise ValueError( + "Server metrics benchmark_id does not match observation identity" + ) + + successful = set(results.endpoints_successful) + self._endpoints = [ + ObservationEndpoint( + endpoint_id=redact_url(endpoint), + status=( + EndpointObservationStatus.COMPLETE + if endpoint in successful + else EndpointObservationStatus.DISABLED + ), + ) + for endpoint in results.endpoints_configured + ] + for error in results.error_summary: + self._errors.append( + ObservationError( + component="server_metrics", + message=redact_string(error.error_details.message), + count=error.count, + ) + ) + + @property + def phase_windows(self) -> list[ObservationPhaseWindow]: + """Return snapshots of phase windows captured so far.""" + + return [phase.model_copy(deep=True) for phase in self._phases] + + def get_phase_window(self, phase: str) -> ObservationPhaseWindow | None: + """Return the latest phase window with the requested name.""" + + for window in reversed(self._phases): + if window.phase == phase: + return window.model_copy(deep=True) + return None + + def build_manifest( + self, + *, + mapping_versions: list[str] | None = None, + artifacts: dict[str, str] | None = None, + ) -> ObservationManifest: + """Build the versioned provenance and completeness envelope.""" + + status = self._status() + errors = list(self._errors) + if self._disabled_reason is not None: + errors.append( + ObservationError( + component="observability", + message=self._disabled_reason, + count=1, + ) + ) + if self._invalid_reason is not None: + errors.append( + ObservationError( + component="observability", + message=self._invalid_reason, + count=1, + ) + ) + return ObservationManifest( + created_at_ns=self._wall_clock_ns(), + identity=self.identity, + status=status, + phases=list(self._phases), + endpoints=list(self._endpoints), + mapping_versions=list(mapping_versions or []), + artifacts=dict(artifacts or {}), + errors=errors, + ) + + def _status(self) -> ObservationRunStatus: + if self._invalid_reason is not None: + return ObservationRunStatus.INVALID + if self._disabled_reason is not None: + return ObservationRunStatus.DISABLED + if self._cancelled or self._active_phase is not None: + return ObservationRunStatus.PARTIAL + if any(not phase.complete for phase in self._phases): + return ObservationRunStatus.PARTIAL + if any( + phase.successful_units + phase.failed_units != phase.expected_units + for phase in self._phases + ): + return ObservationRunStatus.PARTIAL + if any( + endpoint.status != EndpointObservationStatus.COMPLETE + for endpoint in self._endpoints + ): + return ObservationRunStatus.PARTIAL + if self._errors: + return ObservationRunStatus.PARTIAL + return ObservationRunStatus.COMPLETE diff --git a/src/aiperf/observability/mapping.py b/src/aiperf/observability/mapping.py new file mode 100644 index 0000000000..9b47c578ff --- /dev/null +++ b/src/aiperf/observability/mapping.py @@ -0,0 +1,225 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Iterable +from importlib import resources +from pathlib import Path + +from ruamel.yaml import YAML + +from aiperf.common.path_safety import safe_read_template_path +from aiperf.observability.models import ( + MetricCapability, + MetricObservationState, + MetricSupport, + NormalizedMetricObservation, + SemanticMappingResult, + SemanticMetricMapping, + SemanticMetricRule, + SourceMetricObservation, +) + +_yaml = YAML(typ="safe") +_BUILTIN_MAPPING_NAME = re.compile(r"[a-z0-9_-]+") + + +def load_semantic_mapping(path: str | Path) -> SemanticMetricMapping: + """Load and validate a semantic metric mapping from a safe YAML path.""" + + text = safe_read_template_path(str(path)) + if text is None: + raise ValueError(f"Semantic mapping could not be read safely: {path}") + payload = _yaml.load(text) + if not isinstance(payload, dict): + raise ValueError("Semantic mapping must be a YAML mapping") + return SemanticMetricMapping.model_validate(payload) + + +def load_builtin_semantic_mapping(framework: str) -> SemanticMetricMapping: + """Load a packaged framework mapping by its sanitized framework name.""" + + normalized = framework.lower().replace("-", "_") + if _BUILTIN_MAPPING_NAME.fullmatch(normalized) is None: + raise ValueError(f"Invalid built-in mapping name: {framework!r}") + resource = resources.files("aiperf.observability.mappings").joinpath( + f"{normalized}.yaml" + ) + if not resource.is_file(): + raise ValueError(f"No built-in semantic mapping for {framework!r}") + with resources.as_file(resource) as path: + return load_semantic_mapping(path) + + +class SemanticMetricMapper: + """Apply declarative framework rules without changing native observations.""" + + def __init__(self, mapping: SemanticMetricMapping) -> None: + self.mapping = mapping + + def map_observations( + self, + observations: Iterable[SourceMetricObservation], + *, + capabilities: Iterable[MetricCapability] = (), + ) -> SemanticMappingResult: + """Return normalized observations with explicit missing-state semantics.""" + + by_name: dict[str, list[SourceMetricObservation]] = defaultdict(list) + for observation in observations: + if observation.framework == self.mapping.framework: + by_name[observation.metric_name].append(observation) + capability_by_name = { + capability.metric_name: capability for capability in capabilities + } + + normalized: list[NormalizedMetricObservation] = [] + for rule in self.mapping.rules: + capability = capability_by_name.get(rule.target_metric) + if ( + capability is not None + and capability.support == MetricSupport.UNSUPPORTED + ): + normalized.append( + self._unavailable( + rule, + state=MetricObservationState.UNSUPPORTED, + reason=capability.reason + or "Target contract declares this metric unsupported", + ) + ) + continue + + sources = by_name.get(rule.source_metric, []) + if not sources: + state = ( + MetricObservationState.MISSING + if rule.required + else MetricObservationState.UNSUPPORTED + ) + normalized.append( + self._unavailable( + rule, + state=state, + reason=f"Native metric {rule.source_metric!r} was not observed", + ) + ) + continue + + normalized.extend(self._map_source(rule, source) for source in sources) + + return SemanticMappingResult( + mapping_version=self.mapping.mapping_version, + framework=self.mapping.framework, + observations=normalized, + ) + + @staticmethod + def _unavailable( + rule: SemanticMetricRule, + *, + state: MetricObservationState, + reason: str, + ) -> NormalizedMetricObservation: + return NormalizedMetricObservation( + metric_name=rule.target_metric, + unit=rule.target_unit, + state=state, + source_metric=rule.source_metric, + reason=reason, + ) + + @staticmethod + def _map_source( + rule: SemanticMetricRule, + source: SourceMetricObservation, + ) -> NormalizedMetricObservation: + if source.metric_type != rule.source_type: + return SemanticMetricMapper._unavailable_from_source( + rule, + source, + state=MetricObservationState.INVALID, + reason=( + f"Expected metric type {rule.source_type}, " + f"observed {source.metric_type}" + ), + ) + if rule.source_unit is not None and source.unit != rule.source_unit: + return SemanticMetricMapper._unavailable_from_source( + rule, + source, + state=MetricObservationState.INVALID, + reason=f"Expected unit {rule.source_unit!r}, observed {source.unit!r}", + ) + if source.state in { + MetricObservationState.INVALID, + MetricObservationState.MISSING, + MetricObservationState.UNSUPPORTED, + }: + return SemanticMetricMapper._unavailable_from_source( + rule, + source, + state=source.state, + reason=source.reason or "Native metric observation is unavailable", + ) + + values: dict[str, float] = {} + missing_statistics: list[str] = [] + for statistic in rule.statistics: + value = source.statistics.get(statistic.source) + if value is None: + missing_statistics.append(statistic.source) + continue + values[statistic.target] = value + + if not values: + return SemanticMetricMapper._unavailable_from_source( + rule, + source, + state=MetricObservationState.INVALID, + reason=( + "None of the required native statistics were observed: " + + ", ".join(missing_statistics) + ), + ) + + state = source.state + reason = source.reason + if missing_statistics: + state = MetricObservationState.PARTIAL + reason = "Missing native statistics: " + ", ".join(missing_statistics) + + allowlist = set(rule.label_allowlist) + labels = { + name: value for name, value in source.labels.items() if name in allowlist + } + return NormalizedMetricObservation( + metric_name=rule.target_metric, + unit=rule.target_unit, + state=state, + values=values, + labels=labels, + source_metric=source.metric_name, + source_endpoint_id=source.endpoint_id, + reason=reason, + ) + + @staticmethod + def _unavailable_from_source( + rule: SemanticMetricRule, + source: SourceMetricObservation, + *, + state: MetricObservationState, + reason: str, + ) -> NormalizedMetricObservation: + return NormalizedMetricObservation( + metric_name=rule.target_metric, + unit=rule.target_unit, + state=state, + source_metric=source.metric_name, + source_endpoint_id=source.endpoint_id, + reason=reason, + ) diff --git a/src/aiperf/observability/mappings/__init__.py b/src/aiperf/observability/mappings/__init__.py new file mode 100644 index 0000000000..a4e77d8e9e --- /dev/null +++ b/src/aiperf/observability/mappings/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Built-in framework mappings for normalized observability metrics.""" diff --git a/src/aiperf/observability/mappings/telefuser.yaml b/src/aiperf/observability/mappings/telefuser.yaml new file mode 100644 index 0000000000..74eda76077 --- /dev/null +++ b/src/aiperf/observability/mappings/telefuser.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +schema_version: "1.0" +mapping_version: telefuser-v1 +framework: telefuser +rules: + - source_metric: telefuser_queue_size + target_metric: scheduler.queue_depth + source_type: gauge + target_unit: tasks + statistics: + - source: avg + target: avg + - source: max + target: max + + - source_metric: telefuser_queue_processing + target_metric: scheduler.running_tasks + source_type: gauge + target_unit: tasks + statistics: + - source: avg + target: avg + - source: max + target: max + + - source_metric: telefuser_tasks_completed + target_metric: requests.completed + source_type: counter + target_unit: tasks + statistics: + - source: total + target: total + - source: rate + target: rate_per_second + + - source_metric: telefuser_task_duration_seconds + target_metric: requests.server_execution_latency + source_type: histogram + source_unit: seconds + target_unit: seconds + statistics: + - source: avg + target: mean + - source: p50_estimate + target: p50 + - source: p95_estimate + target: p95 + - source: p99_estimate + target: p99 diff --git a/src/aiperf/observability/models.py b/src/aiperf/observability/models.py new file mode 100644 index 0000000000..eac75154fb --- /dev/null +++ b/src/aiperf/observability/models.py @@ -0,0 +1,442 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import ClassVar + +from pydantic import ConfigDict, Field, model_validator +from typing_extensions import Self + +from aiperf import __commit_sha__, __version__ +from aiperf.common.enums import CaseInsensitiveStrEnum, PrometheusMetricType +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel + + +class MetricObservationState(CaseInsensitiveStrEnum): + """Data-availability state for one normalized metric observation.""" + + OBSERVED = "observed" + UNSUPPORTED = "unsupported" + MISSING = "missing" + INVALID = "invalid" + PARTIAL = "partial" + + +class MetricSupport(CaseInsensitiveStrEnum): + """Whether a target contract claims support for a normalized metric.""" + + SUPPORTED = "supported" + UNSUPPORTED = "unsupported" + + +class ObservationRunStatus(CaseInsensitiveStrEnum): + """Completeness state for an observability manifest.""" + + COMPLETE = "complete" + PARTIAL = "partial" + DISABLED = "disabled" + INVALID = "invalid" + + +class EndpointObservationStatus(CaseInsensitiveStrEnum): + """Collection state for one server metrics endpoint.""" + + COMPLETE = "complete" + PARTIAL = "partial" + DISABLED = "disabled" + INVALID = "invalid" + + +class _StrictObservationModel(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid") + + +class ObservationRunIdentity(_StrictObservationModel): + """Stable identity shared by client, server, and normalized artifacts.""" + + benchmark_id: str = Field( + min_length=1, + description="Unique AIPerf benchmark run identifier.", + ) + mode: str = Field(min_length=1, description="Benchmark mode identifier.") + implementation: str = Field( + min_length=1, + description="Target framework or implementation identifier.", + ) + model: str = Field(min_length=1, description="Target model identifier.") + aiperf_version: str = Field( + default_factory=lambda: __version__, + description="AIPerf package version that generated the artifact.", + ) + aiperf_commit: str = Field( + default_factory=lambda: __commit_sha__, + description="AIPerf source commit that generated the artifact.", + ) + contract_digest: str | None = Field( + default=None, + description="SHA-256 digest of the benchmark target contract.", + ) + + +class ObservationClockAnchor(_StrictObservationModel): + """Paired wall and monotonic timestamps from one AIPerf process.""" + + wall_time_ns: int = Field( + ge=0, + description="Wall-clock Unix timestamp in nanoseconds.", + ) + monotonic_ns: int = Field( + ge=0, + description="Monotonic process timestamp in nanoseconds.", + ) + + +class ObservationPhaseWindow(_StrictObservationModel): + """Observed benchmark phase bounds and completed work counts.""" + + phase: str = Field(min_length=1, description="Benchmark phase name.") + start: ObservationClockAnchor = Field( + description="Clock anchor captured when the phase started.", + ) + end: ObservationClockAnchor | None = Field( + default=None, + description="Clock anchor captured when the phase completed.", + ) + expected_units: int = Field( + ge=0, + description="Number of requests or sessions planned for this phase.", + ) + successful_units: int = Field( + default=0, + ge=0, + description="Number of successful requests or sessions.", + ) + failed_units: int = Field( + default=0, + ge=0, + description="Number of failed requests or sessions.", + ) + complete: bool = Field( + default=False, + description="Whether the phase emitted a normal completion event.", + ) + + @model_validator(mode="after") + def validate_counts_and_bounds(self) -> Self: + if self.successful_units + self.failed_units > self.expected_units: + raise ValueError("completed units cannot exceed expected_units") + if self.complete and self.end is None: + raise ValueError("complete phase windows require an end clock anchor") + if self.end is not None: + if self.end.wall_time_ns < self.start.wall_time_ns: + raise ValueError("phase wall-clock end precedes start") + if self.end.monotonic_ns < self.start.monotonic_ns: + raise ValueError("phase monotonic end precedes start") + return self + + +class ObservationEndpoint(_StrictObservationModel): + """Completeness state for one redacted server metrics source.""" + + endpoint_id: str = Field( + min_length=1, + description="Redacted endpoint identity retained in artifacts.", + ) + status: EndpointObservationStatus = Field( + description="Collection completeness for this endpoint.", + ) + error_count: int = Field( + default=0, + ge=0, + description="Number of collection errors attributed to the endpoint.", + ) + + +class ObservationError(_StrictObservationModel): + """Counted observability failure retained in the manifest.""" + + component: str = Field( + min_length=1, + description="Component that reported the failure.", + ) + message: str = Field(min_length=1, description="Redacted failure message.") + count: int = Field(gt=0, description="Number of matching failures.") + + +class MetricCapability(_StrictObservationModel): + """Target capability declaration for one normalized metric.""" + + metric_name: str = Field( + min_length=1, + description="Normalized metric name.", + ) + support: MetricSupport = Field( + description="Whether the target claims support for the metric.", + ) + reason: str | None = Field( + default=None, + description="Optional explanation for the capability declaration.", + ) + + +class SourceMetricObservation(_StrictObservationModel): + """One aggregated native metric series before semantic normalization.""" + + framework: str = Field( + min_length=1, + description="Framework that emitted the native metric.", + ) + metric_name: str = Field(min_length=1, description="Native metric name.") + metric_type: PrometheusMetricType = Field( + description="Prometheus metric type.", + ) + unit: str | None = Field( + default=None, + description="Unit inferred or declared for the native metric.", + ) + endpoint_id: str | None = Field( + default=None, + description="Redacted endpoint identity for the native series.", + ) + labels: dict[str, str] = Field( + default_factory=dict, + description="Native Prometheus labels for this series.", + ) + statistics: dict[str, FiniteFloat] = Field( + default_factory=dict, + description="Finite aggregated statistics keyed by native statistic name.", + ) + state: MetricObservationState = Field( + default=MetricObservationState.OBSERVED, + description="Completeness state of the native series.", + ) + reason: str | None = Field( + default=None, + description="Optional detail when the native series is partial or invalid.", + ) + + @model_validator(mode="after") + def validate_state_values(self) -> Self: + if self.state == MetricObservationState.OBSERVED and not self.statistics: + raise ValueError("observed source metrics require finite statistics") + if ( + self.state + in { + MetricObservationState.MISSING, + MetricObservationState.UNSUPPORTED, + } + and self.statistics + ): + raise ValueError("unavailable source metrics cannot carry statistics") + return self + + +class StatisticMapping(_StrictObservationModel): + """Map one native aggregate statistic to a normalized statistic name.""" + + source: str = Field(min_length=1, description="Native statistic name.") + target: str = Field(min_length=1, description="Normalized statistic name.") + + +class SemanticMetricRule(_StrictObservationModel): + """Declarative normalization rule for one native metric family.""" + + source_metric: str = Field(min_length=1, description="Native metric name.") + target_metric: str = Field(min_length=1, description="Normalized metric name.") + source_type: PrometheusMetricType = Field( + description="Expected Prometheus metric type.", + ) + source_unit: str | None = Field( + default=None, + description="Expected native unit, or None when unit is not constrained.", + ) + target_unit: str | None = Field( + default=None, + description="Unit assigned to the normalized metric.", + ) + statistics: list[StatisticMapping] = Field( + min_length=1, + description="Native-to-normalized aggregate statistic mappings.", + ) + label_allowlist: list[str] = Field( + default_factory=list, + description="Native labels allowed on the normalized observation.", + ) + required: bool = Field( + default=True, + description="Whether absence should be reported as missing.", + ) + + @model_validator(mode="after") + def validate_unique_statistics_and_labels(self) -> Self: + sources = [statistic.source for statistic in self.statistics] + targets = [statistic.target for statistic in self.statistics] + if len(sources) != len(set(sources)): + raise ValueError("statistic source names must be unique within a rule") + if len(targets) != len(set(targets)): + raise ValueError("statistic target names must be unique within a rule") + if len(self.label_allowlist) != len(set(self.label_allowlist)): + raise ValueError("label_allowlist values must be unique within a rule") + return self + + +class SemanticMetricMapping(_StrictObservationModel): + """Versioned mapping from one framework into normalized metric semantics.""" + + schema_version: str = Field( + default="1.0", + description="Semantic mapping schema version.", + ) + mapping_version: str = Field( + min_length=1, + description="Version of this framework mapping.", + ) + framework: str = Field( + min_length=1, + description="Framework matched by all rules in this mapping.", + ) + rules: list[SemanticMetricRule] = Field( + min_length=1, + description="Declarative metric normalization rules.", + ) + + @model_validator(mode="after") + def validate_unique_targets(self) -> Self: + targets = [rule.target_metric for rule in self.rules] + if len(targets) != len(set(targets)): + raise ValueError("target_metric values must be unique within a mapping") + return self + + +class NormalizedMetricObservation(_StrictObservationModel): + """One normalized metric series with explicit availability state.""" + + metric_name: str = Field( + min_length=1, + description="Normalized metric name.", + ) + unit: str | None = Field( + default=None, + description="Normalized metric unit.", + ) + state: MetricObservationState = Field( + description="Availability and validity state.", + ) + values: dict[str, FiniteFloat] = Field( + default_factory=dict, + description="Finite normalized statistics keyed by statistic name.", + ) + labels: dict[str, str] = Field( + default_factory=dict, + description="Allowlisted labels retained from the native series.", + ) + source_metric: str | None = Field( + default=None, + description="Native metric name that produced this observation.", + ) + source_endpoint_id: str | None = Field( + default=None, + description="Redacted endpoint identity for the native series.", + ) + reason: str | None = Field( + default=None, + description="Explanation for a non-observed state.", + ) + + @model_validator(mode="after") + def validate_state_values(self) -> Self: + if self.state == MetricObservationState.OBSERVED and not self.values: + raise ValueError("observed normalized metrics require finite values") + if ( + self.state + in { + MetricObservationState.MISSING, + MetricObservationState.UNSUPPORTED, + MetricObservationState.INVALID, + } + and self.values + ): + raise ValueError("unavailable normalized metrics cannot carry values") + return self + + +class SemanticMappingResult(_StrictObservationModel): + """Normalized observations produced by one mapping version.""" + + mapping_version: str = Field( + min_length=1, + description="Mapping version used to derive the result.", + ) + framework: str = Field( + min_length=1, + description="Framework matched by the mapping.", + ) + observations: list[NormalizedMetricObservation] = Field( + default_factory=list, + description="Normalized metric observations.", + ) + + +class NormalizedMetricsArtifact(_StrictObservationModel): + """Versioned artifact containing normalized metric mapping results.""" + + SCHEMA_VERSION: ClassVar[str] = "1.0" + + schema_version: str = Field( + default=SCHEMA_VERSION, + description="Normalized metrics artifact schema version.", + ) + created_at_ns: int = Field( + ge=0, + description="Artifact creation time as a Unix timestamp in nanoseconds.", + ) + identity: ObservationRunIdentity = Field( + description="Benchmark identity shared with the observation manifest.", + ) + results: list[SemanticMappingResult] = Field( + default_factory=list, + description="Semantic mapping results retained in this artifact.", + ) + + +class ObservationManifest(_StrictObservationModel): + """Versioned completeness and provenance envelope for benchmark artifacts.""" + + SCHEMA_VERSION: ClassVar[str] = "1.0" + + schema_version: str = Field( + default=SCHEMA_VERSION, + description="Observability manifest schema version.", + ) + created_at_ns: int = Field( + ge=0, + description="Manifest creation time as a Unix timestamp in nanoseconds.", + ) + identity: ObservationRunIdentity = Field( + description="Stable run and source identity.", + ) + status: ObservationRunStatus = Field( + description="Overall observability completeness state.", + ) + phases: list[ObservationPhaseWindow] = Field( + default_factory=list, + description="Benchmark phase windows captured by AIPerf.", + ) + endpoints: list[ObservationEndpoint] = Field( + default_factory=list, + description="Server metrics endpoint completeness states.", + ) + mapping_versions: list[str] = Field( + default_factory=list, + description="Semantic mapping versions used for derived artifacts.", + ) + artifacts: dict[str, str] = Field( + default_factory=dict, + description="Artifact roles mapped to relative or absolute paths.", + ) + errors: list[ObservationError] = Field( + default_factory=list, + description="Counted observability failures.", + ) diff --git a/src/aiperf/observability/protocols.py b/src/aiperf/observability/protocols.py new file mode 100644 index 0000000000..f9c7016180 --- /dev/null +++ b/src/aiperf/observability/protocols.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from typing import Protocol, runtime_checkable + +from aiperf.observability.models import ( + MetricCapability, + NormalizedMetricsArtifact, + ObservationManifest, + SemanticMappingResult, + SourceMetricObservation, +) + + +@runtime_checkable +class ObservationLifecycleProtocol(Protocol): + """Receive benchmark phase lifecycle events from any workload mode.""" + + async def on_phase_start( + self, + *, + phase: str, + expected_units: int, + ) -> None: ... + + async def on_phase_complete( + self, + *, + phase: str, + successful_units: int, + failed_units: int, + ) -> None: ... + + async def on_run_cancelled(self, *, error: str) -> None: ... + + +@runtime_checkable +class SemanticMetricMapperProtocol(Protocol): + """Normalize native observations using a versioned mapping.""" + + def map_observations( + self, + observations: Iterable[SourceMetricObservation], + *, + capabilities: Iterable[MetricCapability] = (), + ) -> SemanticMappingResult: ... + + +@runtime_checkable +class ObservationManifestExporterProtocol(Protocol): + """Persist a versioned observability manifest.""" + + async def export( + self, + manifest: ObservationManifest, + output_path: str | Path, + ) -> Path: ... + + +@runtime_checkable +class NormalizedMetricsExporterProtocol(Protocol): + """Persist versioned normalized metric mapping results.""" + + async def export( + self, + artifact: NormalizedMetricsArtifact, + output_path: str | Path, + ) -> Path: ... diff --git a/src/aiperf/observability/server_metrics.py b/src/aiperf/observability/server_metrics.py new file mode 100644 index 0000000000..7d97f04cd4 --- /dev/null +++ b/src/aiperf/observability/server_metrics.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime + +from aiperf import __version__ +from aiperf.common.constants import NANOS_PER_SECOND +from aiperf.common.enums import PrometheusMetricType +from aiperf.common.finite import is_finite_value +from aiperf.common.models.server_metrics_models import ( + CounterMetricData, + GaugeMetricData, + HistogramMetricData, + ServerMetricsEndpointInfo, + ServerMetricsExportData, + ServerMetricsResults, + ServerMetricsSummary, + UnknownMetricData, +) +from aiperf.common.redact import redact_url +from aiperf.observability.models import ( + MetricObservationState, + SourceMetricObservation, +) +from aiperf.server_metrics.units import infer_unit + +ServerMetricData = ( + GaugeMetricData | CounterMetricData | HistogramMetricData | UnknownMetricData +) + + +def _metric_class(metric_type: PrometheusMetricType) -> type[ServerMetricData]: + match metric_type: + case PrometheusMetricType.GAUGE: + return GaugeMetricData + case PrometheusMetricType.UNKNOWN: + return UnknownMetricData + case PrometheusMetricType.COUNTER: + return CounterMetricData + case PrometheusMetricType.HISTOGRAM: + return HistogramMetricData + case _: + raise ValueError(f"Unsupported server metric type: {metric_type}") + + +def _merge_endpoint_metrics( + results: ServerMetricsResults, +) -> tuple[dict[str, ServerMetricData], dict[str, ServerMetricsEndpointInfo]]: + metrics: dict[str, ServerMetricData] = {} + endpoint_info: dict[str, ServerMetricsEndpointInfo] = {} + for summary in (results.endpoint_summaries or {}).values(): + endpoint_info[summary.endpoint_url] = summary.info + for metric_name, metric in summary.metrics.items(): + if metric_name not in metrics: + unit = infer_unit(metric_name, metric.description) + metrics[metric_name] = _metric_class(metric.type)( + description=metric.description, + unit=unit.display_name() if unit else None, + ) + for source_series in metric.series: + series = source_series.model_copy(deep=True) + series.endpoint_url = summary.endpoint_url + metrics[metric_name].series.append(series) + for metric in metrics.values(): + metric.series.sort( + key=lambda series: ( + series.endpoint_url or "", + str(series.labels) if series.labels else "", + ) + ) + return dict(sorted(metrics.items())), dict(sorted(endpoint_info.items())) + + +def server_metrics_results_to_export_data( + results: ServerMetricsResults, + *, + input_config: dict | None = None, +) -> ServerMetricsExportData: + """Build the public server-metrics export model from in-process results.""" + + metrics, endpoint_info = _merge_endpoint_metrics(results) + return ServerMetricsExportData( + aiperf_version=__version__, + benchmark_id=results.benchmark_id, + summary=ServerMetricsSummary( + endpoints_configured=list(results.endpoints_configured), + endpoints_successful=list(results.endpoints_successful), + start_time=datetime.fromtimestamp(results.start_ns / NANOS_PER_SECOND), + end_time=datetime.fromtimestamp(results.end_ns / NANOS_PER_SECOND), + endpoint_info=endpoint_info or None, + ), + metrics=metrics, + input_config=dict(input_config or {}), + ) + + +def server_metrics_export_to_observations( + export: ServerMetricsExportData, + *, + framework: str, +) -> list[SourceMetricObservation]: + """Convert AIPerf server-metrics aggregates into mapper input records.""" + + observations: list[SourceMetricObservation] = [] + for metric_name, metric in export.metrics.items(): + for series in metric.series: + statistics = ( + series.stats.model_dump(exclude_none=True) + if series.stats is not None + else {} + ) + finite_statistics = { + name: float(value) + for name, value in statistics.items() + if is_finite_value(value) + } + empty_histogram = ( + metric.type == PrometheusMetricType.HISTOGRAM + and finite_statistics.get("count") == 0.0 + ) + if empty_histogram: + finite_statistics = {} + state = MetricObservationState.MISSING + reason = "Histogram observed no samples during collection" + elif finite_statistics: + state = MetricObservationState.OBSERVED + reason = None + else: + state = MetricObservationState.INVALID + reason = "Server metrics series has no finite aggregate statistics" + endpoint_id = ( + redact_url(series.endpoint_url) + if series.endpoint_url is not None + else None + ) + observations.append( + SourceMetricObservation( + framework=framework, + metric_name=metric_name, + metric_type=metric.type, + unit=metric.unit, + endpoint_id=endpoint_id, + labels=series.labels or {}, + statistics=finite_statistics, + state=state, + reason=reason, + ) + ) + return observations diff --git a/src/aiperf/resource_telemetry/__init__.py b/src/aiperf/resource_telemetry/__init__.py new file mode 100644 index 0000000000..23b3e56910 --- /dev/null +++ b/src/aiperf/resource_telemetry/__init__.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Active target-side resource collection and history upload.""" + +from aiperf.resource_telemetry.agent import ( + ResourceTelemetryAgent, + ResourceTelemetryError, +) +from aiperf.resource_telemetry.collector import ResourceCollector +from aiperf.resource_telemetry.models import ( + ResourceContainerSample, + ResourceGPUSample, + ResourceNetworkSample, + ResourceRunDescriptor, + ResourceSample, + ResourceTelemetryAck, + ResourceTelemetryBatch, +) + +__all__ = [ + "ResourceContainerSample", + "ResourceCollector", + "ResourceGPUSample", + "ResourceNetworkSample", + "ResourceRunDescriptor", + "ResourceSample", + "ResourceTelemetryAck", + "ResourceTelemetryAgent", + "ResourceTelemetryBatch", + "ResourceTelemetryError", +] diff --git a/src/aiperf/resource_telemetry/agent.py b/src/aiperf/resource_telemetry/agent.py new file mode 100644 index 0000000000..eba9b32de1 --- /dev/null +++ b/src/aiperf/resource_telemetry/agent.py @@ -0,0 +1,343 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Asynchronous one-second sampling and fifteen-second active upload.""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from contextlib import suppress +from typing import Any + +import aiohttp +import orjson + +from aiperf.common.redact import redact_url +from aiperf.resource_telemetry.collector import ( + ResourceCollectionError, + ResourceCollector, +) +from aiperf.resource_telemetry.models import ( + ResourceRunDescriptor, + ResourceRunStatus, + ResourceSample, + ResourceTelemetryAck, + ResourceTelemetryBatch, +) + + +class ResourceTelemetryError(RuntimeError): + """Raised when required resource telemetry cannot be delivered completely.""" + + +class ResourceTelemetryAgent: + """Collect target process, cgroup, and host facts into bounded active batches.""" + + def __init__( + self, + *, + history_url: str, + run: ResourceRunDescriptor, + sample_interval_s: float = 1.0, + upload_interval_s: float = 15.0, + request_timeout_s: float = 10.0, + retry_count: int = 3, + max_buffered_samples: int = 300, + require_gpu: bool = True, + collector: ResourceCollector | Any | None = None, + ) -> None: + if sample_interval_s <= 0: + raise ValueError("sample_interval_s must be positive") + if upload_interval_s <= 0: + raise ValueError("upload_interval_s must be positive") + if request_timeout_s <= 0: + raise ValueError("request_timeout_s must be positive") + if retry_count < 0: + raise ValueError("retry_count must be non-negative") + if max_buffered_samples <= 0: + raise ValueError("max_buffered_samples must be positive") + self.history_url = history_url.rstrip("/") + self.run = run + self.sample_interval_s = sample_interval_s + self.upload_interval_s = upload_interval_s + self.request_timeout_s = request_timeout_s + self.retry_count = retry_count + self._collector = collector or ResourceCollector( + run.root_pid, + require_gpu=require_gpu, + ) + self._samples: asyncio.Queue[ResourceSample | None] = asyncio.Queue( + maxsize=max_buffered_samples + ) + self._session: aiohttp.ClientSession | None = None + self._sample_task: asyncio.Task[None] | None = None + self._upload_task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + self._sampling_done = asyncio.Event() + self._failure: BaseException | None = None + self._final_status: ResourceRunStatus = "failed" + self._sequence = 0 + self._sample_index = 0 + self._dropped_samples = 0 + self._started = False + self._stopped = False + + async def start(self) -> None: + """Prime source counters, register the live run, and start both loops.""" + + if self._started: + return + try: + await asyncio.to_thread(self._collector.start) + timeout = aiohttp.ClientTimeout(total=self.request_timeout_s) + self._session = aiohttp.ClientSession(timeout=timeout) + await self._send(samples=[], final=False, status="running") + except asyncio.CancelledError: + await self._cleanup_failed_start() + raise + except ( + ResourceCollectionError, + ResourceTelemetryError, + aiohttp.ClientError, + OSError, + ValueError, + ): + await self._cleanup_failed_start() + raise + self._started = True + self._sample_task = asyncio.create_task( + self._sampling_loop(), + name=f"aiperf-resource-sampler-{self.run.benchmark_id}", + ) + self._upload_task = asyncio.create_task( + self._upload_loop(), + name=f"aiperf-resource-uploader-{self.run.benchmark_id}", + ) + + async def stop(self, *, status: ResourceRunStatus) -> None: + """Stop sampling, drain all samples, and require final-batch acknowledgement.""" + + if status == "running": + raise ValueError("resource telemetry stop requires a terminal status") + if self._stopped: + self._raise_failure() + return + if not self._started: + raise ResourceTelemetryError("resource telemetry agent was not started") + self._final_status = status + self._stop_event.set() + try: + if self._sample_task is not None: + await self._sample_task + self._sampling_done.set() + if self._upload_task is not None: + if not self._upload_task.done(): + await self._samples.put(None) + await self._upload_task + finally: + await asyncio.to_thread(self._collector.close) + if self._session is not None: + await self._session.close() + self._session = None + self._stopped = True + self._raise_failure() + + async def abort(self) -> None: + """Attempt a failed final flush while preserving the original exception.""" + + if not self._started or self._stopped: + return + with suppress(ResourceTelemetryError): + await self.stop(status="failed") + + async def _sampling_loop(self) -> None: + loop = asyncio.get_running_loop() + next_sample_at = loop.time() + self.sample_interval_s + try: + while not self._stop_event.is_set(): + timeout = max(0.0, next_sample_at - loop.time()) + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=timeout) + break + except asyncio.TimeoutError: + pass + sample = await asyncio.to_thread( + self._collector.sample, + self._sample_index, + ) + self._sample_index += 1 + try: + self._samples.put_nowait(sample) + except asyncio.QueueFull as exc: + self._dropped_samples += 1 + raise ResourceTelemetryError( + "resource telemetry buffer overflowed; no samples were " + "silently discarded" + ) from exc + next_sample_at += self.sample_interval_s + if next_sample_at < loop.time() - self.sample_interval_s: + next_sample_at = loop.time() + self.sample_interval_s + except asyncio.CancelledError: + self._stop_event.set() + raise + except ( + ResourceCollectionError, + ResourceTelemetryError, + OSError, + ValueError, + ) as exc: + self._record_failure(exc) + self._stop_event.set() + finally: + self._sampling_done.set() + + async def _upload_loop(self) -> None: + loop = asyncio.get_running_loop() + pending: list[ResourceSample] = [] + next_upload_at = loop.time() + self.upload_interval_s + try: + while True: + if self._sampling_done.is_set() and self._samples.empty(): + await self._send( + samples=pending, + final=True, + status=self._effective_final_status(), + ) + return + timeout = max(0.0, next_upload_at - loop.time()) + try: + sample = await asyncio.wait_for( + self._samples.get(), + timeout=timeout, + ) + if sample is not None: + pending.append(sample) + except asyncio.TimeoutError: + if pending: + await self._send( + samples=pending, + final=False, + status="running", + ) + pending = [] + next_upload_at = loop.time() + self.upload_interval_s + except asyncio.CancelledError: + self._stop_event.set() + raise + except ResourceTelemetryError as exc: + self._record_failure(exc) + self._stop_event.set() + + async def _cleanup_failed_start(self) -> None: + if self._session is not None: + await self._session.close() + self._session = None + await asyncio.to_thread(self._collector.close) + + async def _send( + self, + *, + samples: list[ResourceSample], + final: bool, + status: ResourceRunStatus, + ) -> ResourceTelemetryAck: + if self._session is None: + raise ResourceTelemetryError("resource telemetry HTTP session is closed") + batch = ResourceTelemetryBatch( + batch_id=str(uuid.uuid4()), + sequence=self._sequence, + run=self.run, + sent_at_ns=time.time_ns(), + final=final, + status=status, + dropped_samples=self._dropped_samples, + samples=samples, + ) + endpoint = self._endpoint() + payload = orjson.dumps(batch.model_dump(mode="json")) + last_error: BaseException | None = None + for attempt in range(self.retry_count + 1): + try: + async with self._session.post( + endpoint, + data=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) as response: + body = await response.read() + if response.status >= 400: + detail = body.decode("utf-8", errors="replace")[:1000] + raise ResourceTelemetryError( + f"history service returned HTTP {response.status}: {detail}" + ) + ack = ResourceTelemetryAck.model_validate(orjson.loads(body)) + if ( + ack.schema_version != batch.schema_version + or ack.batch_id != batch.batch_id + or ack.sequence != batch.sequence + ): + raise ResourceTelemetryError( + "history service acknowledged a different resource batch or schema version" + ) + self._sequence += 1 + return ack + except ( + aiohttp.ClientError, + asyncio.TimeoutError, + orjson.JSONDecodeError, + ResourceTelemetryError, + ValueError, + ) as exc: + last_error = exc + if attempt < self.retry_count: + await asyncio.sleep(min(4.0, 0.5 * (2**attempt))) + redacted_endpoint = redact_url(endpoint) + raise ResourceTelemetryError( + f"resource batch was not acknowledged by {redacted_endpoint}: {last_error}" + ) from last_error + + def _endpoint(self) -> str: + suffix = "/api/v1/history/resource-batches" + if self.history_url.endswith(suffix): + return self.history_url + return f"{self.history_url}{suffix}" + + def _effective_final_status(self) -> ResourceRunStatus: + if self._failure is not None or self._dropped_samples: + return "failed" + return self._final_status + + def _record_failure(self, error: BaseException) -> None: + if isinstance(error, asyncio.CancelledError): + return + if self._failure is None: + self._failure = error + + def _raise_failure(self) -> None: + if self._failure is None: + return + if isinstance(self._failure, ResourceTelemetryError): + raise self._failure + raise ResourceTelemetryError( + f"resource telemetry failed: {type(self._failure).__name__}: " + f"{self._failure}" + ) from self._failure + + async def __aenter__(self) -> ResourceTelemetryAgent: + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: Any, + ) -> None: + if exc is None: + await self.stop(status="completed") + else: + await self.abort() diff --git a/src/aiperf/resource_telemetry/cgroup.py b/src/aiperf/resource_telemetry/cgroup.py new file mode 100644 index 0000000000..e1b1c2e867 --- /dev/null +++ b/src/aiperf/resource_telemetry/cgroup.py @@ -0,0 +1,490 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Target-PID cgroup v1/v2 limits, usage, and GPU visibility discovery.""" + +from __future__ import annotations + +import re +from collections import deque +from dataclasses import dataclass +from pathlib import Path + +from aiperf.common.constants import IS_LINUX +from aiperf.resource_telemetry.cgroup_io import ( + join_cgroup_path, + key_values, + parse_cpu_set, + positive_float, + read_bytes, + read_int, + read_non_negative_int, + read_pid_file, + read_text, +) +from aiperf.resource_telemetry.models import ( + ResourceCgroupVersion, + ResourceContainerSample, + ResourceGPUVisibilitySource, +) + +_MAX_CGROUP_DIRECTORIES = 512 +_MAX_CGROUP_PROCESSES = 4096 +_V1_UNLIMITED_MEMORY = 1 << 60 +_GPU_DEVICE_PATTERN = re.compile(r"^nvidia([0-9]+)$") + + +@dataclass(slots=True, frozen=True) +class CgroupObservation: + """Container facts plus internal PID and GPU-visibility attribution inputs.""" + + container: ResourceContainerSample | None + process_ids: frozenset[int] | None + visible_gpu_tokens: frozenset[str] | None + unavailable: tuple[str, ...] + + +@dataclass(slots=True, frozen=True) +class _CgroupLayout: + version: ResourceCgroupVersion + path: str + cpu_root: Path | None + memory_root: Path | None + cpuset_root: Path | None + process_root: Path | None + + +class CgroupCollector: + """Read finite resource boundaries from the monitored PID's Linux cgroup.""" + + def __init__( + self, + root_pid: int, + *, + proc_root: Path = Path("/proc"), + cgroup_root: Path = Path("/sys/fs/cgroup"), + ) -> None: + if root_pid <= 0: + raise ValueError("root_pid must be positive") + self.root_pid = root_pid + self._proc_root = proc_root + self._cgroup_root = cgroup_root + self._layout: _CgroupLayout | None = None + self._machine_cpu_total_cores = 1.0 + self._machine_memory_total_bytes = 1 + self._previous_cpu_usage_seconds: float | None = None + self._started = False + + def start( + self, + *, + machine_cpu_total_cores: float, + machine_memory_total_bytes: int, + ) -> None: + """Discover the target cgroup and prime its monotonic CPU counter.""" + + if self._started: + return + if machine_cpu_total_cores <= 0: + raise ValueError("machine_cpu_total_cores must be positive") + if machine_memory_total_bytes <= 0: + raise ValueError("machine_memory_total_bytes must be positive") + self._machine_cpu_total_cores = machine_cpu_total_cores + self._machine_memory_total_bytes = machine_memory_total_bytes + self._layout = self._detect_layout() if IS_LINUX else None + if self._layout is not None: + self._previous_cpu_usage_seconds = self._cpu_usage_seconds(self._layout) + self._started = True + + def sample(self, *, elapsed_seconds: float) -> CgroupObservation: + """Return one bounded observation when a finite container boundary exists.""" + + if not self._started: + raise RuntimeError("cgroup collector has not been started") + if elapsed_seconds <= 0: + raise ValueError("elapsed_seconds must be positive") + layout = self._layout + if layout is None: + return CgroupObservation(None, None, None, ()) + + cpu_limit = self._cpu_limit_cores(layout) + memory_limit = self._memory_limit_bytes(layout) + visibility_source, visible_tokens = self._gpu_visibility( + allow_device_nodes=( + cpu_limit is not None + or memory_limit is not None + or self._different_mount_namespace() + ) + ) + gpu_boundary = visible_tokens is not None and visible_tokens != frozenset( + {"all"} + ) + if cpu_limit is None and memory_limit is None and not gpu_boundary: + self._previous_cpu_usage_seconds = self._cpu_usage_seconds(layout) + return CgroupObservation(None, None, visible_tokens, ()) + + unavailable: list[str] = [] + current_cpu_usage = self._cpu_usage_seconds(layout) + cpu_used: float | None = None + if cpu_limit is not None: + previous = self._previous_cpu_usage_seconds + if current_cpu_usage is None or previous is None: + unavailable.append("resource.cpu:container_used") + else: + cpu_used = min( + self._machine_cpu_total_cores, + max(0.0, current_cpu_usage - previous) / elapsed_seconds, + ) + self._previous_cpu_usage_seconds = current_cpu_usage + + memory_used = ( + self._memory_used_bytes(layout) if memory_limit is not None else None + ) + if memory_limit is not None and memory_used is None: + unavailable.append("resource.memory:container_used") + + process_ids = self._collect_process_ids(layout.process_root) + unavailable.extend(self._unavailable_metrics(visible_tokens, process_ids)) + container = ResourceContainerSample( + cgroup_version=layout.version, + cgroup_path=layout.path, + process_count=len(process_ids or ()), + cpu_used_cores=cpu_used, + cpu_limit_cores=cpu_limit, + memory_used_bytes=memory_used, + memory_limit_bytes=memory_limit, + gpu_visibility_source=visibility_source, + ) + return CgroupObservation( + container=container, + process_ids=process_ids, + visible_gpu_tokens=visible_tokens, + unavailable=tuple(sorted(set(unavailable))), + ) + + @staticmethod + def _unavailable_metrics( + visible_tokens: frozenset[str] | None, + process_ids: frozenset[int] | None, + ) -> list[str]: + unavailable: list[str] = [] + if visible_tokens is None: + unavailable.extend( + ( + "resource.gpu:container_total", + "resource.gpu_memory:container_total", + ) + ) + elif visible_tokens and process_ids is None: + unavailable.extend( + ( + "resource.gpu:container_used", + "resource.gpu_memory:container_used", + ) + ) + unavailable.extend( + ( + "resource.network:container_used:unsupported", + "resource.network:container_total:unsupported", + ) + ) + return unavailable + + def close(self) -> None: + """Discard discovered paths and primed counters.""" + + self._layout = None + self._previous_cpu_usage_seconds = None + self._started = False + + def _detect_layout(self) -> _CgroupLayout | None: + text = read_text(self._proc_root / str(self.root_pid) / "cgroup") + if text is None: + return None + entries: list[tuple[str, tuple[str, ...], str]] = [] + for line in text.splitlines(): + parts = line.split(":", 2) + if len(parts) != 3: + continue + hierarchy, raw_controllers, path = parts + controllers = tuple(item for item in raw_controllers.split(",") if item) + entries.append((hierarchy, controllers, path or "/")) + unified = next( + ( + path + for hierarchy, controllers, path in entries + if hierarchy == "0" and not controllers + ), + None, + ) + if unified is not None: + root = join_cgroup_path(self._cgroup_root, unified) + if root is None or not root.is_dir(): + return None + return _CgroupLayout("v2", unified, root, root, root, root) + return self._detect_v1_layout(entries) + + def _detect_v1_layout( + self, + entries: list[tuple[str, tuple[str, ...], str]], + ) -> _CgroupLayout | None: + cpu_entry = self._controller_entry(entries, {"cpu", "cpuacct"}) + memory_entry = self._controller_entry(entries, {"memory"}) + cpuset_entry = self._controller_entry(entries, {"cpuset"}) + cpu_root = self._v1_controller_root( + cpu_entry, + expected=("cpu.cfs_quota_us", "cpuacct.usage"), + ) + memory_root = self._v1_controller_root( + memory_entry, + expected=("memory.limit_in_bytes",), + ) + cpuset_root = self._v1_controller_root( + cpuset_entry, + expected=("cpuset.cpus",), + ) + process_root = cpu_root or memory_root or cpuset_root + if process_root is None: + return None + paths = [entry[1] for entry in (cpu_entry, memory_entry, cpuset_entry) if entry] + path = paths[0] if paths else "/" + return _CgroupLayout( + "v1", + path, + cpu_root, + memory_root, + cpuset_root, + process_root, + ) + + @staticmethod + def _controller_entry( + entries: list[tuple[str, tuple[str, ...], str]], + requested: set[str], + ) -> tuple[tuple[str, ...], str] | None: + for _, controllers, path in entries: + if requested.intersection(controllers): + return controllers, path + return None + + def _v1_controller_root( + self, + entry: tuple[tuple[str, ...], str] | None, + *, + expected: tuple[str, ...], + ) -> Path | None: + if entry is None: + return None + controllers, path = entry + names = [ + ",".join(controllers), + *controllers, + "cpu,cpuacct", + "cpuacct,cpu", + ] + for name in dict.fromkeys(names): + candidate = join_cgroup_path(self._cgroup_root / name, path) + if candidate is not None and any( + (candidate / filename).exists() for filename in expected + ): + return candidate + candidate = join_cgroup_path(self._cgroup_root, path) + if candidate is not None and any( + (candidate / filename).exists() for filename in expected + ): + return candidate + return None + + def _cpu_limit_cores(self, layout: _CgroupLayout) -> float | None: + limits: list[float] = [] + if layout.cpu_root is not None: + for root in self._cgroup_ancestors(layout.cpu_root): + if layout.version == "v2": + text = read_text(root / "cpu.max") + if text is None: + continue + fields = text.split() + if len(fields) >= 2 and fields[0] != "max": + quota = positive_float(fields[0]) + period = positive_float(fields[1]) + if quota is not None and period is not None: + limits.append(quota / period) + else: + quota = read_int(root / "cpu.cfs_quota_us") + period = read_int(root / "cpu.cfs_period_us") + if ( + quota is not None + and quota > 0 + and period is not None + and period > 0 + ): + limits.append(quota / period) + cpuset_count = self._cpuset_count(layout) + if cpuset_count is not None and cpuset_count < self._machine_cpu_total_cores: + limits.append(float(cpuset_count)) + if not limits: + return None + return max(1e-9, min(self._machine_cpu_total_cores, *limits)) + + def _cpuset_count(self, layout: _CgroupLayout) -> int | None: + root = layout.cpuset_root + if root is None: + return None + names = ( + ("cpuset.cpus.effective", "cpuset.cpus") + if layout.version == "v2" + else ("cpuset.cpus",) + ) + for name in names: + text = read_text(root / name) + if text is None or not text.strip(): + continue + count = parse_cpu_set(text) + if count is not None: + return count + return None + + def _memory_limit_bytes(self, layout: _CgroupLayout) -> int | None: + root = layout.memory_root + if root is None: + return None + name = "memory.max" if layout.version == "v2" else "memory.limit_in_bytes" + limits: list[int] = [] + for ancestor in self._cgroup_ancestors(root): + text = read_text(ancestor / name) + if text is None or text.strip() == "max": + continue + try: + value = int(text.strip()) + except ValueError: + continue + if value > 0 and not ( + layout.version == "v1" and value >= _V1_UNLIMITED_MEMORY + ): + limits.append(value) + return min(self._machine_memory_total_bytes, *limits) if limits else None + + def _cgroup_ancestors(self, root: Path) -> list[Path]: + ancestors: list[Path] = [] + current = root + while current.is_relative_to(self._cgroup_root): + ancestors.append(current) + if current == self._cgroup_root: + break + current = current.parent + return ancestors + + @staticmethod + def _memory_used_bytes(layout: _CgroupLayout) -> int | None: + if layout.memory_root is None: + return None + name = "memory.current" if layout.version == "v2" else "memory.usage_in_bytes" + return read_non_negative_int(layout.memory_root / name) + + @staticmethod + def _cpu_usage_seconds(layout: _CgroupLayout) -> float | None: + if layout.cpu_root is None: + return None + if layout.version == "v1": + value = read_non_negative_int(layout.cpu_root / "cpuacct.usage") + return value / 1_000_000_000 if value is not None else None + text = read_text(layout.cpu_root / "cpu.stat") + if text is None: + return None + values = key_values(text) + usage_usec = values.get("usage_usec") + if usage_usec is not None: + return usage_usec / 1_000_000 + usage_nsec = values.get("usage_nsec") + return usage_nsec / 1_000_000_000 if usage_nsec is not None else None + + def _gpu_visibility( + self, + *, + allow_device_nodes: bool, + ) -> tuple[ResourceGPUVisibilitySource | None, frozenset[str] | None]: + environment = self._target_environment() + nvidia = environment.get("NVIDIA_VISIBLE_DEVICES") + if nvidia is not None and nvidia.strip().lower() != "all": + return "nvidia_visible_devices", self._parse_visible_devices(nvidia) + cuda = environment.get("CUDA_VISIBLE_DEVICES") + if cuda is not None: + return "cuda_visible_devices", self._parse_visible_devices(cuda) + if allow_device_nodes: + device_tokens = self._target_gpu_device_nodes() + if device_tokens: + return "device_nodes", device_tokens + if nvidia is not None: + return "nvidia_visible_devices", self._parse_visible_devices(nvidia) + return None, None + + def _target_environment(self) -> dict[str, str]: + raw = read_bytes(self._proc_root / str(self.root_pid) / "environ") + if raw is None: + return {} + environment: dict[str, str] = {} + for item in raw.split(b"\0"): + if b"=" not in item: + continue + key, value = item.split(b"=", 1) + environment[key.decode(errors="replace")] = value.decode(errors="replace") + return environment + + def _target_gpu_device_nodes(self) -> frozenset[str]: + device_root = self._proc_root / str(self.root_pid) / "root" / "dev" + tokens: set[str] = set() + try: + candidates = device_root.glob("nvidia*") + for path in candidates: + match = _GPU_DEVICE_PATTERN.fullmatch(path.name) + if match is not None: + tokens.add(match.group(1)) + except OSError: + return frozenset() + return frozenset(tokens) + + def _different_mount_namespace(self) -> bool: + try: + current = (self._proc_root / "self" / "ns" / "mnt").stat().st_ino + target = (self._proc_root / str(self.root_pid) / "ns" / "mnt").stat().st_ino + except OSError: + return False + return current != target + + def _collect_process_ids(self, root: Path | None) -> frozenset[int] | None: + if root is None: + return None + queue: deque[Path] = deque([root]) + visited = 0 + found_file = False + process_ids: set[int] = set() + while queue and visited < _MAX_CGROUP_DIRECTORIES: + current = queue.popleft() + visited += 1 + values = read_pid_file(current / "cgroup.procs") + if values is None and self._layout and self._layout.version == "v1": + values = read_pid_file(current / "tasks") + if values is not None: + found_file = True + process_ids.update(values) + if len(process_ids) >= _MAX_CGROUP_PROCESSES: + return frozenset(sorted(process_ids)[:_MAX_CGROUP_PROCESSES]) + try: + children = [ + path + for path in current.iterdir() + if path.is_dir() and not path.is_symlink() + ] + except OSError: + children = [] + queue.extend(sorted(children)) + return frozenset(process_ids) if found_file else None + + @staticmethod + def _parse_visible_devices(value: str) -> frozenset[str]: + normalized = value.strip() + if normalized.lower() in {"", "-1", "none", "void"}: + return frozenset() + if normalized.lower() == "all": + return frozenset({"all"}) + return frozenset(item.strip() for item in normalized.split(",") if item.strip()) diff --git a/src/aiperf/resource_telemetry/cgroup_io.py b/src/aiperf/resource_telemetry/cgroup_io.py new file mode 100644 index 0000000000..4a812527e9 --- /dev/null +++ b/src/aiperf/resource_telemetry/cgroup_io.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bounded parsing and filesystem reads for Linux control groups.""" + +from __future__ import annotations + +from pathlib import Path, PurePosixPath + + +def parse_cpu_set(value: str) -> int | None: + """Count logical CPUs in a kernel cpuset range.""" + + count = 0 + try: + for item in value.strip().split(","): + if not item: + continue + if "-" in item: + start, end = (int(part) for part in item.split("-", 1)) + if start < 0 or end < start: + return None + count += end - start + 1 + else: + if int(item) < 0: + return None + count += 1 + except ValueError: + return None + return count or None + + +def key_values(value: str) -> dict[str, int]: + """Parse non-negative integer key/value lines from cgroup statistics.""" + + result: dict[str, int] = {} + for line in value.splitlines(): + fields = line.split() + if len(fields) != 2: + continue + try: + result[fields[0]] = max(0, int(fields[1])) + except ValueError: + continue + return result + + +def positive_float(value: str) -> float | None: + """Parse a strictly positive finite-enough kernel numeric field.""" + + try: + parsed = float(value) + except ValueError: + return None + return parsed if parsed > 0 else None + + +def read_int(path: Path) -> int | None: + """Read one integer without allowing filesystem failures to escape.""" + + text = read_text(path) + if text is None: + return None + try: + return int(text.strip()) + except ValueError: + return None + + +def read_non_negative_int(path: Path) -> int | None: + """Read one kernel counter and clamp it at zero.""" + + value = read_int(path) + return max(0, value) if value is not None else None + + +def read_pid_file(path: Path) -> set[int] | None: + """Read a cgroup PID set while ignoring malformed entries.""" + + text = read_text(path) + if text is None: + return None + result: set[int] = set() + for item in text.split(): + try: + pid = int(item) + except ValueError: + continue + if pid > 0: + result.add(pid) + return result + + +def read_text(path: Path) -> str | None: + """Read UTF-8 kernel text, returning no observation on failure.""" + + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + + +def read_bytes(path: Path) -> bytes | None: + """Read kernel bytes, returning no observation on failure.""" + + try: + return path.read_bytes() + except OSError: + return None + + +def join_cgroup_path(root: Path, value: str) -> Path | None: + """Resolve a kernel cgroup path without permitting parent traversal.""" + + try: + parts = [part for part in PurePosixPath(value).parts if part != "/"] + except (TypeError, ValueError): + return None + if any(part in {"", ".", ".."} for part in parts): + return None + return root.joinpath(*parts) diff --git a/src/aiperf/resource_telemetry/collector.py b/src/aiperf/resource_telemetry/collector.py new file mode 100644 index 0000000000..1edddd6379 --- /dev/null +++ b/src/aiperf/resource_telemetry/collector.py @@ -0,0 +1,286 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Process-tree, cgroup, host, network, and NVIDIA resource sampling.""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from aiperf.resource_telemetry.cgroup import CgroupCollector, CgroupObservation +from aiperf.resource_telemetry.errors import ResourceCollectionError +from aiperf.resource_telemetry.models import ( + ResourceContainerSample, + ResourceGPUSample, + ResourceSample, +) +from aiperf.resource_telemetry.network import NetworkCollector +from aiperf.resource_telemetry.nvml import NVMLCollector + + +@dataclass(slots=True, frozen=True) +class _ProcessSnapshot: + identity: tuple[int, float] + cpu_seconds: float + rss_bytes: int + + +@dataclass(slots=True, frozen=True) +class _HostCPUSnapshot: + total_seconds: float + idle_seconds: float + + +class ResourceCollector: + """Sample one root PID, its cgroup boundary, and the machine hosting them.""" + + def __init__( + self, + root_pid: int, + *, + require_gpu: bool = True, + psutil_module: Any | None = None, + nvml_module: Any | None = None, + cgroup_collector: CgroupCollector | Any | None = None, + network_collector: NetworkCollector | Any | None = None, + wall_clock_ns: Callable[[], int] = time.time_ns, + monotonic_ns: Callable[[], int] = time.monotonic_ns, + ) -> None: + if root_pid <= 0: + raise ValueError("root_pid must be positive") + if psutil_module is None: + import psutil as psutil_module + if nvml_module is None: + try: + import pynvml as nvml_module + except ImportError: + nvml_module = None + self.root_pid = root_pid + self.require_gpu = require_gpu + self._psutil = psutil_module + self._wall_clock_ns = wall_clock_ns + self._monotonic_ns = monotonic_ns + self._nvml = ( + NVMLCollector(nvml_module, wall_clock_ns=wall_clock_ns) + if nvml_module is not None + else None + ) + self._cgroup = cgroup_collector or CgroupCollector(root_pid) + self._network = network_collector or NetworkCollector(self._psutil) + self._logical_cores = float(self._psutil.cpu_count(logical=True) or 1) + self._previous_process_cpu: dict[tuple[int, float], float] = {} + self._previous_host_cpu: _HostCPUSnapshot | None = None + self._previous_monotonic_ns = 0 + self._previous_wall_ns = 0 + self._started = False + + def start(self) -> None: + """Initialize NVML and prime counters needed for one-second deltas.""" + + if self._started: + return + processes = self._process_tree() + snapshots = self._process_snapshots(processes) + self._previous_process_cpu = { + snapshot.identity: snapshot.cpu_seconds for snapshot in snapshots + } + self._previous_host_cpu = self._host_cpu_snapshot() + self._previous_monotonic_ns = self._monotonic_ns() + self._previous_wall_ns = self._wall_clock_ns() + memory = self._psutil.virtual_memory() + self._cgroup.start( + machine_cpu_total_cores=self._logical_cores, + machine_memory_total_bytes=int(memory.total), + ) + self._network.start() + if self._nvml is None: + if self.require_gpu: + raise ResourceCollectionError("pynvml is required for GPU sampling") + else: + self._nvml.start(require_gpu=self.require_gpu) + self._started = True + + def close(self) -> None: + """Release process-local resource collector state.""" + + if self._nvml is not None: + self._nvml.close() + self._cgroup.close() + self._network.close() + self._started = False + + def sample(self, sample_index: int) -> ResourceSample: + """Capture one source-timestamped resource sample.""" + + if not self._started: + raise ResourceCollectionError("resource collector has not been started") + if sample_index < 0: + raise ValueError("sample_index must be non-negative") + sampled_at_ns = self._wall_clock_ns() + monotonic_ns = self._monotonic_ns() + elapsed_seconds = max( + (monotonic_ns - self._previous_monotonic_ns) / 1_000_000_000, + 1e-9, + ) + processes = self._process_tree() + snapshots = self._process_snapshots(processes) + process_cpu = self._process_cpu_cores( + snapshots, + elapsed_seconds=elapsed_seconds, + ) + host_cpu = self._host_cpu_snapshot() + host_used_cores = self._host_used_cores_from_previous(host_cpu) + memory = self._psutil.virtual_memory() + cgroup = self._cgroup.sample(elapsed_seconds=elapsed_seconds) + gpu_samples, gpu_unavailable = self._sample_gpus( + {process.pid for process in processes}, + cgroup, + ) + network_samples, network_unavailable = self._network.sample( + elapsed_seconds=elapsed_seconds + ) + unavailable = [ + *cgroup.unavailable, + *gpu_unavailable, + *network_unavailable, + ] + container = self._container_with_visible_gpus(cgroup.container, gpu_samples) + self._update_baselines(snapshots, host_cpu, monotonic_ns, sampled_at_ns) + return ResourceSample( + sampled_at_ns=sampled_at_ns, + sample_index=sample_index, + process_count=len(snapshots), + process_cpu_cores=process_cpu, + machine_cpu_used_cores=host_used_cores, + machine_cpu_total_cores=self._logical_cores, + process_memory_used_bytes=sum(snapshot.rss_bytes for snapshot in snapshots), + machine_memory_used_bytes=int(memory.total - memory.available), + machine_memory_total_bytes=int(memory.total), + container=container, + gpus=gpu_samples, + networks=network_samples, + unavailable=sorted(set(unavailable))[:64], + ) + + def _sample_gpus( + self, + process_ids: set[int], + cgroup: CgroupObservation, + ) -> tuple[list[ResourceGPUSample], list[str]]: + if self._nvml is not None: + return self._nvml.sample( + process_ids, + container_process_ids=cgroup.process_ids, + visible_gpu_tokens=cgroup.visible_gpu_tokens, + ) + unavailable = ["resource.gpu", "resource.gpu_memory"] + if cgroup.visible_gpu_tokens: + unavailable.extend( + ( + "resource.gpu:container_used", + "resource.gpu:container_total", + "resource.gpu_memory:container_used", + "resource.gpu_memory:container_total", + ) + ) + return [], unavailable + + @staticmethod + def _container_with_visible_gpus( + container: ResourceContainerSample | None, + gpu_samples: list[ResourceGPUSample], + ) -> ResourceContainerSample | None: + if container is None: + return None + return container.model_copy( + update={ + "visible_gpu_devices": [ + gpu.device for gpu in gpu_samples if gpu.container_visible is True + ] + } + ) + + def _update_baselines( + self, + snapshots: list[_ProcessSnapshot], + host_cpu: _HostCPUSnapshot, + monotonic_ns: int, + sampled_at_ns: int, + ) -> None: + self._previous_process_cpu = { + snapshot.identity: snapshot.cpu_seconds for snapshot in snapshots + } + self._previous_host_cpu = host_cpu + self._previous_monotonic_ns = monotonic_ns + self._previous_wall_ns = sampled_at_ns + + def _process_tree(self) -> list[Any]: + try: + root = self._psutil.Process(self.root_pid) + children = root.children(recursive=True) + except self._psutil.NoSuchProcess as exc: + raise ResourceCollectionError( + f"monitored root process exited: {self.root_pid}" + ) from exc + return [root, *children] + + def _process_snapshots(self, processes: list[Any]) -> list[_ProcessSnapshot]: + snapshots: list[_ProcessSnapshot] = [] + for process in processes: + try: + times = process.cpu_times() + memory = process.memory_info() + snapshots.append( + _ProcessSnapshot( + identity=(int(process.pid), float(process.create_time())), + cpu_seconds=float(times.user + times.system), + rss_bytes=int(memory.rss), + ) + ) + except (self._psutil.NoSuchProcess, self._psutil.AccessDenied): + continue + if not any(snapshot.identity[0] == self.root_pid for snapshot in snapshots): + raise ResourceCollectionError( + f"monitored root process is unavailable: {self.root_pid}" + ) + return snapshots + + def _process_cpu_cores( + self, + snapshots: list[_ProcessSnapshot], + *, + elapsed_seconds: float, + ) -> float: + delta_seconds = 0.0 + previous_wall_seconds = self._previous_wall_ns / 1_000_000_000 + for snapshot in snapshots: + previous = self._previous_process_cpu.get(snapshot.identity) + if previous is not None: + delta_seconds += max(0.0, snapshot.cpu_seconds - previous) + elif snapshot.identity[1] >= previous_wall_seconds: + delta_seconds += snapshot.cpu_seconds + return min(self._logical_cores, delta_seconds / elapsed_seconds) + + def _host_cpu_snapshot(self) -> _HostCPUSnapshot: + times = self._psutil.cpu_times() + values = times._asdict() + idle = float(values.get("idle", 0.0) + values.get("iowait", 0.0)) + guest = float(values.get("guest", 0.0) + values.get("guest_nice", 0.0)) + return _HostCPUSnapshot( + total_seconds=float(sum(values.values()) - guest), + idle_seconds=idle, + ) + + def _host_used_cores_from_previous(self, current: _HostCPUSnapshot) -> float: + previous = self._previous_host_cpu + if previous is None: + return 0.0 + total_delta = max(0.0, current.total_seconds - previous.total_seconds) + idle_delta = max(0.0, current.idle_seconds - previous.idle_seconds) + if total_delta <= 0: + return 0.0 + used_fraction = min(1.0, max(0.0, (total_delta - idle_delta) / total_delta)) + return self._logical_cores * used_fraction diff --git a/src/aiperf/resource_telemetry/errors.py b/src/aiperf/resource_telemetry/errors.py new file mode 100644 index 0000000000..fa7635dfc4 --- /dev/null +++ b/src/aiperf/resource_telemetry/errors.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Errors shared by target-side resource collectors.""" + + +class ResourceCollectionError(RuntimeError): + """Raised when required target resource facts cannot be sampled.""" diff --git a/src/aiperf/resource_telemetry/models.py b/src/aiperf/resource_telemetry/models.py new file mode 100644 index 0000000000..962575215e --- /dev/null +++ b/src/aiperf/resource_telemetry/models.py @@ -0,0 +1,405 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Versioned contracts for target-side resource telemetry batches.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import ConfigDict, Field, model_validator +from typing_extensions import Self + +from aiperf import __commit_sha__, __version__ +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel + +ResourceRunStatus = Literal["running", "completed", "partial", "failed", "cancelled"] +ResourceNetworkKind = Literal["ethernet", "rdma"] +ResourceCgroupVersion = Literal["v1", "v2"] +ResourceGPUVisibilitySource = Literal[ + "cuda_visible_devices", + "device_nodes", + "nvidia_visible_devices", +] +ResourceTelemetrySchemaVersion = Literal["1.1", "1.2"] + + +class _StrictResourceModel(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid") + + +class ResourceRunDescriptor(_StrictResourceModel): + """Stable run identity sent with every independently retryable batch.""" + + benchmark_id: str = Field( + min_length=1, + max_length=256, + description="AIPerf benchmark identifier used as the history run identifier.", + ) + run_kind: str = Field( + default="stream", + min_length=1, + max_length=64, + description="Benchmark artifact family associated with the live run.", + ) + started_at_ns: int = Field( + ge=0, + description="Source-host UTC Unix timestamp in nanoseconds when the run started.", + ) + implementation: str = Field( + min_length=1, + max_length=128, + description="Target framework or serving implementation.", + ) + model: str = Field( + default="", + max_length=512, + description="Concrete model identifier under test.", + ) + model_family: str = Field( + default="", + max_length=256, + description="Framework-neutral model family for comparable runs.", + ) + mode: str = Field( + default="", + max_length=128, + description="Benchmark mode such as stream_world.", + ) + scene: str = Field( + default="", + max_length=256, + description="Comparable benchmark scene or workload family.", + ) + task: str = Field( + default="", + max_length=256, + description="Task exercised by the benchmark workload.", + ) + transport: str = Field( + default="", + max_length=64, + description="Client transport used by the benchmark.", + ) + root_pid: int = Field( + gt=0, + description="Root process identifier monitored on the source host.", + ) + hostname: str = Field( + min_length=1, + max_length=255, + description="Source hostname on which resource sampling ran.", + ) + contract_digest: str = Field( + default="", + max_length=256, + description="Benchmark contract digest when one is available.", + ) + aiperf_version: str = Field( + default_factory=lambda: __version__, + max_length=128, + description="AIPerf package version running the resource agent.", + ) + aiperf_commit: str = Field( + default_factory=lambda: __commit_sha__, + max_length=256, + description="AIPerf source commit running the resource agent.", + ) + artifact_path: str = Field( + default="", + max_length=4096, + description="Expected artifact directory, if known when sampling begins.", + ) + tags: dict[str, str] = Field( + default_factory=dict, + max_length=64, + description="Bounded searchable provenance tags for the live run.", + ) + config: dict[str, Any] = Field( + default_factory=dict, + max_length=256, + description="Redacted benchmark configuration retained with the live run.", + ) + metadata: dict[str, Any] = Field( + default_factory=dict, + max_length=256, + description="Additional bounded target metadata retained with the live run.", + ) + + +class ResourceContainerSample(_StrictResourceModel): + """One target control-group usage and effective-capacity observation.""" + + cgroup_version: ResourceCgroupVersion = Field( + description="Linux control-group version used by the target process.", + ) + cgroup_path: str = Field( + min_length=1, + max_length=1024, + description="Target control-group path used to read container resource facts.", + ) + process_count: int = Field( + ge=0, + description="Number of processes found in the target control-group subtree.", + ) + cpu_used_cores: FiniteFloat | None = Field( + default=None, + ge=0, + description="CPU consumed by the target control group in logical cores.", + ) + cpu_limit_cores: FiniteFloat | None = Field( + default=None, + gt=0, + description="Effective CPU quota or cpuset capacity in logical cores.", + ) + memory_used_bytes: int | None = Field( + default=None, + ge=0, + description="Memory charged to the target control group.", + ) + memory_limit_bytes: int | None = Field( + default=None, + gt=0, + description="Effective finite memory limit for the target control group.", + ) + gpu_visibility_source: ResourceGPUVisibilitySource | None = Field( + default=None, + description="Target-namespace signal used to resolve visible physical GPUs.", + ) + visible_gpu_devices: list[str] = Field( + default_factory=list, + max_length=64, + description="Physical GPU labels reliably resolved as visible to the target.", + ) + + +class ResourceGPUSample(_StrictResourceModel): + """One physical GPU observation for a single source timestamp.""" + + device: str = Field( + min_length=1, + max_length=128, + description="Stable device label, normally gpu followed by its physical index.", + ) + uuid: str = Field( + default="", + max_length=256, + description="NVML GPU UUID when available.", + ) + name: str = Field( + default="", + max_length=256, + description="Human-readable GPU product name when available.", + ) + process_utilization_percent: FiniteFloat | None = Field( + default=None, + ge=0, + le=100, + description="Aggregate SM utilization attributed to the monitored process tree.", + ) + device_utilization_percent: FiniteFloat = Field( + ge=0, + le=100, + description="Whole-device SM utilization reported by NVML.", + ) + process_memory_used_bytes: int | None = Field( + default=None, + ge=0, + description=( + "GPU memory attributed to the monitored process tree, or null when " + "the driver cannot expose per-process memory." + ), + ) + device_memory_used_bytes: int = Field( + ge=0, + description="GPU memory used by all processes on the device.", + ) + device_memory_total_bytes: int = Field( + gt=0, + description="Total physical GPU memory on the device.", + ) + container_visible: bool | None = Field( + default=None, + description=( + "Whether this physical device is visible inside the target container, " + "or null when visibility cannot be resolved." + ), + ) + container_utilization_percent: FiniteFloat | None = Field( + default=None, + ge=0, + le=100, + description="Aggregate SM utilization attributed to all target-cgroup processes.", + ) + container_memory_used_bytes: int | None = Field( + default=None, + ge=0, + description="GPU memory attributed to all target-cgroup processes.", + ) + + +class ResourceNetworkSample(_StrictResourceModel): + """Aggregate machine network observation for one transport family.""" + + kind: ResourceNetworkKind = Field( + description="Network transport family represented by this observation.", + ) + receive_bytes_per_second: FiniteFloat = Field( + ge=0, + description="Aggregate receive throughput across the selected interfaces.", + ) + transmit_bytes_per_second: FiniteFloat = Field( + ge=0, + description="Aggregate transmit throughput across the selected interfaces.", + ) + capacity_bytes_per_second: FiniteFloat | None = Field( + default=None, + gt=0, + description=( + "Aggregate one-direction link capacity, or null when any selected " + "interface does not expose a reliable speed." + ), + ) + interfaces: list[str] = Field( + default_factory=list, + max_length=64, + description="Bounded sorted interface or RDMA port identifiers in the aggregate.", + ) + + +class ResourceSample(_StrictResourceModel): + """One source-timestamped process-tree and machine resource snapshot.""" + + sampled_at_ns: int = Field( + ge=0, + description="Source-host UTC Unix timestamp in nanoseconds for this sample.", + ) + sample_index: int = Field( + ge=0, + description="Monotonic sample index within one benchmark run.", + ) + process_count: int = Field( + ge=0, + description="Number of live processes included in the recursive PID tree.", + ) + process_cpu_cores: FiniteFloat = Field( + ge=0, + description="CPU usage of the monitored process tree measured in logical cores.", + ) + machine_cpu_used_cores: FiniteFloat = Field( + ge=0, + description="Whole-machine CPU usage measured in logical cores.", + ) + machine_cpu_total_cores: FiniteFloat = Field( + gt=0, + description="Whole-machine logical CPU capacity in cores.", + ) + process_memory_used_bytes: int = Field( + ge=0, + description="Resident memory used by the monitored process tree.", + ) + machine_memory_used_bytes: int = Field( + ge=0, + description="Whole-machine memory in use, computed as total minus available.", + ) + machine_memory_total_bytes: int = Field( + gt=0, + description="Whole-machine physical memory capacity.", + ) + container: ResourceContainerSample | None = Field( + default=None, + description=( + "Target control-group usage and finite limits when a container or " + "restricted service boundary is detectable." + ), + ) + gpus: list[ResourceGPUSample] = Field( + default_factory=list, + max_length=64, + description="Per-device GPU compute and memory observations.", + ) + networks: list[ResourceNetworkSample] = Field( + default_factory=list, + max_length=2, + description="Machine Ethernet and RDMA bandwidth observations.", + ) + unavailable: list[str] = Field( + default_factory=list, + max_length=64, + description="Signals unavailable from the source host without substituted values.", + ) + + +class ResourceTelemetryBatch(_StrictResourceModel): + """One ordered, retryable upload from a target-side resource agent.""" + + schema_version: ResourceTelemetrySchemaVersion = Field( + default="1.2", + description="Resource telemetry wire schema version.", + ) + batch_id: str = Field( + min_length=1, + max_length=128, + description="Unique identifier retained across retries of this exact batch.", + ) + sequence: int = Field( + ge=0, + description="Monotonic batch sequence within one benchmark run.", + ) + run: ResourceRunDescriptor = Field( + description="Complete run identity making each batch independently ingestible.", + ) + sent_at_ns: int = Field( + ge=0, + description="Source-host UTC Unix timestamp immediately before upload.", + ) + final: bool = Field( + default=False, + description="Whether this batch closes the resource stream for the run.", + ) + status: ResourceRunStatus = Field( + default="running", + description="Run state at the time this batch was emitted.", + ) + dropped_samples: int = Field( + default=0, + ge=0, + description="Samples that could not be buffered; any nonzero value is incomplete.", + ) + samples: list[ResourceSample] = Field( + default_factory=list, + max_length=120, + description="At most two minutes of one-second source-timestamped samples.", + ) + + @model_validator(mode="after") + def validate_final_state(self) -> Self: + """Keep terminal state and final-batch semantics unambiguous.""" + + if self.final == (self.status == "running"): + raise ValueError("final batches require a terminal status and vice versa") + if self.sent_at_ns < self.run.started_at_ns: + raise ValueError("batch sent_at_ns cannot precede run started_at_ns") + return self + + +class ResourceTelemetryAck(_StrictResourceModel): + """History-service acknowledgement for an idempotently ingested batch.""" + + schema_version: ResourceTelemetrySchemaVersion = Field( + default="1.2", + description="Resource telemetry acknowledgement schema version.", + ) + batch_id: str = Field(description="Acknowledged batch identifier.") + sequence: int = Field(ge=0, description="Acknowledged batch sequence.") + run_id: str = Field(min_length=1, description="History run receiving the points.") + accepted_samples: int = Field( + ge=0, + description="Number of samples validated in the request.", + ) + accepted_points: int = Field( + ge=0, + description="Number of finite canonical resource points written or replaced.", + ) + final: bool = Field(description="Whether the acknowledged batch closed the stream.") diff --git a/src/aiperf/resource_telemetry/network.py b/src/aiperf/resource_telemetry/network.py new file mode 100644 index 0000000000..7f41cdc7fd --- /dev/null +++ b/src/aiperf/resource_telemetry/network.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Machine Ethernet and RDMA bandwidth sampling.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from aiperf.common.constants import IS_LINUX +from aiperf.resource_telemetry.models import ( + ResourceNetworkKind, + ResourceNetworkSample, +) + +_RDMA_COUNTER_BYTES = 4 +_RATE_PATTERN = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*([KMGT])b/sec", re.IGNORECASE) +_RATE_MULTIPLIERS = { + "K": 1_000, + "M": 1_000_000, + "G": 1_000_000_000, + "T": 1_000_000_000_000, +} + + +@dataclass(slots=True, frozen=True) +class _InterfaceCounter: + receive_bytes: int + transmit_bytes: int + capacity_bytes_per_second: float | None + + +class NetworkCollector: + """Collect aggregate Ethernet and RDMA rates from monotonic byte counters.""" + + def __init__( + self, + psutil_module: Any, + *, + infiniband_root: Path = Path("/sys/class/infiniband"), + net_class_root: Path = Path("/sys/class/net"), + ) -> None: + self._psutil = psutil_module + self._infiniband_root = infiniband_root + self._net_class_root = net_class_root + self._previous: dict[ResourceNetworkKind, dict[str, _InterfaceCounter]] = { + "ethernet": {}, + "rdma": {}, + } + self._started = False + + def start(self) -> None: + """Prime all available network counters before timed sampling begins.""" + + if self._started: + return + self._previous = self._snapshots() + self._started = True + + def sample( + self, + *, + elapsed_seconds: float, + ) -> tuple[list[ResourceNetworkSample], list[str]]: + """Return aggregate rates and explicit unavailable-signal metadata.""" + + if not self._started: + raise RuntimeError("network collector has not been started") + if elapsed_seconds <= 0: + raise ValueError("elapsed_seconds must be positive") + current = self._snapshots() + samples: list[ResourceNetworkSample] = [] + unavailable: list[str] = [] + for kind in ("ethernet", "rdma"): + counters = current[kind] + if not counters: + unavailable.extend( + ( + f"resource.network:machine_used:{kind}", + f"resource.network:machine_total:{kind}", + ) + ) + continue + sample = self._aggregate( + kind, + previous=self._previous[kind], + current=counters, + elapsed_seconds=elapsed_seconds, + ) + samples.append(sample) + if sample.capacity_bytes_per_second is None: + unavailable.append(f"resource.network:machine_total:{kind}") + self._previous = current + return samples, unavailable + + def close(self) -> None: + """Discard primed counters so a later start establishes a fresh baseline.""" + + self._previous = {"ethernet": {}, "rdma": {}} + self._started = False + + def _snapshots( + self, + ) -> dict[ResourceNetworkKind, dict[str, _InterfaceCounter]]: + rdma_netdevs = self._rdma_netdev_names() + return { + "ethernet": self._ethernet_snapshot(rdma_netdevs), + "rdma": self._rdma_snapshot(), + } + + def _ethernet_snapshot( + self, + rdma_netdevs: set[str], + ) -> dict[str, _InterfaceCounter]: + net_io = getattr(self._psutil, "net_io_counters", None) + net_stats = getattr(self._psutil, "net_if_stats", None) + if not callable(net_io) or not callable(net_stats): + return {} + try: + counters = net_io(pernic=True) + statistics = net_stats() + except (OSError, RuntimeError): + return {} + snapshot: dict[str, _InterfaceCounter] = {} + for interface, counter in counters.items(): + stats = statistics.get(interface) + if not self._is_ethernet_interface(interface, stats, rdma_netdevs): + continue + speed_mbps = float(stats.speed) + snapshot[interface] = _InterfaceCounter( + receive_bytes=max(0, int(counter.bytes_recv)), + transmit_bytes=max(0, int(counter.bytes_sent)), + capacity_bytes_per_second=speed_mbps * 1_000_000.0 / 8.0, + ) + return snapshot + + def _is_ethernet_interface( + self, + interface: str, + stats: Any, + rdma_netdevs: set[str], + ) -> bool: + if ( + interface == "lo" + or interface in rdma_netdevs + or stats is None + or not bool(stats.isup) + or float(stats.speed) <= 0 + ): + return False + if not IS_LINUX: + return True + interface_root = self._net_class_root / interface + if (interface_root / "master").exists(): + return False + return (interface_root / "device").exists() or ( + interface_root / "bonding" + ).exists() + + def _rdma_netdev_names(self) -> set[str]: + if not IS_LINUX or not self._infiniband_root.is_dir(): + return set() + names: set[str] = set() + for device in self._glob(self._infiniband_root, "*"): + net_root = device / "device" / "net" + for netdev in self._glob(net_root, "*"): + names.add(netdev.name) + return names + + def _rdma_snapshot(self) -> dict[str, _InterfaceCounter]: + if not IS_LINUX or not self._infiniband_root.is_dir(): + return {} + snapshot: dict[str, _InterfaceCounter] = {} + for device in self._glob(self._infiniband_root, "*"): + netdevs = sorted( + path.name for path in self._glob(device / "device" / "net", "*") + ) + for port in self._glob(device / "ports", "*"): + if not self._rdma_port_is_active(port): + continue + receive_words = self._read_non_negative_int( + port / "counters" / "port_rcv_data" + ) + transmit_words = self._read_non_negative_int( + port / "counters" / "port_xmit_data" + ) + if receive_words is None or transmit_words is None: + continue + identifier = f"{device.name}/port:{port.name}" + if netdevs: + identifier = f"{identifier}@{','.join(netdevs)}" + snapshot[identifier] = _InterfaceCounter( + receive_bytes=receive_words * _RDMA_COUNTER_BYTES, + transmit_bytes=transmit_words * _RDMA_COUNTER_BYTES, + capacity_bytes_per_second=self._read_rate_bytes_per_second( + port / "rate" + ), + ) + return snapshot + + @staticmethod + def _aggregate( + kind: ResourceNetworkKind, + *, + previous: dict[str, _InterfaceCounter], + current: dict[str, _InterfaceCounter], + elapsed_seconds: float, + ) -> ResourceNetworkSample: + receive_delta = 0 + transmit_delta = 0 + for identifier, counter in current.items(): + prior = previous.get(identifier) + if prior is None: + continue + receive_delta += max(0, counter.receive_bytes - prior.receive_bytes) + transmit_delta += max(0, counter.transmit_bytes - prior.transmit_bytes) + capacities = [counter.capacity_bytes_per_second for counter in current.values()] + capacity = ( + sum(value for value in capacities if value is not None) + if all(value is not None for value in capacities) + else None + ) + return ResourceNetworkSample( + kind=kind, + receive_bytes_per_second=receive_delta / elapsed_seconds, + transmit_bytes_per_second=transmit_delta / elapsed_seconds, + capacity_bytes_per_second=capacity, + interfaces=sorted(current), + ) + + @staticmethod + def _rdma_port_is_active(port: Path) -> bool: + state = NetworkCollector._read_text(port / "state") + return state is None or state.strip().upper().endswith("ACTIVE") + + @staticmethod + def _read_non_negative_int(path: Path) -> int | None: + text = NetworkCollector._read_text(path) + if text is None: + return None + try: + return max(0, int(text.strip())) + except ValueError: + return None + + @staticmethod + def _read_rate_bytes_per_second(path: Path) -> float | None: + text = NetworkCollector._read_text(path) + if text is None: + return None + match = _RATE_PATTERN.match(text) + if match is None: + return None + bits_per_second = ( + float(match.group(1)) * _RATE_MULTIPLIERS[match.group(2).upper()] + ) + return bits_per_second / 8.0 if bits_per_second > 0 else None + + @staticmethod + def _read_text(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + + @staticmethod + def _glob(root: Path, pattern: str) -> list[Path]: + try: + return sorted(root.glob(pattern)) + except OSError: + return [] diff --git a/src/aiperf/resource_telemetry/nvml.py b/src/aiperf/resource_telemetry/nvml.py new file mode 100644 index 0000000000..43b6a06c15 --- /dev/null +++ b/src/aiperf/resource_telemetry/nvml.py @@ -0,0 +1,319 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""NVIDIA device and per-process attribution for resource telemetry.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import suppress +from typing import Any + +from aiperf.resource_telemetry.errors import ResourceCollectionError +from aiperf.resource_telemetry.models import ResourceGPUSample + + +def _decoded(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value or "") + + +class NVMLCollector: + """Collect whole-device facts and attribute supported values to PID sets.""" + + def __init__( + self, + module: Any, + *, + wall_clock_ns: Callable[[], int], + ) -> None: + self._module = module + self._wall_clock_ns = wall_clock_ns + error_type = getattr(module, "NVMLError", RuntimeError) + self._error_type: type[BaseException] = ( + error_type + if isinstance(error_type, type) and issubclass(error_type, BaseException) + else RuntimeError + ) + self._handles: list[Any] = [] + self._last_process_sample_us: dict[int, int] = {} + self._started = False + + def start(self, *, require_gpu: bool) -> None: + """Initialize NVML and cache handles for all physical devices.""" + + try: + self._module.nvmlInit() + self._started = True + count = int(self._module.nvmlDeviceGetCount()) + self._handles = [ + self._module.nvmlDeviceGetHandleByIndex(index) for index in range(count) + ] + except self._error_type as exc: + self.close() + if require_gpu: + raise ResourceCollectionError( + f"NVML initialization failed: {type(exc).__name__}: {exc}" + ) from exc + return + if require_gpu and not self._handles: + self.close() + raise ResourceCollectionError("NVML reported no NVIDIA GPUs") + now_us = self._wall_clock_ns() // 1_000 + self._last_process_sample_us = { + index: max(0, now_us - 1_000_000) for index in range(count) + } + + def close(self) -> None: + """Close the process-local NVML session.""" + + if self._started: + with suppress(self._error_type): + self._module.nvmlShutdown() + self._started = False + self._handles = [] + + def sample( + self, + process_ids: set[int], + *, + container_process_ids: frozenset[int] | None = None, + visible_gpu_tokens: frozenset[str] | None = None, + ) -> tuple[list[ResourceGPUSample], list[str]]: + """Sample all devices and attribute supported counters to both PID sets.""" + + samples: list[ResourceGPUSample] = [] + unavailable: list[str] = [] + resolved_visible_device = False + for index, handle in enumerate(self._handles): + sample, missing = self._sample_device( + index, + handle, + process_ids, + container_process_ids=container_process_ids, + visible_gpu_tokens=visible_gpu_tokens, + ) + samples.append(sample) + unavailable.extend(missing) + resolved_visible_device |= sample.container_visible is True + if ( + visible_gpu_tokens + and "all" not in visible_gpu_tokens + and not resolved_visible_device + ): + unavailable.extend( + ( + "resource.gpu:container_total:visibility_unresolved", + "resource.gpu_memory:container_total:visibility_unresolved", + ) + ) + return samples, unavailable + + def _sample_device( + self, + index: int, + handle: Any, + process_ids: set[int], + *, + container_process_ids: frozenset[int] | None, + visible_gpu_tokens: frozenset[str] | None, + ) -> tuple[ResourceGPUSample, list[str]]: + utilization, memory, uuid, name = self._device_facts(index, handle) + memory_by_pid = self._process_memory_by_pid(handle) + utilization_by_pid = self._process_utilization_by_pid(index, handle) + process_memory = self._sum_memory(memory_by_pid, process_ids) + process_utilization = self._sum_utilization(utilization_by_pid, process_ids) + unavailable: list[str] = [] + if process_memory is None: + unavailable.append(f"resource.gpu_memory:process_used:gpu:{index}") + if process_utilization is None: + unavailable.append(f"resource.gpu:process_used:gpu:{index}") + container_visible = self._container_visible(index, uuid, visible_gpu_tokens) + container_memory, container_utilization, container_missing = ( + self._container_attribution( + index, + visible=container_visible, + process_ids=container_process_ids, + memory_by_pid=memory_by_pid, + utilization_by_pid=utilization_by_pid, + ) + ) + unavailable.extend(container_missing) + return ( + ResourceGPUSample( + device=f"gpu:{index}", + uuid=uuid, + name=name, + process_utilization_percent=process_utilization, + device_utilization_percent=float(utilization.gpu), + process_memory_used_bytes=process_memory, + device_memory_used_bytes=int(memory.used), + device_memory_total_bytes=int(memory.total), + container_visible=container_visible, + container_utilization_percent=container_utilization, + container_memory_used_bytes=container_memory, + ), + unavailable, + ) + + def _device_facts(self, index: int, handle: Any) -> tuple[Any, Any, str, str]: + try: + utilization = self._module.nvmlDeviceGetUtilizationRates(handle) + memory = self._module.nvmlDeviceGetMemoryInfo(handle) + uuid = _decoded(self._module.nvmlDeviceGetUUID(handle)) + name = _decoded(self._module.nvmlDeviceGetName(handle)) + except self._error_type as exc: + raise ResourceCollectionError( + f"NVML device sampling failed for gpu:{index}: {exc}" + ) from exc + return utilization, memory, uuid, name + + def _container_attribution( + self, + index: int, + *, + visible: bool | None, + process_ids: frozenset[int] | None, + memory_by_pid: dict[int, int] | None, + utilization_by_pid: dict[int, float] | None, + ) -> tuple[int | None, float | None, list[str]]: + if visible is not True or process_ids is None: + return None, None, [] + memory = self._sum_memory(memory_by_pid, process_ids) + utilization = self._sum_utilization(utilization_by_pid, process_ids) + unavailable: list[str] = [] + if memory is None: + unavailable.append(f"resource.gpu_memory:container_used:gpu:{index}") + if utilization is None: + unavailable.append(f"resource.gpu:container_used:gpu:{index}") + return memory, utilization, unavailable + + def _process_memory_by_pid(self, handle: Any) -> dict[int, int] | None: + records: dict[int, int] = {} + supported = False + for family in ("Compute", "Graphics"): + function = self._process_function(family) + if function is None: + continue + processes = self._running_processes(function, handle) + if processes is None: + continue + supported = True + for process in processes: + pid = int(getattr(process, "pid", -1)) + raw = getattr( + process, + "usedGpuMemory", + getattr(process, "used_gpu_memory", None), + ) + unavailable = getattr( + self._module, + "NVML_VALUE_NOT_AVAILABLE", + None, + ) + if ( + raw is None + or int(raw) < 0 + or unavailable is not None + and int(raw) == int(unavailable) + ): + continue + records[pid] = max(records.get(pid, 0), int(raw)) + return records if supported else None + + def _running_processes( + self, + function: Callable[[Any], Any], + handle: Any, + ) -> Any | None: + try: + return function(handle) + except self._error_type: + return None + + def _process_function(self, family: str) -> Callable[[Any], Any] | None: + for suffix in ("_v3", "_v2", ""): + function = getattr( + self._module, + f"nvmlDeviceGet{family}RunningProcesses{suffix}", + None, + ) + if callable(function): + return function + return None + + def _process_utilization_by_pid( + self, + index: int, + handle: Any, + ) -> dict[int, float] | None: + function = getattr( + self._module, + "nvmlDeviceGetProcessUtilization", + None, + ) + if not callable(function): + return None + last_seen_us = self._last_process_sample_us[index] + try: + records = function(handle, last_seen_us) + except self._error_type as exc: + if type(exc).__name__ == "NVMLError_NotFound": + self._last_process_sample_us[index] = self._wall_clock_ns() // 1_000 + return {} + return None + now_us = self._wall_clock_ns() // 1_000 + newest: dict[int, tuple[int, float]] = {} + maximum_timestamp = last_seen_us + for record in records: + timestamp = int(getattr(record, "timeStamp", now_us)) + maximum_timestamp = max(maximum_timestamp, timestamp) + pid = int(getattr(record, "pid", -1)) + sm_utilization = float(getattr(record, "smUtil", 0.0)) + previous = newest.get(pid) + if previous is None or timestamp >= previous[0]: + newest[pid] = (timestamp, sm_utilization) + self._last_process_sample_us[index] = max(maximum_timestamp, now_us) + return {pid: value for pid, (_, value) in newest.items()} + + @staticmethod + def _sum_memory( + values: dict[int, int] | None, + process_ids: set[int] | frozenset[int], + ) -> int | None: + if values is None: + return None + return sum(value for pid, value in values.items() if pid in process_ids) + + @staticmethod + def _sum_utilization( + values: dict[int, float] | None, + process_ids: set[int] | frozenset[int], + ) -> float | None: + if values is None: + return None + return min( + 100.0, + sum(value for pid, value in values.items() if pid in process_ids), + ) + + @staticmethod + def _container_visible( + index: int, + uuid: str, + visible_gpu_tokens: frozenset[str] | None, + ) -> bool | None: + if visible_gpu_tokens is None: + return None + normalized = {token.strip().lower() for token in visible_gpu_tokens} + if "all" in normalized: + return True + if str(index) in normalized: + return True + gpu_uuid = uuid.strip().lower() + return any( + gpu_uuid == token or gpu_uuid.startswith(token) + for token in normalized + if token + ) diff --git a/src/aiperf/resource_telemetry/streaming.py b/src/aiperf/resource_telemetry/streaming.py new file mode 100644 index 0000000000..28162cb70e --- /dev/null +++ b/src/aiperf/resource_telemetry/streaming.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Stream-profile integration for the common active resource agent.""" + +from __future__ import annotations + +import socket +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from aiperf.resource_telemetry.agent import ResourceTelemetryAgent +from aiperf.resource_telemetry.models import ( + ResourceRunDescriptor, + ResourceRunStatus, +) + +if TYPE_CHECKING: + from aiperf.observability.models import ObservationRunIdentity + from aiperf.streaming.config import StreamProfileConfig + from aiperf.streaming.contracts import BenchmarkContract + from aiperf.streaming.runner import StreamRunResult + + +async def start_stream_resource_agent( + *, + config: StreamProfileConfig, + contract: BenchmarkContract, + identity: ObservationRunIdentity, + benchmark_id: str, + artifacts_dir: Path, + target_metadata: dict[str, Any], + started_at_ns: int, +) -> ResourceTelemetryAgent | None: + """Build and start one resource agent from a validated stream profile.""" + + settings = config.resource_telemetry + if not settings.enabled: + return None + if settings.history_url is None or settings.target_pid is None: + raise ValueError( + "enabled resource telemetry requires history_url and target_pid" + ) + descriptor = ResourceRunDescriptor( + benchmark_id=benchmark_id, + run_kind="stream", + started_at_ns=started_at_ns, + implementation=contract.implementation, + model=contract.model, + model_family=contract.model_family, + mode=contract.mode, + scene=contract.model_family, + task=str(contract.workload.get("task") or config.task or ""), + transport=contract.transport, + root_pid=settings.target_pid, + hostname=socket.gethostname(), + contract_digest=identity.contract_digest or "", + artifact_path=str(artifacts_dir), + tags={"contract_name": contract.name}, + config=config.model_dump(mode="json"), + metadata={"target": target_metadata}, + ) + agent = ResourceTelemetryAgent( + history_url=settings.history_url, + run=descriptor, + sample_interval_s=float(settings.sample_interval_s), + upload_interval_s=float(settings.upload_interval_s), + request_timeout_s=float(settings.request_timeout_s), + retry_count=settings.retry_count, + max_buffered_samples=settings.max_buffered_samples, + require_gpu=settings.require_gpu, + ) + await agent.start() + return agent + + +def resource_run_status(result: StreamRunResult) -> ResourceRunStatus: + """Map measured-session outcomes onto the live run's terminal state.""" + + attempted = len(result.profile_results) + successful = sum(item.success for item in result.profile_results) + if attempted and successful == attempted: + return "completed" + if successful: + return "partial" + return "failed" diff --git a/src/aiperf/server_metrics/standalone.py b/src/aiperf/server_metrics/standalone.py new file mode 100644 index 0000000000..6d97cfe9f2 --- /dev/null +++ b/src/aiperf/server_metrics/standalone.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import aiofiles +import aiofiles.os +import orjson + +from aiperf.common.enums import ServerMetricsFormat +from aiperf.common.finite import scrub_non_finite +from aiperf.common.models import ErrorDetails +from aiperf.common.models.error_models import ErrorDetailsCount +from aiperf.common.models.server_metrics_models import ( + ServerMetricsRecord, + ServerMetricsResults, +) +from aiperf.common.redact import redact_url +from aiperf.server_metrics.accumulator import ServerMetricsAccumulator +from aiperf.server_metrics.data_collector import ServerMetricsDataCollector + + +@dataclass(slots=True) +class _StandaloneServerMetricsSettings: + enabled: bool = True + formats: list[ServerMetricsFormat] = field(default_factory=list) + + +@dataclass(slots=True) +class _StandaloneArtifactSettings: + slice_duration: float | None = None + + +@dataclass(slots=True) +class _StandaloneBenchmarkConfig: + server_metrics: _StandaloneServerMetricsSettings = field( + default_factory=_StandaloneServerMetricsSettings + ) + artifacts: _StandaloneArtifactSettings = field( + default_factory=_StandaloneArtifactSettings + ) + + +@dataclass(slots=True) +class _StandaloneRun: + benchmark_id: str + cfg: _StandaloneBenchmarkConfig = field(default_factory=_StandaloneBenchmarkConfig) + + +class _RawRecordWriter: + def __init__(self, path: Path) -> None: + self.path = path + self._file: Any = None + self._lock = asyncio.Lock() + + async def start(self) -> None: + await aiofiles.os.makedirs(self.path.parent, exist_ok=True) + self._file = await aiofiles.open(self.path, "wb") + + async def write(self, record: ServerMetricsRecord) -> None: + if self._file is None or record.is_duplicate: + return + async with self._lock: + await self._file.write( + orjson.dumps(scrub_non_finite(record.to_slim().model_dump(mode="json"))) + + b"\n" + ) + + async def close(self) -> None: + if self._file is not None: + await self._file.close() + self._file = None + + +class StandaloneServerMetricsCollector: + """Run existing AIPerf Prometheus collectors around a non-HTTP workload.""" + + def __init__( + self, + *, + benchmark_id: str, + urls: list[str], + collection_interval_s: float, + reachability_timeout_s: float, + raw_jsonl_path: str | Path | None = None, + ) -> None: + self.benchmark_id = benchmark_id + self.urls = list(dict.fromkeys(urls)) + self.collection_interval_s = collection_interval_s + self.reachability_timeout_s = reachability_timeout_s + self._accumulator = ServerMetricsAccumulator( + _StandaloneRun(benchmark_id=benchmark_id) + ) + self._writer = ( + _RawRecordWriter(Path(raw_jsonl_path)) + if raw_jsonl_path is not None + else None + ) + self._collectors: dict[str, ServerMetricsDataCollector] = {} + self._successful: set[str] = set() + self._errors: Counter[ErrorDetails] = Counter() + + async def start(self) -> None: + """Probe endpoints, capture a baseline, and start periodic collection.""" + + if self._writer is not None: + await self._writer.start() + for url in self.urls: + display_url = redact_url(url) + collector = ServerMetricsDataCollector( + endpoint_url=url, + collection_interval=self.collection_interval_s, + reachability_timeout=self.reachability_timeout_s, + record_callback=self._on_records, + error_callback=self._on_error, + collector_id=display_url, + ) + await collector.initialize() + if not await collector.is_url_reachable(): + self._errors[ + ErrorDetails( + type="EndpointUnreachable", + message=f"Server metrics endpoint is unreachable: {display_url}", + ) + ] += 1 + await collector.stop() + continue + self._collectors[display_url] = collector + await collector.collect_and_process_metrics() + await collector.start() + + async def finalize(self, *, start_ns: int, end_ns: int) -> ServerMetricsResults: + """Capture final snapshots, stop collectors, and aggregate the window.""" + + for collector in self._collectors.values(): + await collector.collect_and_process_metrics() + for collector in self._collectors.values(): + await collector.stop() + if self._writer is not None: + await self._writer.close() + error_summary = [ + ErrorDetailsCount(error_details=error, count=count) + for error, count in self._errors.items() + ] + results = await self._accumulator.export_results( + start_ns=start_ns, + end_ns=end_ns, + error_summary=error_summary, + ) + if results is None: + results = ServerMetricsResults( + benchmark_id=self.benchmark_id, + endpoint_summaries={}, + start_ns=start_ns, + end_ns=end_ns, + error_summary=error_summary, + ) + results.endpoints_configured = [redact_url(url) for url in self.urls] + results.endpoints_successful = sorted(self._successful) + return results + + async def abort(self) -> None: + """Best-effort cleanup when a workload exits before final aggregation.""" + + for collector in self._collectors.values(): + await collector.stop() + if self._writer is not None: + await self._writer.close() + + async def _on_records( + self, + records: list[ServerMetricsRecord], + collector_id: str, + ) -> None: + for record in records: + await self._accumulator.process_server_metrics_record(record) + if self._writer is not None: + await self._writer.write(record) + if records: + self._successful.add(collector_id) + + async def _on_error(self, error: ErrorDetails, collector_id: str) -> None: + self._errors[ + ErrorDetails( + code=error.code, + type=error.type, + message=f"Server metrics collection failed: {collector_id}", + ) + ] += 1 diff --git a/src/aiperf/streaming/__init__.py b/src/aiperf/streaming/__init__.py new file mode 100644 index 0000000000..8b4aec1d57 --- /dev/null +++ b/src/aiperf/streaming/__init__.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Streaming benchmark contracts and shared session models.""" + +from aiperf.streaming.adapters import ( + create_stream_adapter, + register_stream_adapter, +) +from aiperf.streaming.config import ( + StreamObservabilityConfig, + StreamProfileConfig, + StreamServerMetricsConfig, + StreamTransportOptions, + load_stream_profile_config, +) +from aiperf.streaming.contracts import ( + REQUIRED_BENCHMARK_CONTRACT_FIELDS, + BenchmarkContract, + benchmark_contract_digest, + load_benchmark_contract, + validate_benchmark_contract, + validate_benchmark_contract_file, + validate_benchmark_contract_model, +) +from aiperf.streaming.measurements import ( + StreamChunkMeasurement, + StreamDeviceMemoryMeasurement, + StreamPhaseMeasurement, + summarise_values, +) +from aiperf.streaming.models import ( + ControlEventResult, + SessionResult, + StreamControlTraceEntry, + StreamEndpointPaths, + StreamSessionPlan, + build_stream_summary, + load_control_trace, + session_result_to_dict, +) +from aiperf.streaming.profile import ( + StreamProfileResult, + run_stream_profile, + run_stream_profile_from_path, +) +from aiperf.streaming.protocols import ( + ManagedStreamTransportAdapterProtocol, + StreamMetricsCollectorProtocol, + StreamPhaseLifecycleProtocol, + StreamResultsCollectorProtocol, + StreamTargetMetadataProviderProtocol, + StreamTransportAdapterProtocol, + StreamWorkloadProtocol, +) +from aiperf.streaming.report import StreamReportExporter +from aiperf.streaming.runner import ( + StreamBenchmarkRunner, + StreamRunPlan, + StreamRunResult, +) +from aiperf.streaming.transports import ( + STREAM_TRANSPORT_DESCRIPTORS, + StreamTransportDescriptor, + get_stream_transport_descriptor, +) +from aiperf.streaming.workload import ContractStreamWorkload + +__all__ = [ + "BenchmarkContract", + "ContractStreamWorkload", + "ControlEventResult", + "ManagedStreamTransportAdapterProtocol", + "REQUIRED_BENCHMARK_CONTRACT_FIELDS", + "STREAM_TRANSPORT_DESCRIPTORS", + "SessionResult", + "StreamBenchmarkRunner", + "StreamChunkMeasurement", + "StreamControlTraceEntry", + "StreamDeviceMemoryMeasurement", + "StreamEndpointPaths", + "StreamMetricsCollectorProtocol", + "StreamObservabilityConfig", + "StreamPhaseLifecycleProtocol", + "StreamPhaseMeasurement", + "StreamProfileConfig", + "StreamProfileResult", + "StreamReportExporter", + "StreamResultsCollectorProtocol", + "StreamRunPlan", + "StreamRunResult", + "StreamServerMetricsConfig", + "StreamSessionPlan", + "StreamTargetMetadataProviderProtocol", + "StreamTransportAdapterProtocol", + "StreamTransportDescriptor", + "StreamTransportOptions", + "StreamWorkloadProtocol", + "benchmark_contract_digest", + "build_stream_summary", + "create_stream_adapter", + "get_stream_transport_descriptor", + "load_benchmark_contract", + "load_control_trace", + "load_stream_profile_config", + "register_stream_adapter", + "run_stream_profile", + "run_stream_profile_from_path", + "session_result_to_dict", + "summarise_values", + "validate_benchmark_contract", + "validate_benchmark_contract_file", + "validate_benchmark_contract_model", +] diff --git a/src/aiperf/streaming/adapters/__init__.py b/src/aiperf/streaming/adapters/__init__.py new file mode 100644 index 0000000000..50a840b769 --- /dev/null +++ b/src/aiperf/streaming/adapters/__init__.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from aiperf.streaming.config import StreamProfileConfig +from aiperf.streaming.contracts import BenchmarkContract +from aiperf.streaming.protocols import ManagedStreamTransportAdapterProtocol + +StreamAdapterFactory = Callable[..., ManagedStreamTransportAdapterProtocol] +_ADAPTER_FACTORIES: dict[str, StreamAdapterFactory] = {} +_BUILTINS_REGISTERED = False + + +def register_stream_adapter( + name: str, + factory: StreamAdapterFactory, + *, + replace: bool = False, +) -> None: + """Register a reusable stream adapter factory by contract name.""" + + if name in _ADAPTER_FACTORIES and not replace: + raise ValueError(f"Stream adapter {name!r} is already registered") + _ADAPTER_FACTORIES[name] = factory + + +def _register_builtins() -> None: + global _BUILTINS_REGISTERED + if _BUILTINS_REGISTERED: + return + from aiperf.streaming.adapters.sglang_websocket import ( + SGLangWebSocketAdapter, + ) + from aiperf.streaming.adapters.telefuser_webrtc import ( + TeleFuserWebRTCAdapter, + ) + + if "sglang_websocket" not in _ADAPTER_FACTORIES: + register_stream_adapter("sglang_websocket", SGLangWebSocketAdapter) + if "telefuser_webrtc" not in _ADAPTER_FACTORIES: + register_stream_adapter("telefuser_webrtc", TeleFuserWebRTCAdapter) + _BUILTINS_REGISTERED = True + + +def create_stream_adapter( + *, + contract: BenchmarkContract, + config: StreamProfileConfig, + artifacts_dir: str | Path, +) -> ManagedStreamTransportAdapterProtocol: + """Create the adapter selected explicitly by config or target contract.""" + + _register_builtins() + name = config.adapter or contract.adapter + if name is None: + raise ValueError("Stream profile requires an adapter in config or contract") + try: + factory = _ADAPTER_FACTORIES[name] + except KeyError as exc: + available = ", ".join(sorted(_ADAPTER_FACTORIES)) + raise ValueError( + f"Unknown stream adapter {name!r}. Available: {available}" + ) from exc + adapter = factory(contract=contract, config=config, artifacts_dir=artifacts_dir) + if adapter.transport != contract.transport: + raise ValueError( + f"Stream adapter {name!r} uses transport {adapter.transport!r}, " + f"but contract declares {contract.transport!r}" + ) + return adapter + + +__all__ = [ + "create_stream_adapter", + "register_stream_adapter", +] diff --git a/src/aiperf/streaming/adapters/common.py b/src/aiperf/streaming/adapters/common.py new file mode 100644 index 0000000000..4f65aca50b --- /dev/null +++ b/src/aiperf/streaming/adapters/common.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +import aiohttp +import orjson + + +class StreamHttpClient: + """Small shared aiohttp client for stream health and signaling requests.""" + + def __init__(self, *, session: aiohttp.ClientSession | None = None) -> None: + self._session = session + self._owns_session = session is None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + self._owns_session = True + return self._session + + async def request_json( + self, + url: str, + *, + method: str, + timeout_s: float, + payload: dict[str, Any] | None = None, + accepted_error_statuses: tuple[int, ...] = (), + ) -> dict[str, Any]: + """Send one JSON request and require an object response.""" + + session = await self._get_session() + data = None if payload is None else orjson.dumps(payload) + headers = {"Content-Type": "application/json"} if data is not None else None + timeout = aiohttp.ClientTimeout(total=timeout_s) + async with session.request( + method, + url, + data=data, + headers=headers, + timeout=timeout, + ) as response: + body = await response.read() + if ( + response.status >= 400 + and response.status not in accepted_error_statuses + ): + detail = body.decode(errors="replace")[:1024] + raise RuntimeError(f"HTTP {response.status} from target: {detail}") + if not body: + return {} + decoded = orjson.loads(body) + if not isinstance(decoded, dict): + raise ValueError("Target JSON response must be an object") + return decoded + + async def check_health(self, url: str, *, timeout_s: float) -> None: + """Require a successful target health response.""" + + session = await self._get_session() + timeout = aiohttp.ClientTimeout(total=timeout_s) + async with session.get(url, timeout=timeout) as response: + await response.read() + if response.status >= 400: + raise RuntimeError( + f"Target health check returned HTTP {response.status}" + ) + + async def aclose(self) -> None: + """Close the owned aiohttp session.""" + + if self._owns_session and self._session is not None: + await self._session.close() + self._session = None + + +class StreamTargetMetadataMixin: + """Fetch optional target metadata through a contract-declared endpoint.""" + + contract: Any + config: Any + options: Any + http: StreamHttpClient + + async def collect_target_metadata(self) -> dict[str, Any]: + metadata_path = self.contract.endpoint.get("metadata_path") + if metadata_path is None: + return {} + return await self.http.request_json( + f"{self.config.server_url}{metadata_path}", + method="GET", + timeout_s=float(self.options.connect_timeout_s), + ) + + +def websocket_url(server_url: str, path: str) -> str: + """Build a WebSocket URL from an HTTP or WebSocket server base URL.""" + + base = server_url.rstrip("/") + if base.startswith("https://"): + base = "wss://" + base[len("https://") :] + elif base.startswith("http://"): + base = "ws://" + base[len("http://") :] + elif not base.startswith(("ws://", "wss://")): + raise ValueError(f"Unsupported stream server URL scheme: {server_url}") + return f"{base}{path}" diff --git a/src/aiperf/streaming/adapters/sglang_measurements.py b/src/aiperf/streaming/adapters/sglang_measurements.py new file mode 100644 index 0000000000..ea70ad6108 --- /dev/null +++ b/src/aiperf/streaming/adapters/sglang_measurements.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +_OPTIONAL_DURATION_FIELDS = { + "request_prepare_ms": "request_prepare_seconds", + "raw_payload_build_ms": "encode_seconds", + "pace_wait_ms": "output_pacing_seconds", + "header_write_ms": "output_header_write_seconds", + "raw_write_ms": "output_payload_write_seconds", + "ws_write_ms": "output_write_seconds", + "chunk_total_ms": "total_seconds", +} + +_OPTIONAL_COUNT_FIELDS = { + "raw_bytes": "raw_output_bytes", + "ws_payload_bytes": "wire_output_bytes", + "num_batches": "output_batches", +} + + +def _milliseconds_to_seconds(value: Any) -> float: + return float(value) / 1000.0 + + +def build_sglang_chunk_measurement( + payload: Mapping[str, Any], +) -> dict[str, Any] | None: + """Map native SGLang-Diffusion chunk stats to the shared stream model.""" + + required = ("chunk_index", "num_frames", "scheduler_forward_ms") + if any(payload.get(field) is None for field in required): + return None + + measurement: dict[str, Any] = { + "index": int(payload["chunk_index"]), + "frames": int(payload["num_frames"]), + "compute_seconds": _milliseconds_to_seconds(payload["scheduler_forward_ms"]), + } + for source, target in _OPTIONAL_DURATION_FIELDS.items(): + if payload.get(source) is not None: + measurement[target] = _milliseconds_to_seconds(payload[source]) + for source, target in _OPTIONAL_COUNT_FIELDS.items(): + if payload.get(source) is not None: + measurement[target] = int(payload[source]) + if payload.get("content_type") is not None: + measurement["output_content_type"] = str(payload["content_type"]) + if payload.get("peak_memory_mb") is not None: + measurement["memory"] = [ + { + "device": str(payload.get("memory_device") or "cuda:0"), + "peak_reserved_bytes": round( + float(payload["peak_memory_mb"]) * 1024 * 1024 + ), + } + ] + return measurement diff --git a/src/aiperf/streaming/adapters/sglang_websocket.py b/src/aiperf/streaming/adapters/sglang_websocket.py new file mode 100644 index 0000000000..704f310e7b --- /dev/null +++ b/src/aiperf/streaming/adapters/sglang_websocket.py @@ -0,0 +1,479 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import importlib +import time +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +import aiofiles +import msgspec.msgpack + +from aiperf.common.path_safety import safe_resolve_regular_file_path +from aiperf.common.redact import redact_string +from aiperf.streaming.adapters.common import ( + StreamHttpClient, + StreamTargetMetadataMixin, + websocket_url, +) +from aiperf.streaming.adapters.sglang_measurements import ( + build_sglang_chunk_measurement, +) +from aiperf.streaming.adapters.target_measurements import record_target_measurement +from aiperf.streaming.config import StreamProfileConfig +from aiperf.streaming.contracts import BenchmarkContract +from aiperf.streaming.events import StreamEventRecorder +from aiperf.streaming.models import ( + ControlEventResult, + SessionResult, + StreamSessionPlan, +) + +KEY_TO_ACTION = { + "ArrowUp": "w", + "ArrowDown": "s", + "ArrowLeft": "a", + "ArrowRight": "d", + "KeyW": "w", + "KeyA": "a", + "KeyS": "s", + "KeyD": "d", + "KeyI": "i", + "KeyJ": "j", + "KeyK": "k", + "KeyL": "l", + "w": "w", + "a": "a", + "s": "s", + "d": "d", + "i": "i", + "j": "j", + "k": "k", + "l": "l", +} +ACTION_ORDER = ("w", "a", "s", "d", "i", "j", "k", "l") +PRESS_ACTIONS = {"press", "down", "keydown", "hold"} +RELEASE_ACTIONS = {"release", "up", "keyup"} + + +def _ordered_actions(held_actions: set[str]) -> list[str]: + return [action for action in ACTION_ORDER if action in held_actions] + + +def apply_control_message( + message: dict[str, Any], + held_actions: set[str], +) -> list[str]: + """Translate a transport-neutral key transition into SGLang action state.""" + + explicit_actions = message.get("actions") + if isinstance(explicit_actions, list): + held_actions.clear() + for action in explicit_actions: + token = KEY_TO_ACTION.get(str(action), str(action)) + if token in ACTION_ORDER: + held_actions.add(token) + return _ordered_actions(held_actions) + + key = message.get("key") or message.get("control") or message.get("direction") + action = str(message.get("action", "press")).lower() + token = KEY_TO_ACTION.get(str(key)) if key is not None else None + if token is None: + return _ordered_actions(held_actions) + if action in RELEASE_ACTIONS: + held_actions.discard(token) + elif action in PRESS_ACTIONS: + held_actions.add(token) + return _ordered_actions(held_actions) + + +async def _read_first_frame(image_path: str | None) -> bytes | str | None: + if image_path is None: + return None + resolved = safe_resolve_regular_file_path(image_path) + if resolved is None: + return image_path + async with aiofiles.open(resolved, "rb") as file: + return await file.read() + + +class _SGLangSession: + def __init__( + self, + *, + adapter: SGLangWebSocketAdapter, + plan: StreamSessionPlan, + ) -> None: + self.adapter = adapter + self.plan = plan + self.result = SessionResult( + logical_session_index=plan.logical_session_index, + phase=plan.phase, + mode=plan.mode, + planned_session_id=plan.planned_session_id, + session_id=plan.planned_session_id, + ) + self.events = StreamEventRecorder( + artifacts_dir=adapter.artifacts_dir, + phase=plan.phase, + logical_session_index=plan.logical_session_index, + planned_session_id=plan.planned_session_id, + print_events=adapter.config.print_events, + ) + self.started_at = 0.0 + self.active_started_at: float | None = None + self.first_frame_at: float | None = None + self.last_frame_at: float | None = None + self.first_metadata_at: float | None = None + self.control_by_event_id: dict[int, int] = {} + self.control_sent_at: dict[int, float] = {} + self.acknowledged_event_ids: set[int] = set() + self.framed_event_ids: set[int] = set() + + def _active_start(self) -> float: + if self.active_started_at is None: + raise RuntimeError("WebSocket active window has not started") + return self.active_started_at + + async def _build_init_payload(self) -> dict[str, Any]: + reserved = {"type", "model", "prompt", "first_frame", "size", "fps"} + conflicts = sorted(reserved.intersection(self.plan.request_extra)) + if conflicts: + raise ValueError( + "SGLang request_extra cannot override protocol fields: " + + ", ".join(conflicts) + ) + payload = { + "type": "init", + "model": self.adapter.model, + "prompt": self.plan.prompt, + "first_frame": await _read_first_frame(self.plan.image_path), + "size": self.adapter.size, + "fps": self.plan.fps, + **self.plan.request_extra, + } + return {key: value for key, value in payload.items() if value is not None} + + def _mark_first_metadata(self, now: float) -> None: + self.result.metadata_messages += 1 + if self.first_metadata_at is None: + self.first_metadata_at = now + self.result.first_metadata_latency_ms = (now - self.started_at) * 1000.0 + + def _mark_control(self, event_id: int, now: float, *, frame: bool) -> None: + seen = self.framed_event_ids if frame else self.acknowledged_event_ids + if event_id in seen: + return + control_index = self.control_by_event_id.get(event_id) + if control_index is None: + return + control = self.result.control_events[control_index] + latency_ms = (now - self.control_sent_at[event_id]) * 1000.0 + if frame: + control.next_frame_latency_ms = max(latency_ms, 0.0) + else: + control.ack_latency_ms = max(latency_ms, 0.0) + seen.add(event_id) + + async def _send_control_trace(self, websocket: Any) -> None: + held_actions: set[str] = set() + active_started_at = self._active_start() + for event_index, entry in enumerate(self.plan.control_trace): + deadline = active_started_at + float(entry["delay_s"]) + await asyncio.sleep(max(deadline - time.perf_counter(), 0.0)) + message = dict(entry["message"]) + sent_at = time.perf_counter() + payload = { + "type": "event", + "kind": "camera_actions", + "event_id": event_index, + "payload": { + "mode": "state", + "transitions": [ + { + "actions": apply_control_message(message, held_actions), + "client_ts_ms": int((sent_at - active_started_at) * 1000.0), + } + ], + }, + } + self.result.control_events.append( + ControlEventResult( + index=event_index, + scheduled_delay_s=float(entry["delay_s"]), + message={"source": message, "wire": payload}, + sent_offset_s=sent_at - active_started_at, + ) + ) + self.control_by_event_id[event_index] = event_index + self.control_sent_at[event_index] = sent_at + await websocket.send(msgspec.msgpack.encode(payload)) + self.events.record("control_sent", event_id=event_index) + + def _handle_chunk_stats(self, payload: dict[str, Any], now: float) -> int | None: + self._mark_first_metadata(now) + self.result.status_messages += 1 + self.result.last_status_stage = "chunk_stats" + target_session_id = payload.get("session_id") + if isinstance(target_session_id, str) and target_session_id: + self.result.session_id = target_session_id + self.events.set_session_id(target_session_id) + if payload.get("event_id") is not None: + self._mark_control(int(payload["event_id"]), now, frame=False) + normalized = payload + measurement = payload.get("measurement") + if not isinstance(measurement, Mapping): + try: + measurement = build_sglang_chunk_measurement(payload) + except (TypeError, ValueError) as exc: + self.events.record_error("target_measurement_invalid", exc) + measurement = None + if measurement is not None: + normalized = {**payload, "measurement": measurement} + recorded = record_target_measurement( + result=self.result, + events=self.events, + stage=str(payload.get("stage", "chunk")), + data=normalized, + ) + self.events.record( + "chunk_stats", + event_id=payload.get("event_id"), + chunk_index=payload.get("chunk_index"), + measurement_recorded=recorded, + ) + if not recorded or not isinstance(measurement, Mapping): + return None + try: + return int(measurement["index"]) + except (KeyError, TypeError, ValueError): + return None + + def _handle_frame_batch(self, header: dict[str, Any], now: float) -> None: + self._mark_first_metadata(now) + frame_count = max(int(header.get("num_frames", 0) or 0), 1) + self.result.frames_received += frame_count + if self.first_frame_at is None: + self.first_frame_at = now + self.result.first_frame_latency_ms = (now - self.started_at) * 1000.0 + self.events.record("first_frame") + self.last_frame_at = now + if header.get("event_id") is not None: + self._mark_control(int(header["event_id"]), now, frame=True) + self.events.record( + "frame_batch", + chunk_index=header.get("chunk_index"), + event_id=header.get("event_id"), + num_frames=frame_count, + content_type=header.get("content_type"), + payload_bytes=header.get("total_size") or header.get("raw_size"), + ) + + def _reached_chunk_limit( + self, + *, + max_chunks: Any, + received_final_chunks: set[int], + received_chunk_stats: set[int], + ) -> bool: + if max_chunks is None: + return False + limit = int(max_chunks) + if len(received_final_chunks) < limit: + return False + return ( + len(received_chunk_stats) >= limit or not self.adapter.expects_chunk_stats + ) + + async def _receive_message( + self, + websocket: Any, + deadline: float, + ) -> bytes | str | None: + remaining = max(deadline - time.perf_counter(), 0.1) + timeout_s = min(float(self.adapter.options.message_timeout_s), remaining) + try: + return await asyncio.wait_for(websocket.recv(), timeout=timeout_s) + except asyncio.TimeoutError: + if time.perf_counter() >= deadline: + return None + raise + except Exception as exc: + if exc.__class__.__name__.startswith("ConnectionClosed"): + self.result.done_received = True + return None + raise + + async def _receive_loop(self, websocket: Any) -> None: + deadline = self._active_start() + float(self.plan.session_duration_s) + received_final_chunks: set[int] = set() + received_chunk_stats: set[int] = set() + max_chunks = self.plan.request_extra.get("max_chunks") + while time.perf_counter() < deadline: + raw = await self._receive_message(websocket, deadline) + if raw is None: + return + now = time.perf_counter() + raw = raw.encode() if isinstance(raw, str) else raw + header = msgspec.msgpack.decode(raw) + if not isinstance(header, dict): + self.events.record("message_ignored", reason="not_mapping") + continue + terminal, measured_chunk_index = await self._handle_message( + websocket, header, now + ) + if terminal: + return + if measured_chunk_index is not None: + received_chunk_stats.add(measured_chunk_index) + chunk_index = header.get("chunk_index") + if header.get("is_final_frame_batch", True) and chunk_index is not None: + received_final_chunks.add(int(chunk_index)) + if self._reached_chunk_limit( + max_chunks=max_chunks, + received_final_chunks=received_final_chunks, + received_chunk_stats=received_chunk_stats, + ): + self.result.done_received = True + return + + async def _handle_message( + self, + websocket: Any, + header: dict[str, Any], + now: float, + ) -> tuple[bool, int | None]: + message_type = header.get("type") + if message_type == "error": + self.result.error = redact_string(str(header.get("content") or header)) + self.events.record("error_message", error=self.result.error) + return True, None + if message_type == "chunk_stats": + return False, self._handle_chunk_stats(header, now) + if message_type == "frame_batch_header": + await asyncio.wait_for( + websocket.recv(), + timeout=float(self.adapter.options.message_timeout_s), + ) + self._handle_frame_batch(header, time.perf_counter()) + return False, None + if message_type == "frame_batch": + header.pop("payload", None) + self._handle_frame_batch(header, now) + return False, None + self._mark_first_metadata(now) + self.events.record("metadata_message", message_type=message_type) + return False, None + + async def run(self) -> SessionResult: + self.started_at = time.perf_counter() + self.events.record("session_start", mode=self.plan.mode) + control_task: asyncio.Task[None] | None = None + try: + connect = self.adapter.websocket_connect + async with connect( + websocket_url(self.plan.server_url, self.plan.endpoints.offer_path), + max_size=None, + ping_interval=None, + open_timeout=float(self.adapter.options.connect_timeout_s), + ) as websocket: + connected_at = time.perf_counter() + self.result.connected_latency_ms = ( + connected_at - self.started_at + ) * 1000.0 + await websocket.send( + msgspec.msgpack.encode(await self._build_init_payload()) + ) + self.active_started_at = time.perf_counter() + self.events.record("active_window_start") + self.events.record("init_sent") + if self.plan.control_trace: + control_task = asyncio.create_task( + self._send_control_trace(websocket) + ) + await self._receive_loop(websocket) + self._finalize_success() + except asyncio.TimeoutError: + self.result.error = "Timed out waiting for a WebSocket stream message" + self.events.record("session_error", error=self.result.error) + except Exception as exc: # noqa: BLE001 - transport failures become results + self.result.error = redact_string(f"{type(exc).__name__}: {exc}") + self.events.record("session_error", error=self.result.error) + finally: + if control_task is not None and not control_task.done(): + control_task.cancel() + await asyncio.gather(control_task, return_exceptions=True) + self.result.session_runtime_s = time.perf_counter() - self.started_at + event_path = await self.events.export() + self.result.artifacts_event_file = str(event_path) + return self.result + + def _finalize_success(self) -> None: + if self.first_frame_at is None: + self.result.error = self.result.error or "No video frame received" + return + self.result.success = self.result.error is None + if self.last_frame_at is not None and self.last_frame_at > self.first_frame_at: + self.result.stream_fps = (self.result.frames_received - 1) / ( + self.last_frame_at - self.first_frame_at + ) + + +class SGLangWebSocketAdapter(StreamTargetMetadataMixin): + """AIPerf adapter for SGLang-Diffusion realtime MessagePack WebSockets.""" + + transport = "websocket" + + def __init__( + self, + *, + contract: BenchmarkContract, + config: StreamProfileConfig, + artifacts_dir: str | Path, + websocket_connect: Callable[..., Any] | None = None, + http_client: StreamHttpClient | None = None, + ) -> None: + self.contract = contract + self.config = config + self.options = config.transport + self.artifacts_dir = Path(artifacts_dir) + self.model = contract.model + self.size = str(contract.workload.get("size", "832x480")) + self.expects_chunk_stats = ( + contract.result_delivery.get("metadata") == "websocket_chunk_stats" + ) + self.websocket_connect = websocket_connect or self._load_websocket_connect() + self.http = http_client or StreamHttpClient() + + @staticmethod + def _load_websocket_connect() -> Callable[..., Any]: + try: + module = importlib.import_module("websockets") + except ImportError as exc: + raise RuntimeError( + "The SGLang WebSocket adapter requires the 'websockets' package" + ) from exc + return module.connect + + async def check_health(self) -> None: + """Check the contract-declared target health endpoint.""" + + health_path = str(self.contract.endpoint.get("health_path", "/health")) + await self.http.check_health( + f"{self.config.server_url}{health_path}", + timeout_s=float(self.options.connect_timeout_s), + ) + + async def run_session(self, plan: StreamSessionPlan) -> SessionResult: + """Execute one normalized plan over the SGLang WebSocket protocol.""" + + return await _SGLangSession(adapter=self, plan=plan).run() + + async def aclose(self) -> None: + """Close adapter-owned HTTP resources.""" + + await self.http.aclose() diff --git a/src/aiperf/streaming/adapters/target_measurements.py b/src/aiperf/streaming/adapters/target_measurements.py new file mode 100644 index 0000000000..61f7f29f7f --- /dev/null +++ b/src/aiperf/streaming/adapters/target_measurements.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiperf.common.finite import scrub_non_finite +from aiperf.streaming.events import StreamEventRecorder +from aiperf.streaming.measurements import ( + StreamChunkMeasurement, + StreamPhaseMeasurement, +) +from aiperf.streaming.models import SessionResult + + +def record_target_measurement( + *, + result: SessionResult, + events: StreamEventRecorder, + stage: str, + data: Mapping[str, Any], +) -> bool: + """Normalize a bounded target phase or chunk measurement into a session.""" + + measurement = data.get("measurement") + if not isinstance(measurement, Mapping): + return False + try: + if stage == "runtime_ready": + phase = StreamPhaseMeasurement.model_validate( + {"name": "runtime_creation", **measurement} + ) + if not any(item.name == phase.name for item in result.phase_measurements): + result.phase_measurements.append(phase) + runtime = data.get("runtime") + if isinstance(runtime, Mapping): + result.runtime_metadata = scrub_non_finite(dict(runtime)) + events.record("phase_measurement", name=phase.name) + return True + chunk = StreamChunkMeasurement.model_validate(measurement) + if not any(item.index == chunk.index for item in result.chunk_measurements): + result.chunk_measurements.append(chunk) + events.record("chunk_measurement", chunk_index=chunk.index) + return True + except (TypeError, ValueError) as exc: + events.record_error("target_measurement_invalid", exc) + return False diff --git a/src/aiperf/streaming/adapters/telefuser_payload.py b/src/aiperf/streaming/adapters/telefuser_payload.py new file mode 100644 index 0000000000..4d09619dd6 --- /dev/null +++ b/src/aiperf/streaming/adapters/telefuser_payload.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +from aiperf.streaming.models import StreamSessionPlan + + +def build_telefuser_offer_body( + plan: StreamSessionPlan, + local_description: Any, +) -> dict[str, Any]: + """Build a TeleFuser offer without allowing protocol-field overrides.""" + + options = dict(plan.request_extra) + reserved = {"session_id", "sdp", "type", "task", "prompt", "fps", "image_path"} + conflicts = sorted(reserved.intersection(options)) + if conflicts: + raise ValueError( + "WebRTC request_extra cannot override protocol fields: " + + ", ".join(conflicts) + ) + body = { + "session_id": plan.planned_session_id, + "sdp": local_description.sdp, + "type": local_description.type, + "task": plan.task, + "prompt": plan.prompt, + "fps": int(options.get("fps", plan.fps)), + "config": dict(options), + **options, + } + if plan.image_path: + body["image_path"] = plan.image_path + return body diff --git a/src/aiperf/streaming/adapters/telefuser_webrtc.py b/src/aiperf/streaming/adapters/telefuser_webrtc.py new file mode 100644 index 0000000000..c1879d560b --- /dev/null +++ b/src/aiperf/streaming/adapters/telefuser_webrtc.py @@ -0,0 +1,497 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import time +from collections import deque +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +import orjson + +from aiperf.common.redact import redact_string +from aiperf.streaming.adapters import webrtc_ice +from aiperf.streaming.adapters.common import ( + StreamHttpClient, + StreamTargetMetadataMixin, +) +from aiperf.streaming.adapters.target_measurements import record_target_measurement +from aiperf.streaming.adapters.telefuser_payload import build_telefuser_offer_body +from aiperf.streaming.config import StreamProfileConfig +from aiperf.streaming.contracts import BenchmarkContract +from aiperf.streaming.events import StreamEventRecorder +from aiperf.streaming.models import ( + ControlEventResult, + SessionResult, + StreamSessionPlan, +) + + +class _WebRTCSession: + def __init__( + self, + *, + adapter: TeleFuserWebRTCAdapter, + plan: StreamSessionPlan, + ) -> None: + self.adapter = adapter + self.plan = plan + self.session_id = plan.planned_session_id + self.result = SessionResult( + logical_session_index=plan.logical_session_index, + phase=plan.phase, + mode=plan.mode, + planned_session_id=plan.planned_session_id, + session_id=plan.planned_session_id, + ) + self.events = StreamEventRecorder( + artifacts_dir=adapter.artifacts_dir, + phase=plan.phase, + logical_session_index=plan.logical_session_index, + planned_session_id=plan.planned_session_id, + print_events=adapter.config.print_events, + ) + self.peer_connection: Any = None + self.data_channel: Any = None + self.started_at = 0.0 + self.active_started_at: float | None = None + self.first_frame_at: float | None = None + self.last_frame_at: float | None = None + self.first_metadata_at: float | None = None + self.connected_event = asyncio.Event() + self.active_event = asyncio.Event() + self.first_frame_event = asyncio.Event() + self.done_event = asyncio.Event() + self.track_tasks: list[asyncio.Task[None]] = [] + self.control_task: asyncio.Task[None] | None = None + self.pending_control_acks: deque[int] = deque() + self.pending_control_frames: deque[int] = deque() + self.control_sent_at: dict[int, float] = {} + + def _active_start(self) -> float: + if self.active_started_at is None: + raise RuntimeError("WebRTC active window has not started") + return self.active_started_at + + def _try_start_active_window(self) -> None: + if self.active_started_at is not None or not self.connected_event.is_set(): + return + if self.plan.mode == "bidirectional" and ( + self.data_channel is None or self.data_channel.readyState != "open" + ): + return + self.active_started_at = time.perf_counter() + self.active_event.set() + self.events.record("active_window_start") + if ( + self.control_task is None + and self.plan.control_trace + and self.data_channel is not None + ): + self.control_task = asyncio.create_task(self._send_control_trace()) + + async def _consume_video_track(self, track: Any) -> None: + try: + while True: + await track.recv() + now = time.perf_counter() + self.result.frames_received += 1 + if self.first_frame_at is None: + self.first_frame_at = now + self.result.first_frame_latency_ms = ( + now - self.started_at + ) * 1000.0 + self.first_frame_event.set() + self.events.record("first_frame") + self.last_frame_at = now + self._mark_control_frame(now) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - remote track termination is data + self.events.record_error("video_track_ended", exc) + + async def _consume_audio_track(self, track: Any) -> None: + try: + while True: + await track.recv() + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - remote track termination is data + self.events.record_error("audio_track_ended", exc) + + def _mark_control_frame(self, now: float) -> None: + if not self.pending_control_frames: + return + index = self.pending_control_frames.popleft() + control = self.result.control_events[index] + if control.next_frame_latency_ms is None: + control.next_frame_latency_ms = max( + (now - self.control_sent_at[index]) * 1000.0, + 0.0, + ) + + def _handle_data_message(self, raw_message: str) -> None: + now = time.perf_counter() + self.result.metadata_messages += 1 + if self.first_metadata_at is None: + self.first_metadata_at = now + self.result.first_metadata_latency_ms = (now - self.started_at) * 1000.0 + try: + payload = orjson.loads(raw_message) + except orjson.JSONDecodeError: + self.events.record("datachannel_message_invalid") + return + if not isinstance(payload, dict): + self.events.record("datachannel_message_ignored", reason="not_mapping") + return + if payload.get("type") == "done": + self.result.done_received = True + self.done_event.set() + self.events.record("done_message") + return + data = payload.get("data") if isinstance(payload.get("data"), dict) else payload + stage = data.get("stage") + if stage is not None: + self._handle_status_stage(str(stage), data, now) + self.events.record("datachannel_message", message_type=payload.get("type")) + + def _handle_status_stage( + self, + stage: str, + data: Mapping[str, Any], + now: float, + ) -> None: + self.result.status_messages += 1 + self.result.last_status_stage = stage + if stage in {"runtime_ready", "chunk_sent"}: + record_target_measurement( + result=self.result, + events=self.events, + stage=stage, + data=data, + ) + if not self.pending_control_acks: + return + if stage not in {"control_state", "applying_direction_control"}: + return + index = self.pending_control_acks.popleft() + control = self.result.control_events[index] + if control.ack_latency_ms is None: + control.ack_latency_ms = max( + (now - self.control_sent_at[index]) * 1000.0, + 0.0, + ) + + async def _send_control_trace(self) -> None: + active_started_at = self._active_start() + for event_index, entry in enumerate(self.plan.control_trace): + deadline = active_started_at + float(entry["delay_s"]) + await asyncio.sleep(max(deadline - time.perf_counter(), 0.0)) + if self.data_channel.readyState != "open": + self.events.record( + "control_trace_aborted", + reason=f"datachannel_state={self.data_channel.readyState}", + ) + return + message = dict(entry["message"]) + sent_at = time.perf_counter() + self.result.control_events.append( + ControlEventResult( + index=event_index, + scheduled_delay_s=float(entry["delay_s"]), + message=message, + sent_offset_s=sent_at - active_started_at, + ) + ) + self.control_sent_at[event_index] = sent_at + self.pending_control_acks.append(event_index) + self.pending_control_frames.append(event_index) + self.data_channel.send(orjson.dumps(message).decode()) + self.events.record("control_sent", event_id=event_index) + + def _bind_data_channel(self, channel: Any) -> None: + self.data_channel = channel + + @channel.on("open") + def _on_open() -> None: + self.events.record("datachannel_open") + self._try_start_active_window() + + @channel.on("message") + def _on_message(message: Any) -> None: + if isinstance(message, bytes): + message = message.decode("utf-8", errors="replace") + self._handle_data_message(str(message)) + + @channel.on("close") + def _on_close() -> None: + self.events.record("datachannel_close") + self.done_event.set() + + def _register_connection_state_callback(self) -> None: + peer = self.peer_connection + + @peer.on("connectionstatechange") + async def _on_connection_state() -> None: + state = peer.connectionState + self.events.record("connection_state", state=state) + if state == "connected" and not self.connected_event.is_set(): + self.result.connected_latency_ms = ( + time.perf_counter() - self.started_at + ) * 1000.0 + self.connected_event.set() + self._try_start_active_window() + if state in {"failed", "closed", "disconnected"}: + self.done_event.set() + + def _register_ice_connection_state_callback(self) -> None: + peer = self.peer_connection + + @peer.on("iceconnectionstatechange") + async def _on_ice_connection_state() -> None: + state = peer.iceConnectionState + self.events.record("ice_connection_state", state=state) + if state in {"failed", "closed", "disconnected"}: + self.done_event.set() + + def _register_track_callback(self) -> None: + peer = self.peer_connection + + @peer.on("track") + def _on_track(track: Any) -> None: + self.events.record("remote_track", kind=track.kind) + if track.kind == "video": + self.track_tasks.append( + asyncio.create_task(self._consume_video_track(track)) + ) + elif track.kind == "audio": + self.track_tasks.append( + asyncio.create_task(self._consume_audio_track(track)) + ) + + def _register_data_channel_callback(self) -> None: + peer = self.peer_connection + + @peer.on("datachannel") + def _on_datachannel(channel: Any) -> None: + if self.data_channel is None: + self._bind_data_channel(channel) + + def _register_peer_callbacks(self) -> None: + self._register_connection_state_callback() + self._register_ice_connection_state_callback() + self._register_track_callback() + self._register_data_channel_callback() + + async def _negotiate(self) -> None: + offer = await self.peer_connection.createOffer() + await self.peer_connection.setLocalDescription(offer) + ice_complete = await webrtc_ice.wait_for_ice_complete( + self.peer_connection, + float(self.adapter.options.ice_gather_timeout_s), + ) + self.events.record("ice_gathering_complete", complete=ice_complete) + offer_started_at = time.perf_counter() + answer = await self.adapter.http.request_json( + f"{self.plan.server_url}{self.plan.endpoints.offer_path}", + method="POST", + timeout_s=float(self.adapter.options.connect_timeout_s), + payload=build_telefuser_offer_body( + self.plan, + self.peer_connection.localDescription, + ), + ) + self.result.offer_rtt_ms = (time.perf_counter() - offer_started_at) * 1000.0 + self.session_id = str(answer.get("session_id", self.session_id)) + self.result.session_id = self.session_id + self.events.set_session_id(self.session_id) + if not isinstance(answer.get("sdp"), str) or not isinstance( + answer.get("type"), str + ): + raise ValueError("WebRTC answer requires string fields 'sdp' and 'type'") + description = self.adapter.session_description_factory( + sdp=answer["sdp"], + type=answer["type"], + ) + await self.peer_connection.setRemoteDescription(description) + self.events.record("offer_answer") + + async def _wait_for_media(self) -> None: + await asyncio.wait_for( + self.active_event.wait(), + timeout=float(self.adapter.options.connect_timeout_s), + ) + first_frame = asyncio.create_task(self.first_frame_event.wait()) + done = asyncio.create_task(self.done_event.wait()) + completed, pending = await asyncio.wait( + {first_frame, done}, + timeout=float(self.adapter.options.frame_timeout_s), + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + if not completed or not self.first_frame_event.is_set(): + raise TimeoutError("No video frame received before the frame timeout") + remaining = max( + self._active_start() + + float(self.plan.session_duration_s) + - time.perf_counter(), + 0.0, + ) + try: + await asyncio.wait_for(self.done_event.wait(), timeout=remaining) + except asyncio.TimeoutError: + self.events.record("session_duration_elapsed") + + async def run(self) -> SessionResult: + self.started_at = time.perf_counter() + self.events.record( + "session_start", + mode=self.plan.mode, + ice_host_ips=self.adapter.ice_host_ips, + ) + completed = False + self.peer_connection = self.adapter.peer_connection_factory( + configuration=self.adapter.rtc_configuration + ) + self._register_peer_callbacks() + if self.plan.mode == "bidirectional": + self._bind_data_channel(self.peer_connection.createDataChannel("telefuser")) + self.peer_connection.addTransceiver("video", direction="recvonly") + if self.adapter.options.receive_audio: + self.peer_connection.addTransceiver("audio", direction="recvonly") + try: + await self._negotiate() + await self._wait_for_media() + completed = True + except Exception as exc: # noqa: BLE001 - transport failures become results + self.result.error = redact_string(f"{type(exc).__name__}: {exc}") + self.events.record("session_error", error=self.result.error) + finally: + await self._shutdown() + if completed: + self._finalize_success() + self.result.session_runtime_s = time.perf_counter() - self.started_at + event_path = await self.events.export() + self.result.artifacts_event_file = str(event_path) + return self.result + + def _finalize_success(self) -> None: + self.result.success = self.first_frame_at is not None + if self.first_frame_at is None: + self.result.error = self.result.error or "No video frame received" + return + if self.last_frame_at is not None and self.last_frame_at > self.first_frame_at: + self.result.stream_fps = (self.result.frames_received - 1) / ( + self.last_frame_at - self.first_frame_at + ) + + async def _shutdown(self) -> None: + if self.data_channel is not None and self.data_channel.readyState == "open": + try: + self.data_channel.send(orjson.dumps({"type": "stop"}).decode()) + self.events.record("stop_sent") + except Exception as exc: # noqa: BLE001 - cleanup is best effort + self.events.record_error("stop_send_failed", exc) + await self._delete_target_session() + tasks = [*self.track_tasks] + if self.control_task is not None: + tasks.append(self.control_task) + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + if self.peer_connection is not None: + try: + await self.peer_connection.close() + except Exception as exc: # noqa: BLE001 - cleanup is best effort + self.events.record_error("peer_close_failed", exc) + + async def _delete_target_session(self) -> None: + template = self.plan.endpoints.delete_path_template + if template is None: + return + delete_path = template.format(session_id=self.session_id) + try: + await self.adapter.http.request_json( + f"{self.plan.server_url}{delete_path}", + method="DELETE", + timeout_s=float(self.adapter.options.shutdown_timeout_s), + accepted_error_statuses=(404,), + ) + self.events.record("session_delete") + except Exception as exc: # noqa: BLE001 - cleanup is best effort + self.events.record_error("session_delete_failed", exc) + + +class TeleFuserWebRTCAdapter(StreamTargetMetadataMixin): + """AIPerf adapter for TeleFuser SDP, RTP, and DataChannel sessions.""" + + transport = "webrtc" + + def __init__( + self, + *, + contract: BenchmarkContract, + config: StreamProfileConfig, + artifacts_dir: str | Path, + peer_connection_factory: Callable[..., Any] | None = None, + session_description_factory: Callable[..., Any] | None = None, + rtc_configuration: Any = None, + http_client: StreamHttpClient | None = None, + ) -> None: + self.contract = contract + self.config = config + self.options = config.transport + self.artifacts_dir = Path(artifacts_dir) + self.ice_host_ips = webrtc_ice.resolve_ice_host_addresses( + self.options.ice_host_ips, + target_url=self.config.server_url, + ) + self._restore_ice_host_addresses = webrtc_ice.configure_ice_host_addresses( + self.ice_host_ips + ) + aiortc = webrtc_ice.load_aiortc( + required=peer_connection_factory is None + or session_description_factory is None + or rtc_configuration is None + ) + self.peer_connection_factory = peer_connection_factory or aiortc[0] + self.session_description_factory = session_description_factory or aiortc[1] + self.rtc_configuration = ( + rtc_configuration + or webrtc_ice.build_rtc_configuration( + self.options, + aiortc[2], + aiortc[3], + ) + ) + self.http = http_client or StreamHttpClient() + + async def check_health(self) -> None: + """Check the contract-declared target health endpoint.""" + + health_path = self.contract.endpoint.get("health_path", "/v1/service/health") + health_path = str(health_path) + await self.http.check_health( + f"{self.config.server_url}{health_path}", + timeout_s=float(self.options.connect_timeout_s), + ) + + async def run_session(self, plan: StreamSessionPlan) -> SessionResult: + """Execute one normalized plan over WebRTC.""" + + return await _WebRTCSession(adapter=self, plan=plan).run() + + async def aclose(self) -> None: + """Close adapter-owned HTTP resources.""" + + try: + await self.http.aclose() + finally: + self._restore_ice_host_addresses() diff --git a/src/aiperf/streaming/adapters/webrtc_ice.py b/src/aiperf/streaming/adapters/webrtc_ice.py new file mode 100644 index 0000000000..b33f0af3b3 --- /dev/null +++ b/src/aiperf/streaming/adapters/webrtc_ice.py @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import importlib +import ipaddress +import socket +import time +from collections.abc import Callable, Iterable +from typing import Any +from urllib.parse import urlparse + +from aiperf.streaming.config import StreamTransportOptions + +AUTO_ICE_HOST = "auto" + + +async def wait_for_ice_complete( + peer_connection: Any, + timeout_s: float, +) -> bool: + """Wait until aiortc reports that local ICE gathering is complete.""" + deadline = time.perf_counter() + timeout_s + while time.perf_counter() < deadline: + if peer_connection.iceGatheringState == "complete": + return True + await asyncio.sleep(0.05) + return peer_connection.iceGatheringState == "complete" + + +def load_aiortc(*, required: bool) -> tuple[Any, Any, Any, Any]: + """Load the optional aiortc classes needed by the WebRTC adapter.""" + if not required: + return None, None, None, None + try: + module = importlib.import_module("aiortc") + except ImportError as exc: + raise RuntimeError( + "The WebRTC adapter requires AIPerf's 'streaming-webrtc' extra" + ) from exc + return ( + module.RTCPeerConnection, + module.RTCSessionDescription, + module.RTCConfiguration, + module.RTCIceServer, + ) + + +def build_rtc_configuration( + options: StreamTransportOptions, + configuration_class: Callable[..., Any], + ice_server_class: Callable[..., Any], +) -> Any: + """Build an aiortc configuration from validated stream transport options.""" + ice_servers: list[Any] = [] + if options.turn_url: + values = {"urls": options.turn_url} + if options.turn_username: + values["username"] = options.turn_username + if options.turn_credential: + values["credential"] = options.turn_credential + ice_servers.append(ice_server_class(**values)) + return configuration_class(iceServers=ice_servers or None) + + +def _normalize_host_ips(host_ips: Iterable[str]) -> list[str]: + normalized: list[str] = [] + for value in host_ips: + value = value.strip() + if not value: + continue + canonical = str(ipaddress.ip_address(value)) + if canonical not in normalized: + normalized.append(canonical) + return normalized + + +def _route_source_addresses(target_url: str) -> list[str]: + parsed = urlparse(target_url) + if parsed.hostname is None: + return [] + port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80) + try: + targets = socket.getaddrinfo( + parsed.hostname, + port, + type=socket.SOCK_DGRAM, + ) + except OSError: + return [] + + addresses: list[str] = [] + for family, socket_type, protocol, _, sockaddr in targets: + try: + with socket.socket(family, socket_type, protocol) as route_socket: + route_socket.connect(sockaddr) + local_address = str(route_socket.getsockname()[0]).split("%", 1)[0] + canonical = str(ipaddress.ip_address(local_address)) + except (OSError, ValueError): + continue + if ipaddress.ip_address(canonical).is_unspecified: + continue + if canonical not in addresses: + addresses.append(canonical) + return addresses + + +def resolve_ice_host_addresses( + host_ips: Iterable[str], + *, + target_url: str, +) -> list[str]: + """Resolve explicit ICE addresses or the route-selected ``auto`` address.""" + + configured = [value.strip() for value in host_ips if value.strip()] + if configured == [AUTO_ICE_HOST]: + return _route_source_addresses(target_url) + return _normalize_host_ips(configured) + + +def configure_ice_host_addresses(host_ips: Iterable[str]) -> Callable[[], None]: + """Limit aioice host candidates to an explicit, validated IP allowlist.""" + + allowed = _normalize_host_ips(host_ips) + try: + aioice_ice = importlib.import_module("aioice.ice") + except ImportError: + return lambda: None + current = aioice_ice.get_host_addresses + original = getattr(current, "_aiperf_original", current) + if not allowed: + aioice_ice.get_host_addresses = original + return lambda: None + + def _get_host_addresses(use_ipv4: bool, use_ipv6: bool) -> list[str]: + matching = [ + address + for address in allowed + if (ipaddress.ip_address(address).version == 4 and use_ipv4) + or (ipaddress.ip_address(address).version == 6 and use_ipv6) + ] + return matching or original(use_ipv4=use_ipv4, use_ipv6=use_ipv6) + + _get_host_addresses._aiperf_original = original # type: ignore[attr-defined] + aioice_ice.get_host_addresses = _get_host_addresses + + def _restore() -> None: + if aioice_ice.get_host_addresses is _get_host_addresses: + aioice_ice.get_host_addresses = original + + return _restore diff --git a/src/aiperf/streaming/artifacts.py b/src/aiperf/streaming/artifacts.py new file mode 100644 index 0000000000..80741ae7f6 --- /dev/null +++ b/src/aiperf/streaming/artifacts.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import aiofiles +import aiofiles.os +import orjson + +from aiperf.common.finite import scrub_non_finite +from aiperf.streaming.models import ( + SessionResult, + build_stream_summary, + session_result_to_dict, +) + + +async def write_json_artifact(path: str | Path, payload: Any) -> Path: + """Write one finite, pretty-printed JSON artifact asynchronously.""" + + output = Path(path) + await aiofiles.os.makedirs(output.parent, exist_ok=True) + content = orjson.dumps( + scrub_non_finite(payload), + option=orjson.OPT_INDENT_2, + ) + async with aiofiles.open(output, "wb") as file: + await file.write(content + b"\n") + return output + + +async def write_jsonl_artifact(path: str | Path, payloads: list[Any]) -> Path: + """Write JSON-compatible payloads as compact JSON lines asynchronously.""" + + output = Path(path) + await aiofiles.os.makedirs(output.parent, exist_ok=True) + async with aiofiles.open(output, "wb") as file: + for payload in payloads: + await file.write(orjson.dumps(scrub_non_finite(payload)) + b"\n") + return output + + +class StreamArtifactExporter: + """Export canonical summary and per-session stream artifacts.""" + + async def export( + self, + *, + artifacts_dir: str | Path, + config: Any, + warmup_results: list[SessionResult], + profile_results: list[SessionResult], + started_at_iso: str, + target_metadata: Mapping[str, Any] | None = None, + ) -> dict[str, str]: + """Write stream summary and session records and return artifact paths.""" + + root = Path(artifacts_dir) + summary = build_stream_summary( + config=config, + warmup_results=warmup_results, + profile_results=profile_results, + started_at_iso=started_at_iso, + target_metadata=target_metadata, + ) + summary_path = await write_json_artifact(root / "summary.json", summary) + sessions_path = await write_jsonl_artifact( + root / "sessions.jsonl", + [ + session_result_to_dict(result) + for result in [*warmup_results, *profile_results] + ], + ) + return { + "stream_summary": str(summary_path), + "stream_sessions": str(sessions_path), + } diff --git a/src/aiperf/streaming/config.py b/src/aiperf/streaming/config.py new file mode 100644 index 0000000000..f019889cb1 --- /dev/null +++ b/src/aiperf/streaming/config.py @@ -0,0 +1,433 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import ipaddress +from collections.abc import Mapping +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from pydantic import ( + ConfigDict, + Field, + field_serializer, + field_validator, + model_validator, +) +from ruamel.yaml import YAML +from typing_extensions import Self + +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel +from aiperf.common.path_safety import safe_read_template_path +from aiperf.common.redact import redact_url +from aiperf.observability.models import MetricCapability + +_yaml = YAML(typ="safe") + + +class _StrictStreamConfig(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid", validate_default=True) + + +class StreamTransportOptions(_StrictStreamConfig): + """Connection and media options shared by built-in stream adapters.""" + + connect_timeout_s: FiniteFloat = Field( + default=30.0, + gt=0, + description="Maximum time allowed for transport connection setup.", + ) + message_timeout_s: FiniteFloat = Field( + default=120.0, + gt=0, + description="Maximum wait for one inbound WebSocket message.", + ) + frame_timeout_s: FiniteFloat = Field( + default=60.0, + gt=0, + description="Maximum wait for the first media frame.", + ) + ice_gather_timeout_s: FiniteFloat = Field( + default=5.0, + gt=0, + description="Maximum wait for local WebRTC ICE candidate gathering.", + ) + shutdown_timeout_s: FiniteFloat = Field( + default=5.0, + gt=0, + description="Maximum wait for target-side session cleanup.", + ) + receive_audio: bool = Field( + default=False, + description="Whether a WebRTC adapter should negotiate a receive-only audio track.", + ) + ice_host_ips: list[str] = Field( + default_factory=lambda: ["auto"], + description=( + "Local IP addresses used for ICE host candidates, or ['auto'] to " + "select the source address routed to the stream target. Use an empty " + "list to retain all aioice-discovered addresses." + ), + ) + turn_url: str | None = Field( + default=None, + description="Optional TURN server URL used by the WebRTC adapter.", + ) + turn_username: str | None = Field( + default=None, + description="Optional TURN username.", + ) + turn_credential: str | None = Field( + default=None, + exclude=True, + repr=False, + description="Optional TURN credential, excluded from serialized artifacts.", + ) + + @field_validator("ice_host_ips") + @classmethod + def validate_ice_host_ips(cls, values: list[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + candidate = value.strip().lower() + if candidate == "auto": + normalized.append(candidate) + else: + normalized.append(str(ipaddress.ip_address(candidate))) + if "auto" in normalized and normalized != ["auto"]: + raise ValueError("ice_host_ips 'auto' cannot be combined with explicit IPs") + return list(dict.fromkeys(normalized)) + + +class StreamServerMetricsConfig(_StrictStreamConfig): + """Prometheus collection settings for a stream profile.""" + + enabled: bool = Field( + default=False, + description="Whether AIPerf should collect target Prometheus metrics.", + ) + urls: list[str] = Field( + default_factory=list, + description="Prometheus exposition endpoint URLs to scrape.", + ) + collection_interval_s: FiniteFloat = Field( + default=1.0, + gt=0, + description="Seconds between Prometheus scrapes.", + ) + reachability_timeout_s: FiniteFloat = Field( + default=5.0, + gt=0, + description="Timeout for Prometheus endpoint reachability checks.", + ) + export_raw_jsonl: bool = Field( + default=True, + description="Whether to retain raw Prometheus snapshots as JSONL.", + ) + + @model_validator(mode="after") + def validate_enabled_urls(self) -> Self: + if self.enabled and not self.urls: + raise ValueError( + "server_metrics.urls is required when collection is enabled" + ) + return self + + @field_validator("urls") + @classmethod + def validate_urls(cls, values: list[str]) -> list[str]: + for value in values: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("server_metrics.urls must contain absolute HTTP URLs") + return values + + @field_serializer("urls") + def serialize_urls(self, values: list[str]) -> list[str]: + return [redact_url(value) for value in values] + + +class StreamObservabilityConfig(_StrictStreamConfig): + """Semantic normalization settings for a stream profile.""" + + mapping: str | None = Field( + default=None, + description=( + "Semantic mapping path or builtin mapping reference such as " + "'builtin:telefuser'." + ), + ) + capabilities: list[MetricCapability] = Field( + default_factory=list, + description="Target capability declarations used for missing-state semantics.", + ) + + +class StreamResourceTelemetryConfig(_StrictStreamConfig): + """Required active resource upload settings for a stream profile.""" + + enabled: bool = Field( + default=False, + description="Whether to monitor a local target PID and actively upload samples.", + ) + history_url: str | None = Field( + default=None, + description="Base URL of the required AIPerf GreptimeDB history service.", + ) + target_pid: int | None = Field( + default=None, + gt=0, + description="Root target PID; all recursive descendants are included.", + ) + sample_interval_s: FiniteFloat = Field( + default=1.0, + gt=0, + le=60, + description="Source-host resource sampling interval in seconds.", + ) + upload_interval_s: FiniteFloat = Field( + default=15.0, + gt=0, + le=120, + description="Maximum interval between active batch uploads in seconds.", + ) + request_timeout_s: FiniteFloat = Field( + default=10.0, + gt=0, + le=120, + description="Timeout for each history-service upload attempt.", + ) + retry_count: int = Field( + default=3, + ge=0, + le=10, + description="Bounded retries after an upload attempt fails.", + ) + max_buffered_samples: int = Field( + default=300, + ge=15, + le=3600, + description="Hard in-memory sample bound before the run is marked failed.", + ) + require_gpu: bool = Field( + default=True, + description="Whether missing NVML or NVIDIA devices must fail collection.", + ) + + @field_validator("history_url") + @classmethod + def validate_history_url(cls, value: str | None) -> str | None: + if value is None: + return None + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError( + "resource_telemetry.history_url must be an absolute HTTP URL" + ) + return value.rstrip("/") + + @field_serializer("history_url") + def serialize_history_url(self, value: str | None) -> str | None: + return redact_url(value) if value is not None else None + + @model_validator(mode="after") + def validate_enabled_settings(self) -> Self: + if self.enabled and self.history_url is None: + raise ValueError( + "resource_telemetry.history_url is required when collection is enabled" + ) + if self.enabled and self.target_pid is None: + raise ValueError( + "resource_telemetry.target_pid is required when collection is enabled" + ) + if self.upload_interval_s < self.sample_interval_s: + raise ValueError( + "resource_telemetry.upload_interval_s cannot be shorter than " + "sample_interval_s" + ) + return self + + +class StreamProfileConfig(_StrictStreamConfig): + """First-class AIPerf configuration for one stream-world profile.""" + + contract: str = Field( + min_length=1, + description="Path to the target benchmark contract YAML file.", + ) + server_url: str = Field( + min_length=1, + description="Base URL of the target stream service.", + ) + adapter: str | None = Field( + default=None, + description="Adapter override; otherwise the contract adapter is used.", + ) + prompt: str = Field( + min_length=1, + description="Prompt supplied to every stream session.", + ) + image_path: str | None = Field( + default=None, + description="Optional initial image path or target-visible image reference.", + ) + mode: str | None = Field( + default=None, + description="Streaming interaction mode override.", + ) + task: str | None = Field( + default=None, + description="Target task override.", + ) + fps: int | None = Field( + default=None, + gt=0, + description="Requested frames per second override.", + ) + session_count: int | None = Field( + default=None, + gt=0, + description="Number of measured stream sessions.", + ) + warmup_sessions: int | None = Field( + default=None, + ge=0, + description="Number of warmup sessions excluded from formal results.", + ) + warmup_chunks: int = Field( + default=1, + ge=0, + description=( + "Leading target-reported chunks excluded per measured session " + "from steady-state chunk statistics." + ), + ) + session_duration_s: FiniteFloat | None = Field( + default=None, + gt=0, + description="Planned active streaming-window duration for each session.", + ) + stagger_s: FiniteFloat = Field( + default=0.0, + ge=0, + description="Delay between consecutive session starts.", + ) + control_trace_path: str | None = Field( + default=None, + description="Optional JSON file containing timed control events.", + ) + request_extra: dict[str, Any] = Field( + default_factory=dict, + description="Target-specific request values merged over the contract workload.", + ) + artifacts_dir: str = Field( + default="artifacts/aiperf/stream", + min_length=1, + description="Directory under which AIPerf creates a run artifact directory.", + ) + print_events: bool = Field( + default=False, + description="Whether transport events should also be printed as JSON lines.", + ) + transport: StreamTransportOptions = Field( + default_factory=StreamTransportOptions, + description="Transport-specific connection and media options.", + ) + server_metrics: StreamServerMetricsConfig = Field( + default_factory=StreamServerMetricsConfig, + description="Prometheus collection settings.", + ) + observability: StreamObservabilityConfig = Field( + default_factory=StreamObservabilityConfig, + description="Semantic metric mapping settings.", + ) + resource_telemetry: StreamResourceTelemetryConfig = Field( + default_factory=StreamResourceTelemetryConfig, + description="Active process-tree and machine resource upload settings.", + ) + + @field_validator("server_url") + @classmethod + def validate_server_url(cls, value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https", "ws", "wss"} or not parsed.netloc: + raise ValueError("server_url must be an absolute HTTP or WebSocket URL") + return value.rstrip("/") + + @field_serializer("server_url") + def serialize_server_url(self, value: str) -> str: + return redact_url(value) + + +def _load_mapping(path: str | Path) -> dict[str, Any]: + text = safe_read_template_path(str(path)) + if text is None: + raise ValueError(f"Stream profile config could not be read safely: {path}") + payload = _yaml.load(text) + if not isinstance(payload, Mapping): + raise ValueError("Stream profile config must be a YAML or JSON mapping") + return dict(payload) + + +def _merge_overrides( + target: dict[str, Any], + overrides: Mapping[str, Any], +) -> None: + for key, value in overrides.items(): + current = target.get(key) + if isinstance(current, dict) and isinstance(value, Mapping): + _merge_overrides(current, value) + else: + target[key] = value + + +def _resolve_required_reference(reference: str, *, source: Path) -> str: + candidate = Path(reference).expanduser() + if safe_read_template_path(str(candidate)) is not None: + return str(candidate) + if candidate.is_absolute(): + raise ValueError(f"Referenced file could not be read safely: {reference}") + relative = source.parent / candidate + if safe_read_template_path(str(relative)) is None: + raise ValueError(f"Referenced file could not be read safely: {reference}") + return str(relative) + + +def _resolve_optional_reference(reference: str | None, *, source: Path) -> str | None: + if reference is None: + return None + candidate = Path(reference).expanduser() + if safe_read_template_path(str(candidate)) is not None: + return str(candidate) + if candidate.is_absolute(): + return reference + relative = source.parent / candidate + return ( + str(relative) + if safe_read_template_path(str(relative)) is not None + else reference + ) + + +def load_stream_profile_config( + path: str | Path, + *, + overrides: Mapping[str, Any] | None = None, +) -> StreamProfileConfig: + """Load a stream profile config and resolve its local file references.""" + + source = Path(path).expanduser() + payload = _load_mapping(source) + _merge_overrides(payload, overrides or {}) + config = StreamProfileConfig.model_validate(payload) + updates = { + "contract": _resolve_required_reference(config.contract, source=source), + "control_trace_path": _resolve_optional_reference( + config.control_trace_path, + source=source, + ), + } + return config.model_copy(update=updates) diff --git a/src/aiperf/streaming/contracts.py b/src/aiperf/streaming/contracts.py new file mode 100644 index 0000000000..3a1edb888e --- /dev/null +++ b/src/aiperf/streaming/contracts.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import orjson +from pydantic import ConfigDict, Field +from ruamel.yaml import YAML + +from aiperf.common.models import AIPerfBaseModel +from aiperf.common.path_safety import safe_read_template_path + +REQUIRED_BENCHMARK_CONTRACT_FIELDS = ( + "contract_version", + "name", + "mode", + "implementation", + "model_family", + "model", + "supported_tasks", + "transport", + "endpoint", + "request_encoding", + "result_delivery", + "workload", + "metrics", + "artifacts", +) + +SUPPORTED_BENCHMARK_MODES = {"batch_video", "stream_world"} +SUPPORTED_TRANSPORTS = {"http", "http_polling", "websocket", "sse", "webrtc"} + +_yaml = YAML(typ="safe") + + +class BenchmarkContract(AIPerfBaseModel): + """Transport-neutral contract for a multimodal benchmark target.""" + + model_config = ConfigDict(extra="allow") + + contract_version: str = Field( + min_length=1, + description="Version of the benchmark contract schema.", + ) + name: str = Field(min_length=1, description="Unique contract name.") + mode: str = Field(min_length=1, description="Benchmark mode identifier.") + implementation: str = Field( + min_length=1, + description="Target framework or implementation identifier.", + ) + model_family: str = Field( + min_length=1, + description="Model family exercised by the contract.", + ) + model: str = Field(min_length=1, description="Model identifier.") + supported_tasks: list[str] = Field( + min_length=1, + description="Tasks supported by the target contract.", + ) + transport: str = Field( + min_length=1, + description="Wire transport used to communicate with the target.", + ) + adapter: str | None = Field( + default=None, + description="AIPerf transport adapter name for stream-world targets.", + ) + endpoint: dict[str, Any] = Field( + min_length=1, + description="Target endpoint paths and connection metadata.", + ) + request_encoding: dict[str, Any] = Field( + min_length=1, + description="Target request encoding contract.", + ) + result_delivery: dict[str, Any] = Field( + min_length=1, + description="Target result-delivery contract.", + ) + workload: dict[str, Any] = Field( + min_length=1, + description="Fixed workload semantics for the benchmark.", + ) + metrics: list[str] = Field( + min_length=1, + description="Metrics emitted by this benchmark mode.", + ) + artifacts: dict[str, Any] = Field( + min_length=1, + description="Inputs and result artifacts used by automation.", + ) + limits: dict[str, Any] = Field( + default_factory=dict, + description="Optional target capability limits.", + ) + + +def _load_yaml(text: str) -> dict[str, Any]: + payload = _yaml.load(text) + if not isinstance(payload, dict): + raise ValueError("Benchmark contract must be a YAML mapping") + return payload + + +def load_benchmark_contract(path: str | Path) -> dict[str, Any]: + """Load a benchmark contract YAML file from a safe regular path.""" + + text = safe_read_template_path(str(path)) + if text is None: + raise ValueError(f"Benchmark contract could not be read safely: {path}") + return _load_yaml(text) + + +def validate_benchmark_contract_model( + contract: Mapping[str, Any], + *, + source: str | Path | None = None, +) -> BenchmarkContract: + """Validate and return the typed benchmark contract model.""" + + label = str(source) if source is not None else "benchmark contract" + missing = [ + field for field in REQUIRED_BENCHMARK_CONTRACT_FIELDS if field not in contract + ] + if missing: + raise ValueError(f"{label}: missing required fields: {', '.join(missing)}") + + mode = contract["mode"] + if mode not in SUPPORTED_BENCHMARK_MODES: + raise ValueError(f"{label}: unsupported mode {mode!r}") + + transport = contract["transport"] + if transport not in SUPPORTED_TRANSPORTS: + raise ValueError(f"{label}: unsupported transport {transport!r}") + + return BenchmarkContract.model_validate(contract) + + +def validate_benchmark_contract( + contract: dict[str, Any], + *, + source: str | Path | None = None, +) -> dict[str, Any]: + """Validate and normalize a benchmark contract mapping.""" + + return validate_benchmark_contract_model(contract, source=source).model_dump( + mode="json" + ) + + +def validate_benchmark_contract_file(path: str | Path) -> dict[str, Any]: + """Load and validate a benchmark contract file.""" + + return validate_benchmark_contract(load_benchmark_contract(path), source=path) + + +def benchmark_contract_digest( + contract: BenchmarkContract | Mapping[str, Any], +) -> str: + """Return a stable SHA-256 digest for a validated benchmark contract.""" + + model = ( + contract + if isinstance(contract, BenchmarkContract) + else validate_benchmark_contract_model(contract) + ) + canonical = orjson.dumps(model.model_dump(mode="json"), option=orjson.OPT_SORT_KEYS) + return hashlib.sha256(canonical).hexdigest() diff --git a/src/aiperf/streaming/events.py b/src/aiperf/streaming/events.py new file mode 100644 index 0000000000..ce09f26edd --- /dev/null +++ b/src/aiperf/streaming/events.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +import time +from pathlib import Path +from typing import Any + +import aiofiles +import aiofiles.os +import orjson + +from aiperf.common.finite import scrub_non_finite +from aiperf.common.redact import redact_string + +_SAFE_FILENAME = re.compile(r"[^A-Za-z0-9_.-]+") + + +def safe_filename_component(value: str) -> str: + """Return a bounded filename component for a target-provided identifier.""" + + sanitized = _SAFE_FILENAME.sub("_", value).strip("._") + return (sanitized or "session")[:128] + + +class StreamEventRecorder: + """Collect one session timeline and export it as canonical JSONL.""" + + def __init__( + self, + *, + artifacts_dir: str | Path, + phase: str, + logical_session_index: int, + planned_session_id: str, + print_events: bool = False, + ) -> None: + self._artifacts_dir = Path(artifacts_dir) + self._phase = phase + self._logical_session_index = logical_session_index + self._session_id = planned_session_id + self._print_events = print_events + self._events: list[dict[str, Any]] = [] + self._monotonic_origin_ns = time.monotonic_ns() + + def set_session_id(self, session_id: str) -> None: + """Use the target-accepted session identifier for subsequent events.""" + + self._session_id = session_id + + def record(self, event: str, **payload: Any) -> None: + """Append one wall-clock event without performing filesystem I/O.""" + + item = { + "event": event, + "session_id": self._session_id, + "logical_session_index": self._logical_session_index, + "phase": self._phase, + "timestamp_ns": time.time_ns(), + "monotonic_offset_ns": time.monotonic_ns() - self._monotonic_origin_ns, + **payload, + } + item = scrub_non_finite(item) + self._events.append(item) + if self._print_events: + print(orjson.dumps(item).decode()) + + async def export(self) -> Path: + """Persist recorded events and return the JSONL path.""" + + events_dir = self._artifacts_dir / "events" + await aiofiles.os.makedirs(events_dir, exist_ok=True) + phase = safe_filename_component(self._phase) + session_id = safe_filename_component(self._session_id) + path = events_dir / ( + f"{phase}_{self._logical_session_index:03d}_{session_id}.jsonl" + ) + async with aiofiles.open(path, "wb") as file: + for event in self._events: + await file.write(orjson.dumps(event) + b"\n") + return path + + def record_error(self, event: str, error: BaseException | str) -> None: + """Record a redacted transport error in the session timeline.""" + + detail = str(error) + if isinstance(error, BaseException): + detail = f"{type(error).__name__}: {error}" + self.record(event, error=redact_string(detail)) diff --git a/src/aiperf/streaming/measurements.py b/src/aiperf/streaming/measurements.py new file mode 100644 index 0000000000..29f0a51d10 --- /dev/null +++ b/src/aiperf/streaming/measurements.py @@ -0,0 +1,317 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import statistics +from collections.abc import Mapping, Sequence +from typing import Any + +from pydantic import ConfigDict, Field, model_validator + +from aiperf.common.finite import FiniteFloat, is_finite_value +from aiperf.common.models import AIPerfBaseModel + +_OPTIONAL_CHUNK_METRIC_FIELDS = { + "chunk_request_prepare_seconds": "request_prepare_seconds", + "chunk_encode_seconds": "encode_seconds", + "chunk_output_pacing_seconds": "output_pacing_seconds", + "chunk_output_header_write_seconds": "output_header_write_seconds", + "chunk_output_payload_write_seconds": "output_payload_write_seconds", + "chunk_output_write_seconds": "output_write_seconds", + "chunk_total_seconds": "total_seconds", + "chunk_raw_output_bytes": "raw_output_bytes", + "chunk_wire_output_bytes": "wire_output_bytes", + "chunk_output_batches": "output_batches", +} + + +class _StrictMeasurementModel(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid") + + +class StreamDeviceMemoryMeasurement(_StrictMeasurementModel): + """Peak allocator usage reported by one target device.""" + + device: str = Field(min_length=1, description="Target device identifier.") + peak_allocated_bytes: int | None = Field( + default=None, + ge=0, + description="Optional peak bytes allocated by the target runtime allocator.", + ) + peak_reserved_bytes: int | None = Field( + default=None, + ge=0, + description="Optional peak bytes reserved by the target runtime allocator.", + ) + + @model_validator(mode="after") + def require_allocator_peak(self) -> StreamDeviceMemoryMeasurement: + if self.peak_allocated_bytes is None and self.peak_reserved_bytes is None: + raise ValueError("at least one allocator peak must be reported") + return self + + +class StreamPhaseMeasurement(_StrictMeasurementModel): + """One target-reported initialization or execution phase measurement.""" + + name: str = Field(min_length=1, description="Stable phase name.") + seconds: FiniteFloat = Field(ge=0, description="Phase duration in seconds.") + memory: list[StreamDeviceMemoryMeasurement] = Field( + default_factory=list, + description="Optional per-device peak allocator usage for this phase.", + ) + + +class StreamChunkMeasurement(_StrictMeasurementModel): + """Target-reported timing and memory facts for one generated chunk.""" + + index: int = Field(ge=0, description="Zero-based chunk index.") + frames: int = Field(ge=0, description="Frames produced by this chunk.") + request_prepare_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional target time spent preparing the generation request.", + ) + compute_seconds: FiniteFloat = Field( + ge=0, + description="Target compute duration excluding output encoding.", + ) + encode_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional target output-encoding duration.", + ) + output_pacing_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional intentional target wait before output delivery.", + ) + output_header_write_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional target time spent writing transport headers.", + ) + output_payload_write_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional target time spent writing the media payload.", + ) + output_write_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional total target transport-write duration.", + ) + total_seconds: FiniteFloat | None = Field( + default=None, + ge=0, + description="Optional complete target-side chunk lifecycle duration.", + ) + raw_output_bytes: int | None = Field( + default=None, + ge=0, + description="Optional unencoded media bytes represented by this chunk.", + ) + wire_output_bytes: int | None = Field( + default=None, + ge=0, + description="Optional media and framing bytes written to the transport.", + ) + output_batches: int | None = Field( + default=None, + ge=0, + description="Optional number of transport batches emitted for this chunk.", + ) + output_content_type: str | None = Field( + default=None, + min_length=1, + max_length=128, + description="Optional bounded media content type emitted by the target.", + ) + memory: list[StreamDeviceMemoryMeasurement] = Field( + default_factory=list, + description="Optional per-device peak allocator usage for this chunk.", + ) + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + raise ValueError("percentile() requires at least one value") + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + position = (len(ordered) - 1) * percentile + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + fraction = position - lower + return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction + + +def summarise_values( + values: Sequence[float | int | None], +) -> dict[str, float] | None: + """Summarise a numeric metric vector with stable percentile fields.""" + + clean_values = [float(value) for value in values if is_finite_value(value)] + if not clean_values: + return None + return { + "count": float(len(clean_values)), + "min": min(clean_values), + "mean": sum(clean_values) / len(clean_values), + "p50": _percentile(clean_values, 0.50), + "p90": _percentile(clean_values, 0.90), + "p99": _percentile(clean_values, 0.99), + "max": max(clean_values), + "std": statistics.pstdev(clean_values), + } + + +def _collect_phases( + results: Sequence[Any], + target_metadata: Mapping[str, Any] | None, +) -> tuple[dict[str, list[float]], dict[str, list[int]], dict[str, list[int]]]: + phase_values: dict[str, list[float]] = {} + allocated: dict[str, list[int]] = {} + reserved: dict[str, list[int]] = {} + phases = [phase for result in results for phase in result.phase_measurements] + performance = ( + target_metadata.get("performance") + if isinstance(target_metadata, Mapping) + else None + ) + target_phases = ( + performance.get("phases") if isinstance(performance, Mapping) else None + ) + if isinstance(target_phases, list): + for payload in target_phases: + try: + phases.append(StreamPhaseMeasurement.model_validate(payload)) + except (TypeError, ValueError): + continue + for phase in phases: + phase_values.setdefault(phase.name, []).append(float(phase.seconds)) + for memory in phase.memory: + if memory.peak_allocated_bytes is not None: + allocated.setdefault(phase.name, []).append(memory.peak_allocated_bytes) + if memory.peak_reserved_bytes is not None: + reserved.setdefault(phase.name, []).append(memory.peak_reserved_bytes) + return phase_values, allocated, reserved + + +def _phase_metrics( + results: Sequence[Any], + target_metadata: Mapping[str, Any] | None, +) -> dict[str, dict[str, float] | None]: + values, allocated, reserved = _collect_phases(results, target_metadata) + metrics: dict[str, dict[str, float] | None] = {} + for name in sorted(values): + metrics[f"{name}_seconds"] = summarise_values(values[name]) + metrics[f"{name}_peak_allocated_bytes"] = summarise_values( + allocated.get(name, []) + ) + metrics[f"{name}_peak_reserved_bytes"] = summarise_values( + reserved.get(name, []) + ) + return metrics + + +def _steady_chunks( + results: Sequence[Any], + warmup_chunks: int, +) -> tuple[list[StreamChunkMeasurement], int]: + chunks: list[StreamChunkMeasurement] = [] + skipped = 0 + for result in results: + ordered = sorted(result.chunk_measurements, key=lambda item: item.index) + session_skipped = min(warmup_chunks, len(ordered)) + skipped += session_skipped + chunks.extend(ordered[session_skipped:]) + return chunks, skipped + + +def _optional_chunk_values( + chunks: Sequence[StreamChunkMeasurement], + field_name: str, +) -> list[float | int]: + values: list[float | int] = [] + for chunk in chunks: + value = getattr(chunk, field_name) + if value is not None: + values.append(value) + return values + + +def _optional_chunk_metrics( + chunks: Sequence[StreamChunkMeasurement], +) -> dict[str, dict[str, float] | None]: + return { + metric_name: summarise_values(_optional_chunk_values(chunks, field_name)) + for metric_name, field_name in _OPTIONAL_CHUNK_METRIC_FIELDS.items() + } + + +def _chunk_metrics( + chunks: Sequence[StreamChunkMeasurement], + skipped: int, +) -> tuple[dict[str, dict[str, float] | None], dict[str, float | int]]: + compute = [float(chunk.compute_seconds) for chunk in chunks] + frames = [chunk.frames for chunk in chunks] + fps = [ + chunk.frames / float(chunk.compute_seconds) + for chunk in chunks + if chunk.compute_seconds > 0 + ] + allocated = [ + item.peak_allocated_bytes + for chunk in chunks + for item in chunk.memory + if item.peak_allocated_bytes is not None + ] + reserved = [ + item.peak_reserved_bytes + for chunk in chunks + for item in chunk.memory + if item.peak_reserved_bytes is not None + ] + raw_output_bytes = _optional_chunk_values(chunks, "raw_output_bytes") + wire_output_bytes = _optional_chunk_values(chunks, "wire_output_bytes") + output_batches = _optional_chunk_values(chunks, "output_batches") + metrics = { + **_optional_chunk_metrics(chunks), + "chunk_compute_seconds": summarise_values(compute), + "chunk_frames": summarise_values(frames), + "chunk_compute_fps": summarise_values(fps), + "chunk_peak_allocated_bytes": summarise_values(allocated), + "chunk_peak_reserved_bytes": summarise_values(reserved), + } + total_seconds = sum(compute) + total_frames = sum(frames) + steady: dict[str, float | int] = { + "count": len(chunks), + "warmup_chunks_skipped": skipped, + "total_frames": total_frames, + "total_compute_seconds": total_seconds, + } + if total_seconds > 0: + steady["frames_per_second"] = total_frames / total_seconds + if raw_output_bytes: + steady["total_raw_output_bytes"] = sum(raw_output_bytes) + if wire_output_bytes: + steady["total_wire_output_bytes"] = sum(wire_output_bytes) + if output_batches: + steady["total_output_batches"] = sum(output_batches) + return metrics, steady + + +def build_measurement_metrics( + results: Sequence[Any], + *, + warmup_chunks: int, + target_metadata: Mapping[str, Any] | None, +) -> tuple[dict[str, dict[str, float] | None], dict[str, float | int]]: + """Aggregate target phases and per-session steady-state chunk facts.""" + + chunks, skipped = _steady_chunks(results, warmup_chunks) + chunk_metrics, steady = _chunk_metrics(chunks, skipped) + return {**_phase_metrics(results, target_metadata), **chunk_metrics}, steady diff --git a/src/aiperf/streaming/models.py b/src/aiperf/streaming/models.py new file mode 100644 index 0000000000..8528d1be16 --- /dev/null +++ b/src/aiperf/streaming/models.py @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import asdict, is_dataclass +from typing import Any + +import orjson +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from aiperf.common.finite import FiniteFloat, is_finite_value, scrub_non_finite +from aiperf.common.models import AIPerfBaseModel +from aiperf.common.path_safety import safe_read_template_path +from aiperf.streaming.measurements import ( + StreamChunkMeasurement, + StreamPhaseMeasurement, + build_measurement_metrics, + summarise_values, +) + + +class _StrictStreamingModel(AIPerfBaseModel): + model_config = ConfigDict(extra="forbid") + + +def _reject_non_finite_payload(value: Any, *, path: str = "payload") -> Any: + if isinstance(value, Mapping): + for key, item in value.items(): + _reject_non_finite_payload(item, path=f"{path}.{key}") + return value + if isinstance(value, (list, tuple)): + for index, item in enumerate(value): + _reject_non_finite_payload(item, path=f"{path}[{index}]") + return value + if isinstance(value, (str, bytes, bytearray, bool, int)) or value is None: + return value + if ( + isinstance(value, float) or hasattr(value, "__float__") + ) and not is_finite_value(value): + raise ValueError(f"{path} contains a non-finite numeric value") + return value + + +class StreamEndpointPaths(_StrictStreamingModel): + """Common endpoint paths for a streaming benchmark target.""" + + health_path: str = Field(min_length=1, description="Target health-check path.") + offer_path: str = Field( + min_length=1, + description="Path used to create or negotiate a streaming session.", + ) + delete_path_template: str | None = Field( + default=None, + description="Path template used to close a streaming session.", + ) + + +class StreamControlTraceEntry(_StrictStreamingModel): + """One scheduled control message in a streaming workload.""" + + delay_s: FiniteFloat = Field( + ge=0, + description=( + "Delay from the active streaming-window start before sending the " + "control message." + ), + ) + message: dict[str, Any] = Field( + description="Transport-independent control message payload.", + ) + + @field_validator("message") + @classmethod + def validate_finite_message(cls, value: dict[str, Any]) -> dict[str, Any]: + return _reject_non_finite_payload(value, path="message") + + +class StreamSessionPlan(_StrictStreamingModel): + """Transport-independent plan for one streaming benchmark session.""" + + logical_session_index: int = Field( + ge=0, + description="Zero-based session index within the benchmark phase.", + ) + phase: str = Field(min_length=1, description="Benchmark phase name.") + planned_session_id: str = Field( + min_length=1, + description="Client-generated stable session identifier.", + ) + server_url: str = Field(min_length=1, description="Target server base URL.") + endpoints: StreamEndpointPaths = Field( + description="Target endpoint paths used by the transport adapter.", + ) + mode: str = Field(min_length=1, description="Streaming interaction mode.") + task: str = Field(min_length=1, description="Target task name.") + prompt: str = Field(description="Prompt supplied to the target session.") + fps: int = Field(gt=0, description="Requested output frames per second.") + session_duration_s: FiniteFloat = Field( + gt=0, + description="Planned active streaming-window duration in seconds.", + ) + request_extra: dict[str, Any] = Field( + default_factory=dict, + description="Target-specific request fields passed through by the adapter.", + ) + image_path: str | None = Field( + default=None, + description="Optional initial image path for image-conditioned sessions.", + ) + control_trace: list[dict[str, Any]] = Field( + default_factory=list, + description="Scheduled transport-independent control messages.", + ) + + @field_validator("request_extra", "control_trace") + @classmethod + def validate_finite_payload(cls, value: Any) -> Any: + return _reject_non_finite_payload(value) + + +class ControlEventResult(_StrictStreamingModel): + """Observed timing for one control event.""" + + index: int = Field(ge=0, description="Zero-based control event index.") + scheduled_delay_s: FiniteFloat = Field( + ge=0, + description="Scheduled send offset from active-window start in seconds.", + ) + message: dict[str, Any] = Field(description="Control message payload.") + sent_offset_s: FiniteFloat = Field( + ge=0, + description="Observed send offset from active-window start in seconds.", + ) + ack_latency_ms: FiniteFloat | None = Field( + default=None, + ge=0, + description="Control acknowledgement latency in milliseconds.", + ) + next_frame_latency_ms: FiniteFloat | None = Field( + default=None, + ge=0, + description="Latency from control send to the next received frame.", + ) + + +class SessionResult(_StrictStreamingModel): + """Transport-independent result for one streaming benchmark session.""" + + logical_session_index: int = Field( + ge=0, + description="Zero-based session index within the benchmark phase.", + ) + phase: str = Field(min_length=1, description="Benchmark phase name.") + mode: str = Field(min_length=1, description="Streaming interaction mode.") + planned_session_id: str = Field( + min_length=1, + description="Client-generated session identifier.", + ) + session_id: str = Field( + min_length=1, + description="Session identifier returned or accepted by the target.", + ) + success: bool = Field(default=False, description="Whether the session succeeded.") + error: str | None = Field( + default=None, + description="Failure detail when the session did not succeed.", + ) + offer_rtt_ms: FiniteFloat | None = Field( + default=None, + ge=0, + description="Session negotiation round-trip latency in milliseconds.", + ) + connected_latency_ms: FiniteFloat | None = Field( + default=None, + ge=0, + description="Latency until the transport reports a connected state.", + ) + first_frame_latency_ms: FiniteFloat | None = Field( + default=None, + ge=0, + description="Latency until the first media frame is received.", + ) + first_metadata_latency_ms: FiniteFloat | None = Field( + default=None, + ge=0, + description="Latency until the first metadata message is received.", + ) + session_runtime_s: FiniteFloat | None = Field( + default=None, + ge=0, + description="Observed session runtime in seconds.", + ) + frames_received: int = Field( + default=0, + ge=0, + description="Number of decoded media frames received.", + ) + metadata_messages: int = Field( + default=0, + ge=0, + description="Number of metadata messages received.", + ) + status_messages: int = Field( + default=0, + ge=0, + description="Number of status messages received.", + ) + done_received: bool = Field( + default=False, + description="Whether the target emitted an explicit completion event.", + ) + stream_fps: FiniteFloat | None = Field( + default=None, + ge=0, + description="Observed media receive rate in frames per second.", + ) + last_status_stage: str | None = Field( + default=None, + description="Last target status stage observed by the adapter.", + ) + control_events: list[ControlEventResult] = Field( + default_factory=list, + description="Observed control-event timings for the session.", + ) + runtime_metadata: dict[str, Any] = Field( + default_factory=dict, + description="Finite target-reported runtime dimensions and cache metadata.", + ) + phase_measurements: list[StreamPhaseMeasurement] = Field( + default_factory=list, + description="Target-reported phase timings and peak allocator usage.", + ) + chunk_measurements: list[StreamChunkMeasurement] = Field( + default_factory=list, + description="Target-reported per-chunk compute and encoding measurements.", + ) + artifacts_event_file: str | None = Field( + default=None, + description="Optional path to the session event artifact.", + ) + + @field_validator("runtime_metadata") + @classmethod + def validate_runtime_metadata(cls, value: dict[str, Any]) -> dict[str, Any]: + return _reject_non_finite_payload(value, path="runtime_metadata") + + +def _normalise_control_trace(payload: Any, *, source: str) -> list[dict[str, Any]]: + events = payload.get("events", payload) if isinstance(payload, dict) else payload + if not isinstance(events, list): + raise ValueError( + f'Control trace must be a list or {{"events": [...]}}: {source}' + ) + + normalised: list[StreamControlTraceEntry] = [] + for index, event in enumerate(events): + if not isinstance(event, dict): + raise ValueError(f"Control trace event #{index} must be a JSON object") + message = event.get("message") + if not isinstance(message, dict): + raise ValueError( + f"Control trace event #{index} is missing object field `message`" + ) + normalised.append( + StreamControlTraceEntry( + delay_s=event.get("delay_s", 0.0), + message=message, + ) + ) + return [ + entry.model_dump() + for entry in sorted(normalised, key=lambda item: item.delay_s) + ] + + +def load_control_trace( + path: str | None, + mode: str, + *, + default_events: Sequence[Mapping[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Load and validate timed control events for an interactive stream session.""" + + if mode != "bidirectional": + return [] + if path is None: + return _normalise_control_trace( + list(default_events or []), source="default control trace" + ) + + text = safe_read_template_path(path) + if text is None: + raise ValueError(f"Control trace could not be read safely: {path}") + return _normalise_control_trace(orjson.loads(text), source=path) + + +def session_result_to_dict(result: SessionResult) -> dict[str, Any]: + """Serialize a session result into a JSON-compatible mapping.""" + + return scrub_non_finite(result.model_dump(mode="json")) + + +def _serialise_config(config: Any) -> dict[str, Any]: + if isinstance(config, BaseModel): + return config.model_dump(mode="json") + if is_dataclass(config) and not isinstance(config, type): + return asdict(config) + if isinstance(config, Mapping): + return dict(config) + if hasattr(config, "__dict__"): + return dict(config.__dict__) + raise TypeError( + f"Unsupported stream benchmark config type: {type(config).__name__}" + ) + + +def _client_metrics( + results: Sequence[SessionResult], + control_events: Sequence[ControlEventResult], +) -> dict[str, dict[str, float] | None]: + return { + "offer_rtt_ms": summarise_values([result.offer_rtt_ms for result in results]), + "connected_latency_ms": summarise_values( + [result.connected_latency_ms for result in results] + ), + "first_frame_latency_ms": summarise_values( + [result.first_frame_latency_ms for result in results] + ), + "first_metadata_latency_ms": summarise_values( + [result.first_metadata_latency_ms for result in results] + ), + "stream_fps": summarise_values([result.stream_fps for result in results]), + "session_runtime_s": summarise_values( + [result.session_runtime_s for result in results] + ), + "frames_received": summarise_values( + [result.frames_received for result in results] + ), + "control_ack_latency_ms": summarise_values( + [event.ack_latency_ms for event in control_events] + ), + "control_to_next_frame_latency_ms": summarise_values( + [event.next_frame_latency_ms for event in control_events] + ), + } + + +def build_stream_summary( + *, + config: Any, + warmup_results: list[SessionResult], + profile_results: list[SessionResult], + started_at_iso: str, + target_metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build the canonical stream benchmark summary payload.""" + + serialized_config = _serialise_config(config) + warmup_chunks = max(int(serialized_config.get("warmup_chunks", 1)), 0) + measurement_metrics, chunk_steady_state = build_measurement_metrics( + profile_results, + warmup_chunks=warmup_chunks, + target_metadata=target_metadata, + ) + successful_profile_results = [ + result for result in profile_results if result.success + ] + all_control_events = [ + control_event + for result in profile_results + for control_event in result.control_events + if control_event.ack_latency_ms is not None + or control_event.next_frame_latency_ms is not None + ] + metrics = { + **_client_metrics(profile_results, all_control_events), + **measurement_metrics, + } + return { + "started_at_utc": started_at_iso, + "config": serialized_config, + "target_metadata": dict(target_metadata or {}), + "warmup": { + "attempted_sessions": len(warmup_results), + "successful_sessions": sum( + 1 for result in warmup_results if result.success + ), + "failed_sessions": sum( + 1 for result in warmup_results if not result.success + ), + }, + "profile": { + "attempted_sessions": len(profile_results), + "successful_sessions": len(successful_profile_results), + "failed_sessions": len(profile_results) - len(successful_profile_results), + "success_rate": ( + len(successful_profile_results) / len(profile_results) + if profile_results + else 0.0 + ), + "chunk_steady_state": chunk_steady_state, + "metrics": metrics, + }, + } diff --git a/src/aiperf/streaming/profile.py b/src/aiperf/streaming/profile.py new file mode 100644 index 0000000000..026f335b78 --- /dev/null +++ b/src/aiperf/streaming/profile.py @@ -0,0 +1,462 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import signal +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import aiofiles.os +from pydantic import ConfigDict, Field + +from aiperf.common.constants import IS_WINDOWS +from aiperf.common.models import AIPerfBaseModel +from aiperf.common.models.server_metrics_models import ServerMetricsExportData +from aiperf.observability import ( + NormalizedMetricsArtifact, + NormalizedMetricsExporter, + ObservationLifecycle, + ObservationManifestExporter, + ObservationRunIdentity, + SemanticMetricMapper, + SemanticMetricMapping, + load_builtin_semantic_mapping, + load_semantic_mapping, + server_metrics_export_to_observations, + server_metrics_results_to_export_data, +) +from aiperf.resource_telemetry.streaming import ( + resource_run_status, + start_stream_resource_agent, +) +from aiperf.server_metrics.standalone import StandaloneServerMetricsCollector +from aiperf.streaming.adapters import create_stream_adapter +from aiperf.streaming.artifacts import StreamArtifactExporter, write_json_artifact +from aiperf.streaming.config import ( + StreamProfileConfig, + load_stream_profile_config, +) +from aiperf.streaming.contracts import ( + BenchmarkContract, + benchmark_contract_digest, + load_benchmark_contract, + validate_benchmark_contract_model, +) +from aiperf.streaming.models import build_stream_summary +from aiperf.streaming.protocols import ( + ManagedStreamTransportAdapterProtocol, + StreamTargetMetadataProviderProtocol, +) +from aiperf.streaming.report import StreamReportExporter +from aiperf.streaming.runner import ( + StreamBenchmarkRunner, + StreamRunResult, +) +from aiperf.streaming.workload import ContractStreamWorkload + +if TYPE_CHECKING: + from aiperf.resource_telemetry import ResourceTelemetryAgent + + +class StreamProfileResult(AIPerfBaseModel): + """Completed stream profile and its AIPerf-owned artifact locations.""" + + model_config = ConfigDict(extra="forbid") + + benchmark_id: str = Field( + min_length=1, + description="Unique benchmark run identifier.", + ) + artifacts_dir: str = Field( + min_length=1, + description="Run-specific artifact directory.", + ) + summary: dict[str, Any] = Field( + description="Canonical stream benchmark summary.", + ) + artifacts: dict[str, str] = Field( + description="Artifact roles mapped to filesystem paths.", + ) + + +@dataclass(slots=True) +class _StreamProfileContext: + config: StreamProfileConfig + contract: BenchmarkContract + benchmark_id: str + artifacts_dir: Path + identity: ObservationRunIdentity + lifecycle: ObservationLifecycle + artifacts: dict[str, str] + adapter: ManagedStreamTransportAdapterProtocol + target_metadata: dict[str, Any] + + +def _run_directory(config: StreamProfileConfig, benchmark_id: str) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + return Path(config.artifacts_dir) / f"{timestamp}_{benchmark_id[:8]}" + + +def _load_mapping(reference: str) -> SemanticMetricMapping: + prefix = "builtin:" + if reference.startswith(prefix): + return load_builtin_semantic_mapping(reference[len(prefix) :]) + return load_semantic_mapping(reference) + + +async def _start_server_metrics( + *, + config: StreamProfileConfig, + benchmark_id: str, + artifacts_dir: Path, + lifecycle: ObservationLifecycle, +) -> StandaloneServerMetricsCollector | None: + settings = config.server_metrics + if not settings.enabled: + lifecycle.disable("Server metrics collection is disabled") + return None + raw_path = ( + artifacts_dir / "server_metrics_export.jsonl" + if settings.export_raw_jsonl + else None + ) + collector = StandaloneServerMetricsCollector( + benchmark_id=benchmark_id, + urls=settings.urls, + collection_interval_s=float(settings.collection_interval_s), + reachability_timeout_s=float(settings.reachability_timeout_s), + raw_jsonl_path=raw_path, + ) + try: + await collector.start() + except Exception as exc: # noqa: BLE001 - observability is fail-open + await collector.abort() + lifecycle.disable( + f"Server metrics collection could not start: {type(exc).__name__}: {exc}" + ) + return None + return collector + + +async def _finalize_server_metrics( + *, + collector: StandaloneServerMetricsCollector | None, + lifecycle: ObservationLifecycle, + config: StreamProfileConfig, + artifacts_dir: Path, + artifacts: dict[str, str], +) -> ServerMetricsExportData | None: + if collector is None: + return None + window = lifecycle.get_phase_window("profiling") + if window is None or window.end is None: + await collector.abort() + lifecycle.invalidate("Profiling phase did not produce a complete clock window") + return None + try: + results = await collector.finalize( + start_ns=window.start.wall_time_ns, + end_ns=window.end.wall_time_ns, + ) + except Exception as exc: # noqa: BLE001 - observability is fail-open + await collector.abort() + lifecycle.invalidate( + f"Server metrics finalization failed: {type(exc).__name__}: {exc}" + ) + return None + lifecycle.attach_server_metrics_results(results) + export = server_metrics_results_to_export_data( + results, + input_config=config.model_dump(mode="json"), + ) + path = await write_json_artifact( + artifacts_dir / "server_metrics_export.json", + export.model_dump(mode="json", exclude_none=True), + ) + artifacts["server_metrics"] = str(path) + if config.server_metrics.export_raw_jsonl: + artifacts["server_metrics_raw"] = str( + artifacts_dir / "server_metrics_export.jsonl" + ) + return export + + +async def _export_normalized_metrics( + *, + config: StreamProfileConfig, + identity: ObservationRunIdentity, + server_metrics_export: ServerMetricsExportData | None, + artifacts_dir: Path, + artifacts: dict[str, str], + lifecycle: ObservationLifecycle, +) -> list[str]: + reference = config.observability.mapping + if reference is None or server_metrics_export is None: + return [] + try: + mapping = _load_mapping(reference) + source = server_metrics_export_to_observations( + server_metrics_export, + framework=mapping.framework, + ) + result = SemanticMetricMapper(mapping).map_observations( + source, + capabilities=config.observability.capabilities, + ) + artifact = NormalizedMetricsArtifact( + created_at_ns=time.time_ns(), + identity=identity, + results=[result], + ) + path = await NormalizedMetricsExporter().export( + artifact, + artifacts_dir / "normalized_metrics.json", + ) + except Exception as exc: # noqa: BLE001 - observability is fail-open + lifecycle.invalidate( + f"Semantic metric mapping failed: {type(exc).__name__}: {exc}" + ) + return [] + artifacts["normalized_metrics"] = str(path) + return [mapping.mapping_version] + + +async def _prepare_context(config: StreamProfileConfig) -> _StreamProfileContext: + contract = validate_benchmark_contract_model( + load_benchmark_contract(config.contract), + source=config.contract, + ) + benchmark_id = str(uuid.uuid4()) + artifacts_dir = _run_directory(config, benchmark_id) + await aiofiles.os.makedirs(artifacts_dir, exist_ok=False) + identity = ObservationRunIdentity( + benchmark_id=benchmark_id, + mode=contract.mode, + implementation=contract.implementation, + model=contract.model, + contract_digest=benchmark_contract_digest(contract), + ) + lifecycle = ObservationLifecycle(identity) + artifacts: dict[str, str] = {} + contract_path = await write_json_artifact( + artifacts_dir / "benchmark_contract.json", + contract.model_dump(mode="json"), + ) + config_path = await write_json_artifact( + artifacts_dir / "stream_config.json", + config.model_dump(mode="json"), + ) + artifacts.update( + benchmark_contract=str(contract_path), + stream_config=str(config_path), + ) + adapter = create_stream_adapter( + contract=contract, + config=config, + artifacts_dir=artifacts_dir, + ) + return _StreamProfileContext( + config=config, + contract=contract, + benchmark_id=benchmark_id, + artifacts_dir=artifacts_dir, + identity=identity, + lifecycle=lifecycle, + artifacts=artifacts, + adapter=adapter, + target_metadata={}, + ) + + +async def _collect_target_metadata(context: _StreamProfileContext) -> None: + if not isinstance(context.adapter, StreamTargetMetadataProviderProtocol): + return + try: + metadata = await context.adapter.collect_target_metadata() + except Exception: # noqa: BLE001 - optional target metadata is fail-open + return + if not metadata: + return + context.target_metadata = metadata + path = await write_json_artifact( + context.artifacts_dir / "target_metadata.json", + metadata, + ) + context.artifacts["target_metadata"] = str(path) + + +async def _export_completed_profile( + context: _StreamProfileContext, + *, + collector: StandaloneServerMetricsCollector | None, + run_result: StreamRunResult, + started_at_iso: str, +) -> StreamProfileResult: + server_export = await _finalize_server_metrics( + collector=collector, + lifecycle=context.lifecycle, + config=context.config, + artifacts_dir=context.artifacts_dir, + artifacts=context.artifacts, + ) + stream_artifacts = await StreamArtifactExporter().export( + artifacts_dir=context.artifacts_dir, + config=context.config, + warmup_results=run_result.warmup_results, + profile_results=run_result.profile_results, + started_at_iso=started_at_iso, + target_metadata=context.target_metadata, + ) + context.artifacts.update(stream_artifacts) + mapping_versions = await _export_normalized_metrics( + config=context.config, + identity=context.identity, + server_metrics_export=server_export, + artifacts_dir=context.artifacts_dir, + artifacts=context.artifacts, + lifecycle=context.lifecycle, + ) + summary = _build_summary( + context.config, + run_result, + started_at_iso, + target_metadata=context.target_metadata, + ) + report_path = await StreamReportExporter().export( + path=context.artifacts_dir / "stream_report.html", + summary=summary, + session_results=[ + *run_result.warmup_results, + *run_result.profile_results, + ], + normalized_metrics_path=context.artifacts.get("normalized_metrics"), + ) + context.artifacts["stream_report"] = str(report_path) + manifest_path = context.artifacts_dir / "observability_manifest.json" + context.artifacts["observability_manifest"] = str(manifest_path) + manifest = context.lifecycle.build_manifest( + mapping_versions=mapping_versions, + artifacts=context.artifacts, + ) + await ObservationManifestExporter().export(manifest, manifest_path) + return StreamProfileResult( + benchmark_id=context.benchmark_id, + artifacts_dir=str(context.artifacts_dir), + summary=summary, + artifacts=context.artifacts, + ) + + +async def run_stream_profile(config: StreamProfileConfig) -> StreamProfileResult: + """Run one complete AIPerf stream profile from contract through artifacts.""" + + context = await _prepare_context(config) + collector: StandaloneServerMetricsCollector | None = None + resource_agent: ResourceTelemetryAgent | None = None + try: + await context.adapter.check_health() + await _collect_target_metadata(context) + collector = await _start_server_metrics( + config=config, + benchmark_id=context.benchmark_id, + artifacts_dir=context.artifacts_dir, + lifecycle=context.lifecycle, + ) + workload = ContractStreamWorkload(contract=context.contract, config=config) + started_at_ns = time.time_ns() + started_at_iso = datetime.fromtimestamp( + started_at_ns / 1_000_000_000, + tz=timezone.utc, + ).isoformat() + resource_agent = await start_stream_resource_agent( + config=context.config, + contract=context.contract, + identity=context.identity, + benchmark_id=context.benchmark_id, + artifacts_dir=context.artifacts_dir, + target_metadata=context.target_metadata, + started_at_ns=started_at_ns, + ) + run_result = await StreamBenchmarkRunner( + workload=workload, + transport=context.adapter, + lifecycle=context.lifecycle, + ).run(workload.build_run_plan()) + if resource_agent is not None: + await resource_agent.stop(status=resource_run_status(run_result)) + resource_agent = None + result = await _export_completed_profile( + context, + collector=collector, + run_result=run_result, + started_at_iso=started_at_iso, + ) + collector = None + return result + except asyncio.CancelledError: + if resource_agent is not None: + await asyncio.shield(resource_agent.stop(status="cancelled")) + resource_agent = None + raise + finally: + if resource_agent is not None: + await resource_agent.abort() + if collector is not None: + await collector.abort() + await context.adapter.aclose() + + +def _build_summary( + config: StreamProfileConfig, + result: StreamRunResult, + started_at_iso: str, + *, + target_metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + return build_stream_summary( + config=config, + warmup_results=result.warmup_results, + profile_results=result.profile_results, + started_at_iso=started_at_iso, + target_metadata=target_metadata, + ) + + +def run_stream_profile_from_path( + path: str | Path, + *, + overrides: dict[str, Any] | None = None, +) -> StreamProfileResult: + """Load a stream config and run it from a synchronous CLI boundary.""" + + config = load_stream_profile_config(path, overrides=overrides) + return asyncio.run(_run_stream_profile_with_sigterm(config)) + + +async def _run_stream_profile_with_sigterm( + config: StreamProfileConfig, +) -> StreamProfileResult: + """Cancel the CLI task on SIGTERM so active telemetry can final-flush.""" + + if IS_WINDOWS: + return await run_stream_profile(config) + loop = asyncio.get_running_loop() + task = asyncio.current_task() + if task is None: + return await run_stream_profile(config) + previous_handler = signal.getsignal(signal.SIGTERM) + try: + loop.add_signal_handler(signal.SIGTERM, task.cancel) + except (NotImplementedError, RuntimeError, ValueError): + return await run_stream_profile(config) + try: + return await run_stream_profile(config) + finally: + loop.remove_signal_handler(signal.SIGTERM) + signal.signal(signal.SIGTERM, previous_handler) diff --git a/src/aiperf/streaming/protocols.py b/src/aiperf/streaming/protocols.py new file mode 100644 index 0000000000..6a6fed3e83 --- /dev/null +++ b/src/aiperf/streaming/protocols.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from aiperf.streaming.models import SessionResult, StreamSessionPlan + + +@runtime_checkable +class StreamWorkloadProtocol(Protocol): + """Build transport-independent session plans for a benchmark phase.""" + + def build_session_plan( + self, + *, + phase: str, + logical_session_index: int, + ) -> StreamSessionPlan: ... + + +@runtime_checkable +class StreamTransportAdapterProtocol(Protocol): + """Execute a session using a target-specific wire transport.""" + + transport: str + + async def run_session(self, plan: StreamSessionPlan) -> SessionResult: ... + + +@runtime_checkable +class ManagedStreamTransportAdapterProtocol( + StreamTransportAdapterProtocol, + Protocol, +): + """Transport adapter with profile-owned health and resource lifecycle.""" + + async def check_health(self) -> None: ... + + async def aclose(self) -> None: ... + + +@runtime_checkable +class StreamTargetMetadataProviderProtocol(Protocol): + """Optionally expose target metadata through a contract-declared endpoint.""" + + async def collect_target_metadata(self) -> dict[str, Any]: ... + + +@runtime_checkable +class StreamResultsCollectorProtocol(Protocol): + """Consume completed transport-independent session results.""" + + def record_session_result(self, result: SessionResult) -> None: ... + + def build_summary(self) -> dict[str, Any]: ... + + +@runtime_checkable +class StreamMetricsCollectorProtocol(StreamResultsCollectorProtocol, Protocol): + """Backward-compatible name for stream result collectors.""" + + +@runtime_checkable +class StreamPhaseLifecycleProtocol(Protocol): + """Receive phase lifecycle events from a stream benchmark runner.""" + + async def on_phase_start( + self, + *, + phase: str, + expected_units: int, + ) -> None: ... + + async def on_phase_complete( + self, + *, + phase: str, + successful_units: int, + failed_units: int, + ) -> None: ... + + async def on_run_cancelled(self, *, error: str) -> None: ... diff --git a/src/aiperf/streaming/report.py b/src/aiperf/streaming/report.py new file mode 100644 index 0000000000..2d977261f1 --- /dev/null +++ b/src/aiperf/streaming/report.py @@ -0,0 +1,302 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import html +from pathlib import Path +from typing import Any + +import aiofiles +import aiofiles.os +import orjson + +from aiperf.streaming.models import SessionResult + + +def _escape(value: Any) -> str: + return html.escape(str(value), quote=True) + + +def _format_value(value: Any) -> str: + if value is None: + return "—" + if isinstance(value, float): + return f"{value:,.3f}" + return _escape(value) + + +def _overview_cards(summary: dict[str, Any]) -> str: + profile = summary.get("profile", {}) + steady_state = profile.get("chunk_steady_state", {}) + cards = [ + ("Attempted", profile.get("attempted_sessions", 0)), + ("Successful", profile.get("successful_sessions", 0)), + ("Failed", profile.get("failed_sessions", 0)), + ("Success rate", f"{float(profile.get('success_rate', 0)) * 100:.1f}%"), + ("Steady frames", steady_state.get("total_frames", 0)), + ("Compute FPS", _format_value(steady_state.get("frames_per_second"))), + ] + return "".join( + f'
{_escape(label)}' + f"{_escape(value)}
" + for label, value in cards + ) + + +def _metric_rows(summary: dict[str, Any]) -> str: + metrics = summary.get("profile", {}).get("metrics", {}) + rows: list[str] = [] + for metric_name, statistics in metrics.items(): + values = statistics if isinstance(statistics, dict) else {} + rows.append( + "" + f"{_escape(metric_name)}" + f"{_format_value(values.get('count'))}" + f"{_format_value(values.get('min'))}" + f"{_format_value(values.get('mean'))}" + f"{_format_value(values.get('p50'))}" + f"{_format_value(values.get('p90'))}" + f"{_format_value(values.get('p99'))}" + f"{_format_value(values.get('max'))}" + f"{_format_value(values.get('std'))}" + "" + ) + return "".join(rows) + + +def _session_rows(results: list[SessionResult]) -> str: + rows: list[str] = [] + for result in results: + status = "success" if result.success else "failed" + total_compute = sum( + float(chunk.compute_seconds) for chunk in result.chunk_measurements + ) + total_frames = sum(chunk.frames for chunk in result.chunk_measurements) + compute_fps = total_frames / total_compute if total_compute > 0 else None + rows.append( + "" + f"{_escape(result.phase)}" + f"{result.logical_session_index}" + f'{status}' + f"{_escape(result.session_id)}" + f"{result.frames_received}" + f"{_format_value(result.stream_fps)}" + f"{_format_value(result.connected_latency_ms)}" + f"{_format_value(result.first_frame_latency_ms)}" + f"{_format_value(result.session_runtime_s)}" + f"{len(result.chunk_measurements)}" + f"{_format_value(compute_fps)}" + f"{_escape(result.error or '')}" + "" + ) + return "".join(rows) + + +def _memory_value(memory: list[Any], field: str) -> str: + if not memory: + return "—" + values = [ + f"{_escape(item.device)}={_format_value(getattr(item, field))}" + for item in memory + if getattr(item, field) is not None + ] + return ", ".join(values) or "—" + + +def _phase_rows(results: list[SessionResult]) -> str: + rows: list[str] = [] + for result in results: + for phase in result.phase_measurements: + rows.append( + "" + f"{_escape(result.phase)}" + f"{_escape(result.session_id)}" + f"{_escape(phase.name)}" + f"{_format_value(phase.seconds)}" + f"{_memory_value(phase.memory, 'peak_allocated_bytes')}" + f"{_memory_value(phase.memory, 'peak_reserved_bytes')}" + "" + ) + return "".join(rows) or ( + 'No target phase measurements.' + ) + + +def _chunk_rows(results: list[SessionResult]) -> str: + rows: list[str] = [] + for result in results: + for chunk in sorted(result.chunk_measurements, key=lambda item: item.index): + fps = ( + chunk.frames / float(chunk.compute_seconds) + if chunk.compute_seconds > 0 + else None + ) + rows.append( + "" + f"{_escape(result.phase)}" + f"{_escape(result.session_id)}" + f"{chunk.index}" + f"{chunk.frames}" + f"{_format_value(chunk.request_prepare_seconds)}" + f"{_format_value(chunk.compute_seconds)}" + f"{_format_value(chunk.encode_seconds)}" + f"{_format_value(chunk.output_pacing_seconds)}" + f"{_format_value(chunk.output_header_write_seconds)}" + f"{_format_value(chunk.output_payload_write_seconds)}" + f"{_format_value(chunk.output_write_seconds)}" + f"{_format_value(chunk.total_seconds)}" + f"{_format_value(fps)}" + f"{_format_value(chunk.raw_output_bytes)}" + f"{_format_value(chunk.wire_output_bytes)}" + f"{_format_value(chunk.output_batches)}" + f"{_escape(chunk.output_content_type or '')}" + f"{_memory_value(chunk.memory, 'peak_allocated_bytes')}" + f"{_memory_value(chunk.memory, 'peak_reserved_bytes')}" + "" + ) + return "".join(rows) or ( + 'No target chunk measurements.' + ) + + +def _flatten_metadata( + payload: Any, + *, + prefix: str = "", +) -> list[tuple[str, str]]: + if isinstance(payload, dict): + rows: list[tuple[str, str]] = [] + for key in sorted(payload): + path = f"{prefix}.{key}" if prefix else str(key) + rows.extend(_flatten_metadata(payload[key], prefix=path)) + return rows + if isinstance(payload, list): + return [(prefix, orjson.dumps(payload).decode())] + return [(prefix, str(payload))] + + +def _metadata_rows(summary: dict[str, Any], results: list[SessionResult]) -> str: + rows: list[str] = [] + for key, value in _flatten_metadata(summary.get("target_metadata", {})): + rows.append( + f"target{_escape(key)}{_escape(value)}" + ) + for result in results: + scope = f"session:{result.session_id}" + for key, value in _flatten_metadata(result.runtime_metadata): + rows.append( + f"{_escape(scope)}" + f"{_escape(key)}{_escape(value)}" + ) + return "".join(rows) or ( + 'No target runtime metadata.' + ) + + +async def _normalized_rows(path: str | Path | None) -> str: + if path is None or not await aiofiles.os.path.exists(path): + return 'No normalized metrics exported.' + async with aiofiles.open(path, "rb") as file: + payload = orjson.loads(await file.read()) + rows: list[str] = [] + for result in payload.get("results", []): + framework = result.get("framework", "") + for observation in result.get("observations", []): + state = str(observation.get("state", "unknown")) + values = observation.get("values", {}) + rendered_values = ", ".join( + f"{_escape(key)}={_format_value(value)}" + for key, value in values.items() + ) + rows.append( + "" + f"{_escape(framework)}" + f"{_escape(observation.get('metric_name', ''))}" + f'{_escape(state)}' + f"{_escape(observation.get('unit', ''))}" + f"{rendered_values or '—'}" + f"{_escape(observation.get('reason') or '')}" + "" + ) + return "".join(rows) or ( + 'No normalized observations.' + ) + + +class StreamReportExporter: + """Write a self-contained, framework-neutral HTML view of stream artifacts.""" + + async def export( + self, + *, + path: str | Path, + summary: dict[str, Any], + session_results: list[SessionResult], + normalized_metrics_path: str | Path | None = None, + ) -> Path: + """Render canonical stream and normalized metrics into one HTML file.""" + + config = summary.get("config", {}) + normalized_rows = await _normalized_rows(normalized_metrics_path) + document = f""" + + + + + AIPerf Stream Report + + +
+

AIPerf Stream Report

+
Started {_escape(summary.get("started_at_utc", ""))} · + {_escape(config.get("server_url", ""))} · {_escape(config.get("mode", "stream"))}
+
{_overview_cards(summary)}
+

Profile metrics

+
+ {_metric_rows(summary)}
MetricCountMinMeanP50P90P99MaxStd
+

Sessions

+
+ + {_session_rows(session_results)}
PhaseIndexStatusSession IDFramesFPSConnected msFirst frame msRuntime sChunksCompute FPSError
+

Target phases

+
+ + {_phase_rows(session_results)}
Profile phaseSession IDTarget phaseSecondsPeak allocated bytesPeak reserved bytes
+

Target chunks

+
+ + + + {_chunk_rows(session_results)}
PhaseSession IDChunkFramesPrepare sCompute sEncode sPacing sHeader write sPayload write sOutput write sTotal sCompute FPSRaw bytesWire bytesBatchesContent typePeak allocated bytesPeak reserved bytes
+

Target metadata

+
+ {_metadata_rows(summary, session_results)}
ScopeKeyValue
+

Normalized server metrics

+
+ {normalized_rows}
FrameworkMetricStateUnitValuesReason
+
+""" + output = Path(path) + await aiofiles.os.makedirs(output.parent, exist_ok=True) + async with aiofiles.open(output, "w", encoding="utf-8") as file: + await file.write(document) + return output diff --git a/src/aiperf/streaming/runner.py b/src/aiperf/streaming/runner.py new file mode 100644 index 0000000000..b32e9fabcb --- /dev/null +++ b/src/aiperf/streaming/runner.py @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio + +from pydantic import ConfigDict, Field + +from aiperf.common.finite import FiniteFloat +from aiperf.common.models import AIPerfBaseModel +from aiperf.common.redact import redact_string +from aiperf.streaming.models import SessionResult, StreamSessionPlan +from aiperf.streaming.protocols import ( + StreamPhaseLifecycleProtocol, + StreamResultsCollectorProtocol, + StreamTransportAdapterProtocol, + StreamWorkloadProtocol, +) + + +class StreamRunPlan(AIPerfBaseModel): + """Session counts and pacing shared by all stream transports.""" + + model_config = ConfigDict(extra="forbid") + + warmup_sessions: int = Field( + default=0, + ge=0, + description="Number of warmup sessions excluded from formal results.", + ) + profile_sessions: int = Field( + gt=0, + description="Number of sessions included in formal results.", + ) + stagger_s: FiniteFloat = Field( + default=0, + ge=0, + description="Delay between consecutive session starts in seconds.", + ) + + +class StreamRunResult(AIPerfBaseModel): + """Completed warmup and profiling session results.""" + + model_config = ConfigDict(extra="forbid") + + warmup_results: list[SessionResult] = Field( + default_factory=list, + description="Session results from the warmup phase.", + ) + profile_results: list[SessionResult] = Field( + default_factory=list, + description="Session results from the profiling phase.", + ) + + +class StreamBenchmarkRunner: + """Run transport-independent streaming sessions through adapter protocols.""" + + def __init__( + self, + *, + workload: StreamWorkloadProtocol, + transport: StreamTransportAdapterProtocol, + results_collector: StreamResultsCollectorProtocol | None = None, + lifecycle: StreamPhaseLifecycleProtocol | None = None, + ) -> None: + self._workload = workload + self._transport = transport + self._results_collector = results_collector + self._lifecycle = lifecycle + + async def _run_session( + self, + *, + plan: StreamSessionPlan, + delay_s: float, + ) -> SessionResult: + if delay_s > 0: + await asyncio.sleep(delay_s) + try: + return await self._transport.run_session(plan) + except Exception as exc: # noqa: BLE001 - adapter failures become session records + return SessionResult( + logical_session_index=plan.logical_session_index, + phase=plan.phase, + mode=plan.mode, + planned_session_id=plan.planned_session_id, + session_id=plan.planned_session_id, + error=redact_string(f"{type(exc).__name__}: {exc}"), + ) + + async def run_phase( + self, + *, + phase: str, + session_count: int, + stagger_s: float, + ) -> list[SessionResult]: + """Run one benchmark phase and notify the shared lifecycle.""" + + if session_count < 0: + raise ValueError("session_count must be non-negative") + if stagger_s < 0: + raise ValueError("stagger_s must be non-negative") + + if self._lifecycle is not None: + await self._lifecycle.on_phase_start( + phase=phase, + expected_units=session_count, + ) + + tasks: list[asyncio.Task[SessionResult]] = [] + try: + plans = [ + self._workload.build_session_plan( + phase=phase, + logical_session_index=index, + ) + for index in range(session_count) + ] + tasks = [ + asyncio.create_task( + self._run_session(plan=plan, delay_s=stagger_s * index) + ) + for index, plan in enumerate(plans) + ] + results = list(await asyncio.gather(*tasks)) if tasks else [] + + if self._results_collector is not None: + for result in results: + self._results_collector.record_session_result(result) + + successful = sum(1 for result in results if result.success) + if self._lifecycle is not None: + await self._lifecycle.on_phase_complete( + phase=phase, + successful_units=successful, + failed_units=len(results) - successful, + ) + return results + except asyncio.CancelledError: + await self._cancel_tasks(tasks) + await self._notify_aborted(f"Stream phase {phase!r} was cancelled") + raise + except Exception as exc: # noqa: BLE001 - lifecycle boundary reports aborts + await self._cancel_tasks(tasks) + await self._notify_aborted( + redact_string( + f"Stream phase {phase!r} aborted: {type(exc).__name__}: {exc}" + ) + ) + raise + + @staticmethod + async def _cancel_tasks(tasks: list[asyncio.Task[SessionResult]]) -> None: + pending = [task for task in tasks if not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + async def _notify_aborted(self, error: str) -> None: + if self._lifecycle is not None: + await self._lifecycle.on_run_cancelled(error=error) + + async def run(self, plan: StreamRunPlan) -> StreamRunResult: + """Run warmup and profiling with one workload and transport adapter.""" + + warmup_results = await self.run_phase( + phase="warmup", + session_count=plan.warmup_sessions, + stagger_s=plan.stagger_s, + ) + profile_results = await self.run_phase( + phase="profiling", + session_count=plan.profile_sessions, + stagger_s=plan.stagger_s, + ) + return StreamRunResult( + warmup_results=warmup_results, + profile_results=profile_results, + ) diff --git a/src/aiperf/streaming/transports.py b/src/aiperf/streaming/transports.py new file mode 100644 index 0000000000..f72d467372 --- /dev/null +++ b/src/aiperf/streaming/transports.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from aiperf.common.models import AIPerfBaseModel + + +class StreamTransportDescriptor(AIPerfBaseModel): + """Static capabilities for a stream benchmark transport family.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str = Field(min_length=1, description="Canonical transport name.") + session_lifecycle: str = Field( + min_length=1, + description="Canonical session lifecycle implemented by the transport.", + ) + bidirectional_controls: bool = Field( + description="Whether the transport supports client control messages.", + ) + frame_events: bool = Field( + description="Whether the transport exposes individual frame events.", + ) + + +STREAM_TRANSPORT_DESCRIPTORS: dict[str, StreamTransportDescriptor] = { + "http_polling": StreamTransportDescriptor( + name="http_polling", + session_lifecycle="submit_poll_delete", + bidirectional_controls=False, + frame_events=False, + ), + "websocket": StreamTransportDescriptor( + name="websocket", + session_lifecycle="connect_messages_close", + bidirectional_controls=True, + frame_events=True, + ), + "sse": StreamTransportDescriptor( + name="sse", + session_lifecycle="connect_events_close", + bidirectional_controls=False, + frame_events=True, + ), + "webrtc": StreamTransportDescriptor( + name="webrtc", + session_lifecycle="offer_answer_rtp_datachannel_delete", + bidirectional_controls=True, + frame_events=True, + ), +} + + +def get_stream_transport_descriptor(name: str) -> StreamTransportDescriptor: + """Return the descriptor used by contract validation and harnesses.""" + + try: + return STREAM_TRANSPORT_DESCRIPTORS[name] + except KeyError as exc: + available = ", ".join(sorted(STREAM_TRANSPORT_DESCRIPTORS)) + raise ValueError( + f"Unknown stream transport {name!r}. Available: {available}" + ) from exc diff --git a/src/aiperf/streaming/workload.py b/src/aiperf/streaming/workload.py new file mode 100644 index 0000000000..87a049b57a --- /dev/null +++ b/src/aiperf/streaming/workload.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any + +from aiperf.common.path_safety import safe_read_template_path +from aiperf.streaming.config import StreamProfileConfig +from aiperf.streaming.contracts import BenchmarkContract +from aiperf.streaming.models import ( + StreamEndpointPaths, + StreamSessionPlan, + load_control_trace, +) +from aiperf.streaming.runner import StreamRunPlan + + +def _value( + override: Any, + workload: dict[str, Any], + name: str, + *, + default: Any = None, +) -> Any: + if override is not None: + return override + return workload.get(name, default) + + +class ContractStreamWorkload: + """Build normalized session plans from a target contract and run config.""" + + def __init__( + self, + *, + contract: BenchmarkContract, + config: StreamProfileConfig, + ) -> None: + if contract.mode != "stream_world": + raise ValueError("ContractStreamWorkload requires mode='stream_world'") + self.contract = contract + self.config = config + workload = contract.workload + control_path = config.control_trace_path or workload.get("control_trace") + if ( + control_path is not None + and safe_read_template_path(str(control_path)) is None + ): + relative = Path(config.contract).parent / str(control_path) + if safe_read_template_path(str(relative)) is not None: + control_path = str(relative) + self._mode = str(_value(config.mode, workload, "mode", default="server_push")) + self._control_trace = load_control_trace(control_path, self._mode) + self._request_extra = dict(workload.get("request_extra") or {}) + self._request_extra.update(config.request_extra) + + def build_run_plan(self) -> StreamRunPlan: + """Build common phase counts and pacing from config overrides.""" + + workload = self.contract.workload + return StreamRunPlan( + warmup_sessions=int( + _value( + self.config.warmup_sessions, workload, "warmup_sessions", default=0 + ) + ), + profile_sessions=int( + _value(self.config.session_count, workload, "session_count", default=1) + ), + stagger_s=float(self.config.stagger_s), + ) + + def build_session_plan( + self, + *, + phase: str, + logical_session_index: int, + ) -> StreamSessionPlan: + workload = self.contract.workload + endpoint = self.contract.endpoint + session_path = endpoint.get("offer_path") or endpoint.get("websocket_path") + if not session_path: + raise ValueError( + "Stream contract endpoint requires offer_path or websocket_path" + ) + return StreamSessionPlan( + logical_session_index=logical_session_index, + phase=phase, + planned_session_id=( + f"{phase}-{logical_session_index:03d}-{uuid.uuid4().hex[:10]}" + ), + server_url=self.config.server_url, + endpoints=StreamEndpointPaths( + health_path=str(endpoint.get("health_path", "/health")), + offer_path=str(session_path), + delete_path_template=endpoint.get("delete_path_template"), + ), + mode=self._mode, + task=str(_value(self.config.task, workload, "task", default=self._mode)), + prompt=self.config.prompt, + fps=int(_value(self.config.fps, workload, "fps", default=16)), + session_duration_s=float( + _value( + self.config.session_duration_s, + workload, + "session_duration_s", + default=12.0, + ) + ), + request_extra=dict(self._request_extra), + image_path=self.config.image_path, + control_trace=list(self._control_trace), + ) diff --git a/tests/unit/cli_commands/test_history.py b/tests/unit/cli_commands/test_history.py new file mode 100644 index 0000000000..416fc2f606 --- /dev/null +++ b/tests/unit/cli_commands/test_history.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from aiperf.cli_commands.history import ingest, serve +from aiperf.history.models import ImportResult + + +def test_history_serve_builds_required_greptime_config(tmp_path: Path) -> None: + with patch("aiperf.history.service.run_history_service") as run_service: + serve( + artifact_roots=[tmp_path], + greptime_url="http://greptime:4000", + greptime_database="benchmarks", + table_prefix="history_test", + scan_interval_seconds=15, + host="0.0.0.0", + port=9000, + ) + + config = run_service.call_args.args[0] + assert str(config.greptime.url) == "http://greptime:4000/" + assert config.greptime.database == "benchmarks" + assert config.greptime.table_prefix == "history_test" + assert config.artifact_roots == [tmp_path] + assert config.port == 9000 + + +def test_history_ingest_forwards_force_and_prints_result( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + result = ImportResult( + discovered=1, + imported=1, + skipped=0, + failed=0, + metric_points=10, + ) + with patch( + "aiperf.history.service.ingest_history_once", + new=AsyncMock(return_value=result), + ) as import_once: + ingest( + artifact_roots=[tmp_path], + greptime_url="http://greptime:4000", + force=True, + ) + + assert import_once.await_args.kwargs["force"] is True + assert '"metric_points": 10' in capsys.readouterr().out diff --git a/tests/unit/cli_commands/test_profile_stream.py b/tests/unit/cli_commands/test_profile_stream.py new file mode 100644 index 0000000000..6a4e853954 --- /dev/null +++ b/tests/unit/cli_commands/test_profile_stream.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from aiperf.cli_commands.profile import ( + StreamProfileCLIConfig, + profile, +) +from aiperf.cli_commands.profile import ( + app as profile_app, +) +from aiperf.config.flags import CLIConfig +from aiperf.streaming.profile import StreamProfileResult + + +def test_profile_dispatches_stream_config_before_request_pipeline( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Stream config dispatches before the request/response pipeline.""" + + config_path = tmp_path / "stream.yaml" + result = StreamProfileResult( + benchmark_id="benchmark-1", + artifacts_dir=str(tmp_path / "artifacts"), + summary={"profile": {"successful_sessions": 2, "attempted_sessions": 2}}, + artifacts={}, + ) + + with patch( + "aiperf.streaming.profile.run_stream_profile_from_path", + return_value=result, + ) as run_stream: + profile( + cli_config=CLIConfig(), + stream_cli=StreamProfileCLIConfig( + config=config_path, + server_url="http://127.0.0.1:8088", + artifacts_dir=tmp_path / "override", + ice_host_ips=["10.0.0.1"], + server_metrics_urls=["http://127.0.0.1:8088/metrics"], + resource_history_url="http://127.0.0.1:8095", + resource_target_pid=123, + ), + ) + + run_stream.assert_called_once_with( + config_path, + overrides={ + "server_url": "http://127.0.0.1:8088", + "artifacts_dir": str(tmp_path / "override"), + "transport": {"ice_host_ips": ["10.0.0.1"]}, + "server_metrics": { + "enabled": True, + "urls": ["http://127.0.0.1:8088/metrics"], + }, + "resource_telemetry": { + "enabled": True, + "history_url": "http://127.0.0.1:8095", + "target_pid": 123, + }, + }, + ) + assert "2/2 succeeded" in capsys.readouterr().out + + +def test_profile_app_parses_stream_cli_flags(tmp_path: Path) -> None: + """The real Cyclopts app preserves all public stream flag names.""" + + config_path = tmp_path / "stream.yaml" + result = StreamProfileResult( + benchmark_id="benchmark-1", + artifacts_dir=str(tmp_path / "artifacts"), + summary={"profile": {"successful_sessions": 1, "attempted_sessions": 1}}, + artifacts={}, + ) + + with ( + patch( + "aiperf.streaming.profile.run_stream_profile_from_path", + return_value=result, + ) as run_stream, + pytest.raises(SystemExit) as exit_info, + ): + profile_app( + [ + "--stream-config", + str(config_path), + "--stream-server-url", + "http://127.0.0.1:8088", + "--stream-artifacts-dir", + str(tmp_path / "override"), + "--stream-ice-host-ip", + "10.0.0.1", + "--stream-ice-host-ip", + "10.0.0.2", + "--stream-server-metrics-url", + "http://127.0.0.1:8088/metrics", + "--stream-resource-history-url", + "http://127.0.0.1:8095", + "--stream-resource-target-pid", + "123", + ], + exit_on_error=False, + ) + + assert exit_info.value.code == 0 + + run_stream.assert_called_once_with( + config_path, + overrides={ + "server_url": "http://127.0.0.1:8088", + "artifacts_dir": str(tmp_path / "override"), + "transport": {"ice_host_ips": ["10.0.0.1", "10.0.0.2"]}, + "server_metrics": { + "enabled": True, + "urls": ["http://127.0.0.1:8088/metrics"], + }, + "resource_telemetry": { + "enabled": True, + "history_url": "http://127.0.0.1:8095", + "target_pid": 123, + }, + }, + ) + + +def test_profile_app_preserves_request_cli_dispatch() -> None: + """Request profile flags still populate the existing flat CLI DTO.""" + + with ( + patch("aiperf.cli_commands.profile._run_request_profile") as run_request, + pytest.raises(SystemExit) as exit_info, + ): + profile_app( + [ + "--url", + "http://127.0.0.1:8000", + "--model", + "test-model", + ], + exit_on_error=False, + ) + + assert exit_info.value.code == 0 + cli_config = run_request.call_args.args[0] + assert cli_config.urls == ["http://127.0.0.1:8000"] + assert cli_config.model_names == ["test-model"] + + +def test_stream_override_requires_stream_config() -> None: + """A stream-only override cannot be silently routed to request profiling.""" + + with pytest.raises(ValueError, match="--stream-config is required"): + profile( + stream_cli=StreamProfileCLIConfig( + server_url="http://127.0.0.1:8088", + ) + ) diff --git a/tests/unit/common/mixins/test_health_server_mixin.py b/tests/unit/common/mixins/test_health_server_mixin.py index ea6f468f5c..6886e97a8b 100644 --- a/tests/unit/common/mixins/test_health_server_mixin.py +++ b/tests/unit/common/mixins/test_health_server_mixin.py @@ -97,11 +97,13 @@ class TestHealthServerMixin: """Test HealthServerMixin functionality.""" @pytest.mark.asyncio - async def test_start_and_stop_server(self, mock_env_settings) -> None: + async def test_start_and_stop_server( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test starting and stopping the health server.""" service = MockServiceWithHealthServer() - with mock_env_settings(enabled=True, port=18080): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() assert service._health_server is not None @@ -122,114 +124,128 @@ async def test_server_not_started_when_disabled(self, mock_env_settings) -> None service.info.assert_not_called() @pytest.mark.asyncio - async def test_healthz_returns_ok_when_healthy(self, mock_env_settings) -> None: + async def test_healthz_returns_ok_when_healthy( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test /healthz returns 200 when service is healthy.""" service = MockServiceWithHealthServer(LifecycleState.RUNNING) - with mock_env_settings(enabled=True, port=18081): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() try: - status, body = await make_http_request(18081, "/healthz") + status, body = await make_http_request(unused_tcp_port, "/healthz") assert status == 200 assert body == "ok" finally: await service._health_server_stop() @pytest.mark.asyncio - async def test_healthz_returns_503_when_failed(self, mock_env_settings) -> None: + async def test_healthz_returns_503_when_failed( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test /healthz returns 503 when service has failed.""" service = MockServiceWithHealthServer(LifecycleState.FAILED) - with mock_env_settings(enabled=True, port=18082): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() try: - status, body = await make_http_request(18082, "/healthz") + status, body = await make_http_request(unused_tcp_port, "/healthz") assert status == 503 assert body == "unhealthy" finally: await service._health_server_stop() @pytest.mark.asyncio - async def test_readyz_returns_ok_when_running(self, mock_env_settings) -> None: + async def test_readyz_returns_ok_when_running( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test /readyz returns 200 when service is running.""" service = MockServiceWithHealthServer(LifecycleState.RUNNING) - with mock_env_settings(enabled=True, port=18083): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() try: - status, body = await make_http_request(18083, "/readyz") + status, body = await make_http_request(unused_tcp_port, "/readyz") assert status == 200 assert body == "ok" finally: await service._health_server_stop() @pytest.mark.asyncio - async def test_readyz_returns_503_when_not_ready(self, mock_env_settings) -> None: + async def test_readyz_returns_503_when_not_ready( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test /readyz returns 503 when service is not ready.""" service = MockServiceWithHealthServer(LifecycleState.INITIALIZING) - with mock_env_settings(enabled=True, port=18084): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() try: - status, body = await make_http_request(18084, "/readyz") + status, body = await make_http_request(unused_tcp_port, "/readyz") assert status == 503 assert body == "not ready" finally: await service._health_server_stop() @pytest.mark.asyncio - async def test_unknown_path_returns_404(self, mock_env_settings) -> None: + async def test_unknown_path_returns_404( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test unknown paths return 404.""" service = MockServiceWithHealthServer() - with mock_env_settings(enabled=True, port=18085): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() try: - status, body = await make_http_request(18085, "/unknown") + status, body = await make_http_request(unused_tcp_port, "/unknown") assert status == 404 assert body == "Not Found" finally: await service._health_server_stop() @pytest.mark.asyncio - async def test_custom_host_and_port(self, mock_env_settings) -> None: + async def test_custom_host_and_port( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test health server starts on custom host and port.""" service = MockServiceWithHealthServer() - with mock_env_settings(enabled=True, host="127.0.0.1", port=18086): + with mock_env_settings(enabled=True, host="127.0.0.1", port=unused_tcp_port): await service._health_server_start() assert service._health_server is not None # Verify we can connect - status, body = await make_http_request(18086, "/healthz") + status, body = await make_http_request(unused_tcp_port, "/healthz") assert status == 200 assert body == "ok" await service._health_server_stop() @pytest.mark.asyncio - async def test_state_change_affects_responses(self, mock_env_settings) -> None: + async def test_state_change_affects_responses( + self, mock_env_settings, unused_tcp_port: int + ) -> None: """Test that changing state affects health responses.""" service = MockServiceWithHealthServer(LifecycleState.INITIALIZING) - with mock_env_settings(enabled=True, port=18087): + with mock_env_settings(enabled=True, port=unused_tcp_port): await service._health_server_start() try: # Initially not ready - status, _ = await make_http_request(18087, "/readyz") + status, _ = await make_http_request(unused_tcp_port, "/readyz") assert status == 503 # Change to RUNNING service._state = LifecycleState.RUNNING # Now should be ready - status, body = await make_http_request(18087, "/readyz") + status, body = await make_http_request(unused_tcp_port, "/readyz") assert status == 200 assert body == "ok" finally: diff --git a/tests/unit/config/test_converter_telemetry_otel.py b/tests/unit/config/test_converter_telemetry_otel.py index 174931c107..90ecb0e8ac 100644 --- a/tests/unit/config/test_converter_telemetry_otel.py +++ b/tests/unit/config/test_converter_telemetry_otel.py @@ -13,6 +13,9 @@ from __future__ import annotations +import textwrap +from pathlib import Path + import pytest from aiperf.common.enums import ServerMetricsFormat @@ -22,6 +25,7 @@ build_server_metrics, ) from aiperf.config.flags.cli_config import CLIConfig +from aiperf.config.flags.resolver import resolve_config def _make_cli(**overrides) -> CLIConfig: @@ -49,6 +53,78 @@ def test_no_server_metrics_with_server_metrics_raises(self): build_server_metrics(cli) +_YAML_SERVER_METRICS_BASE = textwrap.dedent("""\ +benchmark: + models: + - test-model + endpoint: + urls: + - http://localhost:8000/v1/videos + datasets: + - name: default + type: synthetic + entries: 1 + prompts: + isl: 8 + osl: 1 + phases: + - name: profiling + type: concurrency + requests: 1 + concurrency: 1 + server_metrics: + enabled: true + urls: + - http://localhost:8000/old/metrics + formats: + - csv +""") + + +class TestServerMetricsYamlCliOverlay: + @staticmethod + def _config_file(tmp_path: Path) -> Path: + config_file = tmp_path / "base.yaml" + config_file.write_text(_YAML_SERVER_METRICS_BASE) + return config_file + + def test_custom_url_overrides_yaml_without_replacing_formats( + self, tmp_path: Path + ) -> None: + config = resolve_config( + CLIConfig( + server_metrics=[ + "http://127.0.0.1:8000/v1/service/metrics", + ] + ), + self._config_file(tmp_path), + ) + + assert config.benchmark.server_metrics.urls == [ + "http://127.0.0.1:8000/v1/service/metrics" + ] + assert config.benchmark.server_metrics.formats == [ServerMetricsFormat.CSV] + + def test_formats_override_preserves_yaml_url(self, tmp_path: Path) -> None: + config = resolve_config( + CLIConfig(server_metrics_formats=[ServerMetricsFormat.JSON]), + self._config_file(tmp_path), + ) + + assert config.benchmark.server_metrics.urls == [ + "http://localhost:8000/old/metrics" + ] + assert config.benchmark.server_metrics.formats == [ServerMetricsFormat.JSON] + + def test_no_server_metrics_disables_yaml_collection(self, tmp_path: Path) -> None: + config = resolve_config( + CLIConfig(no_server_metrics=True), + self._config_file(tmp_path), + ) + + assert config.benchmark.server_metrics.enabled is False + + class TestOtelUrlNormalization: @pytest.mark.parametrize( "raw,expected", diff --git a/tests/unit/endpoints/test_video_generation_endpoint.py b/tests/unit/endpoints/test_video_generation_endpoint.py index ec5e91189d..07a59e45b0 100644 --- a/tests/unit/endpoints/test_video_generation_endpoint.py +++ b/tests/unit/endpoints/test_video_generation_endpoint.py @@ -2,9 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for VideoGenerationEndpoint.""" +import base64 + import pytest -from aiperf.common.models import Text, Turn +from aiperf.common.models import Image, Text, Turn from aiperf.endpoints.openai_video_generation import VideoGenerationEndpoint from aiperf.plugin.enums import EndpointType from tests.unit.endpoints.conftest import ( @@ -120,6 +122,35 @@ def test_extra_body_shallow_merges_into_payload(self, endpoint, model_endpoint): ) assert payload["vendor_fps"] == 24 + def test_extra_body_cannot_override_endpoint_managed_media( + self, endpoint, model_endpoint + ): + """Media fields are derived from turn media, not per-request extra fields.""" + png_bytes = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + ) + image_content = ( + f"data:image/png;base64,{base64.b64encode(png_bytes).decode('ascii')}" + ) + turn = Turn( + texts=[Text(contents=["animate"])], + images=[Image(contents=[image_content])], + extra_body={ + "input_reference": "malicious-override", + "reference_url": "https://example.com/override.png", + "vendor_fps": 24, + }, + ) + + payload = endpoint.format_payload( + create_request_info(model_endpoint=model_endpoint, turns=[turn]) + ) + + assert payload["input_reference"]["b64_data"] != "malicious-override" + assert "reference_url" not in payload + assert payload["vendor_fps"] == 24 + def test_format_payload_uses_dispatching_turn(self, endpoint, model_endpoint): parent_turn = Turn( texts=[Text(contents=["parent video"])], diff --git a/tests/unit/history/__init__.py b/tests/unit/history/__init__.py new file mode 100644 index 0000000000..52a7a9daf0 --- /dev/null +++ b/tests/unit/history/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/history/test_api.py b/tests/unit/history/test_api.py new file mode 100644 index 0000000000..5a3a3ab236 --- /dev/null +++ b/tests/unit/history/test_api.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from aiperf.history.api import history_router +from aiperf.history.config import GreptimeConfig, HistoryServiceConfig +from aiperf.history.greptime import GreptimeError +from aiperf.history.models import HistoryRunRecord +from aiperf.history.service import create_history_app +from aiperf.resource_telemetry.models import ( + ResourceRunDescriptor, + ResourceSample, + ResourceTelemetryBatch, +) + + +def _run() -> HistoryRunRecord: + timestamp = datetime(2026, 1, 1, tzinfo=timezone.utc) + return HistoryRunRecord( + run_id="run-1", + benchmark_id="run-1", + run_kind="stream", + started_at=timestamp, + ended_at=timestamp, + ingested_at=timestamp, + status="completed", + implementation="telefuser", + model="model", + model_family="family", + mode="stream_world", + scene="scene", + task="task", + transport="webrtc", + hardware="H100", + aiperf_version="0.11.0", + aiperf_commit="abc", + contract_digest="digest", + artifact_path="/artifacts/run", + artifact_digest="artifact-digest", + metric_count=1, + session_count=1, + ) + + +class _APIRepository: + def __init__(self) -> None: + self.resource_batches: list[ResourceTelemetryBatch] = [] + + async def ping(self) -> None: + return None + + async def list_runs(self, **_: Any) -> tuple[list[HistoryRunRecord], int]: + return [_run()], 1 + + async def get_run(self, run_id: str) -> HistoryRunRecord | None: + return _run() if run_id == "run-1" else None + + async def metric_catalog(self, **_: Any) -> list[dict[str, Any]]: + return [ + { + "metric_name": "chunk_compute_fps", + "statistic": "mean", + "scope": "run", + "unit": "frames/second", + "point_count": 1, + } + ] + + async def metric_series(self, **_: Any) -> list[dict[str, Any]]: + return [_metric_row()] + + async def run_metric_values(self, *_: Any, **__: Any) -> list[dict[str, Any]]: + return [_metric_row()] + + async def facets(self) -> dict[str, list[str]]: + return {"implementation": ["telefuser"]} + + async def ingest_resource_batch(self, batch: ResourceTelemetryBatch) -> int: + self.resource_batches.append(batch) + return 6 * len(batch.samples) + + +def _metric_row() -> dict[str, Any]: + return { + "run_id": "run-1", + "recorded_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "value": 5.5, + "unit": "frames/second", + "statistic": "mean", + "scope": "run", + "phase": "profiling", + "session_id": "", + "sample_index": -1, + "device": "", + "source": "summary.json", + "state": "observed", + "labels": {}, + "artifact_digest": "artifact-digest", + "run_kind": "stream", + "status": "completed", + "implementation": "telefuser", + "model": "model", + "model_family": "family", + "mode": "stream_world", + "scene": "scene", + "task": "task", + "transport": "webrtc", + "metric_name": "chunk_compute_fps", + } + + +def _api_app() -> FastAPI: + app = FastAPI() + app.state.history_repository = _APIRepository() + app.state.history_config = HistoryServiceConfig() + app.state.last_import = None + app.include_router(history_router) + return app + + +def test_history_api_lists_runs_and_metric_series() -> None: + client = TestClient(_api_app()) + + runs = client.get("/api/v1/history/runs").json() + series = client.get( + "/api/v1/history/metrics/series", + params={ + "metric_name": "chunk_compute_fps", + "statistic": "mean", + "scope": "run", + }, + ).json() + + assert runs["total"] == 1 + assert runs["items"][0]["implementation"] == "telefuser" + assert series["items"][0]["value"] == 5.5 + + +def test_history_api_returns_not_found_for_unknown_run() -> None: + response = TestClient(_api_app()).get("/api/v1/history/runs/missing") + + assert response.status_code == 404 + + +def test_history_api_accepts_source_timestamped_resource_batch() -> None: + batch = ResourceTelemetryBatch( + batch_id="batch-1", + sequence=0, + run=ResourceRunDescriptor( + benchmark_id="run-live", + started_at_ns=1_700_000_000_000_000_000, + implementation="sglang_diffusion", + root_pid=321, + hostname="gpu-host", + ), + sent_at_ns=1_700_000_001_000_000_000, + samples=[ + ResourceSample( + sampled_at_ns=1_700_000_000_500_000_000, + sample_index=0, + process_count=1, + process_cpu_cores=1.0, + machine_cpu_used_cores=4.0, + machine_cpu_total_cores=8.0, + process_memory_used_bytes=1024, + machine_memory_used_bytes=2048, + machine_memory_total_bytes=4096, + ) + ], + ) + + response = TestClient(_api_app()).post( + "/api/v1/history/resource-batches", + json=batch.model_dump(mode="json"), + ) + + assert response.status_code == 200 + assert response.json() == { + "schema_version": "1.2", + "batch_id": "batch-1", + "sequence": 0, + "run_id": "run-live", + "accepted_samples": 1, + "accepted_points": 6, + "final": False, + } + + +def test_history_api_accepts_legacy_resource_schema_and_echoes_version() -> None: + batch = ResourceTelemetryBatch( + schema_version="1.1", + batch_id="batch-legacy", + sequence=0, + run=ResourceRunDescriptor( + benchmark_id="run-legacy", + started_at_ns=1_700_000_000_000_000_000, + implementation="telefuser", + root_pid=321, + hostname="gpu-host", + ), + sent_at_ns=1_700_000_001_000_000_000, + ) + + response = TestClient(_api_app()).post( + "/api/v1/history/resource-batches", + json=batch.model_dump(mode="json"), + ) + + assert response.status_code == 200 + assert response.json()["schema_version"] == "1.1" + + +def test_history_service_does_not_fallback_when_greptime_is_unavailable() -> None: + config = HistoryServiceConfig(greptime=GreptimeConfig(url="http://127.0.0.1:1")) + app = create_history_app(config) + + with ( + patch( + "aiperf.history.service.GreptimeClient.start", + new=AsyncMock(side_effect=GreptimeError("unavailable")), + ), + pytest.raises(GreptimeError, match="unavailable"), + TestClient(app), + ): + pass diff --git a/tests/unit/history/test_artifacts.py b/tests/unit/history/test_artifacts.py new file mode 100644 index 0000000000..19cf242482 --- /dev/null +++ b/tests/unit/history/test_artifacts.py @@ -0,0 +1,372 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import orjson +import pytest + +from aiperf.history.importer import ArtifactImporter +from aiperf.history.profile_parser import parse_profile_artifact +from aiperf.history.stream_parser import parse_stream_artifact + + +def _write_json(path: Path, payload: Any) -> None: + path.write_bytes(orjson.dumps(payload)) + + +def _write_jsonl(path: Path, payloads: list[Any]) -> None: + path.write_bytes(b"".join(orjson.dumps(payload) + b"\n" for payload in payloads)) + + +def _stream_artifact(root: Path) -> Path: + root.mkdir() + _write_json( + root / "observability_manifest.json", + { + "identity": { + "benchmark_id": "stream-run-1", + "mode": "stream_world", + "implementation": "telefuser", + "model": "LingBot-World-Fast", + "aiperf_version": "0.11.0", + "aiperf_commit": "abc", + "contract_digest": "contract-digest", + }, + "status": "complete", + "phases": [ + { + "phase": "profiling", + "end": {"wall_time_ns": 1_767_225_601_000_000_000}, + } + ], + "errors": [], + }, + ) + _write_json( + root / "benchmark_contract.json", + { + "name": "telefuser_stream", + "mode": "stream_world", + "implementation": "telefuser", + "model_family": "lingbot_world_fast", + "model": "LingBot-World-Fast", + "transport": "webrtc", + "workload": {"task": "bidirectional"}, + }, + ) + _write_json( + root / "summary.json", + { + "started_at_utc": "2026-01-01T00:00:00+00:00", + "config": {"task": "bidirectional", "warmup_chunks": 1}, + "target_metadata": { + "environment": {"gpus": [{"name": "NVIDIA H100"}]}, + "performance": { + "phases": [ + { + "name": "pipeline_init", + "seconds": 10.0, + "memory": [ + { + "device": "cuda:0", + "peak_allocated_bytes": 100, + "peak_reserved_bytes": 120, + } + ], + } + ] + }, + }, + "profile": { + "attempted_sessions": 1, + "successful_sessions": 1, + "failed_sessions": 0, + "success_rate": 1.0, + "chunk_steady_state": { + "count": 1, + "total_frames": 12, + "total_compute_seconds": 2.0, + "frames_per_second": 6.0, + }, + "metrics": { + "stream_fps": {"count": 1, "mean": 15.0, "p90": 15.0}, + "chunk_compute_seconds": { + "count": 1, + "mean": 2.0, + "p90": 2.0, + }, + }, + }, + }, + ) + _write_jsonl( + root / "sessions.jsonl", + [ + { + "logical_session_index": 0, + "phase": "profiling", + "session_id": "session-1", + "success": True, + "stream_fps": 15.0, + "frames_received": 12, + "control_events": [ + {"index": 0, "ack_latency_ms": 2.5, "next_frame_latency_ms": 4.0} + ], + "phase_measurements": [], + "chunk_measurements": [ + { + "index": 0, + "frames": 12, + "compute_seconds": 2.0, + "encode_seconds": 0.1, + "memory": [ + { + "device": "cuda:0", + "peak_allocated_bytes": 200, + "peak_reserved_bytes": 240, + } + ], + } + ], + } + ], + ) + _write_json( + root / "normalized_metrics.json", + { + "results": [ + { + "framework": "telefuser", + "observations": [ + { + "metric_name": "scheduler.queue_depth", + "unit": "tasks", + "state": "observed", + "values": {"avg": 1.0, "max": 2.0}, + "labels": {}, + "source_endpoint_id": "target", + } + ], + } + ] + }, + ) + _write_json( + root / "server_metrics_export.json", + {"metrics": {"queue_depth": {"unit": "tasks"}}}, + ) + _write_jsonl( + root / "server_metrics_export.jsonl", + [ + { + "endpoint_url": "http://target/metrics", + "timestamp_ns": 1_767_225_600_500_000_000, + "endpoint_latency_ns": 2_000_000, + "metrics": { + "queue_depth": [ + { + "labels": {"queue": "main"}, + "value": 3.0, + "buckets": None, + "sum": None, + "count": None, + } + ] + }, + } + ], + ) + return root / "observability_manifest.json" + + +@pytest.mark.asyncio +async def test_parse_stream_artifact_preserves_distinct_metric_scopes( + tmp_path: Path, +) -> None: + parsed = await parse_stream_artifact(_stream_artifact(tmp_path / "run")) + + assert parsed.run.implementation == "telefuser" + assert parsed.run.scene == "lingbot_world_fast" + assert parsed.run.hardware == "NVIDIA H100" + assert parsed.run.status == "completed" + assert parsed.run.metric_count == len(parsed.points) + + points = { + (point.metric_name, point.statistic, point.scope): point + for point in parsed.points + } + assert points[("stream_fps", "mean", "run")].value == 15.0 + assert points[("chunk_compute_fps_weighted", "value", "run")].value == 6.0 + assert points[("chunk_compute_fps", "value", "chunk")].value == 6.0 + assert points[("chunk_compute_fps", "value", "chunk")].phase == "profiling" + assert points[("scheduler.queue_depth", "avg", "normalized")].value == 1.0 + assert points[("queue_depth", "value", "server")].value == 3.0 + + +@pytest.mark.parametrize( + ("directory_name", "config", "expected"), + [ + ( + "telefuser_aiperf", + { + "task": "bidirectional", + "offer_path": "/v1/stream/webrtc/offer", + }, + ( + "telefuser", + "LingBot-World-Fast", + "lingbot_world_fast", + "webrtc", + ), + ), + ( + "sglang_lingbot_stream", + { + "task": "bidirectional", + "model": "robbyant/lingbot-world-fast-diffusers", + "websocket_path": "/v1/realtime_video/generate", + }, + ( + "sglang_diffusion", + "robbyant/lingbot-world-fast-diffusers", + "lingbot_world_fast", + "websocket", + ), + ), + ], +) +@pytest.mark.asyncio +async def test_parse_legacy_stream_artifact_recovers_comparison_identity( + tmp_path: Path, + directory_name: str, + config: dict[str, Any], + expected: tuple[str, str, str, str], +) -> None: + run_dir = tmp_path / directory_name / "stream_lingbot_compare" / "20260709_120000" + run_dir.mkdir(parents=True) + _write_json( + run_dir / "summary.json", + { + "started_at_utc": "2026-07-09T12:00:00+00:00", + "config": config, + "profile": { + "attempted_sessions": 1, + "successful_sessions": 1, + "failed_sessions": 0, + "success_rate": 1.0, + "metrics": {}, + }, + }, + ) + + parsed = await parse_stream_artifact(run_dir / "summary.json") + + implementation, model, model_family, transport = expected + assert parsed.run.implementation == implementation + assert parsed.run.model == model + assert parsed.run.model_family == model_family + assert parsed.run.mode == "stream_world" + assert parsed.run.scene == "lingbot_world_fast" + assert parsed.run.transport == transport + + +@pytest.mark.asyncio +async def test_parse_profile_artifact_imports_aggregate_and_timeslice_metrics( + tmp_path: Path, +) -> None: + run_dir = tmp_path / "profile" + run_dir.mkdir() + _write_json( + run_dir / "profile_export_aiperf.json", + { + "schema_version": "1.3", + "aiperf_version": "0.11.0", + "benchmark_id": "profile-run-1", + "start_time": "2026-01-01T00:00:00+00:00", + "end_time": "2026-01-01T00:01:00+00:00", + "was_cancelled": False, + "input_config": {"endpoint": {"model_names": ["model-a"], "type": "chat"}}, + "request_throughput": {"unit": "requests/sec", "avg": 4.5}, + "request_latency": {"unit": "ms", "avg": 20.0, "p90": 25.0}, + }, + ) + _write_json( + run_dir / "profile_export_aiperf_timeslices.json", + { + "timeslices": [ + { + "timeslice_index": 0, + "request_latency": {"unit": "ms", "avg": 22.0}, + } + ] + }, + ) + + parsed = await parse_profile_artifact(run_dir / "profile_export_aiperf.json") + + assert parsed.run.run_kind == "profile" + assert parsed.run.model == "model-a" + assert parsed.run.mode == "chat" + points = { + (point.metric_name, point.statistic, point.scope) for point in parsed.points + } + assert ("request_throughput", "avg", "run") in points + assert ("request_latency", "p90", "run") in points + assert ("request_latency", "avg", "timeslice") in points + + +@pytest.mark.asyncio +async def test_parse_legacy_profile_recovers_model_and_implementation( + tmp_path: Path, +) -> None: + run_dir = tmp_path / "telefuser_aiperf" / "video_compare" + run_dir.mkdir(parents=True) + _write_json( + run_dir / "profile_export_aiperf.json", + { + "benchmark_id": "legacy-profile", + "start_time": "2026-07-09T11:00:00+00:00", + "input_config": { + "endpoint": {"type": "video_generation"}, + "models": {"items": [{"name": "telefuser-wan21-i2v-480p"}]}, + }, + "request_throughput": {"unit": "requests/sec", "avg": 1.0}, + }, + ) + + parsed = await parse_profile_artifact(run_dir / "profile_export_aiperf.json") + + assert parsed.run.implementation == "telefuser" + assert parsed.run.model == "telefuser-wan21-i2v-480p" + assert parsed.run.model_family == "telefuser-wan21-i2v-480p" + + +class _MemoryRepository: + def __init__(self) -> None: + self.digests: dict[str, str] = {} + self.replacements = 0 + + async def artifact_digest(self, run_id: str) -> str | None: + return self.digests.get(run_id) + + async def replace_run(self, parsed: Any) -> None: + self.digests[parsed.run.run_id] = parsed.run.artifact_digest + self.replacements += 1 + + +@pytest.mark.asyncio +async def test_importer_skips_unchanged_artifact(tmp_path: Path) -> None: + _stream_artifact(tmp_path / "run") + repository = _MemoryRepository() + importer = ArtifactImporter(repository) # type: ignore[arg-type] + + first = await importer.import_roots([tmp_path]) + second = await importer.import_roots([tmp_path]) + + assert first.imported == 1 + assert second.skipped == 1 + assert repository.replacements == 1 diff --git a/tests/unit/history/test_greptime.py b/tests/unit/history/test_greptime.py new file mode 100644 index 0000000000..f242bf00da --- /dev/null +++ b/tests/unit/history/test_greptime.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from aiperf.history.config import GreptimeConfig +from aiperf.history.greptime import GreptimeClient, _extract_record_rows +from aiperf.history.models import ( + HistoryMetricSample, + HistoryRunRecord, + ParsedHistoryRun, +) +from aiperf.history.repository import HistoryRepository +from aiperf.history.repository_sql import ( + METRIC_COLUMNS, + RUN_COLUMNS, + metric_row, + run_row, + sql_literal, +) +from aiperf.history.resource_ingest import resource_batch_to_history +from aiperf.resource_telemetry.models import ( + ResourceRunDescriptor, + ResourceSample, + ResourceTelemetryBatch, +) + + +class _RecordingClient: + def __init__(self) -> None: + self.sql: list[str] = [] + + async def execute(self, sql: str) -> list[dict[str, Any]]: + self.sql.append(sql) + if sql.startswith("SELECT COUNT(*) AS total"): + return [{"total": 6}] + return [] + + +def _parsed_run() -> ParsedHistoryRun: + timestamp = datetime(2026, 1, 1, tzinfo=timezone.utc) + run = HistoryRunRecord( + run_id="run-'1", + benchmark_id="benchmark-1", + run_kind="stream", + started_at=timestamp, + ended_at=timestamp, + ingested_at=timestamp, + status="completed", + implementation="telefuser", + model="model", + model_family="family", + mode="stream_world", + scene="scene", + task="task", + transport="webrtc", + hardware="H100", + aiperf_version="0.11.0", + aiperf_commit="abc", + contract_digest="contract", + artifact_path="/artifacts/run", + artifact_digest="digest", + metric_count=1, + session_count=1, + ) + point = HistoryMetricSample( + point_id="point-1", + run_id=run.run_id, + metric_name="chunk_compute_seconds", + statistic="value", + scope="chunk", + recorded_at=timestamp, + value=2.0, + unit="seconds", + phase="profiling", + sample_index=0, + ) + return ParsedHistoryRun(run=run, points=[point]) + + +def _resource_batch( + *, + final: bool = False, + benchmark_id: str = "run-1", +) -> ResourceTelemetryBatch: + return ResourceTelemetryBatch( + batch_id="batch-1", + sequence=1, + run=ResourceRunDescriptor( + benchmark_id=benchmark_id, + started_at_ns=1_700_000_000_000_000_000, + implementation="telefuser", + model="model", + model_family="family", + mode="stream_world", + scene="scene", + task="bidirectional", + transport="webrtc", + root_pid=123, + hostname="gpu-host", + ), + sent_at_ns=1_700_000_001_000_000_000, + final=final, + status="completed" if final else "running", + samples=[ + ResourceSample( + sampled_at_ns=1_700_000_000_500_000_000, + sample_index=0, + process_count=2, + process_cpu_cores=1.5, + machine_cpu_used_cores=8.0, + machine_cpu_total_cores=16.0, + process_memory_used_bytes=1024, + machine_memory_used_bytes=2048, + machine_memory_total_bytes=4096, + ) + ], + ) + + +def test_greptime_config_rejects_partial_credentials_and_unsafe_prefix() -> None: + with pytest.raises(ValidationError, match="username and password"): + GreptimeConfig(username="user") + with pytest.raises(ValidationError, match="table_prefix"): + GreptimeConfig(table_prefix="history; DROP TABLE runs") + + +def test_extract_record_rows_maps_greptime_schema() -> None: + payload = { + "code": 0, + "output": [ + { + "records": { + "schema": { + "column_schemas": [ + {"name": "run_id"}, + {"name": "metric_value"}, + ] + }, + "rows": [["run-1", 2.5]], + } + } + ], + } + + assert _extract_record_rows(payload) == [{"run_id": "run-1", "metric_value": 2.5}] + + +@pytest.mark.asyncio +async def test_repository_initializes_greptime_and_replaces_run_without_fallback() -> ( + None +): + client = _RecordingClient() + config = GreptimeConfig(table_prefix="test_history") + repository = HistoryRepository(cast(GreptimeClient, client), config) + + await repository.initialize() + await repository.replace_run(_parsed_run()) + + assert client.sql[0].startswith("CREATE TABLE IF NOT EXISTS test_history_runs") + assert client.sql[1].startswith( + "CREATE TABLE IF NOT EXISTS test_history_metric_points" + ) + assert "FROM test_history_runs" in client.sql[2] + assert "metric_scope = 'resource'" in client.sql[3] + assert client.sql[4] == ( + "DELETE FROM test_history_metric_points WHERE run_id = 'run-''1'" + ) + assert client.sql[5].startswith("INSERT INTO test_history_metric_points") + assert client.sql[6].startswith("INSERT INTO test_history_runs") + assert all("sqlite" not in sql.lower() for sql in client.sql) + + +@pytest.mark.asyncio +async def test_repository_ingests_live_resource_batch_without_storage_fallback() -> ( + None +): + client = _RecordingClient() + repository = HistoryRepository( + cast(GreptimeClient, client), + GreptimeConfig(table_prefix="test_history"), + ) + + accepted = await repository.ingest_resource_batch(_resource_batch()) + + assert accepted == 6 + assert any( + sql.startswith("INSERT INTO test_history_metric_points") + and "resource.cpu" in sql + and "resource.memory" in sql + for sql in client.sql + ) + assert client.sql[-1].startswith("INSERT INTO test_history_runs") + + +class _ResourcePreservingClient(_RecordingClient): + def __init__( + self, + resource_row: dict[str, Any], + resource_run_row: dict[str, Any], + ) -> None: + super().__init__() + self.resource_row = resource_row + self.resource_run_row = resource_run_row + + async def execute(self, sql: str) -> list[dict[str, Any]]: + self.sql.append(sql) + if "FROM test_history_runs" in sql and sql.startswith("SELECT"): + return [self.resource_run_row] + if "metric_scope = 'resource'" in sql and sql.startswith("SELECT"): + return [self.resource_row] + return [] + + +@pytest.mark.asyncio +async def test_artifact_replacement_preserves_active_resource_points() -> None: + resource = resource_batch_to_history(_resource_batch(benchmark_id="run-'1")) + resource_row = dict(zip(METRIC_COLUMNS, metric_row(resource, 0), strict=True)) + resource_run_row = dict(zip(RUN_COLUMNS, run_row(resource.run), strict=True)) + client = _ResourcePreservingClient(resource_row, resource_run_row) + repository = HistoryRepository( + cast(GreptimeClient, client), + GreptimeConfig(table_prefix="test_history"), + ) + + await repository.replace_run(_parsed_run()) + + metric_insert = next( + sql + for sql in client.sql + if sql.startswith("INSERT INTO test_history_metric_points") + ) + assert "chunk_compute_seconds" in metric_insert + assert "resource.cpu" in metric_insert + run_insert = next( + sql for sql in client.sql if sql.startswith("INSERT INTO test_history_runs") + ) + assert "resource_agent" in run_insert + assert "telemetry_source" in run_insert + + +def test_sql_literal_rejects_non_finite_values() -> None: + with pytest.raises(ValueError, match="non-finite"): + sql_literal(float("inf")) diff --git a/tests/unit/observability/__init__.py b/tests/unit/observability/__init__.py new file mode 100644 index 0000000000..52a7a9daf0 --- /dev/null +++ b/tests/unit/observability/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/observability/test_lifecycle.py b/tests/unit/observability/test_lifecycle.py new file mode 100644 index 0000000000..5e3c8ed1aa --- /dev/null +++ b/tests/unit/observability/test_lifecycle.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import orjson +import pytest + +from aiperf.common.models.server_metrics_models import ServerMetricsResults +from aiperf.observability import ( + ObservationLifecycle, + ObservationManifestExporter, + ObservationRunIdentity, + ObservationRunStatus, +) + + +def _identity() -> ObservationRunIdentity: + return ObservationRunIdentity( + benchmark_id="benchmark-1", + mode="stream_world", + implementation="telefuser", + model="world-model", + ) + + +@pytest.mark.asyncio +async def test_lifecycle_builds_complete_manifest() -> None: + wall_times = iter([100, 200, 300]) + monotonic_times = iter([10, 20]) + lifecycle = ObservationLifecycle( + _identity(), + wall_clock_ns=lambda: next(wall_times), + monotonic_clock_ns=lambda: next(monotonic_times), + ) + + await lifecycle.on_phase_start(phase="profiling", expected_units=2) + await lifecycle.on_phase_complete( + phase="profiling", + successful_units=2, + failed_units=0, + ) + lifecycle.attach_server_metrics_results( + ServerMetricsResults( + benchmark_id="benchmark-1", + start_ns=100, + end_ns=200, + endpoints_configured=["http://target/metrics"], + endpoints_successful=["http://target/metrics"], + ) + ) + + manifest = lifecycle.build_manifest( + mapping_versions=["telefuser-v1"], + artifacts={"server_metrics": "server_metrics_export.json"}, + ) + + assert manifest.status == ObservationRunStatus.COMPLETE + assert manifest.phases[0].complete is True + assert manifest.phases[0].successful_units == 2 + assert manifest.mapping_versions == ["telefuser-v1"] + + +@pytest.mark.asyncio +async def test_lifecycle_marks_partial_endpoint_collection() -> None: + lifecycle = ObservationLifecycle(_identity()) + await lifecycle.on_phase_start(phase="profiling", expected_units=1) + await lifecycle.on_phase_complete( + phase="profiling", + successful_units=1, + failed_units=0, + ) + lifecycle.attach_server_metrics_results( + ServerMetricsResults( + benchmark_id="benchmark-1", + start_ns=1, + end_ns=2, + endpoints_configured=["http://ok/metrics", "http://down/metrics"], + endpoints_successful=["http://ok/metrics"], + ) + ) + + manifest = lifecycle.build_manifest() + + assert manifest.status == ObservationRunStatus.PARTIAL + assert [endpoint.status for endpoint in manifest.endpoints] == [ + "complete", + "disabled", + ] + + +def test_lifecycle_rejects_mismatched_server_metrics_run() -> None: + lifecycle = ObservationLifecycle(_identity()) + results = ServerMetricsResults( + benchmark_id="different-run", + start_ns=1, + end_ns=2, + ) + + with pytest.raises(ValueError, match="benchmark_id"): + lifecycle.attach_server_metrics_results(results) + + +def test_lifecycle_exposes_invalid_protocol_state() -> None: + lifecycle = ObservationLifecycle(_identity()) + lifecycle.invalidate("mapping schema mismatch") + + manifest = lifecycle.build_manifest() + + assert manifest.status == ObservationRunStatus.INVALID + assert manifest.errors[0].message == "mapping schema mismatch" + + +@pytest.mark.asyncio +async def test_manifest_exporter_writes_versioned_json(tmp_path) -> None: + lifecycle = ObservationLifecycle(_identity()) + manifest = lifecycle.build_manifest() + path = tmp_path / "observability_manifest.json" + + exported = await ObservationManifestExporter().export(manifest, path) + payload = orjson.loads(exported.read_bytes()) + + assert payload["schema_version"] == "1.0" + assert payload["identity"]["benchmark_id"] == "benchmark-1" diff --git a/tests/unit/observability/test_mapping.py b/tests/unit/observability/test_mapping.py new file mode 100644 index 0000000000..169a521649 --- /dev/null +++ b/tests/unit/observability/test_mapping.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest + +from aiperf.common.enums import PrometheusMetricType +from aiperf.observability import ( + MetricCapability, + MetricObservationState, + MetricSupport, + SemanticMetricMapper, + SemanticMetricMapping, + SemanticMetricRule, + SourceMetricObservation, + StatisticMapping, + load_builtin_semantic_mapping, + load_semantic_mapping, +) + + +def _mapping() -> SemanticMetricMapping: + return SemanticMetricMapping( + mapping_version="telefuser-v1", + framework="telefuser", + rules=[ + SemanticMetricRule( + source_metric="telefuser_queue_size", + target_metric="scheduler.queue_depth", + source_type=PrometheusMetricType.GAUGE, + source_unit="requests", + target_unit="requests", + statistics=[ + StatisticMapping(source="avg", target="avg"), + StatisticMapping(source="max", target="max"), + ], + label_allowlist=["worker"], + ) + ], + ) + + +def test_semantic_mapper_preserves_source_and_allowlists_labels() -> None: + mapper = SemanticMetricMapper(_mapping()) + source = SourceMetricObservation( + framework="telefuser", + metric_name="telefuser_queue_size", + metric_type=PrometheusMetricType.GAUGE, + unit="requests", + endpoint_id="http://target/metrics", + labels={"worker": "0", "request_id": "high-cardinality"}, + statistics={"avg": 2.0, "max": 4.0}, + ) + + result = mapper.map_observations([source]) + + observation = result.observations[0] + assert observation.state == MetricObservationState.OBSERVED + assert observation.values == {"avg": 2.0, "max": 4.0} + assert observation.labels == {"worker": "0"} + assert observation.source_metric == "telefuser_queue_size" + + +def test_semantic_mapper_distinguishes_missing_and_unsupported() -> None: + mapper = SemanticMetricMapper(_mapping()) + + missing = mapper.map_observations([]).observations[0] + unsupported = mapper.map_observations( + [], + capabilities=[ + MetricCapability( + metric_name="scheduler.queue_depth", + support=MetricSupport.UNSUPPORTED, + reason="No scheduler queue", + ) + ], + ).observations[0] + + assert missing.state == MetricObservationState.MISSING + assert unsupported.state == MetricObservationState.UNSUPPORTED + assert missing.values == {} + assert unsupported.values == {} + + +def test_semantic_mapper_marks_type_mismatch_invalid() -> None: + mapper = SemanticMetricMapper(_mapping()) + source = SourceMetricObservation( + framework="telefuser", + metric_name="telefuser_queue_size", + metric_type=PrometheusMetricType.COUNTER, + unit="requests", + statistics={"avg": 2.0, "max": 4.0}, + ) + + observation = mapper.map_observations([source]).observations[0] + + assert observation.state == MetricObservationState.INVALID + assert "Expected metric type" in observation.reason + + +def test_load_semantic_mapping_validates_yaml(tmp_path) -> None: + path = tmp_path / "mapping.yaml" + path.write_text( + """ +schema_version: "1.0" +mapping_version: telefuser-v1 +framework: telefuser +rules: + - source_metric: telefuser_queue_size + target_metric: scheduler.queue_depth + source_type: gauge + source_unit: requests + target_unit: requests + statistics: + - source: avg + target: avg +""", + encoding="utf-8", + ) + + mapping = load_semantic_mapping(path) + + assert mapping.rules[0].target_metric == "scheduler.queue_depth" + + +def test_load_builtin_semantic_mapping_returns_telefuser_rules() -> None: + mapping = load_builtin_semantic_mapping("TeleFuser") + + assert mapping.mapping_version == "telefuser-v1" + assert "scheduler.queue_depth" in {rule.target_metric for rule in mapping.rules} + + +def test_source_observation_rejects_non_finite_statistics() -> None: + with pytest.raises(ValueError, match="finite"): + SourceMetricObservation( + framework="telefuser", + metric_name="telefuser_queue_size", + metric_type=PrometheusMetricType.GAUGE, + statistics={"avg": float("nan")}, + ) diff --git a/tests/unit/observability/test_server_metrics.py b/tests/unit/observability/test_server_metrics.py new file mode 100644 index 0000000000..2bc5825d19 --- /dev/null +++ b/tests/unit/observability/test_server_metrics.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime, timezone + +from aiperf.common.enums import PrometheusMetricType +from aiperf.common.models.server_metrics_models import ( + GaugeMetricData, + GaugeSeries, + GaugeStats, + HistogramMetricData, + HistogramSeries, + HistogramStats, + ServerMetricsExportData, + ServerMetricsSummary, +) +from aiperf.observability import ( + MetricObservationState, + server_metrics_export_to_observations, +) + + +def test_server_metrics_export_converts_to_mapper_input() -> None: + now = datetime.now(tz=timezone.utc) + export = ServerMetricsExportData( + benchmark_id="benchmark-1", + summary=ServerMetricsSummary( + endpoints_configured=["http://target/metrics"], + endpoints_successful=["http://target/metrics"], + start_time=now, + end_time=now, + ), + metrics={ + "telefuser_queue_size": GaugeMetricData( + type=PrometheusMetricType.GAUGE, + description="Queue size", + unit="requests", + series=[ + GaugeSeries( + endpoint_url="http://user:secret@target/metrics", + labels={"worker": "0"}, + stats=GaugeStats(avg=2.0, max=4.0), + ) + ], + ), + "telefuser_task_duration_seconds": HistogramMetricData( + type=PrometheusMetricType.HISTOGRAM, + description="Task duration", + unit="seconds", + series=[ + HistogramSeries( + endpoint_url="http://target/metrics", + stats=HistogramStats(count=0), + buckets={"+Inf": 0}, + ) + ], + ), + }, + ) + + observations = server_metrics_export_to_observations( + export, + framework="telefuser", + ) + + assert len(observations) == 2 + by_name = {observation.metric_name: observation for observation in observations} + queue = by_name["telefuser_queue_size"] + duration = by_name["telefuser_task_duration_seconds"] + assert queue.statistics == {"avg": 2.0, "max": 4.0} + assert queue.state == MetricObservationState.OBSERVED + assert "secret" not in queue.endpoint_id + assert duration.statistics == {} + assert duration.state == MetricObservationState.MISSING + assert duration.reason == "Histogram observed no samples during collection" diff --git a/tests/unit/property/test_finite_invariants.py b/tests/unit/property/test_finite_invariants.py index 3a141c0caa..0e4c2609e5 100644 --- a/tests/unit/property/test_finite_invariants.py +++ b/tests/unit/property/test_finite_invariants.py @@ -333,6 +333,11 @@ def _load_or_init_metric_baseline(current: list[str]) -> list[str]: # are not FiniteFloat. Most are: integer enums, free-form pass-through ints, # or fields validated downstream rather than at field level. NUMERIC_BOUNDS_WHITELIST: set[str] = { + # ``endpoint`` contains the substring ``int``; these fields are typed + # models/enums/collections rather than numeric values. + "ObservationEndpoint.status", + "ObservationManifest.endpoints", + "StreamSessionPlan.endpoints", # SweepVariation.index: zero-based index, bounded by sweep size at # runtime; no useful field-level upper bound. "SweepVariation.index", diff --git a/tests/unit/resource_telemetry/__init__.py b/tests/unit/resource_telemetry/__init__.py new file mode 100644 index 0000000000..52a7a9daf0 --- /dev/null +++ b/tests/unit/resource_telemetry/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/resource_telemetry/test_resource_telemetry.py b/tests/unit/resource_telemetry/test_resource_telemetry.py new file mode 100644 index 0000000000..dfaf6c7b51 --- /dev/null +++ b/tests/unit/resource_telemetry/test_resource_telemetry.py @@ -0,0 +1,612 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import threading +from collections import namedtuple +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from aiperf.history.resource_ingest import resource_batch_to_history +from aiperf.resource_telemetry.agent import ResourceTelemetryAgent +from aiperf.resource_telemetry.cgroup import CgroupCollector, CgroupObservation +from aiperf.resource_telemetry.collector import ResourceCollector +from aiperf.resource_telemetry.models import ( + ResourceContainerSample, + ResourceGPUSample, + ResourceNetworkSample, + ResourceRunDescriptor, + ResourceSample, + ResourceTelemetryAck, + ResourceTelemetryBatch, +) +from aiperf.resource_telemetry.network import NetworkCollector + + +def _descriptor() -> ResourceRunDescriptor: + return ResourceRunDescriptor( + benchmark_id="benchmark-1", + started_at_ns=1_700_000_000_000_000_000, + implementation="telefuser", + model="LingBot-World-Fast", + model_family="lingbot_world_fast", + mode="stream_world", + scene="lingbot_world_fast", + task="bidirectional", + transport="webrtc", + root_pid=123, + hostname="gpu-host", + ) + + +def _sample(index: int = 0) -> ResourceSample: + return ResourceSample( + sampled_at_ns=1_700_000_001_000_000_000 + index * 1_000_000_000, + sample_index=index, + process_count=2, + process_cpu_cores=1.5, + machine_cpu_used_cores=12.0, + machine_cpu_total_cores=32.0, + process_memory_used_bytes=10_000, + machine_memory_used_bytes=20_000, + machine_memory_total_bytes=40_000, + gpus=[ + ResourceGPUSample( + device="gpu:4", + uuid="GPU-test", + name="H100", + process_utilization_percent=55.0, + device_utilization_percent=70.0, + process_memory_used_bytes=1_000, + device_memory_used_bytes=2_000, + device_memory_total_bytes=4_000, + ) + ], + networks=[ + ResourceNetworkSample( + kind="ethernet", + receive_bytes_per_second=1_000.0, + transmit_bytes_per_second=2_000.0, + capacity_bytes_per_second=3_125_000_000.0, + interfaces=["bond0"], + ) + ], + ) + + +def test_resource_batch_maps_to_five_canonical_metrics_idempotently() -> None: + batch = ResourceTelemetryBatch( + batch_id="batch-1", + sequence=1, + run=_descriptor(), + sent_at_ns=1_700_000_002_000_000_000, + samples=[_sample()], + ) + + first = resource_batch_to_history(batch) + second = resource_batch_to_history(batch) + + assert {point.metric_name for point in first.points} == { + "resource.cpu", + "resource.memory", + "resource.gpu", + "resource.gpu_memory", + "resource.network", + } + assert len(first.points) == 16 + assert [point.point_id for point in first.points] == [ + point.point_id for point in second.points + ] + process_gpu = next( + point + for point in first.points + if point.metric_name == "resource.gpu" + and point.labels["resource_subject"] == "process_used" + ) + assert process_gpu.device == "gpu:4" + assert process_gpu.labels["source_timestamp_ns"] == str( + batch.samples[0].sampled_at_ns + ) + receive_network = next( + point + for point in first.points + if point.metric_name == "resource.network" + and point.labels["network_direction"] == "receive" + and point.labels["resource_subject"] == "machine_used" + ) + assert receive_network.value == 1_000.0 + assert receive_network.labels["network_kind"] == "ethernet" + assert receive_network.labels["network_interfaces"] == "bond0" + + +def test_resource_batch_maps_container_subjects_without_mixing_machine_capacity() -> ( + None +): + sample = _sample().model_copy( + update={ + "container": ResourceContainerSample( + cgroup_version="v2", + cgroup_path="/docker/test", + process_count=3, + cpu_used_cores=1.25, + cpu_limit_cores=2.0, + memory_used_bytes=5_000, + memory_limit_bytes=8_000, + gpu_visibility_source="nvidia_visible_devices", + visible_gpu_devices=["gpu:4"], + ), + "gpus": [ + _sample() + .gpus[0] + .model_copy( + update={ + "container_visible": True, + "container_utilization_percent": 65.0, + "container_memory_used_bytes": 1_500, + } + ) + ], + } + ) + batch = ResourceTelemetryBatch( + batch_id="batch-container", + sequence=1, + run=_descriptor(), + sent_at_ns=1_700_000_002_000_000_000, + samples=[sample], + ) + + parsed = resource_batch_to_history(batch) + container_points = [ + point + for point in parsed.points + if point.labels["resource_subject"].startswith("container_") + ] + + assert batch.schema_version == "1.2" + assert len(parsed.points) == 24 + assert len(container_points) == 8 + assert {point.labels["resource_subject"] for point in container_points} == { + "container_used", + "container_total", + } + assert all(point.labels["cgroup_version"] == "v2" for point in container_points) + assert all( + point.labels["cgroup_path"] == "/docker/test" for point in container_points + ) + gpu_total = next( + point + for point in container_points + if point.metric_name == "resource.gpu" + and point.labels["resource_subject"] == "container_total" + ) + assert gpu_total.value == 100.0 + assert gpu_total.device == "gpu:4" + assert parsed.run.metadata["resource_agent"]["container"][ + "visible_gpu_devices" + ] == ["gpu:4"] + + +class _FakeProcess: + def __init__( + self, + pid: int, + *, + cpu_user: float, + cpu_system: float, + rss: int, + created_at: float, + ) -> None: + self.pid = pid + self.cpu_user = cpu_user + self.cpu_system = cpu_system + self.rss = rss + self.created_at = created_at + self.descendants: list[_FakeProcess] = [] + + def children(self, *, recursive: bool) -> list[_FakeProcess]: + assert recursive + return self.descendants + + def cpu_times(self) -> Any: + return SimpleNamespace(user=self.cpu_user, system=self.cpu_system) + + def memory_info(self) -> Any: + return SimpleNamespace(rss=self.rss) + + def create_time(self) -> float: + return self.created_at + + +class _FakePsutil: + class NoSuchProcess(Exception): + pass + + class AccessDenied(Exception): + pass + + _CPUTimes = namedtuple("CPUTimes", "user system idle iowait") + + def __init__(self) -> None: + self.root = _FakeProcess( + 123, + cpu_user=1.0, + cpu_system=0.5, + rss=100, + created_at=1_600_000_000.0, + ) + self.child = _FakeProcess( + 124, + cpu_user=0.2, + cpu_system=0.1, + rss=200, + created_at=1_600_000_000.0, + ) + self.root.descendants = [self.child] + self.host_cpu = [ + self._CPUTimes(1.0, 1.0, 8.0, 0.0), + self._CPUTimes(3.0, 2.0, 9.0, 0.0), + ] + + @staticmethod + def cpu_count(*, logical: bool) -> int: + assert logical + return 4 + + def Process(self, pid: int) -> _FakeProcess: # noqa: N802 + if pid != self.root.pid: + raise self.NoSuchProcess(pid) + return self.root + + def cpu_times(self) -> Any: + return self.host_cpu.pop(0) + + @staticmethod + def virtual_memory() -> Any: + return SimpleNamespace(total=10_000, available=4_000) + + +class _FakeNVML: + @staticmethod + def nvmlInit() -> None: # noqa: N802 + return None + + @staticmethod + def nvmlShutdown() -> None: # noqa: N802 + return None + + @staticmethod + def nvmlDeviceGetCount() -> int: # noqa: N802 + return 1 + + @staticmethod + def nvmlDeviceGetHandleByIndex(index: int) -> int: # noqa: N802 + return index + + @staticmethod + def nvmlDeviceGetUtilizationRates(handle: int) -> Any: # noqa: N802 + assert handle == 0 + return SimpleNamespace(gpu=70) + + @staticmethod + def nvmlDeviceGetMemoryInfo(handle: int) -> Any: # noqa: N802 + assert handle == 0 + return SimpleNamespace(used=2_000, total=4_000) + + @staticmethod + def nvmlDeviceGetUUID(handle: int) -> str: # noqa: N802 + return "GPU-test" + + @staticmethod + def nvmlDeviceGetName(handle: int) -> str: # noqa: N802 + return "H100" + + @staticmethod + def nvmlDeviceGetComputeRunningProcesses(handle: int) -> list[Any]: # noqa: N802 + return [ + SimpleNamespace(pid=123, usedGpuMemory=100), + SimpleNamespace(pid=124, usedGpuMemory=200), + SimpleNamespace(pid=999, usedGpuMemory=300), + ] + + @staticmethod + def nvmlDeviceGetProcessUtilization( # noqa: N802 + handle: int, + last_seen_us: int, + ) -> list[Any]: + assert last_seen_us > 0 + return [ + SimpleNamespace(pid=123, smUtil=30, timeStamp=1_700_000_001_000_000), + SimpleNamespace(pid=124, smUtil=20, timeStamp=1_700_000_001_000_000), + SimpleNamespace(pid=999, smUtil=40, timeStamp=1_700_000_001_000_000), + ] + + +class _Clock: + def __init__(self, values: list[int]) -> None: + self.values = iter(values) + + def __call__(self) -> int: + return next(self.values) + + +class _FakeCgroupCollector: + def __init__(self) -> None: + self.started = False + self.closed = False + + def start( + self, + *, + machine_cpu_total_cores: float, + machine_memory_total_bytes: int, + ) -> None: + assert machine_cpu_total_cores == 4 + assert machine_memory_total_bytes == 10_000 + self.started = True + + @staticmethod + def sample(*, elapsed_seconds: float) -> CgroupObservation: + assert elapsed_seconds == 1.0 + return CgroupObservation( + container=ResourceContainerSample( + cgroup_version="v2", + cgroup_path="/docker/test", + process_count=3, + cpu_used_cores=2.0, + cpu_limit_cores=2.0, + memory_used_bytes=700, + memory_limit_bytes=8_000, + gpu_visibility_source="nvidia_visible_devices", + ), + process_ids=frozenset({123, 124, 999}), + visible_gpu_tokens=frozenset({"GPU-test"}), + unavailable=(), + ) + + def close(self) -> None: + self.closed = True + + +def test_collector_uses_pid_tree_deltas_and_nvml_process_attribution() -> None: + psutil = _FakePsutil() + wall_clock = _Clock( + [ + 1_700_000_000_000_000_000, + 1_700_000_000_000_000_000, + 1_700_000_001_000_000_000, + 1_700_000_001_000_000_000, + ] + ) + monotonic = _Clock([1_000_000_000, 2_000_000_000]) + cgroup = _FakeCgroupCollector() + collector = ResourceCollector( + 123, + psutil_module=psutil, + nvml_module=_FakeNVML(), + cgroup_collector=cgroup, + wall_clock_ns=wall_clock, + monotonic_ns=monotonic, + ) + collector.start() + psutil.root.cpu_user = 2.0 + psutil.child.cpu_user = 0.7 + + sample = collector.sample(0) + collector.close() + + assert sample.process_count == 2 + assert sample.process_cpu_cores == pytest.approx(1.5) + assert sample.machine_cpu_used_cores == pytest.approx(3.0) + assert sample.process_memory_used_bytes == 300 + assert sample.machine_memory_used_bytes == 6_000 + assert sample.gpus[0].process_memory_used_bytes == 300 + assert sample.gpus[0].process_utilization_percent == 50.0 + assert sample.gpus[0].device_utilization_percent == 70.0 + assert sample.gpus[0].container_visible is True + assert sample.gpus[0].container_memory_used_bytes == 600 + assert sample.gpus[0].container_utilization_percent == 90.0 + assert sample.container is not None + assert sample.container.cpu_limit_cores == 2.0 + assert sample.container.visible_gpu_devices == ["gpu:0"] + assert cgroup.started + assert cgroup.closed + + +def test_cgroup_v2_collects_quota_memory_and_target_gpu_visibility( + tmp_path: Path, +) -> None: + proc_root = tmp_path / "proc" + cgroup_root = tmp_path / "cgroup" + target_proc = proc_root / "123" + target_proc.mkdir(parents=True) + _write_text(target_proc / "cgroup", "0::/docker/test\n") + (target_proc / "environ").write_bytes(b"NVIDIA_VISIBLE_DEVICES=4,5\0") + control_group = cgroup_root / "docker" / "test" + _write_text(cgroup_root / "docker" / "cpu.max", "200000 100000\n") + _write_text(cgroup_root / "docker" / "memory.max", "4000000000\n") + _write_text(control_group / "cpu.max", "max 100000\n") + _write_text(control_group / "cpuset.cpus.effective", "0-3\n") + _write_text(control_group / "cpu.stat", "usage_usec 1000000\n") + _write_text(control_group / "memory.current", "2000000000\n") + _write_text(control_group / "memory.max", "max\n") + _write_text(control_group / "cgroup.procs", "123\n124\n") + collector = CgroupCollector(123, proc_root=proc_root, cgroup_root=cgroup_root) + collector.start( + machine_cpu_total_cores=8, + machine_memory_total_bytes=16_000_000_000, + ) + _write_text(control_group / "cpu.stat", "usage_usec 2500000\n") + + observation = collector.sample(elapsed_seconds=1.0) + collector.close() + + assert observation.container is not None + assert observation.container.cgroup_version == "v2" + assert observation.container.cpu_used_cores == pytest.approx(1.5) + assert observation.container.cpu_limit_cores == pytest.approx(2.0) + assert observation.container.memory_used_bytes == 2_000_000_000 + assert observation.container.memory_limit_bytes == 4_000_000_000 + assert observation.container.gpu_visibility_source == "nvidia_visible_devices" + assert observation.process_ids == frozenset({123, 124}) + assert observation.visible_gpu_tokens == frozenset({"4", "5"}) + assert "resource.network:container_total:unsupported" in observation.unavailable + + +def test_cgroup_v1_collects_cpuacct_and_memory_limit(tmp_path: Path) -> None: + proc_root = tmp_path / "proc" + cgroup_root = tmp_path / "cgroup" + target_proc = proc_root / "123" + target_proc.mkdir(parents=True) + _write_text( + target_proc / "cgroup", + "2:cpu,cpuacct:/docker/test\n3:memory:/docker/test\n4:cpuset:/docker/test\n", + ) + (target_proc / "environ").write_bytes(b"CUDA_VISIBLE_DEVICES=GPU-test\0") + _write_text(target_proc / "root" / "dev" / "nvidia0", "") + cpu = cgroup_root / "cpu,cpuacct" / "docker" / "test" + memory = cgroup_root / "memory" / "docker" / "test" + cpuset = cgroup_root / "cpuset" / "docker" / "test" + _write_text(cpu / "cpu.cfs_quota_us", "50000\n") + _write_text(cpu / "cpu.cfs_period_us", "100000\n") + _write_text(cpu / "cpuacct.usage", "1000000000\n") + _write_text(cpu / "cgroup.procs", "123\n124\n999\n") + _write_text(memory / "memory.limit_in_bytes", "8000\n") + _write_text(memory / "memory.usage_in_bytes", "3000\n") + _write_text(cpuset / "cpuset.cpus", "0-1\n") + collector = CgroupCollector(123, proc_root=proc_root, cgroup_root=cgroup_root) + collector.start(machine_cpu_total_cores=4, machine_memory_total_bytes=10_000) + _write_text(cpu / "cpuacct.usage", "2000000000\n") + + observation = collector.sample(elapsed_seconds=2.0) + collector.close() + + assert observation.container is not None + assert observation.container.cgroup_version == "v1" + assert observation.container.cpu_used_cores == pytest.approx(0.5) + assert observation.container.cpu_limit_cores == pytest.approx(0.5) + assert observation.container.memory_used_bytes == 3_000 + assert observation.container.memory_limit_bytes == 8_000 + assert observation.container.gpu_visibility_source == "cuda_visible_devices" + assert observation.process_ids == frozenset({123, 124, 999}) + assert observation.visible_gpu_tokens == frozenset({"GPU-test"}) + + +class _FakeNetworkPsutil: + def __init__(self) -> None: + self._counters = iter( + ( + {"eth0": SimpleNamespace(bytes_recv=1_000, bytes_sent=2_000)}, + {"eth0": SimpleNamespace(bytes_recv=1_800, bytes_sent=2_600)}, + ) + ) + + def net_io_counters(self, *, pernic: bool) -> dict[str, Any]: + assert pernic + return next(self._counters) + + @staticmethod + def net_if_stats() -> dict[str, Any]: + return {"eth0": SimpleNamespace(isup=True, speed=25_000)} + + +def _write_text(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + + +def test_network_collector_separates_ethernet_and_rdma_rates(tmp_path: Path) -> None: + infiniband_root = tmp_path / "infiniband" + net_root = tmp_path / "net" + (net_root / "eth0" / "device").mkdir(parents=True) + rdma_device = infiniband_root / "mlx5_0" + (rdma_device / "device" / "net" / "roce0").mkdir(parents=True) + port = rdma_device / "ports" / "1" + _write_text(port / "state", "4: ACTIVE\n") + _write_text(port / "rate", "400 Gb/sec (4X NDR)\n") + _write_text(port / "counters" / "port_rcv_data", "100\n") + _write_text(port / "counters" / "port_xmit_data", "200\n") + collector = NetworkCollector( + _FakeNetworkPsutil(), + infiniband_root=infiniband_root, + net_class_root=net_root, + ) + collector.start() + _write_text(port / "counters" / "port_rcv_data", "600\n") + _write_text(port / "counters" / "port_xmit_data", "500\n") + + samples, unavailable = collector.sample(elapsed_seconds=2.0) + + assert unavailable == [] + by_kind = {sample.kind: sample for sample in samples} + assert by_kind["ethernet"].receive_bytes_per_second == 400.0 + assert by_kind["ethernet"].transmit_bytes_per_second == 300.0 + assert by_kind["ethernet"].capacity_bytes_per_second == 3_125_000_000.0 + assert by_kind["rdma"].receive_bytes_per_second == 1_000.0 + assert by_kind["rdma"].transmit_bytes_per_second == 600.0 + assert by_kind["rdma"].capacity_bytes_per_second == 50_000_000_000.0 + assert by_kind["rdma"].interfaces == ["mlx5_0/port:1@roce0"] + + +class _AgentCollector: + def __init__(self) -> None: + self.started = False + self.closed = False + self.sampled = threading.Event() + + def start(self) -> None: + self.started = True + + def close(self) -> None: + self.closed = True + + def sample(self, sample_index: int) -> ResourceSample: + self.sampled.set() + return _sample(sample_index).model_copy(update={"gpus": []}) + + +@pytest.mark.asyncio +async def test_agent_flushes_pending_samples_immediately_on_stop() -> None: + collector = _AgentCollector() + agent = ResourceTelemetryAgent( + history_url="http://history.example:8095", + run=_descriptor(), + sample_interval_s=0.01, + upload_interval_s=15.0, + collector=collector, + ) + uploads: list[tuple[list[ResourceSample], bool, str]] = [] + + async def fake_send( + *, + samples: list[ResourceSample], + final: bool, + status: str, + ) -> ResourceTelemetryAck: + uploads.append((list(samples), final, status)) + return ResourceTelemetryAck( + batch_id=f"fake-{len(uploads)}", + sequence=len(uploads) - 1, + run_id="benchmark-1", + accepted_samples=len(samples), + accepted_points=10 * len(samples), + final=final, + ) + + agent._send = fake_send # type: ignore[method-assign] + await agent.start() + sampled = await asyncio.to_thread(collector.sampled.wait, 1.0) + assert sampled + + await agent.stop(status="completed") + + assert collector.started + assert collector.closed + assert uploads[0] == ([], False, "running") + assert uploads[-1][1:] == (True, "completed") + assert uploads[-1][0] diff --git a/tests/unit/server_metrics/test_standalone.py b/tests/unit/server_metrics/test_standalone.py new file mode 100644 index 0000000000..a4e0d54cc6 --- /dev/null +++ b/tests/unit/server_metrics/test_standalone.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +import pytest + +from aiperf.common.enums import PrometheusMetricType +from aiperf.common.models.server_metrics_models import ( + MetricFamily, + MetricSample, + ServerMetricsRecord, +) +from aiperf.server_metrics import standalone +from aiperf.server_metrics.standalone import StandaloneServerMetricsCollector + + +class _FakeCollector: + def __init__( + self, + *, + endpoint_url: str, + record_callback: Callable[ + [list[ServerMetricsRecord], str], + Awaitable[None], + ], + collector_id: str, + **kwargs: Any, + ) -> None: + self.endpoint_url = endpoint_url + self.record_callback = record_callback + self.collector_id = collector_id + + async def initialize(self) -> None: + return None + + async def is_url_reachable(self) -> bool: + return True + + async def collect_and_process_metrics(self) -> None: + record = ServerMetricsRecord( + endpoint_url=self.collector_id, + timestamp_ns=time.time_ns(), + endpoint_latency_ns=1_000_000, + metrics={ + "queue_depth": MetricFamily( + type=PrometheusMetricType.GAUGE, + description="Current queue depth", + samples=[MetricSample(value=2.0)], + ) + }, + ) + await self.record_callback([record], self.collector_id) + + async def start(self) -> None: + return None + + async def stop(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_standalone_collector_reuses_server_metrics_aggregation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(standalone, "ServerMetricsDataCollector", _FakeCollector) + raw_path = tmp_path / "server_metrics_export.jsonl" + collector = StandaloneServerMetricsCollector( + benchmark_id="benchmark-1", + urls=["http://127.0.0.1:9000/metrics"], + collection_interval_s=1.0, + reachability_timeout_s=1.0, + raw_jsonl_path=raw_path, + ) + start_ns = time.time_ns() - 1 + + await collector.start() + results = await collector.finalize(start_ns=start_ns, end_ns=time.time_ns()) + + assert results.benchmark_id == "benchmark-1" + assert results.endpoints_successful == ["http://127.0.0.1:9000/metrics"] + assert results.endpoint_summaries + summary = next(iter(results.endpoint_summaries.values())) + assert summary.metrics["queue_depth"].series[0].stats.avg == 2.0 + assert len(raw_path.read_bytes().splitlines()) == 2 diff --git a/tests/unit/streaming/__init__.py b/tests/unit/streaming/__init__.py new file mode 100644 index 0000000000..e5725ea5a4 --- /dev/null +++ b/tests/unit/streaming/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/streaming/test_streaming_adapters.py b/tests/unit/streaming/test_streaming_adapters.py new file mode 100644 index 0000000000..2ee9ad1f3f --- /dev/null +++ b/tests/unit/streaming/test_streaming_adapters.py @@ -0,0 +1,570 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import msgspec.msgpack +import orjson +import pytest + +from aiperf.streaming.adapters.sglang_websocket import ( + SGLangWebSocketAdapter, + apply_control_message, +) +from aiperf.streaming.adapters.telefuser_webrtc import TeleFuserWebRTCAdapter +from aiperf.streaming.adapters.webrtc_ice import resolve_ice_host_addresses +from aiperf.streaming.config import StreamProfileConfig +from aiperf.streaming.contracts import BenchmarkContract +from aiperf.streaming.models import StreamEndpointPaths, StreamSessionPlan + + +class _FakeHttpClient: + def __init__(self) -> None: + self.health_urls: list[str] = [] + self.requests: list[tuple[str, str]] = [] + self.accepted_error_statuses: list[tuple[int, ...]] = [] + + async def check_health(self, url: str, *, timeout_s: float) -> None: + self.health_urls.append(url) + + async def request_json( + self, + url: str, + *, + method: str, + timeout_s: float, + payload: dict[str, Any] | None = None, + accepted_error_statuses: tuple[int, ...] = (), + ) -> dict[str, Any]: + self.requests.append((method, url)) + self.accepted_error_statuses.append(accepted_error_statuses) + if method == "POST": + return {"sdp": "answer", "type": "answer", "session_id": "accepted"} + return {} + + async def aclose(self) -> None: + return None + + +def _contract(*, transport: str, adapter: str) -> BenchmarkContract: + endpoint = {"health_path": "/health"} + if transport == "websocket": + endpoint["websocket_path"] = "/stream" + else: + endpoint.update( + offer_path="/offer", + delete_path_template="/sessions/{session_id}", + ) + return BenchmarkContract( + contract_version="v1", + name="adapter-test", + mode="stream_world", + implementation="test", + model_family="world", + model="world-model", + supported_tasks=["bidirectional"], + transport=transport, + adapter=adapter, + endpoint=endpoint, + request_encoding={"format": "test"}, + result_delivery={"media": "frames"}, + workload={"size": "320x180"}, + metrics=["first_frame_latency_ms"], + artifacts={"config": "stream.json"}, + ) + + +def _config(tmp_path: Path, **values: Any) -> StreamProfileConfig: + payload = { + "contract": "contract.yaml", + "server_url": "http://127.0.0.1:30000", + "prompt": "walk forward", + "artifacts_dir": str(tmp_path), + "transport": { + "connect_timeout_s": 0.5, + "message_timeout_s": 0.5, + "frame_timeout_s": 0.5, + "ice_gather_timeout_s": 0.5, + "shutdown_timeout_s": 0.5, + }, + } + payload.update(values) + return StreamProfileConfig.model_validate(payload) + + +def _plan(*, transport: str) -> StreamSessionPlan: + return StreamSessionPlan( + logical_session_index=0, + phase="profiling", + planned_session_id="planned", + server_url="http://127.0.0.1:30000", + endpoints=StreamEndpointPaths( + health_path="/health", + offer_path="/stream" if transport == "websocket" else "/offer", + delete_path_template=( + None if transport == "websocket" else "/sessions/{session_id}" + ), + ), + mode="bidirectional", + task="bidirectional", + prompt="walk forward", + fps=16, + session_duration_s=0.03, + request_extra={"max_chunks": 1}, + ) + + +def test_apply_control_message_tracks_pressed_keys() -> None: + held: set[str] = set() + + assert apply_control_message({"key": "ArrowUp", "action": "press"}, held) == ["w"] + assert apply_control_message({"key": "ArrowLeft", "action": "press"}, held) == [ + "w", + "a", + ] + assert apply_control_message({"key": "ArrowUp", "action": "release"}, held) == ["a"] + + +def test_auto_ice_host_uses_route_to_target() -> None: + assert resolve_ice_host_addresses( + ["auto"], + target_url="http://127.0.0.1:8088", + ) == ["127.0.0.1"] + + +class _FakeWebSocket: + def __init__(self) -> None: + self.sent: list[bytes] = [] + self.messages = [ + msgspec.msgpack.encode( + { + "type": "frame_batch", + "chunk_index": 0, + "num_frames": 2, + "is_final_frame_batch": True, + } + ) + ] + + async def send(self, payload: bytes) -> None: + self.sent.append(payload) + + async def recv(self) -> bytes: + await asyncio.sleep(0) + return self.messages.pop(0) + + +class _FakeWebSocketContext: + def __init__(self, websocket: Any) -> None: + self.websocket = websocket + + async def __aenter__(self) -> Any: + return self.websocket + + async def __aexit__(self, *args: Any) -> None: + return None + + +@pytest.mark.asyncio +async def test_sglang_adapter_runs_session_and_exports_events(tmp_path: Path) -> None: + websocket = _FakeWebSocket() + http = _FakeHttpClient() + + def connect(*args: Any, **kwargs: Any) -> _FakeWebSocketContext: + return _FakeWebSocketContext(websocket) + + adapter = SGLangWebSocketAdapter( + contract=_contract(transport="websocket", adapter="sglang_websocket"), + config=_config(tmp_path), + artifacts_dir=tmp_path, + websocket_connect=connect, + http_client=http, + ) + + await adapter.check_health() + result = await adapter.run_session(_plan(transport="websocket")) + + assert result.success is True + assert result.frames_received == 2 + assert result.first_frame_latency_ms is not None + assert result.artifacts_event_file is not None + assert Path(result.artifacts_event_file).is_file() + assert http.health_urls == ["http://127.0.0.1:30000/health"] + assert msgspec.msgpack.decode(websocket.sent[0])["type"] == "init" + + +class _SGLangMeasurementWebSocket(_FakeWebSocket): + def __init__(self) -> None: + super().__init__() + self.messages.append( + msgspec.msgpack.encode( + { + "type": "chunk_stats", + "session_id": "sglang-session", + "chunk_index": 0, + "request_prepare_ms": 10, + "scheduler_forward_ms": 500, + "pace_wait_ms": 20, + "header_write_ms": 2, + "raw_payload_build_ms": 100, + "raw_write_ms": 30, + "ws_write_ms": 32, + "chunk_total_ms": 662, + "num_batches": 1, + "num_frames": 2, + "raw_bytes": 1200, + "ws_payload_bytes": 450, + "content_type": "image/webp", + "memory_device": "cuda:0", + "peak_memory_mb": 8192, + } + ) + ) + + +@pytest.mark.asyncio +async def test_sglang_adapter_normalizes_native_chunk_stats( + tmp_path: Path, +) -> None: + websocket = _SGLangMeasurementWebSocket() + + def connect(*args: Any, **kwargs: Any) -> _FakeWebSocketContext: + return _FakeWebSocketContext(websocket) + + contract = _contract( + transport="websocket", + adapter="sglang_websocket", + ).model_copy( + update={ + "result_delivery": { + "media": "websocket_frame_batch", + "metadata": "websocket_chunk_stats", + } + } + ) + adapter = SGLangWebSocketAdapter( + contract=contract, + config=_config(tmp_path), + artifacts_dir=tmp_path, + websocket_connect=connect, + http_client=_FakeHttpClient(), + ) + + result = await adapter.run_session(_plan(transport="websocket")) + + assert result.success is True + assert result.done_received is True + assert result.session_id == "sglang-session" + assert len(result.chunk_measurements) == 1 + measurement = result.chunk_measurements[0] + assert measurement.index == 0 + assert measurement.frames == 2 + assert measurement.request_prepare_seconds == 0.01 + assert measurement.compute_seconds == 0.5 + assert measurement.encode_seconds == 0.1 + assert measurement.output_pacing_seconds == 0.02 + assert measurement.output_header_write_seconds == 0.002 + assert measurement.output_payload_write_seconds == 0.03 + assert measurement.output_write_seconds == 0.032 + assert measurement.total_seconds == 0.662 + assert measurement.raw_output_bytes == 1200 + assert measurement.wire_output_bytes == 450 + assert measurement.output_batches == 1 + assert measurement.output_content_type == "image/webp" + assert len(measurement.memory) == 1 + assert measurement.memory[0].device == "cuda:0" + assert measurement.memory[0].peak_allocated_bytes is None + assert measurement.memory[0].peak_reserved_bytes == 8192 * 1024 * 1024 + + +class _DelayedInitWebSocket: + def __init__(self) -> None: + self.sent: list[bytes] = [] + self.frame_sent = False + + async def send(self, payload: bytes) -> None: + self.sent.append(payload) + if len(self.sent) == 1: + await asyncio.sleep(0.05) + + async def recv(self) -> bytes: + if not self.frame_sent: + self.frame_sent = True + return msgspec.msgpack.encode( + { + "type": "frame_batch", + "chunk_index": 0, + "num_frames": 2, + "is_final_frame_batch": True, + } + ) + await asyncio.Future() + raise AssertionError("unreachable") + + +@pytest.mark.asyncio +async def test_sglang_active_window_starts_after_init_send( + tmp_path: Path, + time_traveler_no_patch_sleep: Any, +) -> None: + websocket = _DelayedInitWebSocket() + + def connect(*args: Any, **kwargs: Any) -> _FakeWebSocketContext: + return _FakeWebSocketContext(websocket) + + adapter = SGLangWebSocketAdapter( + contract=_contract(transport="websocket", adapter="sglang_websocket"), + config=_config(tmp_path), + artifacts_dir=tmp_path, + websocket_connect=connect, + http_client=_FakeHttpClient(), + ) + plan = _plan(transport="websocket").model_copy( + update={ + "session_duration_s": 0.04, + "request_extra": {}, + "control_trace": [ + {"delay_s": 0.02, "message": {"key": "KeyW", "action": "press"}} + ], + } + ) + + started_at = time_traveler_no_patch_sleep.perf_counter() + result = await adapter.run_session(plan) + runtime_s = time_traveler_no_patch_sleep.perf_counter() - started_at + + assert result.success is True + assert result.first_frame_latency_ms is not None + assert result.first_frame_latency_ms >= 45.0 + assert runtime_s >= 0.08 + assert len(result.control_events) == 1 + assert 0.015 <= result.control_events[0].sent_offset_s < 0.04 + + +class _Emitter: + def __init__(self) -> None: + self.callbacks: dict[str, list[Any]] = defaultdict(list) + + def on( + self, + event: str, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def register(callback: Callable[..., Any]) -> Callable[..., Any]: + self.callbacks[event].append(callback) + return callback + + return register + + async def emit(self, event: str, *args: Any) -> None: + for callback in self.callbacks[event]: + result = callback(*args) + if inspect.isawaitable(result): + await result + + +class _FakeDataChannel(_Emitter): + def __init__(self) -> None: + super().__init__() + self.readyState = "connecting" + self.sent: list[str] = [] + + def send(self, payload: str) -> None: + self.sent.append(payload) + + +class _FakeVideoTrack: + kind = "video" + + def __init__(self) -> None: + self.received = False + + async def recv(self) -> object: + if not self.received: + self.received = True + await asyncio.sleep(0) + return object() + await asyncio.Future() + raise AssertionError("unreachable") + + +class _FakePeerConnection(_Emitter): + negotiation_delay_s = 0.0 + track_factory: Callable[[], Any] = _FakeVideoTrack + + def __init__(self, *, configuration: Any) -> None: + super().__init__() + self.configuration = configuration + self.iceGatheringState = "complete" + self.iceConnectionState = "new" + self.connectionState = "new" + self.localDescription: Any = None + self.channel = _FakeDataChannel() + + def createDataChannel(self, label: str) -> _FakeDataChannel: + return self.channel + + def addTransceiver(self, kind: str, *, direction: str) -> None: + return None + + async def createOffer(self) -> object: + return object() + + async def setLocalDescription(self, offer: object) -> None: + self.localDescription = SimpleNamespace(sdp="offer", type="offer") + + async def setRemoteDescription(self, answer: object) -> None: + await asyncio.sleep(self.negotiation_delay_s) + self.connectionState = "connected" + self.iceConnectionState = "connected" + await self.emit("connectionstatechange") + await self.emit("track", self.track_factory()) + self.channel.readyState = "open" + await self.channel.emit("open") + + async def close(self) -> None: + self.connectionState = "closed" + + +@pytest.mark.asyncio +async def test_webrtc_adapter_runs_session_and_deletes_target(tmp_path: Path) -> None: + http = _FakeHttpClient() + adapter = TeleFuserWebRTCAdapter( + contract=_contract(transport="webrtc", adapter="telefuser_webrtc"), + config=_config(tmp_path), + artifacts_dir=tmp_path, + peer_connection_factory=_FakePeerConnection, + session_description_factory=lambda **values: values, + rtc_configuration=object(), + http_client=http, + ) + + result = await adapter.run_session(_plan(transport="webrtc")) + + assert result.success is True + assert result.session_id == "accepted" + assert result.frames_received == 1 + assert ("POST", "http://127.0.0.1:30000/offer") in http.requests + assert ( + "DELETE", + "http://127.0.0.1:30000/sessions/accepted", + ) in http.requests + assert http.accepted_error_statuses[-1] == (404,) + + +class _PeriodicVideoTrack: + kind = "video" + + async def recv(self) -> object: + await asyncio.sleep(0.005) + return object() + + +class _DelayedPeerConnection(_FakePeerConnection): + negotiation_delay_s = 0.05 + track_factory = _PeriodicVideoTrack + + +class _MetricsPeerConnection(_FakePeerConnection): + async def setRemoteDescription(self, answer: object) -> None: + await super().setRemoteDescription(answer) + memory = [ + { + "device": "cuda:0", + "peak_allocated_bytes": 100, + "peak_reserved_bytes": 200, + } + ] + for data in ( + { + "stage": "runtime_ready", + "measurement": { + "name": "runtime_creation", + "seconds": 2.0, + "memory": memory, + }, + "runtime": {"kv_cache_capacity_tokens": 4096}, + }, + { + "stage": "chunk_sent", + "measurement": { + "index": 0, + "frames": 3, + "compute_seconds": 0.5, + "encode_seconds": 0.1, + "memory": memory, + }, + }, + ): + await self.channel.emit( + "message", + orjson.dumps({"type": "chunk", "data": data}).decode(), + ) + + +@pytest.mark.asyncio +async def test_webrtc_active_window_starts_after_transport_is_ready( + tmp_path: Path, + time_traveler_no_patch_sleep: Any, +) -> None: + assert time_traveler_no_patch_sleep is not None + adapter = TeleFuserWebRTCAdapter( + contract=_contract(transport="webrtc", adapter="telefuser_webrtc"), + config=_config(tmp_path), + artifacts_dir=tmp_path, + peer_connection_factory=_DelayedPeerConnection, + session_description_factory=lambda **values: values, + rtc_configuration=object(), + http_client=_FakeHttpClient(), + ) + plan = _plan(transport="webrtc").model_copy( + update={ + "session_duration_s": 0.04, + "request_extra": {}, + "control_trace": [ + {"delay_s": 0.02, "message": {"key": "KeyW", "action": "press"}} + ], + } + ) + + result = await adapter.run_session(plan) + + assert result.success is True + assert result.first_frame_latency_ms is not None + assert result.first_frame_latency_ms >= 50.0 + assert result.session_runtime_s is not None + assert result.session_runtime_s >= 0.085 + assert result.frames_received >= 6 + assert result.stream_fps is not None + assert len(result.control_events) == 1 + assert 0.015 <= result.control_events[0].sent_offset_s < 0.04 + + +@pytest.mark.asyncio +async def test_webrtc_adapter_normalizes_target_phase_and_chunk_measurements( + tmp_path: Path, +) -> None: + adapter = TeleFuserWebRTCAdapter( + contract=_contract(transport="webrtc", adapter="telefuser_webrtc"), + config=_config(tmp_path), + artifacts_dir=tmp_path, + peer_connection_factory=_MetricsPeerConnection, + session_description_factory=lambda **values: values, + rtc_configuration=object(), + http_client=_FakeHttpClient(), + ) + + result = await adapter.run_session(_plan(transport="webrtc")) + + assert result.phase_measurements[0].name == "runtime_creation" + assert result.phase_measurements[0].memory[0].peak_reserved_bytes == 200 + assert result.chunk_measurements[0].compute_seconds == 0.5 + assert result.chunk_measurements[0].encode_seconds == 0.1 + assert result.runtime_metadata["kv_cache_capacity_tokens"] == 4096 diff --git a/tests/unit/streaming/test_streaming_config.py b/tests/unit/streaming/test_streaming_config.py new file mode 100644 index 0000000000..b9809163ac --- /dev/null +++ b/tests/unit/streaming/test_streaming_config.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aiperf.streaming.config import ( + StreamProfileConfig, + StreamResourceTelemetryConfig, + StreamServerMetricsConfig, + load_stream_profile_config, +) +from aiperf.streaming.contracts import BenchmarkContract +from aiperf.streaming.models import StreamEndpointPaths, StreamSessionPlan +from aiperf.streaming.workload import ContractStreamWorkload + + +def _contract() -> BenchmarkContract: + return BenchmarkContract( + contract_version="v1", + name="stream-test", + mode="stream_world", + implementation="test", + model_family="world", + model="world-model", + supported_tasks=["bidirectional"], + transport="websocket", + adapter="sglang_websocket", + endpoint={"health_path": "/health", "websocket_path": "/stream"}, + request_encoding={"message_format": "msgpack"}, + result_delivery={"media": "websocket_frame_batch"}, + workload={ + "mode": "bidirectional", + "task": "bidirectional", + "fps": 12, + "session_count": 3, + "warmup_sessions": 1, + "session_duration_s": 4.0, + "request_extra": {"max_chunks": 4}, + }, + metrics=["first_frame_latency_ms"], + artifacts={"config": "stream.json"}, + ) + + +def _config(**overrides) -> StreamProfileConfig: + values = { + "contract": "contract.yaml", + "server_url": "http://127.0.0.1:30000/", + "prompt": "walk forward", + } + values.update(overrides) + return StreamProfileConfig.model_validate(values) + + +def test_stream_profile_config_normalizes_url_and_hides_turn_credential() -> None: + config = _config( + transport={ + "turn_url": "turn:relay.example:3478", + "turn_credential": "secret", + } + ) + + assert config.server_url == "http://127.0.0.1:30000" + assert "turn_credential" not in config.model_dump(mode="json")["transport"] + + +def test_stream_transport_defaults_to_route_selected_ice() -> None: + assert _config().transport.ice_host_ips == ["auto"] + + +def test_stream_transport_rejects_mixed_auto_and_explicit_ice() -> None: + with pytest.raises(ValueError, match="cannot be combined"): + _config(transport={"ice_host_ips": ["auto", "10.0.0.8"]}) + + +def test_stream_profile_config_redacts_serialized_endpoint_credentials() -> None: + config = _config( + server_url="http://user:secret@127.0.0.1:30000", + server_metrics={ + "enabled": True, + "urls": ["http://metrics:secret@127.0.0.1:30000/metrics"], + }, + ) + + payload = config.model_dump(mode="json") + + assert "secret" not in payload["server_url"] + assert "secret" not in payload["server_metrics"]["urls"][0] + assert "secret" in config.server_url + + +def test_server_metrics_enabled_requires_urls() -> None: + with pytest.raises(ValueError, match="urls is required"): + StreamServerMetricsConfig(enabled=True) + + +def test_resource_telemetry_defaults_to_one_second_samples_and_fifteen_second_uploads() -> ( + None +): + config = StreamResourceTelemetryConfig( + enabled=True, + history_url="http://127.0.0.1:8095", + target_pid=123, + ) + + assert config.sample_interval_s == 1.0 + assert config.upload_interval_s == 15.0 + + +def test_resource_telemetry_enabled_requires_history_and_target_pid() -> None: + with pytest.raises(ValueError, match="history_url"): + StreamResourceTelemetryConfig(enabled=True, target_pid=123) + with pytest.raises(ValueError, match="target_pid"): + StreamResourceTelemetryConfig( + enabled=True, + history_url="http://127.0.0.1:8095", + ) + + +def test_load_stream_profile_config_resolves_relative_contract(tmp_path: Path) -> None: + contract_path = tmp_path / "contract.yaml" + contract_path.write_text("name: target\n", encoding="utf-8") + config_path = tmp_path / "stream.yaml" + config_path.write_text( + "contract: contract.yaml\nserver_url: http://127.0.0.1:8000\nprompt: hello\n", + encoding="utf-8", + ) + + config = load_stream_profile_config( + config_path, + overrides={"server_url": "http://127.0.0.1:9000"}, + ) + + assert config.contract == str(contract_path) + assert config.server_url == "http://127.0.0.1:9000" + + +def test_contract_workload_builds_transport_neutral_plans() -> None: + workload = ContractStreamWorkload( + contract=_contract(), + config=_config(session_count=2, fps=24, request_extra={"max_chunks": 8}), + ) + + run_plan = workload.build_run_plan() + session = workload.build_session_plan( + phase="profiling", + logical_session_index=1, + ) + + assert run_plan.profile_sessions == 2 + assert run_plan.warmup_sessions == 1 + assert session.endpoints.offer_path == "/stream" + assert session.endpoints.delete_path_template is None + assert session.fps == 24 + assert session.request_extra["max_chunks"] == 8 + + +def test_stream_session_plan_rejects_non_finite_request_payload() -> None: + with pytest.raises(ValueError, match="non-finite"): + StreamSessionPlan( + logical_session_index=0, + phase="profiling", + planned_session_id="session", + server_url="http://127.0.0.1:30000", + endpoints=StreamEndpointPaths( + health_path="/health", + offer_path="/stream", + ), + mode="bidirectional", + task="bidirectional", + prompt="prompt", + fps=16, + session_duration_s=1.0, + request_extra={"guidance_scale": float("nan")}, + ) diff --git a/tests/unit/streaming/test_streaming_contracts.py b/tests/unit/streaming/test_streaming_contracts.py new file mode 100644 index 0000000000..34eda82099 --- /dev/null +++ b/tests/unit/streaming/test_streaming_contracts.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from aiperf.streaming import ( + ControlEventResult, + SessionResult, + StreamChunkMeasurement, + StreamDeviceMemoryMeasurement, + StreamPhaseMeasurement, + benchmark_contract_digest, + build_stream_summary, + get_stream_transport_descriptor, + load_control_trace, + session_result_to_dict, + validate_benchmark_contract, + validate_benchmark_contract_file, +) + + +def _minimal_contract() -> dict: + return { + "contract_version": "v1", + "name": "telefuser_test", + "mode": "stream_world", + "implementation": "telefuser", + "model_family": "test", + "model": "test-model", + "supported_tasks": ["bidirectional"], + "transport": "webrtc", + "endpoint": {"offer_path": "/v1/stream/webrtc/offer"}, + "request_encoding": {"offer_content_type": "application/json"}, + "result_delivery": {"media": "rtp_video_track"}, + "workload": {"session_count": 1}, + "metrics": ["first_frame_latency_ms"], + "artifacts": {"config": "stream.json"}, + } + + +def test_validate_benchmark_contract_accepts_stream_contract(): + contract = validate_benchmark_contract(_minimal_contract()) + + assert contract["mode"] == "stream_world" + + +def test_benchmark_contract_digest_is_stable_for_key_order(): + contract = _minimal_contract() + reordered = dict(reversed(list(contract.items()))) + + assert benchmark_contract_digest(contract) == benchmark_contract_digest(reordered) + + +def test_validate_benchmark_contract_rejects_missing_required_field(): + contract = _minimal_contract() + contract.pop("metrics") + + with pytest.raises(ValueError, match="missing required fields: metrics"): + validate_benchmark_contract(contract) + + +def test_validate_benchmark_contract_file(tmp_path): + path = tmp_path / "benchmark_contract.yaml" + path.write_text( + """ +contract_version: v1 +name: telefuser_test +mode: batch_video +implementation: telefuser +model_family: wan_video +model: Wan2.1-I2V-14B-480P +supported_tasks: + - i2v +transport: http +endpoint: + path: /v1/videos +request_encoding: + content_type: multipart/form-data +result_delivery: + polling_path: /v1/videos/{id} +workload: + frames: 81 +metrics: + - request_latency +artifacts: + config: video_generation.yaml +""", + encoding="utf-8", + ) + + contract = validate_benchmark_contract_file(path) + + assert contract["mode"] == "batch_video" + + +def test_load_control_trace_sorts_and_validates_events(tmp_path): + path = tmp_path / "controls.json" + path.write_text( + '{"events": [' + '{"delay_s": 2.0, "message": {"type": "control", "key": "ArrowLeft"}},' + '{"delay_s": 1.0, "message": {"type": "control", "key": "ArrowUp"}}' + "]}", + encoding="utf-8", + ) + + trace = load_control_trace(str(path), "bidirectional") + + assert [event["delay_s"] for event in trace] == [1.0, 2.0] + + +def test_stream_summary_keeps_zero_latency_values(): + result = SessionResult( + logical_session_index=0, + phase="profile", + mode="bidirectional", + planned_session_id="planned", + session_id="actual", + success=True, + offer_rtt_ms=0.0, + connected_latency_ms=0.0, + first_frame_latency_ms=0.0, + frames_received=1, + control_events=[ + ControlEventResult( + index=0, + scheduled_delay_s=0.0, + message={"type": "control"}, + sent_offset_s=0.0, + ack_latency_ms=0.0, + next_frame_latency_ms=0.0, + ) + ], + ) + + summary = build_stream_summary( + config={"session_count": 1}, + warmup_results=[], + profile_results=[result], + started_at_iso="2026-07-03T00:00:00Z", + ) + + assert summary["profile"]["success_rate"] == 1.0 + assert summary["profile"]["metrics"]["offer_rtt_ms"]["count"] == 1.0 + assert summary["profile"]["metrics"]["control_ack_latency_ms"]["mean"] == 0.0 + + +def test_stream_summary_includes_target_phases_and_steady_chunks(): + memory = StreamDeviceMemoryMeasurement( + device="cuda:0", + peak_allocated_bytes=100, + peak_reserved_bytes=200, + ) + result = SessionResult( + logical_session_index=0, + phase="profiling", + mode="bidirectional", + planned_session_id="planned", + session_id="actual", + success=True, + phase_measurements=[ + StreamPhaseMeasurement( + name="runtime_creation", + seconds=2.0, + memory=[memory], + ) + ], + chunk_measurements=[ + StreamChunkMeasurement( + index=0, + frames=3, + compute_seconds=3.0, + encode_seconds=0.3, + memory=[memory], + ), + StreamChunkMeasurement( + index=1, + frames=3, + request_prepare_seconds=0.02, + compute_seconds=2.0, + encode_seconds=0.2, + output_pacing_seconds=0.0, + output_header_write_seconds=0.01, + output_payload_write_seconds=0.03, + output_write_seconds=0.04, + total_seconds=2.26, + raw_output_bytes=1200, + wire_output_bytes=450, + output_batches=1, + output_content_type="image/webp", + memory=[memory], + ), + ], + ) + + summary = build_stream_summary( + config={"session_count": 1, "warmup_chunks": 1}, + warmup_results=[], + profile_results=[result], + started_at_iso="2026-07-03T00:00:00Z", + target_metadata={ + "performance": { + "phases": [ + { + "name": "pipeline_init", + "seconds": 5.0, + "memory": [memory.model_dump()], + } + ] + } + }, + ) + + metrics = summary["profile"]["metrics"] + assert metrics["pipeline_init_seconds"]["mean"] == 5.0 + assert metrics["runtime_creation_seconds"]["mean"] == 2.0 + assert metrics["chunk_request_prepare_seconds"]["mean"] == 0.02 + assert metrics["chunk_compute_seconds"]["mean"] == 2.0 + assert metrics["chunk_encode_seconds"]["mean"] == 0.2 + assert metrics["chunk_output_pacing_seconds"]["mean"] == 0.0 + assert metrics["chunk_output_header_write_seconds"]["mean"] == 0.01 + assert metrics["chunk_output_payload_write_seconds"]["mean"] == 0.03 + assert metrics["chunk_output_write_seconds"]["mean"] == 0.04 + assert metrics["chunk_total_seconds"]["mean"] == 2.26 + assert metrics["chunk_raw_output_bytes"]["mean"] == 1200.0 + assert metrics["chunk_wire_output_bytes"]["mean"] == 450.0 + assert metrics["chunk_output_batches"]["mean"] == 1.0 + assert metrics["chunk_compute_seconds"]["std"] == 0.0 + assert metrics["chunk_peak_allocated_bytes"]["max"] == 100.0 + assert summary["profile"]["chunk_steady_state"] == { + "count": 1, + "warmup_chunks_skipped": 1, + "total_frames": 3, + "total_compute_seconds": 2.0, + "frames_per_second": 1.5, + "total_raw_output_bytes": 1200, + "total_wire_output_bytes": 450, + "total_output_batches": 1, + } + + +def test_stream_summary_accepts_reserved_only_device_memory(): + memory = StreamDeviceMemoryMeasurement( + device="cuda:0", + peak_reserved_bytes=8 * 1024 * 1024, + ) + result = SessionResult( + logical_session_index=0, + phase="profiling", + mode="bidirectional", + planned_session_id="planned", + session_id="actual", + success=True, + chunk_measurements=[ + StreamChunkMeasurement( + index=0, + frames=1, + compute_seconds=1.0, + memory=[memory], + ) + ], + ) + + summary = build_stream_summary( + config={"session_count": 1, "warmup_chunks": 0}, + warmup_results=[], + profile_results=[result], + started_at_iso="2026-07-03T00:00:00Z", + ) + + metrics = summary["profile"]["metrics"] + assert metrics["chunk_peak_allocated_bytes"] is None + assert metrics["chunk_peak_reserved_bytes"]["max"] == 8 * 1024 * 1024 + + +def test_stream_device_memory_rejects_empty_allocator_fact(): + with pytest.raises(ValueError, match="at least one allocator peak"): + StreamDeviceMemoryMeasurement(device="cuda:0") + + +def test_session_result_serialization_scrubs_non_finite_assignment(): + result = SessionResult( + logical_session_index=0, + phase="profile", + mode="bidirectional", + planned_session_id="planned", + session_id="actual", + ) + result.stream_fps = float("nan") + + assert session_result_to_dict(result)["stream_fps"] is None + + +def test_stream_transport_descriptor_covers_webrtc(): + descriptor = get_stream_transport_descriptor("webrtc") + + assert descriptor.bidirectional_controls is True diff --git a/tests/unit/streaming/test_streaming_events.py b/tests/unit/streaming/test_streaming_events.py new file mode 100644 index 0000000000..5a2a9caace --- /dev/null +++ b/tests/unit/streaming/test_streaming_events.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import orjson +import pytest + +from aiperf.streaming.events import StreamEventRecorder, safe_filename_component + + +def test_safe_filename_component_removes_path_syntax() -> None: + assert safe_filename_component("../../target/session") == "target_session" + + +@pytest.mark.asyncio +async def test_event_recorder_exports_canonical_jsonl(tmp_path: Path) -> None: + recorder = StreamEventRecorder( + artifacts_dir=tmp_path, + phase="profiling", + logical_session_index=2, + planned_session_id="planned", + ) + recorder.record("session_start", latency=float("nan")) + recorder.set_session_id("../../accepted") + recorder.record("first_frame", frame=1) + + path = await recorder.export() + rows = [orjson.loads(line) for line in path.read_bytes().splitlines()] + + assert path.parent == tmp_path / "events" + assert ".." not in path.name + assert rows[0]["latency"] is None + assert rows[1]["session_id"] == "../../accepted" diff --git a/tests/unit/streaming/test_streaming_profile.py b/tests/unit/streaming/test_streaming_profile.py new file mode 100644 index 0000000000..fc363a43bc --- /dev/null +++ b/tests/unit/streaming/test_streaming_profile.py @@ -0,0 +1,321 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import orjson +import pytest + +from aiperf.common.enums import PrometheusMetricType +from aiperf.common.models.server_metrics_models import ( + GaugeMetricData, + GaugeSeries, + GaugeStats, + ServerMetricsEndpointInfo, + ServerMetricsEndpointSummary, + ServerMetricsResults, +) +from aiperf.resource_telemetry import streaming as resource_streaming_module +from aiperf.streaming import profile as stream_profile_module +from aiperf.streaming.adapters import register_stream_adapter +from aiperf.streaming.config import StreamProfileConfig +from aiperf.streaming.models import SessionResult, StreamSessionPlan +from aiperf.streaming.profile import run_stream_profile + + +class _FakeAdapter: + transport = "websocket" + + def __init__(self, **kwargs: Any) -> None: + self.closed = False + + async def check_health(self) -> None: + return None + + async def run_session(self, plan: StreamSessionPlan) -> SessionResult: + return SessionResult( + logical_session_index=plan.logical_session_index, + phase=plan.phase, + mode=plan.mode, + planned_session_id=plan.planned_session_id, + session_id=plan.planned_session_id, + success=True, + first_frame_latency_ms=10.0, + session_runtime_s=1.0, + frames_received=16, + stream_fps=16.0, + ) + + async def collect_target_metadata(self) -> dict[str, Any]: + return { + "performance": { + "phases": [{"name": "pipeline_init", "seconds": 3.0, "memory": []}] + }, + "environment": {"torch_version": "test"}, + } + + async def aclose(self) -> None: + self.closed = True + + +def _write_contract(path: Path) -> None: + path.write_text( + "contract_version: v1\n" + "name: fake_stream\n" + "mode: stream_world\n" + "implementation: fake\n" + "model_family: fake_world\n" + "model: fake-model\n" + "supported_tasks: [bidirectional]\n" + "transport: websocket\n" + "adapter: unit_test_fake\n" + "endpoint:\n" + " health_path: /health\n" + " websocket_path: /stream\n" + "request_encoding: {format: test}\n" + "result_delivery: {media: frames}\n" + "workload:\n" + " mode: bidirectional\n" + " task: bidirectional\n" + " fps: 16\n" + " session_count: 2\n" + " warmup_sessions: 1\n" + " session_duration_s: 1.0\n" + "metrics: [first_frame_latency_ms]\n" + "artifacts: {config: stream.yaml}\n", + encoding="utf-8", + ) + + +@pytest.mark.asyncio +async def test_stream_profile_owns_complete_artifact_lifecycle(tmp_path: Path) -> None: + register_stream_adapter("unit_test_fake", _FakeAdapter, replace=True) + contract_path = tmp_path / "contract.yaml" + _write_contract(contract_path) + config = StreamProfileConfig( + contract=str(contract_path), + server_url="http://127.0.0.1:9999", + prompt="test", + artifacts_dir=str(tmp_path / "artifacts"), + ) + + result = await run_stream_profile(config) + + artifact_root = Path(result.artifacts_dir) + assert result.summary["profile"]["successful_sessions"] == 2 + assert len((artifact_root / "sessions.jsonl").read_bytes().splitlines()) == 3 + assert Path(result.artifacts["stream_report"]).is_file() + assert Path(result.artifacts["target_metadata"]).is_file() + assert "AIPerf Stream Report" in Path(result.artifacts["stream_report"]).read_text() + assert result.summary["profile"]["metrics"]["pipeline_init_seconds"]["mean"] == 3.0 + manifest = orjson.loads( + (artifact_root / "observability_manifest.json").read_bytes() + ) + assert manifest["identity"]["benchmark_id"] == result.benchmark_id + assert manifest["status"] == "disabled" + assert {phase["phase"] for phase in manifest["phases"]} == { + "warmup", + "profiling", + } + + +class _FakeServerMetricsCollector: + def __init__(self, *, benchmark_id: str, **kwargs: Any) -> None: + self.benchmark_id = benchmark_id + + async def start(self) -> None: + return None + + async def abort(self) -> None: + return None + + async def finalize(self, *, start_ns: int, end_ns: int) -> ServerMetricsResults: + endpoint = "http://127.0.0.1:8088/v1/service/metrics" + info = ServerMetricsEndpointInfo( + total_fetches=2, + first_fetch_ns=start_ns, + last_fetch_ns=end_ns, + avg_fetch_latency_ms=1.0, + unique_updates=2, + first_update_ns=start_ns, + last_update_ns=end_ns, + duration_seconds=(end_ns - start_ns) / 1_000_000_000, + avg_update_interval_ms=(end_ns - start_ns) / 1_000_000, + ) + summary = ServerMetricsEndpointSummary( + endpoint_url=endpoint, + info=info, + metrics={ + "telefuser_queue_size": GaugeMetricData( + type=PrometheusMetricType.GAUGE, + description="Queue size", + series=[GaugeSeries(stats=GaugeStats(avg=2.0, max=4.0))], + ) + }, + ) + return ServerMetricsResults( + benchmark_id=self.benchmark_id, + endpoint_summaries={endpoint: summary}, + start_ns=start_ns, + end_ns=end_ns, + endpoints_configured=[endpoint], + endpoints_successful=[endpoint], + ) + + +@pytest.mark.asyncio +async def test_stream_profile_exports_server_and_normalized_metrics( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + register_stream_adapter("unit_test_fake", _FakeAdapter, replace=True) + monkeypatch.setattr( + stream_profile_module, + "StandaloneServerMetricsCollector", + _FakeServerMetricsCollector, + ) + contract_path = tmp_path / "contract.yaml" + _write_contract(contract_path) + config = StreamProfileConfig( + contract=str(contract_path), + server_url="http://127.0.0.1:9999", + prompt="test", + artifacts_dir=str(tmp_path / "artifacts"), + server_metrics={ + "enabled": True, + "urls": ["http://127.0.0.1:8088/v1/service/metrics"], + }, + observability={"mapping": "builtin:telefuser"}, + ) + + result = await run_stream_profile(config) + + server_export = orjson.loads(Path(result.artifacts["server_metrics"]).read_bytes()) + normalized = orjson.loads(Path(result.artifacts["normalized_metrics"]).read_bytes()) + manifest = orjson.loads( + Path(result.artifacts["observability_manifest"]).read_bytes() + ) + assert ( + server_export["metrics"]["telefuser_queue_size"]["series"][0]["stats"]["avg"] + == 2.0 + ) + observations = normalized["results"][0]["observations"] + queue = next( + item for item in observations if item["metric_name"] == "scheduler.queue_depth" + ) + assert queue["state"] == "observed" + report = Path(result.artifacts["stream_report"]).read_text() + assert "scheduler.queue_depth" in report + assert "observed" in report + assert manifest["status"] == "complete" + assert manifest["mapping_versions"] == ["telefuser-v1"] + + +class _FakeResourceAgent: + instances: list[_FakeResourceAgent] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.started = False + self.stopped_status: str | None = None + self.aborted = False + self.instances.append(self) + + async def start(self) -> None: + self.started = True + + async def stop(self, *, status: str) -> None: + self.stopped_status = status + + async def abort(self) -> None: + self.aborted = True + + +@pytest.mark.asyncio +async def test_stream_profile_uses_common_active_resource_agent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + register_stream_adapter("unit_test_fake", _FakeAdapter, replace=True) + _FakeResourceAgent.instances.clear() + monkeypatch.setattr( + resource_streaming_module, + "ResourceTelemetryAgent", + _FakeResourceAgent, + ) + contract_path = tmp_path / "contract.yaml" + _write_contract(contract_path) + config = StreamProfileConfig( + contract=str(contract_path), + server_url="http://127.0.0.1:9999", + prompt="test", + artifacts_dir=str(tmp_path / "artifacts"), + resource_telemetry={ + "enabled": True, + "history_url": "http://127.0.0.1:8095", + "target_pid": 123, + }, + ) + + result = await run_stream_profile(config) + + agent = _FakeResourceAgent.instances[0] + descriptor = agent.kwargs["run"] + assert result.benchmark_id == descriptor.benchmark_id + assert descriptor.implementation == "fake" + assert descriptor.root_pid == 123 + assert agent.started + assert agent.stopped_status == "completed" + assert not agent.aborted + + +class _CancellingRunner: + def __init__(self, **kwargs: Any) -> None: + del kwargs + + async def run(self, plan: Any) -> Any: + del plan + raise asyncio.CancelledError + + +@pytest.mark.asyncio +async def test_stream_profile_final_flushes_resource_agent_on_cancellation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + register_stream_adapter("unit_test_fake", _FakeAdapter, replace=True) + _FakeResourceAgent.instances.clear() + monkeypatch.setattr( + resource_streaming_module, + "ResourceTelemetryAgent", + _FakeResourceAgent, + ) + monkeypatch.setattr( + stream_profile_module, + "StreamBenchmarkRunner", + _CancellingRunner, + ) + contract_path = tmp_path / "contract.yaml" + _write_contract(contract_path) + config = StreamProfileConfig( + contract=str(contract_path), + server_url="http://127.0.0.1:9999", + prompt="test", + artifacts_dir=str(tmp_path / "artifacts"), + resource_telemetry={ + "enabled": True, + "history_url": "http://127.0.0.1:8095", + "target_pid": 123, + }, + ) + + with pytest.raises(asyncio.CancelledError): + await run_stream_profile(config) + + agent = _FakeResourceAgent.instances[0] + assert agent.stopped_status == "cancelled" + assert not agent.aborted diff --git a/tests/unit/streaming/test_streaming_report.py b/tests/unit/streaming/test_streaming_report.py new file mode 100644 index 0000000000..755e0c10b1 --- /dev/null +++ b/tests/unit/streaming/test_streaming_report.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aiperf.streaming.artifacts import write_json_artifact +from aiperf.streaming.measurements import ( + StreamChunkMeasurement, + StreamPhaseMeasurement, +) +from aiperf.streaming.models import SessionResult +from aiperf.streaming.report import StreamReportExporter + + +@pytest.mark.asyncio +async def test_stream_report_renders_sessions_metrics_and_escapes_text( + tmp_path: Path, +) -> None: + normalized_path = await write_json_artifact( + tmp_path / "normalized.json", + { + "results": [ + { + "framework": "framework-a", + "observations": [ + { + "metric_name": "scheduler.queue_depth", + "state": "observed", + "unit": "tasks", + "values": {"avg": 2.0}, + "reason": None, + } + ], + } + ] + }, + ) + session = SessionResult( + logical_session_index=0, + phase="profiling", + mode="bidirectional", + planned_session_id="planned", + session_id="session", + success=False, + error="", + runtime_metadata={"kv_cache_capacity_tokens": 4096}, + phase_measurements=[ + StreamPhaseMeasurement(name="runtime_creation", seconds=2.0) + ], + chunk_measurements=[ + StreamChunkMeasurement( + index=0, + frames=3, + request_prepare_seconds=0.02, + compute_seconds=0.5, + encode_seconds=0.1, + output_write_seconds=0.03, + total_seconds=0.65, + raw_output_bytes=1200, + wire_output_bytes=450, + output_batches=1, + output_content_type="image/webp", + ) + ], + ) + summary = { + "started_at_utc": "2026-07-13T00:00:00Z", + "config": {"server_url": "http://target", "mode": "bidirectional"}, + "target_metadata": {"environment": {"torch_version": "2.7.0"}}, + "profile": { + "attempted_sessions": 1, + "successful_sessions": 0, + "failed_sessions": 1, + "success_rate": 0.0, + "metrics": { + "first_frame_latency_ms": { + "count": 1.0, + "mean": 12.0, + "p50": 12.0, + "p90": 12.0, + "p99": 12.0, + "max": 12.0, + } + }, + }, + } + + output = await StreamReportExporter().export( + path=tmp_path / "stream_report.html", + summary=summary, + session_results=[session], + normalized_metrics_path=normalized_path, + ) + + report = output.read_text(encoding="utf-8") + assert "AIPerf Stream Report" in report + assert "first_frame_latency_ms" in report + assert "scheduler.queue_depth" in report + assert "Target chunks" in report + assert "Prepare s" in report + assert "image/webp" in report + assert "runtime_creation" in report + assert "kv_cache_capacity_tokens" in report + assert "torch_version" in report + assert "<script>alert(1)</script>" in report + assert "" not in report diff --git a/tests/unit/streaming/test_streaming_runner.py b/tests/unit/streaming/test_streaming_runner.py new file mode 100644 index 0000000000..237fbcce53 --- /dev/null +++ b/tests/unit/streaming/test_streaming_runner.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from aiperf.streaming import ( + SessionResult, + StreamBenchmarkRunner, + StreamEndpointPaths, + StreamRunPlan, + StreamSessionPlan, +) + + +class _Workload: + def build_session_plan( + self, + *, + phase: str, + logical_session_index: int, + ) -> StreamSessionPlan: + return StreamSessionPlan( + logical_session_index=logical_session_index, + phase=phase, + planned_session_id=f"{phase}-{logical_session_index}", + server_url="http://target", + endpoints=StreamEndpointPaths( + health_path="/health", + offer_path="/offer", + delete_path_template="/sessions/{session_id}", + ), + mode="bidirectional", + task="world_model", + prompt="test", + fps=16, + session_duration_s=1.0, + ) + + +class _Transport: + transport = "websocket" + + async def run_session(self, plan: StreamSessionPlan) -> SessionResult: + if plan.phase == "profiling" and plan.logical_session_index == 1: + raise RuntimeError("target failed") + return SessionResult( + logical_session_index=plan.logical_session_index, + phase=plan.phase, + mode=plan.mode, + planned_session_id=plan.planned_session_id, + session_id=plan.planned_session_id, + success=True, + ) + + +class _Collector: + def __init__(self) -> None: + self.results: list[SessionResult] = [] + + def record_session_result(self, result: SessionResult) -> None: + self.results.append(result) + + def build_summary(self) -> dict[str, Any]: + return {"count": len(self.results)} + + +class _Lifecycle: + def __init__(self) -> None: + self.events: list[tuple] = [] + + async def on_phase_start(self, *, phase: str, expected_units: int) -> None: + self.events.append(("start", phase, expected_units)) + + async def on_phase_complete( + self, + *, + phase: str, + successful_units: int, + failed_units: int, + ) -> None: + self.events.append(("complete", phase, successful_units, failed_units)) + + async def on_run_cancelled(self, *, error: str) -> None: + self.events.append(("cancelled", error)) + + +@pytest.mark.asyncio +async def test_stream_runner_uses_shared_lifecycle_and_converts_adapter_errors() -> ( + None +): + collector = _Collector() + lifecycle = _Lifecycle() + runner = StreamBenchmarkRunner( + workload=_Workload(), + transport=_Transport(), + results_collector=collector, + lifecycle=lifecycle, + ) + + result = await runner.run( + StreamRunPlan(warmup_sessions=1, profile_sessions=2, stagger_s=0) + ) + + assert len(result.warmup_results) == 1 + assert len(result.profile_results) == 2 + assert result.profile_results[1].success is False + assert result.profile_results[1].error == "RuntimeError: target failed" + assert len(collector.results) == 3 + assert lifecycle.events == [ + ("start", "warmup", 1), + ("complete", "warmup", 1, 0), + ("start", "profiling", 2), + ("complete", "profiling", 1, 1), + ] + + +class _CancelledTransport: + transport = "webrtc" + + async def run_session(self, plan: StreamSessionPlan) -> SessionResult: + raise asyncio.CancelledError + + +@pytest.mark.asyncio +async def test_stream_runner_notifies_lifecycle_when_cancelled() -> None: + lifecycle = _Lifecycle() + runner = StreamBenchmarkRunner( + workload=_Workload(), + transport=_CancelledTransport(), + lifecycle=lifecycle, + ) + + with pytest.raises(asyncio.CancelledError): + await runner.run_phase(phase="profiling", session_count=1, stagger_s=0) + + assert lifecycle.events[0] == ("start", "profiling", 1) + assert lifecycle.events[1][0] == "cancelled" diff --git a/web/history-ui/index.html b/web/history-ui/index.html new file mode 100644 index 0000000000..2d41ca536c --- /dev/null +++ b/web/history-ui/index.html @@ -0,0 +1,17 @@ + + + + + + + + AIPerf 历史指标 + + +
+ + + diff --git a/web/history-ui/package-lock.json b/web/history-ui/package-lock.json new file mode 100644 index 0000000000..a8895fcf4b --- /dev/null +++ b/web/history-ui/package-lock.json @@ -0,0 +1,1485 @@ +{ + "name": "aiperf-history-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aiperf-history-ui", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "echarts": "^6.0.0", + "vue": "^3.5.22" + }, + "devDependencies": { + "@types/node": "^24.7.0", + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "~5.9.3", + "vite": "^7.1.7", + "vue-tsc": "^3.1.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.7", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.7.tgz", + "integrity": "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.7", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.7.tgz", + "integrity": "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/history-ui/package.json b/web/history-ui/package.json new file mode 100644 index 0000000000..cabf834fcf --- /dev/null +++ b/web/history-ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "aiperf-history-ui", + "private": true, + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "vue-tsc --noEmit && vite build", + "dev": "vite --host 127.0.0.1 --port 5173", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "echarts": "^6.0.0", + "vue": "^3.5.22" + }, + "devDependencies": { + "@types/node": "^24.7.0", + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "~5.9.3", + "vite": "^7.1.7", + "vue-tsc": "^3.1.1" + } +} diff --git a/web/history-ui/src/App.vue b/web/history-ui/src/App.vue new file mode 100644 index 0000000000..a3e5f6ebf0 --- /dev/null +++ b/web/history-ui/src/App.vue @@ -0,0 +1,516 @@ + + + + diff --git a/web/history-ui/src/api.ts b/web/history-ui/src/api.ts new file mode 100644 index 0000000000..3f65359b74 --- /dev/null +++ b/web/history-ui/src/api.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + FacetsResponse, + HistoryRun, + HistoryStatus, + MetricCatalogItem, + MetricSeriesPoint, + RunFilters, + RunListResponse, +} from "./types"; + +type QueryValue = string | number | boolean | null | undefined; + +async function request(path: string, query?: Record): Promise { + const url = new URL(path, window.location.origin); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + const response = await fetch(`${url.pathname}${url.search}`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { detail?: string } | null; + throw new Error(payload?.detail ?? `历史 API 返回 HTTP ${response.status}`); + } + return (await response.json()) as T; +} + +export async function fetchRuns( + filters: RunFilters, + limit: number, + offset: number, +): Promise { + return request("/api/v1/history/runs", { + ...filters, + limit, + offset, + }); +} + +export async function fetchFacets(): Promise> { + return (await request("/api/v1/history/facets")).facets; +} + +export async function fetchStatus(): Promise { + return request("/api/v1/history/status"); +} + +export async function fetchCatalog(query?: { + runId?: string; + implementation?: string; + scene?: string; +}): Promise { + const response = await request<{ items: MetricCatalogItem[] }>( + "/api/v1/history/metrics/catalog", + { + run_id: query?.runId, + implementation: query?.implementation, + scene: query?.scene, + }, + ); + return response.items; +} + +export async function fetchSeries(query: { + metricName: string; + statistic: string; + scope: string; + runId?: string; + implementation?: string; + model?: string; + scene?: string; + status?: string; + runKind?: string; + search?: string; + phase?: string; +}): Promise { + const response = await request<{ items: MetricSeriesPoint[] }>( + "/api/v1/history/metrics/series", + { + metric_name: query.metricName, + statistic: query.statistic, + scope: query.scope, + run_id: query.runId, + implementation: query.implementation, + model: query.model, + scene: query.scene, + status: query.status, + run_kind: query.runKind, + search: query.search, + phase: query.phase, + }, + ); + return response.items; +} + +export async function fetchRunMetrics( + runId: string, + scope?: string, + phase?: string, +): Promise { + const response = await request<{ items: MetricSeriesPoint[] }>( + `/api/v1/history/runs/${encodeURIComponent(runId)}/metrics`, + { scope, phase, limit: 20_000 }, + ); + return response.items; +} + +export async function fetchRun(runId: string): Promise { + const response = await request<{ run: HistoryRun }>( + `/api/v1/history/runs/${encodeURIComponent(runId)}`, + ); + return response.run; +} diff --git a/web/history-ui/src/comparison.ts b/web/history-ui/src/comparison.ts new file mode 100644 index 0000000000..5a679ac5b0 --- /dev/null +++ b/web/history-ui/src/comparison.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type ComparisonSide = "left" | "right"; + +export interface ComparisonGroups { + left: string[]; + right: string[]; +} + +const STORAGE_KEY = "aiperf.history.comparison.groups.v1"; + +function uniqueRunIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set( + value.filter((item): item is string => typeof item === "string" && item.length > 0), + ), + ]; +} + +export function normalizeComparisonGroups(value: unknown): ComparisonGroups | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + const left = uniqueRunIds(candidate.left); + const leftSet = new Set(left); + const right = uniqueRunIds(candidate.right).filter((runId) => !leftSet.has(runId)); + return { left, right }; +} + +export function readStoredComparisonGroups(): ComparisonGroups | null { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === null) return null; + try { + return normalizeComparisonGroups(JSON.parse(stored)); + } catch { + return null; + } +} + +export function storeComparisonGroups(groups: ComparisonGroups): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(groups)); + } catch { + // Storage can be unavailable in privacy modes; in-memory comparison remains usable. + } +} diff --git a/web/history-ui/src/components/MetricChart.vue b/web/history-ui/src/components/MetricChart.vue new file mode 100644 index 0000000000..e03e7fb158 --- /dev/null +++ b/web/history-ui/src/components/MetricChart.vue @@ -0,0 +1,368 @@ + + + + diff --git a/web/history-ui/src/components/MetricChartCard.vue b/web/history-ui/src/components/MetricChartCard.vue new file mode 100644 index 0000000000..8fafd5d015 --- /dev/null +++ b/web/history-ui/src/components/MetricChartCard.vue @@ -0,0 +1,248 @@ + + + + diff --git a/web/history-ui/src/components/MetricSelector.vue b/web/history-ui/src/components/MetricSelector.vue new file mode 100644 index 0000000000..8067e8ab0b --- /dev/null +++ b/web/history-ui/src/components/MetricSelector.vue @@ -0,0 +1,178 @@ + + + + diff --git a/web/history-ui/src/components/RunDetail.vue b/web/history-ui/src/components/RunDetail.vue new file mode 100644 index 0000000000..cdeeab2093 --- /dev/null +++ b/web/history-ui/src/components/RunDetail.vue @@ -0,0 +1,155 @@ + + + + diff --git a/web/history-ui/src/components/RunsTable.vue b/web/history-ui/src/components/RunsTable.vue new file mode 100644 index 0000000000..ff848b6886 --- /dev/null +++ b/web/history-ui/src/components/RunsTable.vue @@ -0,0 +1,118 @@ + + + + diff --git a/web/history-ui/src/env.d.ts b/web/history-ui/src/env.d.ts new file mode 100644 index 0000000000..43a4245b7e --- /dev/null +++ b/web/history-ui/src/env.d.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// diff --git a/web/history-ui/src/labels.ts b/web/history-ui/src/labels.ts new file mode 100644 index 0000000000..e918c5d530 --- /dev/null +++ b/web/history-ui/src/labels.ts @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const metricLabels: Record = { + benchmark_duration: "基准测试耗时", + "cache.hit_rate": "缓存命中率", + "cache.transfer_bytes": "缓存迁移量", + "cache.usage": "缓存使用率", + chunk_compute_fps: "分块计算帧率", + chunk_compute_fps_weighted: "加权分块计算帧率", + chunk_compute_seconds: "分块计算耗时", + chunk_encode_seconds: "分块编码耗时", + chunk_frames: "分块帧数", + chunk_output_batches: "分块输出批次数", + chunk_output_header_write_seconds: "分块输出头写入耗时", + chunk_output_pacing_seconds: "分块输出节流耗时", + chunk_output_payload_write_seconds: "分块载荷写入耗时", + chunk_output_write_seconds: "分块输出写入耗时", + chunk_peak_allocated_bytes: "分块峰值已分配显存", + chunk_peak_reserved_bytes: "分块峰值保留显存", + chunk_raw_output_bytes: "分块原始输出大小", + chunk_request_prepare_seconds: "分块请求准备耗时", + chunk_steady_state_count: "稳态分块数量", + chunk_steady_state_total_compute_seconds: "稳态总计算耗时", + chunk_steady_state_total_frames: "稳态总帧数", + chunk_steady_state_total_output_batches: "稳态总输出批次数", + chunk_steady_state_total_raw_output_bytes: "稳态原始输出总量", + chunk_steady_state_total_wire_output_bytes: "稳态传输输出总量", + chunk_steady_state_warmup_chunks_skipped: "稳态排除的预热分块数", + chunk_total_seconds: "分块总耗时", + chunk_wire_output_bytes: "分块传输输出大小", + connected_latency_ms: "连接建立时延", + control_ack_latency_ms: "控制确认时延", + control_to_next_frame_latency_ms: "控制到下一帧时延", + first_frame_latency_ms: "首帧时延", + first_metadata_latency_ms: "首条元数据时延", + frames_received: "接收帧数", + gpu_memory_used: "GPU 显存使用量", + gpu_power_usage: "GPU 功耗", + gpu_temperature: "GPU 温度", + gpu_utilization: "GPU 利用率", + input_sequence_length: "输入序列长度", + metadata_messages: "元数据消息数", + offer_rtt_ms: "Offer 往返时延", + output_sequence_length: "输出序列长度", + pipeline_init_peak_allocated_bytes: "流水线初始化峰值已分配显存", + pipeline_init_peak_reserved_bytes: "流水线初始化峰值保留显存", + pipeline_init_seconds: "流水线初始化耗时", + profile_attempted_sessions: "正式测量尝试会话数", + profile_failed_sessions: "正式测量失败会话数", + profile_successful_sessions: "正式测量成功会话数", + request_count: "请求数", + request_latency: "请求时延", + request_throughput: "请求吞吐量", + "resource.cpu": "CPU 使用", + "resource.gpu": "GPU 使用", + "resource.gpu_memory": "GPU 显存", + "resource.memory": "内存使用", + "resource.network": "网络带宽", + "requests.completed": "已完成请求数", + "requests.server_execution_latency": "服务端执行时延", + runtime_creation_peak_allocated_bytes: "运行时创建峰值已分配显存", + runtime_creation_peak_reserved_bytes: "运行时创建峰值保留显存", + runtime_creation_seconds: "运行时创建耗时", + "scheduler.queue_depth": "调度器队列深度", + "scheduler.preemptions": "调度抢占数", + "scheduler.rejections": "调度拒绝数", + "scheduler.running_tasks": "调度器运行任务数", + server_scrape_latency_ms: "服务端指标抓取时延", + session_runtime_s: "会话运行时长", + session_success: "会话成功状态", + status_messages: "状态消息数", + stream_fps: "客户端流式帧率", + success_rate: "成功率", + cpu_utilization: "CPU 利用率", + network_bandwidth: "网络带宽", + pcie_bandwidth: "PCIe 带宽", + system_memory_used: "系统内存使用量", + telefuser_http_request_duration_seconds: "TeleFuser HTTP 请求耗时", + telefuser_http_requests: "TeleFuser HTTP 请求数", + telefuser_http_requests_inflight: "TeleFuser 进行中 HTTP 请求数", + telefuser_queue_pending: "TeleFuser 队列等待数", + telefuser_queue_processing: "TeleFuser 队列处理数", + telefuser_queue_size: "TeleFuser 队列大小", + telefuser_task_duration_seconds: "TeleFuser 任务耗时", + telefuser_task_queue_wait_seconds: "TeleFuser 任务排队耗时", + telefuser_tasks_cancelled: "TeleFuser 已取消任务数", + telefuser_tasks_completed: "TeleFuser 已完成任务数", + telefuser_tasks_failed: "TeleFuser 失败任务数", + total_isl: "输入 Token 总数", +}; + +const statisticLabels: Record = { + value: "值", + avg: "平均值", + mean: "平均值", + min: "最小值", + max: "最大值", + count: "数量", + sum: "总和", + std: "标准差", +}; + +const scopeLabels: Record = { + run: "任务汇总", + session: "会话", + control: "控制事件", + phase: "运行阶段", + chunk: "生成分块", + timeslice: "时间片", + gpu: "GPU 采样", + gpu_summary: "GPU 汇总", + server: "服务端采样", + server_summary: "服务端汇总", + normalized: "归一化指标", + resource: "运行期资源", +}; + +const unitLabels: Record = { + "": "无单位", + unitless: "无单位", + ms: "毫秒", + milliseconds: "毫秒", + s: "秒", + sec: "秒", + seconds: "秒", + bytes: "字节", + "bytes/second": "字节/秒", + cores: "逻辑核", + GB: "GB", + gigabytes: "GB", + frames: "帧", + "frames/second": "帧/秒", + count: "个", + ratio: "比例", + boolean: "布尔值", + tokens: "Token", + requests: "请求", + "requests/sec": "请求/秒", + "requests/second": "请求/秒", + percent: "百分比", + tasks: "任务", + watts: "瓦", + celsius: "摄氏度", +}; + +const statusLabels: Record = { + completed: "已完成", + failed: "失败", + partial: "部分完成", + cancelled: "已取消", + running: "运行中", + pending: "等待中", + disabled: "已禁用", + invalid: "无效", +}; + +const runKindLabels: Record = { + stream: "流式任务", + profile: "性能测试", + batch: "批处理任务", +}; + +const phaseLabels: Record = { + warmup: "预热", + profiling: "正式测量", + initialization: "初始化", + init: "初始化", + runtime: "运行时", + all: "全部阶段", +}; + +const implementationLabels: Record = { + telefuser: "TeleFuser", + sglang_diffusion: "SGLang-Diffusion", + diffusers: "Diffusers", +}; + +const sceneLabels: Record = { + lingbot_world_fast: "LingBot 世界模型", + video_generation: "视频生成", + stream_world: "流式世界模型", +}; + +const transportLabels: Record = { + webrtc: "WebRTC", + websocket: "WebSocket", + http: "HTTP", +}; + +export function metricLabel(value: string): string { + return metricLabels[value] ?? value; +} + +export function statisticLabel(value: string): string { + if (/^p\d+$/i.test(value)) return value.toUpperCase(); + return statisticLabels[value] ?? value; +} + +export function scopeLabel(value: string): string { + return scopeLabels[value] ?? value; +} + +export function unitLabel(value: string): string { + return unitLabels[value] ?? value; +} + +export function statusLabel(value: string): string { + return statusLabels[value] ?? value; +} + +export function runKindLabel(value: string): string { + return runKindLabels[value] ?? value; +} + +export function phaseLabel(value: string): string { + return phaseLabels[value] ?? value; +} + +export function implementationLabel(value: string): string { + return implementationLabels[value] ?? value; +} + +export function sceneLabel(value: string): string { + return sceneLabels[value] ?? value; +} + +export function transportLabel(value: string): string { + return transportLabels[value] ?? value; +} + +const resourceSubjectLabels: Record = { + process_used: "目标进程", + container_used: "容器使用", + container_total: "容器上限", + machine_used: "整机使用", + machine_total: "整机总量", +}; + +export function resourceSubjectLabel(value: string): string { + return resourceSubjectLabels[value] ?? value; +} + +const networkKindLabels: Record = { + ethernet: "Ethernet", + rdma: "RDMA", +}; + +const networkDirectionLabels: Record = { + receive: "接收", + transmit: "发送", +}; + +export function networkKindLabel(value: string): string { + return networkKindLabels[value] ?? value; +} + +export function networkDirectionLabel(value: string): string { + return networkDirectionLabels[value] ?? value; +} diff --git a/web/history-ui/src/main.ts b/web/history-ui/src/main.ts new file mode 100644 index 0000000000..ca1b248c28 --- /dev/null +++ b/web/history-ui/src/main.ts @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createApp } from "vue"; +import App from "./App.vue"; +import "./style.css"; + +createApp(App).mount("#app"); diff --git a/web/history-ui/src/metricPresentation.ts b/web/history-ui/src/metricPresentation.ts new file mode 100644 index 0000000000..6f273ff89c --- /dev/null +++ b/web/history-ui/src/metricPresentation.ts @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { unitLabel } from "./labels"; +import type { MetricSeriesPoint } from "./types"; + +type DisplayFamily = "bytes" | "bytes_per_second" | "cores" | "percent" | "plain"; + +export interface MetricDisplayScale { + family: DisplayFamily; + sourceUnit: string; + divisor: number; + multiplier: number; + label: string; +} + +export interface ResourceMetricPresentation { + usage: MetricSeriesPoint[]; + machineTotal: MetricSeriesPoint[]; + containerLimits: MetricSeriesPoint[]; +} + +interface DecimalScale { + threshold: number; + divisor: number; + prefix: string; +} + +const decimalScales: DecimalScale[] = [ + { threshold: 1e12, divisor: 1e12, prefix: "TB" }, + { threshold: 1e9, divisor: 1e9, prefix: "GB" }, + { threshold: 1e6, divisor: 1e6, prefix: "MB" }, + { threshold: 1e3, divisor: 1e3, prefix: "KB" }, + { threshold: 0, divisor: 1, prefix: "B" }, +]; + +const stackedResourceMetrics = new Set(["resource.gpu", "resource.gpu_memory"]); + +function normalizedUnit(unit: string): string { + return unit.trim().toLowerCase(); +} + +function familyForUnit(unit: string): DisplayFamily { + const normalized = normalizedUnit(unit); + if (normalized === "bytes" || normalized === "byte") return "bytes"; + if (["bytes/second", "bytes/sec", "byte/second", "b/s"].includes(normalized)) { + return "bytes_per_second"; + } + if (normalized === "cores" || normalized === "core") return "cores"; + if (["percent", "percentage", "%"].includes(normalized)) return "percent"; + return "plain"; +} + +function decimalScale(maximum: number): DecimalScale { + const magnitude = Math.abs(maximum); + return decimalScales.find((scale) => magnitude >= scale.threshold) ?? decimalScales.at(-1)!; +} + +function maximumChartMagnitude(points: MetricSeriesPoint[]): number { + let maximum = 0; + const stackedTotals = new Map(); + for (const point of points) { + if (!Number.isFinite(point.value)) continue; + const stackKey = resourceStackKey(point); + if (!stackKey) { + maximum = Math.max(maximum, Math.abs(point.value)); + continue; + } + const timestampKey = `${stackKey}\u001f${point.recorded_at}`; + const total = (stackedTotals.get(timestampKey) ?? 0) + point.value; + stackedTotals.set(timestampKey, total); + maximum = Math.max(maximum, Math.abs(total)); + } + return maximum; +} + +export function createMetricDisplayScale( + points: MetricSeriesPoint[], + fallbackUnit = "", +): MetricDisplayScale { + const sourceUnits = [...new Set(points.map((point) => point.unit).filter(Boolean))]; + const sourceUnit = sourceUnits.length === 1 ? sourceUnits[0] : fallbackUnit; + const family = sourceUnits.length > 1 ? "plain" : familyForUnit(sourceUnit); + const maximum = maximumChartMagnitude(points); + + if (family === "cores") { + return { family, sourceUnit, divisor: 1, multiplier: 100, label: "%" }; + } + if (family === "percent") { + return { family, sourceUnit, divisor: 1, multiplier: 1, label: "%" }; + } + if (family === "bytes" || family === "bytes_per_second") { + const scale = decimalScale(maximum); + return { + family, + sourceUnit, + divisor: scale.divisor, + multiplier: 1, + label: `${scale.prefix}${family === "bytes_per_second" ? "/s" : ""}`, + }; + } + return { + family, + sourceUnit, + divisor: 1, + multiplier: 1, + label: sourceUnits.length > 1 ? "多单位" : unitLabel(sourceUnit), + }; +} + +export function displayMetricValue( + value: number, + unit: string, + scale: MetricDisplayScale, +): number { + if (familyForUnit(unit) !== scale.family && scale.family !== "plain") return value; + return (value * scale.multiplier) / scale.divisor; +} + +function formatNumber(value: number): string { + const magnitude = Math.abs(value); + const maximumFractionDigits = magnitude > 0 && magnitude < 1 ? 3 : 2; + return new Intl.NumberFormat("zh-CN", { maximumFractionDigits }).format(value); +} + +export function formatMetricValue(value: number, unit: string): string { + const family = familyForUnit(unit); + if (family === "cores") return `${formatNumber(value * 100)}%`; + if (family === "percent") return `${formatNumber(value)}%`; + if (family === "bytes" || family === "bytes_per_second") { + const scale = decimalScale(value); + const suffix = family === "bytes_per_second" ? "/s" : ""; + return `${formatNumber(value / scale.divisor)} ${scale.prefix}${suffix}`; + } + return `${formatNumber(value)} ${unitLabel(unit)}`.trim(); +} + +export function formatDisplayAxisValue(value: number): string { + return formatNumber(value); +} + +export function isStackedResourceMetric(metricName: string): boolean { + return stackedResourceMetrics.has(metricName); +} + +export function resourceStackKey(point: MetricSeriesPoint): string | undefined { + const subject = point.labels.resource_subject; + if ( + point.scope !== "resource" || + !isStackedResourceMetric(point.metric_name) || + !point.run_id || + !subject || + !point.device.startsWith("gpu:") + ) { + return undefined; + } + return `${point.run_id}\u001f${point.metric_name}\u001f${subject}`; +} + +function aggregateResourceSubject( + points: MetricSeriesPoint[], + subject: "container_total" | "machine_total", +): MetricSeriesPoint[] { + const groups = new Map(); + for (const point of points) { + if (point.scope !== "resource" || point.labels.resource_subject !== subject) continue; + const key = [point.run_id, point.metric_name, point.unit, point.recorded_at].join("\u001f"); + groups.set(key, [...(groups.get(key) ?? []), point]); + } + return [...groups.values()] + .map((group) => { + const first = group[0]; + const deviceValues = new Map( + group.filter((point) => point.device).map((point) => [point.device, point.value]), + ); + const sumDevices = isStackedResourceMetric(first.metric_name) && deviceValues.size > 0; + const networkCapacityByKind = new Map(); + if (first.metric_name === "resource.network") { + for (const point of group) { + const kind = point.labels.network_kind ?? "network"; + networkCapacityByKind.set( + kind, + Math.max(networkCapacityByKind.get(kind) ?? 0, point.value), + ); + } + } + const sumNetworkKinds = networkCapacityByKind.size > 0; + const value = sumDevices + ? [...deviceValues.values()].reduce((total, current) => total + current, 0) + : sumNetworkKinds + ? [...networkCapacityByKind.values()].reduce((total, current) => total + current, 0) + : Math.max(...group.map((point) => point.value)); + return { + ...first, + value, + device: "", + labels: { + ...first.labels, + resource_aggregate: subject, + aggregated_series_count: String( + sumDevices + ? deviceValues.size + : sumNetworkKinds + ? networkCapacityByKind.size + : group.length, + ), + }, + }; + }) + .sort( + (left, right) => + left.run_id.localeCompare(right.run_id) || + new Date(left.recorded_at).getTime() - new Date(right.recorded_at).getTime(), + ); +} + +function latestPerRun(points: MetricSeriesPoint[]): MetricSeriesPoint[] { + const latest = new Map(); + for (const point of points) { + const current = latest.get(point.run_id); + if (!current || new Date(point.recorded_at).getTime() >= new Date(current.recorded_at).getTime()) { + latest.set(point.run_id, point); + } + } + return [...latest.values()].sort((left, right) => left.run_id.localeCompare(right.run_id)); +} + +export function buildResourceMetricPresentation( + points: MetricSeriesPoint[], +): ResourceMetricPresentation { + const usage = points.filter( + (point) => point.scope === "resource" && point.labels.resource_subject?.endsWith("_used"), + ); + const machineTotal = aggregateResourceSubject(points, "machine_total"); + const containerLimits = latestPerRun(aggregateResourceSubject(points, "container_total")); + return { usage, machineTotal, containerLimits }; +} + +export function metricDisplayExtent( + points: MetricSeriesPoint[], + scale: MetricDisplayScale, +): [number, number] | undefined { + const values: number[] = []; + const stackedTotals = new Map(); + for (const point of points) { + if (!Number.isFinite(point.value)) continue; + const value = displayMetricValue(point.value, point.unit, scale); + const stackKey = resourceStackKey(point); + if (!stackKey) { + values.push(value); + continue; + } + const timestampKey = `${stackKey}\u001f${point.recorded_at}`; + stackedTotals.set(timestampKey, (stackedTotals.get(timestampKey) ?? 0) + value); + } + values.push(...stackedTotals.values()); + if (!values.length) return undefined; + return [Math.min(...values), Math.max(...values)]; +} diff --git a/web/history-ui/src/metricTaxonomy.ts b/web/history-ui/src/metricTaxonomy.ts new file mode 100644 index 0000000000..126b9ab50f --- /dev/null +++ b/web/history-ui/src/metricTaxonomy.ts @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { metricGroupKey, type MetricCatalogGroup } from "./metrics"; +import type { MetricCatalogItem } from "./types"; + +export const MAX_DASHBOARD_METRICS = 16; +const STORAGE_KEY = "aiperf.history.dashboard.semantic-metrics.v5"; +const GROUP_STORAGE_KEY = "aiperf.history.dashboard.metric-groups.v2"; +const LEGACY_STORAGE_KEY = "aiperf.history.dashboard.metrics.v1"; + +interface CoreMetricDefinition { + id: string; + metricNames: string[]; + scopes: string[]; + units?: string[]; +} + +interface CoreDimensionDefinition { + id: string; + label: string; + description: string; + metrics: CoreMetricDefinition[]; +} + +export interface DashboardMetric { + key: string; + metric_name: string; + scope: string; + unit: string; + units: string[]; + variants: MetricCatalogItem[]; + sourceGroups: MetricCatalogGroup[]; + point_count: number; +} + +export interface MetricTreeCategory { + id: string; + label: string; + description: string; + metrics: DashboardMetric[]; + availableCount: number; + expectedCount: number; +} + +export interface MetricTaxonomy { + core: MetricTreeCategory[]; + allMetrics: DashboardMetric[]; + metricByKey: Map; + metricKeyByGroupKey: Map; +} + +const coreDimensions: CoreDimensionDefinition[] = [ + { + id: "request_session", + label: "请求与 Session", + description: "端到端时延、吞吐、成功率和生命周期", + metrics: [ + { id: "request_latency", metricNames: ["request_latency"], scopes: ["run"] }, + { id: "request_throughput", metricNames: ["request_throughput"], scopes: ["run"] }, + { id: "success_rate", metricNames: ["success_rate"], scopes: ["run"] }, + { id: "connected_latency", metricNames: ["connected_latency_ms"], scopes: ["run"] }, + { id: "session_runtime", metricNames: ["session_runtime_s"], scopes: ["run"] }, + { id: "completed_requests", metricNames: ["requests.completed"], scopes: ["normalized"] }, + { + id: "server_execution_latency", + metricNames: ["requests.server_execution_latency"], + scopes: ["normalized"], + }, + ], + }, + { + id: "scheduling_queue", + label: "调度与队列", + description: "运行中、等待中、抢占和拒绝", + metrics: [ + { id: "queue_depth", metricNames: ["scheduler.queue_depth"], scopes: ["normalized"] }, + { id: "running_tasks", metricNames: ["scheduler.running_tasks"], scopes: ["normalized"] }, + { id: "preemptions", metricNames: ["scheduler.preemptions"], scopes: ["normalized"] }, + { id: "rejections", metricNames: ["scheduler.rejections"], scopes: ["normalized"] }, + ], + }, + { + id: "resources", + label: "资源", + description: "进程、容器、整机与 Ethernet/RDMA 的运行期曲线", + metrics: [ + { + id: "cpu_usage", + metricNames: ["resource.cpu", "cpu_utilization"], + scopes: ["resource", "server", "server_summary"], + }, + { + id: "memory_usage", + metricNames: ["resource.memory", "system_memory_used"], + scopes: ["resource", "server", "server_summary"], + }, + { + id: "gpu_usage", + metricNames: ["resource.gpu", "gpu_utilization", "amd_gpu_utilization"], + scopes: ["resource", "gpu", "gpu_summary"], + }, + { + id: "gpu_memory_usage", + metricNames: [ + "resource.gpu_memory", + "gpu_memory_used", + "amd_memory_used", + "chunk_peak_reserved_bytes", + "chunk_peak_allocated_bytes", + ], + scopes: ["resource", "gpu", "gpu_summary", "run"], + }, + { + id: "network_usage", + metricNames: ["resource.network", "network_bandwidth"], + scopes: ["resource", "server", "server_summary"], + }, + ], + }, + { + id: "cache", + label: "缓存", + description: "容量、使用率、命中率、复用和迁移", + metrics: [ + { id: "cache_usage", metricNames: ["cache.usage"], scopes: ["normalized"] }, + { id: "cache_hit_rate", metricNames: ["cache.hit_rate"], scopes: ["normalized"] }, + { id: "cache_transfer", metricNames: ["cache.transfer_bytes"], scopes: ["normalized"] }, + ], + }, + { + id: "media_stream", + label: "媒体流", + description: "帧输出、目标计算吞吐和控制闭环", + metrics: [ + { id: "delivery_fps", metricNames: ["stream_fps"], scopes: ["run"] }, + { + id: "target_compute_fps", + metricNames: ["chunk_compute_fps_weighted"], + scopes: ["run"], + }, + { id: "first_frame_latency", metricNames: ["first_frame_latency_ms"], scopes: ["run"] }, + { + id: "first_metadata_latency", + metricNames: ["first_metadata_latency_ms"], + scopes: ["run"], + }, + { id: "control_ack_latency", metricNames: ["control_ack_latency_ms"], scopes: ["run"] }, + { + id: "control_feedback_latency", + metricNames: ["control_to_next_frame_latency_ms"], + scopes: ["run"], + }, + ], + }, +]; + +const defaultCoreMetricIds = [ + "target_compute_fps", + "delivery_fps", + "cpu_usage", + "memory_usage", + "gpu_usage", + "gpu_memory_usage", + "network_usage", + "first_frame_latency", + "control_ack_latency", + "control_feedback_latency", + "request_throughput", + "request_latency", +]; + +function scopeRank(scope: string, preferred: string[]): number { + const rank = preferred.indexOf(scope); + return rank === -1 ? preferred.length : rank; +} + +function unitRank(unit: string, preferred: string[] | undefined): number { + if (!preferred?.length) return 0; + const rank = preferred.indexOf(unit); + return rank === -1 ? preferred.length : rank; +} + +function selectSourceGroup( + metricName: string, + definition: CoreMetricDefinition, + groups: MetricCatalogGroup[], +): MetricCatalogGroup | undefined { + return groups + .filter( + (group) => + group.metric_name === metricName && definition.scopes.includes(group.scope), + ) + .sort( + (left, right) => + scopeRank(left.scope, definition.scopes) - scopeRank(right.scope, definition.scopes) || + unitRank(left.unit, definition.units) - unitRank(right.unit, definition.units) || + right.point_count - left.point_count, + )[0]; +} + +function buildCoreMetric( + dimensionId: string, + definition: CoreMetricDefinition, + groups: MetricCatalogGroup[], +): DashboardMetric | null { + const candidates = definition.metricNames.flatMap((metricName) => { + const group = selectSourceGroup(metricName, definition, groups); + return group ? [group] : []; + }); + if (!candidates.length) return null; + const primary = candidates[0]; + const sourceGroups = candidates.filter( + (group) => group.scope === primary.scope && group.unit === primary.unit, + ); + const units = [...new Set(sourceGroups.map((group) => group.unit))]; + const scopes = [...new Set(sourceGroups.map((group) => group.scope))]; + return { + key: `core:${dimensionId}:${definition.id}`, + metric_name: definition.metricNames[0], + scope: scopes.length === 1 ? scopes[0] : "mixed", + unit: units.length === 1 ? units[0] : "mixed", + units, + variants: sourceGroups.flatMap((group) => group.variants), + sourceGroups, + point_count: sourceGroups.reduce((total, group) => total + group.point_count, 0), + }; +} + +export function buildMetricTaxonomy(groups: MetricCatalogGroup[]): MetricTaxonomy { + const metricKeyByGroupKey = new Map(); + const core = coreDimensions.map((dimension) => { + const metrics = dimension.metrics.flatMap((definition) => { + const metric = buildCoreMetric(dimension.id, definition, groups); + if (!metric) return []; + for (const group of metric.sourceGroups) { + metricKeyByGroupKey.set(metricGroupKey(group), metric.key); + } + return [metric]; + }); + return { + id: dimension.id, + label: dimension.label, + description: dimension.description, + metrics, + availableCount: metrics.length, + expectedCount: dimension.metrics.length, + }; + }); + + const allMetrics = core.flatMap((category) => category.metrics); + return { + core, + allMetrics, + metricByKey: new Map(allMetrics.map((metric) => [metric.key, metric])), + metricKeyByGroupKey, + }; +} + +export function preferredDashboardMetricKeys(taxonomy: MetricTaxonomy): string[] { + const available = taxonomy.core.flatMap((category) => category.metrics); + const preferred = defaultCoreMetricIds.flatMap((id) => { + const metric = available.find((candidate) => candidate.key.endsWith(`:${id}`)); + return metric ? [metric.key] : []; + }); + return preferred.slice(0, MAX_DASHBOARD_METRICS); +} + +function parseStoredKeys(stored: string): string[] | null { + try { + const value = JSON.parse(stored) as unknown; + if (!Array.isArray(value)) return null; + return value.filter((item): item is string => typeof item === "string"); + } catch { + return null; + } +} + +function migrateGroupKeys(keys: string[], taxonomy: MetricTaxonomy): string[] { + return [ + ...new Set( + keys.flatMap((key) => { + const metricKey = taxonomy.metricKeyByGroupKey.get(key); + return metricKey ? [metricKey] : []; + }), + ), + ]; +} + +export function readStoredDashboardMetricKeys( + taxonomy: MetricTaxonomy, + groups: MetricCatalogGroup[], +): string[] | null { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored !== null) return parseStoredKeys(stored)?.slice(0, MAX_DASHBOARD_METRICS) ?? null; + + const grouped = window.localStorage.getItem(GROUP_STORAGE_KEY); + if (grouped !== null) { + const keys = parseStoredKeys(grouped); + return keys === null ? null : migrateGroupKeys(keys, taxonomy).slice(0, MAX_DASHBOARD_METRICS); + } + + const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy === null) return null; + const legacyKeys = parseStoredKeys(legacy); + if (legacyKeys === null) return null; + const groupKeys = legacyKeys.flatMap((key) => { + const [metricName, , scope] = key.split("\u001f"); + const group = groups.find( + (candidate) => candidate.metric_name === metricName && candidate.scope === scope, + ); + return group ? [metricGroupKey(group)] : []; + }); + return migrateGroupKeys(groupKeys, taxonomy).slice(0, MAX_DASHBOARD_METRICS); +} + +export function storeDashboardMetricKeys(keys: string[]): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(keys.slice(0, MAX_DASHBOARD_METRICS))); + } catch { + // Storage can be unavailable in privacy modes; the in-memory dashboard remains usable. + } +} diff --git a/web/history-ui/src/metrics.ts b/web/history-ui/src/metrics.ts new file mode 100644 index 0000000000..31673b4412 --- /dev/null +++ b/web/history-ui/src/metrics.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MetricCatalogItem } from "./types"; + +const statisticOrder = [ + "value", + "avg", + "mean", + "p50", + "p75", + "p90", + "p95", + "p99", + "min", + "max", + "std", + "count", + "sum", + "bucket", +]; + +export interface MetricCatalogGroup { + metric_name: string; + scope: string; + unit: string; + variants: MetricCatalogItem[]; + point_count: number; +} + +export function metricGroupKey( + item: Pick, +): string { + return `${item.metric_name}\u001f${item.scope}\u001f${item.unit}`; +} + +function statisticRank(value: string): number { + const rank = statisticOrder.indexOf(value.toLowerCase()); + return rank === -1 ? statisticOrder.length : rank; +} + +export function groupMetricCatalog(items: MetricCatalogItem[]): MetricCatalogGroup[] { + const groups = new Map(); + for (const item of items) { + const key = metricGroupKey(item); + const group = groups.get(key) ?? { + metric_name: item.metric_name, + scope: item.scope, + unit: item.unit, + variants: [], + point_count: 0, + }; + group.variants.push(item); + group.point_count += item.point_count; + groups.set(key, group); + } + return [...groups.values()] + .map((group) => ({ + ...group, + variants: group.variants.sort( + (left, right) => + statisticRank(left.statistic) - statisticRank(right.statistic) || + left.statistic.localeCompare(right.statistic), + ), + })) + .sort((left, right) => { + const scopeDifference = Number(right.scope === "run") - Number(left.scope === "run"); + return ( + scopeDifference || + left.metric_name.localeCompare(right.metric_name) || + left.scope.localeCompare(right.scope) || + left.unit.localeCompare(right.unit) + ); + }); +} diff --git a/web/history-ui/src/style.css b/web/history-ui/src/style.css new file mode 100644 index 0000000000..702dce8810 --- /dev/null +++ b/web/history-ui/src/style.css @@ -0,0 +1,364 @@ +/*! SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 */ + +:root { + color-scheme: dark; + font-family: Inter, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, + -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e9f4f1; + background: #07110f; + font-synthesis: none; + text-rendering: optimizeLegibility; + --bg: #07110f; + --panel: #0d1a17; + --panel-2: #11211d; + --line: #213832; + --line-bright: #31564d; + --text: #e9f4f1; + --muted: #8fa39d; + --faint: #657a74; + --mint: #66e3c4; + --mint-dark: #183c34; + --amber: #ffb45d; + --danger: #ff7f8b; +} + +* { box-sizing: border-box; } +html { min-width: 320px; background: var(--bg); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: + radial-gradient(circle at 86% -8%, rgba(55, 143, 121, 0.16), transparent 31rem), + radial-gradient(circle at 6% 30%, rgba(255, 180, 93, 0.05), transparent 24rem), var(--bg); } +button, input, select { font: inherit; } +button, select { cursor: pointer; } +a { color: inherit; text-decoration: none; } + +#aiperf-tooltip-top-layer { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: visible; + pointer-events: none; +} +#aiperf-tooltip-top-layer::backdrop { background: transparent; pointer-events: none; } +.chart-tooltip-totals { display: block; margin-top: 6px; padding-top: 6px; border-top: 1px solid #29433d; + color: #bddc72; } + +.topbar { height: 68px; padding: 0 38px; display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid rgba(74, 111, 102, 0.28); background: rgba(7, 17, 15, 0.82); + backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; } +.brand { display: flex; gap: 12px; align-items: center; } +.brand > span:last-child { display: flex; flex-direction: column; } +.brand strong { font-size: 16px; letter-spacing: 0.02em; } +.brand small { color: var(--muted); font-size: 11px; margin-top: 1px; } +.brand-mark { width: 31px; height: 31px; display: flex; align-items: end; gap: 3px; padding: 7px; + border: 1px solid var(--line-bright); border-radius: 8px; background: var(--panel-2); } +.brand-mark i { display: block; width: 4px; border-radius: 3px; background: var(--mint); } +.brand-mark i:nth-child(1) { height: 7px; opacity: .65; } +.brand-mark i:nth-child(2) { height: 15px; } +.brand-mark i:nth-child(3) { height: 11px; opacity: .82; } +.topbar-meta { display: flex; align-items: center; gap: 24px; color: var(--muted); font-size: 12px; } +.topbar-meta a:hover { color: var(--mint); } +.database-state { display: inline-flex; align-items: center; gap: 8px; } +.database-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 12px var(--mint); } + +main { width: min(1540px, calc(100% - 64px)); margin: 0 auto; padding: 48px 0 80px; } +.hero-row { display: flex; justify-content: space-between; gap: 32px; align-items: end; margin-bottom: 30px; } +.hero-row h1 { margin: 7px 0 8px; font-size: clamp(29px, 4vw, 47px); line-height: 1.08; letter-spacing: -0.04em; font-weight: 620; } +.hero-row p { margin: 0; color: var(--muted); max-width: 710px; font-size: 15px; } +.eyebrow { display: block; color: var(--mint); font-size: 10px; font-weight: 700; letter-spacing: .17em; } +.hero-actions { display: flex; gap: 10px; flex-shrink: 0; } +.primary-button, .ghost-button { border-radius: 8px; padding: 10px 15px; border: 1px solid var(--line-bright); color: var(--text); } +.primary-button { background: var(--mint); color: #07110f; border-color: var(--mint); font-weight: 700; } +.primary-button:hover { background: #86ecd3; } +.ghost-button { background: var(--panel); } +.ghost-button:hover { border-color: var(--mint); } + +.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; } +.summary-grid article { min-height: 122px; padding: 19px 21px; border: 1px solid var(--line); border-radius: 11px; + background: linear-gradient(145deg, rgba(17, 34, 29, .96), rgba(11, 25, 21, .96)); } +.summary-grid span, .summary-grid small { display: block; color: var(--muted); } +.summary-grid span { font-size: 12px; } +.summary-grid strong { display: block; margin: 9px 0 5px; font-size: 29px; line-height: 1; font-weight: 590; } +.summary-grid small { font-size: 11px; } +.summary-grid .summary-time { font-size: 23px; } + +.panel { border: 1px solid var(--line); border-radius: 12px; background: rgba(12, 26, 22, .93); overflow: hidden; } +.filter-panel { padding: 17px; margin-bottom: 14px; } +.filter-grid { display: grid; grid-template-columns: 1.45fr repeat(5, minmax(125px, 1fr)); gap: 10px; } +label > span { display: block; margin: 0 0 6px 2px; color: var(--faint); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } +input, select { width: 100%; height: 39px; border-radius: 7px; border: 1px solid var(--line); background: #091512; + color: var(--text); padding: 0 11px; outline: none; } +input:focus, select:focus { border-color: var(--mint); box-shadow: 0 0 0 2px rgba(102, 227, 196, .09); } +input::placeholder { color: #52645f; } + +.panel-heading { min-height: 82px; display: flex; align-items: center; justify-content: space-between; gap: 24px; + padding: 17px 20px; border-bottom: 1px solid var(--line); } +.panel-heading h2, .detail-section h3 { margin: 4px 0 0; font-size: 17px; font-weight: 590; } +.panel-heading p { color: var(--amber); font-size: 11px; margin: 4px 0 0; } +.text-button { height: 38px; border: 0; color: var(--mint); background: transparent; white-space: nowrap; } +.text-button:disabled { color: var(--faint); cursor: default; } +.loading-line { height: 2px; background: linear-gradient(90deg, transparent, var(--mint), transparent); animation: load 1.2s linear infinite; } +@keyframes load { from { transform: translateX(-100%); } to { transform: translateX(100%); } } + +.dashboard-workspace { display: grid; grid-template-columns: 286px minmax(0, 1fr); gap: 12px; align-items: start; + margin-bottom: 14px; } +.metric-sidebar { min-width: 0; position: sticky; top: 82px; align-self: start; } +.dashboard-content { min-width: 0; } +.dashboard-controls { margin-bottom: 12px; } +.dashboard-heading p { color: var(--muted); } +.comparison-group-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px 20px; } +.comparison-group-summary-card { min-width: 0; display: flex; align-items: center; gap: 12px; padding: 12px 13px; + border: 1px solid var(--line); border-radius: 9px; background: #091512; } +.comparison-group-summary-card > div { min-width: 0; } +.comparison-group-summary-card strong, .comparison-group-summary-card small { display: block; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.comparison-group-summary-card strong { font-size: 12px; font-weight: 590; } +.comparison-group-summary-card small { margin-top: 4px; color: var(--faint); font-size: 10px; } +.comparison-group-summary-card .text-button { margin-left: auto; } +.comparison-group-badge { min-width: 43px; padding: 5px 7px; border-radius: 6px; text-align: center; font-size: 10px; + font-weight: 700; letter-spacing: .08em; } +.left-group-summary { border-color: rgba(102, 227, 196, .35); } +.left-group-summary .comparison-group-badge { color: var(--mint); background: rgba(102, 227, 196, .12); } +.right-group-summary { border-color: rgba(255, 180, 93, .35); } +.right-group-summary .comparison-group-badge { color: var(--amber); background: rgba(255, 180, 93, .12); } +.metric-selector { height: calc(100vh - 94px); min-height: 540px; display: flex; flex-direction: column; } +.metric-sidebar-header { min-height: 82px; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 15px 16px; border-bottom: 1px solid var(--line); } +.metric-sidebar-header h2 { margin: 4px 0 0; font-size: 17px; font-weight: 590; } +.metric-sidebar-header p { margin: 4px 0 0; color: var(--faint); font-size: 10px; } +.metric-sidebar-header > strong { min-width: 43px; padding: 6px 8px; border: 1px solid rgba(102, 227, 196, .3); + border-radius: 7px; color: var(--mint); background: rgba(102, 227, 196, .08); text-align: center; + font-size: 10px; font-weight: 650; } +.metric-sidebar-tools { padding: 12px 13px 10px; border-bottom: 1px solid var(--line); background: #0a1713; } +.metric-search { width: 100%; } +.metric-search input { height: 36px; font-size: 11px; } +.selected-only-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 39px; color: var(--muted); + font-size: 11px; white-space: nowrap; } +.selected-only-toggle input { width: 15px; height: 15px; margin: 0; accent-color: var(--mint); } +.selected-only-toggle span { margin: 0; color: inherit; font-size: inherit; font-weight: 500; text-transform: none; letter-spacing: 0; } +.metric-selection-actions { display: flex; align-items: center; gap: 4px; border-top: 1px solid rgba(49, 78, 70, .38); } +.metric-selection-actions .text-button { height: 31px; padding: 0 5px; font-size: 10px; } +.metric-tree { min-height: 0; flex: 1; overflow: auto; background: #081310; } +.metric-tree-category + .metric-tree-category { border-top: 1px solid rgba(49, 78, 70, .55); } +.metric-tree-category-row { width: 100%; min-height: 51px; display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border: 0; color: var(--text); background: #091512; text-align: left; } +.metric-tree-category-row:hover { background: #0d1d18; } +.metric-tree-category-row > i { width: 15px; color: var(--faint); font-size: 19px; + font-style: normal; line-height: 1; text-align: center; transform: rotate(0deg); transition: transform .16s ease; } +.metric-tree-category-row > i.expanded { transform: rotate(90deg); } +.metric-tree-category-row > span { min-width: 0; flex: 1; } +.metric-tree-category-row strong, .metric-tree-category-row small { display: block; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.metric-tree-category-row strong { font-size: 11px; font-weight: 590; } +.metric-tree-category-row small { margin-top: 3px; color: var(--faint); font-size: 8px; } +.metric-tree-category-row > em { min-width: 23px; color: var(--muted); font-size: 9px; font-style: normal; + text-align: right; white-space: nowrap; } +.metric-tree-children { display: flex; flex-direction: column; gap: 2px; margin-left: 17px; padding: 5px 7px 8px 14px; + border-top: 1px solid rgba(49, 78, 70, .26); border-left: 1px solid rgba(102, 227, 196, .24); + background: #07110f; } +.metric-tree-leaf { min-width: 0; min-height: 43px; display: flex; align-items: center; gap: 8px; position: relative; + padding: 5px 7px; border: 1px solid transparent; border-radius: 6px; background: transparent; cursor: pointer; } +.metric-tree-leaf:hover { border-color: var(--line); background: #0d1c18; } +.metric-tree-leaf.checked { border-color: rgba(102, 227, 196, .38); background: rgba(34, 85, 72, .25); } +.metric-tree-leaf.disabled { opacity: .42; cursor: not-allowed; } +.metric-tree-leaf > input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } +.metric-tree-leaf > i { width: 14px; height: 14px; flex: 0 0 14px; border: 1px solid var(--line-bright); border-radius: 4px; + background: #06100d; } +.metric-tree-leaf.checked > i { border-color: var(--mint); background: var(--mint); box-shadow: inset 0 0 0 4px #0b1b17; } +.metric-tree-leaf > span { display: block; min-width: 0; flex: 1; margin: 0; color: inherit; font-size: inherit; + font-weight: inherit; text-transform: none; letter-spacing: 0; } +.metric-tree-leaf strong, .metric-tree-leaf small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-tree-leaf strong { color: var(--text); font-size: 10px; font-weight: 570; } +.metric-tree-leaf small { margin-top: 3px; color: var(--faint); font-size: 8px; } +.metric-tree-empty { padding: 10px 7px; color: var(--faint); font-size: 9px; } +.metric-tree-availability { padding: 5px 7px 2px; color: var(--amber); font-size: 8px; } +.metric-tree-no-results { padding: 25px 14px; color: var(--muted); text-align: center; font-size: 10px; } +.metric-sidebar-footnote { padding: 9px 13px 11px; border-top: 1px solid var(--line); color: var(--faint); + background: #0a1713; font-size: 8px; line-height: 1.45; } +.metric-selection-notice { margin-top: 8px; padding: 8px 10px; border: 1px solid rgba(255, 180, 93, .28); + border-radius: 7px; color: var(--amber); background: rgba(101, 65, 25, .14); font-size: 10px; } + +.metric-dashboard-sections { min-width: 0; } +.metric-dashboard-section { min-width: 0; margin-bottom: 16px; } +.metric-dashboard-section-heading { min-height: 46px; display: grid; grid-template-columns: minmax(145px, auto) minmax(0, 1fr) auto; + align-items: center; gap: 14px; position: relative; padding: 7px 11px 7px 14px; border: 1px solid var(--line); + border-left: 3px solid rgba(102, 227, 196, .72); border-radius: 9px; background: linear-gradient(90deg, rgba(22, 55, 47, .46), #091512 42%); } +.metric-dashboard-section-heading h3 { margin: 2px 0 0; color: var(--text); font-size: 13px; font-weight: 610; } +.metric-dashboard-section-heading p { min-width: 0; margin: 0; overflow: hidden; color: var(--muted); font-size: 9px; + text-overflow: ellipsis; white-space: nowrap; } +.metric-dashboard-section-heading > strong { min-width: 39px; padding: 4px 6px; border: 1px solid rgba(102, 227, 196, .28); + border-radius: 6px; color: var(--mint); background: rgba(102, 227, 196, .08); text-align: center; font-size: 9px; + font-weight: 650; } +.metric-dashboard-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 8px; } +.metric-chart-card { min-width: 0; min-height: 220px; position: relative; overflow: visible; border: 1px solid var(--line); + border-radius: 12px; background: rgba(12, 26, 22, .93); } +.metric-chart-card:hover, .metric-chart-card:focus-within { z-index: 10; } +.metric-chart-card > header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 9px; + padding: 7px 9px 7px 12px; border-bottom: 1px solid var(--line); } +.metric-chart-card > header div { min-width: 0; } +.metric-chart-card > header strong, .metric-chart-card > header span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-chart-card > header strong { font-size: 12px; font-weight: 590; } +.metric-chart-card > header span { margin-top: 2px; color: var(--faint); font-size: 9px; } +.chart-close-button { width: 27px; height: 27px; flex: 0 0 27px; border: 0; border-radius: 6px; background: transparent; + color: var(--muted); font-size: 18px; line-height: 1; } +.chart-close-button:hover { background: rgba(102, 227, 196, .09); color: var(--mint); } +.comparison-chart-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.comparison-chart-pane { min-width: 0; } +.comparison-chart-pane + .comparison-chart-pane { border-left: 1px solid var(--line); } +.comparison-pane-heading { height: 26px; display: flex; align-items: center; justify-content: space-between; padding: 0 9px; + border-bottom: 1px solid rgba(49, 78, 70, .55); background: #091512; } +.comparison-pane-heading span { font-size: 9px; font-weight: 700; letter-spacing: .1em; } +.comparison-pane-heading small { color: var(--faint); font-size: 9px; } +.left-chart-pane .comparison-pane-heading span { color: var(--mint); } +.right-chart-pane .comparison-pane-heading span { color: var(--amber); } +.comparison-chart-pane .chart-shell { height: 146px; } +.resource-limit-row { min-height: 37px; display: flex; align-items: center; gap: 7px; padding: 5px 8px; + border-bottom: 1px solid rgba(49, 78, 70, .45); background: rgba(7, 17, 15, .58); } +.resource-limit-row > span { flex: 0 0 auto; color: var(--faint); font-size: 8px; font-weight: 700; + letter-spacing: .08em; } +.resource-limit-values { min-width: 0; display: flex; align-items: center; gap: 4px; overflow-x: auto; + scrollbar-width: thin; } +.resource-limit-values small { flex: 0 0 auto; padding: 3px 5px; border: 1px solid rgba(255, 180, 93, .25); + border-radius: 5px; color: #d9c3a2; background: rgba(83, 53, 21, .18); font-size: 8px; white-space: nowrap; } +.resource-limit-values b { color: var(--amber); font-weight: 650; } +.resource-limit-unavailable { min-width: 0; overflow: hidden; color: var(--faint); font-size: 8px; + text-overflow: ellipsis; white-space: nowrap; } +.resource-chart-block { min-width: 0; } +.resource-chart-heading { height: 20px; display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 0 8px; border-bottom: 1px solid rgba(49, 78, 70, .34); color: var(--muted); background: #0a1714; } +.resource-chart-heading span { color: #bccbc7; font-size: 8px; font-weight: 650; } +.resource-chart-heading small { overflow: hidden; color: var(--faint); font-size: 8px; text-overflow: ellipsis; + white-space: nowrap; } +.resource-total-block { border-top: 1px solid rgba(102, 227, 196, .12); } +.comparison-chart-pane .chart-shell.resource-usage-chart { height: 106px; } +.comparison-chart-pane .chart-shell.resource-total-chart { height: 62px; } +.resource-total-chart .chart-empty { padding: 4px 8px; font-size: 8px; text-align: center; } +.chart-error { height: 146px; display: grid; place-items: center; padding: 20px; color: var(--danger); text-align: center; + font-size: 11px; overflow-wrap: anywhere; } +.comparison-chart-error { height: 172px; } +.dashboard-empty { grid-column: 1 / -1; min-height: 230px; display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 9px; color: var(--muted); } +.dashboard-empty strong { color: var(--text); font-size: 15px; } +.dashboard-empty span { font-size: 12px; } +.dashboard-empty button { margin-top: 7px; } + +.chart-shell { height: 370px; position: relative; } +.chart-canvas { width: 100%; height: 100%; } +.chart-canvas.hidden { visibility: hidden; } +.chart-empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 13px; } + +.runs-panel { margin-bottom: 30px; } +.runs-heading { min-height: 74px; } +.pagination { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; } +.pagination button, .icon-button { border: 1px solid var(--line); background: #091512; color: var(--text); border-radius: 7px; } +.pagination button { width: 34px; height: 31px; } +.pagination button:disabled { opacity: .35; cursor: default; } +.table-scroll { overflow: auto; } +.runs-table { border-collapse: collapse; width: 100%; min-width: 1040px; } +.runs-table th { padding: 10px 13px; color: var(--faint); background: #091512; text-align: left; font-size: 10px; + text-transform: uppercase; letter-spacing: .08em; white-space: nowrap; } +.runs-table td { padding: 13px; border-top: 1px solid rgba(49, 78, 70, .55); color: #cddbd7; font-size: 12px; } +.runs-table tbody tr { transition: background .15s ease; cursor: pointer; } +.runs-table tbody tr:hover, .runs-table tbody tr.active { background: rgba(72, 135, 118, .12); } +.runs-table strong, .runs-table small { display: block; max-width: 290px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.runs-table strong { color: var(--text); font-weight: 560; } +.runs-table small { margin-top: 4px; color: var(--faint); } +.comparison-column { width: 58px; text-align: center !important; } +.left-comparison-column { border-right: 1px solid rgba(102, 227, 196, .12); } +.right-comparison-column { border-right: 1px solid rgba(255, 180, 93, .12); } +.check-wrap { display: inline-block; width: 17px; height: 17px; position: relative; } +.check-wrap input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.check-wrap span { display: block; width: 17px; height: 17px; border: 1px solid var(--line-bright); border-radius: 4px; background: #081310; } +.left-check input:checked + span { background: var(--mint); border-color: var(--mint); box-shadow: inset 0 0 0 4px #0b1b17; } +.right-check input:checked + span { background: var(--amber); border-color: var(--amber); box-shadow: inset 0 0 0 4px #21160b; } +.runs-table tbody tr.left-assigned { box-shadow: inset 3px 0 0 rgba(102, 227, 196, .72); } +.runs-table tbody tr.right-assigned { box-shadow: inset 3px 0 0 rgba(255, 180, 93, .72); } +.status-pill { display: inline-block; padding: 3px 8px; border-radius: 99px; color: var(--muted); background: #192723; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.status-pill.completed { color: var(--mint); background: rgba(45, 128, 106, .2); } +.status-pill.failed, .status-pill.cancelled { color: var(--danger); background: rgba(159, 55, 68, .2); } +.status-pill.partial { color: var(--amber); background: rgba(151, 98, 41, .2); } +.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } +.nowrap { white-space: nowrap; } +.run-id { color: var(--faint) !important; } +.empty-row { padding: 35px !important; color: var(--muted) !important; text-align: center !important; } + +.error-banner { margin: 0 0 14px; padding: 12px 15px; border: 1px solid rgba(255, 127, 139, .45); border-radius: 8px; + background: rgba(139, 42, 55, .14); color: #ffabb3; font-size: 12px; } +.drawer-scrim { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .42); backdrop-filter: blur(2px); } +.detail-panel { position: fixed; z-index: 40; inset: 0 0 0 auto; width: min(760px, 94vw); background: #091512; + border-left: 1px solid var(--line-bright); box-shadow: -28px 0 70px rgba(0, 0, 0, .4); overflow: auto; } +.detail-header { position: sticky; top: 0; z-index: 2; min-height: 108px; display: flex; justify-content: space-between; gap: 20px; + padding: 24px 26px; border-bottom: 1px solid var(--line); background: rgba(9, 21, 18, .94); backdrop-filter: blur(15px); } +.detail-header h2 { font-size: 20px; margin: 6px 0; } +.detail-header p { margin: 0; color: var(--faint); font-size: 10px; } +.icon-button { width: 34px; height: 34px; font-size: 22px; flex-shrink: 0; } +.detail-content { padding: 20px 26px 50px; } +.detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; } +.detail-grid article { padding: 13px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); min-width: 0; } +.detail-grid span, .detail-grid strong { display: block; overflow: hidden; text-overflow: ellipsis; } +.detail-grid span { color: var(--faint); font-size: 10px; text-transform: uppercase; } +.detail-grid strong { margin-top: 8px; font-size: 13px; white-space: nowrap; } +.detail-section { margin-top: 22px; padding: 18px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 15px; margin-bottom: 10px; } +.section-heading select, .compact-input { width: min(330px, 54%); } +.detail-section .chart-shell { height: 300px; margin: 0 -8px -8px; } +.metric-list { max-height: 390px; overflow: auto; border: 1px solid var(--line); border-radius: 7px; } +.metric-row { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 9px 11px; border-top: 1px solid var(--line); } +.metric-row:first-child { border-top: 0; } +.metric-row strong, .metric-row small { display: block; } +.metric-row strong { font-size: 11px; font-weight: 560; } +.metric-row small { color: var(--faint); margin-top: 2px; } +.metric-row > span { color: var(--mint); font-size: 11px; text-align: right; } +.artifact-block dl { display: grid; grid-template-columns: 74px 1fr; gap: 8px 12px; font-size: 11px; } +.artifact-block dt { color: var(--faint); } +.artifact-block dd { margin: 0; overflow-wrap: anywhere; } +details { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 10px; } +summary { color: var(--muted); cursor: pointer; font-size: 11px; } +pre { max-height: 330px; overflow: auto; padding: 12px; background: #06100d; border-radius: 6px; color: #b7cac4; font-size: 10px; } +.drawer-enter-active, .drawer-leave-active { transition: transform .24s ease, opacity .24s ease; } +.drawer-enter-from, .drawer-leave-to { transform: translateX(35px); opacity: 0; } + +@media (max-width: 1100px) { + .filter-grid { grid-template-columns: repeat(3, 1fr); } + .summary-grid { grid-template-columns: repeat(2, 1fr); } + .dashboard-workspace { grid-template-columns: 252px minmax(0, 1fr); } + .metric-dashboard-grid { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 960px) { + .dashboard-workspace { grid-template-columns: minmax(0, 1fr); } + .metric-sidebar { position: static; } +} +@media (max-width: 840px) { + .comparison-chart-grid { grid-template-columns: 1fr; } + .comparison-chart-pane + .comparison-chart-pane { border-left: 0; border-top: 1px solid var(--line); } +} +@media (max-width: 720px) { + .topbar { padding: 0 18px; } + .database-state { display: none; } + main { width: min(100% - 28px, 1540px); padding-top: 30px; } + .hero-row { align-items: start; flex-direction: column; } + .hero-actions { width: 100%; } + .hero-actions button { flex: 1; } + .summary-grid { grid-template-columns: 1fr 1fr; } + .summary-grid article { min-height: 105px; padding: 15px; } + .filter-grid { grid-template-columns: 1fr 1fr; } + .search-field { grid-column: span 2; } + .panel-heading { align-items: stretch; flex-direction: column; } + .comparison-group-summary { grid-template-columns: 1fr; padding-left: 14px; padding-right: 14px; } + .detail-grid { grid-template-columns: 1fr 1fr; } + .section-heading { align-items: stretch; flex-direction: column; } + .section-heading select, .compact-input { width: 100%; } +} +@media (max-width: 450px) { + .summary-grid, .filter-grid { grid-template-columns: 1fr; } + .search-field { grid-column: auto; } + .summary-grid article:nth-child(n + 3) { display: none; } + .detail-content, .detail-header { padding-left: 16px; padding-right: 16px; } +} diff --git a/web/history-ui/src/types.ts b/web/history-ui/src/types.ts new file mode 100644 index 0000000000..9d3538377b --- /dev/null +++ b/web/history-ui/src/types.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface HistoryRun { + run_id: string; + benchmark_id: string; + run_kind: string; + started_at: string; + ended_at: string | null; + ingested_at: string; + status: string; + implementation: string; + model: string; + model_family: string; + mode: string; + scene: string; + task: string; + transport: string; + hardware: string; + aiperf_version: string; + aiperf_commit: string; + contract_digest: string; + artifact_path: string; + artifact_digest: string; + metric_count: number; + session_count: number; + tags: Record; + config: Record; + metadata: Record; +} + +export interface RunListResponse { + items: HistoryRun[]; + total: number; + limit: number; + offset: number; +} + +export interface MetricCatalogItem { + metric_name: string; + statistic: string; + scope: string; + unit: string; + point_count: number; +} + +export interface MetricSeriesPoint { + run_id: string; + recorded_at: string; + value: number; + unit: string; + statistic: string; + scope: string; + phase: string; + session_id: string; + sample_index: number; + device: string; + source: string; + state: string; + labels: Record; + artifact_digest: string; + run_kind: string; + status: string; + implementation: string; + model: string; + model_family: string; + mode: string; + scene: string; + task: string; + transport: string; + metric_name: string; +} + +export interface FacetsResponse { + facets: Record; +} + +export interface HistoryStatus { + database: string; + artifact_roots: string[]; + scan_interval_seconds: number; + last_import: { + discovered: number; + imported: number; + skipped: number; + failed: number; + metric_points: number; + } | null; +} + +export interface RunFilters { + search: string; + implementation: string; + model: string; + scene: string; + status: string; + run_kind: string; +} diff --git a/web/history-ui/tsconfig.json b/web/history-ui/tsconfig.json new file mode 100644 index 0000000000..b1da0946c5 --- /dev/null +++ b/web/history-ui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/web/history-ui/vite.config.ts b/web/history-ui/vite.config.ts new file mode 100644 index 0000000000..82218e0792 --- /dev/null +++ b/web/history-ui/vite.config.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, type Plugin } from "vite"; +import vue from "@vitejs/plugin-vue"; + +const licenseBanner = + "/*! SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\n" + + " * SPDX-License-Identifier: Apache-2.0 */\n"; + +const licensePlugin: Plugin = { + name: "aiperf-license-banner", + enforce: "post", + renderChunk(code) { + return { code: licenseBanner + code, map: null }; + }, +}; + +export default defineConfig({ + plugins: [vue(), licensePlugin], + base: "/", + esbuild: { legalComments: "inline" }, + build: { + outDir: "../../src/aiperf/history/static", + emptyOutDir: true, + sourcemap: false, + cssMinify: false, + chunkSizeWarningLimit: 700, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("/node_modules/zrender/") || id.includes("/node_modules/echarts/")) { + return "charts"; + } + if (id.includes("/node_modules/vue/") || id.includes("/node_modules/@vue/")) return "vue"; + return undefined; + }, + }, + }, + }, + server: { + proxy: { + "/api": "http://127.0.0.1:8095", + "/healthz": "http://127.0.0.1:8095", + }, + }, +});