From db2d7cc45db44d1d8a3277dfb2977e34681205cf Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 10 Jun 2026 10:03:24 -0700 Subject: [PATCH 1/3] fix(vehicle): use ON CONFLICT for vehicle upsert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateVehicle previously caught duplicate-key errors with strings.Contains of "Duplicate entry" — MySQL's wording. The repo runs Postgres, which reports "duplicate key value violates unique constraint" (SQLSTATE 23505), so the upsert fallback never fired and every edit surfaced the constraint violation to the user. Replaced the catch-and-retry with clause.OnConflict on the primary key — same pattern already used in vehicle/service/config.go. --- vehicle/service/vehicle.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/vehicle/service/vehicle.go b/vehicle/service/vehicle.go index ac3027dc..f43e33f1 100644 --- a/vehicle/service/vehicle.go +++ b/vehicle/service/vehicle.go @@ -1,11 +1,10 @@ package service import ( - "strings" - "github.com/gaucho-racing/mapache/vehicle/database" mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + "gorm.io/gorm/clause" ) func GetAllVehicles() []mapache.Vehicle { @@ -20,14 +19,16 @@ func GetVehicleByID(id string) mapache.Vehicle { return vehicle } +// CreateVehicle upserts on the primary key. The previous implementation +// caught duplicate-key errors by matching the literal string "Duplicate +// entry" — that's MySQL's wording; Postgres uses "duplicate key value +// violates unique constraint", so the update fallback never fired and +// every edit surfaced SQLSTATE 23505 to the user. func CreateVehicle(vehicle mapache.Vehicle) error { - result := database.DB.Create(&vehicle) - if result.Error != nil { - if strings.Contains(result.Error.Error(), "Duplicate entry") { - result = database.DB.Where("id = ?", vehicle.ID).Updates(&vehicle) - } - } - return result.Error + return database.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"name", "description", "type", "upload_key"}), + }).Create(&vehicle).Error } func DeleteVehicle(vehicleID string) error { From 5af4be590f01507a7754ece2d82238ee004c10d7 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 10 Jun 2026 10:03:47 -0700 Subject: [PATCH 2/3] feat(query): refactor service to ClickHouse with MQL query engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry now lives in ClickHouse (DateTime64-keyed signal table); the query service still owns the Postgres-backed signal_definition / token tables but reads bulk data through clickhouse-connect. Backend layout: - database/clickhouse.py initializes the HTTP client alongside Postgres - service/signals.py wraps the signal table with distinct-name listing and bucketed counts using toStartOfInterval + WITH FILL for zero-filled axes - service/query_lang.py + service/query_exec.py implement MQL v0.2 — a method-chain query language: avg(value).where(name in ("a", "b")).by(name).every(10s) Aggregators: count/sum/avg/min/max/last/p50/p95/p99/stddev. Filters collapse same-column equalities to OR; wildcard `*` in values becomes LIKE. Rollup intervals from 1s to 1d. - routes/signals.py: GET /query/signals + /query/signals/counts - routes/query_run.py: POST /query/run for the new MQL endpoint - service/auth_guard.py: shared bearer-token dependency Removed: old query/log/trip/vehicle/model files that were tied to the prior Postgres-only design. Dropped pandas/numpy/pyarrow from deps in favor of ClickHouse-side aggregation; added clickhouse-connect. Wire-format timestamps are always UTC-tagged ISO 8601 (`...Z` suffix) so the browser doesn't parse them as local time. --- docker-compose.yaml | 7 + example.env | 6 + query/pyproject.toml | 6 +- query/query/config/config.py | 13 +- query/query/database/clickhouse.py | 32 ++ query/query/database/connection.py | 59 ++-- query/query/main.py | 43 +-- query/query/model/log.py | 26 -- query/query/model/query.py | 119 ------- query/query/routes/query.py | 192 ----------- query/query/routes/query_run.py | 121 +++++++ query/query/routes/signal_definition.py | 4 +- query/query/routes/signals.py | 111 +++++++ query/query/routes/token.py | 4 +- query/query/service/auth_guard.py | 25 ++ query/query/service/log.py | 24 -- query/query/service/query.py | 125 ------- query/query/service/query_exec.py | 224 +++++++++++++ query/query/service/query_lang.py | 412 ++++++++++++++++++++++++ query/query/service/signals.py | 141 ++++++++ query/query/service/trip.py | 26 -- query/query/service/vehicle.py | 26 -- query/uv.lock | 319 ++++++++---------- 23 files changed, 1273 insertions(+), 792 deletions(-) create mode 100644 query/query/database/clickhouse.py delete mode 100644 query/query/model/log.py delete mode 100644 query/query/model/query.py delete mode 100644 query/query/routes/query.py create mode 100644 query/query/routes/query_run.py create mode 100644 query/query/routes/signals.py create mode 100644 query/query/service/auth_guard.py delete mode 100644 query/query/service/log.py delete mode 100644 query/query/service/query.py create mode 100644 query/query/service/query_exec.py create mode 100644 query/query/service/query_lang.py create mode 100644 query/query/service/signals.py delete mode 100644 query/query/service/trip.py delete mode 100644 query/query/service/vehicle.py diff --git a/docker-compose.yaml b/docker-compose.yaml index 1388fe8e..a12c41e5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -147,6 +147,8 @@ services: context: . dockerfile: query/Dockerfile.dev restart: unless-stopped + depends_on: + - clickhouse ports: - "7010:7010" volumes: @@ -159,6 +161,11 @@ services: DATABASE_NAME: ${DATABASE_NAME} DATABASE_USER: ${DATABASE_USER} DATABASE_PASSWORD: ${DATABASE_PASSWORD} + CLICKHOUSE_HOST: ${CLICKHOUSE_HOST} + CLICKHOUSE_PORT: ${CLICKHOUSE_PORT} + CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE} KERBECS_ENDPOINT: "http://kerbecs:10300" KERBECS_USER: "admin" KERBECS_PASSWORD: "admin" diff --git a/example.env b/example.env index ea7dbfb2..3f70f700 100644 --- a/example.env +++ b/example.env @@ -8,6 +8,12 @@ DATABASE_NAME="mapache" DATABASE_USER="postgres" DATABASE_PASSWORD="password" +CLICKHOUSE_HOST="clickhouse" +CLICKHOUSE_PORT="9000" +CLICKHOUSE_USER="default" +CLICKHOUSE_PASSWORD="" +CLICKHOUSE_DATABASE="mapache" + SENTINEL_URL="https://sentinel-api.gauchoracing.com" SENTINEL_JWKS_URL="https://sso.gauchoracing.com/.well-known/jwks.json" SENTINEL_CLIENT_ID="z6V9NREjMFhf" diff --git a/query/pyproject.toml b/query/pyproject.toml index 8ad414f1..0fce25de 100644 --- a/query/pyproject.toml +++ b/query/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "query" -version = "3.6.1" +version = "4.0.0" description = "" authors = [ {name = "Bharat Kathi",email = "bkathi@ucsb.edu"}, @@ -15,10 +15,8 @@ dependencies = [ "dotenv>=0.9.9,<0.10.0", "sqlalchemy>=2.0.38,<3.0.0", "psycopg2-binary>=2.9.10,<3.0.0", - "numpy>=2.2.3,<3.0.0", - "pandas>=2.2.3,<3.0.0", + "clickhouse-connect>=0.8.0,<0.9.0", "loguru>=0.7.3,<0.8.0", - "pyarrow>=20.0.0,<21.0.0", "requests>=2.32.3,<3.0.0", "pyjwt[crypto]>=2.11.0,<3.0.0", "mapache-py>=3.0.1,<4.0.0", diff --git a/query/query/config/config.py b/query/query/config/config.py index 90ab3f61..611c1a06 100644 --- a/query/query/config/config.py +++ b/query/query/config/config.py @@ -6,16 +6,24 @@ class Config: """Configuration settings for the application""" # Server settings - VERSION: str = "3.3.0" + VERSION: str = "4.0.0" PORT: int = int(os.getenv('PORT', 7000)) - # Database settings + # Postgres (operational tables: signal_definition, token, log, etc.) DATABASE_HOST: str = os.getenv('DATABASE_HOST') DATABASE_PORT: int = int(os.getenv('DATABASE_PORT')) DATABASE_USER: str = os.getenv('DATABASE_USER') DATABASE_PASSWORD: str = os.getenv('DATABASE_PASSWORD') DATABASE_NAME: str = os.getenv('DATABASE_NAME') + # ClickHouse (data tables: signal, gr26_can, ping, etc.). HTTP port (8123) + # is what clickhouse-connect uses — distinct from gr26's native 9000. + CLICKHOUSE_HOST: str = os.getenv('CLICKHOUSE_HOST') + CLICKHOUSE_PORT: int = int(os.getenv('CLICKHOUSE_PORT', 8123)) + CLICKHOUSE_USER: str = os.getenv('CLICKHOUSE_USER', 'default') + CLICKHOUSE_PASSWORD: str = os.getenv('CLICKHOUSE_PASSWORD', '') + CLICKHOUSE_DATABASE: str = os.getenv('CLICKHOUSE_DATABASE', 'mapache') + # Kerbecs admin endpoint — used to resolve service-to-service routes. KERBECS_ENDPOINT: str = os.getenv('KERBECS_ENDPOINT') KERBECS_USER: str = os.getenv('KERBECS_USER') @@ -41,4 +49,3 @@ def get_database_url() -> URL: port=Config.DATABASE_PORT, database=Config.DATABASE_NAME, ) - diff --git a/query/query/database/clickhouse.py b/query/query/database/clickhouse.py new file mode 100644 index 00000000..65c3236a --- /dev/null +++ b/query/query/database/clickhouse.py @@ -0,0 +1,32 @@ +import clickhouse_connect +from clickhouse_connect.driver.client import Client +from loguru import logger + +from query.config.config import Config + +_client: Client | None = None + + +def init_clickhouse() -> None: + """Initialize the shared ClickHouse HTTP client.""" + global _client + if not Config.CLICKHOUSE_HOST: + raise ValueError("CLICKHOUSE_HOST is not set") + + _client = clickhouse_connect.get_client( + host=Config.CLICKHOUSE_HOST, + port=Config.CLICKHOUSE_PORT, + username=Config.CLICKHOUSE_USER, + password=Config.CLICKHOUSE_PASSWORD, + database=Config.CLICKHOUSE_DATABASE, + ) + logger.info( + f"ClickHouse connected: {Config.CLICKHOUSE_HOST}:{Config.CLICKHOUSE_PORT}/" + f"{Config.CLICKHOUSE_DATABASE} (server v{_client.server_version})" + ) + + +def get_clickhouse() -> Client: + if _client is None: + raise RuntimeError("ClickHouse client is not initialized") + return _client diff --git a/query/query/database/connection.py b/query/query/database/connection.py index e10c4232..6d37aff7 100644 --- a/query/query/database/connection.py +++ b/query/query/database/connection.py @@ -1,46 +1,29 @@ -from query.model.base import Base +from contextlib import contextmanager + from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker + from query.config.config import Config -from contextlib import contextmanager +from query.model.base import Base DATABASE_URL = Config.get_database_url() db_session = None + def init_db(): - """Initialize the database session""" + """Initialize the Postgres session for operational tables.""" if not Config.DATABASE_HOST: raise ValueError("DATABASE_HOST is not set") - elif not Config.DATABASE_PORT: + if not Config.DATABASE_PORT: raise ValueError("DATABASE_PORT is not set") - elif not Config.DATABASE_USER: + if not Config.DATABASE_USER: raise ValueError("DATABASE_USER is not set") - elif not Config.DATABASE_PASSWORD: + if not Config.DATABASE_PASSWORD: raise ValueError("DATABASE_PASSWORD is not set") - elif not Config.DATABASE_NAME: + if not Config.DATABASE_NAME: raise ValueError("DATABASE_NAME is not set") - else: - global db_session - engine = create_engine(DATABASE_URL) - db_session = scoped_session( - sessionmaker( - autocommit=False, - autoflush=False, - expire_on_commit=False, - bind=engine - ) - ) - - from query.model.log import QueryLog - from query.model.token import QueryToken - from query.model.signal_definition import SignalDefinition - - # Create all tables - Base.metadata.create_all(bind=engine) - print("Database initialized") -def init_test_db(): global db_session engine = create_engine(DATABASE_URL) db_session = scoped_session( @@ -48,26 +31,34 @@ def init_test_db(): autocommit=False, autoflush=False, expire_on_commit=False, - bind=engine + bind=engine, ) ) + # Side-effect import so all SQLAlchemy models register on Base.metadata + # before create_all runs. + from query.model.signal_definition import SignalDefinition # noqa: F401 + from query.model.token import QueryToken # noqa: F401 + + Base.metadata.create_all(bind=engine) + print("Postgres initialized") + + @contextmanager def get_db(): - """Get the database session with proper error handling""" if not db_session: raise ValueError("Database session is not initialized") - + try: yield db_session db_session.commit() - except Exception as e: + except Exception: db_session.rollback() - raise e + raise finally: db_session.remove() + def shutdown_session(exception=None): - """Remove the session at the end of request""" if db_session: - db_session.remove() \ No newline at end of file + db_session.remove() diff --git a/query/query/main.py b/query/query/main.py index 5824fbdd..81aea371 100644 --- a/query/query/main.py +++ b/query/query/main.py @@ -1,18 +1,22 @@ from contextlib import asynccontextmanager +import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from loguru import logger -import uvicorn + from query.config.config import Config +from query.database.clickhouse import init_clickhouse from query.database.connection import init_db -from query.routes import ping, query, signal_definition, token +from query.routes import ping, query_run, signal_definition, signals, token from query.service.auth import AuthService from query.service.kerbecs import init as init_kerbecs + @asynccontextmanager async def lifespan(app: FastAPI): init_db() + init_clickhouse() init_kerbecs(Config.KERBECS_ENDPOINT, Config.KERBECS_USER, Config.KERBECS_PASSWORD) if Config.SKIP_AUTH_CHECK: logger.warning("SKIP_AUTH_CHECK is enabled, skipping Sentinel initialization") @@ -20,10 +24,11 @@ async def lifespan(app: FastAPI): AuthService.configure( jwks_url=Config.SENTINEL_JWKS_URL, issuer="https://sso.gauchoracing.com", - audience=Config.SENTINEL_CLIENT_ID + audience=Config.SENTINEL_CLIENT_ID, ) yield + def create_app(): app = FastAPI( title="Gaucho Racing Query", @@ -31,7 +36,7 @@ def create_app(): version=Config.VERSION, docs_url="/query/docs", redoc_url="/query/redoc", - lifespan=lifespan + lifespan=lifespan, ) app.add_middleware( @@ -42,35 +47,19 @@ def create_app(): allow_headers=["*"], ) - app.include_router( - ping.router, - prefix="/query", - tags=["Ping"] - ) - - app.include_router( - query.router, - prefix="/query", - tags=["Query"] - ) - - app.include_router( - signal_definition.router, - prefix="/query", - tags=["Signal Definition"] - ) - - app.include_router( - token.router, - prefix="/query", - tags=["Token"] - ) + app.include_router(ping.router, prefix="/query", tags=["Ping"]) + app.include_router(query_run.router, prefix="/query", tags=["Query"]) + app.include_router(signals.router, prefix="/query", tags=["Signals"]) + app.include_router(signal_definition.router, prefix="/query", tags=["Signal Definition"]) + app.include_router(token.router, prefix="/query", tags=["Token"]) return app + def main(): app = create_app() uvicorn.run(app, host="0.0.0.0", port=Config.PORT) + if __name__ == "__main__": main() diff --git a/query/query/model/log.py b/query/query/model/log.py deleted file mode 100644 index 4c6d3428..00000000 --- a/query/query/model/log.py +++ /dev/null @@ -1,26 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Text -from datetime import datetime -from datetime import timezone -from query.model.base import Base - -class QueryLog(Base): - __tablename__ = "query_log" - - id = Column(String(255), primary_key=True) - user_id = Column(String(255)) - parameters = Column(Text) - status_code = Column(Integer) - latency = Column(Integer) - error_message = Column(Text) - timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - - def to_dict(self): - return { - "id": self.id, - "user_id": self.user_id, - "parameters": self.parameters, - "status_code": self.status_code, - "latency": self.latency, - "error_message": self.error_message, - "timestamp": self.timestamp.isoformat() + "Z" if self.timestamp else None - } diff --git a/query/query/model/query.py b/query/query/model/query.py deleted file mode 100644 index 76dd39ba..00000000 --- a/query/query/model/query.py +++ /dev/null @@ -1,119 +0,0 @@ -from pydantic import BaseModel -from typing import List, Optional -from datetime import datetime -import math - -class Metadata(BaseModel): - model_config = { - "arbitrary_types_allowed": True - } - - num_rows: Optional[int] = None - query_latency: Optional[int] = None - merge_strategy: Optional[str] = None - merge_tolerance: Optional[int] = None - num_signals: Optional[int] = None - signal_names: Optional[List[str]] = None - - # Time-related metadata - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - total_duration: Optional[float] = None - - # Data quality metrics - max_gap_duration: Optional[float] = None - min_gap_duration: Optional[float] = None - avg_gap_duration: Optional[float] = None - max_nan_count: Optional[int] = None - min_nan_count: Optional[int] = None - avg_nan_count: Optional[float] = None - max_row_delta: Optional[int] = None - min_row_delta: Optional[int] = None - avg_row_delta: Optional[float] = None - - def __str__(self) -> str: - """Returns a nicely formatted string representation of the metadata.""" - def format_value(value, is_float=False): - if value is None: - return "N/A" - if is_float: - return f"{value:.2f}" - return str(value) - - sections = [ - ("Basic Info", [ - f"Number of rows: {format_value(self.num_rows)}", - f"Query latency: {format_value(self.query_latency)}ms", - f"Merge strategy: {format_value(self.merge_strategy)}", - f"Merge tolerance: {format_value(self.merge_tolerance)}ms", - f"Number of signals: {format_value(self.num_signals)}", - "Signal names: " + (", ".join(self.signal_names) if self.signal_names else "N/A") - ]), - ("Time Range", [ - f"Start time: {format_value(self.start_time)}", - f"End time: {format_value(self.end_time)}", - f"Total duration: {format_value(self.total_duration, True)}ms" - ]), - ("Gap Statistics (ms)", [ - f"Max gap: {format_value(self.max_gap_duration, True)}", - f"Min gap: {format_value(self.min_gap_duration, True)}", - f"Avg gap: {format_value(self.avg_gap_duration, True)}" - ]), - ("NaN Statistics", [ - f"Max NaNs: {format_value(self.max_nan_count)}", - f"Min NaNs: {format_value(self.min_nan_count)}", - f"Avg NaNs: {format_value(self.avg_nan_count, True)}" - ]), - ("Row Delta Statistics", [ - f"Max delta: {format_value(self.max_row_delta)}", - f"Min delta: {format_value(self.min_row_delta)}", - f"Avg delta: {format_value(self.avg_row_delta, True)}" - ]), - ] - - sections = [ - (name, items) for name, items in sections - if any("N/A" not in item for item in items) - ] - - output = [] - for section_name, items in sections: - output.append(f"\n{section_name}:") - output.extend(f" {item}" for item in items) - - return "\n".join(output) if output else "No metadata available" - - def to_dict(self): - def safe_int_convert(value): - if value is None or math.isnan(value): - return None - return int(value) - - return { - "num_rows": safe_int_convert(self.num_rows), - "query_latency": safe_int_convert(self.query_latency), - "merge_strategy": self.merge_strategy, - "merge_tolerance": safe_int_convert(self.merge_tolerance), - "num_signals": safe_int_convert(self.num_signals), - "signal_names": self.signal_names, - "start_time": self.start_time.isoformat() + "Z" if self.start_time else None, - "end_time": self.end_time.isoformat() + "Z" if self.end_time else None, - "total_duration": safe_int_convert(self.total_duration), - "max_gap_duration": safe_int_convert(self.max_gap_duration), - "min_gap_duration": safe_int_convert(self.min_gap_duration), - "avg_gap_duration": safe_int_convert(self.avg_gap_duration), - "max_nan_count": safe_int_convert(self.max_nan_count), - "min_nan_count": safe_int_convert(self.min_nan_count), - "avg_nan_count": safe_int_convert(self.avg_nan_count), - "max_row_delta": safe_int_convert(self.max_row_delta), - "min_row_delta": safe_int_convert(self.min_row_delta), - "avg_row_delta": safe_int_convert(self.avg_row_delta) - } - -class QueryWarning(): - def __init__(self): - self.warnings = [] - def add_warning(self, warning: str): - self.warnings.append(str(warning)) - def get_warnings(self): - return self.warnings \ No newline at end of file diff --git a/query/query/routes/query.py b/query/query/routes/query.py deleted file mode 100644 index 9a7ce66e..00000000 --- a/query/query/routes/query.py +++ /dev/null @@ -1,192 +0,0 @@ -from datetime import datetime -from fastapi import APIRouter, Query, Response, Header -from typing import Annotated -from loguru import logger -from fastapi.responses import JSONResponse -import numpy as np -import traceback - -from query.config.config import Config -from query.model.log import QueryLog -from query.service.auth import AuthService -from query.service.log import create_log -from query.service.query import query_signals, merge_signals -from query.service.token import get_token_by_id, validate_token -from query.service.trip import get_trip_by_id -import pandas as pd - -router = APIRouter() - -@router.get("/signals") -async def get_signals( - authorization: str = Header(None), - token: Annotated[str | None, Query()] = None, - vehicle_id: Annotated[str | None, Query()] = None, - signals: Annotated[str | None, Query()] = None, - trip_id: Annotated[str | None, Query()] = None, - start: Annotated[str | None, Query()] = None, - end: Annotated[str | None, Query()] = None, - merge: Annotated[str | None, Query(enum=['smallest', 'largest'])] = 'smallest', - fill: Annotated[str | None, Query(enum=['none', 'forward', 'backward', 'linear', 'time'])] = 'none', - tolerance: Annotated[int | None, Query()] = 50, - export: Annotated[str | None, Query(enum=['csv', 'json', 'parquet'])] = 'json' -): - user_id = None - try: - if Config.SKIP_AUTH_CHECK: - user_id = "mock-user" - elif authorization and "Bearer " in authorization: - logger.info(f"Found bearer token: {authorization}") - auth_token = authorization.split("Bearer ")[1] - user_id = AuthService.get_user_id_from_token(auth_token) - elif token: - logger.info(f"Found query token: {token}") - t = get_token_by_id(token) - if not validate_token(t): - return JSONResponse( - status_code=401, - content={ - "message": "invalid query token provided", - } - ) - user_id = t.user_id - else: - return JSONResponse( - status_code=401, - content={ - "message": "you are not authorized to access this resource", - } - ) - - logger.info(f"Successfully authenticated user: {user_id}") - - if vehicle_id is None: - return JSONResponse( - status_code=400, - content={ - "message": "vehicle_id is required", - } - ) - - if signals is None or len(signals.split(",")) == 0 or any(not s.strip() for s in signals.split(",")): - return JSONResponse( - status_code=400, - content={ - "message": "one or more signals are required", - } - ) - - if trip_id is not None: - try: - trip = get_trip_by_id(trip_id) - if trip.get("id"): - start = trip.get("start_time").rstrip("Z") - end = trip.get("end_time").rstrip("Z") - else: - return JSONResponse( - status_code=400, - content=trip - ) - except Exception as e: - return JSONResponse( - status_code=400, - content={ - "message": f"failed to get trip: {e}", - } - ) - else: - if start is not None: - try: - pd.to_datetime(start) - except ValueError: - return JSONResponse( - status_code=400, - content={ - "message": "invalid start timestamp format", - } - ) - if end is not None: - try: - pd.to_datetime(end) - except ValueError: - return JSONResponse( - status_code=400, - content={ - "message": "invalid end timestamp format", - } - ) - - start_time = datetime.now() - dfs = query_signals(vehicle_id=vehicle_id, signals=signals.split(","), start=start, end=end) - - merged_df, metadata = merge_signals(*dfs, strategy=merge, tolerance=tolerance, fill=fill) - - df_dict = merged_df.copy() - df_dict['produced_at'] = df_dict['produced_at'].dt.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - - df_dict = df_dict.replace([np.inf, -np.inf], None) - df_dict = df_dict.replace({np.nan: None}) - - metadata.query_latency = int((datetime.now() - start_time).total_seconds() * 1000) - - logger.info(f"Merged DataFrame: {df_dict}") - logger.info(f"Metadata: {metadata}") - - create_log(QueryLog( - user_id=user_id, - parameters=f"vehicle_id={vehicle_id}, signals={signals}, start={start}, end={end}, merge={merge}, fill={fill}, tolerance={tolerance}, export={export}", - latency=metadata.query_latency, - status_code=200, - error_message="", - )) - - if export == 'json': - return JSONResponse( - status_code=200, - content={ - "data": df_dict.to_dict(orient="records"), - "metadata": metadata.to_dict() - } - ) - elif export == 'csv': - csv_data = df_dict.to_csv(index=False) - return Response( - content=csv_data, - media_type="text/csv", - headers={ - "Content-Disposition": "attachment; filename=export.csv" - } - ) - elif export == 'parquet': - parquet_data = df_dict.to_parquet() - return Response( - content=parquet_data, - media_type="application/octet-stream", - headers={ - "Content-Disposition": "attachment; filename=export.parquet" - } - ) - else: - return JSONResponse( - status_code=400, - content={ - "message": "invalid export format", - } - ) - - except Exception as e: - logger.error(traceback.format_exc()) - if user_id is not None: - create_log(QueryLog( - user_id=user_id, - parameters=f"vehicle_id={vehicle_id}, signals={signals}, start={start}, end={end}, merge={merge}, fill={fill}, tolerance={tolerance}, export={export}", - latency=0, - status_code=500, - error_message=str(e), - )) - return JSONResponse( - status_code=500, - content={ - "message": str(e), - } - ) diff --git a/query/query/routes/query_run.py b/query/query/routes/query_run.py new file mode 100644 index 00000000..310177f4 --- /dev/null +++ b/query/query/routes/query_run.py @@ -0,0 +1,121 @@ +"""POST /query/run — execute a query-language string against a vehicle. + +Body: + { + "query": "count(signal) by (name)", + "vehicle_id": "gr26", + "start": "2026-06-03T00:00:00Z", + "end": "2026-06-10T00:00:00Z", + "interval": "2h" + } + +Response: + { + "series": [ { "tags": {...}, "points": [ { "bucket": ..., "value": ... } ] } ], + "metadata": { "query": ..., "start": ..., "end": ..., "interval": ... } + } + +Parse errors come back as 400 with `{message, position}` so the UI can +underline the offending character. Execution errors are 500. +""" + +from __future__ import annotations + +import traceback +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from loguru import logger +from pydantic import BaseModel, Field + +from query.service.auth_guard import require_user +from query.service.query_exec import run_query +from query.service.query_lang import QueryParseError, parse +from query.service.signals import INTERVALS, utc_iso + +router = APIRouter() + + +class QueryRunRequest(BaseModel): + query: str = Field(..., min_length=1) + vehicle_id: str = Field(..., min_length=1) + start: str | None = None + end: str | None = None + interval: str = "1h" + + +def _parse_ts(value: str | None, field: str) -> datetime | None: + if value is None: + return None + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).replace(tzinfo=None) + except ValueError as e: + raise HTTPException( + status_code=400, detail=f"invalid {field} timestamp: {e}" + ) + + +@router.post("/run") +async def post_query_run( + body: QueryRunRequest, + _user_id: str = Depends(require_user), +): + try: + try: + ast = parse(body.query) + except QueryParseError as e: + return JSONResponse( + status_code=400, + content={"message": str(e), "position": e.position}, + ) + + if body.interval not in INTERVALS: + raise HTTPException( + status_code=400, + detail=f"invalid interval; must be one of {list(INTERVALS)}", + ) + + end_dt = _parse_ts(body.end, "end") or datetime.now( + timezone.utc + ).replace(tzinfo=None) + start_dt = _parse_ts(body.start, "start") or ( + end_dt - timedelta(days=7) + ) + if start_dt >= end_dt: + raise HTTPException( + status_code=400, detail="start must be before end" + ) + + result = run_query( + ast, + vehicle_id=body.vehicle_id, + start=start_dt, + end=end_dt, + interval=body.interval, + ) + + return JSONResponse( + status_code=200, + content={ + "series": result["series"], + "metadata": { + "query": body.query, + "start": utc_iso(start_dt), + "end": utc_iso(end_dt), + # The effective interval — what the buckets actually + # used. May differ from body.interval if the query + # carried a `.rollup(...)` override. + "interval": result["interval"], + }, + }, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(traceback.format_exc()) + return JSONResponse(status_code=500, content={"message": str(e)}) diff --git a/query/query/routes/signal_definition.py b/query/query/routes/signal_definition.py index d4baa122..79630b6d 100644 --- a/query/query/routes/signal_definition.py +++ b/query/query/routes/signal_definition.py @@ -1,9 +1,7 @@ -from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Query, Response, Header +from fastapi import APIRouter, Query, Header from typing import Annotated from loguru import logger from fastapi.responses import JSONResponse -import pandas as pd from query.config.config import Config from query.service.auth import AuthService import traceback diff --git a/query/query/routes/signals.py b/query/query/routes/signals.py new file mode 100644 index 00000000..1e8a75b5 --- /dev/null +++ b/query/query/routes/signals.py @@ -0,0 +1,111 @@ +"""Signals endpoints — Datadog-metrics analog for the dashboard. + +GET /query/signals?vehicle_id=...&start=...&end=... + -> [{ name, count, first_seen, last_seen }] + +GET /query/signals/counts?vehicle_id=...&start=...&end=...&interval=...&name=... + -> [{ bucket, count }] (zero-filled across the window) +""" + +import traceback +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +from loguru import logger + +from query.service.auth_guard import require_user +from query.service.signals import ( + INTERVALS, + utc_iso, + list_signal_names, + signal_counts, +) + +router = APIRouter() + + +def _parse_ts(value: str | None, field: str) -> datetime | None: + if value is None: + return None + try: + # Accept both 'Z' and explicit offsets; coerce to UTC for ClickHouse. + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).replace(tzinfo=None) + except ValueError as e: + raise HTTPException( + status_code=400, detail=f"invalid {field} timestamp: {e}" + ) + + +@router.get("/signals") +async def get_signals( + vehicle_id: Annotated[str, Query()], + start: Annotated[str | None, Query()] = None, + end: Annotated[str | None, Query()] = None, + _user_id: str = Depends(require_user), +): + try: + start_dt = _parse_ts(start, "start") + end_dt = _parse_ts(end, "end") + rows = list_signal_names(vehicle_id=vehicle_id, start=start_dt, end=end_dt) + return JSONResponse(status_code=200, content={"data": rows}) + except HTTPException: + raise + except Exception as e: + logger.error(traceback.format_exc()) + return JSONResponse(status_code=500, content={"message": str(e)}) + + +@router.get("/signals/counts") +async def get_signal_counts( + vehicle_id: Annotated[str, Query()], + start: Annotated[str | None, Query()] = None, + end: Annotated[str | None, Query()] = None, + interval: Annotated[str, Query()] = "1h", + name: Annotated[str | None, Query()] = None, + _user_id: str = Depends(require_user), +): + try: + # `datetime.utcnow()` is deprecated and returns a naive datetime; + # use an aware-then-stripped pair so the math is unambiguous. + end_dt = _parse_ts(end, "end") or datetime.now(timezone.utc).replace( + tzinfo=None + ) + start_dt = _parse_ts(start, "start") or (end_dt - timedelta(days=7)) + + if start_dt >= end_dt: + raise HTTPException(status_code=400, detail="start must be before end") + + if interval not in INTERVALS: + raise HTTPException( + status_code=400, + detail=f"invalid interval; must be one of {list(INTERVALS)}", + ) + + rows = signal_counts( + vehicle_id=vehicle_id, + start=start_dt, + end=end_dt, + interval=interval, + name=name, + ) + return JSONResponse( + status_code=200, + content={ + "data": rows, + "metadata": { + "start": utc_iso(start_dt), + "end": utc_iso(end_dt), + "interval": interval, + }, + }, + ) + except HTTPException: + raise + except Exception as e: + logger.error(traceback.format_exc()) + return JSONResponse(status_code=500, content={"message": str(e)}) diff --git a/query/query/routes/token.py b/query/query/routes/token.py index be101dbd..6ece782d 100644 --- a/query/query/routes/token.py +++ b/query/query/routes/token.py @@ -1,9 +1,7 @@ from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Query, Response, Header -from typing import Annotated +from fastapi import APIRouter, Header from loguru import logger from fastapi.responses import JSONResponse -import pandas as pd from query.config.config import Config from query.service.auth import AuthService import traceback diff --git a/query/query/service/auth_guard.py b/query/query/service/auth_guard.py new file mode 100644 index 00000000..648f97ca --- /dev/null +++ b/query/query/service/auth_guard.py @@ -0,0 +1,25 @@ +"""Bearer-token auth helper that respects SKIP_AUTH_CHECK. + +Centralizes the same eight-line dance every route was doing inline, so new +routes don't drift in how they handle the bypass flag. +""" + +from fastapi import Header, HTTPException +from loguru import logger + +from query.config.config import Config +from query.service.auth import AuthService + + +def require_user(authorization: str | None = Header(None)) -> str: + if Config.SKIP_AUTH_CHECK: + return "mock-user" + if not authorization or "Bearer " not in authorization: + raise HTTPException( + status_code=401, + detail="you are not authorized to access this resource", + ) + token = authorization.split("Bearer ")[1] + user_id = AuthService.get_user_id_from_token(token) + logger.info(f"Successfully authenticated user: {user_id}") + return user_id diff --git a/query/query/service/log.py b/query/query/service/log.py deleted file mode 100644 index 7aaf2b78..00000000 --- a/query/query/service/log.py +++ /dev/null @@ -1,24 +0,0 @@ -from datetime import datetime, timezone -import ulid -from query.database.connection import get_db -from query.model.log import QueryLog -from sqlalchemy.orm import Session - -def get_all_logs() -> list[QueryLog]: - with get_db() as db: - return db.query(QueryLog).all() - -def get_logs_by_user_id(user_id: int) -> list[QueryLog]: - with get_db() as db: - return db.query(QueryLog).filter(QueryLog.user_id == user_id).all() - -def get_log_by_id(log_id: int) -> QueryLog: - with get_db() as db: - return db.query(QueryLog).filter(QueryLog.id == log_id).first() - -def create_log(log: QueryLog) -> QueryLog: - log.id = ulid.make().prefixed("qlog") - log.created_at = datetime.now(timezone.utc) - with get_db() as db: - db.add(log) - return log \ No newline at end of file diff --git a/query/query/service/query.py b/query/query/service/query.py deleted file mode 100644 index f406ad49..00000000 --- a/query/query/service/query.py +++ /dev/null @@ -1,125 +0,0 @@ -from loguru import logger -from query.database.connection import get_db -import pandas as pd -from sqlalchemy import bindparam, text -from query.model.query import Metadata - - -def query_signals(vehicle_id: str, signals: list[str], start: str | None = None, end: str | None = None) -> list[pd.DataFrame]: - if not vehicle_id: - raise ValueError("Vehicle ID is required") - - params = {"vehicle_id": vehicle_id, "signals": list(signals)} - query_str = """ - SELECT produced_at, name, value - FROM signal - WHERE name IN :signals AND vehicle_id = :vehicle_id""" - - if start is not None: - query_str += " AND produced_at > :start" - params["start"] = start - if end is not None: - query_str += " AND produced_at < :end" - params["end"] = end - - query_str += " ORDER BY produced_at ASC" - logger.info(f"Query: {query_str} | Params: {params}") - - # `expanding=True` makes SQLAlchemy render the IN list as individual - # placeholders (... IN (:s_1, :s_2)) on every backend, instead of binding - # a single tuple param (which only works by accident on psycopg2). - stmt = text(query_str).bindparams(bindparam("signals", expanding=True)) - - with get_db() as db: - result = pd.read_sql(stmt.bindparams(**params), db.bind) - - result["produced_at"] = pd.to_datetime(result["produced_at"], utc=True) - - pivoted = result.pivot_table(index="produced_at", columns="name", values="value", aggfunc="first") - pivoted = pivoted.reset_index().sort_values("produced_at") - - return [ - pivoted[["produced_at", signal]].dropna(subset=[signal]).reset_index(drop=True) - for signal in signals - if signal in pivoted.columns - ] - - -def _apply_fill(df: pd.DataFrame, fill: str) -> pd.DataFrame: - if fill == "none": - return df - elif fill == "forward": - df = df.ffill() - elif fill == "backward": - df = df.bfill() - elif fill == "linear": - df = df.interpolate(method="linear") - elif fill == "time": - df = df.set_index("produced_at") - df = df.interpolate(method="time") - df = df.reset_index() - else: - raise ValueError(f"Invalid fill value: {fill}") - return df.fillna(0) - - -def merge_signals( - *dfs: pd.DataFrame, - strategy: str = "smallest", - tolerance: int = 50, - fill: str = "none", -) -> tuple[pd.DataFrame, Metadata]: - if not dfs: - raise ValueError("At least one DataFrame must be provided") - - if len(dfs) == 1: - merged_df = dfs[0].copy() - initial_signal_lengths = {merged_df.columns[1]: len(merged_df)} - else: - anchor = min(dfs, key=len) if strategy == "smallest" else max(dfs, key=len) - - initial_signal_lengths = {df.columns[1]: len(df) for df in dfs} - - merged_df = anchor.copy() - td = pd.Timedelta(milliseconds=tolerance) - for i, df in enumerate(dfs): - if df is anchor: - continue - merged_df = pd.merge_asof( - merged_df, df, on="produced_at", direction="nearest", tolerance=td, - ) - - merged_df = merged_df.sort_values("produced_at").reset_index(drop=True) - - signal_length_deltas = { - signal: len(merged_df) - length for signal, length in initial_signal_lengths.items() - } - - metadata = Metadata() - metadata.num_rows = len(merged_df) - metadata.merge_strategy = f"{strategy}_{fill}" - metadata.merge_tolerance = tolerance - metadata.num_signals = len(merged_df.columns) - 1 - metadata.signal_names = merged_df.columns[1:].tolist() - - metadata.start_time = merged_df["produced_at"].min() - metadata.end_time = merged_df["produced_at"].max() - metadata.total_duration = (metadata.end_time - metadata.start_time).total_seconds() * 1000 - - time_diffs = merged_df["produced_at"].diff().dropna() - metadata.max_gap_duration = time_diffs.max().total_seconds() * 1000 - metadata.min_gap_duration = time_diffs.min().total_seconds() * 1000 - metadata.avg_gap_duration = time_diffs.mean().total_seconds() * 1000 - - nan_counts = merged_df.drop("produced_at", axis=1).isna().sum() - metadata.max_nan_count = nan_counts.max() - metadata.min_nan_count = nan_counts.min() - metadata.avg_nan_count = nan_counts.mean() - - merged_df = _apply_fill(merged_df, fill) - - metadata.max_row_delta = max(signal_length_deltas.values()) - metadata.min_row_delta = min(signal_length_deltas.values()) - metadata.avg_row_delta = sum(signal_length_deltas.values()) / len(signal_length_deltas) - - return merged_df, metadata diff --git a/query/query/service/query_exec.py b/query/query/service/query_exec.py new file mode 100644 index 00000000..a11d8e54 --- /dev/null +++ b/query/query/service/query_exec.py @@ -0,0 +1,224 @@ +"""Execute a parsed query against ClickHouse and shape the response. + +The output is always multi-series shaped: + + { + "series": [ + {"tags": {"name": "ecu_acc_pedal"}, "points": [{"bucket": "...Z", "value": 12.4}, ...]}, + ... + ], + "metadata": {"query": ..., "start": ..., "end": ..., "interval": ...} + } + +Single-series queries (no group-by) return one series with empty `tags`. +This keeps the frontend rendering path uniform: bar/line/area all consume +the same shape. + +The bucket axis is always zero-filled across [start, end) so the chart +doesn't need to interpolate. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from query.database.clickhouse import get_clickhouse +from query.service.query_lang import Query +from query.service.signals import INTERVALS, utc_iso + + +def _build_filter_sql( + filters: tuple, params: dict[str, Any] +) -> list[str]: + """Translate a list of Predicates into ClickHouse WHERE fragments. + + Within a column, predicates are OR'd (multi-value any-of). Across + columns they're AND'd by virtue of being separate elements in the + final WHERE list. Values containing `*` are translated to LIKE + patterns by swapping `*` → `%`. + """ + by_col: dict[str, list] = {} + for p in filters: + by_col.setdefault(p.column, []).append(p) + + out: list[str] = [] + for col, preds in by_col.items(): + sub: list[str] = [] + for i, pred in enumerate(preds): + key = f"f_{col}_{i}" + if "*" in pred.value: + # ClickHouse's LIKE uses `%` as the multi-char wildcard; + # `*` is what users naturally type. Single-char `_` and + # literal-`%` escaping aren't useful at the signal-name + # scale today, so we don't translate them. + sub.append(f"{col} LIKE {{{key}:String}}") + params[key] = pred.value.replace("*", "%") + else: + sub.append(f"{col} = {{{key}:String}}") + params[key] = pred.value + out.append(sub[0] if len(sub) == 1 else f"({' OR '.join(sub)})") + return out + + +_FN_SQL: dict[str, str] = { + # field gets substituted in below; count() ignores it entirely. + "count": "count()", + "sum": "sum({field})", + "avg": "avg({field})", + "min": "min({field})", + "max": "max({field})", + "last": "argMax({field}, produced_at)", + "p50": "quantile(0.5)({field})", + "p95": "quantile(0.95)({field})", + "p99": "quantile(0.99)({field})", + "stddev": "stddevPop({field})", +} + + +def run_query( + q: Query, + vehicle_id: str, + start: datetime, + end: datetime, + interval: str, +) -> dict[str, Any]: + # An explicit rollup in the query string wins over the request-level + # `interval` fallback. The parser already validated that it's in + # ALLOWED_ROLLUPS, but we re-check membership in INTERVALS here so + # divergence between the two lists fails loudly rather than silently + # producing wrong buckets. + effective_interval = q.rollup or interval + if effective_interval not in INTERVALS: + raise ValueError( + f"invalid interval '{effective_interval}'; " + f"must be one of {list(INTERVALS)}" + ) + + interval_expr, step_seconds = INTERVALS[effective_interval] + + agg_sql = _FN_SQL[q.fn].format(field=q.field) + + select_parts = [f"toStartOfInterval(produced_at, {interval_expr}) AS bucket"] + for col in q.group_by: + select_parts.append(f"{col} AS series_{col}") + select_parts.append(f"{agg_sql} AS value") + + where = [ + "vehicle_id = {vehicle_id:String}", + "produced_at >= {start:DateTime64(6)}", + "produced_at < {end:DateTime64(6)}", + ] + params: dict[str, Any] = { + "vehicle_id": vehicle_id, + "start": start, + "end": end, + } + # Build the user-supplied WHERE: same-column predicates OR within the + # column (so `name = A and name = B` matches any-of, the only sensible + # reading of "give me both signals"), distinct-column predicates AND + # across columns. Values containing `*` become LIKE patterns. + where.extend(_build_filter_sql(q.filters, params)) + + group_by_cols = ["bucket"] + [f"series_{c}" for c in q.group_by] + + sql = f""" + SELECT {', '.join(select_parts)} + FROM signal + WHERE {' AND '.join(where)} + GROUP BY {', '.join(group_by_cols)} + ORDER BY bucket + """ + + rows = get_clickhouse().query(sql, parameters=params).result_rows + series = _shape_response( + rows=rows, + group_by=q.group_by, + start=start, + end=end, + step_seconds=step_seconds, + ) + return {"series": series, "interval": effective_interval} + + +def _shape_response( + rows: list[tuple], + group_by: tuple[str, ...], + start: datetime, + end: datetime, + step_seconds: int, +) -> list[dict[str, Any]]: + """Pivot ClickHouse rows into the multi-series response shape. + + For grouped queries we zero-fill per-series in Python rather than + relying on ClickHouse's WITH FILL — the latter requires INTERPOLATE + clauses to carry group columns and gets unwieldy fast. Doing it here + is cheap because cardinality is bounded by the number of distinct + series the query produces (typically <100 at our scale). + """ + # Quantize start/end to bucket boundaries so the fill axis lines up + # with whatever ClickHouse returned. + expected_buckets = _bucket_axis(start, end, step_seconds) + + # Group rows by their series tags + by_series: dict[tuple, dict[datetime, float]] = {} + for row in rows: + bucket_ts = row[0] + group_vals = tuple(row[1 : 1 + len(group_by)]) + value = row[-1] + by_series.setdefault(group_vals, {})[bucket_ts] = value + + # If the query had no rows at all, still emit a single empty series so + # the chart has the right axis range to render. + if not by_series: + by_series[tuple([None] * len(group_by))] = {} + + out: list[dict[str, Any]] = [] + for group_vals, points_by_bucket in by_series.items(): + tags = {col: group_vals[i] for i, col in enumerate(group_by)} + points = [ + { + "bucket": utc_iso(b), + "value": _coerce_number(points_by_bucket.get(b, 0)), + } + for b in expected_buckets + ] + out.append({"tags": tags, "points": points}) + + # Sort series by total descending so the largest contributors render + # on top in a stacked chart. + out.sort( + key=lambda s: -sum(p["value"] for p in s["points"] if p["value"] is not None) + ) + return out + + +def _bucket_axis( + start: datetime, end: datetime, step_seconds: int +) -> list[datetime]: + step = timedelta(seconds=step_seconds) + # Round `start` down to the nearest bucket boundary so the first + # bucket in our axis matches what ClickHouse produced. + epoch = datetime(1970, 1, 1, tzinfo=start.tzinfo) + offset = (start - epoch).total_seconds() + aligned_offset = int(offset // step_seconds) * step_seconds + aligned = epoch + timedelta(seconds=aligned_offset) + + buckets: list[datetime] = [] + t = aligned + while t < end: + buckets.append(t) + t = t + step + return buckets + + +def _coerce_number(v: Any) -> float | int | None: + """ClickHouse hands us Decimal / Float / int — collapse to JSON-safe.""" + if v is None: + return None + if isinstance(v, (int, float)): + return v + try: + return float(v) + except (TypeError, ValueError): + return None diff --git a/query/query/service/query_lang.py b/query/query/service/query_lang.py new file mode 100644 index 00000000..590451ec --- /dev/null +++ b/query/query/service/query_lang.py @@ -0,0 +1,412 @@ +"""Tiny query language for the signals chart. + +Grammar (v0): + () [where = "" (and ...)?] [by ((, )*)] + +Examples: + count(signal) + count(signal) by (name) + avg(value) where name = "ecu_acc_pedal" + p95(value) where name = "bcu_12v_voltage" + max(value) where name = "ecu_acc_pedal" by (name) + +This is intentionally small. We want it to feel like Datadog's metric +query syntax (one aggregator + filters + group-by + automatic time +bucketing from the page's timeframe), not a full SQL dialect. The +ClickHouse query is built in `query_exec.py` from the parsed AST. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +# Aggregators we support. The value (column-aware?) tells the validator +# whether the aggregator needs a numeric `value`/`raw_value` field or can +# operate on `signal` (i.e. row-count). +FUNCTIONS: dict[str, bool] = { + # name: requires_numeric_field + "count": False, # count(signal) — counts rows + "sum": True, + "avg": True, + "min": True, + "max": True, + "last": True, # last value in the bucket (argMax on produced_at) + "p50": True, + "p95": True, + "p99": True, + "stddev": True, +} + +NUMERIC_FIELDS = {"value", "raw_value"} +COUNT_FIELD = "signal" +ALL_FIELDS = NUMERIC_FIELDS | {COUNT_FIELD} + +# Columns that may appear in a `where` predicate or `by` group. Kept +# narrow on purpose — `vehicle_id` is page-level, `produced_at` is +# timeframe-level, so neither belongs in the query string. +FILTERABLE_COLUMNS = {"name"} +GROUPABLE_COLUMNS = {"name"} + +# Canonical rollup interval names. Owned here (rather than in signals.py) +# because the grammar references them — the executor's INTERVALS map keys +# off this list to wire each name to its ClickHouse INTERVAL clause. +ROLLUP_INTERVALS: tuple[str, ...] = ( + "1s", "10s", "30s", + "1m", "5m", "15m", "30m", + "1h", "2h", "6h", + "1d", +) +ALLOWED_ROLLUPS = frozenset(ROLLUP_INTERVALS) + + +class QueryParseError(ValueError): + """User-visible parser error. Carries a column offset for the UI.""" + + def __init__(self, message: str, position: int = 0): + super().__init__(message) + self.position = position + + +@dataclass(frozen=True) +class Predicate: + column: str + op: str # always "=" for v0 + value: str + + +@dataclass(frozen=True) +class Query: + fn: str + field: str + filters: tuple[Predicate, ...] = () + group_by: tuple[str, ...] = () + # Optional rollup interval (one of INTERVALS in signals.py). When None, + # the caller falls back to its auto-picked interval — typically a + # function of the requested time window. + rollup: str | None = None + + +# --------------------------------------------------------------------------- +# Tokenizer +# --------------------------------------------------------------------------- + +_TOKEN_RX = re.compile( + r""" + (?P\s+) + | (?P"(?:[^"\\]|\\.)*") + | (?P\d+[smhd]) + | (?P[A-Za-z_][A-Za-z0-9_]*) + | (?P[().=,]) + """, + re.VERBOSE, +) + + +@dataclass +class Token: + kind: str # "ident", "string", "punct" + value: str + pos: int + + +def _tokenize(s: str) -> list[Token]: + out: list[Token] = [] + i = 0 + while i < len(s): + m = _TOKEN_RX.match(s, i) + if not m: + raise QueryParseError(f"unexpected character '{s[i]}'", i) + kind = m.lastgroup + if kind != "ws": + value = m.group(kind) + if kind == "string": + # Strip surrounding quotes; v0 doesn't handle escapes. + value = value[1:-1] + out.append(Token(kind=kind, value=value, pos=i)) + i = m.end() + return out + + +# --------------------------------------------------------------------------- +# Parser (recursive descent over a token cursor) +# --------------------------------------------------------------------------- + +# Method names recognized after the aggregator-call opener. Treated as +# regular identifiers at the lexer level — the parser dispatches on them +# inside the chain loop. Case-insensitive. +_METHODS = {"where", "by", "every"} + +# Old-syntax identifiers we want to flag with a friendly migration error +# instead of a confusing "unknown method". v0.2 cleanup. +_RENAMED_METHODS = {"rollup": "every"} + + +class _Cursor: + def __init__(self, tokens: list[Token]): + self.tokens = tokens + self.i = 0 + + @property + def eof(self) -> bool: + return self.i >= len(self.tokens) + + def peek(self) -> Token | None: + return None if self.eof else self.tokens[self.i] + + def advance(self) -> Token: + t = self.tokens[self.i] + self.i += 1 + return t + + def expect_ident(self) -> Token: + t = self.peek() + if t is None or t.kind != "ident": + raise QueryParseError( + "expected an identifier", t.pos if t else self._tail_pos() + ) + return self.advance() + + def expect_punct(self, p: str) -> Token: + t = self.peek() + if t is None or t.kind != "punct" or t.value != p: + raise QueryParseError( + f"expected '{p}'", t.pos if t else self._tail_pos() + ) + return self.advance() + + def _tail_pos(self) -> int: + return self.tokens[-1].pos + len(self.tokens[-1].value) if self.tokens else 0 + + +def parse(s: str) -> Query: + """Parse an MQL string into a validated AST. + + Grammar (v0.2): + () ( '.' '(' ')' )* + + Methods are `.where()`, `.by([, ...])`, `.every()`. + Method order doesn't affect semantics — the parser collects them into + a flat AST that the executor applies canonically. + + Raises QueryParseError with a column position so the UI can underline + the offending character. + """ + s = s.strip() + if not s: + raise QueryParseError("query is empty", 0) + + tokens = _tokenize(s) + if not tokens: + raise QueryParseError("query is empty", 0) + + c = _Cursor(tokens) + + fn, field_name = _parse_aggregator_call(c) + + filters: list[Predicate] = [] + group_by: list[str] = [] + rollup: str | None = None + every_seen_pos: int | None = None + + # Method-call chain: zero or more `.method(args)` calls. Order is + # accepted in any sequence — the semantics are the same. + while not c.eof: + nxt = c.peek() + assert nxt is not None + # If anything other than `.` appears here it's a leftover token + # from the old infix grammar or a typo — surface a clean error. + if nxt.kind != "punct" or nxt.value != ".": + raise QueryParseError( + f"unexpected '{nxt.value}' — methods are chained with '.'", + nxt.pos, + ) + c.advance() # consume '.' + + method_tok = c.expect_ident() + method = method_tok.value.lower() + + if method in _RENAMED_METHODS: + raise QueryParseError( + f"'.{method}' was renamed to '.{_RENAMED_METHODS[method]}'", + method_tok.pos, + ) + if method not in _METHODS: + raise QueryParseError( + f"unknown method '.{method_tok.value}'; expected one of " + + ", ".join(f".{m}" for m in sorted(_METHODS)), + method_tok.pos, + ) + + c.expect_punct("(") + if method == "where": + filters.extend(_parse_where_args(c)) + elif method == "by": + group_by.extend(_parse_by_args(c)) + elif method == "every": + if every_seen_pos is not None: + raise QueryParseError( + "'.every' specified more than once", + method_tok.pos, + ) + rollup = _parse_every_args(c) + every_seen_pos = method_tok.pos + c.expect_punct(")") + + return Query( + fn=fn, + field=field_name, + filters=tuple(filters), + group_by=tuple(group_by), + rollup=rollup, + ) + + +def _parse_aggregator_call(c: _Cursor) -> tuple[str, str]: + """Consume `()` from the head of the token stream.""" + fn_tok = c.expect_ident() + fn = fn_tok.value.lower() + if fn not in FUNCTIONS: + raise QueryParseError( + f"unknown function '{fn_tok.value}'; expected one of " + + ", ".join(sorted(FUNCTIONS)), + fn_tok.pos, + ) + + c.expect_punct("(") + field_tok = c.expect_ident() + field_name = field_tok.value.lower() + c.expect_punct(")") + + if field_name not in ALL_FIELDS: + raise QueryParseError( + f"unknown field '{field_tok.value}'; expected one of " + + ", ".join(sorted(ALL_FIELDS)), + field_tok.pos, + ) + + needs_numeric = FUNCTIONS[fn] + if needs_numeric and field_name not in NUMERIC_FIELDS: + raise QueryParseError( + f"function '{fn}' needs a numeric field " + f"({', '.join(sorted(NUMERIC_FIELDS))}), not '{field_name}'", + field_tok.pos, + ) + if not needs_numeric and field_name != COUNT_FIELD: + raise QueryParseError( + f"function '{fn}' operates on rows; use '{COUNT_FIELD}' instead " + f"of '{field_name}'", + field_tok.pos, + ) + + return fn, field_name + + +def _parse_where_args(c: _Cursor) -> list[Predicate]: + """Parse the args inside one `.where(...)` call. + + Single predicate: ` = `. Multi-value: ` in + (, , ...)` — desugared to a list of equality + predicates that the executor unions (OR within a column). + """ + col_tok = c.expect_ident() + col = col_tok.value.lower() + if col not in FILTERABLE_COLUMNS: + raise QueryParseError( + f"can't filter on '{col_tok.value}'; filterable columns: " + + ", ".join(sorted(FILTERABLE_COLUMNS)), + col_tok.pos, + ) + + op_tok = c.peek() + if op_tok and op_tok.kind == "ident" and op_tok.value.lower() == "in": + c.advance() + c.expect_punct("(") + values = _parse_string_list(c) + c.expect_punct(")") + if not values: + raise QueryParseError( + "'in' requires at least one value", + op_tok.pos, + ) + return [Predicate(column=col, op="=", value=v) for v in values] + + if op_tok and op_tok.kind == "punct" and op_tok.value == "=": + c.advance() + val_tok = c.peek() + if val_tok is None or val_tok.kind != "string": + raise QueryParseError( + 'expected a quoted string (e.g. "ecu_acc_pedal")', + val_tok.pos if val_tok else c._tail_pos(), + ) + c.advance() + return [Predicate(column=col, op="=", value=val_tok.value)] + + raise QueryParseError( + "expected '=' or 'in'", + op_tok.pos if op_tok else c._tail_pos(), + ) + + +def _parse_string_list(c: _Cursor) -> list[str]: + """Parse one or more quoted strings separated by commas.""" + out: list[str] = [] + while True: + t = c.peek() + if t is None or t.kind != "string": + raise QueryParseError( + "expected a quoted string", + t.pos if t else c._tail_pos(), + ) + c.advance() + out.append(t.value) + nxt = c.peek() + if nxt and nxt.kind == "punct" and nxt.value == ",": + c.advance() + continue + break + return out + + +def _parse_by_args(c: _Cursor) -> list[str]: + """Parse one or more groupable column names separated by commas.""" + cols: list[str] = [] + while True: + col_tok = c.expect_ident() + col = col_tok.value.lower() + if col not in GROUPABLE_COLUMNS: + raise QueryParseError( + f"can't group by '{col_tok.value}'; groupable columns: " + + ", ".join(sorted(GROUPABLE_COLUMNS)), + col_tok.pos, + ) + cols.append(col) + nxt = c.peek() + if nxt and nxt.kind == "punct" and nxt.value == ",": + c.advance() + continue + break + if not cols: + raise QueryParseError( + "'.by' requires at least one column", c._tail_pos() + ) + return cols + + +def _parse_every_args(c: _Cursor) -> str: + """Parse a single interval literal (e.g. `10s`, `1m`, `1h`).""" + iv_tok = c.peek() + if iv_tok is None or iv_tok.kind != "interval": + raise QueryParseError( + "expected an interval (e.g. 1m, 10s, 1h)", + iv_tok.pos if iv_tok else c._tail_pos(), + ) + c.advance() + if iv_tok.value not in ALLOWED_ROLLUPS: + raise QueryParseError( + f"invalid interval '{iv_tok.value}'; valid: " + + ", ".join(ROLLUP_INTERVALS), + iv_tok.pos, + ) + return iv_tok.value diff --git a/query/query/service/signals.py b/query/query/service/signals.py new file mode 100644 index 00000000..0aee0a2f --- /dev/null +++ b/query/query/service/signals.py @@ -0,0 +1,141 @@ +"""ClickHouse-backed reads against the `signal` table. + +Schema (see mapache-go/signal.go::SignalClickHouseDDL): + id, timestamp (Int64 micros), vehicle_id, name, value, raw_value, + produced_at DateTime64(6, 'UTC'), created_at DateTime64(6, 'UTC') + +The table is a ReplacingMergeTree partitioned by toYYYYMM(produced_at) and +ordered by (vehicle_id, timestamp, name). Filtering by vehicle_id + +produced_at window keeps reads on the primary index. +""" + +from datetime import datetime, timezone +from typing import Any + +from query.database.clickhouse import get_clickhouse + + +def utc_iso(dt: datetime | None) -> str | None: + """Render a datetime as an unambiguously-UTC ISO 8601 string. + + The `signal` table's produced_at / created_at columns are + DateTime64(6, 'UTC'), so anything we read out is already in UTC even + when clickhouse-connect hands us a naive datetime. Without an explicit + 'Z'/'+00:00' suffix on the wire, JavaScript's `new Date(iso)` would + parse the string as the user's local time and silently shift the bucket + labels (the chart would render bars 7h off in PT, etc). + """ + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + +# Bucket widths offered by /signals/counts. The value is the ClickHouse +# INTERVAL spec that's plugged into both toStartOfInterval and toInterval*. +# Picking a sensible default for a given timeframe is the caller's job. +INTERVALS: dict[str, tuple[str, int]] = { + # name: (INTERVAL clause, step seconds). Order is significant — used + # by the frontend to render the rollup dropdown in ascending order. + "1s": ("INTERVAL 1 SECOND", 1), + "10s": ("INTERVAL 10 SECOND", 10), + "30s": ("INTERVAL 30 SECOND", 30), + "1m": ("INTERVAL 1 MINUTE", 60), + "5m": ("INTERVAL 5 MINUTE", 5 * 60), + "15m": ("INTERVAL 15 MINUTE", 15 * 60), + "30m": ("INTERVAL 30 MINUTE", 30 * 60), + "1h": ("INTERVAL 1 HOUR", 60 * 60), + "2h": ("INTERVAL 2 HOUR", 2 * 60 * 60), + "6h": ("INTERVAL 6 HOUR", 6 * 60 * 60), + "1d": ("INTERVAL 1 DAY", 24 * 60 * 60), +} + + +def list_signal_names( + vehicle_id: str, + start: datetime | None = None, + end: datetime | None = None, +) -> list[dict[str, Any]]: + """Distinct signal names seen for a vehicle, with collected counts.""" + where = ["vehicle_id = {vehicle_id:String}"] + params: dict[str, Any] = {"vehicle_id": vehicle_id} + + if start is not None: + where.append("produced_at >= {start:DateTime64(6)}") + params["start"] = start + if end is not None: + where.append("produced_at < {end:DateTime64(6)}") + params["end"] = end + + sql = f""" + SELECT + name, + count() AS count, + min(produced_at) AS first_seen, + max(produced_at) AS last_seen + FROM signal + WHERE {' AND '.join(where)} + GROUP BY name + ORDER BY name + """ + result = get_clickhouse().query(sql, parameters=params) + return [ + { + "name": name, + "count": int(count), + "first_seen": utc_iso(first_seen), + "last_seen": utc_iso(last_seen), + } + for name, count, first_seen, last_seen in result.result_rows + ] + + +def signal_counts( + vehicle_id: str, + start: datetime, + end: datetime, + interval: str, + name: str | None = None, +) -> list[dict[str, Any]]: + """Bucketed signal counts for a vehicle over a time range. + + Buckets without data are emitted as zero via WITH FILL so the frontend + doesn't need to interpolate. `interval` must be a key of INTERVALS. + """ + if interval not in INTERVALS: + raise ValueError( + f"invalid interval '{interval}', must be one of {list(INTERVALS)}" + ) + + interval_expr, step_seconds = INTERVALS[interval] + where = [ + "vehicle_id = {vehicle_id:String}", + "produced_at >= {start:DateTime64(6)}", + "produced_at < {end:DateTime64(6)}", + ] + params: dict[str, Any] = { + "vehicle_id": vehicle_id, + "start": start, + "end": end, + } + if name is not None: + where.append("name = {name:String}") + params["name"] = name + + sql = f""" + SELECT + toStartOfInterval(produced_at, {interval_expr}) AS bucket, + count() AS count + FROM signal + WHERE {' AND '.join(where)} + GROUP BY bucket + ORDER BY bucket WITH FILL + FROM toStartOfInterval({{start:DateTime64(6)}}, {interval_expr}) + TO toStartOfInterval({{end:DateTime64(6)}}, {interval_expr}) + STEP toIntervalSecond({step_seconds}) + """ + result = get_clickhouse().query(sql, parameters=params) + return [ + {"bucket": utc_iso(bucket_ts), "count": int(count)} + for bucket_ts, count in result.result_rows + ] diff --git a/query/query/service/trip.py b/query/query/service/trip.py deleted file mode 100644 index 34601439..00000000 --- a/query/query/service/trip.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Any - -from loguru import logger -import requests - -from query.service.kerbecs import resolve - - -def get_all_trips() -> list[dict[str, Any]]: - try: - url = resolve("GET", "/api/sessions") - r = requests.get(url) - return r.json() - except Exception as e: - logger.error(f"Error getting all trips: {e}") - raise e - - -def get_trip_by_id(trip_id: str) -> dict[str, Any]: - try: - url = resolve("GET", f"/api/sessions/{trip_id}") - r = requests.get(url) - return r.json() - except Exception as e: - logger.error(f"Error getting trip by id: {e}") - raise e diff --git a/query/query/service/vehicle.py b/query/query/service/vehicle.py deleted file mode 100644 index cfe3e325..00000000 --- a/query/query/service/vehicle.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Any - -from loguru import logger -import requests - -from query.service.kerbecs import resolve - - -def get_all_vehicles() -> list[dict[str, Any]]: - try: - url = resolve("GET", "/api/vehicles") - r = requests.get(url) - return r.json() - except Exception as e: - logger.error(f"Error getting all vehicles: {e}") - raise e - - -def get_vehicle_by_id(vehicle_id: str) -> dict[str, Any]: - try: - url = resolve("GET", f"/api/vehicles/{vehicle_id}") - r = requests.get(url) - return r.json() - except Exception as e: - logger.error(f"Error getting vehicle by id: {e}") - raise e diff --git a/query/uv.lock b/query/uv.lock index c424f3b5..482e842c 100644 --- a/query/uv.lock +++ b/query/uv.lock @@ -159,6 +159,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "clickhouse-connect" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "lz4" }, + { name = "pytz" }, + { name = "urllib3" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/3d/a06d938d1efb94fdf8a343bbe1dc4ad2458aef08b9c69e0080d695ab24c1/clickhouse_connect-0.8.18.tar.gz", hash = "sha256:206a33decf2d9ed689d3156ef906dc06f1db7eabfe512e3552e08e9e86b4c73a", size = 91383, upload-time = "2025-06-24T19:08:08.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/4d/58fd5b141bb5b2e8a315bcaebea5d182a7b775c92767c556a85f2c6cd31a/clickhouse_connect-0.8.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21570aa28c0a9753a8172f88fbe492dcc903f5162798725a04920e319b4771bb", size = 262237, upload-time = "2025-06-24T19:06:53.223Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/bfa24aa9c0c972883fc5d86331b76d1acaf15f10c1e3a4f46163c61dc96c/clickhouse_connect-0.8.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9494b4d2c74f94ad05ca17ebe91960396af7f45922ba908931eace77b53acca", size = 253924, upload-time = "2025-06-24T19:06:54.926Z" }, + { url = "https://files.pythonhosted.org/packages/39/3a/854622c3114a6b4a1d4239ee710e9e820ed7f76b901ec42c2791cb17940e/clickhouse_connect-0.8.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d438a6d6461f5bdb37344b668049a78e1a0f3353987a1e649001d075196fd688", size = 1058189, upload-time = "2025-06-24T19:06:56.786Z" }, + { url = "https://files.pythonhosted.org/packages/59/d5/3fa03d352103c40e4f9e3dd3dabfcacc14d5d94d4d06fb6fc5daa4a4bf7d/clickhouse_connect-0.8.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:777dfbea92984e1c834a75499f459823edb9e7afde9ed62281455edb5d7be577", size = 1076550, upload-time = "2025-06-24T19:06:58.457Z" }, + { url = "https://files.pythonhosted.org/packages/68/cd/e121f79b2e96d36262df57a5e6ce1efd8d200180331690eb6013d2d0da74/clickhouse_connect-0.8.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2eb88acedc3802fb39b2b3eb48b76ec7c7cc895c189b3ac031f880ee12e82bd9", size = 1031815, upload-time = "2025-06-24T19:07:00.028Z" }, + { url = "https://files.pythonhosted.org/packages/04/07/ff6a823d7f9062cc6c932e07c85b39f2fe8067b82d6079e5b63e84eb1eb9/clickhouse_connect-0.8.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:83ae184507c671c3d833e688bd1445bc0fd62a4cf6db6cbaf9f8aaebd4134921", size = 1057457, upload-time = "2025-06-24T19:07:01.653Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c0/2405c796cf561b3592a7cba5f1a6752396783fe0e048c84435c78df701e6/clickhouse_connect-0.8.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a444ea5e14135c88349223af00d124e09f3f77384180ca7df96b4aafd8b6e9ee", size = 1074145, upload-time = "2025-06-24T19:07:03.27Z" }, + { url = "https://files.pythonhosted.org/packages/a3/db/4d9f4fa5bdcbecebc53beb4faffc976fbcda96fc9ea2da8cceb941840970/clickhouse_connect-0.8.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1f0594ee5a261f2b89823f049edb7aebfa4cf3262ab324a76e462de0fd404320", size = 1099462, upload-time = "2025-06-24T19:07:04.932Z" }, + { url = "https://files.pythonhosted.org/packages/99/41/67f5d409c7986fee8631379b88583eb245b745f16dfe4e9f2d1f54be1145/clickhouse_connect-0.8.18-cp312-cp312-win32.whl", hash = "sha256:6e087bc4162d156fc040678454e5eb6160f72d470e3817906128069a7881af7d", size = 229493, upload-time = "2025-06-24T19:07:06.117Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/d65c933af7ef35a481eff8f2adb995274a611535c5067d9097b87f521a8c/clickhouse_connect-0.8.18-cp312-cp312-win_amd64.whl", hash = "sha256:7dfd1280d62f24ff8f991953487958d4b97dd2435fea9d680f7f193049ed7e81", size = 247567, upload-time = "2025-06-24T19:07:07.355Z" }, + { url = "https://files.pythonhosted.org/packages/93/04/ad43f7ac57142c41d0eb33fca64400c61bfe30ae10af9c9f5c16201e2de9/clickhouse_connect-0.8.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbb56d86b6e26016e24a260a2280c9831736797cf8eda8594551e25fa91a1b0e", size = 259470, upload-time = "2025-06-24T19:07:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f4/691829df8e5ec71a34293be1061a5d161a15948013f5bd022073033427c5/clickhouse_connect-0.8.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8e9b47475f82ad31c4981cb5284e6a7d9869dda7c295d69ba650011ec6b6c64e", size = 251140, upload-time = "2025-06-24T19:07:09.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/64/554632a889a9e920904979725bb445cea0a51fb3cd0d1b89bebc098218eb/clickhouse_connect-0.8.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da68a0cece7a350d6ebeacc5934406c78c381d6bf6a598765d56f24b65b1ec85", size = 1041149, upload-time = "2025-06-24T19:07:11.249Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bd/52b7c28edc817ceda499c3ef1eaafefcaf319c7cde7c80618d380c8c09ba/clickhouse_connect-0.8.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0036960dcb6bf88b262e9b461ddafb7597c0beb36700aab1e74dde64f02c8cfd", size = 1059571, upload-time = "2025-06-24T19:07:12.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/0ba93932f5096b49d1f04aa6859dcb659abbad957a7b5d9becd9b1d944df/clickhouse_connect-0.8.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcd291e920223edb3152bc4e7720b2b514b7a48fb18bb0c3346f66b2691add67", size = 1015179, upload-time = "2025-06-24T19:07:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d8/ad0422135e359390986739a6fedbacdacc40f2ee58a03be6e7aee8cb68ad/clickhouse_connect-0.8.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba1747a569b87f693d2c470faaaf83e02a24792c3533502f92b11ada672c2a6f", size = 1042200, upload-time = "2025-06-24T19:07:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d2/93838044318e2a51008c43c64e5fddd611ae200ffaaf09a38e7f3fdaa708/clickhouse_connect-0.8.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:63c9c1ab6899ff916f4751b1d7604b6e81c3a63f169ec538429be072905cf7c3", size = 1057602, upload-time = "2025-06-24T19:07:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/25/24/da6aa82d26e4db72a20c88ceef076ae5cf1feb0f170ab05331e65bea27b0/clickhouse_connect-0.8.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38bdf84bbbb784ce1ffb813edc1ddc7622cea3cc6ca59f336f82c3365fed36ef", size = 1085044, upload-time = "2025-06-24T19:07:18.346Z" }, + { url = "https://files.pythonhosted.org/packages/97/63/a0540da4db8c8adc1a522615481568296edf7ae29cf8b261697d02627629/clickhouse_connect-0.8.18-cp313-cp313-win32.whl", hash = "sha256:252549ed7596baaf955699f7713ff171cb21292ea333ff01cc295d7bbf20a4d9", size = 228783, upload-time = "2025-06-24T19:07:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/5e9b71b29eb28066c207cb0da6fb0bcc89f12d5ed2114085854cfa5f4fdf/clickhouse_connect-0.8.18-cp313-cp313-win_amd64.whl", hash = "sha256:a7915cdd844d083905b5fe4f9139c65aa033652a670986381dc2e2b885108266", size = 246541, upload-time = "2025-06-24T19:07:20.802Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -264,6 +299,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -272,6 +308,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -280,6 +317,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -288,6 +326,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -325,6 +364,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + [[package]] name = "mapache-py" version = "3.1.0" @@ -334,114 +413,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/86/2f4ace70b37d28484bf5ecf75e8aa88edcdf0df16b90fb666576f4c27cff/mapache_py-3.1.0-py3-none-any.whl", hash = "sha256:f69a8e16af353c4a5aaaa04a5fc8c47542e2fde62474904ba44fcf7024d0da6e", size = 5001, upload-time = "2026-05-06T07:57:12.001Z" }, ] -[[package]] -name = "numpy" -version = "2.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, - { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, - { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, - { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, - { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, - { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, - { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, - { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, - { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, - { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, - { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, - { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, - { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, - { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, - { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, - { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, - { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, - { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, - { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, -] - -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, -] - [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -483,41 +454,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] -[[package]] -name = "pyarrow" -version = "20.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/ee/a7810cb9f3d6e9238e61d312076a9859bf3668fd21c69744de9532383912/pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1", size = 1125187, upload-time = "2025-04-27T12:34:23.264Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/d6/0c10e0d54f6c13eb464ee9b67a68b8c71bcf2f67760ef5b6fbcddd2ab05f/pyarrow-20.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:75a51a5b0eef32727a247707d4755322cb970be7e935172b6a3a9f9ae98404ba", size = 30815067, upload-time = "2025-04-27T12:29:44.384Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e2/04e9874abe4094a06fd8b0cbb0f1312d8dd7d707f144c2ec1e5e8f452ffa/pyarrow-20.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:211d5e84cecc640c7a3ab900f930aaff5cd2702177e0d562d426fb7c4f737781", size = 32297128, upload-time = "2025-04-27T12:29:52.038Z" }, - { url = "https://files.pythonhosted.org/packages/31/fd/c565e5dcc906a3b471a83273039cb75cb79aad4a2d4a12f76cc5ae90a4b8/pyarrow-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba3cf4182828be7a896cbd232aa8dd6a31bd1f9e32776cc3796c012855e1199", size = 41334890, upload-time = "2025-04-27T12:29:59.452Z" }, - { url = "https://files.pythonhosted.org/packages/af/a9/3bdd799e2c9b20c1ea6dc6fa8e83f29480a97711cf806e823f808c2316ac/pyarrow-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c3a01f313ffe27ac4126f4c2e5ea0f36a5fc6ab51f8726cf41fee4b256680bd", size = 42421775, upload-time = "2025-04-27T12:30:06.875Z" }, - { url = "https://files.pythonhosted.org/packages/10/f7/da98ccd86354c332f593218101ae56568d5dcedb460e342000bd89c49cc1/pyarrow-20.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a2791f69ad72addd33510fec7bb14ee06c2a448e06b649e264c094c5b5f7ce28", size = 40687231, upload-time = "2025-04-27T12:30:13.954Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1b/2168d6050e52ff1e6cefc61d600723870bf569cbf41d13db939c8cf97a16/pyarrow-20.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4250e28a22302ce8692d3a0e8ec9d9dde54ec00d237cff4dfa9c1fbf79e472a8", size = 42295639, upload-time = "2025-04-27T12:30:21.949Z" }, - { url = "https://files.pythonhosted.org/packages/b2/66/2d976c0c7158fd25591c8ca55aee026e6d5745a021915a1835578707feb3/pyarrow-20.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89e030dc58fc760e4010148e6ff164d2f44441490280ef1e97a542375e41058e", size = 42908549, upload-time = "2025-04-27T12:30:29.551Z" }, - { url = "https://files.pythonhosted.org/packages/31/a9/dfb999c2fc6911201dcbf348247f9cc382a8990f9ab45c12eabfd7243a38/pyarrow-20.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6102b4864d77102dbbb72965618e204e550135a940c2534711d5ffa787df2a5a", size = 44557216, upload-time = "2025-04-27T12:30:36.977Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/9adee63dfa3911be2382fb4d92e4b2e7d82610f9d9f668493bebaa2af50f/pyarrow-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:96d6a0a37d9c98be08f5ed6a10831d88d52cac7b13f5287f1e0f625a0de8062b", size = 25660496, upload-time = "2025-04-27T12:30:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/9b/aa/daa413b81446d20d4dad2944110dcf4cf4f4179ef7f685dd5a6d7570dc8e/pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893", size = 30798501, upload-time = "2025-04-27T12:30:48.351Z" }, - { url = "https://files.pythonhosted.org/packages/ff/75/2303d1caa410925de902d32ac215dc80a7ce7dd8dfe95358c165f2adf107/pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061", size = 32277895, upload-time = "2025-04-27T12:30:55.238Z" }, - { url = "https://files.pythonhosted.org/packages/92/41/fe18c7c0b38b20811b73d1bdd54b1fccba0dab0e51d2048878042d84afa8/pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae", size = 41327322, upload-time = "2025-04-27T12:31:05.587Z" }, - { url = "https://files.pythonhosted.org/packages/da/ab/7dbf3d11db67c72dbf36ae63dcbc9f30b866c153b3a22ef728523943eee6/pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4", size = 42411441, upload-time = "2025-04-27T12:31:15.675Z" }, - { url = "https://files.pythonhosted.org/packages/90/c3/0c7da7b6dac863af75b64e2f827e4742161128c350bfe7955b426484e226/pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5", size = 40677027, upload-time = "2025-04-27T12:31:24.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/27/43a47fa0ff9053ab5203bb3faeec435d43c0d8bfa40179bfd076cdbd4e1c/pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b", size = 42281473, upload-time = "2025-04-27T12:31:31.311Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/d56c63b078876da81bbb9ba695a596eabee9b085555ed12bf6eb3b7cab0e/pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3", size = 42893897, upload-time = "2025-04-27T12:31:39.406Z" }, - { url = "https://files.pythonhosted.org/packages/92/ac/7d4bd020ba9145f354012838692d48300c1b8fe5634bfda886abcada67ed/pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368", size = 44543847, upload-time = "2025-04-27T12:31:45.997Z" }, - { url = "https://files.pythonhosted.org/packages/9d/07/290f4abf9ca702c5df7b47739c1b2c83588641ddfa2cc75e34a301d42e55/pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031", size = 25653219, upload-time = "2025-04-27T12:31:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/95/df/720bb17704b10bd69dde086e1400b8eefb8f58df3f8ac9cff6c425bf57f1/pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63", size = 30853957, upload-time = "2025-04-27T12:31:59.215Z" }, - { url = "https://files.pythonhosted.org/packages/d9/72/0d5f875efc31baef742ba55a00a25213a19ea64d7176e0fe001c5d8b6e9a/pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c", size = 32247972, upload-time = "2025-04-27T12:32:05.369Z" }, - { url = "https://files.pythonhosted.org/packages/d5/bc/e48b4fa544d2eea72f7844180eb77f83f2030b84c8dad860f199f94307ed/pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70", size = 41256434, upload-time = "2025-04-27T12:32:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/c3/01/974043a29874aa2cf4f87fb07fd108828fc7362300265a2a64a94965e35b/pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b", size = 42353648, upload-time = "2025-04-27T12:32:20.766Z" }, - { url = "https://files.pythonhosted.org/packages/68/95/cc0d3634cde9ca69b0e51cbe830d8915ea32dda2157560dda27ff3b3337b/pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122", size = 40619853, upload-time = "2025-04-27T12:32:28.1Z" }, - { url = "https://files.pythonhosted.org/packages/29/c2/3ad40e07e96a3e74e7ed7cc8285aadfa84eb848a798c98ec0ad009eb6bcc/pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6", size = 42241743, upload-time = "2025-04-27T12:32:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/eb/cb/65fa110b483339add6a9bc7b6373614166b14e20375d4daa73483755f830/pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c", size = 42839441, upload-time = "2025-04-27T12:32:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/98/7b/f30b1954589243207d7a0fbc9997401044bf9a033eec78f6cb50da3f304a/pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a", size = 44503279, upload-time = "2025-04-27T12:32:56.503Z" }, - { url = "https://files.pythonhosted.org/packages/37/40/ad395740cd641869a13bcf60851296c89624662575621968dcfafabaa7f6/pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9", size = 25944982, upload-time = "2025-04-27T12:33:04.72Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -627,18 +563,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -659,18 +583,16 @@ wheels = [ [[package]] name = "query" -version = "3.4.5" +version = "4.0.0" source = { editable = "." } dependencies = [ + { name = "clickhouse-connect" }, { name = "dotenv" }, { name = "fastapi" }, { name = "gr-ulid" }, { name = "loguru" }, { name = "mapache-py" }, - { name = "numpy" }, - { name = "pandas" }, { name = "psycopg2-binary" }, - { name = "pyarrow" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, { name = "sqlalchemy" }, @@ -679,15 +601,13 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "clickhouse-connect", specifier = ">=0.8.0,<0.9.0" }, { name = "dotenv", specifier = ">=0.9.9,<0.10.0" }, { name = "fastapi", specifier = ">=0.115.10,<0.116.0" }, { name = "gr-ulid", specifier = ">=1.1.2,<2.0.0" }, { name = "loguru", specifier = ">=0.7.3,<0.8.0" }, { name = "mapache-py", specifier = ">=3.0.1,<4.0.0" }, - { name = "numpy", specifier = ">=2.2.3,<3.0.0" }, - { name = "pandas", specifier = ">=2.2.3,<3.0.0" }, { name = "psycopg2-binary", specifier = ">=2.9.10,<3.0.0" }, - { name = "pyarrow", specifier = ">=20.0.0,<21.0.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.11.0,<3.0.0" }, { name = "requests", specifier = ">=2.32.3,<3.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.38,<3.0.0" }, @@ -709,15 +629,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.48" @@ -797,15 +708,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, -] - [[package]] name = "urllib3" version = "2.6.3" @@ -836,3 +738,60 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] From 8c752ceae6a82293fbc90c0530a3d63df0464751 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 10 Jun 2026 10:04:04 -0700 Subject: [PATCH 3/3] feat(dashboard): add signals page with MQL builder, drop query and trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Signals page (/signals) is the chart-driven analog of Datadog's metrics explorer: - TimeframePicker — pill displays the absolute range with the preset label as muted annotation, click opens an editable input that accepts shortcuts (`1h`, `30m`, `2d`) or absolute ranges in the form `YYYY-MM-DD HH:MM - YYYY-MM-DD HH:MM` (local time). - QueryBuilder — Datadog-style chips for aggregator / field / filters / group-by / rollup. Filter values support `*` wildcards (LIKE under the hood) and same-column chips show an OR separator since the semantics union. Serialized live preview emits MQL v0.2 method-chain syntax. - QueryChart — recharts-backed bar/line/area with chart-type toggle, top-K + "other" rollup so 400-series queries don't blow up the legend, click-and-drag brush selection to set custom timeframes, and a safety-rail confirmation panel when bucketCount × seriesCount exceeds 20k (SVG render budget on most browsers). - Collected-signals table (deliberately unbounded by the timeframe) with fuzzy search via fuse.js, sortable columns, hover tooltips for absolute timestamps. Sidebar reorganized: Debug moved into the bottom group above Settings, Signals added under Dashboard, Query and Trips entries removed. Removed: pages/query, pages/trips, components/query, components/trips, models/trip, dead useTrip store slot. --- dashboard/src/components/Sidebar.tsx | 32 +- dashboard/src/components/query/SignalCard.tsx | 28 - dashboard/src/components/query/TripCard.tsx | 31 - .../components/signals/ChartTypeToggle.tsx | 52 ++ .../src/components/signals/QueryBuilder.tsx | 526 +++++++++++++ .../src/components/signals/QueryChart.tsx | 378 ++++++++++ .../components/signals/TimeframePicker.tsx | 317 ++++++++ .../components/trips/TripDetailsDialog.tsx | 218 ------ .../trips/WidgetSelectionDialog.tsx | 209 ------ dashboard/src/lib/query.ts | 130 ++++ dashboard/src/lib/store.ts | 3 - dashboard/src/main.tsx | 16 +- dashboard/src/models/trip.tsx | 45 -- dashboard/src/pages/query/QueryPage.tsx | 705 ------------------ dashboard/src/pages/signals/SignalsPage.tsx | 522 +++++++++++++ dashboard/src/pages/trips/TripDetailsPage.tsx | 578 -------------- dashboard/src/pages/trips/TripsPage.tsx | 98 --- 17 files changed, 1940 insertions(+), 1948 deletions(-) delete mode 100644 dashboard/src/components/query/SignalCard.tsx delete mode 100644 dashboard/src/components/query/TripCard.tsx create mode 100644 dashboard/src/components/signals/ChartTypeToggle.tsx create mode 100644 dashboard/src/components/signals/QueryBuilder.tsx create mode 100644 dashboard/src/components/signals/QueryChart.tsx create mode 100644 dashboard/src/components/signals/TimeframePicker.tsx delete mode 100644 dashboard/src/components/trips/TripDetailsDialog.tsx delete mode 100644 dashboard/src/components/trips/WidgetSelectionDialog.tsx create mode 100644 dashboard/src/lib/query.ts delete mode 100644 dashboard/src/models/trip.tsx delete mode 100644 dashboard/src/pages/query/QueryPage.tsx create mode 100644 dashboard/src/pages/signals/SignalsPage.tsx delete mode 100644 dashboard/src/pages/trips/TripDetailsPage.tsx delete mode 100644 dashboard/src/pages/trips/TripsPage.tsx diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 1dc2ec41..8e0b6c20 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -8,14 +8,13 @@ import { useVehicleList, } from "@/lib/store"; import { + Activity, Briefcase, Bug, CarFront, ChevronsUpDown, LayoutDashboard, LucideIcon, - MapPinned, - SearchCode, Settings, } from "lucide-react"; import { @@ -285,29 +284,15 @@ const Sidebar = (props: SidebarProps) => { isSidebarExpanded={props.isSidebarExpanded} /> -
- {
+ -
-
- {selected ? ( - - ) : ( - - )} -
-
-
{signal.name == "" ? signal.id : signal.name}
-
{signal.id}
-
-
- - ); -} diff --git a/dashboard/src/components/query/TripCard.tsx b/dashboard/src/components/query/TripCard.tsx deleted file mode 100644 index 8144a47d..00000000 --- a/dashboard/src/components/query/TripCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Trip } from "@/models/trip"; -import { Card } from "@/components/ui/card"; -import { CheckCircle2, Circle } from "lucide-react"; - -interface TripCardProps { - trip: Trip; - selected: boolean; -} - -export function TripCard({ trip, selected }: TripCardProps) { - return ( - -
-
- {selected ? ( - - ) : ( - - )} -
-
-
{trip.name}
-
- {new Date(trip.start_time).toLocaleString()} -{" "} - {new Date(trip.end_time).toLocaleTimeString()} -
-
-
-
- ); -} diff --git a/dashboard/src/components/signals/ChartTypeToggle.tsx b/dashboard/src/components/signals/ChartTypeToggle.tsx new file mode 100644 index 00000000..6c3a642d --- /dev/null +++ b/dashboard/src/components/signals/ChartTypeToggle.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/lib/utils"; +import { AreaChart, BarChart3, LineChart } from "lucide-react"; + +export type ChartType = "bar" | "line" | "area"; + +const OPTIONS: { type: ChartType; icon: typeof BarChart3; title: string }[] = [ + { type: "bar", icon: BarChart3, title: "Bar" }, + { type: "line", icon: LineChart, title: "Line" }, + { type: "area", icon: AreaChart, title: "Area" }, +]; + +interface ChartTypeToggleProps { + value: ChartType; + onChange: (next: ChartType) => void; + className?: string; +} + +export function ChartTypeToggle({ + value, + onChange, + className, +}: ChartTypeToggleProps) { + return ( +
+ {OPTIONS.map(({ type, icon: Icon, title }) => { + const active = value === type; + return ( + + ); + })} +
+ ); +} diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx new file mode 100644 index 00000000..c157cf13 --- /dev/null +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -0,0 +1,526 @@ +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + AGGREGATORS, + type Aggregator, + COUNT_FIELD, + defaultFieldFor, + type FieldName, + type FilterColumn, + FILTERABLE_COLUMNS, + type GroupColumn, + GROUPABLE_COLUMNS, + NUMERIC_FIELDS, + type Predicate, + type Query, + type Rollup, + ROLLUP_INTERVALS, + ROW_COUNT_AGGS, + serializeQuery, +} from "@/lib/query"; +import { cn } from "@/lib/utils"; +import Fuse from "fuse.js"; +import { ChevronDown, Plus, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +interface QueryBuilderProps { + value: Query; + onChange: (next: Query) => void; + /** Available signal names for the filter-value autocomplete. Pulled from + * the page's existing `/query/signals` fetch so the picker and the + * table never disagree about what exists. */ + signalNames: string[]; + /** Parser/execution error from the most recent run. Surfaced under the + * serialized preview; the builder itself stays interactive so the user + * can keep iterating. */ + error?: { message: string; position?: number } | null; +} + +export function QueryBuilder({ + value, + onChange, + signalNames, + error, +}: QueryBuilderProps) { + const fieldOptions: FieldName[] = ROW_COUNT_AGGS.has(value.fn) + ? [COUNT_FIELD] + : NUMERIC_FIELDS; + + function setFn(fn: Aggregator) { + // Swapping aggregator classes (count ↔ avg/sum/...) invalidates the + // current field. Reset to the canonical default to keep the AST + // valid without a separate "field is wrong" error state. + const fieldClassChanged = + ROW_COUNT_AGGS.has(fn) !== ROW_COUNT_AGGS.has(value.fn); + onChange({ + ...value, + fn, + field: fieldClassChanged ? defaultFieldFor(fn) : value.field, + }); + } + + function setField(field: FieldName) { + onChange({ ...value, field }); + } + + function addFilter() { + onChange({ + ...value, + filters: [ + ...value.filters, + { column: "name", op: "=", value: "" }, + ], + }); + } + + function updateFilter(i: number, next: Predicate) { + const filters = [...value.filters]; + filters[i] = next; + onChange({ ...value, filters }); + } + + function removeFilter(i: number) { + onChange({ + ...value, + filters: value.filters.filter((_, idx) => idx !== i), + }); + } + + function addGroup() { + // Only one groupable column today; if it's already in the list bail + // out instead of creating a duplicate the SQL would reject anyway. + const next = GROUPABLE_COLUMNS.find((c) => !value.groupBy.includes(c)); + if (!next) return; + onChange({ ...value, groupBy: [...value.groupBy, next] }); + } + + function removeGroup(col: GroupColumn) { + onChange({ + ...value, + groupBy: value.groupBy.filter((c) => c !== col), + }); + } + + function setRollup(next: Rollup | undefined) { + // Pass an explicit undefined to clear instead of leaving rollup + // hanging around as an empty string in the AST. + const { rollup: _drop, ...rest } = value; + onChange(next ? { ...rest, rollup: next } : rest); + } + + return ( +
+
+ ({ + value: a.value, + label: a.label, + }))} + onSelect={(v) => setFn(v as Aggregator)} + /> + ({ value: f, label: f }))} + onSelect={(v) => setField(v as FieldName)} + disabled={fieldOptions.length === 1} + /> + + + from + + {value.filters.map((pred, i) => { + // Adjacent filters on the same column union (OR semantics); + // show a tiny "or" between them so the user sees this rather + // than reading the visual sequence as AND. + const prev = i > 0 ? value.filters[i - 1] : null; + const sameColAsPrev = prev !== null && prev.column === pred.column; + return ( + + {sameColAsPrev ? ( + + or + + ) : null} + updateFilter(i, next)} + onRemove={() => removeFilter(i)} + signalNames={signalNames} + /> + + ); + })} + + + + by + + {value.groupBy.map((col) => ( + removeGroup(col)} /> + ))} + {value.groupBy.length < GROUPABLE_COLUMNS.length ? ( + + ) : null} + + + rollup + + +
+ + + {serializeQuery(value)} + + {error ? ( +

+ {error.message} + {typeof error.position === "number" + ? ` (col ${error.position + 1})` + : ""} +

+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Chip primitives +// --------------------------------------------------------------------------- + +const CHIP_BASE = + "inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs font-mono transition-colors"; + +function SelectChip({ + label, + options, + onSelect, + disabled, +}: { + label: string; + options: { value: string; label: string }[]; + onSelect: (value: string) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + if (disabled) { + return ( + + {label} + + ); + } + return ( + + + + + + {options.map((o) => ( + + ))} + + + ); +} + +function AddChip({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +function RollupChip({ + value, + onChange, +}: { + value: Rollup | undefined; + onChange: (next: Rollup | undefined) => void; +}) { + const [open, setOpen] = useState(false); + const label = value ?? "auto"; + const isAuto = !value; + return ( + + + + + + +
+ {ROLLUP_INTERVALS.map((iv) => ( + + ))} + + + ); +} + +function GroupChip({ + column, + onRemove, +}: { + column: GroupColumn; + onRemove: () => void; +}) { + return ( + + {column} + + + ); +} + +function FilterChip({ + value, + onChange, + onRemove, + signalNames, +}: { + value: Predicate; + onChange: (next: Predicate) => void; + onRemove: () => void; + signalNames: string[]; +}) { + // Open the popover automatically when the chip is freshly added (empty + // value) so the user doesn't have to click again to start typing. + const [open, setOpen] = useState(value.value === ""); + + const display = value.value + ? `${value.column} = "${value.value}"` + : `${value.column} = …`; + + return ( + + + + + + setOpen(false)} + /> + + + ); +} + +function FilterEditor({ + value, + onChange, + signalNames, + onCommit, +}: { + value: Predicate; + onChange: (next: Predicate) => void; + signalNames: string[]; + onCommit: () => void; +}) { + const [search, setSearch] = useState(value.value); + + const fuse = useMemo( + () => + new Fuse(signalNames, { + threshold: 0.3, + ignoreLocation: true, + }), + [signalNames], + ); + + const hasWildcard = search.includes("*"); + + const matches = useMemo(() => { + const q = search.trim(); + if (!q) return signalNames.slice(0, 50); + if (hasWildcard) { + // Compile the wildcard pattern to a regex so the preview list + // shows what would actually match on the backend (`*` ⇒ any run + // of characters). Anchor with ^…$ to mirror LIKE's full-string + // semantics — `bcu_*_temp` shouldn't match `prefix_bcu_x_temp`. + const escaped = q + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") // escape regex metachars + .replace(/\*/g, ".*"); + try { + const rx = new RegExp(`^${escaped}$`, "i"); + return signalNames.filter((n) => rx.test(n)).slice(0, 50); + } catch { + return []; + } + } + return fuse.search(q).slice(0, 50).map((r) => r.item); + }, [search, signalNames, fuse, hasWildcard]); + + return ( +
+
+ ({ value: c, label: c }))} + onSelect={(c) => + onChange({ ...value, column: c as FilterColumn }) + } + /> + = + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + // Wildcards commit the literal pattern (we want LIKE + // semantics, not "pick the first match"). Otherwise prefer + // the top fuzzy match — saves a click when the user typed + // an exact-ish prefix. + const pick = hasWildcard + ? search.trim() + : (matches[0] ?? search.trim()); + if (!pick) return; + onChange({ ...value, value: pick }); + onCommit(); + } else if (e.key === "Escape") { + onCommit(); + } + }} + placeholder="signal name (use * for wildcards)" + className="h-8 font-mono text-xs" + /> + {hasWildcard ? ( + + {matches.length} match + {matches.length === 1 ? "" : "es"} + + ) : null} +
+
+ {matches.length === 0 ? ( +
+ No matches +
+ ) : ( + matches.map((name) => ( + + )) + )} +
+
+ ); +} diff --git a/dashboard/src/components/signals/QueryChart.tsx b/dashboard/src/components/signals/QueryChart.tsx new file mode 100644 index 00000000..a5b20965 --- /dev/null +++ b/dashboard/src/components/signals/QueryChart.tsx @@ -0,0 +1,378 @@ +import { Button } from "@/components/ui/button"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { AlertTriangle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ReferenceArea, + XAxis, + YAxis, +} from "recharts"; +import type { ChartType } from "./ChartTypeToggle"; + +export interface SeriesPoint { + bucket: string; + value: number | null; +} + +export interface Series { + tags: Record; + points: SeriesPoint[]; +} + +interface QueryChartProps { + series: Series[]; + type: ChartType; + /** Number of seconds per bucket — drives the x-axis tick formatting. */ + intervalSec: number; + /** Max series shown before everything beyond gets rolled into "other". + * Past ~12 the legend stops being useful. */ + maxSeries?: number; + /** If set, click-and-drag on the chart highlights a range and commits + * it on mouse-up. Receives the [start, end) of the brushed window. */ + onBrushSelect?: (start: Date, end: Date) => void; +} + +// Stable, high-contrast palette. Datadog-ish saturated colors that survive +// dark backgrounds — first slot deliberately matches our existing gr-pink +// so single-series bars don't change color when you flip into multi-series. +const PALETTE = [ + "#e105a3", + "#8412fc", + "#10b981", + "#f59e0b", + "#3b82f6", + "#ef4444", + "#06b6d4", + "#84cc16", + "#a855f7", + "#ec4899", + "#14b8a6", + "#f97316", +]; + +const OTHER_KEY = "__other__"; + +function formatBucketTick(iso: string, intervalSec: number): string { + const d = new Date(iso); + if (intervalSec >= 24 * 60 * 60) { + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + if (intervalSec >= 60 * 60) { + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + }); + } + if (intervalSec >= 60) { + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + } + // Sub-minute buckets need seconds — otherwise every tick reads the + // same "1:23 PM" and the user can't tell them apart. + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatCount(n: number): string { + const abs = Math.abs(n); + if (abs < 1_000) return Number.isInteger(n) ? n.toString() : n.toFixed(2); + if (abs < 1_000_000) return `${(n / 1_000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +} + +/** Make a stable label for a series from its tag values. Empty tags → + * "value" so the single-series legend reads naturally. */ +function seriesLabel(tags: Record): string { + const entries = Object.entries(tags); + if (entries.length === 0) return "value"; + return entries.map(([, v]) => v ?? "—").join(" · "); +} + +/** Sum every point in a series — for top-K ranking. Null values are 0. */ +function seriesTotal(s: Series): number { + let acc = 0; + for (const p of s.points) acc += p.value ?? 0; + return acc; +} + +/** Roll any series past `max` into a single "other" bucket so the chart + * stays readable when a query produces hundreds of groups. */ +function topK(series: Series[], max: number): { kept: Series[]; otherCount: number } { + if (series.length <= max) return { kept: series, otherCount: 0 }; + const sorted = [...series].sort((a, b) => seriesTotal(b) - seriesTotal(a)); + const kept = sorted.slice(0, max); + const tail = sorted.slice(max); + // Build the "other" series by summing point-by-point across the tail. + // Every series shares the same bucket axis (server zero-fills), so a + // simple index walk is correct. + const refBuckets = sorted[0]?.points ?? []; + const otherPoints: SeriesPoint[] = refBuckets.map((p, i) => { + let sum = 0; + for (const s of tail) sum += s.points[i]?.value ?? 0; + return { bucket: p.bucket, value: sum }; + }); + kept.push({ tags: { [OTHER_KEY]: `+${tail.length} other` }, points: otherPoints }); + return { kept, otherCount: tail.length }; +} + +export function QueryChart({ + series, + type, + intervalSec, + maxSeries = 10, + onBrushSelect, +}: QueryChartProps) { + // Brush state. `start` is fixed on mousedown; `current` follows the + // mouse and renders the live highlight. We commit on mouseup when the + // two are distinct buckets — a same-bucket release is treated as a + // click (tooltip), not a selection, so single clicks keep working. + const [brushStart, setBrushStart] = useState(null); + const [brushCurrent, setBrushCurrent] = useState(null); + + type ChartMouseEvent = { activeLabel?: string | number | null }; + const handleMouseDown = onBrushSelect + ? (e: ChartMouseEvent | null) => { + const label = e?.activeLabel; + if (typeof label === "string") { + setBrushStart(label); + setBrushCurrent(label); + } + } + : undefined; + const handleMouseMove = onBrushSelect + ? (e: ChartMouseEvent | null) => { + if (brushStart === null) return; + const label = e?.activeLabel; + if (typeof label === "string") setBrushCurrent(label); + } + : undefined; + const handleMouseUp = onBrushSelect + ? () => { + if (brushStart !== null && brushCurrent !== null && brushStart !== brushCurrent) { + const a = new Date(brushStart); + const b = new Date(brushCurrent); + const [start, end] = a < b ? [a, b] : [b, a]; + // Extend `end` to the *end* of its bucket so a brush that + // visually covers a bar actually includes that bar's data. + // intervalSec is the bucket width in seconds. + onBrushSelect( + start, + new Date(end.getTime() + intervalSec * 1000), + ); + } + setBrushStart(null); + setBrushCurrent(null); + } + : undefined; + // Cancel an in-progress drag if the cursor leaves the chart — avoids + // a stuck highlight when the user releases the button off-chart. + const handleMouseLeave = onBrushSelect + ? () => { + setBrushStart(null); + setBrushCurrent(null); + } + : undefined; + // Top-K rollup before any other shaping; bar/area would stack hundreds + // of slivers otherwise. + const { kept } = useMemo(() => topK(series, maxSeries), [series, maxSeries]); + + // Pivot tall → wide so recharts can render multiple series from one + // dataset. Each row: { bucket, [seriesKey1]: value1, [seriesKey2]: ... }. + // Series keys are array indices ("s0", "s1", ...) so we never collide on + // user-provided values like "name". + const { data, seriesKeys, config } = useMemo(() => { + const seriesKeys: { key: string; label: string; color: string }[] = kept.map( + (s, i) => ({ + key: `s${i}`, + label: seriesLabel(s.tags), + color: PALETTE[i % PALETTE.length], + }), + ); + const config: ChartConfig = Object.fromEntries( + seriesKeys.map(({ key, label, color }) => [key, { label, color }]), + ); + const buckets = kept[0]?.points.map((p) => p.bucket) ?? []; + const data = buckets.map((bucket, i) => { + const row: Record = { bucket }; + kept.forEach((s, sIdx) => { + row[`s${sIdx}`] = s.points[i]?.value ?? 0; + }); + return row; + }); + return { data, seriesKeys, config }; + }, [kept]); + + const isMulti = seriesKeys.length > 1; + // Bar/area stack by default in multi-series; line doesn't (lines stacked + // on top of each other are unreadable). Single-series ignores stackId. + const stackId = isMulti && type !== "line" ? "stack" : undefined; + + // Safety rail. Recharts is SVG-based — each bucket × series renders a + // DOM element, and the browser starts choking past ~20k of them. Bar + // charts hit this hardest (one per bar); line charts only render + // one per series so they're cheap. We treat any chart type the + // same here for simplicity — if 20k is too conservative for line later, + // we can split the threshold by type. + const RENDER_LIMIT = 20_000; + const renderElementCount = data.length * Math.max(1, seriesKeys.length); + const renderSig = `${data.length}x${seriesKeys.length}`; + const [confirmedSig, setConfirmedSig] = useState(null); + const needsConfirm = + renderElementCount > RENDER_LIMIT && confirmedSig !== renderSig; + + if (data.length === 0) { + return ( +
+ No data in this window +
+ ); + } + + if (needsConfirm) { + return ( +
+ +
+

+ Large render —{" "} + {data.length.toLocaleString()} buckets ×{" "} + {seriesKeys.length} series ={" "} + {renderElementCount.toLocaleString()} elements +

+

+ Past about {RENDER_LIMIT.toLocaleString()} SVG elements the browser + tab can hang. Pick a coarser rollup or a narrower timeframe, or + render anyway. +

+
+ +
+ ); + } + + const commonAxes = ( + <> + + formatBucketTick(v, intervalSec)} + tickLine={false} + axisLine={false} + minTickGap={32} + /> + + new Date(v as string).toLocaleString()} + /> + } + /> + + ); + + // Shared props for whichever chart variant we render below. + const chartProps = { + data, + margin: { top: 8, right: 8, left: -16, bottom: 0 }, + onMouseDown: handleMouseDown, + onMouseMove: handleMouseMove, + onMouseUp: handleMouseUp, + onMouseLeave: handleMouseLeave, + // Drag-to-select feels wrong with text selection happening underneath. + style: onBrushSelect ? { userSelect: "none" as const, cursor: "crosshair" as const } : undefined, + }; + + const brushHighlight = + brushStart !== null && brushCurrent !== null && brushStart !== brushCurrent ? ( + + ) : null; + + return ( + + {type === "bar" ? ( + + {commonAxes} + {seriesKeys.map(({ key }) => ( + + ))} + {brushHighlight} + + ) : type === "line" ? ( + + {commonAxes} + {seriesKeys.map(({ key }) => ( + + ))} + {brushHighlight} + + ) : ( + + {commonAxes} + {seriesKeys.map(({ key }) => ( + + ))} + {brushHighlight} + + )} + + ); +} diff --git a/dashboard/src/components/signals/TimeframePicker.tsx b/dashboard/src/components/signals/TimeframePicker.tsx new file mode 100644 index 00000000..257b48b4 --- /dev/null +++ b/dashboard/src/components/signals/TimeframePicker.tsx @@ -0,0 +1,317 @@ +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { Clock } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +/** The page-level time window. Always absolute under the hood — presets + * just snap start/end to a "now-anchored" pair at the moment they're + * picked. `label` is metadata for the chip display ("Past 1 week", + * "Past 45 minutes", or "Custom" when the user dragged on the chart). */ +export interface Timeframe { + start: Date; + end: Date; + label: string; +} + +export interface TimeframePreset { + label: string; + shortcut: string; + rangeSeconds: number; +} + +export const TIMEFRAME_PRESETS: TimeframePreset[] = [ + { label: "Past 1 minute", shortcut: "1m", rangeSeconds: 60 }, + { label: "Past 15 minutes", shortcut: "15m", rangeSeconds: 15 * 60 }, + { label: "Past 30 minutes", shortcut: "30m", rangeSeconds: 30 * 60 }, + { label: "Past 1 hour", shortcut: "1h", rangeSeconds: 60 * 60 }, + { label: "Past 4 hours", shortcut: "4h", rangeSeconds: 4 * 60 * 60 }, + { label: "Past 1 day", shortcut: "1d", rangeSeconds: 24 * 60 * 60 }, + { label: "Past 2 days", shortcut: "2d", rangeSeconds: 2 * 24 * 60 * 60 }, + { label: "Past 1 week", shortcut: "1w", rangeSeconds: 7 * 24 * 60 * 60 }, +]; + +// Aliases mapped to seconds-per-unit. Plural / abbreviated variants all +// collapse to the same multiplier so users can type whatever feels natural. +const UNIT_SECONDS: Record = { + s: 1, sec: 1, secs: 1, second: 1, seconds: 1, + m: 60, min: 60, mins: 60, minute: 60, minutes: 60, + h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600, + d: 86400, day: 86400, days: 86400, + w: 604800, wk: 604800, week: 604800, weeks: 604800, +}; + +const SHORTCUT_RX = /^(\d+)\s*([a-z]+)$/; + +// Absolute-range parser. Canonical separator is " - " (space-dash-space) +// since it's easy to type and not conflicting with the date format's +// internal dashes. We also accept `→`, `->`, and ` to ` as input tolerance +// — output always uses " - ". Times are interpreted in the user's local +// timezone — that's what `new Date(y, m, d, h, min)` does by default and +// matches the chip's display. +const RANGE_SEPARATOR_RX = /\s+(?:-|→|->|to)\s+/i; +const ABS_DT_RX = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/; + +function parseAbsoluteDatetime(s: string): Date | null { + const m = s.trim().match(ABS_DT_RX); + if (!m) return null; + const [, y, mo, d, h, mi, se] = m; + const dt = new Date( + Number(y), + Number(mo) - 1, + Number(d), + Number(h ?? "0"), + Number(mi ?? "0"), + Number(se ?? "0"), + ); + return isNaN(dt.getTime()) ? null : dt; +} + +function parseAbsoluteRange(input: string): Timeframe | null { + // Split on the first separator match; if there's no separator we're + // not looking at an absolute range and fall through to shortcuts. + const parts = input.split(RANGE_SEPARATOR_RX); + if (parts.length !== 2) return null; + const start = parseAbsoluteDatetime(parts[0]); + const end = parseAbsoluteDatetime(parts[1]); + if (!start || !end || start >= end) return null; + return { start, end, label: "Custom" }; +} + +/** Build a now-anchored relative timeframe with the given preset label. */ +export function relativeTimeframe( + rangeSeconds: number, + label: string, +): Timeframe { + const end = new Date(); + const start = new Date(end.getTime() - rangeSeconds * 1000); + return { start, end, label }; +} + +export function defaultTimeframe(): Timeframe { + return relativeTimeframe(7 * 24 * 60 * 60, "Past 1 week"); +} + +/** Parse user input into a Timeframe. Accepts three forms, tried in + * order: absolute range (`YYYY-MM-DD HH:MM - YYYY-MM-DD HH:MM`), preset + * label or shortcut (`Past 1 week`, `1w`), and ad-hoc shortcut (`45m`). + * Returns null if none match. */ +export function parseTimeframeInput(input: string): Timeframe | null { + const raw = input.trim(); + if (!raw) return null; + + // Absolute range first — it's the only form that can contain + // whitespace internally, so the cheap match is unambiguous. + const abs = parseAbsoluteRange(raw); + if (abs) return abs; + + const s = raw.toLowerCase(); + for (const p of TIMEFRAME_PRESETS) { + if (s === p.label.toLowerCase() || s === p.shortcut) { + return relativeTimeframe(p.rangeSeconds, p.label); + } + } + const m = s.match(SHORTCUT_RX); + if (m) { + const n = parseInt(m[1], 10); + const mult = UNIT_SECONDS[m[2]]; + if (n > 0 && mult) { + const seconds = n * mult; + const label = `Past ${formatDuration(seconds)}`; + return relativeTimeframe(seconds, label); + } + } + return null; +} + +function formatDuration(s: number): string { + if (s % 604800 === 0) return plural(s / 604800, "week"); + if (s % 86400 === 0) return plural(s / 86400, "day"); + if (s % 3600 === 0) return plural(s / 3600, "hour"); + if (s % 60 === 0) return plural(s / 60, "minute"); + return plural(s, "second"); +} + +function plural(n: number, unit: string): string { + return `${n} ${unit}${n === 1 ? "" : "s"}`; +} + +/** Compact local-time formatting for the chip's range display. Shows the + * date prefix only when the range crosses a day boundary, so a 1-hour + * range reads "1:00 PM - 2:00 PM" without redundant "Jun 10" on both + * sides. */ +function formatRange(start: Date, end: Date): string { + const sameDay = + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate(); + const time: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + }; + const dateTime: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }; + if (sameDay) { + const date = start.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + return `${date}, ${start.toLocaleTimeString(undefined, time)} - ${end.toLocaleTimeString(undefined, time)}`; + } + return `${start.toLocaleString(undefined, dateTime)} - ${end.toLocaleString(undefined, dateTime)}`; +} + +/** Editable form: `YYYY-MM-DD HH:MM - YYYY-MM-DD HH:MM`, local time. Round + * trips through `parseAbsoluteRange` so what the user sees in the input + * is exactly what they'd type to reproduce it. */ +function formatRangeForInput(start: Date, end: Date): string { + return `${formatLocalDT(start)} - ${formatLocalDT(end)}`; +} + +function formatLocalDT(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const h = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return `${y}-${m}-${day} ${h}:${min}`; +} + +interface TimeframePickerProps { + value: Timeframe; + onChange: (next: Timeframe) => void; + className?: string; +} + +export function TimeframePicker({ + value, + onChange, + className, +}: TimeframePickerProps) { + const [editing, setEditing] = useState(false); + const [input, setInput] = useState(""); + const [error, setError] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!editing) return; + // Always seed with the absolute range so the user can tweak either + // side directly. Typing a shortcut like `1h` still works — the + // parser tries the absolute form first, then falls back to presets + // and shortcuts. + setInput(formatRangeForInput(value.start, value.end)); + setError(false); + const t = setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + return () => clearTimeout(t); + }, [editing, value]); + + useEffect(() => { + if (!editing) return; + function onMouseDown(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setEditing(false); + setError(false); + } + } + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [editing]); + + function commit(tf: Timeframe) { + onChange(tf); + setEditing(false); + setError(false); + } + + function tryCommit() { + const parsed = parseTimeframeInput(input); + if (parsed === null) { + setError(true); + return; + } + commit(parsed); + } + + if (!editing) { + return ( + + ); + } + + return ( +
+ { + setInput(e.target.value); + setError(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + tryCommit(); + } else if (e.key === "Escape") { + setEditing(false); + setError(false); + } + }} + placeholder="1h, 2d, or 2026-06-03 14:00 - 2026-06-08 09:00" + className={cn( + "h-12 font-mono text-xs", + error && "border-destructive focus-visible:ring-destructive", + )} + /> +
+ {TIMEFRAME_PRESETS.map((p) => ( + + ))} +
+
+ ); +} diff --git a/dashboard/src/components/trips/TripDetailsDialog.tsx b/dashboard/src/components/trips/TripDetailsDialog.tsx deleted file mode 100644 index 254a0fd1..00000000 --- a/dashboard/src/components/trips/TripDetailsDialog.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Trip, initTrip } from "@/models/trip"; -import { formatTime } from "@/lib/utils"; -import { Clock, MapPin, Hash, Edit2 } from "lucide-react"; -import { Separator } from "@/components/ui/separator"; -import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; -import { notify } from "@/lib/notify"; -import { BACKEND_URL } from "@/consts/config"; -import axios from "axios"; -import { getAxiosErrorMessage } from "@/lib/axios-error-handler"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { useState, ChangeEvent } from "react"; -import { OutlineButton } from "@/components/ui/outline-button"; -import { useVehicle } from "@/lib/store"; - -interface TripDetailsDialogProps { - trip: Trip; - tripDetailsOpen: boolean; - setTripDetailsOpen: (open: boolean) => void; -} - -export function TripDetailsDialog({ - trip, - tripDetailsOpen, - setTripDetailsOpen, -}: TripDetailsDialogProps) { - const vehicle = useVehicle(); - - const [isEditing, setIsEditing] = useState(false); - const [editedTrip, setEditedTrip] = useState(initTrip); - - // Calculate duration in milliseconds - const duration = () => { - const start = new Date(trip.start_time).getTime(); - const end = new Date(trip.end_time).getTime(); - return end - start; - }; - - const updateTrip = async () => { - try { - const response = await axios.post( - `${BACKEND_URL}/sessions/${trip.id}`, - editedTrip, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("sentinel_access_token")}`, - }, - }, - ); - if (response.status == 200) { - notify.success("Updated trip successfully"); - setIsEditing(false); - window.location.reload(); - } - } catch (error) { - notify.error(getAxiosErrorMessage(error)); - } - }; - - const handleEdit = () => { - setEditedTrip(trip); - setIsEditing(true); - }; - - const handleSave = () => { - if (!editedTrip.name.toString().trim()) { - notify.error("Trip name cannot be empty"); - return; - } - updateTrip(); - }; - - const handleNameChange = (e: ChangeEvent) => { - setEditedTrip({ ...editedTrip, name: e.target.value }); - }; - - const handleDescriptionChange = (e: ChangeEvent) => { - setEditedTrip({ ...editedTrip, description: e.target.value }); - }; - - const handleOpenChange = (open: boolean) => { - if (!open && isEditing) { - setIsEditing(false); - setEditedTrip(initTrip); - } - setTripDetailsOpen(open); - }; - - return ( - - Trip Details Dialog - - - - {isEditing ? ( - - ) : ( - {trip.name} - )} -
- {isEditing ? ( - <> - Save - - ) : ( - - )} -
-
- -
-

Trip Vehicle

- -
-
- - {vehicle?.type} - -
-
-
- -
-
-

{vehicle?.name}

-

{vehicle?.id}

- -

- {vehicle?.description} -

-
-
-
-
-
-
-
-

Trip Information

-
-
- - - Trip ID: - - {trip.id} -
-
- - - Duration: - - - {formatTime(duration())} ({duration()} ms) - -
-
- - - Start Time: - - - {new Date(trip.start_time).toLocaleString()} - -
-
- - - End Time: - - - {new Date(trip.end_time).toLocaleString()} - -
-
-

- Description: -

- {isEditing ? ( -