Compare commits
No commits in common. "feat/staging-fleetops-architecture" and "main" have entirely different histories.
feat/stagi
...
main
66 changed files with 12098 additions and 4236 deletions
|
|
@ -1,39 +0,0 @@
|
|||
# SEC-04: keep secrets and bulk artefacts out of image layers.
|
||||
# The Dockerfile ends with `COPY . .` — everything not listed here ships in the image.
|
||||
|
||||
# Secrets — never in an image layer
|
||||
.env
|
||||
.env.*
|
||||
*.pw
|
||||
|
||||
# VCS / local tooling
|
||||
.git
|
||||
.gitignore
|
||||
.claude
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
*.code-workspace
|
||||
|
||||
# Bulk data artefacts (rebuildable / operator-side only)
|
||||
*.osm.pbf
|
||||
*.geojson
|
||||
csv/
|
||||
data/
|
||||
tools/data/
|
||||
shell_stations.csv
|
||||
|
||||
# Not needed at runtime
|
||||
docs/
|
||||
tests/
|
||||
agents/
|
||||
db_audit/
|
||||
legacy/
|
||||
SOUL.md
|
||||
README.md
|
||||
CLAUDE.md
|
||||
uv.lock.bak
|
||||
2
.env
2
.env
|
|
@ -11,5 +11,7 @@ POSTGRES_PASSWORD=U1pm3f5SX34DXkHoW6aKFsBHOlMA9binDPNG4aT0FAcg7AubEvYm0e6kU2dZiY
|
|||
DATABASE_URL=postgres://postgres:U1pm3f5SX34DXkHoW6aKFsBHOlMA9binDPNG4aT0FAcg7AubEvYm0e6kU2dZiYrR@timescale_db:5432/tracksolid_db
|
||||
|
||||
# Grafana
|
||||
GRAFANA_ADMIN_PASSWORD=ed3aaf20707fb5af9185708ec27f5211f71b35067277993eab624abce1
|
||||
GRAFANA_DB_RO_PASSWORD=7942a1DeLgyuiCzh8XFH21sPVJqRJo737qDW1PNDEtM
|
||||
|
||||
API_BASE_URL=https://eu-open.tracksolidpro.com/route/rest
|
||||
|
|
|
|||
102
CLAUDE.md
102
CLAUDE.md
|
|
@ -28,7 +28,7 @@ docker exec -i $DB psql -U postgres -d tracksolid_db < migrations/07_your_migrat
|
|||
|
||||
Fleet telematics ingestion and analytics stack for a **telco first-line support client** operating in Nairobi, Mombasa, and Kampala. The client dispatches field technicians to install, repair, and maintain home and business broadband, handle LOS signal faults, service migrations, and maintain outside plant infrastructure. The fleet is ~80 vehicles across three cities, all tracked via Tracksolid Pro (Jimi IoT API).
|
||||
|
||||
This repository ingests the Tracksolid Pro API into a TimescaleDB/PostGIS database and serves it to the FleetNow / FleetOps SPAs (own repos) via the `dashboard_api` read layer. The pipeline is deployed on Coolify at `stage.rahamafresh.com`. (Grafana was retired 2026-06-10 — FleetOps now owns KPI visualisation.)
|
||||
This repository ingests the Tracksolid Pro API into a TimescaleDB/PostGIS database and visualises fleet and operational KPIs in Grafana. The pipeline is deployed on Coolify at `stage.rahamafresh.com`.
|
||||
|
||||
**Repository:** `https://repo.rahamafresh.com/kianiadee/tracksolid_timescale_grafana_prod.git`
|
||||
|
||||
|
|
@ -38,11 +38,12 @@ This repository ingests the Tracksolid Pro API into a TimescaleDB/PostGIS databa
|
|||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Ingestion | Python 3.12 — `ingest_worker_rev.py` (merged movement + events poller), `webhook_receiver_rev.py` |
|
||||
| Ingestion | Python 3.12 — `ingest_movement_rev.py`, `ingest_events_rev.py`, `webhook_receiver_rev.py` |
|
||||
| Shared utils | `ts_shared_rev.py` — token cache, DB pool, API signing, clean helpers |
|
||||
| Database | PostgreSQL 16 + TimescaleDB 2.15 + PostGIS 3 (`tracksolid_db`) |
|
||||
| Orchestration | Docker Compose on Coolify |
|
||||
| Visualisation | FleetOps / FleetNow SPAs (own repos) via `dashboard_api`. Grafana **removed** 2026-06-10 (was redundant — service, provisioning, and runbooks all deleted) |
|
||||
| Visualisation | Grafana (provisioned via custom image) |
|
||||
| Workflow automation | n8n |
|
||||
| API source | Tracksolid Pro / Jimi IoT Open Platform (`eu-open.tracksolidpro.com/route/rest`) |
|
||||
| Backup | pg_dump sidecar → rustfs S3 (`fleet-db` bucket), nightly |
|
||||
| Version control | Forgejo at `repo.rahamafresh.com` |
|
||||
|
|
@ -54,9 +55,10 @@ This repository ingests the Tracksolid Pro API into a TimescaleDB/PostGIS databa
|
|||
See `docs/CONNECTIONS.md` for the full shape. Summary:
|
||||
|
||||
- **SSH:** `ssh -i ~/.ssh/id_ed25519 kianiadee@stage.rahamafresh.com`
|
||||
- **DB name:** `tracksolid_db` · **DB user:** `postgres` (internal) · `tracksolid_owner` (app) · `dashboard_ro` (read-only, used by the staging bridge) · `grafana_ro` (legacy read-only role, retained but no longer used by any active service)
|
||||
- **DB name:** `tracksolid_db` · **DB user:** `postgres` (internal) · `tracksolid_owner` (app) · `grafana_ro` (read-only)
|
||||
- **DB schemas:** `tracksolid` (live, single source of truth) · `reporting` (map-dashboard read layer) · `infrastructure`. The legacy `tracksolid_2` schema no longer exists (migrations 02–06, 2026-04-18); the `ops` and `dwh_gold` schemas were purged 2026-06-05 (migrations 12/13) as unused.
|
||||
- **DB access:** `DATABASE_URL` points to `timescale_db:5432` (internal Docker network — not reachable locally). Use `docker exec` pattern above. See `docs/CONNECTIONS.md` for full reference.
|
||||
- **DWH target DB:** `tracksolid_dwh` at `31.97.44.246:5888` (separate PostGIS instance, public IP). Users: `dwh_owner` (bronze writes + `dwh_control`), `grafana_ro` (reads bronze/silver/gold/`dwh_control`). Always connect with `sslmode=require`. Fed by the n8n `dwh_extract` + `dwh_load_bronze` workflows — see `docs/DWH_PIPELINE.md`.
|
||||
- **Container naming:** Coolify appends a random suffix. Always resolve with:
|
||||
```bash
|
||||
docker ps --filter name=<service_name> --format "{{.Names}}" | head -1
|
||||
|
|
@ -66,35 +68,22 @@ See `docs/CONNECTIONS.md` for the full shape. Summary:
|
|||
|
||||
### Map dashboards & read-API
|
||||
|
||||
The UIs read the **`dashboard_api`** service (FastAPI, `dashboard_api_rev.py`) at
|
||||
`https://fleetapi.rahamafresh.com` — the stable read-API for the map dashboards. It serves
|
||||
The map UIs read the **`dashboard_api`** service (FastAPI, `dashboard_api_rev.py`) at
|
||||
`https://fleetapi.rahamafresh.com` — the stable replacement for the retired n8n webhooks. It serves
|
||||
GeoJSON from the `reporting.*` functions (`fn_live_positions`, `fn_vehicle_track`, `fn_trips_for_map`)
|
||||
+ filter options, **plus the `/analytics/*` read endpoints** (fleet-summary, utilisation,
|
||||
driver-behaviour, fuel, filters) that power FleetOps. **`dashboard_api` is a STANDALONE
|
||||
Traefik-labelled bridge container, NOT Coolify-managed** — it bind-mounts the host file
|
||||
`~/dashboard_api/dashboard_api_rev.py` and is (re)deployed by `~/deploy_dashboard_api.sh` on the host
|
||||
(an env/CORS change needs a *recreate*, not a restart). The SPAs that consume it:
|
||||
+ filter options. **`dashboard_api` is a STANDALONE Traefik-labelled bridge container, NOT Coolify-managed** —
|
||||
it bind-mounts the host file `~/dashboard_api/dashboard_api_rev.py` and is (re)deployed by
|
||||
`~/deploy_dashboard_api.sh` on the host (an env/CORS change needs a *recreate*, not a restart). Three
|
||||
single-page apps consume it:
|
||||
|
||||
| Dashboard | What | Hosting |
|
||||
|---|---|---|
|
||||
| `liveposition.rahamafresh.com` | live positions only | `index.html` in rustfs bucket `liveposition` behind an nginx proxy |
|
||||
| `fleetintelligence.rahamafresh.com` | historical trips only | `index.html` in rustfs bucket `fleetintelligence` behind an nginx proxy |
|
||||
| `fleetnow.rahamafresh.com` | **merged** live + trips map (fleet *tracking*) | **own repo** `repo.rahamafresh.com/kianiadee/fleetnow.git`, **Coolify (Dockerfile → nginx)** |
|
||||
| `fleetops.rahamafresh.com` | fuel / analytics / KPIs (fleet *operations*) | **own repo** `repo.rahamafresh.com/kianiadee/fleetops.git` (local `~/Downloads/projects/15_fleetops`), **Coolify (Dockerfile → Caddy)** |
|
||||
| `fleetnow.rahamafresh.com` | **merged** live + trips (current best UI) | **own repo** `repo.rahamafresh.com/kianiadee/fleetnow.git`, deployed via **Coolify (Dockerfile → nginx)** |
|
||||
|
||||
All prod origins must be in the API's `DASHBOARD_CORS_ORIGINS` (see FIX-D03). **FleetNow and FleetOps
|
||||
each live in their own repos — edit them there, not here.**
|
||||
|
||||
**Staging (the `fivetitude.com` wildcard umbrella).** A parallel staging stack mirrors the above so the
|
||||
frozen prod apps are never edited directly: `fleetnow.fivetitude.com`, `fleetops.fivetitude.com`, and a
|
||||
**second `dashboard_api` bridge** at `fleetapi.fivetitude.com` (port **8891**,
|
||||
`deploy_dashboard_api_staging.sh` in this repo). The staging bridge reads the **same prod DB** as the
|
||||
dedicated **read-only `dashboard_ro`** role (`scripts/dashboard_ro_role.sql` + `bootstrap_dashboard_ro.sh`),
|
||||
with the `v_trips` refresher disabled (`VTRIPS_REFRESH_INTERVAL_S=0`) — prod owns the refresh. Each SPA's
|
||||
**API base is injected per-environment at container start** (FleetOps via Caddy `templates` → `/env.js`;
|
||||
FleetNow via an nginx `envsubst` entrypoint → `/env.js`), falling back to the prod API if unset. Deploys
|
||||
are **Forgejo → Coolify webhooks**. Full topology + runbooks: **`docs/STAGING_FLEETOPS_ARCHITECTURE.md`**
|
||||
and the fleetops repo's `docs/webhook-auto-deploy.html`.
|
||||
All three origins must be in the API's `DASHBOARD_CORS_ORIGINS` (see FIX-D03). **FleetNow is the
|
||||
single source of truth for the merged map and lives in its own repo — edit it there, not here.**
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -102,39 +91,40 @@ and the fleetops repo's `docs/webhook-auto-deploy.html`.
|
|||
|
||||
```
|
||||
ts_shared_rev.py # Shared: config, signing, DB pool, token cache, clean helpers
|
||||
ingest_worker_rev.py # Merged poller entrypoint — runs movement + events in one process (the deployed `ingest_worker` service)
|
||||
ingest_movement_rev.py # GPS positions, trips, parking, track-list (high-res trail), device sync. main() split into startup_catchup()/register_jobs() for reuse; standalone entrypoint still works
|
||||
ingest_events_rev.py # Alarm events polling (fallback for webhook push). Same startup_catchup()/register_jobs() split
|
||||
ingest_movement_rev.py # GPS positions, trips, parking, track-list (high-res trail), device sync
|
||||
ingest_events_rev.py # Alarm events polling (fallback for webhook push)
|
||||
webhook_receiver_rev.py # FastAPI push receiver: /pushobd /pushevent /pushtripreport etc.
|
||||
sync_driver_audit.py # One-shot: API↔DB driver/IMEI gap report + full upsert
|
||||
import_drivers_csv.py # One-shot: populate 144 X3/JC400P devices from CSV (--apply to commit)
|
||||
run_migrations.py # Applies SQL migrations in order at container startup
|
||||
docker-compose.yaml # Services (4 app + db, was 7): timescale_db, ingest_worker,
|
||||
# webhook_receiver, dashboard_api, db_backup.
|
||||
# pgbouncer + grafana REMOVED 2026-06-10.
|
||||
docker-compose.yaml # Services: timescale_db, ingest_movement, ingest_events,
|
||||
# webhook_receiver, grafana
|
||||
grafana/ # Grafana provisioning (baked into image)
|
||||
n8n-workflows/ # n8n workflow exports (incl. dwh_extract, dwh_load_bronze)
|
||||
docs/ # Reference docs (connections, API, KPIs, project context)
|
||||
docs/PLATFORM_OVERVIEW.html # Current-state platform reference (architecture, deploy, read-API,
|
||||
# full DB schema) — open in a browser.
|
||||
# full DB schema, Grafana panels) — open in a browser. Post n8n→fleetapi.
|
||||
docs/DWH_PIPELINE.md # DWH pipeline operations runbook (setup, troubleshooting)
|
||||
docs/OSM_POI_EXPORT.md # Runbook: OSM .pbf → POI GeoJSON → FleetNow map layer (Shell stations)
|
||||
docs/superpowers/ # Pitch specs and implementation plans (not deployed code)
|
||||
scripts/export_osm_pois.py # OSM .pbf → GeoJSON+CSV POI exporter (amenity/brand filter); see OSM_POI_EXPORT.md
|
||||
migrations/ # Numbered SQL migrations 02–20, applied in order by run_migrations.py
|
||||
dwh/ # DWH migrations for tracksolid_dwh@31.97.44.246:5888
|
||||
# 260423_dwh_ddl_v1.sql — bronze/silver/gold schemas + roles
|
||||
# 261001_dwh_control.sql — watermarks + run log
|
||||
# 261002_bronze_constraints_audit.sql — ON CONFLICT key assertion
|
||||
# 261003_dwh_roles.sql — role contract assertion
|
||||
# 261004_dwh_observability_views.sql — freshness/failure views
|
||||
migrations/ # Numbered SQL migrations 02–13, applied in order by run_migrations.py
|
||||
# 02 full schema · 03 webhook · 04 distance fix · 05 enhancements
|
||||
# 06 ops/analytics · 07 views · 08 config · 09 trips enrichment
|
||||
# 10_driver_clock_views.sql · 10_pgbouncer_auth.sql · 11 reporting
|
||||
# 12 drop ops schema · 13 drop dwh_gold schema (both 2026-06-05)
|
||||
# 14 fleet segment · 15 map exclude cc · 16 live feed vehicle_type
|
||||
# 17 reporting.v_fuel_daily (FleetOps) · 18 grant reporting.* to grafana_ro
|
||||
# 19 reporting.v_ingest_health (pipeline freshness; replaces Grafana panels)
|
||||
# 20 restore live-feed (re-assert mig-15 exclusion + mig-16 vehicle_type after a mig-11 re-apply clobber)
|
||||
deploy_dashboard_api_staging.sh # Staging dashboard_api bridge (8891, fleetapi.fivetitude.com); see STAGING_FLEETOPS_ARCHITECTURE.md
|
||||
scripts/dashboard_ro_role.sql + bootstrap_dashboard_ro.sh # Dedicated read-only DB role for the staging bridge
|
||||
Dockerfile # Custom image for ingest/webhook containers
|
||||
pyproject.toml # Python project + uv dependency spec
|
||||
backup/ # pg_dump sidecar scripts and config
|
||||
data/ # Source CSVs (FS Logistics 144-device list, FSG vehicles)
|
||||
legacy/ # Superseded pre-_rev scripts + old pipeline notes (NOT deployed)
|
||||
docs/manuals/ # OPERATIONS_MANUAL, docker commands, DB manual
|
||||
docs/manuals/ # OPERATIONS_MANUAL, grafana + DWH manuals, docker commands, DB manual
|
||||
docs/reference/ # 01_BusinessAnalytics.md (SQL library — read before writing queries),
|
||||
# tracksolidApiDocumentation.md, 260507_pgbouncer_deployment.md
|
||||
docs/reports/ # Baseline reports, audit output, improvement reviews
|
||||
|
|
@ -148,7 +138,7 @@ docs/reports/ # Baseline reports, audit output, improvement review
|
|||
tracksolid.devices -- Device / driver / vehicle registry (63 rows; 0 driver_name populated)
|
||||
-- IMEI mix: 353549* AT4 (23), 862798* X3/JC400P (23), 865135* X3/JC400P (10), 359857* (7)
|
||||
-- Full CSV (144 devices) not yet imported — run import_drivers_csv.py --apply
|
||||
tracksolid.live_positions -- Current fix per IMEI (19 rows; refreshed every 60s by ingest_worker / movement pipeline)
|
||||
tracksolid.live_positions -- Current fix per IMEI (19 rows; refreshed every 60s by ingest_movement)
|
||||
tracksolid.position_history -- All GPS fixes (hypertable, partitioned by gps_time). ~519 rows (308 track_list + 211 poll).
|
||||
-- pg_stat_user_tables shows 0 for hypertables — always COUNT(*) directly.
|
||||
-- source: 'poll' (60s sweep) | 'track_list' (30m high-res)
|
||||
|
|
@ -158,12 +148,13 @@ tracksolid.alarms -- Alarm events (alarm_type, alarm_name, alarm_time
|
|||
tracksolid.obd_readings -- OBD diagnostics (push only, awaiting webhook registration)
|
||||
tracksolid.device_events -- Power on/off tamper events (push only)
|
||||
tracksolid.ingestion_log -- API call audit trail — 875 runs / 24h, 0 failures at last check (2026-04-19)
|
||||
tracksolid.schema_migrations -- Applied migrations 02–20 (20 restores live-feed exclusion/vehicle_type, 2026-06-10)
|
||||
tracksolid.schema_migrations -- Applied migrations 02–13
|
||||
-- PURGED 2026-06-05 (migrations 12 + 13): the dormant `ops` schema (tickets, service_log,
|
||||
-- odometer_readings, cost_rates, kpi_targets, vw_service_forecast), tracksolid.dispatch_log,
|
||||
-- and the `dwh_gold` schema (dim_vehicles, fact_daily_fleet_metrics, refresh_daily_metrics).
|
||||
-- Those workshop/dispatch/SLA/utilisation features were never implemented. Do NOT reintroduce
|
||||
-- references to ops.* or dwh_gold.* — they no longer exist.
|
||||
-- references to ops.* or dwh_gold.* — they no longer exist. (The separate tracksolid_dwh DB
|
||||
-- at 31.97.44.246:5888 is unrelated and untouched.)
|
||||
```
|
||||
|
||||
Full DDL: `02_tracksolid_full_schema_rev.sql` + migrations `03`–`06`.
|
||||
|
|
@ -184,6 +175,25 @@ tracksolid.v_alarms_daily -- §7 Panel 7 alarm frequency
|
|||
|
||||
All views carry a `COMMENT ON VIEW` referencing their spec — `\d+ tracksolid.v_*` shows the provenance.
|
||||
|
||||
**DWH bronze layer (separate DB `tracksolid_dwh`)** — populated by the n8n `dwh_extract` + `dwh_load_bronze` workflows. Operational details in `docs/DWH_PIPELINE.md`.
|
||||
|
||||
```sql
|
||||
-- bronze schema mirrors tracksolid.* (16 tables, DDL in dwh/260423_dwh_ddl_v1.sql)
|
||||
bronze.devices, bronze.live_positions -- snapshot tables (TRUNCATE + reload)
|
||||
bronze.position_history, bronze.trips,
|
||||
bronze.alarms, bronze.parking_events,
|
||||
bronze.device_events, bronze.ingestion_log -- incremental (watermark + ON CONFLICT DO NOTHING)
|
||||
-- Schema drift: bronze.trips.distance_km vs source tracksolid.trips.distance_m
|
||||
-- Extract SQL divides by 1000. Cross-ref FIX-M16.
|
||||
|
||||
-- dwh_control schema tracks pipeline state + observability
|
||||
dwh_control.extract_watermarks -- one row per incremental table
|
||||
dwh_control.extract_runs -- per-run audit log (status lifecycle)
|
||||
dwh_control.v_table_freshness -- Grafana: load lag per table
|
||||
dwh_control.v_recent_failures -- Grafana: failures in last 24h
|
||||
dwh_control.v_watermark_lag -- Grafana: extract vs. load lag per table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API Critical Facts
|
||||
|
|
@ -263,7 +273,7 @@ Latest full snapshot: `docs/reports/260412_baseline_report.md`
|
|||
|
||||
| Priority | Item |
|
||||
|---|---|
|
||||
| LOW | Pipeline freshness is surfaced by `reporting.v_ingest_health` (migration 19) via dashboard_api `GET /health/ingest` — wire it into a FleetOps panel. |
|
||||
| HIGH | **Redeploy the Grafana service in Coolify** to apply `daily_operations_dashboard.json` — 5 panel areas (In-flight SLA, Idle Cost, Utilisation Heatmap, Row 7 Field-Service SLAs) that queried the now-dropped `v_sla_inflight`/`v_utilisation_daily` were removed. The DB views are already gone, so **live Grafana shows errors on those panels until the redeploy** (purge commit `8c5a43f`, 2026-06-05). |
|
||||
| HIGH | Run `import_drivers_csv.py --apply` — 144 X3/JC400P devices with names + plates waiting |
|
||||
| HIGH | Register webhooks: `/pushoil` `/pushtem` `/pushlbs` (auto-register on push now done — commit 257643c) |
|
||||
| HIGH | Investigate X3-63282 in Kampala — legitimate or unauthorised? |
|
||||
|
|
@ -271,3 +281,5 @@ Latest full snapshot: `docs/reports/260412_baseline_report.md`
|
|||
| MEDIUM | Investigate 44 silent devices (only 19 of 63 reporting) — SIM installed? Activated? |
|
||||
| MEDIUM | Co-develop client KPI framework (see `docs/KPI_FRAMEWORK.md`) |
|
||||
| LOW | Populate geofences — depot boundaries, city zones |
|
||||
| HIGH | Deploy DWH bronze pipeline: apply `dwh/26100{1,2,3,4}.sql` to `tracksolid_dwh`, import + wire the two n8n workflows, verify first run via `dwh_control.v_table_freshness`. Runbook: `docs/DWH_PIPELINE.md` |
|
||||
| MEDIUM | Rotate `dwh_owner` / `grafana_ro` passwords on `tracksolid_dwh` — plaintext in `dwh/260423_dwh_ddl_v1.sql` is a pre-existing flaw to clean up separately |
|
||||
|
|
|
|||
13
Dockerfile
13
Dockerfile
|
|
@ -14,15 +14,12 @@ RUN apt-get update && apt-get install -y \
|
|||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files (lockfile pins exact versions — SEC-05)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml .
|
||||
# COPY uv.lock . # Uncomment this once you have generated a lockfile locally
|
||||
|
||||
# Install the locked dependency set into the system environment.
|
||||
# `uv export --frozen` fails the build if uv.lock is out of sync with pyproject.toml,
|
||||
# so image builds are reproducible and can't silently pull newer packages.
|
||||
RUN uv export --frozen --no-dev --no-emit-project --format requirements-txt -o /tmp/requirements.txt \
|
||||
&& uv pip install --system -r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
# Install dependencies into a system-wide environment
|
||||
RUN uv pip install --system -r pyproject.toml
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
|
|
|||
|
|
@ -25,14 +25,6 @@ is the base URL (the `N8N_BASE` constant in each dashboard SPA):
|
|||
cost_centre, assigned_city,
|
||||
start_date, end_date}
|
||||
→ trips payload (reporting.fn_trips_for_map)
|
||||
GET /webhook/tickets?service_type=&status=&open_only=
|
||||
→ { summary, geojson } (reporting.fn_tickets_for_map) — INC/CRQ map layer
|
||||
GET /webhook/{inc,crq}-dashboard?cluster=&status=&window=&from=&to=
|
||||
→ { window, open, closed, metrics, freshness } (reporting.fn_{inc,crq}_dashboard)
|
||||
GET /webhook/{inc,crq}-search?ticket_id=&owner=&cluster=&status=&state=&from=&to=
|
||||
→ { count, truncated, limit, state, rows } (reporting.fn_{inc,crq}_search)
|
||||
GET /webhook/{inc,crq}-filter-options
|
||||
→ { owners, clusters, open_ticket_ids } (reporting.fn_{inc,crq}_filter_options)
|
||||
GET /health
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
|
@ -49,8 +41,7 @@ from urllib.parse import parse_qs
|
|||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
|
@ -77,16 +68,6 @@ _ALLOWED_ORIGINS = [
|
|||
# uvicorn workers (only one worker refreshes per tick); the work runs in a
|
||||
# thread so the async event loop never blocks on the ~9s REFRESH.
|
||||
_DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
# The request pool (get_conn / DATABASE_URL) can be a READ-ONLY role
|
||||
# (dashboard_ro) — least privilege for serving. The v_trips refresher needs write
|
||||
# perms (it owns the REFRESH), so it connects via a SEPARATE privileged URL:
|
||||
# REFRESH_DATABASE_URL if set, else DATABASE_URL (single-role / legacy deploys).
|
||||
# So a prod bridge runs DATABASE_URL=dashboard_ro + REFRESH_DATABASE_URL=<app role>.
|
||||
_REFRESH_DB_URL = os.getenv("REFRESH_DATABASE_URL") or _DATABASE_URL
|
||||
# VTRIPS_REFRESH_INTERVAL_S <= 0 disables the in-process refresher entirely.
|
||||
# Staging sets it to 0: it connects read-only and prod owns the refresh, so a
|
||||
# staging instance must never attempt REFRESH (it would only log permission
|
||||
# errors). Prod keeps the 300s default.
|
||||
_REFRESH_INTERVAL_S = int(os.getenv("VTRIPS_REFRESH_INTERVAL_S", "300"))
|
||||
_REFRESH_LOCK_KEY = 920_145 # arbitrary, stable advisory-lock key for this job
|
||||
|
||||
|
|
@ -96,11 +77,10 @@ def _refresh_v_trips_once() -> str:
|
|||
|
||||
Uses a dedicated autocommit connection: REFRESH ... CONCURRENTLY cannot run
|
||||
inside a transaction block (so the pooled get_conn, which wraps a txn, won't
|
||||
do). Connects via _REFRESH_DB_URL (REFRESH_DATABASE_URL or DATABASE_URL) — a
|
||||
privileged role that may REFRESH the matview, distinct from a read-only
|
||||
request pool.
|
||||
do). DATABASE_URL connects as a superuser, which may REFRESH the matview
|
||||
even though reporting_refresher owns it.
|
||||
"""
|
||||
conn = psycopg2.connect(_REFRESH_DB_URL, connect_timeout=10)
|
||||
conn = psycopg2.connect(_DATABASE_URL, connect_timeout=10)
|
||||
try:
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
|
|
@ -139,26 +119,17 @@ async def _refresh_loop():
|
|||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
refresher = None
|
||||
if _REFRESH_INTERVAL_S > 0:
|
||||
log.info(
|
||||
"Dashboard API starting (v1.2). Origins=%s. v_trips refresh every %ss.",
|
||||
_ALLOWED_ORIGINS, _REFRESH_INTERVAL_S,
|
||||
)
|
||||
refresher = asyncio.create_task(_refresh_loop())
|
||||
else:
|
||||
log.info(
|
||||
"Dashboard API starting (v1.2). Origins=%s. v_trips refresher DISABLED "
|
||||
"(VTRIPS_REFRESH_INTERVAL_S<=0) — read-only / staging mode.",
|
||||
_ALLOWED_ORIGINS,
|
||||
)
|
||||
log.info(
|
||||
"Dashboard API starting (v1.1). Origins=%s. v_trips refresh every %ss.",
|
||||
_ALLOWED_ORIGINS, _REFRESH_INTERVAL_S,
|
||||
)
|
||||
refresher = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
if refresher is not None:
|
||||
refresher.cancel()
|
||||
try:
|
||||
await refresher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
refresher.cancel()
|
||||
try:
|
||||
await refresher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
close_pool()
|
||||
|
||||
|
||||
|
|
@ -188,36 +159,6 @@ def health():
|
|||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Ingest pipeline freshness ────────────────────────────────────────────────
|
||||
# Replaces the Grafana pipeline-health panels (Grafana removed 2026-06-10).
|
||||
# Reads reporting.v_ingest_health (migration 19) — one row per ingest endpoint
|
||||
# with last-run age + freshness verdict (ok|stale|error). Lets FleetOps show
|
||||
# whether the ingest_worker pollers are alive without a separate dashboard product.
|
||||
@app.get("/health/ingest")
|
||||
def ingest_health():
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM reporting.v_ingest_health")
|
||||
rows = cur.fetchall()
|
||||
worst = (
|
||||
"error" if any(r["freshness"] == "error" for r in rows)
|
||||
else "stale" if any(r["freshness"] == "stale" for r in rows)
|
||||
else "ok"
|
||||
) if rows else "unknown"
|
||||
# rows carry last_run_at (datetime) — jsonable_encoder (Decimal→float,
|
||||
# datetime→ISO) before JSONResponse, else json.dumps raises TypeError
|
||||
# and the whole feed 500s into the except below. (260702 fix.)
|
||||
return JSONResponse(jsonable_encoder({"overall": worst, "endpoints": rows}))
|
||||
except Exception:
|
||||
log.exception("ingest-health failed")
|
||||
return JSONResponse(
|
||||
{"overall": "unknown", "endpoints": [],
|
||||
"error": {"type": "unknown",
|
||||
"message": "Ingest-health feed is unavailable. Try again in a few seconds."}}
|
||||
)
|
||||
|
||||
|
||||
# ── Live positions (#004) ───────────────────────────────────────────────────
|
||||
|
||||
@app.get("/webhook/live-positions")
|
||||
|
|
@ -274,298 +215,6 @@ def vehicle_track(vehicle_number: str | None = None, hours: int = 1):
|
|||
return JSONResponse({"error": "vehicle-track unavailable"})
|
||||
|
||||
|
||||
# ── Tickets (FleetOps Tickets map) ───────────────────────────────────────────
|
||||
# INC (incident / customer fault) + CRQ (new-installation) tickets as a GeoJSON
|
||||
# FeatureCollection for the FleetOps Tickets tab (FleetNow-style map). Backed by
|
||||
# reporting.fn_tickets_for_map over tickets.inc / tickets.crq. The schema, ingest,
|
||||
# and that read function are owned by the separate `fleettickets` repo
|
||||
# (repo.rahamafresh.com/kianiadee/fleettickets.git); this endpoint just calls it.
|
||||
# Only geocoded rows are mapped; open_only (default true) restricts to actionable.
|
||||
@app.get("/webhook/tickets")
|
||||
def tickets(
|
||||
service_type: str | None = None, # 'inc' | 'crq' | None (both)
|
||||
status: str | None = None, # normalized_status exact match
|
||||
open_only: bool = True, # actionable tickets only (the map default)
|
||||
):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT reporting.fn_tickets_for_map(%s, %s, %s)",
|
||||
(_clean(service_type), _clean(status), open_only),
|
||||
)
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(
|
||||
{
|
||||
"summary": payload.get("summary") or {},
|
||||
"geojson": payload.get("geojson") or _EMPTY_GEOJSON,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
log.exception("tickets failed")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"type": "unknown",
|
||||
"message": "Ticket feed is unavailable. Try again in a few seconds.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── INC operations dashboard ──────────────────────────────────────────────────
|
||||
# Thin passthrough over reporting.fn_inc_dashboard (fleettickets migration 09):
|
||||
# returns { window, open: GeoJSON, closed: GeoJSON, metrics, freshness } for the
|
||||
# FleetOps live INC map. open = all currently-open INC tickets (not time-filtered);
|
||||
# closed = closures within the selected window; metrics react to the selection.
|
||||
# Vehicle positions/routes are overlaid by the SPA (FleetNow), not this endpoint.
|
||||
_INC_WINDOWS = {"today", "week", "month", "custom"}
|
||||
|
||||
|
||||
def _bad_request(msg):
|
||||
return JSONResponse({"error": {"type": "bad_request", "message": msg}}, status_code=400)
|
||||
|
||||
|
||||
@app.get("/webhook/inc-dashboard")
|
||||
def inc_dashboard(
|
||||
cluster: str | None = None, # exact tickets.inc.cluster (UPPERCASE), blank = all
|
||||
status: str | None = None, # exact normalized_status, blank = all
|
||||
window: str = "today", # today | week | month | custom (calendar EAT)
|
||||
from_: str | None = Query(None, alias="from"), # custom start (inclusive), ISO-8601
|
||||
to: str | None = None, # custom end (exclusive), ISO-8601
|
||||
):
|
||||
# Validation (mirrors the contract). The SQL treats any from/to as a custom
|
||||
# window; presets resolve to EAT calendar bounds inside the function.
|
||||
if window not in _INC_WINDOWS:
|
||||
return _bad_request("window must be one of today|week|month|custom")
|
||||
f, t = _clean(from_), _clean(to)
|
||||
if window == "custom" and not f and not t:
|
||||
return _bad_request("custom window requires from and/or to")
|
||||
|
||||
def _parse(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
pf, pt = _parse(f), _parse(t)
|
||||
if pf is False or pt is False:
|
||||
return _bad_request("from/to must be ISO-8601 timestamps with an offset/Z")
|
||||
if pf and pt and pf >= pt:
|
||||
return _bad_request("from must be earlier than to")
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT reporting.fn_inc_dashboard(%s, %s, %s, %s, %s)",
|
||||
(_clean(cluster), _clean(status), window, f, t),
|
||||
)
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload) # jsonb body returned unchanged
|
||||
except Exception:
|
||||
log.exception("inc-dashboard failed")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"type": "unknown",
|
||||
"message": "INC dashboard is unavailable. Try again in a few seconds.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── INC ticket explorer (search) ──────────────────────────────────────────────
|
||||
# Thin passthrough over reporting.fn_inc_search (fleettickets migration 13): ad-hoc
|
||||
# ticket lookup by id / engineer / cluster / status / state / time, for historical +
|
||||
# current tracking. Returns { count, truncated, limit, state, rows }.
|
||||
_INC_STATES = {"open", "closed", "all"}
|
||||
|
||||
|
||||
@app.get("/webhook/inc-search")
|
||||
def inc_search(
|
||||
ticket_id: str | None = None, # substring match on ticket_id
|
||||
owner: str | None = None, # engineer — case-insensitive substring on owner
|
||||
cluster: str | None = None, # exact tickets.inc.cluster
|
||||
status: str | None = None, # exact normalized_status
|
||||
state: str = "closed", # closed | open | all
|
||||
from_: str | None = Query(None, alias="from"), # closed-at range start (ISO-8601)
|
||||
to: str | None = None, # closed-at range end (exclusive, ISO-8601)
|
||||
):
|
||||
state = (state or "closed").lower()
|
||||
if state not in _INC_STATES:
|
||||
return _bad_request("state must be one of open|closed|all")
|
||||
f, t = _clean(from_), _clean(to)
|
||||
|
||||
def _parse(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
pf, pt = _parse(f), _parse(t)
|
||||
if pf is False or pt is False:
|
||||
return _bad_request("from/to must be ISO-8601 timestamps with an offset/Z")
|
||||
if pf and pt and pf >= pt:
|
||||
return _bad_request("from must be earlier than to")
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT reporting.fn_inc_search(%s, %s, %s, %s, %s, %s, %s)",
|
||||
(_clean(ticket_id), _clean(owner), _clean(cluster), _clean(status), state, f, t),
|
||||
)
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload) # jsonb body returned unchanged
|
||||
except Exception:
|
||||
log.exception("inc-search failed")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"type": "unknown",
|
||||
"message": "Ticket search is unavailable. Try again in a few seconds.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/webhook/inc-filter-options")
|
||||
def inc_filter_options():
|
||||
# Dropdown options for the ticket explorer (engineers, clusters, open ticket ids).
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT reporting.fn_inc_filter_options()")
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload)
|
||||
except Exception:
|
||||
log.exception("inc-filter-options failed")
|
||||
return JSONResponse({"owners": [], "clusters": [], "open_ticket_ids": []})
|
||||
|
||||
|
||||
# ── CRQ operations dashboard (new-installation tickets) ───────────────────────
|
||||
# CRQ mirrors INC: identical payload shape, served by reporting.fn_crq_dashboard /
|
||||
# fn_crq_search / fn_crq_filter_options over tickets.crq (fleettickets migration 16).
|
||||
# Powers the FleetOps Tickets tab's CRQ sub-tab. The INC routes above are unchanged;
|
||||
# these are parallel so the two datasets stay independently observable.
|
||||
@app.get("/webhook/crq-dashboard")
|
||||
def crq_dashboard(
|
||||
cluster: str | None = None, # exact tickets.crq.cluster (UPPERCASE), blank = all
|
||||
status: str | None = None, # exact normalized_status, blank = all
|
||||
window: str = "today", # today | week | month | custom (calendar EAT)
|
||||
from_: str | None = Query(None, alias="from"), # custom start (inclusive), ISO-8601
|
||||
to: str | None = None, # custom end (exclusive), ISO-8601
|
||||
):
|
||||
if window not in _INC_WINDOWS:
|
||||
return _bad_request("window must be one of today|week|month|custom")
|
||||
f, t = _clean(from_), _clean(to)
|
||||
if window == "custom" and not f and not t:
|
||||
return _bad_request("custom window requires from and/or to")
|
||||
|
||||
def _parse(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
pf, pt = _parse(f), _parse(t)
|
||||
if pf is False or pt is False:
|
||||
return _bad_request("from/to must be ISO-8601 timestamps with an offset/Z")
|
||||
if pf and pt and pf >= pt:
|
||||
return _bad_request("from must be earlier than to")
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT reporting.fn_crq_dashboard(%s, %s, %s, %s, %s)",
|
||||
(_clean(cluster), _clean(status), window, f, t),
|
||||
)
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload) # jsonb body returned unchanged
|
||||
except Exception:
|
||||
log.exception("crq-dashboard failed")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"type": "unknown",
|
||||
"message": "CRQ dashboard is unavailable. Try again in a few seconds.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/webhook/crq-search")
|
||||
def crq_search(
|
||||
ticket_id: str | None = None, # substring match on ticket_id
|
||||
owner: str | None = None, # engineer — case-insensitive substring on owner
|
||||
cluster: str | None = None, # exact tickets.crq.cluster
|
||||
status: str | None = None, # exact normalized_status
|
||||
state: str = "closed", # closed | open | all
|
||||
from_: str | None = Query(None, alias="from"), # closed-at range start (ISO-8601)
|
||||
to: str | None = None, # closed-at range end (exclusive, ISO-8601)
|
||||
):
|
||||
state = (state or "closed").lower()
|
||||
if state not in _INC_STATES:
|
||||
return _bad_request("state must be one of open|closed|all")
|
||||
f, t = _clean(from_), _clean(to)
|
||||
|
||||
def _parse(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
pf, pt = _parse(f), _parse(t)
|
||||
if pf is False or pt is False:
|
||||
return _bad_request("from/to must be ISO-8601 timestamps with an offset/Z")
|
||||
if pf and pt and pf >= pt:
|
||||
return _bad_request("from must be earlier than to")
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT reporting.fn_crq_search(%s, %s, %s, %s, %s, %s, %s)",
|
||||
(_clean(ticket_id), _clean(owner), _clean(cluster), _clean(status), state, f, t),
|
||||
)
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload) # jsonb body returned unchanged
|
||||
except Exception:
|
||||
log.exception("crq-search failed")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"type": "unknown",
|
||||
"message": "Ticket search is unavailable. Try again in a few seconds.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/webhook/crq-filter-options")
|
||||
def crq_filter_options():
|
||||
# Dropdown options for the CRQ ticket explorer (engineers, clusters, open ticket ids).
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT reporting.fn_crq_filter_options()")
|
||||
payload = cur.fetchone()[0] or {}
|
||||
return JSONResponse(payload)
|
||||
except Exception:
|
||||
log.exception("crq-filter-options failed")
|
||||
return JSONResponse({"owners": [], "clusters": [], "open_ticket_ids": []})
|
||||
|
||||
|
||||
# ── Fleet trips (#002) ───────────────────────────────────────────────────────
|
||||
|
||||
_FILTER_OPTIONS_SQL = """
|
||||
|
|
@ -618,9 +267,6 @@ def _preset_to_range(period: str | None, start_date, end_date):
|
|||
except ValueError:
|
||||
return default
|
||||
return _d(start_date, today), _d(end_date, today)
|
||||
# generic 'Nd' window (e.g. 7d / 30d / 90d / 365d) — used by the Fuel Log tab
|
||||
if p.endswith("d") and p[:-1].isdigit() and int(p[:-1]) > 0:
|
||||
return today - timedelta(days=int(p[:-1]) - 1), today
|
||||
# default + '7d'
|
||||
return today - timedelta(days=6), today
|
||||
|
||||
|
|
@ -676,467 +322,3 @@ async def fleet_trips(request: Request):
|
|||
return JSONResponse(
|
||||
{"error": {"type": "unknown", "message": "Fleet feed is unavailable. Try again in a few seconds."}}
|
||||
)
|
||||
|
||||
|
||||
# ── FleetOps analytics (#15) ─────────────────────────────────────────────────
|
||||
# Read-only roll-ups powering the FleetOps SPA (fleetops.rahamafresh.com):
|
||||
# utilisation, distance, driver behaviour and fuel. Every query SELECTs the
|
||||
# indexed reporting.* / tracksolid.v_* views and never writes — so the staging
|
||||
# instance serves them against the prod DB as a read-only role. Numeric/date
|
||||
# values come back as Decimal/date from psycopg2, so responses pass through
|
||||
# jsonable_encoder (Decimal→float, date→ISO) before JSONResponse.
|
||||
#
|
||||
# GET /analytics/fleet-summary per-vehicle + per-cost-centre roll-up
|
||||
# GET /analytics/utilisation per-vehicle utilisation + daily fleet trend
|
||||
# GET /analytics/driver-behaviour per-driver speeding / harsh index
|
||||
# GET /analytics/fuel actual vs estimated litres (data-gated)
|
||||
# GET /analytics/filters dropdown options (alias of GET /webhook/fleet-dashboard)
|
||||
#
|
||||
# Shared query params: period (today|7d|30d|custom, default 30d), start_date,
|
||||
# end_date, and optional dims cost_centre / assigned_city / vehicle_number /
|
||||
# driver.
|
||||
|
||||
def _json(obj):
|
||||
"""Serialise dicts that may carry Decimal / date values from psycopg2."""
|
||||
return JSONResponse(jsonable_encoder(obj))
|
||||
|
||||
|
||||
def _analytics_window(period, start_date, end_date):
|
||||
"""Date range for analytics — defaults to a 30-day window (vs the 7d trips default)."""
|
||||
return _preset_to_range(period or "30d", start_date, end_date)
|
||||
|
||||
|
||||
def _dim_filters(cost_centre=None, assigned_city=None, vehicle_number=None, driver=None):
|
||||
"""Optional WHERE fragments shared by the reporting.* analytics views.
|
||||
|
||||
Column names are fixed literals (not user input); only the values are
|
||||
parameterised, so interpolating the fragments into the query is injection-safe.
|
||||
"""
|
||||
clauses, params = [], {}
|
||||
for col, val in (
|
||||
("cost_centre", cost_centre),
|
||||
("assigned_city", assigned_city),
|
||||
("vehicle_number", vehicle_number),
|
||||
("assigned_driver", driver),
|
||||
):
|
||||
v = _clean(val)
|
||||
if v is not None:
|
||||
clauses.append(f"{col} = %({col})s")
|
||||
params[col] = v
|
||||
return clauses, params
|
||||
|
||||
|
||||
def _analytics_error(name):
|
||||
log.exception("%s failed", name)
|
||||
return JSONResponse(
|
||||
{"error": {"type": "unknown",
|
||||
"message": "Analytics feed is unavailable. Try again in a few seconds."}}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/analytics/fleet-summary")
|
||||
def analytics_fleet_summary(
|
||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
||||
vehicle_number: str | None = None, driver: str | None = None,
|
||||
):
|
||||
start, end = _analytics_window(period, start_date, end_date)
|
||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
||||
params |= {"start": start, "end": end}
|
||||
where = " AND ".join(["trip_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT vehicle_number, cost_centre, assigned_city, assigned_driver,
|
||||
count(DISTINCT trip_date) AS active_days,
|
||||
sum(trip_count) AS trips,
|
||||
round(sum(total_km), 1) AS total_km,
|
||||
round(sum(driving_hours), 1) AS driving_hours,
|
||||
round(sum(idle_hours), 1) AS idle_hours,
|
||||
round(100.0 * sum(idle_hours)
|
||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct,
|
||||
round(max(max_speed_kmh)) AS max_speed_kmh
|
||||
FROM reporting.v_daily_summary
|
||||
WHERE {where}
|
||||
GROUP BY vehicle_number, cost_centre, assigned_city, assigned_driver
|
||||
ORDER BY total_km DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT cost_centre,
|
||||
count(DISTINCT vehicle_number) AS vehicles,
|
||||
sum(trip_count) AS trips,
|
||||
round(sum(total_km), 1) AS total_km,
|
||||
round(sum(driving_hours), 1) AS driving_hours,
|
||||
round(sum(idle_hours), 1) AS idle_hours,
|
||||
round(100.0 * sum(idle_hours)
|
||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct
|
||||
FROM reporting.v_daily_summary
|
||||
WHERE {where}
|
||||
GROUP BY cost_centre
|
||||
ORDER BY total_km DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_cc = cur.fetchall()
|
||||
totals = {
|
||||
"vehicles": len({r["vehicle_number"] for r in rows}),
|
||||
"trips": sum(int(r["trips"] or 0) for r in rows),
|
||||
"total_km": round(sum(float(r["total_km"] or 0) for r in rows), 1),
|
||||
"driving_hours": round(sum(float(r["driving_hours"] or 0) for r in rows), 1),
|
||||
"idle_hours": round(sum(float(r["idle_hours"] or 0) for r in rows), 1),
|
||||
}
|
||||
return _json({"window": {"start": str(start), "end": str(end)},
|
||||
"totals": totals, "rows": rows, "by_cost_centre": by_cc})
|
||||
except Exception:
|
||||
return _analytics_error("analytics/fleet-summary")
|
||||
|
||||
|
||||
@app.get("/analytics/utilisation")
|
||||
def analytics_utilisation(
|
||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
||||
vehicle_number: str | None = None, driver: str | None = None,
|
||||
):
|
||||
start, end = _analytics_window(period, start_date, end_date)
|
||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
||||
params |= {"start": start, "end": end}
|
||||
where = " AND ".join(["trip_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT vehicle_number, cost_centre, assigned_city,
|
||||
count(DISTINCT trip_date) AS active_days,
|
||||
round(sum(total_km), 1) AS total_km,
|
||||
round(sum(total_km)
|
||||
/ NULLIF(count(DISTINCT trip_date), 0), 1) AS km_per_active_day,
|
||||
round(sum(driving_hours), 1) AS driving_hours,
|
||||
round(sum(idle_hours), 1) AS idle_hours,
|
||||
round(100.0 * sum(idle_hours)
|
||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct
|
||||
FROM reporting.v_daily_summary
|
||||
WHERE {where}
|
||||
GROUP BY vehicle_number, cost_centre, assigned_city
|
||||
ORDER BY total_km DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_vehicle = cur.fetchall()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT trip_date,
|
||||
count(DISTINCT vehicle_number) AS active_vehicles,
|
||||
round(sum(total_km), 1) AS total_km,
|
||||
round(sum(driving_hours), 1) AS driving_hours,
|
||||
round(sum(idle_hours), 1) AS idle_hours,
|
||||
round(100.0 * sum(idle_hours)
|
||||
/ NULLIF(sum(idle_hours + driving_hours), 0), 1) AS idle_pct
|
||||
FROM reporting.v_daily_summary
|
||||
WHERE {where}
|
||||
GROUP BY trip_date
|
||||
ORDER BY trip_date
|
||||
""",
|
||||
params,
|
||||
)
|
||||
daily_trend = cur.fetchall()
|
||||
return _json({"window": {"start": str(start), "end": str(end)},
|
||||
"by_vehicle": by_vehicle, "daily_trend": daily_trend})
|
||||
except Exception:
|
||||
return _analytics_error("analytics/utilisation")
|
||||
|
||||
|
||||
@app.get("/analytics/driver-behaviour")
|
||||
def analytics_driver_behaviour(
|
||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
||||
assigned_city: str | None = None, driver: str | None = None,
|
||||
):
|
||||
start, end = _analytics_window(period, start_date, end_date)
|
||||
clauses = ["day BETWEEN %(start)s AND %(end)s", "driver_name IS NOT NULL"]
|
||||
params = {"start": start, "end": end}
|
||||
city, drv = _clean(assigned_city), _clean(driver)
|
||||
if city is not None:
|
||||
clauses.append("assigned_city = %(city)s")
|
||||
params["city"] = city
|
||||
if drv is not None:
|
||||
clauses.append("driver_name = %(drv)s")
|
||||
params["drv"] = drv
|
||||
where = " AND ".join(clauses)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT driver_name, assigned_city,
|
||||
count(DISTINCT day) AS active_days,
|
||||
round(sum(km), 1) AS total_km,
|
||||
sum(trips) AS trips,
|
||||
sum(events_80) AS events_80,
|
||||
sum(events_100) AS events_100,
|
||||
sum(events_120) AS events_120,
|
||||
sum(harsh_events) AS harsh_events,
|
||||
round(sum(events_80)::numeric
|
||||
/ NULLIF(sum(km), 0) * 100, 2) AS speeding_per_100km,
|
||||
round(sum(harsh_events)::numeric
|
||||
/ NULLIF(sum(km), 0) * 100, 2) AS harsh_per_100km
|
||||
FROM tracksolid.v_driver_aggregates_daily
|
||||
WHERE {where}
|
||||
GROUP BY driver_name, assigned_city
|
||||
ORDER BY speeding_per_100km DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
# driver_name may be sparsely populated (enrichment via tools/import_drivers_csv
|
||||
# or at source), so this can legitimately return []; it fills in as drivers land.
|
||||
return _json({"window": {"start": str(start), "end": str(end)},
|
||||
"drivers_populated": bool(rows), "rows": rows})
|
||||
except Exception:
|
||||
return _analytics_error("analytics/driver-behaviour")
|
||||
|
||||
|
||||
@app.get("/analytics/fuel")
|
||||
def analytics_fuel(
|
||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
||||
vehicle_number: str | None = None, driver: str | None = None,
|
||||
):
|
||||
start, end = _analytics_window(period, start_date, end_date)
|
||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
||||
params |= {"start": start, "end": end}
|
||||
where = " AND ".join(["trip_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT vehicle_number, cost_centre, assigned_city,
|
||||
round(sum(distance_km), 1) AS total_km,
|
||||
round(sum(actual_fuel_l), 2) AS actual_fuel_l,
|
||||
round(sum(estimated_fuel_l), 2) AS estimated_fuel_l,
|
||||
count(*) FILTER (WHERE actual_fuel_l IS NOT NULL) AS trips_with_actual,
|
||||
count(*) AS trips
|
||||
FROM reporting.v_fuel_daily
|
||||
WHERE {where}
|
||||
GROUP BY vehicle_number, cost_centre, assigned_city
|
||||
ORDER BY total_km DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT bool_or(actual_fuel_l IS NOT NULL) AS actual_available,
|
||||
bool_or(estimated_fuel_l IS NOT NULL) AS estimated_available
|
||||
FROM reporting.v_fuel_daily
|
||||
WHERE {where}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
flags = cur.fetchone() or {}
|
||||
data_status = {
|
||||
"actual_fuel_available": bool(flags.get("actual_available")),
|
||||
"estimated_fuel_available": bool(flags.get("estimated_available")),
|
||||
"notes": [
|
||||
"actual_fuel_l comes from trips.fuel_consumed_l (/pushtripreport webhook).",
|
||||
"estimated_fuel_l needs devices.fuel_100km set per vehicle "
|
||||
"(currently NULL fleet-wide — see CLAUDE.md Open Items).",
|
||||
"Fuel-cost monetisation is unavailable: ops.cost_rates was purged 2026-06-05.",
|
||||
],
|
||||
}
|
||||
return _json({"window": {"start": str(start), "end": str(end)},
|
||||
"data_status": data_status, "rows": rows})
|
||||
except Exception:
|
||||
return _analytics_error("analytics/fuel")
|
||||
|
||||
|
||||
# ── Fuel Log (#fuelfuel) — actual fills from the WhatsApp feed ───────────────
|
||||
# Backed by reporting.v_fuel_fills / v_fuel_efficiency (owned by the `fleetfuel`
|
||||
# repo, which ingests the rustfs `fuel` bucket). Separate from /analytics/fuel
|
||||
# above (that one is the trip-derived estimate); this is real litres + KES spend.
|
||||
def _fuel_filters(cost_centre, assigned_city, vehicle_number, driver, department, fuel_type):
|
||||
"""Shared dims (_dim_filters) plus the fuel-native department / fuel_type."""
|
||||
clauses, params = _dim_filters(cost_centre, assigned_city, vehicle_number, driver)
|
||||
for col, val in (("department", department), ("fuel_type", fuel_type)):
|
||||
v = _clean(val)
|
||||
if v is not None:
|
||||
clauses.append(f"{col} = %({col})s")
|
||||
params[col] = v
|
||||
return clauses, params
|
||||
|
||||
|
||||
@app.get("/analytics/fuel-fills")
|
||||
def analytics_fuel_fills(
|
||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
||||
vehicle_number: str | None = None, driver: str | None = None,
|
||||
department: str | None = None, fuel_type: str | None = None,
|
||||
):
|
||||
start, end = _analytics_window(period or "90d", start_date, end_date)
|
||||
clauses, params = _fuel_filters(cost_centre, assigned_city, vehicle_number, driver,
|
||||
department, fuel_type)
|
||||
params |= {"start": start, "end": end}
|
||||
where = " AND ".join(["fuel_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT round(sum(liters), 1) AS litres,
|
||||
round(sum(amount), 0) AS spend_kes,
|
||||
count(*) AS fills,
|
||||
round(sum(amount) / NULLIF(sum(liters), 0), 1) AS avg_price_per_litre,
|
||||
count(DISTINCT plate) AS vehicles_fuelled,
|
||||
count(*) FILTER (WHERE vehicle_number IS NULL) AS unmatched_fills
|
||||
FROM reporting.v_fuel_fills
|
||||
WHERE {where}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
totals = cur.fetchone() or {}
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT f.plate, f.vehicle_number, f.cost_centre, f.assigned_city,
|
||||
round(sum(f.liters), 1) AS litres,
|
||||
round(sum(f.amount), 0) AS spend_kes,
|
||||
count(*) AS fills,
|
||||
max(f.odometer) AS last_odometer,
|
||||
round(sum(f.amount) / NULLIF(sum(f.liters), 0), 1) AS avg_price_per_litre,
|
||||
eff.km_per_litre
|
||||
FROM reporting.v_fuel_fills f
|
||||
LEFT JOIN (
|
||||
SELECT plate, round(avg(km_per_litre), 2) AS km_per_litre
|
||||
FROM reporting.v_fuel_efficiency
|
||||
WHERE fuel_date BETWEEN %(start)s AND %(end)s
|
||||
AND km_per_litre IS NOT NULL
|
||||
GROUP BY plate
|
||||
) eff ON eff.plate = f.plate
|
||||
WHERE {where}
|
||||
GROUP BY f.plate, f.vehicle_number, f.cost_centre, f.assigned_city, eff.km_per_litre
|
||||
ORDER BY spend_kes DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT coalesce(department, '(unspecified)') AS department,
|
||||
round(sum(liters), 1) AS litres,
|
||||
round(sum(amount), 0) AS spend_kes,
|
||||
count(*) AS fills
|
||||
FROM reporting.v_fuel_fills
|
||||
WHERE {where}
|
||||
GROUP BY department
|
||||
ORDER BY spend_kes DESC NULLS LAST
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_department = cur.fetchall()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT fuel_date,
|
||||
round(sum(liters), 1) AS litres,
|
||||
round(sum(amount), 0) AS spend_kes,
|
||||
count(*) AS fills
|
||||
FROM reporting.v_fuel_fills
|
||||
WHERE {where}
|
||||
GROUP BY fuel_date
|
||||
ORDER BY fuel_date
|
||||
""",
|
||||
params,
|
||||
)
|
||||
trend = cur.fetchall()
|
||||
data_status = {
|
||||
"matched_to_fleet": (totals.get("fills") or 0) - (totals.get("unmatched_fills") or 0),
|
||||
"unmatched_fills": totals.get("unmatched_fills") or 0,
|
||||
"notes": [
|
||||
"Fills are real WhatsApp fuel-update records (litres + KES amount).",
|
||||
"unmatched_fills are records whose plate didn't match a known vehicle "
|
||||
"in tracksolid.devices — they still count in totals.",
|
||||
"km_per_litre is derived from consecutive odometer readings; sparse where "
|
||||
"odometer is missing or implausible.",
|
||||
],
|
||||
}
|
||||
return _json({"window": {"start": str(start), "end": str(end)},
|
||||
"data_status": data_status, "totals": totals, "rows": rows,
|
||||
"by_department": by_department, "trend": trend})
|
||||
except Exception:
|
||||
return _analytics_error("analytics/fuel-fills")
|
||||
|
||||
|
||||
@app.get("/analytics/fuel-fills/recent")
|
||||
def analytics_fuel_fills_recent(
|
||||
period: str | None = None, start_date: str | None = None, end_date: str | None = None,
|
||||
cost_centre: str | None = None, assigned_city: str | None = None,
|
||||
vehicle_number: str | None = None, driver: str | None = None,
|
||||
department: str | None = None, fuel_type: str | None = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
start, end = _analytics_window(period or "90d", start_date, end_date)
|
||||
clauses, params = _fuel_filters(cost_centre, assigned_city, vehicle_number, driver,
|
||||
department, fuel_type)
|
||||
params |= {"start": start, "end": end, "lim": max(1, min(limit, 500))}
|
||||
where = " AND ".join(["fuel_date BETWEEN %(start)s AND %(end)s"] + clauses)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT record_datetime, plate, vehicle_number, cost_centre, assigned_city,
|
||||
department, driver, liters, amount, fuel_type, odometer
|
||||
FROM reporting.v_fuel_fills
|
||||
WHERE {where}
|
||||
ORDER BY record_datetime DESC NULLS LAST
|
||||
LIMIT %(lim)s
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return _json({"window": {"start": str(start), "end": str(end)}, "rows": rows})
|
||||
except Exception:
|
||||
return _analytics_error("analytics/fuel-fills/recent")
|
||||
|
||||
|
||||
@app.get("/analytics/filters")
|
||||
def analytics_filters():
|
||||
# Trips dropdowns (drivers / cost_centres / cities / vehicles) plus the fuel
|
||||
# dropdowns (departments / fuel_types), so FleetOps has a single /analytics/*
|
||||
# filter surface for every tab including Fuel Log.
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(_FILTER_OPTIONS_SQL)
|
||||
row = cur.fetchone() or {}
|
||||
# Fuel dims are best-effort: a missing reporting.v_fuel_fills (fleetfuel
|
||||
# migration not yet applied) must NOT break the trips dropdowns, so query
|
||||
# it in its own savepoint and degrade to empty lists if it isn't there.
|
||||
fuel = {}
|
||||
try:
|
||||
cur.execute("SAVEPOINT fuel_dims")
|
||||
cur.execute(
|
||||
"SELECT array_agg(DISTINCT department) FILTER (WHERE department IS NOT NULL) AS departments,"
|
||||
" array_agg(DISTINCT fuel_type) FILTER (WHERE fuel_type IS NOT NULL) AS fuel_types"
|
||||
" FROM reporting.v_fuel_fills"
|
||||
)
|
||||
fuel = cur.fetchone() or {}
|
||||
except Exception:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT fuel_dims")
|
||||
log.warning("fuel filter dims unavailable (reporting.v_fuel_fills missing?)")
|
||||
return JSONResponse({
|
||||
"drivers": row.get("drivers") or [],
|
||||
"cost_centres": row.get("cost_centres") or [],
|
||||
"cities": row.get("cities") or [],
|
||||
"vehicles": row.get("vehicles") or [],
|
||||
"departments": sorted(fuel.get("departments") or []),
|
||||
"fuel_types": sorted(fuel.get("fuel_types") or []),
|
||||
})
|
||||
except Exception:
|
||||
log.exception("analytics/filters failed")
|
||||
return JSONResponse({"drivers": [], "cost_centres": [], "cities": [],
|
||||
"vehicles": [], "departments": [], "fuel_types": []})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
db_audit/run_audit.py — Fireside Communications Fleet Telemetry DB Audit
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Runs six health checks against the production TimescaleDB.
|
||||
Writes results to tracksolid.health_checks for monitoring/auditing.
|
||||
Writes results to tracksolid.health_checks for Grafana monitoring.
|
||||
Exits with code 1 if any critical finding is detected.
|
||||
|
||||
Usage:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@ CREATE TABLE IF NOT EXISTS tracksolid.health_checks (
|
|||
row_count INT
|
||||
);
|
||||
|
||||
-- Index for time-range queries
|
||||
-- Index for Grafana time-range queries
|
||||
CREATE INDEX IF NOT EXISTS health_checks_checked_at_idx
|
||||
ON tracksolid.health_checks (checked_at DESC);
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# deploy_dashboard_api.sh — PROD dashboard_api bridge (fleetapi.rahamafresh.com).
|
||||
# Standalone Traefik-labelled bridge (NOT Coolify-managed): reuses the
|
||||
# webhook_receiver image + app network, bind-mounts the WIP API file. An env/CORS
|
||||
# change needs a container RECREATE (this script does that).
|
||||
#
|
||||
# Stage-2 least privilege: the request pool connects as the READ-ONLY dashboard_ro
|
||||
# role (DATABASE_URL), while the v_trips refresher keeps the privileged app role
|
||||
# (REFRESH_DATABASE_URL) — REFRESH needs write perms that dashboard_ro lacks.
|
||||
set -euo pipefail
|
||||
|
||||
WH=$(docker ps --filter name=webhook_receiver --format "{{.Names}}" | head -1)
|
||||
IMG=$(docker inspect "$WH" --format "{{.Image}}")
|
||||
APPNET=$(docker inspect "$WH" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
|
||||
echo "Reusing image $IMG on network $APPNET (from $WH)"
|
||||
|
||||
mkdir -p /home/kianiadee/dashboard_api
|
||||
# Stage a fresh copy only if one was scp'd to ~; otherwise keep the existing mount.
|
||||
if [ -f /home/kianiadee/dashboard_api_rev.py ]; then
|
||||
mv -f /home/kianiadee/dashboard_api_rev.py /home/kianiadee/dashboard_api/dashboard_api_rev.py
|
||||
fi
|
||||
test -f /home/kianiadee/dashboard_api/dashboard_api_rev.py \
|
||||
|| { echo "ERROR: dashboard_api_rev.py missing in mount dir; scp it to ~ first"; exit 1; }
|
||||
|
||||
# Reuse the webhook container's env, stripping runtime noise AND any inherited
|
||||
# DATABASE_URL + DASHBOARD_CORS_ORIGINS (both set explicitly below).
|
||||
docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||
| grep -vE '^(PATH=|HOSTNAME=|HOME=|PWD=|TERM=|SHLVL=|_=|LANG=|GPG_KEY=|PYTHON_VERSION=|PYTHON_PIP_VERSION=|PYTHONUNBUFFERED=|DATABASE_URL=|DASHBOARD_CORS_ORIGINS=)' \
|
||||
> /home/kianiadee/dashboard_api/dapi.env
|
||||
echo 'DASHBOARD_CORS_ORIGINS=https://liveposition.rahamafresh.com,https://fleetintelligence.rahamafresh.com,https://fleetnow.rahamafresh.com,https://fleetops.rahamafresh.com' \
|
||||
>> /home/kianiadee/dashboard_api/dapi.env
|
||||
|
||||
# Split roles: REFRESH_DATABASE_URL = the inherited privileged URL (for the
|
||||
# refresher); DATABASE_URL = read-only dashboard_ro (for request handling).
|
||||
SRC_DB_URL=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DATABASE_URL=//p' | head -1)
|
||||
RO_PW=$(cat /home/kianiadee/.dashboard_ro.pw 2>/dev/null || true)
|
||||
[ -n "$SRC_DB_URL" ] || { echo "ERROR: DATABASE_URL not found in $WH env"; exit 1; }
|
||||
[ -n "$RO_PW" ] || { echo "ERROR: ~/.dashboard_ro.pw missing — run bootstrap_dashboard_ro.sh first"; exit 1; }
|
||||
HOSTPART="${SRC_DB_URL#*@}"
|
||||
{
|
||||
echo "REFRESH_DATABASE_URL=${SRC_DB_URL}"
|
||||
echo "DATABASE_URL=postgresql://dashboard_ro:${RO_PW}@${HOSTPART}"
|
||||
} >> /home/kianiadee/dashboard_api/dapi.env
|
||||
chmod 600 /home/kianiadee/dashboard_api/dapi.env
|
||||
|
||||
docker rm -f dashboard_api 2>/dev/null || true
|
||||
docker run -d --name dashboard_api --restart unless-stopped \
|
||||
--network "$APPNET" \
|
||||
--env-file /home/kianiadee/dashboard_api/dapi.env \
|
||||
-v /home/kianiadee/dashboard_api/dashboard_api_rev.py:/app/dashboard_api_rev.py:ro \
|
||||
--label 'traefik.enable=true' \
|
||||
--label 'traefik.docker.network=coolify' \
|
||||
--label 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https' \
|
||||
--label 'traefik.http.routers.http-0-fleetapi.entryPoints=http' \
|
||||
--label 'traefik.http.routers.http-0-fleetapi.middlewares=redirect-to-https' \
|
||||
--label 'traefik.http.routers.http-0-fleetapi.rule=Host(`fleetapi.rahamafresh.com`)' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi.entryPoints=https' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi.rule=Host(`fleetapi.rahamafresh.com`)' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi.tls=true' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi.tls.certresolver=letsencrypt' \
|
||||
--label 'traefik.http.services.fleetapi.loadbalancer.server.port=8890' \
|
||||
"$IMG" sh -c 'uvicorn dashboard_api_rev:app --host 0.0.0.0 --port 8890 --workers 2'
|
||||
|
||||
docker network connect coolify dashboard_api 2>/dev/null || true
|
||||
sleep 8
|
||||
echo "== container =="; docker ps --filter name=dashboard_api --format "{{.Names}} | {{.Status}}"
|
||||
echo "== CORS origins in effect =="; docker exec dashboard_api printenv DASHBOARD_CORS_ORIGINS
|
||||
echo "== request role (expect dashboard_ro) =="; docker exec dashboard_api sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
|
||||
echo "== refresh role set? =="; docker exec dashboard_api sh -lc 'printenv REFRESH_DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
|
||||
echo "== internal health =="; docker exec dashboard_api sh -lc 'curl -s http://localhost:8890/health' 2>&1 | head || true
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# deploy_dashboard_api_staging.sh — STAGING twin of ~/deploy_dashboard_api.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stands up a SECOND dashboard_api bridge for the staging umbrella
|
||||
# (fleetapi.fivetitude.com). It mirrors the prod script but with four
|
||||
# deliberate differences:
|
||||
#
|
||||
# 1. Container name dashboard_api_staging (prod: dashboard_api)
|
||||
# 2. Port / Traefik 8891 + Host(fleetapi.fivetitude.com) (prod: 8890 + rahamafresh)
|
||||
# 3. DB role READ-ONLY dashboard_ro DATABASE_URL — dedicated least-privilege
|
||||
# role (scripts/bootstrap_dashboard_ro.sh); password read from
|
||||
# ~/.dashboard_ro.pw (prod: the app's read/write DATABASE_URL).
|
||||
# 4. Refresher OFF VTRIPS_REFRESH_INTERVAL_S=0 — prod owns the v_trips refresh;
|
||||
# a read-only instance must never attempt REFRESH.
|
||||
#
|
||||
# Staging reads the SAME production DB (over the internal Docker network) as the
|
||||
# dedicated read-only dashboard_ro role, so it is physically incapable of writing.
|
||||
# See docs/STAGING_FLEETOPS_ARCHITECTURE.md §6.
|
||||
#
|
||||
# Like prod, this is a STANDALONE bridge container (NOT Coolify-managed): it
|
||||
# reuses the webhook_receiver image + app network, bind-mounts the WIP API file,
|
||||
# and an env/CORS change needs a container RECREATE (this script does that).
|
||||
#
|
||||
# Deploy:
|
||||
# scp dashboard_api_rev.py kianiadee@twala.rahamafresh.com:~/dashboard_api_staging_rev.py
|
||||
# scp deploy_dashboard_api_staging.sh kianiadee@twala.rahamafresh.com:~/
|
||||
# ssh kianiadee@twala.rahamafresh.com 'bash ~/deploy_dashboard_api_staging.sh'
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
NAME=dashboard_api_staging
|
||||
PORT=8891
|
||||
MOUNT_DIR=/home/kianiadee/dashboard_api_staging
|
||||
ENV_FILE="$MOUNT_DIR/dapi.staging.env"
|
||||
STAGED_SRC=/home/kianiadee/dashboard_api_staging_rev.py
|
||||
CORS='https://fleetnow.fivetitude.com,https://fleetops.fivetitude.com'
|
||||
|
||||
WH=$(docker ps --filter name=webhook_receiver --format "{{.Names}}" | head -1)
|
||||
IMG=$(docker inspect "$WH" --format "{{.Image}}")
|
||||
APPNET=$(docker inspect "$WH" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
|
||||
echo "Reusing image $IMG on network $APPNET (from $WH)"
|
||||
|
||||
mkdir -p "$MOUNT_DIR"
|
||||
# Stage a fresh copy only if one was scp'd to ~; otherwise keep the existing mount.
|
||||
if [ -f "$STAGED_SRC" ]; then
|
||||
mv -f "$STAGED_SRC" "$MOUNT_DIR/dashboard_api_rev.py"
|
||||
fi
|
||||
test -f "$MOUNT_DIR/dashboard_api_rev.py" \
|
||||
|| { echo "ERROR: dashboard_api_rev.py missing in $MOUNT_DIR; scp it to ~/dashboard_api_staging_rev.py first"; exit 1; }
|
||||
|
||||
# Derive a READ-ONLY DATABASE_URL on the host (never printed): take the app's
|
||||
# DATABASE_URL host:port/dbname and swap the credentials for dashboard_ro, whose
|
||||
# password lives in the host-only 0600 file written by bootstrap_dashboard_ro.sh.
|
||||
SRC_DB_URL=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DATABASE_URL=//p' | head -1)
|
||||
RO_PW=$(cat "${DASHBOARD_RO_PW_FILE:-$HOME/.dashboard_ro.pw}" 2>/dev/null || true)
|
||||
[ -n "$SRC_DB_URL" ] || { echo "ERROR: DATABASE_URL not found in $WH env"; exit 1; }
|
||||
[ -n "$RO_PW" ] || { echo "ERROR: ~/.dashboard_ro.pw missing — run bootstrap_dashboard_ro.sh first"; exit 1; }
|
||||
HOSTPART="${SRC_DB_URL#*@}" # host:port/dbname[?params]
|
||||
RO_DB_URL="postgresql://dashboard_ro:${RO_PW}@${HOSTPART}"
|
||||
|
||||
# Reuse the webhook env, stripping runtime noise AND anything we override below
|
||||
# (DATABASE_URL -> read-only, CORS -> staging origins, refresher -> disabled).
|
||||
docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||
| grep -vE '^(PATH=|HOSTNAME=|HOME=|PWD=|TERM=|SHLVL=|_=|LANG=|GPG_KEY=|PYTHON_VERSION=|PYTHON_PIP_VERSION=|PYTHONUNBUFFERED=|DATABASE_URL=|DASHBOARD_CORS_ORIGINS=|VTRIPS_REFRESH_INTERVAL_S=)' \
|
||||
> "$ENV_FILE"
|
||||
{
|
||||
echo "DATABASE_URL=${RO_DB_URL}"
|
||||
echo "DASHBOARD_CORS_ORIGINS=${CORS}"
|
||||
echo "VTRIPS_REFRESH_INTERVAL_S=0"
|
||||
} >> "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
docker rm -f "$NAME" 2>/dev/null || true
|
||||
docker run -d --name "$NAME" --restart unless-stopped \
|
||||
--network "$APPNET" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-v "$MOUNT_DIR/dashboard_api_rev.py:/app/dashboard_api_rev.py:ro" \
|
||||
--label 'traefik.enable=true' \
|
||||
--label 'traefik.docker.network=coolify' \
|
||||
--label 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https' \
|
||||
--label 'traefik.http.routers.http-0-fleetapi-staging.entryPoints=http' \
|
||||
--label 'traefik.http.routers.http-0-fleetapi-staging.middlewares=redirect-to-https' \
|
||||
--label 'traefik.http.routers.http-0-fleetapi-staging.rule=Host(`fleetapi.fivetitude.com`)' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi-staging.entryPoints=https' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi-staging.rule=Host(`fleetapi.fivetitude.com`)' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi-staging.tls=true' \
|
||||
--label 'traefik.http.routers.https-0-fleetapi-staging.tls.certresolver=letsencrypt' \
|
||||
--label "traefik.http.services.fleetapi-staging.loadbalancer.server.port=${PORT}" \
|
||||
"$IMG" sh -c "uvicorn dashboard_api_rev:app --host 0.0.0.0 --port ${PORT} --workers 2"
|
||||
|
||||
docker network connect coolify "$NAME" 2>/dev/null || true
|
||||
sleep 5
|
||||
echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}"
|
||||
echo "== CORS origins in effect =="; docker exec "$NAME" printenv DASHBOARD_CORS_ORIGINS
|
||||
echo "== refresher (expect 0 = disabled) =="; docker exec "$NAME" printenv VTRIPS_REFRESH_INTERVAL_S
|
||||
echo "== DB role (expect dashboard_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
|
||||
echo "== internal health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/health" 2>&1 | head
|
||||
|
|
@ -10,11 +10,7 @@ services:
|
|||
# Mount the named volume there so data survives container rebuilds.
|
||||
- PGDATA=/home/postgres/pgdata/data
|
||||
ports:
|
||||
# SEC-01: default to loopback-only — the DB must not listen on the public
|
||||
# internet (services reach it over the internal Docker network; local
|
||||
# tooling uses an SSH tunnel: `ssh -L 5433:localhost:5433 <host>`).
|
||||
# Set DB_BIND_ADDR=0.0.0.0 in .env only if you deliberately re-expose it.
|
||||
- "${DB_BIND_ADDR:-127.0.0.1}:5433:5432"
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- timescale-data:/home/postgres/pgdata
|
||||
healthcheck:
|
||||
|
|
@ -23,14 +19,22 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
ingest_worker:
|
||||
# Merged movement + events pollers (was ingest_movement + ingest_events).
|
||||
# Both pipelines run in one process via ingest_worker_rev.py — same image,
|
||||
# same shared connection pool, one `schedule` loop. See ingest_worker_rev.py.
|
||||
ingest_movement:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: sh -c "python run_migrations.py && python ingest_worker_rev.py"
|
||||
command: sh -c "python run_migrations.py && python ingest_movement_rev.py"
|
||||
restart: always
|
||||
depends_on:
|
||||
timescale_db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
|
||||
ingest_events:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: sh -c "python run_migrations.py && python ingest_events_rev.py"
|
||||
restart: always
|
||||
depends_on:
|
||||
timescale_db:
|
||||
|
|
@ -80,19 +84,61 @@ services:
|
|||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# grafana — REMOVED 2026-06-10. Fleet visualisation/KPIs are now served by the
|
||||
# FleetOps SPA (own repo) via the dashboard_api read layer. Pipeline freshness
|
||||
# (the one thing only Grafana surfaced) is replaced by reporting.v_ingest_health
|
||||
# (migration 19) exposed on the read-API. The grafana_ro role + reporting.*
|
||||
# grants are retained (harmless, reusable). Provisioning kept in ./grafana for
|
||||
# reference. To restore, re-add this service block.
|
||||
grafana:
|
||||
build:
|
||||
context: ./grafana
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
depends_on:
|
||||
timescale_db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
- GF_USERS_DEFAULT_THEME=dark
|
||||
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards-json/noc_fleet_dashboard.json
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
# Provisioning is baked into the image via grafana/Dockerfile — no bind mount needed.
|
||||
# COOLIFY DOMAIN LOGIC:
|
||||
# You will set the actual URL in the Coolify UI,
|
||||
# but the service needs to expose port 3000 internally.
|
||||
|
||||
# pgbouncer — REMOVED 2026-06-10. It was deployed but dormant (zero clients
|
||||
# pointed at :6432; every service connects directly to timescale_db:5432).
|
||||
# In-process pooling (ts_shared_rev ThreadedConnectionPool) is more than
|
||||
# sufficient at this scale, and transaction-mode pooling is unsafe for the
|
||||
# advisory-lock'd v_trips refresher (FIX-D02). Migration 10 (pgbouncer role +
|
||||
# user_lookup()) is left applied but inert. To restore, re-add this service block.
|
||||
pgbouncer:
|
||||
# Connection pooler in front of timescale_db.
|
||||
# Runbook: docs/reference/260507_pgbouncer_deployment.md
|
||||
# Internal Docker network only — no host port. SCRAM passthrough via
|
||||
# auth_query against the public.user_lookup() function (migration 10).
|
||||
image: edoburu/pgbouncer
|
||||
restart: always
|
||||
depends_on:
|
||||
timescale_db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
- DB_HOST=timescale_db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${POSTGRES_USER}
|
||||
- DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- DB_NAME=${POSTGRES_DB}
|
||||
- POOL_MODE=transaction
|
||||
- AUTH_TYPE=scram-sha-256
|
||||
- AUTH_USER=pgbouncer
|
||||
# $$1 escapes docker-compose interpolation; pgbouncer sees literal $1.
|
||||
- AUTH_QUERY=SELECT uname, phash FROM public.user_lookup($$1)
|
||||
- MAX_CLIENT_CONN=200
|
||||
- DEFAULT_POOL_SIZE=15
|
||||
- MIN_POOL_SIZE=2
|
||||
- RESERVE_POOL_SIZE=5
|
||||
- SERVER_RESET_QUERY=DISCARD ALL
|
||||
- SERVER_IDLE_TIMEOUT=600
|
||||
- ADMIN_USERS=${POSTGRES_USER}
|
||||
- LISTEN_PORT=6432
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 -U ${POSTGRES_USER}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
db_backup:
|
||||
build:
|
||||
|
|
@ -118,4 +164,5 @@ services:
|
|||
volumes:
|
||||
timescale-data:
|
||||
name: timescale-data
|
||||
# grafana-data removed with the grafana service (2026-06-10).
|
||||
grafana-data:
|
||||
name: grafana-data
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ ssh -i ~/.ssh/id_ed25519 kianiadee@stage.rahamafresh.com
|
|||
| Host (internal) | `timescale_db` (Docker service name) |
|
||||
| Port | `5432` |
|
||||
| App user | `tracksolid_owner` |
|
||||
| Read-only user | `dashboard_ro` (used by the staging bridge) · `grafana_ro` (legacy, retained but unused) |
|
||||
| Read-only user | `grafana_ro` |
|
||||
| Superuser | `postgres` |
|
||||
|
||||
### `.env` variable names
|
||||
|
|
@ -36,6 +36,7 @@ POSTGRES_DB=tracksolid_db
|
|||
POSTGRES_USER=...
|
||||
POSTGRES_PASSWORD=...
|
||||
DATABASE_URL=postgresql://tracksolid_owner:<password>@timescale_db:5432/tracksolid_db
|
||||
GRAFANA_DB_RO_PASSWORD=...
|
||||
```
|
||||
|
||||
### Run a query from host
|
||||
|
|
@ -76,9 +77,9 @@ docker ps --filter name=<service_name> --format "{{.Names}}" | head -1
|
|||
|
||||
# Examples
|
||||
docker ps --filter name=timescale_db --format "{{.Names}}" | head -1
|
||||
docker ps --filter name=ingest_worker --format "{{.Names}}" | head -1
|
||||
docker ps --filter name=ingest_movement --format "{{.Names}}" | head -1
|
||||
docker ps --filter name=webhook_receiver --format "{{.Names}}" | head -1
|
||||
docker ps --filter name=dashboard_api --format "{{.Names}}" | head -1
|
||||
docker ps --filter name=grafana --format "{{.Names}}" | head -1
|
||||
```
|
||||
|
||||
Current suffix (may change on redeploy): `bo3nov2ija7g8wn9b1g2paxs-19xxxxxxxxxx`
|
||||
|
|
@ -92,3 +93,19 @@ Host: https://repo.rahamafresh.com
|
|||
Repo: kianiadee/tracksolid_timescale_grafana_prod
|
||||
Remote: https://repo.rahamafresh.com/kianiadee/tracksolid_timescale_grafana_prod.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Grafana
|
||||
|
||||
- Deployed as Docker service `grafana`
|
||||
- Provisioning baked into image (datasources + dashboards via `grafana/Dockerfile`)
|
||||
- Admin password: `GF_SECURITY_ADMIN_PASSWORD` from `.env`
|
||||
- Default dashboard: NOC Fleet Dashboard
|
||||
|
||||
---
|
||||
|
||||
## n8n
|
||||
|
||||
- Deployed as separate Coolify service (`n8n-usoksgg8o40044g0cw08s8wc`)
|
||||
- Workflows exported to `n8n-workflows/`
|
||||
|
|
|
|||
|
|
@ -3,15 +3,12 @@
|
|||
**Scope:** the *live* fleet pipeline only (`tracksolid` + `reporting`). The `ops` and
|
||||
`dwh_gold` schemas were **purged on 2026-06-05** (migrations 12/13) — those workshop /
|
||||
dispatch / SLA / utilisation features were never implemented. They are shown below only as
|
||||
a struck-out "removed" footnote for historical context.
|
||||
|
||||
> **Update 2026-06-10:** Grafana, pgbouncer, and the separate DWH pipeline were removed.
|
||||
> The `tracksolid.v_*` analytics views still exist but no longer have an active consumer
|
||||
> (Grafana was their only reader). The two pollers were merged into one `ingest_worker`.
|
||||
a struck-out "removed" footnote for historical context. (The *separate* `tracksolid_dwh`
|
||||
server at 31.97.44.246:5888 is unrelated and was not touched.)
|
||||
|
||||
**Verified against prod 2026-06-05** (TimescaleDB hypertable + continuous-aggregate
|
||||
catalog, `pg_depend` view graph, ingestion `INSERT` targets, `dashboard_api` queries).
|
||||
Key facts that surprised the docs:
|
||||
catalog, `pg_depend` view graph, ingestion `INSERT` targets, `dashboard_api` queries,
|
||||
Grafana panel SQL). Key facts that surprised the docs:
|
||||
|
||||
- Only `position_history` (and the empty/planned `heartbeats`, `fuel_readings`,
|
||||
`temperature_readings`) are **hypertables**. `trips` and `alarms` are **plain tables**.
|
||||
|
|
@ -67,7 +64,7 @@ flowchart TD
|
|||
VT --> FILT & SUMM
|
||||
LP --> VLP
|
||||
|
||||
subgraph L3R["L3 · Analytics views — tracksolid.* (read base tables directly · UNCONSUMED since Grafana removed 2026-06-10)"]
|
||||
subgraph L3R["L3 · Grafana views — tracksolid.* (read base tables directly)"]
|
||||
GV["v_fleet_today · v_fleet_status · v_active_dispatch_map<br/>v_currently_idle · v_alarms_daily · v_fleet_km_daily<br/>v_ingestion_health · v_vehicles_not_moved_today<br/>v_driver_aggregates_daily · v_fleet_trace · v_driver_clock_*"]
|
||||
end
|
||||
LP --> GV
|
||||
|
|
@ -91,15 +88,16 @@ flowchart TD
|
|||
subgraph L5["L5 · Consumers"]
|
||||
DAPI["dashboard_api_rev.py<br/>FastAPI :8890 · 2 workers"]
|
||||
SPA["SPAs: liveposition.* · fleetintelligence.*<br/>(rustfs / S3 single-file maps)"]
|
||||
GRAF["Grafana"]
|
||||
end
|
||||
FLP --> DAPI
|
||||
FVT --> DAPI
|
||||
FTM --> DAPI
|
||||
FILT --> DAPI
|
||||
DAPI -->|"HTTPS · fleetapi.rahamafresh.com"| SPA
|
||||
GV --> GRAF
|
||||
|
||||
CAGG -.->|no consumer yet| NONE(["⚠ unconsumed — no panel / API reads it"])
|
||||
GV -.->|Grafana removed 2026-06-10| NONE
|
||||
|
||||
subgraph PARK["REMOVED 2026-06-05 — purged via migrations 12 / 13 (never implemented)"]
|
||||
GONE["ops.* (tickets · dispatch_log · service_log · odometer_readings · cost_rates · kpi_targets · vw_service_forecast)<br/>dwh_gold.* (dim_vehicles · fact_daily_fleet_metrics · refresh_daily_metrics)<br/>tracksolid.v_sla_inflight · tracksolid.v_utilisation_daily + their Grafana panels"]
|
||||
|
|
@ -150,10 +148,10 @@ flowchart TD
|
|||
╚══════╤══════════════════════════╤══════════════════════════════════╤════════════════════════╝
|
||||
│ │ │
|
||||
│ TimescaleDB │ matview refresh │ direct reads
|
||||
│ cont-agg policy │ (in-app loop) │ (ad-hoc SQL)
|
||||
│ cont-agg policy │ (in-app loop) │ (Grafana SQL)
|
||||
▼ ▼ ▼
|
||||
╔══════════════════╗ ╔════════════════════════════════╗ ╔══════════════════════════════════╗
|
||||
║ L2 · AGGREGATION ║ ║ L2 · AGGREGATION (matview) ║ ║ L3 · ANALYTICS VIEWS — UNCONSUMED ║
|
||||
║ L2 · AGGREGATION ║ ║ L2 · AGGREGATION (matview) ║ ║ L3 · GRAFANA VIEWS (tracksolid.*) ║
|
||||
║ ║ ║ ║ ║ read base tables directly ║
|
||||
║ v_mileage_daily ║ ║ reporting.v_trips [MATVIEW] ║ ║ ║
|
||||
║ _cagg ║ ║ ◀── trips + devices ║ ║ v_fleet_today v_fleet_status ║
|
||||
|
|
@ -202,7 +200,7 @@ flowchart TD
|
|||
║ SPAs: liveposition.* · fleetintelligence.* ║
|
||||
║ (rustfs/S3 single-file maps) ║
|
||||
║ ║
|
||||
║Grafana removed 2026-06-10 — v_* now unconsumed║
|
||||
║ Grafana ──SQL──▶ tracksolid.v_* (L3 right) ║
|
||||
╚═══════════════════════════════════════════════╝
|
||||
|
||||
|
||||
|
|
@ -227,19 +225,16 @@ flowchart TD
|
|||
loop — the FIX-D02 self-refresher that replaced the dead n8n job).
|
||||
- **L4 functions are the only thing the API calls.** SPAs never touch tables directly:
|
||||
SPA → `dashboard_api` → `fn_*` → (matview / tables).
|
||||
- **The `tracksolid.v_*` analytics views (L3 right) are now unconsumed.** Grafana was their
|
||||
only reader and was removed 2026-06-10; the views still exist (migration 07) but nothing
|
||||
reads them. Candidates for removal or for wiring into a FleetOps panel.
|
||||
- **Grafana** takes the right-hand path, reading the `tracksolid.v_*` analytics views
|
||||
straight off L1.
|
||||
|
||||
## Caveats / housekeeping notes
|
||||
|
||||
1. **`v_mileage_daily_cagg` has no consumer** — nothing in the API reads it. It
|
||||
1. **`v_mileage_daily_cagg` has no consumer** — nothing in the API or Grafana reads it. It
|
||||
is a live continuous aggregate maintaining itself for nobody. Candidate for removal or
|
||||
wiring into a panel.
|
||||
2. **The `tracksolid.v_*` analytics views are unconsumed** since Grafana's removal
|
||||
(2026-06-10) — same housekeeping candidate as the cagg above.
|
||||
3. **`ops` and `dwh_gold` were purged** (2026-06-05, migrations 12/13) along with
|
||||
`v_utilisation_daily`, `v_sla_inflight`, and their (then-live) Grafana panels — the
|
||||
features were never implemented.
|
||||
4. See the companion housekeeping audit (2026-06-05) for the full unused-object list; the
|
||||
2. **`ops` and `dwh_gold` were purged** (2026-06-05, migrations 12/13) along with
|
||||
`v_utilisation_daily`, `v_sla_inflight`, and their Grafana panels — the features were
|
||||
never implemented.
|
||||
3. See the companion housekeeping audit (2026-06-05) for the full unused-object list; the
|
||||
only clean ad-hoc drop is `public.trips_viz_v1`.
|
||||
|
|
|
|||
252
docs/DWH_PIPELINE.md
Normal file
252
docs/DWH_PIPELINE.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# DWH Pipeline — Operations Runbook
|
||||
|
||||
**Pipeline:** n8n extract + load into `tracksolid_dwh` bronze schema
|
||||
**Design spec:** `docs/superpowers/specs/2026-04-24-n8n-dwh-bronze-pipeline-design.md`
|
||||
**Implementation plan:** `docs/superpowers/plans/2026-04-24-n8n-dwh-bronze-pipeline.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. What This Pipeline Does
|
||||
|
||||
Every ~3 hours during active hours (7 runs/day, 05:00–23:00 EAT), n8n extracts 8 tables from the production `tracksolid_db` (Coolify internal network), writes each as a timestamped CSV to rustfs, then loads each CSV into the `bronze` schema on `tracksolid_dwh` (31.97.44.246:5888). Rustfs CSVs are moved to `dwh/processed/` after a successful load — never deleted.
|
||||
|
||||
Two n8n workflows:
|
||||
- **`dwh_extract`** — cron-triggered, iterates tables in sequence, writes CSVs, calls `dwh_load_bronze` per table.
|
||||
- **`dwh_load_bronze`** — triggered per-table by `dwh_extract`, loads one CSV inside a single transaction (insert → update watermark → update run log → move CSV).
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Locations
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Source DB | `tracksolid_db` on Coolify (internal Docker network, `timescale_db:5432`) |
|
||||
| Target DB | `tracksolid_dwh` at `31.97.44.246:5888` |
|
||||
| Rustfs bucket | `fleet-db` (same bucket used by pg_dump backups) |
|
||||
| Active CSVs | `s3://fleet-db/dwh/exports/{table}/{YYYYMMDD_HHMM}_EAT.csv` |
|
||||
| Processed CSVs | `s3://fleet-db/dwh/processed/{table}/{YYYYMMDD_HHMM}_EAT.csv` |
|
||||
| Control schema | `dwh_control` in `tracksolid_dwh` |
|
||||
| Migrations | `dwh/26*.sql` applied in numeric order |
|
||||
|
||||
---
|
||||
|
||||
## 3. First-Time Setup
|
||||
|
||||
Apply migrations to `tracksolid_dwh` in numeric order, as a superuser (not `dwh_owner`):
|
||||
|
||||
```bash
|
||||
PSQL="psql 'postgres://postgres:***@31.97.44.246:5888/tracksolid_dwh?sslmode=require'"
|
||||
|
||||
$PSQL -f dwh/260423_dwh_ddl_v1.sql # Bronze DDL, roles, schemas
|
||||
$PSQL -f dwh/261001_dwh_control.sql # Watermarks + run log
|
||||
$PSQL -f dwh/261002_bronze_constraints_audit.sql # Assertion: ON CONFLICT keys exist
|
||||
$PSQL -f dwh/261003_dwh_roles.sql # Assertion: roles + grants present
|
||||
$PSQL -f dwh/261004_dwh_observability_views.sql # Freshness/failure views
|
||||
```
|
||||
|
||||
Each migration is idempotent. Audit files (261002, 261003) raise an exception with a bullet list of what is missing if the contract is broken — re-apply the relevant predecessor file and try again.
|
||||
|
||||
### Rustfs prefixes
|
||||
|
||||
```bash
|
||||
aws --endpoint ${RUSTFS_ENDPOINT} s3api put-object \
|
||||
--bucket fleet-db --key dwh/exports/
|
||||
aws --endpoint ${RUSTFS_ENDPOINT} s3api put-object \
|
||||
--bucket fleet-db --key dwh/processed/
|
||||
```
|
||||
|
||||
### n8n credentials
|
||||
|
||||
Three credentials, all configured in the n8n UI before importing workflows:
|
||||
|
||||
| Credential | Target | User | Notes |
|
||||
|---|---|---|---|
|
||||
| `tracksolid_source` | Coolify internal → `tracksolid_db` | `grafana_ro` | Read-only; no `sslmode` needed on internal network |
|
||||
| `tracksolid_dwh_target` | `31.97.44.246:5888` → `tracksolid_dwh` | `dwh_owner` | **Must set `sslmode=require`** — public IP |
|
||||
| `rustfs_s3` | `${RUSTFS_ENDPOINT}` | `${RUSTFS_ACCESS_KEY}` | Same creds as pg_dump backup sidecar |
|
||||
|
||||
Test each credential via the n8n "Test connection" button before enabling the cron schedule.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schedule
|
||||
|
||||
n8n Schedule node, Africa/Nairobi TZ: `0 5,8,11,14,17,20,23 * * *`
|
||||
|
||||
- 7 runs/day at 05:00, 08:00, 11:00, 14:00, 17:00, 20:00, 23:00 EAT
|
||||
- Overnight gap (23:00 → 05:00 = 6h) by design — device traffic minimal
|
||||
- First-of-day run carries the overnight backlog (watermark picks up where 23:00 left off)
|
||||
|
||||
---
|
||||
|
||||
## 5. What Each Table Does on Every Run
|
||||
|
||||
### Snapshot tables (TRUNCATE + full reload)
|
||||
|
||||
`bronze.devices`, `bronze.live_positions` — small state tables, "current state" semantics. Full replace every run.
|
||||
|
||||
### Incremental tables (watermark + append-with-dedup)
|
||||
|
||||
| Bronze table | Source watermark column | ON CONFLICT target |
|
||||
|---|---|---|
|
||||
| `position_history` | `recorded_at` (DB insertion time) | `(imei, gps_time)` |
|
||||
| `trips` | `updated_at` | `id` |
|
||||
| `alarms` | `updated_at` | `id` |
|
||||
| `parking_events` | `updated_at` | `id` |
|
||||
| `device_events` | `created_at` | `id` |
|
||||
| `ingestion_log` | `run_at` | `id` |
|
||||
|
||||
Watermark bounds are closed upper: `WHERE <col> > last_extracted_at AND <col> <= :run_started_at`.
|
||||
|
||||
### Schema drift to handle in extract SQL
|
||||
|
||||
- **`trips.distance_m` → `bronze.trips.distance_km`**: source stores metres, bronze expects km. Extract SQL: `SELECT ..., distance_m/1000.0 AS distance_km, ...`. Cross-reference: FIX-M16 in `CLAUDE.md`.
|
||||
|
||||
### PostGIS geometry round-trip
|
||||
|
||||
All six geometry columns (`live_positions`, `position_history`, `trips.start_geom`, `trips.end_geom`, `parking_events`, `alarms`) use EWKT serialisation:
|
||||
|
||||
```sql
|
||||
-- Extract
|
||||
SELECT ..., CASE WHEN geom IS NULL THEN NULL ELSE ST_AsEWKT(geom) END AS geom_ewkt FROM ...;
|
||||
|
||||
-- Load
|
||||
INSERT INTO bronze... (..., geom) VALUES (..., ST_GeomFromEWKT(:geom_ewkt));
|
||||
```
|
||||
|
||||
SRID 4326 is preserved inline; no separate SRID step required.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verifying a Healthy Run
|
||||
|
||||
### Immediate sanity checks (after any scheduled run)
|
||||
|
||||
```sql
|
||||
-- Any failures in the last hour?
|
||||
SELECT * FROM dwh_control.v_recent_failures WHERE run_started_at > NOW() - INTERVAL '1 hour';
|
||||
|
||||
-- All tables loaded in last 4h?
|
||||
SELECT * FROM dwh_control.v_table_freshness WHERE lag > INTERVAL '4 hours';
|
||||
|
||||
-- Watermarks advancing?
|
||||
SELECT * FROM dwh_control.v_watermark_lag ORDER BY extract_lag DESC;
|
||||
```
|
||||
|
||||
### Row-count parity (spot check weekly)
|
||||
|
||||
```sql
|
||||
-- Source
|
||||
SELECT COUNT(*) FROM tracksolid.position_history;
|
||||
-- Target
|
||||
SELECT COUNT(*) FROM bronze.position_history;
|
||||
```
|
||||
|
||||
Numbers should match ± rows inserted between the two queries. Persistent gap > 1% → investigate CSV parse errors or a dropped batch.
|
||||
|
||||
### Geometry round-trip
|
||||
|
||||
```sql
|
||||
SELECT ST_AsText(geom) FROM bronze.position_history WHERE geom IS NOT NULL LIMIT 5;
|
||||
-- Should return valid POINT(lng lat), not NULL or garbage.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
### "A table is stale (`v_table_freshness` shows lag > 4h)"
|
||||
|
||||
1. Check `v_recent_failures` for that table. If a row exists, read `error_message`.
|
||||
2. If `status='loading'` in `extract_runs` for that table, a load is in progress — wait for the next cron tick. If it stays `loading` across two ticks, the n8n executor crashed mid-transaction; the DB rolled back, and the next run will retry naturally.
|
||||
3. If no failure row and no in-progress row, the extract workflow never fired — check n8n execution logs for the cron trigger.
|
||||
|
||||
### "A CSV is stuck in `dwh/exports/`"
|
||||
|
||||
The load failed after upload. The next scheduled run will re-process it (the watermark did not advance, so the extract SQL returns the same window). Safe to leave. If multiple days of CSVs pile up, the load workflow has a persistent bug — open n8n execution logs for the specific `run_id` in `extract_runs`.
|
||||
|
||||
### "Row counts diverge more than ~1%"
|
||||
|
||||
Usually one of:
|
||||
- A retry window overlapped the PK range and some rows lost the race with a concurrent source-side write. Re-trigger the extract for that window manually (see §8).
|
||||
- CSV parse error silently dropped a row. Check `extract_runs.rows_extracted` vs. `rows_loaded` — if they differ, the loader found malformed CSV.
|
||||
|
||||
### "Geometry loaded as NULL"
|
||||
|
||||
EWKT serialisation broke on the extract side. Verify the source query has the `CASE WHEN geom IS NULL` guard — without it, `ST_AsEWKT(NULL)` returns `NULL` correctly but an empty geometry returns `'GEOMETRYCOLLECTION EMPTY'` which `ST_GeomFromEWKT` rejects.
|
||||
|
||||
### "`bronze.trips.distance_km` values are 1000× too large"
|
||||
|
||||
The extract query is missing `/1000.0` on the source `distance_m` column. See §5 "Schema drift".
|
||||
|
||||
---
|
||||
|
||||
## 8. Manual Re-Run
|
||||
|
||||
### Re-run a single table for the current window
|
||||
|
||||
1. Open n8n, go to `dwh_extract` workflow.
|
||||
2. Locate the branch for that table.
|
||||
3. Click **Execute Workflow** → selects that table only.
|
||||
4. Confirm a new row appears in `dwh_control.extract_runs` with `status='loaded'`.
|
||||
|
||||
### Back-fill a historical window
|
||||
|
||||
The extract workflow respects the watermark; to re-extract a window, rewind the watermark:
|
||||
|
||||
```sql
|
||||
-- Rewind position_history to 24h ago
|
||||
UPDATE dwh_control.extract_watermarks
|
||||
SET last_extracted_at = NOW() - INTERVAL '24 hours'
|
||||
WHERE table_name = 'position_history';
|
||||
```
|
||||
|
||||
Next scheduled run will re-extract the gap. Loads are idempotent (`ON CONFLICT DO NOTHING` on the PK), so duplicate rows are filtered at the bronze boundary.
|
||||
|
||||
### Full reseed (nuclear option)
|
||||
|
||||
```sql
|
||||
-- Restart position_history from the beginning
|
||||
UPDATE dwh_control.extract_watermarks
|
||||
SET last_extracted_at = '2026-01-01T00:00:00Z'
|
||||
WHERE table_name = 'position_history';
|
||||
```
|
||||
|
||||
The first post-reseed run will produce one very large CSV (~all history). The rustfs `exports/` prefix will briefly hold a multi-GB object. Expected; it moves to `processed/` on success.
|
||||
|
||||
---
|
||||
|
||||
## 9. Credential Rotation
|
||||
|
||||
`260423_dwh_ddl_v1.sql` commits role passwords in plaintext — a pre-existing flaw to be cleaned up separately.
|
||||
|
||||
To rotate:
|
||||
|
||||
```sql
|
||||
-- As superuser on tracksolid_dwh:
|
||||
ALTER ROLE dwh_owner PASSWORD '<new secret>';
|
||||
ALTER ROLE grafana_ro PASSWORD '<new secret>';
|
||||
```
|
||||
|
||||
Then update the matching n8n credential and Grafana datasource. Never commit the new password — store in `.env` if needed for scripts, or keep exclusively inside n8n/Grafana credential stores.
|
||||
|
||||
---
|
||||
|
||||
## 10. Known Quirks
|
||||
|
||||
| Quirk | Source | Handling |
|
||||
|---|---|---|
|
||||
| `trips.distance_m` → `bronze.trips.distance_km` | Schema drift between source and bronze | Divide in extract SQL (§5) |
|
||||
| Hypertable row counts read 0 in `pg_stat_user_tables` | TimescaleDB quirk | Always `SELECT COUNT(*)` directly |
|
||||
| `parking_events` can be empty for days | Endpoint returns empty; not a failure | Zero rows loaded is a valid run outcome |
|
||||
| First run of each day larger | Overnight backlog | Expected; plan watermark design |
|
||||
| `last_extracted_at` default `2026-01-01` | Seed value from 261001 | First run on a new table back-fills all history |
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of Scope (follow-up)
|
||||
|
||||
- Silver/gold transformations — `silver` and `gold` schemas exist but contain no views.
|
||||
- Grafana dashboard panels — these views are the data source; panels TBD.
|
||||
- OBD / fault codes / fuel / temperature / LBS / heartbeats — webhooks not yet registered; add a watermark row + extract branch when they start reporting.
|
||||
- Bronze schema evolution tooling — additive changes via numbered migrations is fine for one pipeline; revisit if scope grows.
|
||||
|
|
@ -9,13 +9,13 @@
|
|||
## How to Use This Document
|
||||
|
||||
1. **Proposed** — KPI defined, not yet validated with client
|
||||
2. **Active** — Client confirmed this matters; query written; FleetOps panel exists or is in progress
|
||||
2. **Active** — Client confirmed this matters; query written; Grafana panel exists or is in progress
|
||||
3. **Baseline set** — Enough historical data exists to set a meaningful target
|
||||
4. **Retired** — No longer tracked (document reason)
|
||||
|
||||
Each active KPI should link to:
|
||||
- The SQL query (or reference to `01_BusinessAnalytics.md`)
|
||||
- The FleetOps panel name/dashboard
|
||||
- The Grafana panel name/dashboard
|
||||
- The refresh frequency
|
||||
- The person who reviews it
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ Each active KPI should link to:
|
|||
|
||||
### Fleet Utilisation
|
||||
|
||||
| KPI | Status | SQL ref | FleetOps panel | Reviewed by | Cadence |
|
||||
| KPI | Status | SQL ref | Grafana panel | Reviewed by | Cadence |
|
||||
|---|---|---|---|---|---|
|
||||
| Utilisation rate (%) | Proposed | `01_BusinessAnalytics.md §2.1` | — | — | Daily |
|
||||
| Idle time % of shift | Proposed | `01_BusinessAnalytics.md §2.2` | — | — | Daily |
|
||||
|
|
@ -35,7 +35,7 @@ Each active KPI should link to:
|
|||
|
||||
### Technician Productivity *(requires job system integration)*
|
||||
|
||||
| KPI | Status | SQL ref | FleetOps panel | Reviewed by | Cadence |
|
||||
| KPI | Status | SQL ref | Grafana panel | Reviewed by | Cadence |
|
||||
|---|---|---|---|---|---|
|
||||
| Jobs completed per tech per day | Proposed | TBD | — | — | Daily |
|
||||
| First-time fix rate | Proposed | TBD | — | — | Weekly |
|
||||
|
|
@ -45,7 +45,7 @@ Each active KPI should link to:
|
|||
|
||||
### Driver Behaviour
|
||||
|
||||
| KPI | Status | SQL ref | FleetOps panel | Reviewed by | Cadence |
|
||||
| KPI | Status | SQL ref | Grafana panel | Reviewed by | Cadence |
|
||||
|---|---|---|---|---|---|
|
||||
| Speeding events per 100 km | Proposed | `01_BusinessAnalytics.md §3.1` | — | — | Weekly |
|
||||
| Harsh driving index | Proposed | `01_BusinessAnalytics.md §3.2` | — | — | Weekly |
|
||||
|
|
@ -55,7 +55,7 @@ Each active KPI should link to:
|
|||
|
||||
### Asset Health & Cost
|
||||
|
||||
| KPI | Status | SQL ref | FleetOps panel | Reviewed by | Cadence |
|
||||
| KPI | Status | SQL ref | Grafana panel | Reviewed by | Cadence |
|
||||
|---|---|---|---|---|---|
|
||||
| Estimated idle fuel cost (KES) | Proposed | `01_BusinessAnalytics.md §2.2` | — | — | Monthly |
|
||||
| Vehicles at service threshold | Proposed | TBD | — | — | Weekly |
|
||||
|
|
@ -96,6 +96,6 @@ Adjust with client after first month of live data:
|
|||
- [ ] Confirm shift hours (start, end, lunch, working days)
|
||||
- [ ] Confirm SLA tiers (home vs business customer)
|
||||
- [ ] Confirm which KPIs the ops manager wants on a daily digest
|
||||
- [ ] Confirm reporting format (FleetOps link, PDF, WhatsApp summary)
|
||||
- [ ] Confirm reporting format (Grafana link, PDF, WhatsApp summary)
|
||||
- [ ] Identify job management system / ticketing tool for MTTA/MTTR
|
||||
- [ ] Confirm vehicle categories (motorcycle, van, 4WD) for per-type benchmarks
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,243 +0,0 @@
|
|||
# Staging Environment & FleetOps Split — Architecture
|
||||
|
||||
**Status:** ✅ **delivered 2026-06-10** (phase closed) · **Owner:** kianiadee · **Audience:** both
|
||||
developers (mixed technical/ops background — readable without prior context).
|
||||
|
||||
> **As-built (2026-06-10).** All six surfaces are live: staging `fleetapi` / `fleetops` /
|
||||
> `fleetnow` `.fivetitude.com`, and prod `fleetops.rahamafresh.com` + `/analytics/*` on
|
||||
> `fleetapi.rahamafresh.com`. The dedicated read-only `dashboard_ro` role backs the staging
|
||||
> bridge **and now the prod bridge too** (stage 2 done — reads via `dashboard_ro`, the `v_trips`
|
||||
> refresher keeps a privileged connection via `REFRESH_DATABASE_URL`). Migrations 17 + 18 applied.
|
||||
> Both Forgejo→Coolify webhooks (FleetOps, FleetNow) registered + active. The client's live
|
||||
> FleetNow map was unaffected throughout. **Remaining:** review/merge tracksolid PR #17.
|
||||
|
||||
This document describes how we (a) introduce a **staging environment** under the
|
||||
`fivetitude.com` umbrella so the production FleetNow map is never edited directly, and (b)
|
||||
**split the product** into two surfaces: **FleetNow** (live tracking) and **FleetOps** (fleet
|
||||
operations — fuel, analytics, KPIs).
|
||||
|
||||
> **No secrets here.** All connection values come from `.env` at runtime — see
|
||||
> [`CONNECTIONS.md`](CONNECTIONS.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this change
|
||||
|
||||
FleetNow (`fleetnow.rahamafresh.com`) is now the client's **production** map, so we can no
|
||||
longer make feature changes or run tests directly against it. Separately, the client asked us
|
||||
to separate **fleet tracking** from **fleet operations** (fuel management, analytics). That
|
||||
gives us two needs:
|
||||
|
||||
1. A **staging environment** that mirrors production for safe development and testing.
|
||||
2. A **new FleetOps surface** (`fleetops.rahamafresh.com`) distinct from the tracking map.
|
||||
|
||||
### Decisions on record
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Staging umbrella domain | **`fivetitude.com`** — DNS is a **wildcard** (`*.fivetitude.com` → the VPS), so staging subdomains need **no per-host DNS records**, only Traefik/Coolify host rules |
|
||||
| FleetOps surface | **New custom SPA** (FleetNow-style), consuming an extended `dashboard_api` — *not* Grafana |
|
||||
| Staging data backing | **Full stack reading the shared production `reporting.*` read-layer** (read-only, no DB duplication) |
|
||||
| Deploy mechanism | **Forgejo → Coolify webhook deploys** across all Coolify apps (replaces polling/manual) |
|
||||
| FleetOps web server | **Caddy** (greenfield) for the cleaner Caddyfile + native `{env.*}` API-base injection. Chosen for config ergonomics, **not** TLS — Traefik already terminates TLS. Existing nginx SPAs stay as-is (mixed fleet until FleetNow's next touch) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Target topology
|
||||
|
||||
| Environment | FleetNow (tracking) | FleetOps (operations) | Read-API |
|
||||
|---|---|---|---|
|
||||
| **Production** (`rahamafresh.com`) | `fleetnow.rahamafresh.com` — *frozen* | `fleetops.rahamafresh.com` — **new** | `fleetapi.rahamafresh.com` |
|
||||
| **Staging** (`fivetitude.com`) | `fleetnow.fivetitude.com` | `fleetops.fivetitude.com` | `fleetapi.fivetitude.com` |
|
||||
|
||||
- Every product surface (FleetNow/FleetOps × prod/staging) is a **Coolify app** (Dockerfile →
|
||||
static web server), one app per cell, each bound to its own git branch. **FleetOps uses
|
||||
Caddy** (clean Caddyfile, native `{env.*}` for the per-env API base); the existing FleetNow
|
||||
and the two legacy SPAs remain on **nginx**. Both are plain `:80` file servers — **Traefik
|
||||
terminates TLS**, so Caddy's auto-HTTPS is intentionally unused.
|
||||
- The read-API (`dashboard_api`) is a **standalone Traefik-labelled bridge container** — *not*
|
||||
Coolify-managed. It is deployed by a host script and gains a **second staging instance**.
|
||||
- **Staging reads the same production TimescaleDB** over the internal Docker network, but as a
|
||||
**read-only role** with the materialized-view refresher **disabled** (see §6).
|
||||
|
||||
```
|
||||
┌─────────────────────────── VPS (31.97.44.246) ───────────────────────────┐
|
||||
PRODUCTION │ │
|
||||
fleetnow.raha… ──────┼─► Coolify app (FleetNow:main) ─┐ │
|
||||
fleetops.raha… ──────┼─► Coolify app (FleetOps:main) ─┼─► fleetapi.rahamafresh.com (bridge:8890) │
|
||||
│ │ │ app role (rw) + refresher │
|
||||
│ │ ▼ │
|
||||
STAGING │ │ ┌──────────────────────────┐ │
|
||||
fleetnow.fivet… ─────┼─► Coolify app (FleetNow:staging)┼──►│ tracksolid_db │ │
|
||||
fleetops.fivet… ─────┼─► Coolify app (FleetOps:staging)┼─┐ │ reporting.* / v_trips MV │ │
|
||||
│ │ └►│ tracksolid.v_* │ │
|
||||
│ fleetapi.fivetitude.com ──────┘ └──────────────────────────┘ │
|
||||
│ (bridge:8891, read-only role, refresher OFF) │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. The two read-API instances
|
||||
|
||||
The API code is `dashboard_api_rev.py` **in this repo**. Production is deployed by
|
||||
`~/deploy_dashboard_api.sh` (bind-mounts `~/dashboard_api/dashboard_api_rev.py`, **port 8890**,
|
||||
Traefik host `fleetapi.rahamafresh.com`). Staging mirrors it:
|
||||
|
||||
| | Production | Staging |
|
||||
|---|---|---|
|
||||
| Host rule | `fleetapi.rahamafresh.com` | `fleetapi.fivetitude.com` |
|
||||
| Port | 8890 | **8891** |
|
||||
| Code mount | `~/dashboard_api/` | `~/dashboard_api_staging/` (WIP checkout) |
|
||||
| Deploy script | `~/deploy_dashboard_api.sh` | **`deploy_dashboard_api_staging.sh`** (checked into this repo) |
|
||||
| DB role | app role (read/write) | **read-only** `dashboard_ro` (dedicated) |
|
||||
| `v_trips` refresher | **owns it** | **disabled** |
|
||||
| CORS origins | `fleetnow.rahamafresh.com`, `fleetintelligence.…`, `liveposition.…`, **+ `fleetops.rahamafresh.com`** | `fleetnow.fivetitude.com`, `fleetops.fivetitude.com` |
|
||||
|
||||
> **CORS must be set unconditionally** in the deploy script (strip any inherited value) — this
|
||||
> is the [FIX-D03](../CLAUDE.md) lesson. Env/CORS changes require a container **recreate**, not
|
||||
> a restart.
|
||||
|
||||
### Analytics endpoints (FleetOps)
|
||||
|
||||
FleetOps consumes new **read-only** routes added to `dashboard_api_rev.py`, reusing the
|
||||
existing psycopg2 pool (`ts_shared_rev.py`), the Content-Type body-parse pattern (FIX-D01), and
|
||||
the JSONB/GeoJSON return style of the existing `/webhook/*` routes:
|
||||
|
||||
| Route | Backed by |
|
||||
|---|---|
|
||||
| `GET /analytics/fleet-summary` | `reporting.v_daily_summary` / `v_weekly_summary` / `v_monthly_summary` + `v_daily_cost_centre` |
|
||||
| `GET /analytics/utilisation` | derived from the `reporting` summaries (idle_pct, km/day) |
|
||||
| `GET /analytics/driver-behaviour` | `tracksolid.v_driver_aggregates_daily` |
|
||||
| `GET /analytics/fuel` | `reporting.v_fuel_daily` (migration 17 — wraps `v_trips.fuel_consumed_l` + `devices.fuel_100km`) — **data-gated** (returns "needs data" flags until populated) |
|
||||
| `GET /analytics/filters` | `reporting.v_filter_*` (alias of `GET /webhook/fleet-dashboard`) |
|
||||
|
||||
Aggregations that aren't thin wrappers get a **new numbered migration** — never edit an applied
|
||||
one. The fuel roll-up ships as `migrations/17_fleetops_fuel_view.sql` (the live migration head was
|
||||
**16**, not 13 as older docs imply; 17 + 18 are now applied). `dashboard_ro` reads `v_fuel_daily`
|
||||
via the schema-wide `SELECT` grant in `scripts/dashboard_ro_role.sql`.
|
||||
|
||||
> **Reuse the existing reporting layer.** The analytics building blocks are `reporting.*`
|
||||
> (migrations 11/14) and the surviving `tracksolid.v_*` views (migration 07). The `ops.*` and
|
||||
> `dwh_gold.*` schemas were **purged 2026-06-05** (migrations 12/13) — do **not** reference
|
||||
> `ops.*`, `dwh_gold.*`, `v_utilisation_daily`, or `v_sla_inflight`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deploy & promotion (Forgejo → Coolify webhooks)
|
||||
|
||||
All Coolify apps move from polling/manual to **webhook-driven** deploys. For each app, take
|
||||
Coolify's per-app **deploy webhook URL** (+ token) and register it as a **push webhook in the
|
||||
matching Forgejo repo**, scoped to the bound branch.
|
||||
|
||||
**Promotion model** (both FleetNow and FleetOps):
|
||||
|
||||
```
|
||||
feature branch ──merge──► staging ──(Forgejo webhook)──► Coolify deploys *.fivetitude.com
|
||||
│ validate
|
||||
main ◄──merge──────────────────────┘
|
||||
│
|
||||
└──(Forgejo webhook)──► Coolify deploys *.rahamafresh.com (prod)
|
||||
```
|
||||
|
||||
Production is touched **only** by a merge to `main`. That branch discipline is what satisfies
|
||||
"no direct changes to production FleetNow."
|
||||
|
||||
> **Exception:** the `dashboard_api` bridge is **not** Coolify-managed and does **not** deploy
|
||||
> via Forgejo webhook — it is deployed by its host script (`deploy_dashboard_api*.sh`). The API
|
||||
> code's source of truth is this repo; the staging instance bind-mounts a WIP checkout so new
|
||||
> endpoints are validated on `fleetapi.fivetitude.com` before the file is promoted to
|
||||
> `~/dashboard_api/` on prod.
|
||||
|
||||
---
|
||||
|
||||
## 5. FleetOps SPA (new repo)
|
||||
|
||||
- **Remote:** `https://repo.rahamafresh.com/kianiadee/fleetops.git`
|
||||
- **Local working copy:** `~/Downloads/projects/15_fleetops` (scaffolded from empty)
|
||||
- **Shape:** FleetNow-style deploy flow, but **Dockerfile → Caddy** via Coolify; branded for
|
||||
operations/analytics. The Caddyfile is a ~5-line SPA server (`try_files {path} /index.html`,
|
||||
`encode zstd gzip`) on `:80` behind Traefik.
|
||||
- **API base URL is build/runtime configurable** via Caddy's native `{env.API_BASE}`
|
||||
substitution (set per Coolify app): staging → `fleetapi.fivetitude.com`, prod →
|
||||
`fleetapi.rahamafresh.com`.
|
||||
- **FleetNow** gets the same treatment in *its own* repo: a `staging` branch and a
|
||||
parameterized API base URL (assumed currently hardcoded to `fleetapi.rahamafresh.com`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Safety — staging on the shared production read-layer
|
||||
|
||||
Staging hits the **production database**, so isolation is enforced at the **DB-role level**,
|
||||
not by a separate DB:
|
||||
|
||||
- The staging `dashboard_api` connects as a **dedicated read-only role `dashboard_ro`**
|
||||
(`scripts/dashboard_ro_role.sql` + `scripts/bootstrap_dashboard_ro.sh`). It grants exactly
|
||||
what the API reads — `SELECT` on `reporting.*` + `tracksolid.*`, an explicit `SELECT` on the
|
||||
`reporting.v_trips` **materialized view** (matviews aren't covered by `GRANT ... ON ALL
|
||||
TABLES`), `EXECUTE` on the `reporting.fn_*` map functions, and `ALTER DEFAULT PRIVILEGES` so
|
||||
future objects are auto-readable ("dynamic"). No write/REFRESH privilege, so accidental writes
|
||||
are impossible. The password is generated on the host into `~/.dashboard_ro.pw` (0600), never
|
||||
in the repo. **Both stages done:** staging *and* prod bridges now connect reads via
|
||||
`dashboard_ro`. Because the prod bridge also runs the `v_trips` refresher (which needs write
|
||||
perms `dashboard_ro` lacks), the refresher uses a **separate privileged connection** —
|
||||
`REFRESH_DATABASE_URL` (the app/superuser role); `DATABASE_URL` is `dashboard_ro`. Staging
|
||||
keeps the refresher disabled, so it needs only `dashboard_ro`.
|
||||
- The **`reporting.v_trips` materialized-view refresher is disabled on staging** — production
|
||||
owns it. The refresher needs write perms and is already pg-advisory-lock guarded (key
|
||||
`920_145`, FIX-D02); a read-only staging role would only log errors, so disable it explicitly
|
||||
(refresh interval `0` / env guard).
|
||||
- New `/analytics/*` queries stay backed by the **indexed `reporting.*` views / matview**, not
|
||||
raw hypertable scans, so staging traffic doesn't load the prod DB.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased rollout
|
||||
|
||||
Ordered by dependency and risk — prove the foundation and the deploy pipeline first; touch the
|
||||
client's production domains **last**.
|
||||
|
||||
| Phase | Scope | Status (as-built) |
|
||||
|---|---|---|
|
||||
| **0 — Foundation** | This document; provision the read-only `dashboard_ro` role; migrate Coolify apps to Forgejo webhook deploys | ✅ doc + `dashboard_ro` role done; webhook mechanism proven (FleetOps-staging live) — **FleetOps-prod + FleetNow-staging webhook registration is the remaining op follow-up** |
|
||||
| **1 — Staging backbone** | Staging `dashboard_api` bridge (`deploy_dashboard_api_staging.sh`, 8891, `fleetapi.fivetitude.com`, `dashboard_ro`, refresher off, staging CORS) | ✅ live; verified read-only; no staging rows in `reporting.refresh_log` |
|
||||
| **2 — FleetNow staging** | FleetNow repo: `staging` branch + `/env.js` nginx `envsubst` API-base injection + `fleetnow.fivetitude.com` Coolify app | ✅ live against staging API; prod FleetNow (`main`) untouched |
|
||||
| **3 — FleetOps backend** | `/analytics/*` endpoints in `dashboard_api_rev.py` + `migrations/17` (+ `18` grants); refresher disable-able (`VTRIPS_REFRESH_INTERVAL_S<=0`) | ✅ all routes verified on staging then promoted to prod; fuel route data-gated |
|
||||
| **4 — FleetOps SPA** | `fleetops.git` (`~/Downloads/projects/15_fleetops`), Caddy SPA + `/env.js` injection; `fleetops.fivetitude.com` Coolify app | ✅ live; auto-deploy verified push→live |
|
||||
| **5 — Production cutover** | Promote API to prod + prod CORS add; `fleetops.rahamafresh.com` Coolify app (`main`); docs | ✅ FleetOps live on prod with real data; prod FleetNow/API otherwise unchanged (one intentional CORS add); `rahamafresh.com` confirmed wildcard (no DNS record needed) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification checklist
|
||||
|
||||
1. **Staging API up:** `curl -f https://fleetapi.fivetitude.com/health` → `{status: ok}`;
|
||||
resolve the container via `docker ps --filter name=dashboard_api_staging`.
|
||||
2. **Read-only enforced:** a write attempt from the staging role fails; **no**
|
||||
`reporting.refresh_log` rows carry a staging source.
|
||||
3. **Analytics:** hit each `/analytics/*` on staging, diff the JSON against the underlying view
|
||||
output via `docker exec $DB psql`; fuel returns "needs data" flags.
|
||||
4. **CORS:** browser-load `fleetops.fivetitude.com` and `fleetnow.fivetitude.com`; XHRs to
|
||||
`fleetapi.fivetitude.com` succeed; prod `fleetops.rahamafresh.com` reaches the prod API.
|
||||
5. **Webhook promotion:** push to `staging` → Forgejo webhook fires → **only** the
|
||||
`*.fivetitude.com` app redeploys (check Coolify deploy log + Forgejo webhook delivery);
|
||||
merge to `main` → only the `*.rahamafresh.com` app redeploys.
|
||||
6. **Prod FleetNow untouched:** prod `fleetnow`/`fleetapi` containers not recreated except the
|
||||
intentional prod-CORS add.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks & open items
|
||||
|
||||
- **FleetNow API-base** parameterization assumes it's currently hardcoded — confirm in that repo.
|
||||
- **Shared-DB load:** staging traffic is light, but watch the prod DB if staging analytics
|
||||
queries get heavy; the read-only role + indexed views are the guardrails.
|
||||
- **Fuel analytics are data-blocked:** `devices.fuel_100km` is NULL fleet-wide and the
|
||||
`/pushoil` + `/pushobd` webhooks aren't registered, so FleetOps fuel views ship as scaffold
|
||||
until those Open Items (CLAUDE.md §10) are closed.
|
||||
- **Naming trap:** `stage.rahamafresh.com` is the *production* host alias (a legacy name). Keep
|
||||
all real staging under `*.fivetitude.com` to avoid confusion.
|
||||
|
||||
---
|
||||
|
||||
*Related: [`CONNECTIONS.md`](CONNECTIONS.md) · [`PLATFORM_OVERVIEW.html`](PLATFORM_OVERVIEW.html) ·
|
||||
root `CLAUDE.md` (§3 map dashboards, §7 fix history).*
|
||||
385
docs/manuals/DWH_Execution_Manual.md
Normal file
385
docs/manuals/DWH_Execution_Manual.md
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
# DWH Execution Manual
|
||||
|
||||
> **Purpose:** A reusable playbook for building an `extract → blob → load` data warehouse bronze layer using n8n (or any equivalent orchestrator) + object storage + PostgreSQL/PostGIS. Generalised from the Fireside Tracksolid DWH pipeline (2026-04-24). Apply this pattern to future data projects to skip re-deriving the same decisions.
|
||||
>
|
||||
> **Reference implementation:** `dwh/26100{1..4}.sql` + `docs/DWH_PIPELINE.md` + the two `n8n-workflows/dwh_*.json` files. Treat those as the copy-paste template for the next project.
|
||||
|
||||
---
|
||||
|
||||
## 1. When to Use This Pattern
|
||||
|
||||
**Use it when all of these are true:**
|
||||
- You need an analytical replica of a production OLTP DB without letting analytical load hit the OLTP.
|
||||
- Source and target are separate PostgreSQL instances (possibly separate networks).
|
||||
- Data volumes are moderate: millions of rows per day, not billions.
|
||||
- You already have an orchestrator (n8n, Airflow, Prefect) and object storage (rustfs, S3, MinIO) in the stack.
|
||||
- Latency tolerance is hours, not minutes.
|
||||
|
||||
**Don't use it when:**
|
||||
- Sub-minute latency is required → use logical replication or CDC (Debezium, pg_logical, AWS DMS).
|
||||
- Volumes exceed ~100 GB/day → step up to Spark/Flink + columnar store (Iceberg, Delta).
|
||||
- Source and target are the same DB → just use materialized views or scheduled SQL.
|
||||
- You need exactly-once streaming semantics → this pattern is at-least-once + idempotent load.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Core Pattern
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Source DB │──(a)──▶ Orchestr. │──(b)──▶ Object Store │──(c)──▶ Target DB │
|
||||
│ (OLTP) │ │ (extract) │ │ (CSVs) │ │ (bronze) │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
│ ▲
|
||||
└──────────────(d)─────────────────────────────┘
|
||||
(load workflow, per CSV)
|
||||
|
||||
(a) Watermarked SELECT, closed upper bound
|
||||
(b) Atomic CSV upload with timestamped path
|
||||
(c) CSV stays until load confirms success
|
||||
(d) Load = BEGIN → INSERT ON CONFLICT → UPDATE watermark → UPDATE run log → COMMIT → move CSV
|
||||
```
|
||||
|
||||
**Why three stages and not two:**
|
||||
- **Audit trail** — every extracted CSV is a point-in-time snapshot you can replay.
|
||||
- **Decoupling** — target DB downtime doesn't lose data; the CSV waits.
|
||||
- **Observability** — blob listings are a second source of truth independent of the DB.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pre-flight Checklist
|
||||
|
||||
Before writing any SQL or workflow JSON, confirm in writing:
|
||||
|
||||
- [ ] Source DB reachable from orchestrator (internal network preferred, VPN/public IP with `sslmode=require` otherwise).
|
||||
- [ ] Target DB reachable; you hold a superuser credential for one-time DDL.
|
||||
- [ ] Object storage bucket exists; credentials are configured in the orchestrator.
|
||||
- [ ] For each source table to extract, you have identified:
|
||||
- A **DB-insertion timestamp column** (not device/user-reported time), or "it's a snapshot table".
|
||||
- A **natural unique key** that already has a `PRIMARY KEY` or `UNIQUE` constraint on source (for the `ON CONFLICT` target on bronze).
|
||||
- Any **unit/column drift** between source and target (e.g., `distance_m` vs. `distance_km`).
|
||||
- [ ] Acceptable end-to-end latency (to calibrate cron cadence).
|
||||
- [ ] Security baseline: who writes bronze, who reads it, SSL requirement, password rotation cadence.
|
||||
|
||||
If any row is unchecked, pause and resolve it. Skipping this step is the #1 cause of pipelines that "worked in test but lose data in prod."
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase-by-Phase Execution
|
||||
|
||||
Execute in order. Phases are independent of each other within their phase, but phases have strict dependencies.
|
||||
|
||||
### Phase A — Target DB preparation
|
||||
|
||||
Apply three types of migrations, in numeric order:
|
||||
|
||||
1. **Bronze DDL** — one table per source table. Use `IF NOT EXISTS`; make it idempotent.
|
||||
2. **Control schema** — `dwh_control.extract_watermarks` + `dwh_control.extract_runs`.
|
||||
3. **Assertion migrations** — verify roles exist, verify every `ON CONFLICT` target is backed by a PK/UNIQUE (fail loudly if not).
|
||||
|
||||
Template files: `dwh/260423_dwh_ddl_v1.sql`, `dwh/261001_dwh_control.sql`, `dwh/261002_bronze_constraints_audit.sql`, `dwh/261003_dwh_roles.sql`.
|
||||
|
||||
**Role model:**
|
||||
- `<proj>_owner` — owns schemas, writes bronze + control tables.
|
||||
- `<proj>_ro` (or `grafana_ro`) — reads everything, writes nothing.
|
||||
- Never use `postgres` or another superuser from the orchestrator.
|
||||
|
||||
**Watermark seed:** set `last_extracted_at` to a date before any real data (`'2000-01-01T00:00:00Z'` is safe) so the first run back-fills all history in a single CSV per table.
|
||||
|
||||
### Phase B — Object storage
|
||||
|
||||
Create two prefixes per table:
|
||||
|
||||
```
|
||||
s3://<bucket>/<project>/exports/{table}/ # active CSVs, in-flight
|
||||
s3://<bucket>/<project>/processed/{table}/ # loaded CSVs, never deleted (audit)
|
||||
```
|
||||
|
||||
Naming convention: `{YYYYMMDD_HHMM}_{TZ}.csv` (e.g., `20260424_1400_EAT.csv`). Timezone in the filename because "08:00" means nothing a year from now without it.
|
||||
|
||||
Retention: match whatever backup retention is already in the stack (e.g., 30 days). `processed/` should outlive `exports/`.
|
||||
|
||||
### Phase C — Orchestrator credentials
|
||||
|
||||
Three credentials:
|
||||
|
||||
| Credential | Role | Purpose |
|
||||
|---|---|---|
|
||||
| `<proj>_source` | Read-only role on source DB | Extract queries |
|
||||
| `<proj>_dwh_target` | `<proj>_owner` on target DB | Bronze writes + control updates |
|
||||
| `<proj>_s3` | IAM user with `s3:PutObject`, `s3:GetObject`, `s3:ListBucket`, `s3:DeleteObject` on the prefix | CSV upload/download/move |
|
||||
|
||||
**Always** `sslmode=require` on any public-IP DB connection. Test each credential with the orchestrator's "Test connection" button before proceeding.
|
||||
|
||||
### Phase D — Load workflow (build this BEFORE the extract workflow)
|
||||
|
||||
Building load first lets you iterate with hand-crafted CSVs in blob storage before wiring up extract. Much faster feedback loop.
|
||||
|
||||
Load workflow input (parameters):
|
||||
```json
|
||||
{
|
||||
"table": "position_history",
|
||||
"csv_path": "s3://bucket/project/exports/position_history/20260424_1400_EAT.csv",
|
||||
"run_id": 12345,
|
||||
"run_started_at": "2026-04-24T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Load workflow steps:
|
||||
1. Download CSV from blob storage.
|
||||
2. Parse CSV into rows.
|
||||
3. **Open transaction.**
|
||||
4. `INSERT INTO bronze.<table> (...) VALUES (...) ON CONFLICT (<natural_key>) DO NOTHING;`
|
||||
5. `UPDATE dwh_control.extract_watermarks SET last_extracted_at = :run_started_at, last_loaded_at = NOW(), rows_loaded_last_run = <count> WHERE table_name = :table;`
|
||||
6. `UPDATE dwh_control.extract_runs SET status = 'loaded', run_finished_at = NOW(), rows_loaded = <count> WHERE run_id = :run_id;`
|
||||
7. **Commit.**
|
||||
8. Move CSV from `exports/` to `processed/` (copy-then-delete; never delete before copy confirms).
|
||||
|
||||
**Non-negotiable invariants:**
|
||||
- Steps 3–7 are one transaction. If any fails, all rollback.
|
||||
- Step 8 only runs after commit. If step 8 fails, the next run will re-load the CSV (idempotent via ON CONFLICT) — not a data loss event.
|
||||
|
||||
### Phase E — Extract workflow
|
||||
|
||||
Extract workflow steps, per table:
|
||||
|
||||
1. Read current watermark: `SELECT last_extracted_at FROM dwh_control.extract_watermarks WHERE table_name = :table;`
|
||||
2. Capture `run_started_at = NOW()` (in the target DB's clock, not the orchestrator's — reduces clock-skew bugs).
|
||||
3. `INSERT INTO dwh_control.extract_runs (table_name, run_started_at, status) VALUES (:table, :run_started_at, 'extracting') RETURNING run_id;`
|
||||
4. Query source with closed upper bound:
|
||||
```sql
|
||||
SELECT <cols>
|
||||
FROM <source_schema>.<table>
|
||||
WHERE <watermark_col> > :last_extracted_at
|
||||
AND <watermark_col> <= :run_started_at
|
||||
ORDER BY <watermark_col>;
|
||||
```
|
||||
5. Render rows as CSV. For geometry columns: `CASE WHEN geom IS NULL THEN NULL ELSE ST_AsEWKT(geom) END`.
|
||||
6. Upload CSV to `s3://bucket/project/exports/{table}/{YYYYMMDD_HHMM}_{TZ}.csv`.
|
||||
7. `UPDATE dwh_control.extract_runs SET status = 'uploaded', rows_extracted = <count>, csv_path = :path WHERE run_id = :run_id;`
|
||||
8. Call load workflow with `{table, csv_path, run_id, run_started_at}`.
|
||||
|
||||
### Phase F — Schedule + observability
|
||||
|
||||
**Cron cadence:** start with 6–8 runs/day during active hours. Fold the overnight gap where traffic is low. Example: `0 5,8,11,14,17,20,23 * * *` TZ `Africa/Nairobi`.
|
||||
|
||||
**Three observability views** (readable by the RO role):
|
||||
|
||||
- `v_table_freshness` — per-table lag from last successful load. Drives the freshness alert.
|
||||
- `v_recent_failures` — failed runs in last 24h. Zero rows = healthy.
|
||||
- `v_watermark_lag` — extract vs. load lag per table. Distinguishes "nothing to extract" from "stuck".
|
||||
|
||||
Template file: `dwh/261004_dwh_observability_views.sql`.
|
||||
|
||||
**Grafana panels** (add at minimum):
|
||||
1. Freshness panel — red if any row in `v_table_freshness` has `lag > 4h`.
|
||||
2. Failures panel — red if `v_recent_failures` has any row.
|
||||
3. Row counts panel — daily bar chart from `extract_runs`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Design Principles (Do Not Skip)
|
||||
|
||||
### 5.1 Watermark on DB insertion time, not source-reported time
|
||||
|
||||
The watermark column must be "when the target DB got the row", not "when the device/user said it happened". Device clocks skew, webhooks arrive late, and batch imports backdate records. A source-reported watermark will silently drop rows that arrive out of order. Use `recorded_at`, `created_at`, `updated_at` (with `DEFAULT NOW()`), or `ingested_at` — never `gps_time` / `event_time` / `timestamp`.
|
||||
|
||||
### 5.2 Closed upper bound
|
||||
|
||||
Extract uses `> last_extracted_at AND <= run_started_at`. The closed upper bound prevents "row committed at `NOW()` during the extract query" from appearing in two adjacent runs. Without it, some rows are double-extracted (wasteful) or missed (data loss).
|
||||
|
||||
### 5.3 Idempotent load via natural unique keys
|
||||
|
||||
Every incremental bronze table needs a PRIMARY KEY or UNIQUE that matches the source's natural unique key. `ON CONFLICT DO NOTHING` makes re-running a CSV harmless. **Do not invent surrogate keys on bronze** — they defeat the ON CONFLICT guarantee. If the source has no natural key, fix the source or accept the table as a snapshot.
|
||||
|
||||
### 5.4 Transactional load boundary
|
||||
|
||||
Insert + watermark update + run-log update are one transaction. Splitting them creates "ghost" states where watermark advanced but rows didn't load, causing silent holes.
|
||||
|
||||
### 5.5 CSV audit trail — never delete
|
||||
|
||||
Moved-to-`processed/` CSVs are cheap ($0.023/GB/month on S3-class storage). They pay for themselves the first time you need to replay a window or debug a row-count mismatch.
|
||||
|
||||
### 5.6 PostGIS round-trip via EWKT
|
||||
|
||||
`ST_AsEWKT(geom)` on extract, `ST_GeomFromEWKT(ewkt)` on load. Preserves SRID inline. Do NOT store `ST_AsText` + separate SRID column — it doubles the chance of mismatch. Guard NULLs: `CASE WHEN geom IS NULL THEN NULL ELSE ST_AsEWKT(geom) END`.
|
||||
|
||||
### 5.7 Fail loud, fail early
|
||||
|
||||
Audit migrations (roles, constraints) should `RAISE EXCEPTION` with a bullet list of what's missing. Silent success is worse than noisy failure — a missing PK surfaces three months later as "why are there duplicate trips?".
|
||||
|
||||
---
|
||||
|
||||
## 6. Snapshot vs. Incremental Decision Matrix
|
||||
|
||||
| Signal | Snapshot (TRUNCATE + reload) | Incremental (watermark + append) |
|
||||
|---|---|---|
|
||||
| Row count | < ~10k | > ~10k |
|
||||
| Meaning of "current state" | Matters | Doesn't matter; history matters |
|
||||
| Deletes in source | Common | Rare |
|
||||
| Update frequency per row | High | Low (append-mostly) |
|
||||
| Natural unique key | May not exist | Must exist |
|
||||
| Example | `devices`, `live_positions`, `geofences` | `position_history`, `trips`, `alarms`, event logs |
|
||||
|
||||
When in doubt: **snapshot is simpler**. Only escalate to incremental when the snapshot CSV would exceed a few MB per run.
|
||||
|
||||
---
|
||||
|
||||
## 7. Observability Contract
|
||||
|
||||
Every pipeline adds these three views to its control schema — no exceptions:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW <control>.v_table_freshness AS
|
||||
SELECT table_name,
|
||||
MAX(run_finished_at) AS last_loaded_at,
|
||||
NOW() - MAX(run_finished_at) AS lag,
|
||||
COUNT(*) FILTER (WHERE run_started_at > NOW() - INTERVAL '24 hours') AS loads_last_24h
|
||||
FROM <control>.extract_runs
|
||||
WHERE status = 'loaded'
|
||||
GROUP BY table_name;
|
||||
|
||||
CREATE OR REPLACE VIEW <control>.v_recent_failures AS
|
||||
SELECT run_id, table_name, run_started_at, run_finished_at, csv_path, error_message
|
||||
FROM <control>.extract_runs
|
||||
WHERE status = 'failed' AND run_started_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY run_started_at DESC;
|
||||
|
||||
CREATE OR REPLACE VIEW <control>.v_watermark_lag AS
|
||||
SELECT table_name, last_extracted_at, last_loaded_at, rows_loaded_last_run,
|
||||
NOW() - last_loaded_at AS load_lag,
|
||||
NOW() - last_extracted_at AS extract_lag
|
||||
FROM <control>.extract_watermarks;
|
||||
```
|
||||
|
||||
Wire a Grafana alert on each view. Test the alert by manually failing a run before go-live.
|
||||
|
||||
---
|
||||
|
||||
## 8. Schema Drift Handling
|
||||
|
||||
Schema drift between source and bronze is inevitable. Two rules:
|
||||
|
||||
1. **Detect at design time.** Diff source DDL against bronze DDL before writing any extract SQL. Unit changes (metres vs. km), renamed columns, and added nullable columns are the usual suspects.
|
||||
2. **Fix in the extract query, not the load.** Put all transformations in the SELECT so the CSV on disk already matches the bronze column names and units. The load workflow should be dumb — CSV column N goes to bronze column N.
|
||||
|
||||
Document every drift in the runbook (§5 of the operations runbook). Future developers WILL hit them.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verification Gates
|
||||
|
||||
### Pre-deploy (before first cron tick)
|
||||
|
||||
- [ ] Every migration applied successfully.
|
||||
- [ ] Control tables seeded (one watermark row per incremental table).
|
||||
- [ ] Every credential's "Test connection" passes.
|
||||
- [ ] Blob storage prefixes exist.
|
||||
- [ ] Manual workflow trigger succeeds end-to-end for one table.
|
||||
|
||||
### First run (manual trigger of extract workflow)
|
||||
|
||||
- [ ] Every processed table has a row in `extract_runs` with `status='loaded'`.
|
||||
- [ ] Row-count parity with source (± in-flight writes): `SELECT COUNT(*) FROM <source>` vs. `SELECT COUNT(*) FROM bronze.<table>`.
|
||||
- [ ] Geometry columns round-trip cleanly: `SELECT ST_AsText(geom) FROM bronze.<table> LIMIT 5` returns valid POINTs.
|
||||
- [ ] All CSVs moved from `exports/` to `processed/`.
|
||||
|
||||
### Steady-state (after 24h / first full schedule cycle)
|
||||
|
||||
- [ ] `v_table_freshness` shows lag < cadence × 2 for every table.
|
||||
- [ ] `v_recent_failures` is empty.
|
||||
- [ ] Row counts in bronze growing at expected rate.
|
||||
|
||||
Only declare "done" after all three gates pass.
|
||||
|
||||
---
|
||||
|
||||
## 10. Scheduling Calibration
|
||||
|
||||
Tradeoffs:
|
||||
|
||||
| Cadence | Pros | Cons |
|
||||
|---|---|---|
|
||||
| Every 15 min | Low lag, small CSVs | High orchestrator churn, noisy alerts |
|
||||
| Every 3 h (recommended) | Predictable, fits ops windows, tolerable lag | Overnight backlog carries to morning |
|
||||
| Nightly (once/day) | Cheap, simple | Unacceptable for real-time panels |
|
||||
|
||||
Rule of thumb: cadence = 25–50% of your latency tolerance. 4h latency budget → 1-2h cadence.
|
||||
|
||||
Fold cadence around traffic patterns. Don't run 24× at 1-hour intervals if the source generates zero rows between midnight and 05:00.
|
||||
|
||||
---
|
||||
|
||||
## 11. Common Failure Modes & Recovery
|
||||
|
||||
| Failure | Symptom | Fix |
|
||||
|---|---|---|
|
||||
| CSV stuck in `exports/` | `v_recent_failures` has a row; CSV never moved | Next scheduled run retries automatically (idempotent). If persistent, open orchestrator logs by `run_id`. |
|
||||
| Table marked `loading` for >1 cadence | n8n executor crashed mid-transaction | DB rolled back. Next run retries. If stuck >2 cadences, manually re-trigger the extract. |
|
||||
| Row counts diverge > 1% | CSV parse error silently dropped rows | `rows_extracted != rows_loaded` in `extract_runs` — inspect the CSV for malformed rows. |
|
||||
| Geometry loads as NULL | EWKT serialisation broke | Check for missing `CASE WHEN geom IS NULL` guard in extract SQL. |
|
||||
| Distance/units 1000× wrong | Schema drift not caught | Check extract SQL for the unit conversion (see §8). |
|
||||
|
||||
**Back-fill a window:**
|
||||
```sql
|
||||
UPDATE <control>.extract_watermarks
|
||||
SET last_extracted_at = NOW() - INTERVAL '24 hours'
|
||||
WHERE table_name = '<table>';
|
||||
```
|
||||
Next run re-extracts the gap. `ON CONFLICT DO NOTHING` filters duplicates.
|
||||
|
||||
**Full reseed (nuclear):**
|
||||
```sql
|
||||
UPDATE <control>.extract_watermarks
|
||||
SET last_extracted_at = '2000-01-01T00:00:00Z'
|
||||
WHERE table_name = '<table>';
|
||||
```
|
||||
Next run back-fills all history in one very large CSV. Expected; it moves to `processed/` on success.
|
||||
|
||||
---
|
||||
|
||||
## 12. Security Baseline
|
||||
|
||||
- Two roles minimum: owner (writes) and RO (reads). Never use superuser from the orchestrator.
|
||||
- `sslmode=require` on every public-IP DB connection.
|
||||
- Passwords never in committed SQL — use placeholder tokens (`CHANGE_ME_BEFORE_APPLY`) and swap in-session during apply. Document rotation in the runbook.
|
||||
- Blob storage credentials scoped to the project's prefix, not the whole bucket.
|
||||
- Rotate all credentials before go-live (don't reuse the ones that were flying around in design conversations).
|
||||
|
||||
---
|
||||
|
||||
## 13. Reusability Checklist (Applying to a New Project)
|
||||
|
||||
When starting a new data project, copy the Tracksolid DWH layout and edit these points:
|
||||
|
||||
- [ ] Rename schemas: `<proj>_control` instead of `dwh_control` if multiple DWHs share a DB.
|
||||
- [ ] Adjust `<proj>_owner` / `<proj>_ro` role names.
|
||||
- [ ] Update bucket prefix: `s3://<bucket>/<project>/exports|processed/`.
|
||||
- [ ] Re-do the snapshot/incremental decision for every source table (§6).
|
||||
- [ ] Identify watermark columns and natural unique keys for every incremental table (§5.1, §5.3).
|
||||
- [ ] Map schema drift before writing extract SQL (§8).
|
||||
- [ ] Calibrate cadence to the new project's latency budget (§10).
|
||||
- [ ] Ship the three observability views (§7) — even if nobody will look at them in week one.
|
||||
- [ ] Write the runbook from the template: follow `docs/DWH_PIPELINE.md` section-for-section.
|
||||
- [ ] Run the verification gates (§9) before declaring done.
|
||||
|
||||
---
|
||||
|
||||
## 14. Reference Implementation (Tracksolid DWH)
|
||||
|
||||
These files are the copy-paste template:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `dwh/260423_dwh_ddl_v1.sql` | Bronze DDL + roles + schemas |
|
||||
| `dwh/261001_dwh_control.sql` | Control schema (watermarks + run log) |
|
||||
| `dwh/261002_bronze_constraints_audit.sql` | ON CONFLICT key assertion |
|
||||
| `dwh/261003_dwh_roles.sql` | Role contract assertion |
|
||||
| `dwh/261004_dwh_observability_views.sql` | Freshness/failure/watermark views |
|
||||
| `docs/DWH_PIPELINE.md` | Operations runbook (troubleshooting, manual re-run, rotation) |
|
||||
| `docs/superpowers/specs/2026-04-24-n8n-dwh-bronze-pipeline-design.md` | Design spec (why each decision) |
|
||||
| `docs/superpowers/plans/2026-04-24-n8n-dwh-bronze-pipeline.md` | Task-by-task implementation plan |
|
||||
| `n8n-workflows/dwh_extract.json` | Extract workflow (reference) |
|
||||
| `n8n-workflows/dwh_load_bronze.json` | Load workflow (reference) |
|
||||
|
||||
**For the next project, fork this manual first, then adapt.** Do not re-design from scratch — the seven design principles in §5 are the parts people keep getting wrong.
|
||||
|
|
@ -30,6 +30,7 @@ After adding it: `source ~/.zshrc`
|
|||
tsdb # open interactive psql prompt
|
||||
tsdb -c "\dt tracksolid.*" # list all tracksolid tables
|
||||
tsdb -c "SELECT COUNT(*) FROM tracksolid.trips;" # run a single query
|
||||
tsdb -c "SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1);" # run nightly ETL
|
||||
```
|
||||
|
||||
### One-liner (for scripts and migrations)
|
||||
|
|
@ -64,25 +65,25 @@ JIMI TRACKSOLID PRO API
|
|||
+-- POLLING (Pull) +-- PUSH (Webhook)
|
||||
| (Fallback / Catch-up) | (Real-time)
|
||||
| |
|
||||
ingest_worker (movement + events) webhook_receiver
|
||||
(60s/5m/15m/daily polling) (FastAPI :8888)
|
||||
| |
|
||||
+----------------------------------+
|
||||
ingest_movement ingest_events webhook_receiver
|
||||
(60s/15m/daily) (5m polling) (FastAPI :8000)
|
||||
| | |
|
||||
+------------------+----------------+
|
||||
|
|
||||
timescale_db
|
||||
(PG16 + TimescaleDB + PostGIS)
|
||||
|
|
||||
dashboard_api (:8890)
|
||||
(read-API → FleetNow / FleetOps SPAs)
|
||||
grafana
|
||||
(Dashboards :3000)
|
||||
```
|
||||
|
||||
| Service | Purpose | Restart Policy |
|
||||
|---------|---------|----------------|
|
||||
| `timescale_db` | PostgreSQL 16 + TimescaleDB 2.15 + PostGIS 3 | always |
|
||||
| `ingest_worker` | GPS positions, trips, parking, device sync + alarm polling (merged movement + events) | always |
|
||||
| `ingest_movement` | GPS positions, trips, parking, device sync (polling) | always |
|
||||
| `ingest_events` | Alarm event polling (catch-up/fallback) | always |
|
||||
| `webhook_receiver` | Real-time push data from Jimi (OBD, faults, GPS, alarms, heartbeats, trips) | always |
|
||||
| `dashboard_api` | Read-API for the FleetNow / FleetOps SPAs | always |
|
||||
| `db_backup` | pg_dump → rustfs S3, scheduled | always |
|
||||
| `grafana` | Visualization dashboards (read-only DB access) | always |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ SELECT * FROM timescaledb_information.hypertables;
|
|||
```sql
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema IN ('tracksolid', 'reporting')
|
||||
WHERE table_schema IN ('tracksolid', 'dwh_gold')
|
||||
ORDER BY table_schema, table_name;
|
||||
```
|
||||
|
||||
|
|
@ -116,6 +117,8 @@ Expected tables:
|
|||
- `tracksolid.obd_readings`
|
||||
- `tracksolid.fault_codes`
|
||||
- `tracksolid.heartbeats` (hypertable)
|
||||
- `dwh_gold.dim_vehicles`
|
||||
- `dwh_gold.fact_daily_fleet_metrics`
|
||||
|
||||
**Verify hypertables are configured:**
|
||||
```sql
|
||||
|
|
@ -454,18 +457,21 @@ curl -X POST https://<your-webhook-domain>/pushobd \
|
|||
|
||||
---
|
||||
|
||||
### 2.5 dashboard_api
|
||||
### 2.5 grafana
|
||||
|
||||
```bash
|
||||
# Verify the read-API is healthy
|
||||
curl -f https://fleetapi.rahamafresh.com/health
|
||||
# Expected: {"status":"ok"}
|
||||
|
||||
# Ingest pipeline freshness (replaces the old Grafana health panels)
|
||||
curl -f https://fleetapi.rahamafresh.com/health/ingest
|
||||
# Expected: {"overall":"ok","endpoints":[...]}
|
||||
# Verify Grafana is accessible
|
||||
curl -f https://<your-grafana-domain>/api/health
|
||||
# Expected: {"commit":"...","database":"ok","version":"11.0.0"}
|
||||
```
|
||||
|
||||
**Configure data source in Grafana UI:**
|
||||
- Type: PostgreSQL
|
||||
- Host: `timescale_db:5432` (internal Docker network)
|
||||
- Database: `tracksolid_db`
|
||||
- User: `grafana_ro`
|
||||
- SSL Mode: disable (internal network)
|
||||
|
||||
---
|
||||
|
||||
## 3. Overall Health Dashboard Queries
|
||||
|
|
@ -572,6 +578,7 @@ The `source` column ('poll' or 'push') tracks data origin where applicable.
|
|||
| `POSTGRES_DB` | Yes | timescale_db | Database name |
|
||||
| `POSTGRES_USER` | Yes | timescale_db | Database superuser |
|
||||
| `POSTGRES_PASSWORD` | Yes | timescale_db | Database password |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Yes | grafana | Grafana admin UI password |
|
||||
| `JIMI_WEBHOOK_TOKEN` | No | webhook_receiver | Webhook auth token (empty = skip validation) |
|
||||
| `DB_POOL_MAX` | No | All Python services | Max DB connections (default: 12) |
|
||||
|
||||
|
|
|
|||
324
docs/manuals/grafanaDeployment.md
Normal file
324
docs/manuals/grafanaDeployment.md
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# Grafana NOC Fleet Dashboard — Deployment Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Single-region NOC dashboard monitoring 80 vehicles (Nairobi, Mombasa, Kampala) via Tracksolid/Jimi GPS.
|
||||
Live location, direction arrows, speed, and driver info. Auto-refreshes every 30 seconds.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Compose stack running (`timescale_db`, `grafana`)
|
||||
- `.env` file with all existing variables
|
||||
- Add one new variable to `.env`:
|
||||
```
|
||||
GRAFANA_DB_RO_PASSWORD=<password for grafana_ro postgres user>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
grafana/
|
||||
└── provisioning/
|
||||
├── datasources/
|
||||
│ └── tracksolid_postgres.yaml
|
||||
├── dashboards/
|
||||
│ └── noc_fleet.yaml
|
||||
└── dashboards-json/
|
||||
└── noc_fleet_dashboard.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — docker-compose.yaml (grafana service)
|
||||
|
||||
Replace the existing `grafana` service block with:
|
||||
|
||||
```yaml
|
||||
grafana:
|
||||
image: grafana/grafana:11.0.0
|
||||
restart: always
|
||||
depends_on:
|
||||
timescale_db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
- GF_USERS_DEFAULT_THEME=dark
|
||||
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards-json/noc_fleet_dashboard.json
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Datasource: `grafana/provisioning/datasources/tracksolid_postgres.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: TracksolidDB
|
||||
type: postgres
|
||||
uid: tracksolid_pg
|
||||
url: timescale_db:5432
|
||||
database: tracksolid_db
|
||||
user: grafana_ro
|
||||
secureJsonData:
|
||||
password: ${GRAFANA_DB_RO_PASSWORD}
|
||||
jsonData:
|
||||
sslmode: disable
|
||||
maxOpenConns: 5
|
||||
maxIdleConns: 2
|
||||
connMaxLifetime: 14400
|
||||
postgresVersion: 1600
|
||||
timescaledb: true
|
||||
editable: false
|
||||
isDefault: true
|
||||
```
|
||||
|
||||
> **Note:** `uid: tracksolid_pg` is referenced by the dashboard JSON. Never rename it after deployment.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Dashboard Provider: `grafana/provisioning/dashboards/noc_fleet.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: NOC Fleet Dashboards
|
||||
orgId: 1
|
||||
folder: NOC
|
||||
type: file
|
||||
disableDeletion: true
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: false
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards-json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Dashboard JSON: `grafana/provisioning/dashboards-json/noc_fleet_dashboard.json`
|
||||
|
||||
### Dashboard Settings
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| UID | `noc-fleet-live` |
|
||||
| Auto-refresh | 30s |
|
||||
| Theme | Dark |
|
||||
| Schema version | 39 (Grafana 11.0.0) |
|
||||
| Editable | false |
|
||||
| Bookmark URL | `/d/noc-fleet-live` |
|
||||
|
||||
---
|
||||
|
||||
### Panel Layout
|
||||
|
||||
| Row | Panel | Type | Notes |
|
||||
|-----|-------|------|-------|
|
||||
| 1 | Total Vehicles | Stat | Count all enabled devices |
|
||||
| 1 | Online Now | Stat | GPS fix < 5 min — green |
|
||||
| 1 | Recent (5-30m) | Stat | GPS fix 5-30 min — amber |
|
||||
| 1 | Offline | Stat | GPS fix > 30 min — red |
|
||||
| 1 | Moving Now | Stat | speed > 0 AND acc on |
|
||||
| 1 | Avg Speed (km/h) | Stat | Moving vehicles only |
|
||||
| 2 | Live Vehicle Locations | Geomap | Direction arrows, color by plate |
|
||||
| 3 | Vehicle Status Table | Table | All vehicles, sorted Online first |
|
||||
| 4 | Ingestion Health | Table | Collapsed by default |
|
||||
|
||||
---
|
||||
|
||||
### SQL Queries
|
||||
|
||||
**Total Vehicles**
|
||||
```sql
|
||||
SELECT COUNT(*) AS "Total Vehicles"
|
||||
FROM tracksolid.devices WHERE enabled_flag = 1;
|
||||
```
|
||||
|
||||
**Online Now**
|
||||
```sql
|
||||
SELECT COUNT(*) AS "Online"
|
||||
FROM tracksolid.v_fleet_status
|
||||
WHERE connectivity_status = 'online';
|
||||
```
|
||||
|
||||
**Recent (5-30 min)**
|
||||
```sql
|
||||
SELECT COUNT(*) AS "Recent"
|
||||
FROM tracksolid.v_fleet_status
|
||||
WHERE connectivity_status = 'recent';
|
||||
```
|
||||
|
||||
**Offline**
|
||||
```sql
|
||||
SELECT COUNT(*) AS "Offline"
|
||||
FROM tracksolid.v_fleet_status
|
||||
WHERE connectivity_status = 'offline';
|
||||
```
|
||||
|
||||
**Moving Now**
|
||||
```sql
|
||||
SELECT COUNT(*) AS "Moving"
|
||||
FROM tracksolid.v_fleet_status
|
||||
WHERE speed > 0 AND acc_status = '1' AND connectivity_status = 'online';
|
||||
```
|
||||
|
||||
**Avg Speed**
|
||||
```sql
|
||||
SELECT ROUND(AVG(speed)::numeric, 1) AS "Avg Speed km/h"
|
||||
FROM tracksolid.v_fleet_status
|
||||
WHERE speed > 0 AND acc_status = '1' AND connectivity_status = 'online';
|
||||
```
|
||||
|
||||
**Geomap — Live Vehicle Locations**
|
||||
```sql
|
||||
SELECT
|
||||
d.imei,
|
||||
d.vehicle_number,
|
||||
d.vehicle_name,
|
||||
d.driver_name,
|
||||
d.driver_phone,
|
||||
d.city,
|
||||
d.device_group,
|
||||
lp.lat,
|
||||
lp.lng,
|
||||
lp.speed,
|
||||
lp.direction,
|
||||
lp.acc_status,
|
||||
lp.loc_desc,
|
||||
lp.gps_time,
|
||||
CASE
|
||||
WHEN lp.gps_time >= NOW() - INTERVAL '5 minutes' THEN 'online'
|
||||
WHEN lp.gps_time >= NOW() - INTERVAL '30 minutes' THEN 'recent'
|
||||
ELSE 'offline'
|
||||
END AS connectivity_status
|
||||
FROM tracksolid.devices d
|
||||
INNER JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1
|
||||
AND lp.lat IS NOT NULL
|
||||
AND lp.lng IS NOT NULL
|
||||
ORDER BY d.vehicle_number;
|
||||
```
|
||||
|
||||
> INNER JOIN — only vehicles with a valid GPS fix appear on the map.
|
||||
> `acc_status` is TEXT ('0'/'1') in the schema — all queries use string comparisons.
|
||||
|
||||
**Vehicle Status Table**
|
||||
```sql
|
||||
SELECT
|
||||
d.vehicle_number AS "Plate",
|
||||
d.vehicle_name AS "Vehicle",
|
||||
d.driver_name AS "Driver",
|
||||
d.driver_phone AS "Driver Phone",
|
||||
d.city AS "City",
|
||||
ROUND(lp.speed::numeric, 0) AS "Speed (km/h)",
|
||||
lp.loc_desc AS "Last Location",
|
||||
lp.gps_time AS "Last Fix",
|
||||
CASE
|
||||
WHEN lp.gps_time >= NOW() - INTERVAL '5 minutes' THEN 'Online'
|
||||
WHEN lp.gps_time >= NOW() - INTERVAL '30 minutes' THEN 'Recent'
|
||||
ELSE 'Offline'
|
||||
END AS "Status",
|
||||
EXTRACT(EPOCH FROM (NOW() - lp.gps_time))::int / 60 AS "Min Since Fix"
|
||||
FROM tracksolid.devices d
|
||||
LEFT JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lp.gps_time >= NOW() - INTERVAL '5 minutes' THEN 0
|
||||
WHEN lp.gps_time >= NOW() - INTERVAL '30 minutes' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
d.vehicle_number;
|
||||
```
|
||||
|
||||
> LEFT JOIN — offline vehicles (no GPS fix) still appear in the table showing their null status.
|
||||
|
||||
**Ingestion Health**
|
||||
```sql
|
||||
SELECT
|
||||
endpoint AS "Endpoint",
|
||||
TO_CHAR(run_at, 'HH24:MI DD-Mon') AS "Last Run",
|
||||
CASE WHEN success THEN 'OK' ELSE 'FAIL' END AS "Result",
|
||||
error_message AS "Error",
|
||||
seconds_ago AS "Lag (s)"
|
||||
FROM tracksolid.v_ingestion_health
|
||||
ORDER BY endpoint;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Geomap Configuration
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Basemap | Carto Dark |
|
||||
| Location mode | `coords` — fields `lat` / `lng` |
|
||||
| Marker symbol | `img/icons/marker/arrow-up.svg` (built-in Grafana 11) |
|
||||
| Rotation | field: `direction` (0° = North, clockwise) |
|
||||
| Color | field: `vehicle_number`, scheme: `palette-classic-by-name` |
|
||||
| Tooltip hidden fields | `lat`, `lng`, `imei` |
|
||||
| Direction unit override | `degree` (shows `245°` in tooltip) |
|
||||
| Initial view | East Africa center — auto-fits to vehicle positions |
|
||||
|
||||
### Connectivity Status Color Mapping
|
||||
|
||||
| Value | Color |
|
||||
|-------|-------|
|
||||
| Online / online | Green |
|
||||
| Recent / recent | Amber |
|
||||
| Offline / offline | Red |
|
||||
|
||||
### Stat Panel Thresholds
|
||||
|
||||
| Panel | Green | Amber | Red |
|
||||
|-------|-------|-------|-----|
|
||||
| Online Now | > 0 | — | = 0 |
|
||||
| Recent | — | > 0 | — |
|
||||
| Offline | = 0 | — | > 0 |
|
||||
| Moving Now | > 0 | — | — |
|
||||
| Avg Speed | < 80 km/h | 80-120 | > 120 |
|
||||
|
||||
All stat panels: `colorMode: background`, `graphMode: none`.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Deploy
|
||||
|
||||
```bash
|
||||
docker compose up -d grafana
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Verify
|
||||
|
||||
1. Open `http://localhost:3000/d/noc-fleet-live`
|
||||
2. Map renders with arrow markers on vehicle positions
|
||||
3. Arrows rotate per heading (northbound vehicle = arrow pointing up)
|
||||
4. Tooltip shows: plate, driver name, speed, city, connectivity status
|
||||
5. Table sorts: Online → Recent → Offline
|
||||
6. 30s auto-refresh fires (watch "Last Fix" timestamps)
|
||||
7. Check provisioning logs:
|
||||
```bash
|
||||
docker compose logs grafana | grep -i provision
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `grafana/` directory must be committed to git — Coolify's clone must include it
|
||||
- `:ro` mounts prevent Grafana from writing back into the repo
|
||||
- `allowUiUpdates: false` in the provider YAML means UI edits are not persisted — the JSON file is the source of truth
|
||||
- `acc_status` in `live_positions` is TEXT (`'0'`/`'1'`), not integer — all queries use string comparisons
|
||||
- `uid: tracksolid_pg` in the datasource YAML is referenced by the dashboard JSON — never rename it after deployment
|
||||
- ServiceNow ticket integration is deferred to a future phase
|
||||
647
docs/manuals/grafanaOperationalManual.md
Normal file
647
docs/manuals/grafanaOperationalManual.md
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
# Grafana NOC Fleet Dashboard — Operational Manual
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Pre-Deployment Checklist](#1-pre-deployment-checklist)
|
||||
2. [Deploy](#2-deploy)
|
||||
3. [Post-Deployment Verification](#3-post-deployment-verification)
|
||||
4. [Dashboard Panel Verification](#4-dashboard-panel-verification)
|
||||
5. [Database Verification Queries](#5-database-verification-queries)
|
||||
6. [Troubleshooting](#6-troubleshooting)
|
||||
7. [Day-to-Day NOC Operations](#7-day-to-day-noc-operations)
|
||||
8. [Maintenance](#8-maintenance)
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-Deployment Checklist
|
||||
|
||||
Run these checks before starting Grafana for the first time.
|
||||
|
||||
### 1.1 Environment Variables
|
||||
|
||||
Confirm `.env` contains all required Grafana variables:
|
||||
|
||||
```bash
|
||||
grep -E 'GRAFANA_ADMIN_PASSWORD|GRAFANA_DB_RO_PASSWORD' .env
|
||||
```
|
||||
|
||||
Expected output — both lines must be present and non-empty:
|
||||
```
|
||||
GRAFANA_ADMIN_PASSWORD=<your admin password>
|
||||
GRAFANA_DB_RO_PASSWORD=<grafana_ro postgres password>
|
||||
```
|
||||
|
||||
If `GRAFANA_DB_RO_PASSWORD` is missing, add it before continuing:
|
||||
```bash
|
||||
echo "GRAFANA_DB_RO_PASSWORD=<password>" >> .env
|
||||
```
|
||||
|
||||
### 1.2 Verify grafana_ro User Exists in Postgres
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "\du grafana_ro"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Role name | Attributes
|
||||
------------+---------------------------
|
||||
grafana_ro | Cannot login, ...
|
||||
```
|
||||
|
||||
If the role is missing, create it:
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
CREATE ROLE grafana_ro WITH LOGIN PASSWORD '<password>' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
GRANT USAGE ON SCHEMA tracksolid TO grafana_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA tracksolid TO grafana_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA tracksolid GRANT SELECT ON TABLES TO grafana_ro;
|
||||
"
|
||||
```
|
||||
|
||||
### 1.3 Verify Provisioning Files Are Present
|
||||
|
||||
```bash
|
||||
ls -R grafana/provisioning/
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
grafana/provisioning/:
|
||||
dashboards dashboards-json datasources
|
||||
|
||||
grafana/provisioning/datasources:
|
||||
tracksolid_postgres.yaml
|
||||
|
||||
grafana/provisioning/dashboards:
|
||||
noc_fleet.yaml
|
||||
|
||||
grafana/provisioning/dashboards-json:
|
||||
noc_fleet_dashboard.json
|
||||
```
|
||||
|
||||
### 1.4 Verify Database Has Live Data
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT COUNT(*) AS devices,
|
||||
COUNT(lp.imei) AS with_position
|
||||
FROM tracksolid.devices d
|
||||
LEFT JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1;
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `devices` = total enabled vehicles, `with_position` > 0 (at least some vehicles have GPS fixes).
|
||||
|
||||
---
|
||||
|
||||
## 2. Deploy
|
||||
|
||||
### Start Grafana
|
||||
|
||||
```bash
|
||||
docker compose up -d grafana
|
||||
```
|
||||
|
||||
### Confirm Container Is Running
|
||||
|
||||
```bash
|
||||
docker compose ps grafana
|
||||
```
|
||||
|
||||
Expected `STATUS`: `Up` (not `Restarting` or `Exit`).
|
||||
|
||||
### Tail Startup Logs
|
||||
|
||||
```bash
|
||||
docker compose logs --follow grafana
|
||||
```
|
||||
|
||||
Watch for these lines — they confirm provisioning loaded successfully:
|
||||
|
||||
```
|
||||
msg="Starting Grafana"
|
||||
msg="Provisioning datasource" name=TracksolidDB
|
||||
msg="Finished provisioning data sources"
|
||||
msg="Inserting/updating dashboard" name="NOC Fleet Operations — Live"
|
||||
msg="HTTP Server Listen" address=0.0.0.0:3000
|
||||
```
|
||||
|
||||
Press `Ctrl+C` to stop following once you see the server listening.
|
||||
|
||||
---
|
||||
|
||||
## 3. Post-Deployment Verification
|
||||
|
||||
### 3.1 Check Provisioning Loaded
|
||||
|
||||
```bash
|
||||
docker compose logs grafana | grep -E "provision|Inserting|dashboard|datasource"
|
||||
```
|
||||
|
||||
| What to look for | Means |
|
||||
|---|---|
|
||||
| `Provisioning datasource` with `name=TracksolidDB` | Datasource YAML was read |
|
||||
| `Finished provisioning data sources` | Datasource created successfully |
|
||||
| `Inserting/updating dashboard` with `noc-fleet-live` | Dashboard JSON was loaded |
|
||||
| `Failed to provision` or `Error` | See Troubleshooting section |
|
||||
|
||||
### 3.2 Verify Datasource via API
|
||||
|
||||
```bash
|
||||
curl -s -u admin:${GRAFANA_ADMIN_PASSWORD} \
|
||||
http://localhost:3000/api/datasources/name/TracksolidDB \
|
||||
| python3 -m json.tool | grep -E '"name"|"uid"|"type"|"url"'
|
||||
```
|
||||
|
||||
Expected:
|
||||
```json
|
||||
"name": "TracksolidDB",
|
||||
"type": "postgres",
|
||||
"uid": "tracksolid_pg",
|
||||
"url": "timescale_db:5432",
|
||||
```
|
||||
|
||||
### 3.3 Test Datasource Connection via API
|
||||
|
||||
```bash
|
||||
curl -s -u admin:${GRAFANA_ADMIN_PASSWORD} \
|
||||
-X POST http://localhost:3000/api/datasources/uid/tracksolid_pg/health \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected:
|
||||
```json
|
||||
{
|
||||
"message": "Database Connection OK",
|
||||
"status": "OK"
|
||||
}
|
||||
```
|
||||
|
||||
If status is `ERROR` — see [Troubleshooting: Datasource Connection Fails](#datasource-connection-fails).
|
||||
|
||||
### 3.4 Verify Dashboard Is Registered
|
||||
|
||||
```bash
|
||||
curl -s -u admin:${GRAFANA_ADMIN_PASSWORD} \
|
||||
http://localhost:3000/api/dashboards/uid/noc-fleet-live \
|
||||
| python3 -m json.tool | grep -E '"title"|"uid"|"version"'
|
||||
```
|
||||
|
||||
Expected:
|
||||
```json
|
||||
"title": "NOC Fleet Operations — Live",
|
||||
"uid": "noc-fleet-live",
|
||||
"version": 1,
|
||||
```
|
||||
|
||||
### 3.5 Open the Dashboard in a Browser
|
||||
|
||||
Navigate to: `http://localhost:3000/d/noc-fleet-live`
|
||||
|
||||
Login with:
|
||||
- **Username:** `admin`
|
||||
- **Password:** value of `GRAFANA_ADMIN_PASSWORD` from `.env`
|
||||
|
||||
The NOC dashboard should load as the home page automatically.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboard Panel Verification
|
||||
|
||||
Work through each panel to confirm it renders correctly.
|
||||
|
||||
### 4.1 Stat Panels (Row 1)
|
||||
|
||||
| Panel | Expected | Red Flag |
|
||||
|---|---|---|
|
||||
| Total Vehicles | Integer matching enabled device count (should be ~80) | Shows 0 or `-` |
|
||||
| Online Now | Integer ≤ Total Vehicles | Shows `-` |
|
||||
| Recent (5-30m) | Integer ≤ Total Vehicles | Shows `-` |
|
||||
| Offline | Integer ≤ Total Vehicles | Shows `-` |
|
||||
| Moving Now | Integer ≤ Online Now | Shows `-` |
|
||||
| Avg Speed (km/h) | Numeric value, green < 80 / amber 80-120 / red > 120 | Shows `No data` |
|
||||
|
||||
**Online + Recent + Offline should sum to Total Vehicles.**
|
||||
|
||||
Check the sum:
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT
|
||||
connectivity_status,
|
||||
COUNT(*) AS vehicles
|
||||
FROM tracksolid.v_fleet_status
|
||||
GROUP BY connectivity_status
|
||||
ORDER BY connectivity_status;
|
||||
"
|
||||
```
|
||||
|
||||
### 4.2 Geomap Panel
|
||||
|
||||
Work through this checklist visually:
|
||||
|
||||
- [ ] Map loads with dark Carto basemap (not a grey blank tile)
|
||||
- [ ] Arrow markers appear on the map (not dots or circles)
|
||||
- [ ] Markers are clustered around East Africa (Nairobi / Mombasa / Kampala area)
|
||||
- [ ] Arrows point in different directions — not all the same
|
||||
- [ ] Clicking a marker opens a tooltip showing: Plate, Driver, Speed, Heading, Status, Location
|
||||
- [ ] `lat` and `lng` fields are NOT visible in the tooltip (hidden by field overrides)
|
||||
- [ ] `imei` field is NOT visible in the tooltip
|
||||
|
||||
**Verify direction data exists:**
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT
|
||||
MIN(direction) AS min_dir,
|
||||
MAX(direction) AS max_dir,
|
||||
COUNT(*) FILTER (WHERE direction IS NOT NULL) AS with_direction,
|
||||
COUNT(*) AS total
|
||||
FROM tracksolid.live_positions;
|
||||
"
|
||||
```
|
||||
|
||||
If `with_direction` = 0, all arrows will point North (0°) — this means the GPS devices haven't sent bearing data yet, which is normal for parked vehicles.
|
||||
|
||||
### 4.3 Vehicle Status Table
|
||||
|
||||
- [ ] Table shows all enabled vehicles (row count matches Total Vehicles stat)
|
||||
- [ ] Rows sort: Online (green) → Recent (amber) → Offline (red)
|
||||
- [ ] "Status" column has colour-coded background
|
||||
- [ ] "Speed (km/h)" column shows colour-coded text (green/amber/red)
|
||||
- [ ] "Last Fix" column shows a readable timestamp (not a raw epoch number)
|
||||
- [ ] "Min Ago" column shows integers (minutes since last GPS fix)
|
||||
- [ ] Vehicles with no GPS fix show `null` in speed/location columns but still appear
|
||||
|
||||
**Spot-check a specific vehicle:**
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT
|
||||
d.vehicle_number, d.driver_name, lp.speed,
|
||||
lp.gps_time, lp.lat, lp.lng
|
||||
FROM tracksolid.devices d
|
||||
LEFT JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1
|
||||
LIMIT 5;
|
||||
"
|
||||
```
|
||||
|
||||
### 4.4 Ingestion Health Panel (Collapsed)
|
||||
|
||||
Expand the panel by clicking its title row.
|
||||
|
||||
- [ ] Rows appear for each polling endpoint (`live_positions`, `trips`, `alarms`, etc.)
|
||||
- [ ] "Result" column shows `OK` (green) for all active endpoints
|
||||
- [ ] "Last Run" timestamps are recent (within the last few minutes for live_positions)
|
||||
- [ ] No `FAIL` (red) entries
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT endpoint, success, seconds_ago, error_message
|
||||
FROM tracksolid.v_ingestion_health
|
||||
ORDER BY endpoint;
|
||||
"
|
||||
```
|
||||
|
||||
### 4.5 Auto-Refresh Verification
|
||||
|
||||
1. Note the "Last Fix" timestamp for any vehicle in the table
|
||||
2. Wait 30 seconds
|
||||
3. Confirm the page auto-refreshes (watch the Grafana spinner in the top-right)
|
||||
4. Confirm "Last Fix" values have updated for Online vehicles
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Verification Queries
|
||||
|
||||
Run these directly against the database to validate the data powering each panel.
|
||||
|
||||
### Fleet Status Summary
|
||||
```sql
|
||||
SELECT
|
||||
connectivity_status,
|
||||
COUNT(*) AS vehicles,
|
||||
ROUND(AVG(speed)::numeric, 1) AS avg_speed_kmh
|
||||
FROM tracksolid.v_fleet_status
|
||||
GROUP BY connectivity_status
|
||||
ORDER BY connectivity_status;
|
||||
```
|
||||
|
||||
### Live Position Freshness
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS total_positions,
|
||||
COUNT(*) FILTER (WHERE gps_time >= NOW() - INTERVAL '5 minutes') AS fresh_5m,
|
||||
COUNT(*) FILTER (WHERE gps_time >= NOW() - INTERVAL '30 minutes') AS fresh_30m,
|
||||
MAX(gps_time) AS newest_fix,
|
||||
MIN(gps_time) AS oldest_fix
|
||||
FROM tracksolid.live_positions;
|
||||
```
|
||||
|
||||
### Moving Vehicles
|
||||
```sql
|
||||
SELECT
|
||||
d.vehicle_number, d.driver_name,
|
||||
lp.speed, lp.direction, lp.loc_desc, lp.gps_time
|
||||
FROM tracksolid.devices d
|
||||
INNER JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1
|
||||
AND lp.speed > 0
|
||||
AND lp.acc_status = '1'
|
||||
ORDER BY lp.speed DESC;
|
||||
```
|
||||
|
||||
### Vehicles With No GPS Fix
|
||||
```sql
|
||||
SELECT
|
||||
d.vehicle_number, d.vehicle_name, d.driver_name, d.city
|
||||
FROM tracksolid.devices d
|
||||
LEFT JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1
|
||||
AND lp.imei IS NULL
|
||||
ORDER BY d.vehicle_number;
|
||||
```
|
||||
|
||||
### Ingestion Pipeline Health
|
||||
```sql
|
||||
SELECT
|
||||
endpoint,
|
||||
run_at,
|
||||
success,
|
||||
imei_count,
|
||||
rows_upserted,
|
||||
duration_ms,
|
||||
seconds_ago,
|
||||
error_message
|
||||
FROM tracksolid.v_ingestion_health
|
||||
ORDER BY endpoint;
|
||||
```
|
||||
|
||||
### Stale Positions (no update in > 1 hour)
|
||||
```sql
|
||||
SELECT
|
||||
d.vehicle_number, d.driver_name, d.city,
|
||||
lp.gps_time,
|
||||
EXTRACT(EPOCH FROM (NOW() - lp.gps_time))::int / 60 AS minutes_since_fix
|
||||
FROM tracksolid.devices d
|
||||
INNER JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.enabled_flag = 1
|
||||
AND lp.gps_time < NOW() - INTERVAL '1 hour'
|
||||
ORDER BY lp.gps_time ASC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### Grafana Container Keeps Restarting
|
||||
|
||||
```bash
|
||||
docker compose logs grafana --tail=50
|
||||
```
|
||||
|
||||
Common causes:
|
||||
|
||||
| Error in logs | Fix |
|
||||
|---|---|
|
||||
| `failed to connect to server` | Database not healthy yet — wait 30s and retry |
|
||||
| `permission denied` on provisioning path | Check the `./grafana/provisioning` directory exists and is readable |
|
||||
| `GF_SECURITY_ADMIN_PASSWORD not set` | Add `GRAFANA_ADMIN_PASSWORD` to `.env` |
|
||||
|
||||
### Datasource Connection Fails
|
||||
|
||||
```bash
|
||||
docker compose logs grafana | grep -i "datasource\|postgres\|connect"
|
||||
```
|
||||
|
||||
Check the password is correct:
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U grafana_ro -d tracksolid_db -c "SELECT 1;"
|
||||
```
|
||||
|
||||
If this fails with `authentication failed`, reset the password:
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c \
|
||||
"ALTER ROLE grafana_ro WITH PASSWORD '<new_password>';"
|
||||
```
|
||||
|
||||
Then update `GRAFANA_DB_RO_PASSWORD` in `.env` and restart:
|
||||
```bash
|
||||
docker compose restart grafana
|
||||
```
|
||||
|
||||
### Dashboard Shows "No Data"
|
||||
|
||||
**Step 1 — confirm the datasource UID matches:**
|
||||
```bash
|
||||
curl -s -u admin:${GRAFANA_ADMIN_PASSWORD} \
|
||||
http://localhost:3000/api/datasources \
|
||||
| python3 -m json.tool | grep uid
|
||||
```
|
||||
|
||||
Must show `"uid": "tracksolid_pg"`. If the UID is different, the dashboard JSON references the wrong datasource.
|
||||
|
||||
**Step 2 — test the query directly in Grafana:**
|
||||
1. Open the dashboard
|
||||
2. Click the panel title → Edit
|
||||
3. Switch to the Query tab
|
||||
4. Click "Run query"
|
||||
5. If it errors, the SQL or connection is the issue
|
||||
|
||||
**Step 3 — verify data exists:**
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c \
|
||||
"SELECT COUNT(*) FROM tracksolid.live_positions;"
|
||||
```
|
||||
|
||||
If this returns 0, the ingestion pipeline has not populated data yet. Check ingestion service logs:
|
||||
```bash
|
||||
docker compose logs ingest_movement --tail=30
|
||||
docker compose logs ingest_events --tail=30
|
||||
```
|
||||
|
||||
### Geomap Shows No Markers
|
||||
|
||||
**Check coordinates are valid:**
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE lat IS NOT NULL AND lng IS NOT NULL) AS with_coords,
|
||||
COUNT(*) FILTER (WHERE lat BETWEEN -90 AND 90 AND lng BETWEEN -180 AND 180) AS valid_coords
|
||||
FROM tracksolid.live_positions;
|
||||
"
|
||||
```
|
||||
|
||||
If `with_coords` = 0: GPS data has not arrived yet. Wait for the next ingest cycle (60 seconds).
|
||||
|
||||
If `valid_coords` < `with_coords`: some coordinates are out of range — data quality issue on the device side.
|
||||
|
||||
**Check the basemap is loading:**
|
||||
|
||||
Open browser DevTools (F12) → Network tab → filter for `tile` or `carto`. If Carto tile requests are failing, there may be no internet access from the browser. Try switching the basemap to OpenStreetMap in the Grafana panel editor temporarily.
|
||||
|
||||
### Arrows All Point North (No Direction Data)
|
||||
|
||||
This is expected for parked vehicles — direction is only meaningful when the vehicle is moving. Confirm:
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT COUNT(*) FILTER (WHERE direction > 0) AS with_bearing,
|
||||
COUNT(*) AS total
|
||||
FROM tracksolid.live_positions;
|
||||
"
|
||||
```
|
||||
|
||||
If `with_bearing` = 0 even for moving vehicles, the GPS device firmware may not be sending bearing in the position payload. Check with the Tracksolid account settings.
|
||||
|
||||
### Dashboard Not Loading as Home Page
|
||||
|
||||
Confirm the environment variable is set:
|
||||
```bash
|
||||
docker compose exec grafana env | grep DASHBOARDS
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards-json/noc_fleet_dashboard.json
|
||||
```
|
||||
|
||||
If missing, confirm `env_file: .env` is present in the grafana service block of `docker-compose.yaml` and restart:
|
||||
```bash
|
||||
docker compose restart grafana
|
||||
```
|
||||
|
||||
### Provisioning Changes Not Reflecting
|
||||
|
||||
Grafana polls the provisioning directory every 30 seconds. If changes to the dashboard JSON are not appearing:
|
||||
|
||||
```bash
|
||||
docker compose restart grafana
|
||||
docker compose logs grafana | grep -i "provision\|dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Day-to-Day NOC Operations
|
||||
|
||||
### Morning Health Check (run at shift start)
|
||||
|
||||
```bash
|
||||
# 1. Confirm all services are up
|
||||
docker compose ps
|
||||
|
||||
# 2. Check ingestion pipeline ran recently
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c \
|
||||
"SELECT endpoint, run_at, success, seconds_ago FROM tracksolid.v_ingestion_health ORDER BY endpoint;"
|
||||
|
||||
# 3. Check live position freshness
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c \
|
||||
"SELECT connectivity_status, COUNT(*) FROM tracksolid.v_fleet_status GROUP BY 1 ORDER BY 1;"
|
||||
```
|
||||
|
||||
### Investigating a Specific Vehicle
|
||||
|
||||
Replace `KAA 123A` with the actual plate:
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT
|
||||
d.vehicle_number, d.vehicle_name, d.driver_name, d.driver_phone,
|
||||
lp.lat, lp.lng, lp.speed, lp.direction,
|
||||
lp.acc_status, lp.gps_time, lp.loc_desc,
|
||||
EXTRACT(EPOCH FROM (NOW() - lp.gps_time))::int / 60 AS minutes_ago
|
||||
FROM tracksolid.devices d
|
||||
LEFT JOIN tracksolid.live_positions lp USING (imei)
|
||||
WHERE d.vehicle_number = 'KAA 123A';
|
||||
"
|
||||
```
|
||||
|
||||
### Checking Recent Trips for a Vehicle
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT
|
||||
t.start_time, t.end_time,
|
||||
ROUND(t.distance_m / 1000.0, 2) AS distance_km,
|
||||
t.avg_speed_kmh, t.max_speed_kmh,
|
||||
ROUND(t.driving_time_s / 3600.0, 2) AS driving_hours
|
||||
FROM tracksolid.trips t
|
||||
INNER JOIN tracksolid.devices d USING (imei)
|
||||
WHERE d.vehicle_number = 'KAA 123A'
|
||||
AND t.start_time >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY t.start_time DESC;
|
||||
"
|
||||
```
|
||||
|
||||
### Checking Recent Alarms
|
||||
|
||||
```bash
|
||||
docker compose exec timescale_db psql -U $POSTGRES_USER -d tracksolid_db -c "
|
||||
SELECT
|
||||
d.vehicle_number, d.driver_name,
|
||||
a.alarm_type, a.alarm_name, a.alarm_time, a.speed
|
||||
FROM tracksolid.alarms a
|
||||
INNER JOIN tracksolid.devices d USING (imei)
|
||||
WHERE a.alarm_time >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY a.alarm_time DESC
|
||||
LIMIT 20;
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Maintenance
|
||||
|
||||
### Restart Grafana Only
|
||||
|
||||
```bash
|
||||
docker compose restart grafana
|
||||
```
|
||||
|
||||
### Restart Full Stack
|
||||
|
||||
```bash
|
||||
docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
### Update the Dashboard JSON
|
||||
|
||||
1. Edit `grafana/provisioning/dashboards-json/noc_fleet_dashboard.json`
|
||||
2. Grafana auto-reloads within 30 seconds (no restart needed)
|
||||
3. Commit the change: `git add grafana/ && git commit -m "Update NOC dashboard"`
|
||||
|
||||
### Check Container Resource Usage
|
||||
|
||||
```bash
|
||||
docker stats grafana timescale_db --no-stream
|
||||
```
|
||||
|
||||
### Grafana Data Volume Size
|
||||
|
||||
```bash
|
||||
docker system df -v | grep grafana-data
|
||||
```
|
||||
|
||||
### View Grafana Version
|
||||
|
||||
```bash
|
||||
docker compose exec grafana grafana-server -v
|
||||
```
|
||||
|
||||
Expected: `Version 11.0.0`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Start Grafana | `docker compose up -d grafana` |
|
||||
| Stop Grafana | `docker compose stop grafana` |
|
||||
| Restart Grafana | `docker compose restart grafana` |
|
||||
| View logs | `docker compose logs grafana --tail=50` |
|
||||
| Check provisioning | `docker compose logs grafana \| grep -i provision` |
|
||||
| Test datasource | `curl -s -u admin:$GRAFANA_ADMIN_PASSWORD http://localhost:3000/api/datasources/uid/tracksolid_pg/health` |
|
||||
| Open dashboard | `http://localhost:3000/d/noc-fleet-live` |
|
||||
| Fleet summary | `SELECT connectivity_status, COUNT(*) FROM tracksolid.v_fleet_status GROUP BY 1;` |
|
||||
| Ingestion health | `SELECT * FROM tracksolid.v_ingestion_health ORDER BY endpoint;` |
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
# Tracksolid Database Manual
|
||||
|
||||
> **⚠️ Deprecation note (2026-06-10).** This manual predates two cleanups. (1) The `dwh_gold`
|
||||
> schema and its tables (`dim_vehicles`, `fact_daily_fleet_metrics`, `refresh_daily_metrics()`)
|
||||
> were **dropped on 2026-06-05** (migration 13) — any section or query below that references
|
||||
> `dwh_gold.*` is obsolete and will error; treat `tracksolid.*` (+ the `reporting.*` read layer)
|
||||
> as the single source of truth. (2) **Grafana and the n8n/DWH pipeline were removed 2026-06-10**;
|
||||
> dashboards are now the FleetNow / FleetOps SPAs served by `dashboard_api`. Mentions of Grafana
|
||||
> panels / n8n jobs below are historical.
|
||||
|
||||
**Database:** `tracksolid_db`
|
||||
**Host:** `kianiadee@stage.rahamafresh.com`
|
||||
**Container:** `timescale_db-*` (Coolify-generated suffix — changes on redeploy, use dynamic lookup below)
|
||||
|
|
@ -39,8 +31,8 @@ docker exec -it "$TS_DB" psql -U postgres -d tracksolid_db
|
|||
9. [tracksolid.obd_readings](#9-tracksolidobd_readings)
|
||||
10. [tracksolid.parking_events](#10-tracksolidparking_events)
|
||||
11. [tracksolid.fault_codes](#11-trackshopfault_codes)
|
||||
12. ~~dwh_gold.dim_vehicles~~ — *removed 2026-06-05 (migration 13)*
|
||||
13. ~~dwh_gold.fact_daily_fleet_metrics~~ — *removed 2026-06-05 (migration 13)*
|
||||
12. [dwh_gold.dim_vehicles](#12-dwh_golddim_vehicles)
|
||||
13. [dwh_gold.fact_daily_fleet_metrics](#13-dwh_goldfact_daily_fleet_metrics)
|
||||
14. [Business Intelligence Queries](#14-business-intelligence-queries)
|
||||
15. [Today's Metrics — From 00:00 Nairobi Time to Now](#15-todays-metrics--from-0000-nairobi-time-to-now)
|
||||
16. [Daily Analytics](#16-daily-analytics)
|
||||
|
|
@ -59,7 +51,7 @@ The database is organised into three schemas:
|
|||
| Schema | Purpose |
|
||||
|---|---|
|
||||
| `tracksolid` | Raw operational data ingested from the Tracksolid/Jimi Open Platform API |
|
||||
| `reporting` | Read layer (map + analytics views/functions) consumed by `dashboard_api` |
|
||||
| `dwh_gold` | Pre-aggregated data warehouse layer for reporting and dashboards |
|
||||
| `public` | PostGIS spatial reference tables (system-managed) |
|
||||
|
||||
Data is pulled from the Jimi/Tracksolid API on a continuous polling schedule. The `ingestion_log` table records every API call so you can audit pipeline health.
|
||||
|
|
@ -74,7 +66,7 @@ SELECT
|
|||
tablename,
|
||||
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS size
|
||||
FROM pg_tables
|
||||
WHERE schemaname IN ('tracksolid', 'reporting')
|
||||
WHERE schemaname IN ('tracksolid', 'dwh_gold')
|
||||
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;
|
||||
```
|
||||
|
||||
|
|
@ -97,7 +89,9 @@ UNION ALL SELECT 'tracksolid.fuel_readings', COUNT(*) FROM tracksolid.f
|
|||
UNION ALL SELECT 'tracksolid.temperature_readings', COUNT(*) FROM tracksolid.temperature_readings
|
||||
UNION ALL SELECT 'tracksolid.lbs_readings', COUNT(*) FROM tracksolid.lbs_readings
|
||||
UNION ALL SELECT 'tracksolid.geofences', COUNT(*) FROM tracksolid.geofences
|
||||
UNION ALL SELECT 'tracksolid.ingestion_log', COUNT(*) FROM tracksolid.ingestion_log;
|
||||
UNION ALL SELECT 'tracksolid.ingestion_log', COUNT(*) FROM tracksolid.ingestion_log
|
||||
UNION ALL SELECT 'dwh_gold.dim_vehicles', COUNT(*) FROM dwh_gold.dim_vehicles
|
||||
UNION ALL SELECT 'dwh_gold.fact_daily_fleet_metrics', COUNT(*) FROM dwh_gold.fact_daily_fleet_metrics;
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -289,9 +283,9 @@ ORDER BY delta_kmh DESC;
|
|||
|
||||
---
|
||||
|
||||
### Continuous route trace for map rendering
|
||||
### Continuous route trace for Grafana map panel
|
||||
|
||||
Returns ordered waypoints for all vehicles in the last hour suitable for rendering as a continuous path on a map (e.g. the FleetNow trail layer via `dashboard_api`). The `source` filter includes both ingestion paths to maximise trace density.
|
||||
Returns ordered waypoints for all vehicles in the last hour suitable for rendering as a continuous path in Grafana's Geomap plugin. The `source` filter includes both ingestion paths to maximise trace density.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
|
|
@ -599,16 +593,31 @@ At the time of audit this table contained **0 rows**, which is consistent with t
|
|||
|
||||
---
|
||||
|
||||
## 12. dwh_gold.dim_vehicles — REMOVED
|
||||
## 12. dwh_gold.dim_vehicles
|
||||
|
||||
> **Removed 2026-06-05 (migration 13).** The `dwh_gold` star-schema layer (`dim_vehicles`,
|
||||
> `fact_daily_fleet_metrics`, `refresh_daily_metrics()`) was never populated and was dropped as
|
||||
> unused. Compute fleet metrics directly from `tracksolid.*` / `reporting.*` (see §14–§19).
|
||||
This is the vehicles dimension table in the data warehouse gold layer. It is intended to be a clean, enriched, business-friendly view of the fleet — combining device metadata from `tracksolid.devices` with any additional attributes needed for reporting (cost centre, vehicle category, assigned route, etc.). Dimension tables in a star schema are typically populated by an ETL job that joins and transforms raw operational tables.
|
||||
|
||||
## 13. dwh_gold.fact_daily_fleet_metrics — REMOVED
|
||||
At the time of audit this table contained **0 rows**. The ETL pipeline that populates the gold layer has not yet been run.
|
||||
|
||||
> **Removed 2026-06-05 (migration 13).** See §12. The pre-aggregated daily fact table no longer
|
||||
> exists; the BI queries below run against the live `tracksolid.trips` / `position_history` tables.
|
||||
### Describe table structure
|
||||
|
||||
```sql
|
||||
\d dwh_gold.dim_vehicles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. dwh_gold.fact_daily_fleet_metrics
|
||||
|
||||
This is the central fact table of the data warehouse. It is designed to hold one pre-aggregated row per vehicle per day, summarising distance driven, fuel consumed, driving time, idle time, trip count, first departure time, last return time, and alarm counts. Pre-aggregating at this level makes dashboards and management reports extremely fast — a full month's fleet summary requires scanning at most 63 vehicles × 31 days = ~2,000 rows rather than hundreds of thousands of raw trip and position records.
|
||||
|
||||
At the time of audit this table contained **0 rows**. Once the ETL job is running, this should be the primary data source for all Grafana dashboards and business reports.
|
||||
|
||||
### Describe table structure
|
||||
|
||||
```sql
|
||||
\d dwh_gold.fact_daily_fleet_metrics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -963,7 +972,7 @@ ORDER BY created_at DESC;
|
|||
|
||||
## 16. Daily Analytics
|
||||
|
||||
Daily analytics answer the question: **"What happened across the fleet today?"** All queries use `Africa/Nairobi` timezone so that a working day (typically 08:00–20:00 EAT) maps cleanly to a single calendar date. (The former `dwh_gold.refresh_daily_metrics()` pre-aggregation was removed 2026-06-05 — these queries run directly against `tracksolid.trips` / `position_history`.)
|
||||
Daily analytics answer the question: **"What happened across the fleet today?"** All queries use `Africa/Nairobi` timezone so that a working day (typically 08:00–20:00 EAT) maps cleanly to a single calendar date. The `dwh_gold.refresh_daily_metrics()` function pre-aggregates these into `fact_daily_fleet_metrics` for fast dashboard queries — run it nightly for the previous day.
|
||||
|
||||
### Day summary — all vehicles, single date
|
||||
|
||||
|
|
@ -1229,7 +1238,7 @@ ORDER BY work_date;
|
|||
|
||||
## 18. Monthly Analytics
|
||||
|
||||
Monthly analytics answer: **"How did the fleet perform this month?"** Queries use `DATE_TRUNC('month', ...)` to anchor to the first of the current month. For historical months replace with a specific date literal.
|
||||
Monthly analytics answer: **"How did the fleet perform this month?"** Queries use `DATE_TRUNC('month', ...)` to anchor to the first of the current month. For historical months replace with a specific date literal. When `dwh_gold.fact_daily_fleet_metrics` is fully populated these queries can be rewritten against that table for much faster execution.
|
||||
|
||||
### Monthly summary per vehicle
|
||||
|
||||
|
|
@ -1530,6 +1539,6 @@ The following issues were identified during the April 2026 audit. Each represent
|
|||
| 6 | `heartbeats` | 0 rows | Cannot distinguish parked-alive from powered-off | **Open** — verify tracker firmware supports heartbeat push |
|
||||
| 7 | `obd_readings` | 0 rows | No engine health data | **Open** — requires OBD cable installation + `/pushobd` webhook registration in Tracksolid account |
|
||||
| 8 | `parking_events` | 0 rows despite 358 successful API calls | No parking dwell-time reporting | **Fixed** in `ingest_movement_rev.py` [FIX-M13] — added missing `account` and `acc_type=0` params; fixed `durSecond` field mapping |
|
||||
| 9 | `dwh_gold.*` | Both tables empty (never populated) | — | **Removed** 2026-06-05 (migration 13) — the gold layer was dropped as unused; metrics come from `tracksolid.*` directly |
|
||||
| 10 | `position_history` | Only 1 fix/min per vehicle from fleet sweep — route traces incomplete | Map route paths had gaps; speed profiles too coarse for harsh-driving detection | **Fixed** — `poll_track_list()` added [FIX-M14]; captures every device waypoint every 30 min; density increases to 2–6 fixes/min per vehicle |
|
||||
| 9 | `dwh_gold.*` | Both tables empty | Grafana dashboards have no data | **Fixed** — migration 05 adds `refresh_daily_metrics()` ETL function; run nightly via cron or n8n |
|
||||
| 10 | `position_history` | Only 1 fix/min per vehicle from fleet sweep — route traces incomplete | Grafana map paths had gaps; speed profiles too coarse for harsh-driving detection | **Fixed** — `poll_track_list()` added [FIX-M14]; captures every device waypoint every 30 min; density increases to 2–6 fixes/min per vehicle |
|
||||
| 11 | `live_positions` | No on-demand refresh mechanism for specific vehicles | Alarm enrichment and stale-device recovery required waiting up to 60s | **Fixed** — `get_device_locations()` utility added [FIX-M15]; call with specific IMEIs for instant precision refresh |
|
||||
|
|
|
|||
|
|
@ -643,6 +643,11 @@ tsdb -c "\d tracksolid.trips"
|
|||
tsdb -c "\d tracksolid.live_positions"
|
||||
```
|
||||
|
||||
### Run DWH gold ETL for yesterday
|
||||
```sql
|
||||
SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1);
|
||||
```
|
||||
|
||||
### Apply a migration manually (from your Mac)
|
||||
```bash
|
||||
ssh kianiadee@rahamafresh.com \
|
||||
|
|
|
|||
|
|
@ -2,14 +2,6 @@
|
|||
## Tracksolid Pro · Field Operations & Logistics Intelligence Assessment
|
||||
### April 2026
|
||||
|
||||
> **⚠️ Deprecation note (2026-06-10).** This SQL library is still the reference for query
|
||||
> *patterns*, but two things changed after it was written: (1) the `dwh_gold` pre-aggregation
|
||||
> layer (`fact_daily_fleet_metrics`, etc.) was **dropped 2026-06-05** (migration 13) — any query
|
||||
> below that reads `dwh_gold.*` will error; run the equivalent aggregation directly against
|
||||
> `tracksolid.trips` / `position_history`. (2) **Grafana and the n8n/DWH pipeline were removed
|
||||
> 2026-06-10** — panels are now FleetNow / FleetOps SPAs served by `dashboard_api`. Read
|
||||
> "Grafana panel" / "n8n" below as "dashboard" / "the ingest or ops integration layer".
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
|
@ -21,7 +13,7 @@
|
|||
4. [Real-Time Dispatch & Field-Service SLAs](#4-real-time-dispatch--field-service-slas)
|
||||
5. [Distance per Driver per Day](#5-distance-per-driver-per-day)
|
||||
6. [Business Questions Now Answerable](#6-business-questions-now-answerable)
|
||||
7. [Dashboard Blueprint](#7-dashboard-blueprint)
|
||||
7. [Grafana Dashboard Blueprint](#7-grafana-dashboard-blueprint)
|
||||
8. [What Unlocks the Remaining 30%](#8-what-unlocks-the-remaining-30)
|
||||
9. [Fleet Readiness Scorecard](#9-fleet-readiness-scorecard)
|
||||
10. [Service-Interval Forecaster](#10-service-interval-forecaster)
|
||||
|
|
@ -30,7 +22,7 @@
|
|||
|
||||
## 0. How to Use This Document
|
||||
|
||||
Every query in this document is tagged by intended consumption cadence. Build dashboard panels, alert rules, and scheduled reports against the tag — not the SQL text — so that moving a metric between dashboard and alert is a one-line change.
|
||||
Every query in this document is tagged by intended consumption cadence. Build Grafana panels, alert rules, and scheduled reports against the tag — not the SQL text — so that moving a metric between dashboard and alert is a one-line change.
|
||||
|
||||
| Tag | Meaning | Typical cadence | Owner |
|
||||
|---|---|---|---|
|
||||
|
|
@ -85,7 +77,7 @@ Once deployed, the ingestion stack populates the following data sources:
|
|||
| `tracksolid.parking_events` | Stop/idle events with address and duration | Every 15 minutes |
|
||||
| `tracksolid.alarms` | Alarm events with type, severity, location | Every 5 minutes |
|
||||
| `tracksolid.devices` | Vehicle and driver registry | Daily at 02:00 |
|
||||
| ~~`dwh_gold.fact_daily_fleet_metrics`~~ | *Removed 2026-06-05 (migration 13) — aggregate from `tracksolid.trips` directly* | — |
|
||||
| `dwh_gold.fact_daily_fleet_metrics` | Daily KPI aggregates per vehicle | Nightly ETL |
|
||||
|
||||
**Position history density** improvement with `poll_track_list` (POLL-01):
|
||||
|
||||
|
|
@ -757,11 +749,11 @@ LIMIT 5;
|
|||
|
||||
---
|
||||
|
||||
### 4.2 Dispatch Logic for API Integration
|
||||
### 4.2 Dispatch Logic for n8n or API Integration
|
||||
|
||||
The recommended workflow when a new job/ticket arrives:
|
||||
|
||||
1. **Trigger:** New job created (webhook from job management system)
|
||||
1. **Trigger:** New job created (webhook from job management system or n8n)
|
||||
2. **Force-refresh positions:** Call `get_device_locations()` for the top 10 candidate IMEIs to get sub-second fresh positions before committing
|
||||
3. **Run dispatch query** above with job coordinates
|
||||
4. **Filter by vehicle type** if the job requires specific capacity (`AND d.vehicle_category = 'van'`)
|
||||
|
|
@ -773,7 +765,7 @@ The recommended workflow when a new job/ticket arrives:
|
|||
|
||||
### 4.3 All Active Vehicles Map — Live Fleet View
|
||||
|
||||
Returns all vehicles with a position fix in the last 10 minutes, suitable for a map panel with auto-refresh at 30 seconds.
|
||||
Returns all vehicles with a position fix in the last 10 minutes, suitable for a Grafana Geomap panel with auto-refresh at 30 seconds.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
|
|
@ -827,7 +819,7 @@ CREATE INDEX IF NOT EXISTS idx_dispatch_log_assigned_at
|
|||
ON tracksolid.dispatch_log(assigned_at DESC);
|
||||
```
|
||||
|
||||
**Population plan:** the ops integration layer writes one row per dispatch at assignment. A nightly job back-fills `first_movement_at` / `on_site_at` by joining `trips` and `live_positions` against `job_geom`.
|
||||
**Population plan:** n8n or the ops integration layer writes one row per dispatch at assignment. A nightly job back-fills `first_movement_at` / `on_site_at` by joining `trips` and `live_positions` against `job_geom`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1043,9 +1035,7 @@ Status key: **✅ Ready** = answerable once new stack deployed | **⚙ Needs dat
|
|||
|
||||
---
|
||||
|
||||
## 7. Dashboard Blueprint
|
||||
|
||||
*(Panel layout is tool-agnostic — now implemented in the FleetNow / FleetOps SPAs via `dashboard_api`.)*
|
||||
## 7. Grafana Dashboard Blueprint
|
||||
|
||||
### Panel 1 — Real-Time Fleet Map (auto-refresh: 30s)
|
||||
- **Type:** Geomap
|
||||
|
|
@ -1124,6 +1114,8 @@ docker exec -it ingest_movement python sync_driver_audit.py
|
|||
docker exec -it ingest_movement python import_drivers_csv.py # dry-run
|
||||
docker exec -it ingest_movement python import_drivers_csv.py --apply # commit
|
||||
|
||||
# 6. Schedule nightly ETL
|
||||
# Add to cron or n8n: SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1);
|
||||
```
|
||||
|
||||
**Expected state after Step 0:**
|
||||
|
|
@ -1337,7 +1329,7 @@ ORDER BY readiness_score ASC NULLS FIRST;
|
|||
| < 60 | Red — unreliable | Do not dispatch for priority jobs; service or replace |
|
||||
| NULL | Silent | Vehicle never reported — investigate install / commission |
|
||||
|
||||
The scorecard is also the cleanest Panel 2 replacement for the Fleet Status Summary.
|
||||
The scorecard is also the cleanest Panel 2 replacement for the Grafana Fleet Status Summary.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
# pgbouncer sidecar deployment
|
||||
|
||||
> **⚠️ SUPERSEDED — pgbouncer REMOVED 2026-06-10.** The pooler was deployed but
|
||||
> stayed dormant (zero app clients ever pointed at `:6432`; every service connects
|
||||
> directly to `timescale_db:5432`). In-process pooling (`ts_shared_rev`
|
||||
> `ThreadedConnectionPool`) is more than sufficient at fleet scale, and transaction-mode
|
||||
> pooling is unsafe for the advisory-lock'd `v_trips` refresher (FIX-D02). The
|
||||
> `pgbouncer` service was dropped from `docker-compose.yaml`; migration
|
||||
> `10_pgbouncer_auth.sql` (the `pgbouncer` role + `public.user_lookup()` fn) is left
|
||||
> applied but **inert**. This document is retained for history only.
|
||||
|
||||
**Date:** 2026-05-07
|
||||
**Branch:** `quality-program-2026-04-12`
|
||||
**Status:** Phase 1 deployed, then **removed 2026-06-10** (see banner above). Phase 2 (pgAdmin4 sidecar) was rolled back on 2026-05-08 — see git history (`bc020cb`, then reverted).
|
||||
**Status:** Phase 1 deployed. Phase 2 (pgAdmin4 sidecar) was rolled back on 2026-05-08 — see git history (`bc020cb`, then reverted).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
# Fix & Refactor Plan — 2026-07-02
|
||||
|
||||
Companion to `260702_platform_audit_report.md` (findings) and `260702_work_done.md`
|
||||
(execution log). Goal per the brief: **faster, more scalable, and above all secure**,
|
||||
without regressing any FIX-* in CLAUDE.md §7.
|
||||
|
||||
The plan splits into (A) code/repo changes implemented in this session, and
|
||||
(B) operational actions that touch prod and therefore need explicit operator
|
||||
confirmation (CLAUDE.md working rule 1) — commands are provided, not executed.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — code & repo changes (implemented locally, tests must stay green)
|
||||
|
||||
### A1. Security hardening in the repo
|
||||
| Step | Finding | Change |
|
||||
|---|---|---|
|
||||
| A1.1 | SEC-04 | Add `.dockerignore` excluding `.env*`, `.git`, `.venv`, `*.osm.pbf`, CSV/data artefacts, docs, tests, caches. |
|
||||
| A1.2 | SEC-05 | Dockerfile: `COPY uv.lock` + install with `uv pip install --system --require-hashes`-equivalent (`uv sync --frozen` layout) so builds are pinned and reproducible. Keep the runtime layout identical (`/app`, non-root user). |
|
||||
| A1.3 | SEC-02 | `webhook_receiver_rev.py`: log a **CRITICAL** warning at startup when `JIMI_WEBHOOK_TOKEN` is empty, and add `WEBHOOK_REQUIRE_TOKEN=1` (opt-in) that refuses to start unauthenticated. Default stays permissive so the next deploy doesn't break live ingestion before the token is configured on the Jimi side; flipping the flag is step B3. |
|
||||
| A1.4 | SEC-01 | `docker-compose.yaml`: publish Postgres as `${DB_BIND_ADDR:-127.0.0.1}:5433:5432`. Default becomes localhost-only **on the next stack redeploy**; remote tooling moves to an SSH tunnel (documented). Setting `DB_BIND_ADDR=0.0.0.0` in `.env` restores the old exposure deliberately. |
|
||||
|
||||
### A2. Correctness fixes
|
||||
| Step | Finding | Change |
|
||||
|---|---|---|
|
||||
| A2.1 | BUG-P1 | Capture `cur.rowcount` immediately after the INSERT, before `RELEASE SAVEPOINT`, in `poll_alarms`, `poll_trips`, `poll_parking`. |
|
||||
| A2.2 | BUG-P3 | `_parse_request`: branch on Content-Type — parse `application/json` bodies (`{"token", "data_list"}`), keep the observed form-encoded path (`msgType`/`data`) as-is. |
|
||||
| A2.3 | BUG-P2 | Webhook endpoints: keep async request parsing, move each endpoint's DB work into a synchronous `_process_*(items)` function executed via `await asyncio.to_thread(...)`. Event loop never blocks on psycopg2 again; existing tests (which patch `webhook_receiver_rev.get_conn`) still pass because module references are unchanged. |
|
||||
| A2.4 | BUG-P4 | `poll_trips`: three-phase restructure — (1) fetch API trips per batch with **no** connection held; (2) one short read transaction for position_history enrichment; (3) reverse-geocode with no connection held; (4) one write transaction for the upserts + log. Nominatim's 1 req/s throttle no longer holds a pool connection or transaction open. |
|
||||
| A2.5 | BUG-P6 | `sync_devices`: when **every** configured target listed successfully and returned a sane device count, set `enabled_flag=0` for enabled IMEIs absent from the aggregate (and re-enable ones that return). Guard: skip the disable pass entirely if any target call failed or the aggregate is empty — an API outage must not disable the fleet. |
|
||||
| A2.6 | BUG-P9 | `get_token()`: only `.replace(tzinfo=utc)` when the value is naive; otherwise use it as-is. |
|
||||
| A2.7 | BUG-P8 | `webhook_receiver` + `ingest_events`: sanity-guard inbound event timestamps — reject alarm/event rows whose timestamp is > 2 days in the future or before 2026-01-01 (project epoch), logging a warning. Prevents 2019-clock devices polluting `alarms`/`position_history` further. |
|
||||
|
||||
### A3. Observability & data lifecycle
|
||||
| Step | Finding | Change |
|
||||
|---|---|---|
|
||||
| A3.1 | BUG-P5 | New migration `21_ingest_health_active_only.sql`: redefine `reporting.v_ingest_health` to only include pipeline endpoints (explicit allow-list matching the ingest_worker schedule + webhook endpoints) so one-shot tools can't wedge `/health/ingest` at "stale". |
|
||||
| A3.2 | BUG-P7 | New daily housekeeping job in the ingest worker: `DELETE FROM tracksolid.ingestion_log WHERE run_at < now() - interval '90 days'`; same for `reporting.refresh_log` (180 days). Registered at 02:30 daily. |
|
||||
| A3.3 | OPS-03 | Remove the orphaned duplicate `migrations/10_driver_clock_views.sql` (never registered in `run_migrations.py`, superseded by the reporting layer). |
|
||||
| A3.4 | OPS-04 | `pyproject.toml`: move ruff `select` under `[tool.ruff.lint]`. |
|
||||
|
||||
### A4. Explicit non-goals for this session
|
||||
- No change to the `reporting.*` SQL functions (healthy; PERF-03 tuning is config-level).
|
||||
- No pgbouncer reintroduction (removed deliberately 2026-06-10; connection counts are fine).
|
||||
- No changes to `tickets.*` / fleettickets code (different repo) — flagged separately.
|
||||
- No alarm retention policy (business decision on how much ACC history to keep).
|
||||
|
||||
---
|
||||
|
||||
## Phase B — operational actions (prod; NOT executed without confirmation)
|
||||
|
||||
| # | Finding | Action | Command sketch |
|
||||
|---|---|---|---|
|
||||
| B1 | SEC-01 | **Rotate the postgres superuser password** (it was shared in chat), update `.env` on twala, redeploy stack. | `ALTER USER postgres WITH PASSWORD '<new>';` then edit `.env`, Coolify redeploy. |
|
||||
| B2 | SEC-01 | Close public 5433 (after B1): redeploy with the new compose default, then use `ssh -L 5433:localhost:5433` for local tools. Interim: `ufw` allow-list. |
|
||||
| B3 | SEC-02 | Configure a push token in the Jimi/Tracksolid console, set `JIMI_WEBHOOK_TOKEN` in `.env`, set `WEBHOOK_REQUIRE_TOKEN=1`, redeploy webhook_receiver. |
|
||||
| B4 | SEC-03 | Move services off the superuser: point `DATABASE_URL` for ingest_worker/webhook at `tracksolid_owner` (grants already exist for the tracksolid schema; verify with a staging dry-run), `REFRESH_DATABASE_URL` at `reporting_refresher`, db_backup at a dedicated dump role or keep postgres but only via internal network once B2 lands. |
|
||||
| B5 | OPS-01 | Redeploy the prod dashboard_api bridge so it matches the repo (restores the 8 missing INC/CRQ/fuel routes): `scp dashboard_api_rev.py twala:~/ && ssh twala 'bash ~/deploy_dashboard_api.sh'`. |
|
||||
| B6 | SEC-07 | Schedule a maintenance window to bump the TimescaleDB image to the latest pg16 minor. |
|
||||
| B7 | PERF-05 | In the **fleettickets** repo: drop the three unused geo indexes (~134 MB), consider trimming/compressing the `raw` payload column. |
|
||||
|
||||
Suggested order: B1 → B2 (same window), then B5 (fixes user-visible FleetOps prod), then
|
||||
B3, B4, B6 as follow-ups. Phase A ships first since B3/B4/B5 deploy code from this repo.
|
||||
|
||||
---
|
||||
|
||||
## Verification per phase
|
||||
- **A:** `pytest tests/` (77 green before → must stay green, plus new tests for the
|
||||
rowcount fix and JSON parsing), `ruff check .`, `docker build` locally to confirm the
|
||||
image excludes `.env`/pbf (spot-check with `docker run --rm <img> ls -la /app`).
|
||||
- **B:** after each deploy: `GET /health`, `GET /health/ingest` (should report `ok`
|
||||
overall once A3.1 lands), `docker logs` of ingest_worker for positive alarm counts,
|
||||
`SELECT * FROM reporting.v_ingest_health` shows no negative `rows_inserted` on new rows.
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
# Platform Audit — Bugs & Refactoring Report
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Scope:** repo code (webhook → pollers → dashboard_api), the live Coolify stack on
|
||||
`twala.rahamafresh.com`, and the `tracksolid_db` database (schema, contents, roles, sizes).
|
||||
**Companion docs:** `260702_fix_plan.md` (the plan) · `260702_work_done.md` (what was implemented).
|
||||
|
||||
Findings are ranked. `SEC-*` = security, `BUG-*` = correctness, `PERF-*` = performance/scalability,
|
||||
`OPS-*` = operability/drift. Each item states where it lives and how it was verified.
|
||||
|
||||
---
|
||||
|
||||
## 1. Critical security findings
|
||||
|
||||
### SEC-01 — Postgres superuser reachable from the public internet, without TLS
|
||||
`docker-compose.yaml` publishes `5433:5432` on all interfaces. Verified live: a direct
|
||||
`psql` connection from an off-site machine to `twala.rahamafresh.com:5433` as **postgres
|
||||
(superuser)** succeeds. Server settings: `listen_addresses=*`, **`ssl=off`** — so every
|
||||
remote session (including query results containing fleet GPS data) travels the public
|
||||
internet in cleartext. SCRAM protects the password exchange, but not the data, and the
|
||||
superuser password was also pasted into a chat session (this one), so it must be treated
|
||||
as exposed.
|
||||
|
||||
**Required actions (operational, need operator confirmation):**
|
||||
1. Rotate the `postgres` password (`ALTER USER postgres PASSWORD ...`), update `.env` on
|
||||
the host, redeploy the stack.
|
||||
2. Stop publishing 5432 publicly — bind to `127.0.0.1` and use an SSH tunnel
|
||||
(`ssh -L 5433:localhost:5433 kianiadee@twala...`) for local tooling, or firewall the
|
||||
port to known admin IPs (`ufw allow from <ip> to any port 5433`).
|
||||
3. Longer-term: enable SSL on the TimescaleDB container.
|
||||
|
||||
### SEC-02 — Webhook receiver accepts unauthenticated pushes in prod
|
||||
`webhook_receiver_rev.py::_validate_token()` silently skips validation when
|
||||
`JIMI_WEBHOOK_TOKEN` is empty. Verified live: the prod container has **no
|
||||
`JIMI_WEBHOOK_TOKEN` set**, and the service is publicly routed at
|
||||
`tshook.rahamafresh.com`. Anyone who finds the URL can inject fake GPS fixes, alarms,
|
||||
trips, OBD data, and (via `ensure_device`) create device rows — poisoning
|
||||
`live_positions` and the FleetNow map. This is a silent fail-open.
|
||||
|
||||
### SEC-03 — Every backend service connects as the postgres superuser
|
||||
`pg_stat_activity` shows 21 connections, all `postgres`. Container envs confirm
|
||||
`ingest_worker`, `webhook_receiver`, `db_backup`, and the prod dashboard_api's
|
||||
**refresher** (`REFRESH_DATABASE_URL`) all use the superuser URL. The least-privilege
|
||||
roles already exist (`tracksolid_owner`, `dashboard_ro`, `reporting_refresher`) but are
|
||||
unused by the write path. A compromise of any app container (e.g. via SEC-02) is a full
|
||||
DB compromise, including other schemas (`tickets`, `fuel`).
|
||||
|
||||
### SEC-04 — Secrets and 350 MB of junk baked into every Docker image
|
||||
There is **no `.dockerignore`**, and the Dockerfile ends with `COPY . .`. Every image
|
||||
build embeds: `.env` (all Tracksolid + DB credentials) as an image layer, the 346 MB
|
||||
`kenya-260605.osm.pbf`, `.git/`, `.venv/`, and CSV exports. Anyone with access to the
|
||||
image (Coolify registry, `docker save`, layer cache) can read the secrets. It also makes
|
||||
builds/deploys slow and images ~400 MB heavier than needed.
|
||||
|
||||
### SEC-05 — Unpinned dependency installs (no lockfile in image)
|
||||
`Dockerfile` installs from `pyproject.toml` ranges (`uv pip install --system -r
|
||||
pyproject.toml`); the `uv.lock` copy line is commented out. Every rebuild can silently
|
||||
pull different (potentially compromised or breaking) versions. Reproducibility and
|
||||
supply-chain hygiene both fail.
|
||||
|
||||
### SEC-06 — dashboard_api is public with no authentication or rate limiting
|
||||
By design the read API is anonymous, but it exposes live vehicle positions, driver
|
||||
names/phones and full trip histories at `fleetapi.rahamafresh.com` to anyone (CORS only
|
||||
restricts browsers, not curl). Acceptable only if the client considers fleet locations
|
||||
public. Flagged for a product decision; a static API key header checked in the SPA layer
|
||||
would be a cheap mitigation.
|
||||
|
||||
### SEC-07 — PostgreSQL 16.3 (June 2024 vintage)
|
||||
The `timescale/timescaledb-ha:pg16-ts2.15` image carries PG 16.3; several CVE-fixing
|
||||
minor releases exist since. Plan an image bump (minor upgrades are drop-in).
|
||||
|
||||
---
|
||||
|
||||
## 2. Bugs (correctness)
|
||||
|
||||
### BUG-P1 — Poller "inserted" counters read `rowcount` after `RELEASE SAVEPOINT` (live-confirmed)
|
||||
In `ingest_events_rev.poll_alarms`, `ingest_movement_rev.poll_trips`, and
|
||||
`ingest_movement_rev.poll_parking`, the pattern is:
|
||||
|
||||
```python
|
||||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
inserted += cur.rowcount # rowcount of RELEASE (-1), not the INSERT
|
||||
```
|
||||
|
||||
Live logs show `Alarms: -4 new events inserted`, and
|
||||
`reporting.v_ingest_health.rows_inserted = -1`. Every deduplicated batch *decrements* the
|
||||
counter, corrupting `ingestion_log` metrics and the FleetOps freshness feed.
|
||||
|
||||
### BUG-P2 — Webhook endpoints block the asyncio event loop
|
||||
All seven `/push*` endpoints are `async def` but perform synchronous psycopg2 work
|
||||
(row-by-row inserts, savepoints) directly on the event loop. One slow/large push stalls
|
||||
*every* concurrent request on that uvicorn worker, including `/health` (Docker may then
|
||||
restart the container mid-write). With 2 workers this is the receiver's main scalability
|
||||
ceiling.
|
||||
|
||||
### BUG-P3 — `_parse_request` drops JSON-body pushes
|
||||
The helper only calls `request.form()`. Jimi's documented integration push
|
||||
(`Content-Type: application/json`, `{"token":..., "data_list":[...]}`) parses as an empty
|
||||
form → returns `("", [])` → the endpoint replies `success` and **silently discards the
|
||||
data**. Works today because Jimi currently sends form-encoded, but a vendor-side format
|
||||
switch would cause invisible data loss.
|
||||
|
||||
### BUG-P4 — poll_trips holds a DB connection + open transaction across network calls
|
||||
The entire `poll_trips` body runs inside one `with get_conn()`: Tracksolid API batches
|
||||
*and* Nominatim reverse-geocoding (globally throttled at 1 req/sec) execute while the
|
||||
transaction is open. N trips with cold geocode cache ⇒ the transaction (and a pool
|
||||
connection) is held for up to ~2·N seconds, blocking vacuum and risking pool exhaustion.
|
||||
`poll_parking`/`poll_alarms` similarly hold a connection across API calls (less severe —
|
||||
no geocoding).
|
||||
|
||||
### BUG-P5 — `/health/ingest` reports the whole pipeline "stale" forever
|
||||
`reporting.v_ingest_health` (migration 19) includes *every* endpoint ever written to
|
||||
`ingestion_log`. The one-shot tool `backfill_trips_enrichment` last ran 2026-05-01 and is
|
||||
flagged `stale` permanently, so `GET /health/ingest` returns `"overall": "stale"` even
|
||||
when all real pollers are healthy — the FleetOps panel signal is useless.
|
||||
|
||||
### BUG-P6 — Devices removed from Tracksolid are never disabled
|
||||
`sync_devices` only upserts. The DB has **184 enabled devices** while the API now returns
|
||||
**172**; nothing ever sets `enabled_flag = 0`. Consequences: `poll_stale_locations`
|
||||
churns ~160 permanently-dead IMEIs every 10 minutes (live logs: "refreshing 160 stale
|
||||
IMEI(s) … 0 positions refreshed"), and all batch endpoints poll IMEIs that no longer
|
||||
exist, wasting API quota against the 1006 rate limit.
|
||||
|
||||
### BUG-P7 — Unbounded audit tables
|
||||
`tracksolid.ingestion_log`: 461 k rows / 78 MB, no retention (≈5 k rows/day, growing
|
||||
forever). `reporting.refresh_log`: 22 k rows (one per 300 s refresh). Neither is a
|
||||
hypertable, so no Timescale retention policy applies. `v_ingest_health` aggregates over
|
||||
the whole table (the `agg` CTE scans everything each call).
|
||||
|
||||
### BUG-P8 — Alarm table polluted by device-clock garbage and ACC noise
|
||||
`tracksolid.alarms` has 600 k rows: 405 k (68 %) are `ACC ON`/`ACC OFF` status flips, and
|
||||
push alarms include `alarm_time` values back to **2019-01-01** (device clock resets).
|
||||
18 pre-2026 rows also landed in `position_history`. No sanity guard exists on inbound
|
||||
event timestamps. Grows ~8 k rows/day; affects every alarm-based view.
|
||||
|
||||
### BUG-P9 — `get_token()` mangles timezone-aware expiries
|
||||
`row['expires_at'].replace(tzinfo=timezone.utc)` assumes the driver returns a naive
|
||||
timestamp. The column is `timestamptz`; psycopg2 returns an *aware* datetime in the
|
||||
session timezone — `.replace()` relabels rather than converts, so a non-UTC session TZ
|
||||
would silently shift expiry by hours. Latent (containers run UTC) but a one-line fix.
|
||||
|
||||
---
|
||||
|
||||
## 3. Performance & scalability
|
||||
|
||||
### PERF-01 — Webhook throughput ceiling (see BUG-P2)
|
||||
Blocking DB work on the event loop + per-item `INSERT` with savepoints. `/pushgps`
|
||||
already got the batched `execute_values` treatment; the other six endpoints still do
|
||||
~1 ms/row round trips.
|
||||
|
||||
### PERF-02 — Single-threaded scheduler serialises all pollers
|
||||
The merged `ingest_worker` runs every job on one `schedule` loop: a slow
|
||||
`poll_track_list` (184 per-IMEI API calls) or `sync_devices` delays the 60 s live sweep.
|
||||
Currently tolerable (fleet mostly silent); becomes the bottleneck as active devices grow.
|
||||
Fixing BUG-P6 (don't poll dead devices) buys most of the headroom back.
|
||||
|
||||
### PERF-03 — `reporting.v_trips` refresh scans everything every 5 minutes
|
||||
`pg_stat_user_tables`: 6.8 k seq scans on `tracksolid.trips` reading 485 M cumulative
|
||||
tuples — the 300 s `REFRESH MATERIALIZED VIEW CONCURRENTLY` rebuilds all 62 k rows each
|
||||
tick, and CONCURRENTLY doubles the work. Fine today (~9 s), but O(history) — it gets
|
||||
slower every week forever. Mitigations: raise `VTRIPS_REFRESH_INTERVAL_S`, or bound the
|
||||
matview to a rolling window (e.g. 180 days) since the map's guardrail already caps
|
||||
unfiltered queries at 31 days.
|
||||
|
||||
### PERF-04 — Docker image bloat (see SEC-04): ~350 MB of OSM/CSV/venv copied into every build; deploys and Coolify builds pay it each time.
|
||||
|
||||
### PERF-05 — tickets.* dead weight (owned by the fleettickets repo)
|
||||
`tickets.inc` = 766 MB, `tickets.crq` = 486 MB — dominated by the `raw` jsonb column
|
||||
(avg 754 B/row) and **134 MB of never-used geo indexes** (`ix_inc_geog` 83 MB,
|
||||
`ix_inc_geom` 51 MB, `ix_crq_geom` 49 MB — all 0 scans). `tickets.geo_clusters` shows
|
||||
24.4 M seq scans (844 M tuples) from the geocoding path. Not this repo's code, but it is
|
||||
this database — flagged to the fleettickets repo.
|
||||
|
||||
### PERF-06 — Host is CPU-saturated by co-tenants
|
||||
Load average ~3.9 on 4 cores; a `sidekiq` container (unrelated stack) burns 88 % CPU
|
||||
continuously and the box has no swap with 9.2 GiB/15 GiB used. The fleet stack is healthy
|
||||
but has no headroom; any burst competes with the co-tenants.
|
||||
|
||||
---
|
||||
|
||||
## 4. Operability / drift
|
||||
|
||||
### OPS-01 — Prod dashboard_api runs a stale file (missing 8 routes)
|
||||
The prod bridge bind-mounts `~/dashboard_api/dashboard_api_rev.py`, whose md5 differs
|
||||
from the repo/staging copy. Verified: the deployed prod file contains **none** of the
|
||||
`inc-dashboard`, `crq-dashboard`, `fuel-fills` routes (staging has all 8). Any FleetOps
|
||||
prod tab calling those paths against `fleetapi.rahamafresh.com` 404s. Redeploy via
|
||||
`~/deploy_dashboard_api.sh` (needs operator go-ahead; env changes need a recreate).
|
||||
|
||||
### OPS-02 — `db_backup`/`ingest_worker` carry irrelevant env (e.g. `DASHBOARD_CORS_ORIGINS`) because everything shares one `.env`; harmless but muddies audits like this one. Cosmetic.
|
||||
|
||||
### OPS-03 — Duplicate migration number
|
||||
`migrations/10_driver_clock_views.sql` and `migrations/10_pgbouncer_auth.sql` share a
|
||||
number; only the pgbouncer one is in `run_migrations.py`. `10_driver_clock_views.sql` is
|
||||
orphaned — never applied by the runner. Decide: renumber+register or delete.
|
||||
|
||||
### OPS-04 — `ruff` config uses deprecated top-level `select` (warns on every run); tests otherwise green (77 passed).
|
||||
|
||||
### OPS-05 — Data-quality watch items (no code change): 18 pre-2026 rows in
|
||||
`position_history` (device clock resets — see BUG-P8 guard), `parking_events` still 0
|
||||
rows fleet-wide, and only ~29/184 devices reported a fix in the last hour (silent-device
|
||||
investigation is an existing Open Item).
|
||||
|
||||
---
|
||||
|
||||
## 5. What is in good shape (for the record)
|
||||
|
||||
- The reporting layer is well-designed: `fn_trips_for_map` has a 31-day guardrail,
|
||||
`v_trips` carries 7 purposeful indexes, functions are `STABLE`, and comments document
|
||||
provenance.
|
||||
- `position_history` is a proper hypertable: 6.26 M rows, compression after 14 d,
|
||||
2-year retention, chunk-partitioned. `heartbeats` has 30 d retention.
|
||||
- Migration runner is idempotent with sentinel seeding and critical-table verification.
|
||||
- The staging bridge genuinely runs read-only (`dashboard_ro`, refresher disabled) —
|
||||
stage-2 least privilege worked; prod request path also uses `dashboard_ro`.
|
||||
- 77 unit/integration tests pass; savepoint-per-item batching protects webhook batches.
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# Work Done — 2026-07-02 Audit Remediation
|
||||
|
||||
Execution log for `260702_fix_plan.md` (Phase A). All changes are **local commits-ready
|
||||
only — nothing was pushed or deployed** (CLAUDE.md working rule 1). Phase B (prod
|
||||
operations) is listed at the end with its blockers.
|
||||
|
||||
Verification: `pytest tests/` → **82 passed** (77 pre-existing + 5 new), all modules
|
||||
import cleanly, migration 21 dry-run against the live DB inside a rolled-back
|
||||
transaction returned `ok` for all seven pipeline endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Security (Phase A1)
|
||||
|
||||
| Fix | Files | What changed |
|
||||
|---|---|---|
|
||||
| SEC-04 | `.dockerignore` (new) | `.env*`, `*.pw`, `.git`, `.venv`, `*.osm.pbf`, CSV/data artefacts, docs and tests are now excluded from the build context. Secrets no longer land in image layers; image shrinks by ~400 MB. |
|
||||
| SEC-05 | `Dockerfile` | Dependencies now install from `uv.lock` via `uv export --frozen` → pinned, hash-checked, reproducible builds. The build **fails** if the lockfile drifts from `pyproject.toml`. |
|
||||
| SEC-02 | `webhook_receiver_rev.py` (FIX-W01) | Startup logs a CRITICAL warning when `JIMI_WEBHOOK_TOKEN` is empty; setting `WEBHOOK_REQUIRE_TOKEN=1` makes the service refuse to start unauthenticated. Default stays permissive so the next deploy doesn't cut off live Jimi pushes before a token is configured (flip the flag in Phase B3). |
|
||||
| SEC-01 | `docker-compose.yaml` | Postgres port now binds `${DB_BIND_ADDR:-127.0.0.1}:5433:5432` — loopback-only by default **on the next stack redeploy**. ⚠️ Local tooling that connects to `twala:5433` directly must switch to an SSH tunnel (`ssh -L 5433:localhost:5433 kianiadee@twala.rahamafresh.com`) once deployed, or set `DB_BIND_ADDR=0.0.0.0` in `.env` to deliberately keep the old exposure. |
|
||||
|
||||
## Correctness (Phase A2)
|
||||
|
||||
| Fix | Files | What changed |
|
||||
|---|---|---|
|
||||
| BUG-P1 (FIX-M22 / FIX-E07) | `ingest_movement_rev.py`, `ingest_events_rev.py` | `cur.rowcount` captured immediately after each INSERT instead of after `RELEASE SAVEPOINT` — kills the live "Alarms: -4 new events inserted" and negative `ingestion_log.rows_inserted`. |
|
||||
| BUG-P3 (FIX-W02) | `webhook_receiver_rev.py` | `_parse_request` branches on Content-Type: `application/json` bodies (`{"token","data_list"}` and `data` variants) are parsed; the observed form-encoded path is unchanged. A Jimi format switch can no longer silently discard telemetry. |
|
||||
| BUG-P2 (FIX-W03) | `webhook_receiver_rev.py` | Every endpoint's DB work moved into sync `_process_*()` functions run via `await asyncio.to_thread(...)`. The event loop (and `/health`) never blocks on psycopg2 again. Endpoint behaviour, SQL, and savepoint-per-item semantics are byte-identical. |
|
||||
| BUG-P4 (FIX-M23) | `ingest_movement_rev.py` | `poll_trips` split into 4 phases: API fetch (no conn) → one short read txn (plates + position_history enrichment) → Nominatim geocoding (no conn; 1 req/s throttle) → one write txn. No more minutes-long open transactions pinning pool connections. |
|
||||
| BUG-P6 (FIX-M24) | `ingest_movement_rev.py` | `sync_devices` disables enabled devices absent from the aggregated API result — guarded to run only when *every* target listed successfully and returned devices, so an API outage cannot mass-disable the fleet. Expect ~12 devices to flip to `enabled_flag=0` on the first prod run, and the "refreshing 160 stale IMEI(s)" churn to shrink. |
|
||||
| BUG-P9 | `ts_shared_rev.py` | `get_token()` only tags `tzinfo` on naive expiry timestamps; aware values pass through unmangled. |
|
||||
| BUG-P8 (FIX-W04) | `webhook_receiver_rev.py` | `/pushalarm` rejects `alarm_time` outside a sane window (`WEBHOOK_EVENT_MAX_AGE_DAYS`=30 back / `WEBHOOK_EVENT_MAX_FUTURE_DAYS`=2 ahead) — stops 2019-clock devices polluting `tracksolid.alarms`. Existing bad rows are untouched (cleanup is a Phase B decision). |
|
||||
|
||||
## Observability & lifecycle (Phase A3)
|
||||
|
||||
| Fix | Files | What changed |
|
||||
|---|---|---|
|
||||
| BUG-P5 | `migrations/21_ingest_health_active_only.sql` (new), `run_migrations.py` | `reporting.v_ingest_health` now allow-lists the 6 poller + 7 webhook endpoints; one-shot tools (`backfill_trips_enrichment`) can no longer wedge `GET /health/ingest` at "overall: stale". Also: the 1-hour aggregate CTE now filters by time *before* grouping (cheaper on a large `ingestion_log`), and the daily registry sync got its own 86400 s expected interval instead of the 3600 s default. **Dry-run verified against prod (rolled back): 7 rows, all `ok`.** Applies automatically at next container start. |
|
||||
| BUG-P7 (FIX-M25) | `ingest_movement_rev.py` | New daily `purge_audit_logs()` at 02:30 — deletes `ingestion_log` rows older than 90 d and `reporting.refresh_log` older than 180 d (env-tunable via `INGESTION_LOG_KEEP_DAYS` / `REFRESH_LOG_KEEP_DAYS`). |
|
||||
| OPS-03 | `migrations/10_driver_clock_views.sql` (deleted) | Orphaned duplicate number — never registered in `run_migrations.py`, never applied (verified against `tracksolid.schema_migrations`). |
|
||||
| OPS-04 | `pyproject.toml` | ruff `select` moved to `[tool.ruff.lint]`; deprecation warning gone. Removed two pre-existing unused imports (`clean_int` in ingest_events, `TARGET_ACCOUNT` in ingest_movement). |
|
||||
|
||||
## Tests
|
||||
- `tests/integration/test_webhook_endpoints.py`: +5 tests — JSON `data_list` accepted,
|
||||
JSON single-object `data` accepted, malformed JSON degrades safely, ancient (2019)
|
||||
`alarm_time` rejected, `_is_sane_event_ts` unit coverage. Test env widens the sanity
|
||||
window (`WEBHOOK_EVENT_MAX_AGE_DAYS=100000`) so the fixed 2024 fixtures stay valid.
|
||||
|
||||
## Docs
|
||||
- `CLAUDE.md`: migrations map (02–21), fix-history table (FIX-W01–04, FIX-M22–25),
|
||||
schema notes updated.
|
||||
- This folder: `260702_platform_audit_report.md`, `260702_fix_plan.md`, this file.
|
||||
|
||||
---
|
||||
|
||||
## NOT done — Phase B operational actions awaiting operator confirmation
|
||||
|
||||
1. **Rotate the postgres superuser password** — it is exposed (shared in chat, used
|
||||
over a non-TLS internet-reachable port). Highest priority.
|
||||
2. **Redeploy the stack** so the new compose port binding + migration 21 + fixed
|
||||
ingest/webhook code go live (Coolify redeploy; remember the tooling-tunnel change).
|
||||
3. **Redeploy the prod dashboard_api bridge** — it currently runs a stale file missing
|
||||
all 8 INC/CRQ/fuel-fills routes (`scp dashboard_api_rev.py twala:~/ && bash
|
||||
~/deploy_dashboard_api.sh`).
|
||||
4. **Configure a Jimi push token**, set `JIMI_WEBHOOK_TOKEN` + `WEBHOOK_REQUIRE_TOKEN=1`.
|
||||
5. **Move services off the superuser** (`tracksolid_owner` for ingest/webhook,
|
||||
`reporting_refresher` for the matview refresher).
|
||||
6. TimescaleDB minor-version bump; fleettickets repo: drop 134 MB of unused geo indexes.
|
||||
367
dwh/260423_dwh_ddl_v1.sql
Normal file
367
dwh/260423_dwh_ddl_v1.sql
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
-- =============================================================
|
||||
-- TRACKSOLID DWH SETUP & PERMISSIONS
|
||||
-- Target Database: tracksolid_dwh
|
||||
-- =============================================================
|
||||
|
||||
-- 1. EXTENSIONS
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
CREATE EXTENSION IF NOT EXISTS postgis; -- REQUIRED for geometry(Point,4326) columns
|
||||
|
||||
-- 2. ROLE CREATION (Idempotent)
|
||||
-- SECURITY: Passwords below are placeholders. Before applying this file:
|
||||
-- 1. Generate two strong secrets (e.g. `openssl rand -hex 24`)
|
||||
-- 2. Replace both CHANGE_ME_BEFORE_APPLY tokens in-session (do NOT commit real values)
|
||||
-- 3. Store the generated secrets in the n8n / Grafana credential stores only
|
||||
-- Rotation: `ALTER ROLE <role> PASSWORD '<new secret>'` as a superuser.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'dwh_owner') THEN
|
||||
CREATE ROLE dwh_owner WITH LOGIN PASSWORD 'CHANGE_ME_BEFORE_APPLY';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
CREATE ROLE grafana_ro WITH LOGIN PASSWORD 'CHANGE_ME_BEFORE_APPLY';
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Grant database connection
|
||||
GRANT CONNECT ON DATABASE tracksolid_dwh TO dwh_owner;
|
||||
GRANT CONNECT ON DATABASE tracksolid_dwh TO grafana_ro;
|
||||
|
||||
-- 3. SCHEMAS
|
||||
CREATE SCHEMA IF NOT EXISTS bronze AUTHORIZATION dwh_owner;
|
||||
CREATE SCHEMA IF NOT EXISTS silver AUTHORIZATION dwh_owner;
|
||||
CREATE SCHEMA IF NOT EXISTS gold AUTHORIZATION dwh_owner;
|
||||
|
||||
ALTER DATABASE tracksolid_dwh SET search_path TO bronze, silver, gold, public;
|
||||
|
||||
-- 4. PERMISSIONS & DEFAULT PRIVILEGES (Critical for Security & Automation)
|
||||
-- Schema access
|
||||
GRANT USAGE, CREATE ON SCHEMA bronze TO dwh_owner;
|
||||
GRANT USAGE, CREATE ON SCHEMA silver TO dwh_owner;
|
||||
GRANT USAGE, CREATE ON SCHEMA gold TO dwh_owner;
|
||||
GRANT USAGE ON SCHEMA bronze TO grafana_ro;
|
||||
GRANT USAGE ON SCHEMA silver TO grafana_ro;
|
||||
GRANT USAGE ON SCHEMA gold TO grafana_ro;
|
||||
GRANT USAGE ON SCHEMA public TO dwh_owner, grafana_ro;
|
||||
|
||||
-- Existing table access for Grafana
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA bronze TO grafana_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA silver TO grafana_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA gold TO grafana_ro;
|
||||
|
||||
-- FUTURE table access: Any table created by dwh_owner will automatically be readable by grafana_ro
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE dwh_owner IN SCHEMA bronze GRANT SELECT ON TABLES TO grafana_ro;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE dwh_owner IN SCHEMA silver GRANT SELECT ON TABLES TO grafana_ro;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE dwh_owner IN SCHEMA gold GRANT SELECT ON TABLES TO grafana_ro;
|
||||
|
||||
-- 5. BRONZE SCHEMA TABLES
|
||||
-- Run as dwh_owner to ensure correct ownership & default privileges apply
|
||||
SET ROLE dwh_owner;
|
||||
SET search_path TO bronze, public;
|
||||
|
||||
-- 5.1 DEVICES (Slowly Changing Dimension - Type 2 handled in Silver)
|
||||
CREATE TABLE IF NOT EXISTS bronze.devices (
|
||||
imei TEXT PRIMARY KEY,
|
||||
device_name TEXT,
|
||||
mc_type TEXT,
|
||||
mc_type_use_scope TEXT,
|
||||
vehicle_name TEXT,
|
||||
vehicle_number TEXT,
|
||||
vehicle_models TEXT,
|
||||
vehicle_icon TEXT,
|
||||
vin TEXT,
|
||||
engine_number TEXT,
|
||||
vehicle_brand TEXT,
|
||||
fuel_100km NUMERIC(6,2),
|
||||
driver_name TEXT,
|
||||
driver_phone TEXT,
|
||||
sim TEXT,
|
||||
iccid TEXT,
|
||||
imsi TEXT,
|
||||
account TEXT,
|
||||
customer_name TEXT,
|
||||
device_group_id TEXT,
|
||||
device_group TEXT,
|
||||
activation_time TIMESTAMPTZ,
|
||||
expiration TIMESTAMPTZ,
|
||||
enabled_flag SMALLINT DEFAULT 1 NOT NULL,
|
||||
status TEXT DEFAULT 'active'::text,
|
||||
city TEXT,
|
||||
current_mileage_km NUMERIC(12,2),
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
vehicle_category TEXT,
|
||||
cost_centre TEXT,
|
||||
assigned_route TEXT,
|
||||
depot_geom geometry(Point,4326),
|
||||
depot_address TEXT,
|
||||
assigned_city TEXT,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.2 POSITION HISTORY (High-volume fact table)
|
||||
CREATE TABLE IF NOT EXISTS bronze.position_history (
|
||||
imei TEXT NOT NULL,
|
||||
gps_time TIMESTAMPTZ NOT NULL,
|
||||
geom geometry(Point,4326),
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
speed NUMERIC(7,2),
|
||||
direction NUMERIC(6,2),
|
||||
acc_status TEXT,
|
||||
satellite SMALLINT,
|
||||
current_mileage NUMERIC(12,2),
|
||||
recorded_at TIMESTAMPTZ DEFAULT now(),
|
||||
altitude NUMERIC(8,2),
|
||||
post_type SMALLINT,
|
||||
source TEXT DEFAULT 'poll'::text,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (imei, gps_time)
|
||||
);
|
||||
|
||||
-- 5.3 TRIPS (Aggregated fact table)
|
||||
CREATE TABLE IF NOT EXISTS bronze.trips (
|
||||
id BIGINT NOT NULL,
|
||||
imei TEXT NOT NULL,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
start_geom geometry(Point,4326),
|
||||
end_geom geometry(Point,4326),
|
||||
distance_km NUMERIC(12,2),
|
||||
avg_speed_kmh NUMERIC(7,2),
|
||||
max_speed_kmh NUMERIC(7,2),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
fuel_consumed_l NUMERIC(8,2),
|
||||
idle_time_s INTEGER,
|
||||
driving_time_s INTEGER,
|
||||
trip_seq INTEGER,
|
||||
source TEXT DEFAULT 'poll'::text,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- 5.4 ALARMS (Event log fact table)
|
||||
CREATE TABLE IF NOT EXISTS bronze.alarms (
|
||||
id BIGINT PRIMARY KEY,
|
||||
imei TEXT,
|
||||
alarm_type TEXT,
|
||||
alarm_time TIMESTAMPTZ,
|
||||
geom geometry(Point,4326),
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
speed NUMERIC(7,2),
|
||||
acc_status TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
alarm_name TEXT,
|
||||
source TEXT DEFAULT 'poll'::text,
|
||||
severity TEXT,
|
||||
geofence_id TEXT,
|
||||
geofence_name TEXT,
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
acknowledged_by TEXT,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.5 DEVICE EVENTS (Connection lifecycle)
|
||||
CREATE TABLE IF NOT EXISTS bronze.device_events (
|
||||
id BIGINT PRIMARY KEY,
|
||||
imei TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_time TIMESTAMPTZ NOT NULL,
|
||||
timezone TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.6 DISPATCH LOG (Operational/SLA tracking)
|
||||
CREATE TABLE IF NOT EXISTS bronze.dispatch_log (
|
||||
dispatch_id BIGINT PRIMARY KEY,
|
||||
ticket_id TEXT NOT NULL,
|
||||
imei TEXT NOT NULL,
|
||||
driver_name TEXT,
|
||||
job_lat DOUBLE PRECISION NOT NULL,
|
||||
job_lng DOUBLE PRECISION NOT NULL,
|
||||
job_geom geometry(Point,4326),
|
||||
assigned_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
first_movement_at TIMESTAMPTZ,
|
||||
on_site_at TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
cancelled_at TIMESTAMPTZ,
|
||||
distance_km NUMERIC(8,2),
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.7 FAULT CODES (OBD/DTC diagnostics)
|
||||
CREATE TABLE IF NOT EXISTS bronze.fault_codes (
|
||||
id BIGINT PRIMARY KEY,
|
||||
imei TEXT NOT NULL,
|
||||
reported_at TIMESTAMPTZ NOT NULL,
|
||||
fault_code TEXT NOT NULL,
|
||||
status_flags INTEGER,
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
geom geometry(Point,4326),
|
||||
event_time TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.8 FUEL READINGS
|
||||
CREATE TABLE IF NOT EXISTS bronze.fuel_readings (
|
||||
imei TEXT NOT NULL,
|
||||
reading_time TIMESTAMPTZ NOT NULL,
|
||||
sensor_path TEXT,
|
||||
value NUMERIC(10,3),
|
||||
unit TEXT,
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
geom geometry(Point,4326),
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (imei, reading_time)
|
||||
);
|
||||
|
||||
-- 5.9 GEOFENCES (Dimension/Reference)
|
||||
CREATE TABLE IF NOT EXISTS bronze.geofences (
|
||||
id BIGINT PRIMARY KEY,
|
||||
fence_id TEXT,
|
||||
fence_name TEXT NOT NULL,
|
||||
fence_type TEXT,
|
||||
geom geometry(Geometry,4326),
|
||||
radius_m NUMERIC(10,2),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.10 HEARTBEATS (Device health/ping)
|
||||
CREATE TABLE IF NOT EXISTS bronze.heartbeats (
|
||||
imei TEXT NOT NULL,
|
||||
gate_time TIMESTAMPTZ NOT NULL,
|
||||
power_level SMALLINT,
|
||||
gsm_signal SMALLINT,
|
||||
acc_status SMALLINT,
|
||||
power_status SMALLINT,
|
||||
fortify SMALLINT,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (imei, gate_time)
|
||||
);
|
||||
|
||||
-- 5.11 INGESTION LOG (Metadata for tracking loads)
|
||||
CREATE TABLE IF NOT EXISTS bronze.ingestion_log (
|
||||
id BIGINT PRIMARY KEY,
|
||||
run_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
imei_count INTEGER DEFAULT 0 NOT NULL,
|
||||
rows_upserted INTEGER DEFAULT 0 NOT NULL,
|
||||
rows_inserted INTEGER DEFAULT 0 NOT NULL,
|
||||
duration_ms INTEGER DEFAULT 0 NOT NULL,
|
||||
success BOOLEAN DEFAULT true NOT NULL,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.12 LBS READINGS (Fallback positioning)
|
||||
CREATE TABLE IF NOT EXISTS bronze.lbs_readings (
|
||||
id BIGINT PRIMARY KEY,
|
||||
imei TEXT NOT NULL,
|
||||
gate_time TIMESTAMPTZ NOT NULL,
|
||||
post_type TEXT,
|
||||
lbs_data JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.13 LIVE POSITIONS (Current state snapshot)
|
||||
CREATE TABLE IF NOT EXISTS bronze.live_positions (
|
||||
imei TEXT PRIMARY KEY,
|
||||
geom geometry(Point,4326),
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
pos_type TEXT,
|
||||
confidence SMALLINT,
|
||||
gps_time TIMESTAMPTZ,
|
||||
hb_time TIMESTAMPTZ,
|
||||
speed NUMERIC(7,2),
|
||||
direction NUMERIC(6,2),
|
||||
acc_status TEXT,
|
||||
gps_signal SMALLINT,
|
||||
gps_num SMALLINT,
|
||||
elec_quantity NUMERIC(5,2),
|
||||
power_value NUMERIC(5,2),
|
||||
battery_power_val NUMERIC(5,2),
|
||||
tracker_oil TEXT,
|
||||
temperature NUMERIC(8,2),
|
||||
current_mileage NUMERIC(12,2),
|
||||
device_status TEXT,
|
||||
expire_flag TEXT,
|
||||
activation_flag TEXT,
|
||||
loc_desc TEXT,
|
||||
recorded_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.14 OBD READINGS (Vehicle diagnostics)
|
||||
CREATE TABLE IF NOT EXISTS bronze.obd_readings (
|
||||
id BIGINT PRIMARY KEY,
|
||||
imei TEXT,
|
||||
reading_time TIMESTAMPTZ,
|
||||
engine_rpm INTEGER,
|
||||
fuel_level_pct NUMERIC(5,2),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
car_type SMALLINT,
|
||||
acc_state SMALLINT,
|
||||
status_flags INTEGER,
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
geom geometry(Point,4326),
|
||||
obd_data JSONB,
|
||||
coolant_temp_c NUMERIC(6,2),
|
||||
battery_voltage NUMERIC(5,2),
|
||||
intake_pressure NUMERIC(6,2),
|
||||
throttle_pct NUMERIC(5,2),
|
||||
vehicle_speed NUMERIC(7,2),
|
||||
engine_load_pct NUMERIC(5,2),
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.15 PARKING EVENTS
|
||||
CREATE TABLE IF NOT EXISTS bronze.parking_events (
|
||||
id BIGINT PRIMARY KEY,
|
||||
imei TEXT NOT NULL,
|
||||
event_type TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_seconds INTEGER,
|
||||
geom geometry(Point,4326),
|
||||
address TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5.16 TEMPERATURE READINGS (Cold chain sensors)
|
||||
CREATE TABLE IF NOT EXISTS bronze.temperature_readings (
|
||||
imei TEXT NOT NULL,
|
||||
reading_time TIMESTAMPTZ NOT NULL,
|
||||
temperature NUMERIC(6,2),
|
||||
humidity_pct NUMERIC(5,2),
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
ingested_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (imei, reading_time)
|
||||
);
|
||||
|
||||
-- Reset role back to superuser
|
||||
RESET ROLE;
|
||||
RESET search_path;
|
||||
|
||||
-- 6. VERIFICATION GRANTS (Ensure Grafana can query immediately)
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA bronze TO grafana_ro;
|
||||
GRANT USAGE ON SCHEMA bronze TO grafana_ro;
|
||||
163
dwh/260424_all_vehicles.csv
Normal file
163
dwh/260424_all_vehicles.csv
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"imei","device_name","mc_type","mc_type_use_scope","vehicle_name","vehicle_number","vehicle_models","vehicle_icon","vin","engine_number","vehicle_brand","fuel_100km","driver_name","driver_phone","sim","iccid","imsi","account","customer_name","device_group_id","device_group","activation_time","expiration","enabled_flag","status","city","current_mileage_km","created_at","updated_at","last_synced_at","vehicle_category","cost_centre","assigned_route","depot_geom","depot_address","assigned_city"
|
||||
"353549090553685","Daniel Omondi - KMFF 099Z","AT4","personal","KMFF 099Z","KMFF 099Z","Motorbike","mtc",NULL,NULL,NULL,NULL,"Robert","0112794067","759336150","89254021334258404099","639021335840409","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-09-23 05:50:30+00","2040-09-23 23:59:59+00",1,"1",NULL,"2354.70","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"353549090561720","Wireless_Git","AT4","personal",NULL,NULL,NULL,"bus",NULL,NULL,NULL,NULL,NULL,NULL,"0701211913","89254021374215155053","639021371515505","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2025-06-09 09:12:50+00","2035-06-09 23:59:59+00",1,"1",NULL,"5992.43","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"353549090566281","KDR 592N","AT4","personal",NULL,NULL,NULL,"bus",NULL,NULL,NULL,NULL,NULL,NULL,"0797680464","89254021334258159693","639021335815969","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2024-11-08 04:01:30+00","2034-11-08 23:59:59+00",1,"1",NULL,"7771.90","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"353549090566885","Wireless GPS","AT4","personal",NULL,NULL,NULL,"bus",NULL,NULL,NULL,NULL,NULL,NULL,"0768445963","89254021334212352574","639021331235257","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2024-10-15 13:16:57+00","2034-10-15 23:59:59+00",1,"1",NULL,"17036.41","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"353549090567685","Daniel Kipkirui - KMFF 162Z","AT4","personal","KMFF 162Z","KMFF 162Z","Motorbike","mtc",NULL,NULL,NULL,NULL,"edwine","0112795498","742532058","89254021264260388966","639021266038896","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-09-23 05:09:39+00","2040-09-23 23:59:59+00",1,"1",NULL,"462.33","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"353549090567701","Wireless","AT4","personal",NULL,NULL,NULL,"bus",NULL,NULL,NULL,NULL,NULL,NULL,"0790176094","89254021394215205906","639021391520590","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2024-11-08 04:04:44+00","2034-11-08 23:59:59+00",1,"1",NULL,"16896.20","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081885410","Allan Owana - KDK 780K","GT06E","aotomobile","KDK 780K","KDK 780K","Probox","automobile",NULL,NULL,NULL,NULL,"Allan Owana",NULL,"703616117","89254021234222499854","639021232249985","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2019-06-19 09:32:22+00","2039-06-19 23:59:59+00",1,"1",NULL,"128853.11","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081886467","Gideon Kiprono - KCQ 215F","GT06E","aotomobile","KCQ 215F","OHS","Probox","automobile",NULL,NULL,NULL,"0.00","Gideon Kiprono",NULL,"746763076","89254021084186499865","639021088649986","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2019-06-30 09:30:00+00","2039-06-30 23:59:59+00",1,"1",NULL,"141057.46","2026-04-23 10:56:37.983314+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081886871","Kamonde KBA 467S","GT06E","aotomobile",NULL,NULL,NULL,"bus",NULL,NULL,NULL,NULL,NULL,NULL,"0746763083","89254021084186499873","639021088649987","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2019-06-30 09:09:14+00","2039-06-30 23:59:59+00",1,"1",NULL,"74183.36","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081886905","Kennedy Chege - KCQ 618K","GT06E","aotomobile","KCQ 618K","KCQ 618K","Probox","automobile",NULL,NULL,NULL,NULL,"Kennedy Chege",NULL,"746763132","89254021084186499923","639021088649992","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2019-06-30 07:08:35+00","2039-06-30 23:59:59+00",1,"1",NULL,"215608.19","2026-04-23 10:35:37.678371+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081887069","Wright Oseko - KCG 668W","GT06E","aotomobile","KCG 668W","KCG 668W","Probox","automobile",NULL,NULL,NULL,NULL,"Wright Oseko",NULL,"746763106","89254021084186499915","639021088649991","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2019-06-30 06:17:43+00","2039-06-30 23:59:59+00",1,"1",NULL,"239001.19","2026-04-23 11:00:08.769463+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081887192","Ndegwa Dancun - KCG 669W","GT06E","aotomobile","KCG 669W","KCG 669W","Probox","automobile",NULL,NULL,NULL,NULL,"Ndegwa Dancun",NULL,"746760191","89254021084186499501","639021088649950","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2019-06-15 10:26:15+00","2039-06-15 23:59:59+00",1,"1",NULL,"199191.85","2026-04-23 10:34:29.074112+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081891566","Simon Kamau - KCE 090R","GT06E","aotomobile","KCE 090R","KCE 090R","Probox","automobile",NULL,NULL,NULL,NULL,"Simon Kamau",NULL,"746760404","89254021084186499527","639021088649952","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2019-06-16 07:06:15+00","2039-06-16 23:59:59+00",1,"1",NULL,"215592.36","2026-04-23 10:30:55.739184+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081891590","Garage - KCE 699F","GT06E","aotomobile","KCE 699F","KCE 699F","Probox","automobile",NULL,NULL,NULL,NULL,"Garage",NULL,"746760215","89254021084186499519","639021088649951","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2019-06-16 11:11:24+00","2039-06-16 23:59:59+00",1,"1",NULL,"207814.05","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081891632","John Ondego - KCA 542Q","GT06E","aotomobile","KCA 542Q","KCA 542Q","Probox","automobile",NULL,NULL,NULL,NULL,"John Ondego",NULL,"746760038","89254021084186499485","639021088649948","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2019-06-15 09:17:53+00","2039-06-15 23:59:59+00",1,"1",NULL,"178914.47","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081891798","Garage - KCH 167M","GT06E","aotomobile","KCH 167M","KCH 167M","Probox","automobile",NULL,NULL,NULL,NULL,"Garage",NULL,"746760102","89254021084186499493","639021088649949","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2019-06-16 10:18:57+00","2039-06-16 23:59:59+00",1,"1",NULL,"168840.95","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081892101","Cornelius Kimutai - KCU 938R","GT06E","aotomobile","KCU 938R","KCU 938R","Van","automobile",NULL,NULL,NULL,NULL,"Cornelius Kimutai",NULL,"746759919","89254021084186499451","639021088649945","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2019-06-12 08:13:48+00","2039-06-12 23:59:59+00",1,"1",NULL,"149558.50","2026-04-23 10:29:21.507861+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081892309","Nicholas Erastus - KCQ 581M","GT06E","aotomobile","KCQ 581M","KCQ 581M","Probox","automobile",NULL,NULL,NULL,NULL,"Nicholas Erastus",NULL,"700023776","89254021084178504672","639021087850467","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2019-06-09 09:39:40+00","2039-06-09 23:59:59+00",1,"1",NULL,"209105.89","2026-04-23 10:40:40.169684+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081892440","KAZ 489Z","GT06E","aotomobile",NULL,NULL,NULL,"bus",NULL,NULL,NULL,NULL,NULL,NULL,"0700023806","89254021084178504698","639021087850469","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2019-06-09 10:04:39+00","2039-06-09 23:59:59+00",1,"1",NULL,"38197.20","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857081892762","Nicholas","GT06E","aotomobile",NULL,NULL,"Station Wagon","bus",NULL,NULL,"Toyota",NULL,NULL,NULL,"0746760503","89254021274233125361","639021273312536","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2019-06-16 08:31:46+00","2039-06-16 23:59:59+00",1,"1",NULL,"51048.97","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082037185","Amani Kazungu - KCY 084X","GT06E","aotomobile","KCY 084X","KCY 084X","Probox","automobile",NULL,NULL,NULL,NULL,"Amani Kazungu",NULL,"757338522","89254021154287000597","639021158700059","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-07-13 09:42:28+00","2040-07-13 23:59:59+00",1,"1",NULL,"172298.81","2026-04-23 10:51:08.665273+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082038977","Wilfred Kinyanjui - KCU 729C","GT06E","aotomobile","KCU 729C","KCU 729C","Crane","truck",NULL,NULL,NULL,NULL,"Wilfred Kinyanjui",NULL,"110094469","89254021164215938057","639021161593805","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-04-05 09:26:00+00","2040-04-05 23:59:59+00",1,"1",NULL,"172487.09","2026-04-23 10:24:33.914628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082040981","Amani Sulubu - KCY 090X","GT06E","aotomobile","KCY 090X","KCY 090X","Probox","automobile",NULL,NULL,NULL,NULL,"Amani Sulubu",NULL,"793375853","89254021064168004164","639021066800416","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-07-13 07:25:16+00","2040-07-13 23:59:59+00",1,"1",NULL,"166028.15","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082042052","Gabriel Musumba - KCE 690F","GT06E","aotomobile","KCE 690F","KCE 690F","Probox","automobile",NULL,NULL,NULL,NULL,"Gabriel Musumba",NULL,"110094466","89254021164215938024","639021161593802","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2020-04-03 17:30:13+00","2040-04-03 23:59:59+00",1,"1",NULL,"192693.23","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082042854","Elias Baya - KCZ 476E","GT06E","aotomobile","KCZ 476E","KCZ 476E","Probox","automobile",NULL,NULL,NULL,NULL,"Elias Baya",NULL,"110941187","89254021164224352993","639021162435299","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-08-09 05:06:42+00","2040-08-09 23:59:59+00",1,"1",NULL,"217595.68","2026-04-23 10:33:56.216621+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082042953","KCU 865Q Vanguard",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,"unknown",NULL,NULL,"2026-04-23 13:24:33.293453+00","2026-04-23 13:24:33.293453+00",NULL,NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082044280","Lawrence Kijogi - KCY 080X","GT06E","aotomobile","KCY 080X","KCY 080X","Probox","automobile",NULL,NULL,NULL,NULL,"Lawrence Kijogi",NULL,"708155933","89254029851005131222","639029850513122","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-07-13 11:05:02+00","2040-07-13 11:05:02+00",1,"1",NULL,"169740.37","2026-04-23 14:52:58.983571+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082046145","Joseph Kabandi - KCY 076X","GT06E","aotomobile","KCY 076X","KCY 076X","Probox","automobile",NULL,NULL,NULL,NULL,"Joseph Kabandi",NULL,"110850007","89254021164223447158","639021162344715","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-07-13 08:31:26+00","2040-07-13 23:59:59+00",1,"1",NULL,"122254.48","2026-04-23 10:47:40.895504+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082896911","Hamisi Pande - KDD 689Y","GT06E","aotomobile","KDD 689Y","KDD 689Y","Probox","automobile",NULL,NULL,NULL,NULL,"Hamisi Pande",NULL,"112714612","89254021214211314660","639021211131466","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-09-17 11:50:53+00","2041-09-17 23:59:59+00",1,"1",NULL,"163435.74","2026-04-23 10:26:09.922447+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082897091","Peter Mbugua - KDK 728K","GT06E","aotomobile","KDK 728K","KDK 728K","Probox","automobile",NULL,NULL,NULL,NULL,"Peter Mbugua",NULL,"790262984","89254021234222500396","639021232250039","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-12-14 11:31:57+00","2042-12-14 23:59:59+00",1,"1",NULL,"131109.26","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082897257","Cassius Wakiyo - KDB 323M","GT06E","aotomobile","KDB 323M","KDB 323M","Probox","automobile",NULL,NULL,NULL,NULL,"Cassius Wakiyo",NULL,"746428882","89254021234222500818","639021232250081","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-08-29 15:07:26+00","2041-08-29 15:07:26+00",1,"1",NULL,"121688.92","2026-04-23 10:28:26.388654+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082897737","John Makori - KDB 585E","GT06E","aotomobile","KDB 585E","KDB 585E","Probox","automobile",NULL,NULL,NULL,NULL,"John Makori",NULL,"114596734","89254021214211145262","639021211114526","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-08-29 14:29:28+00","2041-08-29 14:29:28+00",1,"1",NULL,"156765.03","2026-04-23 10:38:57.445964+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082897794","Mutuku Joseph - KDC 739F","GT06E","aotomobile","KDC 739F","KDC 739F","Probox","automobile",NULL,NULL,NULL,NULL,"Mutuku Joseph","115019037","115019037","89254021224222632356","639021222263235","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-04-10 14:55:32+00","2041-04-10 14:55:32+00",1,"1",NULL,"205169.79","2026-04-23 10:30:22.530563+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082898008","Samuel Ng'ang'a - KDE 264M","GT06E","aotomobile","KDE 264M","KDE 264M","Probox","automobile",NULL,NULL,NULL,NULL,"Samuel Ng'ang'a",NULL,"711731539","89254021264260342245","639021266034224","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-10-28 09:43:11+00","2041-10-28 23:59:59+00",1,"1",NULL,"126584.24","2026-04-23 11:35:59.816581+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082898016","Job Ngare - KDM 309S","GT06E","aotomobile","KDM 309S","KDM 309S","Probox","automobile",NULL,NULL,NULL,NULL,"Job Ngare",NULL,"706895756","89254021324273007563","639021327300756","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-08-15 13:45:26+00","2033-08-15 23:59:59+00",1,"1",NULL,"107726.56","2026-04-23 11:20:25.939244+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082898073","Mutuku Antony - KDK 732K","GT06E","aotomobile","KDK 732K","KDK 732K","Probox","automobile",NULL,NULL,NULL,NULL,"Mutuku Antony",NULL,"793026954","89254021234222387539","639021232238753","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-12-20 06:33:12+00","2042-12-20 23:59:59+00",1,"1",NULL,"82096.79","2026-04-23 14:52:07.094447+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082898487","Dan Watila - KDE 638J","GT06E","aotomobile","KDE 638J","KDE 638J","Probox","automobile",NULL,NULL,NULL,NULL,"Dan Watila",NULL,"116242996","89254021334258404214","639021335840421","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-10-21 15:50:06+00","2041-10-21 23:59:59+00",1,"1",NULL,"123872.36","2026-04-23 10:31:45.186653+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082900168","KDD 913G_Ruth Mazda",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,"unknown",NULL,NULL,"2026-04-23 15:09:48.575568+00","2026-04-23 15:09:48.575568+00",NULL,NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082900341","Simon Munda - KCZ 154S","GT06E","aotomobile","KCZ 154S","KCZ 154S","Probox","automobile",NULL,NULL,NULL,NULL,"Simon Munda",NULL,"757236135","89254021154296723312","639021159672331","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-09-23 17:12:51+00","2040-09-23 23:59:59+00",1,"1",NULL,"186504.10","2026-04-23 10:45:21.454595+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082900358","Geoffrey Too - KDM 308S","GT06E","aotomobile","KDM 308S","KDM 308S","Probox","automobile",NULL,NULL,NULL,NULL,"Geoffrey Too",NULL,"796527601","89254021264260126572","639021266012657","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-10-21 15:25:28+00","2041-10-21 23:59:59+00",1,"1",NULL,"142216.91","2026-04-23 12:35:06.661934+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082900697","George Ochieng' - KDD 684Y","GT06E","aotomobile","KDD 684Y","KDD 684Y","Probox","automobile",NULL,NULL,NULL,NULL,"George Ochieng'",NULL,"114879518","89254021214211314678","639021211131467","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-09-17 10:53:11+00","2041-09-17 23:59:59+00",1,"1",NULL,"152820.07","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082902461","Sadique Wakayula - KDC 490Q","GT06E","aotomobile","KDC 490Q","KDC 490Q","Crane","truck",NULL,NULL,NULL,NULL,"Sadique Wakayula",NULL,"757556468","89254021154296722488","639021159672248","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-05-22 11:27:30+00","2041-05-22 11:27:30+00",1,"1",NULL,"183009.52","2026-04-23 11:16:03.730519+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082902503","Felix Andole - KDC 207R","GT06E","aotomobile","KDC 207R","KDC 207R","Probox","automobile",NULL,NULL,NULL,NULL,"Felix Andole",NULL,"794820817","89254021224270993254","639021227099325","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-05-15 11:38:24+00","2041-05-15 11:38:24+00",1,"1",NULL,"208724.46","2026-04-23 15:32:46.935568+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082907973","Felix Muema - KCZ 223P","GT06E","aotomobile","KCZ 223P","KCZ 223P","Probox","automobile",NULL,NULL,NULL,NULL,"Felix Muema",NULL,"757843826","89254021154287138371","639021158713837","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-08-22 14:01:25+00","2040-08-22 23:59:59+00",1,"1",NULL,"222126.36","2026-04-23 10:26:48.220151+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082908500","Santoes Omondi - KCZ 181P","GT06E","aotomobile","KCZ 181P","KCZ 181P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Santoes Omondi",NULL,"701211974","89254021374215155087","639021371515508","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-08-23 08:58:55+00","2040-08-23 23:59:59+00",1,"1",NULL,"221339.62","2026-04-23 10:48:09.537346+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082910589","Patric Bett - KDA 609E","GT06E","aotomobile","KDA 609E","KDA 609E","Probox","automobile",NULL,NULL,NULL,NULL,"Patric Bett",NULL,"797622637","89254021154296722496","639021159672249","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2020-10-26 15:46:41+00","2040-10-26 23:59:59+00",1,"1",NULL,"194618.69","2026-04-23 10:34:25.350862+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082910886","Makanda Andrew - KCZ 155P","GT06E","aotomobile","KCZ 155P","KCZ 155P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Makanda Andrew",NULL,"745067338","89254021154287138397","639021158713839","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-08-23 11:52:35+00","2040-08-23 23:59:59+00",1,"1",NULL,"231065.89","2026-04-23 11:36:31.150282+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082911983","Brian Ngetich - KDA 717B","GT06E","aotomobile","KDA 717B","KDA 717B","Probox","automobile",NULL,NULL,NULL,NULL,"Brian Ngetich","795188807","795188807","89254021214211145288","639021211114528","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-08-29 07:21:43+00","2041-08-29 07:21:43+00",1,"1",NULL,"145404.96","2026-04-23 10:36:11.774166+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082912239","Dickson Jaoko - KDK 815R","GT06E","aotomobile","KDK 815R","KDK 815R","Probox","automobile",NULL,NULL,"Probox","0.00","Sammy",NULL,"706392117","89254021234296021287","639021239602128","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-06-21 07:14:51+00","2033-06-21 23:59:59+00",1,"1",NULL,"77008.75","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082912486","Moses Wambua - KCZ 751V","GT06E","aotomobile","KCZ 751V","KCZ 751V","Probox","automobile",NULL,NULL,NULL,NULL,"Moses Wambua","0792756503","792756503","89254021154296723437","639021159672343","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-09-23 10:14:28+00","2040-09-23 23:59:59+00",1,"1",NULL,"139762.20","2026-04-23 10:41:00.207177+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082916826","Denis Kazungu - KDM 794R","GT06E","aotomobile","KDM 794R","KDM 794R","Probox","automobile",NULL,NULL,NULL,NULL,"Denis Kazungu",NULL,"705700971","89254021324273006854","639021327300685","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2023-08-21 06:38:00+00","2033-08-21 23:59:59+00",1,"1",NULL,"79639.71","2026-04-23 20:18:46.496567+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082918012","Charles Nyambane - KCB 711C","GT06E","aotomobile","KCB 711C","KCB 711C","Probox","automobile",NULL,NULL,NULL,NULL,"Charles Nyambane",NULL,"793704231","89254021154287138363","639021158713836","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2020-09-21 10:48:35+00","2040-09-21 23:59:59+00",1,"1",NULL,"159597.27","2026-04-23 10:25:52.843474+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082918038","Mbuvi Kioko - KCC 199P","GT06E","aotomobile","KCC 199P","KCC 199P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Mbuvi Kioko",NULL,"797318126","89254021154287138389","639021158713838","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-08-22 15:26:27+00","2040-08-22 23:59:59+00",1,"1",NULL,"222106.80","2026-04-23 12:09:05.609075+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082918186","KDD 977T Fielder",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,"unknown",NULL,NULL,"2026-04-23 10:36:25.832836+00","2026-04-23 10:36:25.832836+00",NULL,NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"359857082925330","Noel Merengeni - KCY 838X","GT06E","aotomobile","KCY 838X","KCY 838X","Probox","automobile",NULL,NULL,NULL,NULL,"Noel Merengeni",NULL,"794873610","89254021154296723429","639021159672342","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2020-10-26 16:36:37+00","2040-10-26 23:59:59+00",1,"1",NULL,"194429.24","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050288212","Nicholas Erastus - KCQ 581M","JC400P","aotomobile","KCQ 581M","KCQ 581M","Probox","automobile",NULL,NULL,NULL,NULL,"Nicholas Erastus",NULL,"746979531",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-11-02 09:07:41+00","2041-11-02 23:59:59+00",1,"1",NULL,"40898.98","2026-04-23 13:05:18.326254+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050288261","Patric Bett - KDA 609E","JC400P","aotomobile","KDA 609E","KDA 609E","Probox","automobile",NULL,NULL,NULL,NULL,"Patric Bett","112693340","790176509",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2021-10-23 11:50:11+00","2041-10-23 23:59:59+00",1,"1",NULL,"18538.42","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050288303","Elias Baya - KCZ 476E","JC400P","aotomobile","KCZ 476E","KCZ 476E","Probox","automobile",NULL,NULL,NULL,NULL,"Elias Baya",NULL,"115870439",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-11-06 08:50:28+00","2041-11-06 23:59:59+00",1,"1",NULL,"116091.42","2026-04-23 17:46:09.993791+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050288345","Santoes Omondi - KCZ 181P","JC400P","aotomobile","KCZ 181P","KCZ 181P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Santoes Omondi",NULL,"768446105",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2021-11-06 10:17:51+00","2041-11-06 23:59:59+00",1,"1",NULL,"107462.79","2026-04-23 10:29:45.563231+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050288360","Brian Ngetich - KDA 717B","JC400P","aotomobile","KDA 717B","KDA 717B","Probox","automobile",NULL,NULL,NULL,NULL,"Brian Ngetich",NULL,"717867861",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2021-11-05 08:47:08+00","2041-11-05 23:59:59+00",1,"1",NULL,"17808.56","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050521521","John Kimeria - KDS 525D","JC400P","aotomobile","KDS 525D","KDS 525D","Crane","truck",NULL,NULL,NULL,NULL,"John Kimeria",NULL,"752958416",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-11-26 07:58:13+00","2033-11-26 23:59:59+00",1,"1",NULL,"19354.92","2026-04-23 10:28:34.917147+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050521612","Denis Kazungu - KDM 794R","JC400P","aotomobile","KDM 794R","KDM 794R","Probox","automobile",NULL,NULL,NULL,NULL,"Denis Kazungu",NULL,"704113731",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-22 07:52:12+00","2042-01-22 23:59:59+00",1,"1",NULL,"4350.75","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050521752","Simon Munda - KCZ 154S","JC400P","aotomobile","KCZ 154S","KCZ 154S","Probox","automobile",NULL,NULL,NULL,NULL,"Simon Munda",NULL,"113805921",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 08:14:37+00","2042-01-16 23:59:59+00",1,"1",NULL,"4698.02","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522065","Gideon Kiprono - KCQ 215F","JC400P","aotomobile","KCQ 215F","KCQ 215F","Probox","automobile",NULL,NULL,NULL,NULL,"Gideon Kiprono",NULL,"113343715",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2022-01-16 07:10:16+00","2042-01-16 23:59:59+00",1,"1",NULL,"8111.98","2026-04-23 18:23:51.445608+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522107","Cassius Wakiyo - KDB 323M","JC400P","aotomobile","KDB 323M","KDB 323M","Probox","automobile",NULL,NULL,NULL,NULL,"Cassius Wakiyo",NULL,"114149576",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-22 08:18:15+00","2042-01-22 23:59:59+00",1,"1",NULL,"23316.09","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522719","Mbuvi Kioko - KCZ 199P","JC400P","aotomobile","KCZ 199P","KCZ 199P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Mbuvi Kioko",NULL,"768218655",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 22:07:24+00","2042-01-16 23:59:59+00",1,"1",NULL,"16973.89","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522743","Charles Nyambane - KCB 711C","JC400P","aotomobile","KCB 711C","KCB 711C","Probox","automobile",NULL,NULL,NULL,NULL,"Charles Nyambane",NULL,"768657106",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-12-22 21:53:57+00","2033-12-22 23:59:59+00",1,"1",NULL,"12133.75","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522859","Garage - KCH 167M","JC400P","aotomobile","KCH 167M","KCH 167M","Probox","automobile",NULL,NULL,NULL,NULL,"Garage",NULL,"706740252",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-15 08:23:21+00","2042-01-15 23:59:59+00",1,"1",NULL,"6934.86","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522883","Dan Watila - KDE 638J","JC400P","aotomobile","KDE 638J","KDE 638J","Probox","automobile",NULL,NULL,NULL,NULL,"Dan Watila",NULL,"112615393",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-15 07:17:21+00","2042-01-15 23:59:59+00",1,"1",NULL,"14483.01","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050522891","Lawrence Kijogi - KCY 080X","JC400P","aotomobile","KCY 080X","KCY 080X","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Lawrence Kijogi",NULL,"113287191",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 10:51:30+00","2042-01-16 23:59:59+00",1,"1",NULL,"11585.33","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523014","Samuel Muriithy - KDR 594N","JC400P","aotomobile","KDR 594N","KDR 594N","Probox","automobile",NULL,NULL,NULL,NULL,"Samuel Muriithy",NULL,"790175423",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-21 15:54:32+00","2033-12-21 23:59:59+00",1,"1",NULL,"27275.43","2026-04-23 10:26:17.747928+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523063","Kelvin Wambugu - KDR 594N","JC400P","aotomobile","KDR 594N","KDR 594N","Probox","automobile",NULL,NULL,NULL,NULL,"Kelvin Wambugu",NULL,"701211876",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-22 19:24:51+00","2043-12-22 19:24:51+00",1,"1",NULL,"32698.94","2026-04-23 15:31:08.065856+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523139","Mike Wanaswa - KDT 724R","JC400P","aotomobile","KDT 724R","KDT 724R","Probox","automobile",NULL,NULL,NULL,NULL,"Mike Wanaswa",NULL,"790175045",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-22 22:28:09+00","2043-12-22 22:28:09+00",1,"1",NULL,"29559.82","2026-04-23 11:16:37.277518+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523204","Amani Kazungu - KCY 084X","JC400P","aotomobile","KCY 084X","KCY 084X","Probox","automobile",NULL,NULL,NULL,NULL,"Amani Kazungu",NULL,"707892547",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 06:18:20+00","2042-01-16 23:59:59+00",1,"1",NULL,"66955.70","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523295","Emmanuel Luseno - KDS 453 Y","JC400P","aotomobile","KDS 453 Y","KDS 453 Y","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Emmanuel Luseno",NULL,"700242474",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-22 14:39:50+00","2033-12-22 23:59:59+00",1,"1",NULL,"37098.35","2026-04-23 11:29:48.369147+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523337","Victor Kimutai - KDS 919Y","JC400P","aotomobile","KDS 919Y","KDS 919Y","Probox","automobile",NULL,NULL,NULL,NULL,"Victor Kimutai",NULL,"700242527",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-22 18:00:49+00","2043-12-22 18:00:49+00",1,"1",NULL,"50756.64","2026-04-23 10:27:13.522675+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523386","George Ochieng' - KDD 684Y","JC400P","aotomobile","KDD 684Y","KDD 684Y","Probox","automobile",NULL,NULL,NULL,NULL,"George Ochieng'",NULL,"785586834",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-22 06:36:08+00","2042-01-22 23:59:59+00",1,"1",NULL,"33979.83","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523527","Allan Owana - KDK 780K","JC400P","aotomobile","KDK 780K","KDK 780K","Probox","automobile",NULL,NULL,NULL,NULL,"Allan Owana",NULL,"792375024",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2022-12-03 10:43:41+00","2042-12-03 23:59:59+00",1,"1",NULL,"109564.95","2026-04-23 10:25:24.360765+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523618","Geoffrey Too - KDM 308S","JC400P","aotomobile","KDM 308S","KDM 308S","Probox","automobile",NULL,NULL,NULL,NULL,"Geoffrey Too",NULL,"701211625",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-08-15 15:42:32+00","2033-08-15 23:59:59+00",1,"1",NULL,"26496.50","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523626","Major Simiyu - KDS 949Y","JC400P","aotomobile","KDS 949Y","KDS 949Y","Probox","automobile",NULL,NULL,NULL,NULL,"Major Simiyu",NULL,"701211892",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-22 18:05:16+00","2033-12-22 23:59:59+00",1,"1",NULL,"37042.97","2026-04-23 10:51:18.245194+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523816","Job Ngare - KDM 309S","JC400P","aotomobile","KDM 309S","KDM 309S","Probox","automobile",NULL,NULL,NULL,NULL,"Job Ngare",NULL,"707936781",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-08-15 14:05:52+00","2033-08-15 23:59:59+00",1,"1",NULL,"54320.21","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050523949","Joseph Kabandi - KCY 076X","JC400P","aotomobile","KCY 076X","KCY 076X","Probox","automobile",NULL,NULL,NULL,NULL,"Joseph Kabandi",NULL,"113288492",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 05:52:54+00","2042-01-16 23:59:59+00",1,"1",NULL,"14427.50","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524012","Moses Wambua - KCZ 751V","JC400P","aotomobile","KCZ 751V","KCZ 751V","Probox","automobile",NULL,NULL,NULL,NULL,"Moses Wambua",NULL,"113313797",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 07:40:10+00","2042-01-16 23:59:59+00",1,"1",NULL,"26551.46","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524087","Felix Muema - KCZ 223P","JC400P","aotomobile","KCZ 223P","KCZ 223P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Felix Muema",NULL,"113973875",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 13:02:24+00","2042-01-16 23:59:59+00",1,"1",NULL,"11543.26","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524368","862798050524368","JC400P","aotomobile",NULL,NULL,NULL,"automobile",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-10-29 09:24:53+00","2042-10-29 23:59:59+00",1,"1",NULL,"169208.91","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524384","Hamisi Pande - KDD 689Y","JC400P","aotomobile","KDD 689Y","KDD 689Y","Probox","automobile",NULL,NULL,NULL,"0.00","Hamisi Pande",NULL,"701211744",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-22 05:49:19+00","2042-01-22 23:59:59+00",1,"1",NULL,"13685.18","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524392","Ndegwa Dancun - KCG 669W","JC400P","aotomobile","KCG 669W","KCG 669W","Probox","automobile",NULL,NULL,NULL,NULL,"Ndegwa Dancun",NULL,"113799173",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 09:43:10+00","2042-01-16 23:59:59+00",1,"1",NULL,"13638.25","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524426","Amani Sulubu - KCY 090X","JC400P","aotomobile","KCY 090X","KCY 090X","Probox","automobile",NULL,NULL,NULL,NULL,"Amani Sulubu",NULL,"113823350",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2022-01-16 08:56:25+00","2042-01-16 23:59:59+00",1,"1",NULL,"14243.83","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524533","Leonard Nzai - KDM 306S","JC400P","aotomobile","KDM 306S","KDM 306S","Probox","automobile",NULL,NULL,NULL,NULL,"Leonard Nzai",NULL,"703487162",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-08-21 08:22:12+00","2033-08-21 23:59:59+00",1,"1",NULL,"68942.41","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524558","Mutuku Joseph - KDC 739F","JC400P","aotomobile","KDC 739F","KDC 739F","Probox","automobile",NULL,NULL,NULL,NULL,"Mutuku Joseph",NULL,"100858817",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-22 10:38:25+00","2042-01-22 23:59:59+00",1,"1",NULL,"23711.63","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524566","Makanda Andrew - KCZ 155P","JC400P","aotomobile","KCZ 155P","KCZ 155P","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Makanda Andrew",NULL,"758781444",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-22 09:47:33+00","2042-01-22 23:59:59+00",1,"1",NULL,"31663.30","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524608","Peter Mbugua - KDK 728K","JC400P","aotomobile","KDK 728K","KDK 728K","Probox","automobile",NULL,NULL,NULL,NULL,"Peter Mbugua",NULL,"706742413",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-12-03 12:11:32+00","2042-12-03 23:59:59+00",1,"1",NULL,"7219.31","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524657","Felix Andole - KDC 207R","JC400P","aotomobile","KDC 207R","KDC 207R","Probox","automobile",NULL,NULL,NULL,NULL,"Felix Andole",NULL,"758689195",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-22 07:17:47+00","2042-01-22 23:59:59+00",1,"1",NULL,"46233.99","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524681","Mutuku Antony - KDK 732K","JC400P","aotomobile","KDK 732K","KDK 732K","Probox","automobile",NULL,NULL,NULL,NULL,"Mutuku Antony",NULL,"796275746",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-12-06 13:37:49+00","2042-12-06 23:59:59+00",1,"1",NULL,"14993.36","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524707","Garage - KCE 699F","JC400P","aotomobile","KCE 699F","KCE 699F","Probox","automobile",NULL,NULL,NULL,NULL,"Garage",NULL,"110525751",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-15 07:58:49+00","2042-01-15 23:59:59+00",1,"1",NULL,"34715.97","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050524897","Cornelius Kimutai - KCU 938R","JC400P","aotomobile","KCU 938R","KCU 938R","Van","automobile",NULL,NULL,NULL,NULL,"Cornelius Kimutai",NULL,"114924404",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-22 09:03:40+00","2042-01-22 23:59:59+00",1,"1",NULL,"12668.43","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525068","Samuel Ng'ang'a - KDE 264M","JC400P","aotomobile","KDE 264M","KDE 264M","Probox","automobile",NULL,NULL,NULL,NULL,"Samuel Ng'ang'a",NULL,"768658564",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-12-22 13:33:42+00","2033-12-22 23:59:59+00",1,"1",NULL,"12299.13","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525225","Sadique Wakayula - KDC 490Q","JC400P","aotomobile","KDC 490Q","KDC 490Q","Crane","truck",NULL,NULL,NULL,NULL,"Sadique Wakayula",NULL,"768652386",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-12-22 20:52:08+00","2043-12-22 20:52:08+00",1,"1",NULL,"19138.05","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525266","Dickson Jaoko - KDK 815R","JC400P","aotomobile","KDK 815R","KDK 815R","Probox","automobile",NULL,NULL,NULL,NULL,"Dickson Jaoko",NULL,"706665867",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-06-21 07:50:00+00","2033-06-21 23:59:59+00",1,"1",NULL,"63754.71","2026-04-23 13:50:24.21992+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525423","Makori John - KDB 585E","JC400P","aotomobile","KDB 585E","KDB 585E","Probox","automobile",NULL,NULL,NULL,NULL,"Makori John",NULL,"701211724",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-15 10:59:19+00","2042-01-15 23:59:59+00",1,"1",NULL,"48804.83","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525589","Simon Kamau - KCE 090R","JC400P","aotomobile","KCE 090R","KCE 090R","Probox","automobile",NULL,NULL,NULL,NULL,"Simon Kamau",NULL,"796276387",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-19 10:10:04+00","2042-01-19 23:59:59+00",1,"1",NULL,"15874.39","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525605","John Ondego - KCA 542Q","JC400P","aotomobile","KCA 542Q","KCA 542Q","Probox","automobile",NULL,NULL,NULL,NULL,"John Ondego",NULL,"110526783",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-15 05:56:11+00","2042-01-15 23:59:59+00",1,"1",NULL,"23976.94","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525613","Kennedy Chege - KCQ 618K","JC400P","aotomobile","KCQ 618K","KCQ 618K","Probox","automobile",NULL,NULL,NULL,NULL,"Kennedy Chege",NULL,"729994247",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-16 05:21:05+00","2042-01-16 23:59:59+00",1,"1",NULL,"12804.24","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525670","Gabriel Musumba - KCE 690F","JC400P","aotomobile","KCE 690F","KCE 690F","Probox","automobile",NULL,NULL,NULL,NULL,"Gabriel Musumba",NULL,"701211996",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2022-01-15 06:40:01+00","2042-01-15 23:59:59+00",1,"1",NULL,"20110.93","2026-04-24 05:34:23.167312+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525753","Noel Merengeni - KCY 838X","JC400P","aotomobile","KCY 838X","KCY 838X","Probox","automobile",NULL,NULL,NULL,NULL,"Noel Merengeni",NULL,NULL,NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2022-01-15 05:24:00+00","2042-01-15 23:59:59+00",1,"1",NULL,"14596.59","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525837","Kennedy Ondieki - KCU 237Z","JC400P","aotomobile","KCU 237Z","KCU 237Z","Probox","automobile",NULL,NULL,NULL,NULL,"Kennedy Ondieki",NULL,"113669852",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2023-12-21 19:32:44+00","2033-12-21 23:59:59+00",1,"1",NULL,NULL,"2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050525951","Wright Oseko - KCG 668W","JC400P","aotomobile","KCG 668W","KCG 668W","Probox","automobile",NULL,NULL,NULL,NULL,"Wright Oseko",NULL,"741943212",NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2022-01-15 09:36:45+00","2042-01-15 23:59:59+00",1,"1",NULL,"13116.00","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050526165","Wilfred Kinyanjui - KCU 729C","JC400P","aotomobile","KCU 729C","KCU 729C","Crane","truck",NULL,NULL,NULL,NULL,"Wilfred Kinyanjui",NULL,"790564929",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2023-11-26 10:17:19+00","2033-11-26 23:59:59+00",1,"1",NULL,"24270.20","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050526231","Rashid Hassan - KDM 840V","JC400P","aotomobile","KDM 840V","KDM 840V","Probox","automobile",NULL,NULL,NULL,NULL,"Rashid Hassan",NULL,"790175526",NULL,NULL,"Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2023-12-22 22:36:15+00","2043-12-22 22:36:15+00",1,"1",NULL,"45418.38","2026-04-23 10:29:41.575467+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798050526256","Ian Dancun - KDT 923R","JC400P","aotomobile","KDT 923R","KDT 923R","Probox","automobile",NULL,NULL,NULL,NULL,"Ian Dancun",NULL,"794873610",NULL,NULL,"Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2023-12-22 19:37:24+00","2043-12-22 19:37:24+00",1,"1",NULL,"11093.11","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052707888","Benjamin Ananda - KDV 438W","JC400P","aotomobile","KDV 438W","KDV 438W","Probox","automobile",NULL,NULL,NULL,NULL,"Benjamin Ananda",NULL,"758047312","89254021414206816980","639021410681698","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-12-15 07:39:23+00","2035-12-15 23:59:59+00",1,"1",NULL,"8720.87","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052707896","John Mbugua - KDW 573B","JC400P","aotomobile","KDW 573B","KDW 573B","Probox","automobile",NULL,NULL,NULL,NULL,"John Mbugua",NULL,NULL,"89254021414206816725","639021410681672","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-01-30 14:48:17+00","2036-01-30 23:59:59+00",1,"1",NULL,"515.16","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052707946","Tom Wekesa/OSP-KCY 930Y_CAM","JC400P","aotomobile",NULL,NULL,NULL,"automobile",NULL,NULL,NULL,NULL,NULL,NULL,"0758047806","89254021414206816766","639021410681676","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2026-01-20 21:02:13+00","2036-01-20 23:59:59+00",1,"1",NULL,"10079.17","2026-04-23 10:25:24.363965+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052708035","862798052708035","JC400P","aotomobile",NULL,NULL,"Probox","automobile",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,"fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group",NULL,NULL,1,"1",NULL,NULL,"2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052708068","Dominic Wambua - KDV 683Z","JC400P","aotomobile","KDV 683Z","KDV 683Z","Probox","automobile",NULL,NULL,NULL,NULL,"Dominic Wambua",NULL,"758048043","89254021414206816964","639021410681696","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-01-24 09:20:09+00","2036-01-24 23:59:59+00",1,"1",NULL,"4438.55","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052708076","Albert Mutwiri - KDV 437W","JC400P","aotomobile",NULL,"KDV 437W","Probox","automobile",NULL,NULL,NULL,NULL,"Albert Mutwiri",NULL,"758047094","89254021414206816782","639021410681678","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-12-13 15:03:30+00","2035-12-13 23:59:59+00",1,"1",NULL,"5575.64","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052708167","Levine Wasike - KDV 439W","JC400P","aotomobile","KDV 439W","KDV 439W","Probox","automobile",NULL,NULL,NULL,NULL,"Levine Wasike",NULL,"758046738","89254021414206816741","639021410681674","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-12-13 19:49:29+00","2035-12-13 23:59:59+00",1,"1",NULL,"4601.08","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052708282","Godffrey Nandwa - KCN 496A","JC400P","aotomobile","KCN 496A","KCN 496A","Probox","automobile",NULL,NULL,NULL,NULL,"Godffrey Nandwa",NULL,"758047934","89254021414206816865","639021410681686","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2026-01-25 18:55:54+00","2036-01-25 23:59:59+00",1,"1",NULL,"7040.60","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713654","Garage/ISP_KCL 502T_CAM","JC400P","aotomobile",NULL,NULL,NULL,"automobile",NULL,NULL,NULL,NULL,NULL,NULL,"0780215879","89254035061001753803","639035060175380","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-09-02 10:09:57+00","2035-09-02 23:59:59+00",1,"1",NULL,"5199.72","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713696","862798052713696","JC400P","aotomobile",NULL,NULL,"Probox","automobile",NULL,NULL,NULL,NULL,NULL,NULL,NULL,"89254021394215205906","639021391520590","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-09-02 10:20:58+00","2035-09-02 23:59:59+00",1,"1",NULL,"6214.49","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713761","Management_Mazda - KDU 613A","JC400P","aotomobile","KDU 613A","KDU 613A","Mazda","automobile",NULL,NULL,NULL,NULL,"Management_Mazda",NULL,"790176786","89254021394215205955","639021391520595","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-07-09 15:49:26+00","2035-07-09 23:59:59+00",1,"1",NULL,"9262.78","2026-04-23 16:40:48.879666+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713779","Benard Kimutai - KDN 759G","JC400P","aotomobile","KDN 759G","KDN 759G","Probox","automobile",NULL,NULL,NULL,NULL,"Benard Kimutai",NULL,"752143258","89254035061001753860","639035060175386","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-08-23 11:15:59+00","2035-08-23 23:59:59+00",1,"1",NULL,"5344.24","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713811","James Onyango - KDU 613B","JC400P","aotomobile","KDU 613B","KDU 613B","Probox","automobile",NULL,NULL,NULL,NULL,"James Onyango",NULL,"790176542","89254021394215205880","639021391520588","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-09 19:24:14+00","2035-07-09 23:59:59+00",1,"1",NULL,"9657.42","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713837","Kennedy Ondieki - KCU 237Z","JC400P","aotomobile","KCU 237Z","KCU 237Z","Probox","automobile",NULL,NULL,NULL,NULL,"Kennedy Ondieki",NULL,"113669852","89254021414206327855","639021410632785","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-10-08 14:55:23+00","2035-10-08 23:59:59+00",1,"1",NULL,"9346.02","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052713985","Timothy Gitau - KDT 916R","JC400P","aotomobile","KDT 916R","KDT 916R","Probox","automobile",NULL,NULL,NULL,NULL,"Timothy Gitau",NULL,"768696668","89254021394274518892","639021397451889","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-08-02 18:21:23+00","2035-08-02 23:59:59+00",1,"1",NULL,"19998.22","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052714066","862798052714066","JC400P","aotomobile",NULL,NULL,"Probox","automobile",NULL,NULL,NULL,NULL,NULL,NULL,NULL,"89254021414206378684","639021410637868","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-11-21 17:44:44+00","2035-11-21 23:59:59+00",1,"1",NULL,"10755.28","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"862798052715220","Rofas Njagi - KDT 728R","JC400P","aotomobile","KDT 728R","KDT 728R","Probox","automobile",NULL,NULL,NULL,NULL,"Rofas Njagi",NULL,"704573658","89254021334258495873","639021335849587","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-16 07:09:25+00","2035-07-16 23:59:59+00",1,"1",NULL,"16385.58","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061035133","Major Simiyu - KDS 949Y","X3","aotomobile","KDS 949Y","KDS 949Y","Probox","automobile",NULL,NULL,NULL,NULL,"Major Simiyu",NULL,"768696642","89254021394274518918","639021397451891","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2025-08-02 13:14:33+00","2035-08-02 23:59:59+00",1,"1",NULL,"25089.98","2026-04-23 12:07:56.044395+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061035653","Richardson Komu - KDT 923R","X3","aotomobile","KDT 923R","KDT 923R","Probox","automobile",NULL,NULL,NULL,NULL,"Richardson Komu",NULL,"768697292","89254021394274518942","639021397451894","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-08-02 08:11:46+00","2035-08-02 23:59:59+00",1,"1",NULL,"23556.65","2026-04-23 10:24:50.340401+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061035778","John Kimeria - KDS 525D","X3","aotomobile","KDS 525D","KDS 525D","Crane","truck",NULL,NULL,NULL,NULL,"John Kimeria",NULL,"790176738","89254021394215205922","639021391520592","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-11 05:50:36+00","2035-07-11 23:59:59+00",1,"1",NULL,"17653.96","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061036164","Brian Njenga - KMFF 113Z","X3","aotomobile","KMFF 113Z","KMFF 113Z","Motorbike","mtc",NULL,NULL,NULL,NULL,"Brian Njenga",NULL,"768696705","89254021394274518850","639021397451885","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-31 10:06:46+00","2035-07-31 23:59:59+00",1,"1",NULL,"22990.33","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061037980","Emmanuel Luseno - KDS 453Y","X3","aotomobile","KDS 453Y","KDS 453Y","Pick-Up","automobile",NULL,NULL,NULL,NULL,"Emmanuel Luseno",NULL,"790176734","89254021394215205856","639021391520585","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-15 06:30:34+00","2035-07-15 23:59:59+00",1,"1",NULL,"42609.03","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061042261","Kelvin Wambugu - KDR 592N","X3","aotomobile","KDR 592N","KDR 592N","Probox","automobile",NULL,NULL,NULL,NULL,"Kelvin Wambugu",NULL,"797680464","89254021334258159693","639021335815969","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-10 10:23:44+00","2035-07-10 23:59:59+00",1,"1",NULL,"18755.66","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061043079","Mike Wanaswa - KDT 724R","X3","aotomobile","KDT 724R","KDT 724R","Probox","automobile",NULL,NULL,NULL,NULL,"Mike Wanaswa",NULL,"768696664","89254021394274518959","639021397451895","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2025-08-02 12:16:11+00","2035-08-02 23:59:59+00",1,"1",NULL,"27470.11","2026-04-23 11:16:35.682194+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061043426","Geoffrey Karanja - KMGS 239H","X3","aotomobile","KMGS 239H","KMGS 239H","Motorbike","mtc",NULL,NULL,NULL,NULL,"Geoffrey Karanja",NULL,"768696658","89254021394274518926","639021397451892","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-08-22 13:32:25+00","2035-08-22 23:59:59+00",1,"1",NULL,"21267.01","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061047435","Management_Mazda - KDU 613A","X3","aotomobile","KDU 613A","KDU 613A","Mazda","automobile",NULL,NULL,NULL,NULL,"Management_Mazda",NULL,"790175971","89254021394215205971","639021391520597","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-09 08:02:26+00","2035-07-09 23:59:59+00",1,"1",NULL,"9761.38","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061048276","Victor Kimutai - KDS 919Y","X3","aotomobile","KDS 919Y","KDS 919Y","Probox","automobile",NULL,NULL,NULL,NULL,"Victor Kimutai",NULL,"768696755","89254021394274518900","639021397451890","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2025-08-02 07:38:01+00","2035-08-02 23:59:59+00",1,"1",NULL,"23296.79","2026-04-23 10:54:41.63532+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061048300","KMGR 409U HENRY JAZZ",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,"unknown",NULL,NULL,"2026-04-24 04:30:20.231102+00","2026-04-24 04:30:20.231102+00",NULL,NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061048466","Samuel Muriithy - KDR 594N","X3","aotomobile","KDR 594N","KDR 594N","Probox","automobile",NULL,NULL,NULL,NULL,"Samuel Muriithy",NULL,"797680395","89254021334258159628","639021335815962","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-07-24 09:37:31+00","2035-07-24 23:59:59+00",1,"1",NULL,"27634.10","2026-04-23 11:43:39.178819+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061048615","Office-KMDG 902Z","X3","aotomobile",NULL,NULL,NULL,"automobile",NULL,NULL,NULL,NULL,NULL,NULL,"0768697276","89254021394274518876","639021397451887","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-31 09:59:43+00","2035-07-31 23:59:59+00",1,"1",NULL,"5721.21","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061048953","Timothy Gitau - KDT 916R","X3","aotomobile","KDT 916R","KDT 916R","Probox","automobile",NULL,NULL,NULL,NULL,"Timothy Gitau",NULL,"768697056","89254021394274518967","639021397451896","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2025-08-02 08:48:05+00","2035-08-02 23:59:59+00",1,"1",NULL,"28536.23","2026-04-23 10:53:31.102315+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061049001","Parked - KMGK 596V","X3","aotomobile","KMGK 596V","KMGK 596V","Motorbike","mtc",NULL,NULL,NULL,NULL,"Parked",NULL,"768697064","89254021394274518884","639021397451888","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-31 08:40:18+00","2035-07-31 23:59:59+00",1,"1",NULL,"20612.89","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061053714","Samuel Kihara - KMEL 225X","X3","aotomobile","KMEL 225X","KMEL 225X","Motorbike","mtc",NULL,NULL,NULL,NULL,"Samuel Kihara",NULL,"768696832","89254021394274518934","639021397451893","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-08-02 13:51:47+00","2035-08-02 23:59:59+00",1,"1",NULL,"26897.18","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061053748","Rashid Hassan - KDM 840V","X3","aotomobile","KDM 840V","KDM 840V","Probox","automobile",NULL,NULL,NULL,NULL,"Rashid Hassan",NULL,"768445963","89254021334212352574","639021331235257","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-07-10 13:54:11+00","2035-07-10 23:59:59+00",1,"1",NULL,"26612.42","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061054548","James Onyango - KDU 613B","X3","aotomobile","KDU 613B","KDU 613B","Probox","automobile",NULL,NULL,NULL,NULL,"James Onyango",NULL,"790175997","89254021394215205948","639021391520594","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-07-09 07:11:15+00","2035-07-09 23:59:59+00",1,"1",NULL,"13446.05","2026-04-23 10:26:24.667167+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061054555","Rofas Njagi - KDT 728R","X3","aotomobile","KDT 728R","KDT 728R","Probox","automobile",NULL,NULL,NULL,NULL,"Rofas Njagi",NULL,"790176726","89254021394215205823","639021391520582","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-07-16 06:44:33+00","2035-07-16 23:59:59+00",1,"1",NULL,"27250.80","2026-04-23 10:25:21.085437+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061559538","FRED KMGW 538W HULETI",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,"unknown",NULL,NULL,"2026-04-23 10:42:18.5831+00","2026-04-23 10:42:18.5831+00",NULL,NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061562722","John Mbugua - KDW 573B","X3","aotomobile","KDW 573B","KDW 573B","Probox","automobile",NULL,NULL,NULL,NULL,"John Mbugua",NULL,"758052508","89254021414206816832","639021410681683","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2026-01-30 06:53:57+00","2036-01-30 23:59:59+00",1,"1",NULL,"4488.19","2026-04-23 10:25:38.887433+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061562847","Levine Wasike - KDV 439W","X3","aotomobile","KDV 439W","KDV 439W","Probox","automobile",NULL,NULL,NULL,NULL,"Levine Wasike",NULL,"758047032","89254021414206816840","639021410681684","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2025-12-13 11:14:14+00","2035-12-13 23:59:59+00",1,"1",NULL,"7880.92","2026-04-23 10:35:50.779597+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061563282","X3-63282","X3","aotomobile",NULL,NULL,NULL,"automobile",NULL,NULL,NULL,NULL,NULL,NULL,NULL,"8925610001837573427F","641101970467668","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-02-14 07:20:10+00","2036-02-14 23:59:59+00",1,"1",NULL,"4758.32","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061563415","Barack Orwa - KDW 781E","X3","aotomobile","KDW 781E","KDW 781E","Vazel","automobile",NULL,NULL,NULL,NULL,"Barack Orwa",NULL,"758052541","89254021414206816931","639021410681693","Fireside_MSA","Fireside Group MSA","9d0927d235e44fe7abf254902fc68921","Default group","2026-01-13 12:37:42+00","2036-01-13 23:59:59+00",1,"1",NULL,"4165.95","2026-04-23 11:22:00.676215+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061563423","Joel Ntumba - UMA 826AB","X3","aotomobile","UMA 826AB","UMA 826AB","Motorbike","mtc",NULL,NULL,NULL,NULL,"Joel Ntumba",NULL,"119051036","89254021414206652690","639021410665269","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-01-28 13:55:39+00","2036-01-28 23:59:59+00",1,"1",NULL,"1174.05","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061563597","Dominic Wambua - KDV 683Z","X3","aotomobile","KDV 683Z","KDV 683Z","Probox","automobile",NULL,NULL,NULL,NULL,"Dominic Wambua",NULL,"758052405","89254021414206816733","639021410681673","Fireside@HQ","Fireside Telematics","6ef0b0fc2d964b358b70dc2cfcbc5b7e","Default group","2026-01-30 06:55:35+00","2036-01-30 23:59:59+00",1,"1",NULL,"6790.53","2026-04-23 10:25:40.125927+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061563639","Benjamin Ananda - KDV 438W","X3","aotomobile","KDV 438W","KDV 438W","Probox","automobile",NULL,NULL,NULL,NULL,"Benjamin Ananda",NULL,"758047065","89254021414206816683","639021410681668","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-12-13 16:02:37+00","2035-12-13 23:59:59+00",1,"1",NULL,"14446.33","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061564280","Rodin Kiberu - UMA 011EK","X3","aotomobile","UMA 011EK","UMA 011EK","Motorbike","mtc",NULL,NULL,NULL,NULL,"Rodin Kiberu",NULL,"118081642","89254021414206817244","639021410681724","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-01-28 13:13:57+00","2036-01-28 23:59:59+00",1,"1",NULL,"841.39","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061564470","Silvanus Kipkorir - KDV 064S","X3","aotomobile","KDV 064S","KDV 064S","Probox","automobile",NULL,NULL,NULL,NULL,"Silvanus Kipkorir",NULL,"113669866","89254021414206378718","639021410637871","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-11-21 16:49:17+00","2035-11-21 23:59:59+00",1,"1",NULL,"23869.16","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061568968","X3-68968","X3","aotomobile",NULL,NULL,NULL,"automobile",NULL,NULL,NULL,NULL,NULL,NULL,NULL,"89254021414206816915","639021410681691","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-03-11 06:19:14+00","2036-03-11 23:59:59+00",1,"1",NULL,"16.23","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061569123","Albert Mutwiri - KDV 437W","X3","aotomobile","KDV 437W","KDV 437W","Probox","automobile",NULL,NULL,NULL,NULL,"Albert Mutwiri",NULL,"758047101","89254021414206816881","639021410681688","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-12-13 14:26:17+00","2035-12-13 23:59:59+00",1,"1",NULL,"13032.60","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061569131","UMA 418EK","X3","aotomobile","UMA 418EK","UMA 418EK",NULL,"automobile",NULL,NULL,NULL,NULL,"UG",NULL,"256792997053","8925610001837573385F","641101970467664","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-02-26 08:15:44+00","2036-02-26 23:59:59+00",1,"1",NULL,"2333.45","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061569479","UMA 382EK","X3","aotomobile","UMA 382EK","UMA 382EK",NULL,"automobile",NULL,NULL,NULL,NULL,"UG",NULL,"256792997079","8925610001837573419F","641101970467667","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2026-02-26 08:21:10+00","2036-02-26 23:59:59+00",1,"1",NULL,"1954.86","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061578553","X3-78553",NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,"unknown",NULL,NULL,"2026-04-23 15:30:19.981271+00","2026-04-23 15:30:19.981271+00",NULL,NULL,NULL,NULL,NULL,NULL,NULL
|
||||
"865135061581904","Robert Kipruto - KDV 072L","X3","aotomobile","KDV 072L","KDV 072L","Probox","automobile",NULL,NULL,NULL,NULL,"Robert Kipruto",NULL,"114149576","89254021264261503993","639021266150399","fireside","Fireside Group HQ","2f1acaef6c884214b4598719180fe68d","Default group","2025-11-21 15:30:29+00","2035-11-21 23:59:59+00",1,"1",NULL,"15252.84","2026-04-23 10:23:56.546784+00","2026-04-24 07:43:45.210628+00","2026-04-24 07:43:45.210628+00",NULL,NULL,NULL,NULL,NULL,NULL
|
||||
|
69
dwh/261001_dwh_control.sql
Normal file
69
dwh/261001_dwh_control.sql
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
-- =============================================================
|
||||
-- TRACKSOLID DWH CONTROL SCHEMA
|
||||
-- Target Database: tracksolid_dwh
|
||||
-- Purpose: Watermarks + per-run audit log for the n8n DWH pipeline
|
||||
-- Applies after: 260423_dwh_ddl_v1.sql (requires dwh_owner role + grafana_ro role)
|
||||
-- =============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. CONTROL SCHEMA
|
||||
-- Owned by dwh_owner to match bronze/silver/gold ownership convention from 260423.
|
||||
CREATE SCHEMA IF NOT EXISTS dwh_control AUTHORIZATION dwh_owner;
|
||||
|
||||
GRANT USAGE ON SCHEMA dwh_control TO grafana_ro;
|
||||
|
||||
-- 2. PERMISSIONS (dwh_owner writes, grafana_ro reads)
|
||||
-- Existing default privileges from 260423 only cover bronze/silver/gold; extend to dwh_control.
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE dwh_owner IN SCHEMA dwh_control
|
||||
GRANT SELECT ON TABLES TO grafana_ro;
|
||||
|
||||
-- 3. EXTRACT WATERMARKS
|
||||
-- One row per incremental table. Updated by Workflow 2 after a successful load commit.
|
||||
-- last_extracted_at is the UPPER bound used in the most recent successful extract,
|
||||
-- so the next run uses `WHERE <ts_col> > last_extracted_at AND <ts_col> <= :run_started_at`.
|
||||
SET ROLE dwh_owner;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dwh_control.extract_watermarks (
|
||||
table_name TEXT PRIMARY KEY,
|
||||
last_extracted_at TIMESTAMPTZ NOT NULL DEFAULT '2026-01-01T00:00:00Z',
|
||||
last_loaded_at TIMESTAMPTZ,
|
||||
rows_loaded_last_run INT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 4. EXTRACT RUN AUDIT LOG
|
||||
-- One row per table per cron tick. Lifecycle: extracting → uploaded → loading → loaded (or failed).
|
||||
-- Failures retain error_message; the CSV stays in dwh/exports/ for the next run to pick up.
|
||||
CREATE TABLE IF NOT EXISTS dwh_control.extract_runs (
|
||||
run_id BIGSERIAL PRIMARY KEY,
|
||||
table_name TEXT NOT NULL,
|
||||
run_started_at TIMESTAMPTZ NOT NULL,
|
||||
run_finished_at TIMESTAMPTZ,
|
||||
rows_extracted INT,
|
||||
rows_loaded INT,
|
||||
csv_path TEXT,
|
||||
status TEXT CHECK (status IN ('extracting','uploaded','loading','loaded','failed')),
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extract_runs_table_time
|
||||
ON dwh_control.extract_runs (table_name, run_started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_extract_runs_status_time
|
||||
ON dwh_control.extract_runs (status, run_finished_at DESC);
|
||||
|
||||
-- 5. SEED WATERMARKS
|
||||
-- One row per incremental table. Snapshot tables (devices, live_positions) do not need
|
||||
-- watermarks and are intentionally omitted.
|
||||
INSERT INTO dwh_control.extract_watermarks (table_name) VALUES
|
||||
('position_history'),
|
||||
('trips'),
|
||||
('alarms'),
|
||||
('parking_events'),
|
||||
('device_events'),
|
||||
('ingestion_log')
|
||||
ON CONFLICT (table_name) DO NOTHING;
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
COMMIT;
|
||||
63
dwh/261002_bronze_constraints_audit.sql
Normal file
63
dwh/261002_bronze_constraints_audit.sql
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
-- =============================================================
|
||||
-- BRONZE CONSTRAINTS AUDIT
|
||||
-- Target Database: tracksolid_dwh
|
||||
-- Purpose: Assert that every ON CONFLICT target used by Workflow 2
|
||||
-- (dwh_load_bronze) is backed by a PRIMARY KEY or UNIQUE
|
||||
-- constraint in the bronze schema. Fails loudly if a future
|
||||
-- DDL edit removes a key the ingestion pipeline depends on.
|
||||
-- Applies after: 260423_dwh_ddl_v1.sql
|
||||
-- Idempotent: pure assertion, no DDL changes.
|
||||
-- =============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
missing TEXT := '';
|
||||
expected RECORD;
|
||||
BEGIN
|
||||
-- Each row asserts: bronze.<table> has a PK/UNIQUE matching <cols>.
|
||||
-- If the pipeline's ON CONFLICT clause ever diverges from this list,
|
||||
-- update both here and the n8n load workflow in lockstep.
|
||||
FOR expected IN
|
||||
SELECT * FROM (VALUES
|
||||
('devices', 'imei'),
|
||||
('live_positions', 'imei'),
|
||||
('position_history', 'imei,gps_time'),
|
||||
('trips', 'id'),
|
||||
('alarms', 'id'),
|
||||
('parking_events', 'id'),
|
||||
('device_events', 'id'),
|
||||
('ingestion_log', 'id')
|
||||
) AS t(table_name, cols)
|
||||
LOOP
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class r ON r.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = r.relnamespace
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT string_agg(a.attname, ',' ORDER BY k.ord) AS keycols
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||
) AS cols
|
||||
WHERE n.nspname = 'bronze'
|
||||
AND r.relname = expected.table_name
|
||||
AND c.contype IN ('p','u')
|
||||
AND cols.keycols = expected.cols
|
||||
) THEN
|
||||
missing := missing
|
||||
|| format(E'\n - bronze.%s missing PK/UNIQUE on (%s)',
|
||||
expected.table_name, expected.cols);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
IF length(missing) > 0 THEN
|
||||
RAISE EXCEPTION E'Bronze constraint audit FAILED:%s', missing;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Bronze constraint audit OK: all 8 ON CONFLICT targets backed by PK/UNIQUE.';
|
||||
END$$;
|
||||
|
||||
COMMIT;
|
||||
66
dwh/261003_dwh_roles.sql
Normal file
66
dwh/261003_dwh_roles.sql
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
-- =============================================================
|
||||
-- DWH ROLES AUDIT
|
||||
-- Target Database: tracksolid_dwh
|
||||
-- Purpose: Assert that the n8n DWH pipeline's role contract holds:
|
||||
-- - dwh_owner exists (writes bronze + dwh_control)
|
||||
-- - grafana_ro exists (reads bronze + silver + gold + dwh_control)
|
||||
-- - grafana_ro has CONNECT on the database
|
||||
-- - grafana_ro has USAGE on every schema it needs
|
||||
-- Applies after: 260423_dwh_ddl_v1.sql, 261001_dwh_control.sql
|
||||
-- Idempotent: pure assertion, no CREATE ROLE or GRANT statements.
|
||||
--
|
||||
-- Why this file exists: 260423 creates both roles and grants bronze/silver/gold;
|
||||
-- 261001 grants dwh_control. This file is a single checkpoint that verifies
|
||||
-- those prior migrations were applied in the right order, and fails loudly
|
||||
-- if anything is missing before the pipeline goes live.
|
||||
--
|
||||
-- Password rotation and sslmode=require enforcement are out-of-band:
|
||||
-- rotate via ALTER ROLE ... PASSWORD ... in a psql superuser session,
|
||||
-- enforce SSL via the n8n credential (sslmode=require) — not SQL-level.
|
||||
-- =============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
missing TEXT := '';
|
||||
r RECORD;
|
||||
BEGIN
|
||||
-- 1. Roles exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dwh_owner') THEN
|
||||
missing := missing || E'\n - role dwh_owner missing (expected from 260423)';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
missing := missing || E'\n - role grafana_ro missing (expected from 260423)';
|
||||
END IF;
|
||||
|
||||
-- 2. grafana_ro CONNECT on this database
|
||||
IF NOT has_database_privilege('grafana_ro', current_database(), 'CONNECT') THEN
|
||||
missing := missing
|
||||
|| format(E'\n - grafana_ro lacks CONNECT on database %s',
|
||||
current_database());
|
||||
END IF;
|
||||
|
||||
-- 3. grafana_ro USAGE on every schema the pipeline / dashboards touch
|
||||
FOR r IN
|
||||
SELECT unnest(ARRAY['bronze','silver','gold','dwh_control']) AS schema_name
|
||||
LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = r.schema_name) THEN
|
||||
missing := missing
|
||||
|| format(E'\n - schema %s missing (expected from 260423/261001)',
|
||||
r.schema_name);
|
||||
ELSIF NOT has_schema_privilege('grafana_ro', r.schema_name, 'USAGE') THEN
|
||||
missing := missing
|
||||
|| format(E'\n - grafana_ro lacks USAGE on schema %s',
|
||||
r.schema_name);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
IF length(missing) > 0 THEN
|
||||
RAISE EXCEPTION E'DWH roles audit FAILED:%s', missing;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'DWH roles audit OK: dwh_owner + grafana_ro present with expected grants.';
|
||||
END$$;
|
||||
|
||||
COMMIT;
|
||||
79
dwh/261004_dwh_observability_views.sql
Normal file
79
dwh/261004_dwh_observability_views.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- =============================================================
|
||||
-- DWH OBSERVABILITY VIEWS
|
||||
-- Target Database: tracksolid_dwh
|
||||
-- Purpose: Surface pipeline health for Grafana dashboards. Three views,
|
||||
-- one concern each:
|
||||
-- v_table_freshness — how long since each table was last loaded
|
||||
-- v_recent_failures — failed runs in the last 24h
|
||||
-- v_watermark_lag — per-table watermark vs. now
|
||||
-- Applies after: 261001_dwh_control.sql
|
||||
-- Readability: owned by dwh_owner → grafana_ro inherits SELECT via the
|
||||
-- ALTER DEFAULT PRIVILEGES set in 261001. Explicit GRANT below
|
||||
-- covers the case where defaults were set AFTER this file runs.
|
||||
-- =============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET ROLE dwh_owner;
|
||||
|
||||
-- 1. FRESHNESS
|
||||
-- One row per table that has ever loaded successfully. `lag` drives the
|
||||
-- freshness panel; `loads_last_24h` sanity-checks the cron cadence.
|
||||
CREATE OR REPLACE VIEW dwh_control.v_table_freshness AS
|
||||
SELECT
|
||||
table_name,
|
||||
MAX(run_finished_at) AS last_loaded_at,
|
||||
NOW() - MAX(run_finished_at) AS lag,
|
||||
COUNT(*) FILTER (WHERE run_started_at > NOW() - INTERVAL '24 hours') AS loads_last_24h
|
||||
FROM dwh_control.extract_runs
|
||||
WHERE status = 'loaded'
|
||||
GROUP BY table_name;
|
||||
|
||||
COMMENT ON VIEW dwh_control.v_table_freshness IS
|
||||
'Per-table load lag. Alert when lag > 4h during active hours (05:00–23:00 EAT).';
|
||||
|
||||
-- 2. RECENT FAILURES
|
||||
-- Failures retain error_message; the CSV stays in dwh/exports/ for the next
|
||||
-- scheduled run to retry. Panel should show run_id so operators can grep logs.
|
||||
CREATE OR REPLACE VIEW dwh_control.v_recent_failures AS
|
||||
SELECT
|
||||
run_id,
|
||||
table_name,
|
||||
run_started_at,
|
||||
run_finished_at,
|
||||
csv_path,
|
||||
error_message
|
||||
FROM dwh_control.extract_runs
|
||||
WHERE status = 'failed'
|
||||
AND run_started_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY run_started_at DESC;
|
||||
|
||||
COMMENT ON VIEW dwh_control.v_recent_failures IS
|
||||
'Failed extract/load runs in the last 24h. Alert on any row.';
|
||||
|
||||
-- 3. WATERMARK LAG
|
||||
-- Distinguishes "pipeline ran but found nothing" (load_lag small, extract_lag
|
||||
-- growing) from "pipeline is stuck" (both lags growing). Snapshot tables are
|
||||
-- not in extract_watermarks so they do not appear here — that is intentional.
|
||||
CREATE OR REPLACE VIEW dwh_control.v_watermark_lag AS
|
||||
SELECT
|
||||
table_name,
|
||||
last_extracted_at,
|
||||
last_loaded_at,
|
||||
rows_loaded_last_run,
|
||||
NOW() - last_loaded_at AS load_lag,
|
||||
NOW() - last_extracted_at AS extract_lag
|
||||
FROM dwh_control.extract_watermarks;
|
||||
|
||||
COMMENT ON VIEW dwh_control.v_watermark_lag IS
|
||||
'Per-table watermark position vs. now. Incremental tables only (6 rows).';
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
-- Explicit grants: defensive in case ALTER DEFAULT PRIVILEGES from 261001
|
||||
-- was not in effect when these views were created.
|
||||
GRANT SELECT ON dwh_control.v_table_freshness TO grafana_ro;
|
||||
GRANT SELECT ON dwh_control.v_recent_failures TO grafana_ro;
|
||||
GRANT SELECT ON dwh_control.v_watermark_lag TO grafana_ro;
|
||||
|
||||
COMMIT;
|
||||
BIN
dwh/device_events_schema
Normal file
BIN
dwh/device_events_schema
Normal file
Binary file not shown.
6842
dwh/tracksolid_full_schema.txt
Normal file
6842
dwh/tracksolid_full_schema.txt
Normal file
File diff suppressed because it is too large
Load diff
5
grafana/Dockerfile
Normal file
5
grafana/Dockerfile
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FROM grafana/grafana:11.0.0
|
||||
|
||||
# Bake provisioning files into the image so Coolify bind mounts are not needed.
|
||||
# Grafana substitutes ${ENV_VAR} references in provisioning files at startup.
|
||||
COPY provisioning /etc/grafana/provisioning
|
||||
1082
grafana/provisioning/dashboards-json/daily_operations_dashboard.json
Normal file
1082
grafana/provisioning/dashboards-json/daily_operations_dashboard.json
Normal file
File diff suppressed because it is too large
Load diff
589
grafana/provisioning/dashboards-json/noc_fleet_dashboard.json
Normal file
589
grafana/provisioning/dashboards-json/noc_fleet_dashboard.json
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
{
|
||||
"title": "NOC Fleet Operations — Live",
|
||||
"uid": "noc-fleet-live",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {
|
||||
"refresh_intervals": ["10s", "30s", "1m", "5m"]
|
||||
},
|
||||
"editable": false,
|
||||
"tags": ["noc", "fleet", "live"],
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Total Vehicles",
|
||||
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 3 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "text", "value": null }]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT COUNT(*) AS \"Total Vehicles\"\nFROM tracksolid.devices WHERE enabled_flag = 1;",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Online Now",
|
||||
"description": "GPS fix within last 5 minutes",
|
||||
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 3 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT COUNT(*) AS \"Online\"\nFROM tracksolid.v_fleet_status\nWHERE connectivity_status = 'online';",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Recent (5-30 min)",
|
||||
"description": "GPS fix between 5 and 30 minutes ago",
|
||||
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 3 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "text", "value": null },
|
||||
{ "color": "yellow", "value": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT COUNT(*) AS \"Recent\"\nFROM tracksolid.v_fleet_status\nWHERE connectivity_status = 'recent';",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Offline",
|
||||
"description": "GPS fix older than 30 minutes",
|
||||
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 3 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT COUNT(*) AS \"Offline\"\nFROM tracksolid.v_fleet_status\nWHERE connectivity_status = 'offline';",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "stat",
|
||||
"title": "Moving Now",
|
||||
"description": "Vehicles with speed > 0 and engine on",
|
||||
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 3 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "text", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT COUNT(*) AS \"Moving\"\nFROM tracksolid.v_fleet_status\nWHERE speed > 0 AND acc_status = '1' AND connectivity_status = 'online';",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "stat",
|
||||
"title": "Avg Speed (km/h)",
|
||||
"description": "Average speed of currently moving vehicles",
|
||||
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 3 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"unit": "velocitykmh",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 80 },
|
||||
{ "color": "red", "value": 120 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT ROUND(AVG(speed)::numeric, 1) AS \"Avg Speed km/h\"\nFROM tracksolid.v_fleet_status\nWHERE speed > 0 AND acc_status = '1' AND connectivity_status = 'online';",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "geomap",
|
||||
"title": "Live Vehicle Locations",
|
||||
"gridPos": { "x": 0, "y": 3, "w": 24, "h": 16 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"basemap": {
|
||||
"config": { "theme": "dark" },
|
||||
"name": "Basemap",
|
||||
"type": "carto"
|
||||
},
|
||||
"controls": {
|
||||
"mouseWheelZoom": true,
|
||||
"showAttribution": true,
|
||||
"showDebug": false,
|
||||
"showScale": true,
|
||||
"showZoom": true
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"config": {
|
||||
"showLegend": false,
|
||||
"style": {
|
||||
"color": {
|
||||
"field": "vehicle_number",
|
||||
"fixed": "dark-green",
|
||||
"mode": "field"
|
||||
},
|
||||
"opacity": 1,
|
||||
"rotation": {
|
||||
"field": "direction",
|
||||
"fixed": 0,
|
||||
"max": 360,
|
||||
"min": -360,
|
||||
"mode": "field"
|
||||
},
|
||||
"size": {
|
||||
"fixed": 18,
|
||||
"max": 15,
|
||||
"min": 2,
|
||||
"mode": "fixed"
|
||||
},
|
||||
"symbol": {
|
||||
"fixed": "img/icons/marker/arrow-up.svg",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"symbolAlign": {
|
||||
"h": "center",
|
||||
"v": "center"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filterData": { "id": "byRefId", "options": "A" },
|
||||
"location": {
|
||||
"latitude": "lat",
|
||||
"longitude": "lng",
|
||||
"mode": "coords"
|
||||
},
|
||||
"name": "Vehicles",
|
||||
"tooltip": true,
|
||||
"type": "markers"
|
||||
}
|
||||
],
|
||||
"tooltip": { "mode": "details" },
|
||||
"view": {
|
||||
"allLayers": true,
|
||||
"id": "coords",
|
||||
"lat": -1.5,
|
||||
"lon": 36.5,
|
||||
"zoom": 6
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic-by-name" }
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "lat" },
|
||||
"properties": [
|
||||
{ "id": "custom.hideFrom", "value": { "legend": true, "tooltip": true, "viz": false } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "lng" },
|
||||
"properties": [
|
||||
{ "id": "custom.hideFrom", "value": { "legend": true, "tooltip": true, "viz": false } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "imei" },
|
||||
"properties": [
|
||||
{ "id": "custom.hideFrom", "value": { "legend": true, "tooltip": true, "viz": false } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "direction" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "degree" },
|
||||
{ "id": "displayName", "value": "Heading" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "speed" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "velocitykmh" },
|
||||
{ "id": "displayName", "value": "Speed" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "vehicle_number" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Plate" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "driver_name" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Driver" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "driver_phone" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "vehicle_name" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Vehicle" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "connectivity_status" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Status" },
|
||||
{
|
||||
"id": "mappings",
|
||||
"value": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"online": { "color": "green", "index": 0, "text": "Online" },
|
||||
"recent": { "color": "yellow", "index": 1, "text": "Recent" },
|
||||
"offline": { "color": "red", "index": 2, "text": "Offline" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "acc_status" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "ACC" },
|
||||
{
|
||||
"id": "mappings",
|
||||
"value": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"1": { "text": "On", "color": "green", "index": 0 },
|
||||
"0": { "text": "Off", "color": "red", "index": 1 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "gps_time" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Last Fix" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "loc_desc" },
|
||||
"properties": [
|
||||
{ "id": "displayName", "value": "Location" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT\n d.imei,\n d.vehicle_number,\n d.vehicle_name,\n d.driver_name,\n d.driver_phone,\n d.city,\n d.device_group,\n lp.lat,\n lp.lng,\n lp.speed,\n lp.direction,\n lp.acc_status,\n lp.loc_desc,\n lp.gps_time,\n CASE\n WHEN lp.gps_time >= NOW() - INTERVAL '5 minutes' THEN 'online'\n WHEN lp.gps_time >= NOW() - INTERVAL '30 minutes' THEN 'recent'\n ELSE 'offline'\n END AS connectivity_status\nFROM tracksolid.devices d\nINNER JOIN tracksolid.live_positions lp USING (imei)\nWHERE d.enabled_flag = 1\n AND lp.lat IS NOT NULL\n AND lp.lng IS NOT NULL\nORDER BY d.vehicle_number;",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "table",
|
||||
"title": "Vehicle Status",
|
||||
"gridPos": { "x": 0, "y": 19, "w": 24, "h": 10 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false },
|
||||
"showHeader": true,
|
||||
"sortBy": []
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": { "type": "auto" },
|
||||
"filterable": true,
|
||||
"inspect": false
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Status" },
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.cellOptions",
|
||||
"value": { "type": "color-background", "mode": "basic" }
|
||||
},
|
||||
{
|
||||
"id": "mappings",
|
||||
"value": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"Online": { "color": "green", "index": 0 },
|
||||
"Recent": { "color": "yellow", "index": 1 },
|
||||
"Offline": { "color": "red", "index": 2 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Speed (km/h)" },
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.cellOptions",
|
||||
"value": { "type": "color-text" }
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"value": { "mode": "thresholds" }
|
||||
},
|
||||
{
|
||||
"id": "thresholds",
|
||||
"value": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 80 },
|
||||
{ "color": "red", "value": 120 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Last Fix" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "dateTimeAsLocal" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Min Since Fix" },
|
||||
"properties": [
|
||||
{ "id": "custom.width", "value": 110 },
|
||||
{ "id": "displayName", "value": "Min Ago" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Driver Phone" },
|
||||
"properties": [
|
||||
{ "id": "custom.width", "value": 130 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Plate" },
|
||||
"properties": [
|
||||
{ "id": "custom.width", "value": 110 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT\n d.vehicle_number AS \"Plate\",\n d.vehicle_name AS \"Vehicle\",\n d.driver_name AS \"Driver\",\n d.driver_phone AS \"Driver Phone\",\n d.city AS \"City\",\n ROUND(lp.speed::numeric, 0) AS \"Speed (km/h)\",\n lp.loc_desc AS \"Last Location\",\n lp.gps_time AS \"Last Fix\",\n CASE\n WHEN lp.gps_time >= NOW() - INTERVAL '5 minutes' THEN 'Online'\n WHEN lp.gps_time >= NOW() - INTERVAL '30 minutes' THEN 'Recent'\n ELSE 'Offline'\n END AS \"Status\",\n EXTRACT(EPOCH FROM (NOW() - lp.gps_time))::int / 60 AS \"Min Since Fix\"\nFROM tracksolid.devices d\nLEFT JOIN tracksolid.live_positions lp USING (imei)\nWHERE d.enabled_flag = 1\nORDER BY\n CASE\n WHEN lp.gps_time >= NOW() - INTERVAL '5 minutes' THEN 0\n WHEN lp.gps_time >= NOW() - INTERVAL '30 minutes' THEN 1\n ELSE 2\n END,\n d.vehicle_number;",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "table",
|
||||
"title": "Ingestion Health",
|
||||
"collapsed": true,
|
||||
"gridPos": { "x": 0, "y": 29, "w": 24, "h": 8 },
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false },
|
||||
"showHeader": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": { "type": "auto" },
|
||||
"filterable": false,
|
||||
"inspect": false
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Result" },
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.cellOptions",
|
||||
"value": { "type": "color-background", "mode": "basic" }
|
||||
},
|
||||
{
|
||||
"id": "mappings",
|
||||
"value": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"OK": { "color": "green", "index": 0 },
|
||||
"FAIL": { "color": "red", "index": 1 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "postgres", "uid": "tracksolid_pg" },
|
||||
"rawSql": "SELECT\n endpoint AS \"Endpoint\",\n TO_CHAR(run_at, 'HH24:MI DD-Mon') AS \"Last Run\",\n CASE WHEN success THEN 'OK' ELSE 'FAIL' END AS \"Result\",\n error_message AS \"Error\",\n seconds_ago AS \"Lag (s)\"\nFROM tracksolid.v_ingestion_health\nORDER BY endpoint;",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
12
grafana/provisioning/dashboards/noc_fleet.yaml
Normal file
12
grafana/provisioning/dashboards/noc_fleet.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: NOC Fleet Dashboards
|
||||
orgId: 1
|
||||
folder: NOC
|
||||
type: file
|
||||
disableDeletion: true
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: false
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards-json
|
||||
20
grafana/provisioning/datasources/tracksolid_postgres.yaml
Normal file
20
grafana/provisioning/datasources/tracksolid_postgres.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: TracksolidDB
|
||||
type: postgres
|
||||
uid: tracksolid_pg
|
||||
url: timescale_db:5432
|
||||
database: tracksolid_db
|
||||
user: grafana_ro
|
||||
secureJsonData:
|
||||
password: ${GRAFANA_DB_RO_PASSWORD}
|
||||
jsonData:
|
||||
sslmode: disable
|
||||
maxOpenConns: 5
|
||||
maxIdleConns: 2
|
||||
connMaxLifetime: 14400
|
||||
postgresVersion: 1600
|
||||
timescaledb: true
|
||||
editable: false
|
||||
isDefault: true
|
||||
|
|
@ -31,6 +31,7 @@ from ts_shared_rev import (
|
|||
log_ingestion,
|
||||
clean,
|
||||
clean_num,
|
||||
clean_int,
|
||||
clean_ts,
|
||||
get_logger,
|
||||
safe_task,
|
||||
|
|
@ -91,12 +92,8 @@ def poll_alarms():
|
|||
lng, lat, lng, lat, lat, lng,
|
||||
clean_num(a.get("speed")), clean(a.get("accStatus"))
|
||||
))
|
||||
# [FIX-E07] Capture rowcount BEFORE RELEASE SAVEPOINT —
|
||||
# reading it after counts the RELEASE (-1), which is why
|
||||
# logs showed "Alarms: -4 new events inserted".
|
||||
row_inserted = cur.rowcount
|
||||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
inserted += row_inserted
|
||||
inserted += cur.rowcount
|
||||
except Exception:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT sp")
|
||||
log.warning("Failed to process alarm for %s", a.get("imei"), exc_info=True)
|
||||
|
|
@ -108,23 +105,15 @@ def poll_alarms():
|
|||
|
||||
# ── Main Loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def startup_catchup():
|
||||
"""Run the alarm poll once on boot. Split out of main() so the merged
|
||||
ingest_worker can reuse it (DRY).
|
||||
OBD removed: data arrives via webhook push (/pushobd), not polling."""
|
||||
safe_task(poll_alarms, log)()
|
||||
|
||||
|
||||
def register_jobs():
|
||||
"""Register the events jobs on the global `schedule` scheduler.
|
||||
Reused by both this module's main() and ingest_worker_rev.main()."""
|
||||
schedule.every(5).minutes.do(safe_task(poll_alarms, log))
|
||||
|
||||
|
||||
def main():
|
||||
log.info("Starting EVENTS PIPELINE (v2.1)...")
|
||||
startup_catchup()
|
||||
register_jobs()
|
||||
# OBD removed: Data arrives via webhook push (/pushobd), not polling.
|
||||
|
||||
# Startup catch-up
|
||||
safe_task(poll_alarms, log)()
|
||||
|
||||
# Schedule
|
||||
schedule.every(5).minutes.do(safe_task(poll_alarms, log))
|
||||
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
|
|
|
|||
|
|
@ -55,27 +55,9 @@ REVISIONS (QA-Verified):
|
|||
get_device_locations() now share the same time-guarded
|
||||
upsert. _ensure_device → ensure_device relocated to
|
||||
ts_shared_rev for FK-guard reuse.
|
||||
[FIX-M22] 260702 BUG-P1: poll_trips/poll_parking captured cur.rowcount AFTER
|
||||
"RELEASE SAVEPOINT sp", counting the RELEASE (-1) instead of the
|
||||
INSERT — ingestion_log carried negative rows_inserted. Rowcount is
|
||||
now read immediately after the INSERT.
|
||||
[FIX-M23] 260702 BUG-P4: poll_trips restructured into phases — API fetch,
|
||||
one short read txn (plates + enrichment), reverse-geocoding with
|
||||
NO connection held, then one write txn. Previously the whole poll
|
||||
ran inside a single transaction that stayed open across Tracksolid
|
||||
and Nominatim (1 req/s throttled) network calls, pinning a pool
|
||||
connection for minutes.
|
||||
[FIX-M24] 260702 BUG-P6: sync_devices now disables devices that vanish from
|
||||
every Tracksolid target (enabled_flag=0), but ONLY when all target
|
||||
list calls succeeded — an API outage can't mass-disable the fleet.
|
||||
Stops poll_stale_locations churning ~160 dead IMEIs every 10 min.
|
||||
[FIX-M25] 260702 BUG-P7: daily purge_audit_logs() housekeeping job — trims
|
||||
tracksolid.ingestion_log (90d) and reporting.refresh_log (180d),
|
||||
both previously unbounded.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
|
|
@ -84,6 +66,7 @@ from datetime import datetime, timezone, timedelta
|
|||
from psycopg2.extras import execute_values
|
||||
|
||||
from ts_shared_rev import (
|
||||
TARGET_ACCOUNT,
|
||||
TARGETS,
|
||||
api_post,
|
||||
get_active_imeis,
|
||||
|
|
@ -118,13 +101,11 @@ def sync_devices():
|
|||
# [FIX-M19] Fleet is split across multiple sub-accounts. Aggregate the
|
||||
# device list from every configured target and dedupe by IMEI.
|
||||
devices_by_imei: dict[str, dict] = {}
|
||||
all_targets_ok = True # [FIX-M24] gate for the disable pass below
|
||||
for target in TARGETS:
|
||||
resp = api_post("jimi.user.device.list", {"target": target}, token)
|
||||
if resp.get("code") != 0:
|
||||
log.warning("device.list failed for target=%s: code=%s msg=%s",
|
||||
target, resp.get("code"), resp.get("message"))
|
||||
all_targets_ok = False
|
||||
continue
|
||||
for d in (resp.get("result") or []):
|
||||
imei = d.get("imei")
|
||||
|
|
@ -212,27 +193,10 @@ def sync_devices():
|
|||
clean(dtl.get("status", "active")), clean_num(dtl.get("currentMileage"))
|
||||
))
|
||||
upserted += 1
|
||||
|
||||
# [FIX-M24] Devices that disappeared from every target are dead to
|
||||
# the polling APIs — disable them so batch polls and the stale-IMEI
|
||||
# rescuer stop burning quota on them. Returning devices are
|
||||
# re-enabled by the upsert above (enabledFlag from the API).
|
||||
# Guarded: only runs when every target listed successfully and the
|
||||
# aggregate is non-empty, so an API outage can't disable the fleet.
|
||||
disabled = 0
|
||||
if all_targets_ok and devices_by_imei:
|
||||
cur.execute("""
|
||||
UPDATE tracksolid.devices
|
||||
SET enabled_flag = 0, updated_at = NOW()
|
||||
WHERE enabled_flag = 1
|
||||
AND imei != ALL(%s)
|
||||
""", (list(devices_by_imei.keys()),))
|
||||
disabled = cur.rowcount
|
||||
|
||||
|
||||
log_ingestion(cur, "jimi.user.device.list+detail", len(devices), upserted, 0, int((time.time()-t0)*1000), True)
|
||||
conn.commit()
|
||||
log.info("Registry sync: %d devices updated, %d disabled (absent from API).",
|
||||
upserted, disabled)
|
||||
log.info("Registry sync: %d devices updated.", upserted)
|
||||
|
||||
# ── 2. Live Positions (Every 60s) ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -368,133 +332,103 @@ def _load_plates_cache(cur) -> dict[str, str]:
|
|||
|
||||
|
||||
def poll_trips():
|
||||
# [FIX-M23] Phased so no DB connection/transaction is held across network
|
||||
# calls (Tracksolid batches, Nominatim at 1 req/s): fetch → short read txn
|
||||
# for enrichment → geocode → one write txn.
|
||||
t0 = time.time()
|
||||
token, imeis = get_token(), get_active_imeis()
|
||||
if not token or not imeis: return
|
||||
|
||||
end_ts = datetime.now(timezone.utc)
|
||||
start_ts = end_ts - timedelta(hours=1)
|
||||
inserted = 0
|
||||
|
||||
# Phase 1: fetch all trips from the API — no connection held.
|
||||
api_trips: list[dict] = []
|
||||
for i in range(0, len(imeis), 50):
|
||||
batch = imeis[i:i+50]
|
||||
resp = api_post("jimi.device.track.mileage", {
|
||||
"imeis": ",".join(batch),
|
||||
"begin_time": start_ts.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_time": end_ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}, token)
|
||||
api_trips.extend(resp.get("result") or [])
|
||||
|
||||
if not api_trips:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
log_ingestion(cur, "jimi.device.track.mileage", len(imeis), 0, 0,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
log.info("Trips: 0 records processed.")
|
||||
return
|
||||
|
||||
# Phase 2: one short read transaction — plates cache + per-trip enrichment
|
||||
# from position_history ([FIX-M20]; trip_start/end may be None on rare
|
||||
# malformed payloads — skip enrichment there but still capture the row).
|
||||
enriched: list[dict] = []
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
plates = _load_plates_cache(cur)
|
||||
for t in api_trips:
|
||||
imei = t.get("imei")
|
||||
trip_start = clean_ts(t.get("startTime"))
|
||||
trip_end = clean_ts(t.get("endTime"))
|
||||
|
||||
start_geom = end_geom = route_geom = None
|
||||
start_lat = start_lng = end_lat = end_lng = None
|
||||
waypoints_count = 0
|
||||
if trip_start and trip_end:
|
||||
cur.execute(_ENRICH_QUERY, (
|
||||
imei, trip_start, # start_geom
|
||||
imei, trip_start, # start_lat
|
||||
imei, trip_start, # start_lng
|
||||
imei, trip_end, # end_geom
|
||||
imei, trip_end, # end_lat
|
||||
imei, trip_end, # end_lng
|
||||
imei, trip_start, trip_end, # route_geom
|
||||
imei, trip_start, trip_end, # waypoints_count
|
||||
))
|
||||
(start_geom, start_lat, start_lng,
|
||||
end_geom, end_lat, end_lng,
|
||||
route_geom, waypoints_count) = cur.fetchone()
|
||||
for i in range(0, len(imeis), 50):
|
||||
batch = imeis[i:i+50]
|
||||
resp = api_post("jimi.device.track.mileage", {
|
||||
"imeis": ",".join(batch),
|
||||
"begin_time": start_ts.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_time": end_ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}, token)
|
||||
|
||||
enriched.append({
|
||||
"raw": t, "imei": imei,
|
||||
"trip_start": trip_start, "trip_end": trip_end,
|
||||
"start_geom": start_geom, "end_geom": end_geom,
|
||||
"route_geom": route_geom, "waypoints_count": waypoints_count,
|
||||
"start_lat": start_lat, "start_lng": start_lng,
|
||||
"end_lat": end_lat, "end_lng": end_lng,
|
||||
"vehicle_plate": plates.get(imei),
|
||||
})
|
||||
trips = resp.get("result") or []
|
||||
for t in trips:
|
||||
try:
|
||||
cur.execute("SAVEPOINT sp")
|
||||
# [FIX-M16] API returns distance in METRES despite documentation saying km.
|
||||
# Confirmed via: avgSpeed(km/h) × runTimeSecond / 3600 == distance/1000.
|
||||
# startMileage/endMileage are cumulative odometer in metres (same unit).
|
||||
# Divide by 1000 to store as distance_km.
|
||||
raw_dist = clean_num(t.get("distance"))
|
||||
dist_km = round(raw_dist / 1000.0, 4) if raw_dist is not None else None
|
||||
|
||||
# Phase 3: reverse-geocode — throttled to 1 req/s, so no connection held.
|
||||
for e in enriched:
|
||||
e["start_address"] = reverse_geocode(e["start_lat"], e["start_lng"])
|
||||
e["end_address"] = reverse_geocode(e["end_lat"], e["end_lng"])
|
||||
imei = t.get("imei")
|
||||
trip_start = clean_ts(t.get("startTime"))
|
||||
trip_end = clean_ts(t.get("endTime"))
|
||||
idle_s = clean_int(t.get("idleSecond"))
|
||||
|
||||
# Phase 4: one write transaction.
|
||||
inserted = 0
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for e in enriched:
|
||||
t = e["raw"]
|
||||
try:
|
||||
cur.execute("SAVEPOINT sp")
|
||||
# [FIX-M16] API returns distance in METRES despite documentation saying km.
|
||||
# Confirmed via: avgSpeed(km/h) × runTimeSecond / 3600 == distance/1000.
|
||||
# startMileage/endMileage are cumulative odometer in metres (same unit).
|
||||
# Divide by 1000 to store as distance_km.
|
||||
raw_dist = clean_num(t.get("distance"))
|
||||
dist_km = round(raw_dist / 1000.0, 4) if raw_dist is not None else None
|
||||
# [FIX-M20] Enrich from position_history. trip_start/end
|
||||
# may be None (rare malformed payload) — skip enrichment
|
||||
# in that case so we still capture the row.
|
||||
start_geom = end_geom = route_geom = None
|
||||
start_lat = start_lng = end_lat = end_lng = None
|
||||
waypoints_count = 0
|
||||
if trip_start and trip_end:
|
||||
cur.execute(_ENRICH_QUERY, (
|
||||
imei, trip_start, # start_geom
|
||||
imei, trip_start, # start_lat
|
||||
imei, trip_start, # start_lng
|
||||
imei, trip_end, # end_geom
|
||||
imei, trip_end, # end_lat
|
||||
imei, trip_end, # end_lng
|
||||
imei, trip_start, trip_end, # route_geom
|
||||
imei, trip_start, trip_end, # waypoints_count
|
||||
))
|
||||
(start_geom, start_lat, start_lng,
|
||||
end_geom, end_lat, end_lng,
|
||||
route_geom, waypoints_count) = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO tracksolid.trips (
|
||||
imei, start_time, end_time, distance_km,
|
||||
avg_speed_kmh, max_speed_kmh, driving_time_s, idle_time_s,
|
||||
start_address = reverse_geocode(start_lat, start_lng)
|
||||
end_address = reverse_geocode(end_lat, end_lng)
|
||||
vehicle_plate = plates.get(imei)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO tracksolid.trips (
|
||||
imei, start_time, end_time, distance_km,
|
||||
avg_speed_kmh, max_speed_kmh, driving_time_s, idle_time_s,
|
||||
start_geom, end_geom, route_geom, waypoints_count,
|
||||
start_address, end_address, vehicle_plate, source
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, 'poll')
|
||||
ON CONFLICT (imei, start_time) DO UPDATE SET
|
||||
end_time = EXCLUDED.end_time,
|
||||
distance_km = EXCLUDED.distance_km,
|
||||
max_speed_kmh = COALESCE(EXCLUDED.max_speed_kmh, tracksolid.trips.max_speed_kmh),
|
||||
driving_time_s = COALESCE(EXCLUDED.driving_time_s, tracksolid.trips.driving_time_s),
|
||||
idle_time_s = COALESCE(EXCLUDED.idle_time_s, tracksolid.trips.idle_time_s),
|
||||
start_geom = COALESCE(tracksolid.trips.start_geom, EXCLUDED.start_geom),
|
||||
end_geom = COALESCE(EXCLUDED.end_geom, tracksolid.trips.end_geom),
|
||||
route_geom = COALESCE(EXCLUDED.route_geom, tracksolid.trips.route_geom),
|
||||
waypoints_count = EXCLUDED.waypoints_count,
|
||||
start_address = COALESCE(tracksolid.trips.start_address, EXCLUDED.start_address),
|
||||
end_address = COALESCE(EXCLUDED.end_address, tracksolid.trips.end_address),
|
||||
vehicle_plate = COALESCE(EXCLUDED.vehicle_plate, tracksolid.trips.vehicle_plate)
|
||||
""", (
|
||||
imei, trip_start, trip_end, dist_km,
|
||||
clean_num(t.get("avgSpeed")),
|
||||
clean_num(t.get("maxSpeed")),
|
||||
clean_int(t.get("runTimeSecond")),
|
||||
idle_s,
|
||||
start_geom, end_geom, route_geom, waypoints_count,
|
||||
start_address, end_address, vehicle_plate, source
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, 'poll')
|
||||
ON CONFLICT (imei, start_time) DO UPDATE SET
|
||||
end_time = EXCLUDED.end_time,
|
||||
distance_km = EXCLUDED.distance_km,
|
||||
max_speed_kmh = COALESCE(EXCLUDED.max_speed_kmh, tracksolid.trips.max_speed_kmh),
|
||||
driving_time_s = COALESCE(EXCLUDED.driving_time_s, tracksolid.trips.driving_time_s),
|
||||
idle_time_s = COALESCE(EXCLUDED.idle_time_s, tracksolid.trips.idle_time_s),
|
||||
start_geom = COALESCE(tracksolid.trips.start_geom, EXCLUDED.start_geom),
|
||||
end_geom = COALESCE(EXCLUDED.end_geom, tracksolid.trips.end_geom),
|
||||
route_geom = COALESCE(EXCLUDED.route_geom, tracksolid.trips.route_geom),
|
||||
waypoints_count = EXCLUDED.waypoints_count,
|
||||
start_address = COALESCE(tracksolid.trips.start_address, EXCLUDED.start_address),
|
||||
end_address = COALESCE(EXCLUDED.end_address, tracksolid.trips.end_address),
|
||||
vehicle_plate = COALESCE(EXCLUDED.vehicle_plate, tracksolid.trips.vehicle_plate)
|
||||
""", (
|
||||
e["imei"], e["trip_start"], e["trip_end"], dist_km,
|
||||
clean_num(t.get("avgSpeed")),
|
||||
clean_num(t.get("maxSpeed")),
|
||||
clean_int(t.get("runTimeSecond")),
|
||||
clean_int(t.get("idleSecond")),
|
||||
e["start_geom"], e["end_geom"], e["route_geom"], e["waypoints_count"],
|
||||
e["start_address"], e["end_address"], e["vehicle_plate"],
|
||||
))
|
||||
# [FIX-M22] Read rowcount BEFORE RELEASE (RELEASE reports -1).
|
||||
row_inserted = cur.rowcount
|
||||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
inserted += row_inserted
|
||||
except Exception:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT sp")
|
||||
log.warning("Failed to process trip for %s", t.get("imei"), exc_info=True)
|
||||
start_address, end_address, vehicle_plate,
|
||||
))
|
||||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
inserted += cur.rowcount
|
||||
except Exception:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT sp")
|
||||
log.warning("Failed to process trip for %s", t.get("imei"), exc_info=True)
|
||||
|
||||
log_ingestion(cur, "jimi.device.track.mileage", len(imeis), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
|
|
@ -555,10 +489,8 @@ def poll_parking():
|
|||
lng, lat, lng, lat,
|
||||
clean(p.get("address"))
|
||||
))
|
||||
# [FIX-M22] Read rowcount BEFORE RELEASE (RELEASE reports -1).
|
||||
row_inserted = cur.rowcount
|
||||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
inserted += row_inserted
|
||||
inserted += cur.rowcount
|
||||
except Exception:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT sp")
|
||||
log.warning("Failed to process parking for %s", p.get("imei"), exc_info=True)
|
||||
|
|
@ -580,7 +512,7 @@ def poll_track_list():
|
|||
|
||||
Impact on reporting:
|
||||
- position_history row density increases from ~1/min to ~1–4/min per device
|
||||
- Route traces on the map become accurate continuous paths
|
||||
- Route traces in Grafana become accurate continuous paths
|
||||
- Speed profile queries gain meaningful resolution (avg over 10s intervals
|
||||
vs 60s intervals) — enables hard-braking / harsh-acceleration detection
|
||||
- v_mileage_daily_cagg continuous aggregate gains finer odometer deltas
|
||||
|
|
@ -737,35 +669,12 @@ def poll_stale_locations():
|
|||
get_device_locations(stale)
|
||||
|
||||
|
||||
# ── 8. Audit-log Housekeeping (Daily) — [FIX-M25] ────────────────────────────
|
||||
|
||||
# Neither table is a hypertable, so Timescale retention policies don't apply.
|
||||
INGESTION_LOG_KEEP_DAYS = int(os.getenv("INGESTION_LOG_KEEP_DAYS", "90"))
|
||||
REFRESH_LOG_KEEP_DAYS = int(os.getenv("REFRESH_LOG_KEEP_DAYS", "180"))
|
||||
|
||||
|
||||
def purge_audit_logs():
|
||||
"""[FIX-M25] Trim the unbounded audit tables: tracksolid.ingestion_log
|
||||
(~5k rows/day from the pollers + webhook) and reporting.refresh_log
|
||||
(one row per v_trips refresh, every 300s). Runs daily at 02:30."""
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM tracksolid.ingestion_log WHERE run_at < NOW() - (%s || ' days')::interval",
|
||||
(str(INGESTION_LOG_KEEP_DAYS),))
|
||||
il = cur.rowcount
|
||||
cur.execute(
|
||||
"DELETE FROM reporting.refresh_log WHERE refreshed_at < NOW() - (%s || ' days')::interval",
|
||||
(str(REFRESH_LOG_KEEP_DAYS),))
|
||||
rl = cur.rowcount
|
||||
log.info("purge_audit_logs: %d ingestion_log + %d refresh_log rows deleted.", il, rl)
|
||||
|
||||
|
||||
# ── Main Loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def startup_catchup():
|
||||
"""Run every movement task once on boot so the DB is warm immediately.
|
||||
Split out of main() so the merged ingest_worker can reuse it (DRY)."""
|
||||
def main():
|
||||
log.info("Starting MOVEMENT PIPELINE (v2.2)...")
|
||||
|
||||
# Startup catch-up
|
||||
safe_task(sync_devices, log)()
|
||||
safe_task(poll_live_positions, log)()
|
||||
safe_task(poll_trips, log)()
|
||||
|
|
@ -773,23 +682,13 @@ def startup_catchup():
|
|||
safe_task(poll_track_list, log)()
|
||||
safe_task(poll_stale_locations, log)()
|
||||
|
||||
|
||||
def register_jobs():
|
||||
"""Register the movement jobs on the global `schedule` scheduler.
|
||||
Reused by both this module's main() and ingest_worker_rev.main()."""
|
||||
# Schedule
|
||||
schedule.every(60).seconds.do(safe_task(poll_live_positions, log))
|
||||
schedule.every(15).minutes.do(safe_task(poll_trips, log))
|
||||
schedule.every(15).minutes.do(safe_task(poll_parking, log))
|
||||
schedule.every(30).minutes.do(safe_task(poll_track_list, log)) # [FIX-M14]
|
||||
schedule.every(10).minutes.do(safe_task(poll_stale_locations, log)) # [FIX-M21]
|
||||
schedule.every().day.at("02:00").do(safe_task(sync_devices, log))
|
||||
schedule.every().day.at("02:30").do(safe_task(purge_audit_logs, log)) # [FIX-M25]
|
||||
|
||||
|
||||
def main():
|
||||
log.info("Starting MOVEMENT PIPELINE (v2.2)...")
|
||||
startup_catchup()
|
||||
register_jobs()
|
||||
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
"""
|
||||
ingest_worker_rev.py — Fireside Communications · Merged Ingest Worker
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
RESPONSIBILITY: Run the movement and events polling pipelines in a single
|
||||
process. Consolidates the former `ingest_movement` and `ingest_events`
|
||||
containers into one `ingest_worker` service.
|
||||
|
||||
WHY ONE PROCESS: both pipelines were identical in shape — blocking
|
||||
`while True: schedule.run_pending()` daemons that register jobs onto the
|
||||
`schedule` library's module-global default scheduler and share the same
|
||||
ts_shared_rev ThreadedConnectionPool. Driving every job from one
|
||||
run_pending() loop is strictly equivalent to running them separately, with
|
||||
one fewer container, one log stream, and one connection pool.
|
||||
|
||||
The inbound `webhook_receiver` is deliberately NOT merged here: pushed
|
||||
device data is unrecoverable, so it stays isolated from poller faults.
|
||||
|
||||
Standalone entrypoints (`python ingest_movement_rev.py`,
|
||||
`python ingest_events_rev.py`) remain intact for local debugging — this
|
||||
module only reuses their startup_catchup()/register_jobs() helpers.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
import time
|
||||
import schedule
|
||||
|
||||
from ts_shared_rev import get_logger, setup_shutdown
|
||||
import ingest_movement_rev as mv
|
||||
import ingest_events_rev as ev
|
||||
|
||||
log = get_logger("ingest_worker")
|
||||
|
||||
|
||||
def main():
|
||||
log.info("Starting INGEST WORKER — merged MOVEMENT + EVENTS pipelines")
|
||||
setup_shutdown(log) # one SIGTERM/SIGINT handler for the shared DB pool
|
||||
|
||||
# Startup catch-up — warm both pipelines immediately.
|
||||
mv.startup_catchup()
|
||||
ev.startup_catchup()
|
||||
|
||||
# Register every job onto the shared global `schedule` scheduler.
|
||||
mv.register_jobs()
|
||||
ev.register_jobs()
|
||||
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
111
migrations/10_driver_clock_views.sql
Normal file
111
migrations/10_driver_clock_views.sql
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
-- 10_driver_clock_views.sql
|
||||
-- Driver clock-in / clock-out views for n8n tardiness + working-hours monitoring.
|
||||
--
|
||||
-- Two views, both keyed by IMEI per local Africa/Nairobi date:
|
||||
-- • v_driver_clock_daily — historical, one row per IMEI per day with activity
|
||||
-- • v_driver_clock_today — thin pass-through filtered to today (EAT)
|
||||
--
|
||||
-- The view exposes raw times, locations, and addresses; n8n owns tardiness
|
||||
-- thresholds and cost-centre filtering. Closing time is the latest end_time
|
||||
-- of any trip whose start_time falls on the local Nairobi date — kept lossless
|
||||
-- so n8n can detect midnight crossings via reporting_ts/closing_ts.
|
||||
--
|
||||
-- Reuses:
|
||||
-- • tracksolid.trips.distance_km (renamed in migration 04)
|
||||
-- • tracksolid.trips.driving_time_s (referenced in migration 05)
|
||||
-- • tracksolid.trips.start_geom/end_geom (migration 02 §3.06)
|
||||
-- • tracksolid.trips.start_address/end_address (migration 09 — Nominatim)
|
||||
--
|
||||
-- Pattern follows 07_analytics_views.sql: regular views (not materialised),
|
||||
-- COMMENT ON VIEW with provenance, GRANT SELECT TO grafana_ro.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- v_driver_clock_daily
|
||||
-- One row per IMEI per local Africa/Nairobi date with at least one trip.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE VIEW tracksolid.v_driver_clock_daily AS
|
||||
WITH daily_agg AS (
|
||||
SELECT
|
||||
t.imei,
|
||||
(t.start_time AT TIME ZONE 'Africa/Nairobi')::date AS report_date,
|
||||
MIN(t.start_time) AS reporting_ts,
|
||||
MAX(t.end_time) AS closing_ts,
|
||||
COUNT(*) AS trips_count,
|
||||
SUM(t.distance_km) AS total_km,
|
||||
SUM(t.driving_time_s)::numeric / 3600 AS drive_hours
|
||||
FROM tracksolid.trips t
|
||||
GROUP BY t.imei, (t.start_time AT TIME ZONE 'Africa/Nairobi')::date
|
||||
),
|
||||
start_row AS (
|
||||
SELECT DISTINCT ON (t.imei, (t.start_time AT TIME ZONE 'Africa/Nairobi')::date)
|
||||
t.imei,
|
||||
(t.start_time AT TIME ZONE 'Africa/Nairobi')::date AS report_date,
|
||||
ST_Y(t.start_geom::geometry) AS start_lat,
|
||||
ST_X(t.start_geom::geometry) AS start_lng,
|
||||
t.start_address AS start_address
|
||||
FROM tracksolid.trips t
|
||||
ORDER BY t.imei, (t.start_time AT TIME ZONE 'Africa/Nairobi')::date, t.start_time ASC
|
||||
),
|
||||
end_row AS (
|
||||
SELECT DISTINCT ON (t.imei, (t.start_time AT TIME ZONE 'Africa/Nairobi')::date)
|
||||
t.imei,
|
||||
(t.start_time AT TIME ZONE 'Africa/Nairobi')::date AS report_date,
|
||||
ST_Y(t.end_geom::geometry) AS end_lat,
|
||||
ST_X(t.end_geom::geometry) AS end_lng,
|
||||
t.end_address AS end_address
|
||||
FROM tracksolid.trips t
|
||||
ORDER BY t.imei, (t.start_time AT TIME ZONE 'Africa/Nairobi')::date, t.end_time DESC NULLS LAST
|
||||
)
|
||||
SELECT
|
||||
a.imei,
|
||||
d.driver_name,
|
||||
d.vehicle_number,
|
||||
d.cost_centre,
|
||||
COALESCE(d.assigned_city, d.city, 'unassigned') AS assigned_city,
|
||||
a.report_date,
|
||||
(a.reporting_ts AT TIME ZONE 'Africa/Nairobi')::time AS reporting_time,
|
||||
(a.closing_ts AT TIME ZONE 'Africa/Nairobi')::time AS closing_time,
|
||||
a.reporting_ts,
|
||||
a.closing_ts,
|
||||
s.start_lat,
|
||||
s.start_lng,
|
||||
s.start_address,
|
||||
e.end_lat,
|
||||
e.end_lng,
|
||||
e.end_address,
|
||||
a.trips_count,
|
||||
a.total_km,
|
||||
a.drive_hours
|
||||
FROM daily_agg a
|
||||
LEFT JOIN start_row s ON s.imei = a.imei AND s.report_date = a.report_date
|
||||
LEFT JOIN end_row e ON e.imei = a.imei AND e.report_date = a.report_date
|
||||
LEFT JOIN tracksolid.devices d ON d.imei = a.imei;
|
||||
|
||||
COMMENT ON VIEW tracksolid.v_driver_clock_daily IS
|
||||
'Driver clock-in / clock-out daily series. One row per IMEI per Africa/Nairobi '
|
||||
'date with at least one trip. Reporting/closing times are derived from trip '
|
||||
'start_time/end_time bounded by local-date start; closing_ts may cross '
|
||||
'midnight UTC. No policy embedded — n8n applies tardiness rules and cost-centre '
|
||||
'filtering. See plan i-would-like-to-wobbly-volcano (2026-05-04).';
|
||||
|
||||
GRANT SELECT ON tracksolid.v_driver_clock_daily TO grafana_ro;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- v_driver_clock_today
|
||||
-- Pass-through filter: today's row from v_driver_clock_daily.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE VIEW tracksolid.v_driver_clock_today AS
|
||||
SELECT *
|
||||
FROM tracksolid.v_driver_clock_daily
|
||||
WHERE report_date = (NOW() AT TIME ZONE 'Africa/Nairobi')::date;
|
||||
|
||||
COMMENT ON VIEW tracksolid.v_driver_clock_today IS
|
||||
'Today snapshot of v_driver_clock_daily, filtered to (NOW() AT TIME ZONE '
|
||||
'''Africa/Nairobi'')::date. Refreshes as trips land throughout the day.';
|
||||
|
||||
GRANT SELECT ON tracksolid.v_driver_clock_today TO grafana_ro;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
-- 17_fleetops_fuel_view.sql
|
||||
-- FleetOps fuel roll-up source: reporting.v_fuel_daily.
|
||||
--
|
||||
-- Backs GET /analytics/fuel in dashboard_api_rev.py (the FleetOps SPA). It pairs
|
||||
-- ACTUAL fuel (trips.fuel_consumed_l, from the /pushtripreport webhook) with an
|
||||
-- ESTIMATED figure (distance_km * devices.fuel_100km / 100) so the SPA can show
|
||||
-- both and flag the gap.
|
||||
--
|
||||
-- Why a view (not a direct join in the API): it encapsulates the
|
||||
-- reporting.v_trips -> tracksolid.devices join so the read-only staging role only
|
||||
-- needs SELECT on this one reporting.* object, not on tracksolid.devices. It reuses
|
||||
-- the same per-trip grain + is_meaningful_route filter as the other reporting
|
||||
-- summaries (migration 11), and the same imei key v_trips already exposes.
|
||||
--
|
||||
-- Data state (2026-06-10): devices.fuel_100km is NULL fleet-wide and the /pushoil
|
||||
-- + /pushobd webhooks are unregistered, so estimated_fuel_l is NULL today and
|
||||
-- actual_fuel_l is sparse. The view is correct now and fills in as data lands —
|
||||
-- the API surfaces availability flags rather than faking numbers. Fuel-cost
|
||||
-- monetisation is intentionally absent: ops.cost_rates was purged 2026-06-05
|
||||
-- (migration 12).
|
||||
--
|
||||
-- CREATE OR REPLACE + guarded grant -> safe to re-apply.
|
||||
|
||||
SET search_path = reporting, tracksolid, public;
|
||||
|
||||
CREATE OR REPLACE VIEW reporting.v_fuel_daily AS
|
||||
SELECT t.trip_date,
|
||||
t.vehicle_number,
|
||||
t.cost_centre,
|
||||
t.assigned_city,
|
||||
t.assigned_driver,
|
||||
t.imei,
|
||||
t.distance_km,
|
||||
t.fuel_consumed_l AS actual_fuel_l,
|
||||
CASE
|
||||
WHEN d.fuel_100km IS NOT NULL AND t.distance_km IS NOT NULL
|
||||
THEN round(t.distance_km * d.fuel_100km / 100.0, 3)
|
||||
ELSE NULL::numeric
|
||||
END AS estimated_fuel_l
|
||||
FROM reporting.v_trips t
|
||||
LEFT JOIN tracksolid.devices d ON d.imei = t.imei
|
||||
WHERE t.is_meaningful_route;
|
||||
|
||||
COMMENT ON VIEW reporting.v_fuel_daily IS
|
||||
'Per-trip fuel: actual (trips.fuel_consumed_l) vs estimated (distance_km * devices.fuel_100km/100). '
|
||||
'Source for dashboard_api GET /analytics/fuel. Encapsulates the v_trips->devices join so the '
|
||||
'read-only staging role needs SELECT only on this view. fuel_100km is NULL fleet-wide as of 2026-06-10.';
|
||||
|
||||
-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
|
||||
DO $grants$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
GRANT USAGE ON SCHEMA reporting TO grafana_ro;
|
||||
GRANT SELECT ON reporting.v_fuel_daily TO grafana_ro;
|
||||
END IF;
|
||||
END $grants$;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
-- 18_grant_reporting_ro.sql
|
||||
-- Read-only access to the reporting.* layer for grafana_ro.
|
||||
--
|
||||
-- grafana_ro is the read-only role the STAGING dashboard_api connects as (it reads
|
||||
-- the prod DB but must be physically unable to write — see
|
||||
-- docs/STAGING_FLEETOPS_ARCHITECTURE.md §6). It already reads tracksolid.* (Grafana
|
||||
-- + the migration-07 analytics views), but was never granted SELECT on the
|
||||
-- reporting.* map/analytics layer (migration 11) — the prod dashboard_api connects
|
||||
-- as the app/superuser role, so the gap went unnoticed until the read-only staging
|
||||
-- instance hit "permission denied for view v_filter_drivers / v_daily_summary".
|
||||
--
|
||||
-- This grants USAGE + SELECT across reporting.* and sets DEFAULT PRIVILEGES so any
|
||||
-- future reporting view/table is auto-readable by grafana_ro (no re-grant needed).
|
||||
-- Read-only only: no INSERT/UPDATE/DELETE, so grafana_ro still cannot write or
|
||||
-- REFRESH. Guarded + idempotent -> safe to re-apply.
|
||||
|
||||
DO $grants$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
GRANT USAGE ON SCHEMA reporting TO grafana_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO grafana_ro; -- includes views
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA reporting GRANT SELECT ON TABLES TO grafana_ro;
|
||||
END IF;
|
||||
END $grants$;
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
-- 19_v_ingest_health.sql
|
||||
-- reporting.v_ingest_health — per-endpoint ingest freshness for FleetOps.
|
||||
--
|
||||
-- CONTEXT: Grafana was removed (2026-06-10) as redundant with the FleetOps SPA.
|
||||
-- The one signal only Grafana surfaced was pipeline freshness. This view replaces
|
||||
-- it with a read-API-friendly surface derived from the existing
|
||||
-- tracksolid.ingestion_log (every poll already writes a row via log_ingestion()),
|
||||
-- so FleetOps can show "is the ingest pipeline alive / stale / erroring" per
|
||||
-- endpoint without a separate dashboard product. Exposed by dashboard_api as
|
||||
-- GET /health/ingest.
|
||||
--
|
||||
-- One row per endpoint: the latest run, how long ago, last success/error, 1-hour
|
||||
-- run + failure counts, and a coarse freshness verdict. Each endpoint's expected
|
||||
-- cadence mirrors the ingest_worker schedule (ingest_worker_rev.py); 'stale' fires
|
||||
-- only past 3x that cadence so daily/low-frequency jobs aren't false-flagged.
|
||||
-- Guarded + idempotent -> safe to re-apply.
|
||||
|
||||
CREATE OR REPLACE VIEW reporting.v_ingest_health AS
|
||||
WITH last_run AS (
|
||||
SELECT DISTINCT ON (endpoint)
|
||||
endpoint, run_at, success, error_code, error_message,
|
||||
rows_inserted, rows_upserted, imei_count, duration_ms
|
||||
FROM tracksolid.ingestion_log
|
||||
ORDER BY endpoint, run_at DESC
|
||||
),
|
||||
agg AS (
|
||||
SELECT endpoint,
|
||||
count(*) FILTER (WHERE run_at > now() - interval '1 hour') AS runs_1h,
|
||||
count(*) FILTER (WHERE run_at > now() - interval '1 hour' AND NOT success) AS failures_1h
|
||||
FROM tracksolid.ingestion_log
|
||||
GROUP BY endpoint
|
||||
)
|
||||
SELECT
|
||||
lr.endpoint,
|
||||
lr.run_at AS last_run_at,
|
||||
EXTRACT(EPOCH FROM (now() - lr.run_at))::int AS seconds_ago,
|
||||
lr.success AS last_success,
|
||||
lr.error_code,
|
||||
lr.error_message,
|
||||
lr.rows_inserted,
|
||||
lr.rows_upserted,
|
||||
COALESCE(a.runs_1h, 0) AS runs_1h,
|
||||
COALESCE(a.failures_1h, 0) AS failures_1h,
|
||||
ex.expected_interval_s,
|
||||
CASE
|
||||
WHEN EXTRACT(EPOCH FROM (now() - lr.run_at)) > 3 * ex.expected_interval_s THEN 'stale'
|
||||
WHEN NOT lr.success THEN 'error'
|
||||
ELSE 'ok'
|
||||
END AS freshness
|
||||
FROM last_run lr
|
||||
LEFT JOIN agg a USING (endpoint)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE lr.endpoint
|
||||
WHEN 'jimi.user.device.location.list' THEN 60 -- live sweep (60s)
|
||||
WHEN 'jimi.device.alarm.list' THEN 300 -- alarms (5m)
|
||||
WHEN 'jimi.device.track.mileage' THEN 900 -- trips (15m)
|
||||
WHEN 'jimi.open.platform.report.parking' THEN 900 -- parking (15m)
|
||||
WHEN 'jimi.device.track.list' THEN 1800 -- high-res trail (30m)
|
||||
ELSE 3600 -- default (1h)
|
||||
END AS expected_interval_s
|
||||
) ex
|
||||
ORDER BY seconds_ago DESC;
|
||||
|
||||
COMMENT ON VIEW reporting.v_ingest_health IS
|
||||
'Per-endpoint ingest freshness from tracksolid.ingestion_log. Replaces the '
|
||||
'Grafana pipeline-health panels (Grafana removed 2026-06-10). Surfaced by '
|
||||
'dashboard_api GET /health/ingest. freshness = ok|stale|error (stale = past 3x '
|
||||
'the ingest_worker_rev.py cadence).';
|
||||
|
||||
-- Read-only access for the dashboard roles (guarded; idempotent).
|
||||
DO $grants$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
GRANT SELECT ON reporting.v_ingest_health TO grafana_ro;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
|
||||
GRANT SELECT ON reporting.v_ingest_health TO dashboard_ro;
|
||||
END IF;
|
||||
END $grants$;
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
-- 20_restore_live_feed.sql
|
||||
-- Restore the live-map feed's intended final state after a migration-ordering regression.
|
||||
--
|
||||
-- WHAT HAPPENED: reporting.v_live_positions and reporting.fn_live_positions are first
|
||||
-- defined in migration 11, then MODIFIED by later migrations:
|
||||
-- * migration 15 added the cost-centre exclusion to v_live_positions (hide
|
||||
-- personal/management/mtn from the live map), and
|
||||
-- * migration 16 added 'vehicle_type'/'fleet_segment' to the fn_live_positions GeoJSON.
|
||||
-- On 2026-06-10 migration 11 was applied for the first time on prod (the reporting objects
|
||||
-- had originally been hand-created, so 11 was never recorded; 14/15/16 had already run).
|
||||
-- Re-running 11 recreated both objects at their BASE definitions, and because 15/16 were
|
||||
-- already marked applied they were skipped — silently reverting the exclusion and the
|
||||
-- vehicle_type/fleet_segment additions. Symptom: live-map vehicle count jumped 74 -> 80 and
|
||||
-- markers lost their specialist-icon fields.
|
||||
--
|
||||
-- THE FIX: re-assert both objects' intended final definitions here. As the highest-numbered
|
||||
-- migration this always runs last, so the correct state wins regardless of apply order.
|
||||
-- Verbatim union of migration 15 (v_live_positions + exclusion) and migration 16
|
||||
-- (fn_live_positions + vehicle_type/fleet_segment). Idempotent — safe to re-apply.
|
||||
|
||||
SET search_path = tracksolid, reporting, public;
|
||||
|
||||
-- ── exclusion config (created by migration 15; re-asserted so this file is self-contained) ──
|
||||
CREATE TABLE IF NOT EXISTS reporting.map_excluded_cost_centres (
|
||||
cost_centre text PRIMARY KEY, -- compared case-insensitively (store lowercase)
|
||||
note text,
|
||||
added_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO reporting.map_excluded_cost_centres (cost_centre, note) VALUES
|
||||
('personal', 'staff/personal vehicles — not operational fleet'),
|
||||
('management', 'management vehicles — not operational fleet'),
|
||||
('mtn', 'MTN contract / Uganda (Kampala) — outside Kenyan ops')
|
||||
ON CONFLICT (cost_centre) DO NOTHING;
|
||||
|
||||
-- ── v_live_positions: base definition + cost-centre exclusion (from migration 15) ─────────
|
||||
CREATE OR REPLACE VIEW reporting.v_live_positions AS
|
||||
WITH primary_device AS (
|
||||
SELECT DISTINCT ON ((reporting.normalize_plate(d_1.vehicle_number))) reporting.normalize_plate(d_1.vehicle_number) AS vehicle_number,
|
||||
d_1.imei AS primary_imei
|
||||
FROM devices d_1
|
||||
LEFT JOIN live_positions lp_1 ON lp_1.imei = d_1.imei
|
||||
WHERE d_1.vehicle_number IS NOT NULL AND d_1.enabled_flag = 1
|
||||
-- exclude plates whose device(s) carry a non-operational cost centre
|
||||
AND reporting.normalize_plate(d_1.vehicle_number) NOT IN (
|
||||
SELECT reporting.normalize_plate(x.vehicle_number)
|
||||
FROM devices x
|
||||
WHERE x.vehicle_number IS NOT NULL
|
||||
AND lower(trim(x.cost_centre)) IN (
|
||||
SELECT cost_centre FROM reporting.map_excluded_cost_centres)
|
||||
)
|
||||
ORDER BY (reporting.normalize_plate(d_1.vehicle_number)), (
|
||||
CASE
|
||||
WHEN (d_1.mc_type = ANY (ARRAY['GT06E'::text, 'X3'::text, 'AT4'::text])) AND lp_1.gps_time >= (now() - '24:00:00'::interval) THEN 0
|
||||
ELSE 1
|
||||
END), lp_1.gps_time DESC NULLS LAST, (
|
||||
CASE d_1.mc_type
|
||||
WHEN 'GT06E'::text THEN 1
|
||||
WHEN 'X3'::text THEN 2
|
||||
WHEN 'AT4'::text THEN 3
|
||||
WHEN 'JC400P'::text THEN 4
|
||||
ELSE 5
|
||||
END), d_1.activation_time, d_1.imei
|
||||
)
|
||||
SELECT lp.imei,
|
||||
pd.vehicle_number,
|
||||
d.driver_name AS assigned_driver,
|
||||
d.cost_centre,
|
||||
d.assigned_city,
|
||||
d.vehicle_category,
|
||||
d.vehicle_models,
|
||||
d.mc_type,
|
||||
CASE d.mc_type
|
||||
WHEN 'GT06E'::text THEN 'tracker'::text
|
||||
WHEN 'X3'::text THEN 'tracker'::text
|
||||
WHEN 'AT4'::text THEN 'tracker'::text
|
||||
WHEN 'JC400P'::text THEN 'camera'::text
|
||||
ELSE 'other'::text
|
||||
END AS device_kind,
|
||||
lp.lat,
|
||||
lp.lng,
|
||||
lp.speed,
|
||||
lp.direction,
|
||||
lp.acc_status,
|
||||
lp.device_status,
|
||||
lp.gps_signal,
|
||||
lp.gps_num,
|
||||
lp.current_mileage,
|
||||
lp.loc_desc,
|
||||
lp.gps_time,
|
||||
lp.updated_at,
|
||||
(lp.gps_time AT TIME ZONE 'Africa/Nairobi'::text) AS gps_time_eat,
|
||||
(lp.updated_at AT TIME ZONE 'Africa/Nairobi'::text) AS updated_at_eat,
|
||||
round(EXTRACT(epoch FROM now() - lp.gps_time) / 3600::numeric, 2) AS source_age_hours
|
||||
FROM live_positions lp
|
||||
JOIN primary_device pd ON pd.primary_imei = lp.imei
|
||||
JOIN devices d ON d.imei = lp.imei;
|
||||
|
||||
COMMENT ON TABLE reporting.map_excluded_cost_centres IS
|
||||
'Cost centres hidden from the live map (reporting.v_live_positions). Edit to hide/restore; '
|
||||
'effective on next query. Seeded: personal, management, mtn. See migration 15.';
|
||||
|
||||
-- ── fn_live_positions: base definition + vehicle_type/fleet_segment (from migration 16) ───
|
||||
CREATE OR REPLACE FUNCTION reporting.fn_live_positions(p_cost_centre text DEFAULT NULL::text, p_acc_status text DEFAULT NULL::text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
p_cost_centre := NULLIF(p_cost_centre, '');
|
||||
p_acc_status := NULLIF(p_acc_status, '');
|
||||
WITH filtered AS (
|
||||
SELECT * FROM reporting.v_live_positions
|
||||
WHERE (p_cost_centre IS NULL OR cost_centre = p_cost_centre)
|
||||
AND (p_acc_status IS NULL OR acc_status = p_acc_status)
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'summary', jsonb_build_object(
|
||||
'vehicle_count', COUNT(*),
|
||||
-- "moving" and "parked" both restrict to devices that have reported
|
||||
-- within the OFFLINE_THRESHOLD (24 h) so they represent the live
|
||||
-- fleet, not equipment-failure stragglers. "offline" is its own
|
||||
-- counter for the > 24 h tail.
|
||||
'moving', COUNT(*) FILTER (WHERE acc_status = '1'
|
||||
AND source_age_hours < 24),
|
||||
'parked', COUNT(*) FILTER (WHERE acc_status = '0'
|
||||
AND source_age_hours < 24),
|
||||
'offline', COUNT(*) FILTER (WHERE source_age_hours >= 24),
|
||||
'median_speed_moving', percentile_cont(0.5) WITHIN GROUP (ORDER BY speed)
|
||||
FILTER (WHERE acc_status = '1'
|
||||
AND source_age_hours < 24
|
||||
AND speed > 0),
|
||||
'last_batch_at', to_char(MAX(updated_at) AT TIME ZONE 'Africa/Nairobi',
|
||||
'YYYY-MM-DD HH24:MI:SS'),
|
||||
'oldest_fix_at', to_char(MIN(gps_time) AT TIME ZONE 'Africa/Nairobi',
|
||||
'YYYY-MM-DD HH24:MI:SS'),
|
||||
'newest_fix_at', to_char(MAX(gps_time) AT TIME ZONE 'Africa/Nairobi',
|
||||
'YYYY-MM-DD HH24:MI:SS'),
|
||||
'last_batch_utc', MAX(updated_at),
|
||||
'newest_fix_utc', MAX(gps_time)
|
||||
),
|
||||
'geojson', jsonb_build_object(
|
||||
'type', 'FeatureCollection',
|
||||
'features', COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'type', 'Feature',
|
||||
'properties', jsonb_build_object(
|
||||
'imei', imei,
|
||||
'vehicle_number', vehicle_number,
|
||||
'driver', assigned_driver,
|
||||
'cost_centre', cost_centre,
|
||||
'assigned_city', assigned_city,
|
||||
'vehicle_category', vehicle_category,
|
||||
'vehicle_type', vehicle_models,
|
||||
'fleet_segment', reporting.fn_fleet_segment(vehicle_models),
|
||||
'mc_type', mc_type,
|
||||
'device_kind', device_kind,
|
||||
'source_age_hours', source_age_hours,
|
||||
'speed', speed,
|
||||
'direction', direction,
|
||||
'acc_status', acc_status,
|
||||
'device_status', device_status,
|
||||
'gps_signal', gps_signal,
|
||||
'gps_num', gps_num,
|
||||
'current_mileage', current_mileage,
|
||||
'loc_desc', loc_desc,
|
||||
'gps_time', to_char(gps_time AT TIME ZONE 'Africa/Nairobi',
|
||||
'YYYY-MM-DD HH24:MI:SS'),
|
||||
'updated_at', to_char(updated_at AT TIME ZONE 'Africa/Nairobi',
|
||||
'YYYY-MM-DD HH24:MI:SS'),
|
||||
'gps_time_utc', gps_time,
|
||||
'updated_at_utc', updated_at
|
||||
),
|
||||
'geometry', jsonb_build_object(
|
||||
'type', 'Point',
|
||||
'coordinates', jsonb_build_array(lng, lat)
|
||||
)
|
||||
)
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
) INTO v_result FROM filtered;
|
||||
|
||||
RETURN v_result;
|
||||
END $function$;
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
-- 21_ingest_health_active_only.sql
|
||||
-- BUG-P5 (260702 audit): reporting.v_ingest_health included EVERY endpoint ever
|
||||
-- written to tracksolid.ingestion_log. One-shot operator tools (e.g.
|
||||
-- backfill_trips_enrichment, last run 2026-05-01) therefore sat at 'stale'
|
||||
-- forever, wedging dashboard_api GET /health/ingest at "overall": "stale" even
|
||||
-- when every real poller was healthy — the FleetOps freshness panel signal was
|
||||
-- useless.
|
||||
--
|
||||
-- Fix: restrict the view to the ALLOW-LIST of endpoints the running pipeline
|
||||
-- actually emits (ingest_worker_rev.py schedule + webhook_receiver_rev.py).
|
||||
-- Adding a new pipeline endpoint => extend the list here (and the cadence CASE).
|
||||
-- Everything else about the view (columns, verdict logic) is unchanged, so the
|
||||
-- dashboard_api endpoint and its consumers need no changes.
|
||||
-- Idempotent — CREATE OR REPLACE.
|
||||
|
||||
CREATE OR REPLACE VIEW reporting.v_ingest_health AS
|
||||
WITH pipeline_endpoints(endpoint) AS (
|
||||
VALUES
|
||||
('jimi.user.device.location.list'), -- live sweep (60s)
|
||||
('jimi.device.alarm.list'), -- alarms (5m)
|
||||
('jimi.device.track.mileage'), -- trips (15m)
|
||||
('jimi.open.platform.report.parking'), -- parking (15m)
|
||||
('jimi.device.track.list'), -- high-res trail (30m)
|
||||
('jimi.user.device.list+detail'), -- registry sync (daily)
|
||||
('webhook/pushobd'),
|
||||
('webhook/pushfaultinfo'),
|
||||
('webhook/pushalarm'),
|
||||
('webhook/pushgps'),
|
||||
('webhook/pushhb'),
|
||||
('webhook/pushtripreport'),
|
||||
('webhook/pushevent')
|
||||
),
|
||||
last_run AS (
|
||||
SELECT DISTINCT ON (il.endpoint)
|
||||
il.endpoint, il.run_at, il.success, il.error_code, il.error_message,
|
||||
il.rows_inserted, il.rows_upserted, il.imei_count, il.duration_ms
|
||||
FROM tracksolid.ingestion_log il
|
||||
JOIN pipeline_endpoints pe USING (endpoint)
|
||||
ORDER BY il.endpoint, il.run_at DESC
|
||||
),
|
||||
agg AS (
|
||||
SELECT il.endpoint,
|
||||
count(*) FILTER (WHERE il.run_at > now() - interval '1 hour') AS runs_1h,
|
||||
count(*) FILTER (WHERE il.run_at > now() - interval '1 hour' AND NOT il.success) AS failures_1h
|
||||
FROM tracksolid.ingestion_log il
|
||||
JOIN pipeline_endpoints pe USING (endpoint)
|
||||
WHERE il.run_at > now() - interval '1 hour'
|
||||
GROUP BY il.endpoint
|
||||
)
|
||||
SELECT
|
||||
lr.endpoint,
|
||||
lr.run_at AS last_run_at,
|
||||
EXTRACT(EPOCH FROM (now() - lr.run_at))::int AS seconds_ago,
|
||||
lr.success AS last_success,
|
||||
lr.error_code,
|
||||
lr.error_message,
|
||||
lr.rows_inserted,
|
||||
lr.rows_upserted,
|
||||
COALESCE(a.runs_1h, 0) AS runs_1h,
|
||||
COALESCE(a.failures_1h, 0) AS failures_1h,
|
||||
ex.expected_interval_s,
|
||||
CASE
|
||||
WHEN EXTRACT(EPOCH FROM (now() - lr.run_at)) > 3 * ex.expected_interval_s THEN 'stale'
|
||||
WHEN NOT lr.success THEN 'error'
|
||||
ELSE 'ok'
|
||||
END AS freshness
|
||||
FROM last_run lr
|
||||
LEFT JOIN agg a USING (endpoint)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE lr.endpoint
|
||||
WHEN 'jimi.user.device.location.list' THEN 60 -- live sweep (60s)
|
||||
WHEN 'jimi.device.alarm.list' THEN 300 -- alarms (5m)
|
||||
WHEN 'jimi.device.track.mileage' THEN 900 -- trips (15m)
|
||||
WHEN 'jimi.open.platform.report.parking' THEN 900 -- parking (15m)
|
||||
WHEN 'jimi.device.track.list' THEN 1800 -- high-res trail (30m)
|
||||
WHEN 'jimi.user.device.list+detail' THEN 86400 -- registry sync (daily)
|
||||
ELSE 3600 -- webhooks / default (1h)
|
||||
END AS expected_interval_s
|
||||
) ex
|
||||
ORDER BY seconds_ago DESC;
|
||||
|
||||
COMMENT ON VIEW reporting.v_ingest_health IS
|
||||
'Per-endpoint ingest freshness from tracksolid.ingestion_log, restricted to '
|
||||
'the active pipeline endpoints (migration 21) so one-shot tools cannot wedge '
|
||||
'the verdict at stale. Surfaced by dashboard_api GET /health/ingest. '
|
||||
'freshness = ok|stale|error (stale = past 3x the ingest_worker_rev.py cadence).';
|
||||
|
||||
-- Read-only access for the dashboard roles (guarded; idempotent).
|
||||
DO $grants$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
GRANT SELECT ON reporting.v_ingest_health TO grafana_ro;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
|
||||
GRANT SELECT ON reporting.v_ingest_health TO dashboard_ro;
|
||||
END IF;
|
||||
END $grants$;
|
||||
91
n8n-workflows/jimi_pushalarm.json
Normal file
91
n8n-workflows/jimi_pushalarm.json
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"name": "Jimi Alarm Push",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "pushalarm",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-pushalarm",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300],
|
||||
"webhookId": "pushalarm"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({\"code\": 0, \"msg\": \"success\"}) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-pushalarm",
|
||||
"name": "Respond to Jimi",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://tshook.rahamafresh.com/pushalarm",
|
||||
"sendBody": true,
|
||||
"contentType": "form-urlencoded",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"value": "={{ $('Webhook').item.json.body.token }}"
|
||||
},
|
||||
{
|
||||
"name": "data_list",
|
||||
"value": "={{ $('Webhook').item.json.body.data_list }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
},
|
||||
"id": "http-pushalarm",
|
||||
"name": "Forward to Webhook Receiver",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [700, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Jimi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Respond to Jimi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Forward to Webhook Receiver",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Jimi Tracksolid"
|
||||
}
|
||||
]
|
||||
}
|
||||
91
n8n-workflows/jimi_pushfaultinfo.json
Normal file
91
n8n-workflows/jimi_pushfaultinfo.json
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"name": "Jimi Fault Code Push",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "pushfaultinfo",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-pushfaultinfo",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300],
|
||||
"webhookId": "pushfaultinfo"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({\"code\": 0, \"msg\": \"success\"}) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-pushfaultinfo",
|
||||
"name": "Respond to Jimi",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://tshook.rahamafresh.com/pushfaultinfo",
|
||||
"sendBody": true,
|
||||
"contentType": "form-urlencoded",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"value": "={{ $('Webhook').item.json.body.token }}"
|
||||
},
|
||||
{
|
||||
"name": "data_list",
|
||||
"value": "={{ $('Webhook').item.json.body.data_list }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
},
|
||||
"id": "http-pushfaultinfo",
|
||||
"name": "Forward to Webhook Receiver",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [700, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Jimi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Respond to Jimi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Forward to Webhook Receiver",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Jimi Tracksolid"
|
||||
}
|
||||
]
|
||||
}
|
||||
91
n8n-workflows/jimi_pushgps.json
Normal file
91
n8n-workflows/jimi_pushgps.json
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"name": "Jimi GPS Push",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "pushgps",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-pushgps",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300],
|
||||
"webhookId": "pushgps"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({\"code\": 0, \"msg\": \"success\"}) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-pushgps",
|
||||
"name": "Respond to Jimi",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://tshook.rahamafresh.com/pushgps",
|
||||
"sendBody": true,
|
||||
"contentType": "form-urlencoded",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"value": "={{ $('Webhook').item.json.body.token }}"
|
||||
},
|
||||
{
|
||||
"name": "data_list",
|
||||
"value": "={{ $('Webhook').item.json.body.data_list }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
},
|
||||
"id": "http-pushgps",
|
||||
"name": "Forward to Webhook Receiver",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [700, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Jimi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Respond to Jimi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Forward to Webhook Receiver",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Jimi Tracksolid"
|
||||
}
|
||||
]
|
||||
}
|
||||
91
n8n-workflows/jimi_pushhb.json
Normal file
91
n8n-workflows/jimi_pushhb.json
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"name": "Jimi Heartbeat Push",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "pushhb",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-pushhb",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300],
|
||||
"webhookId": "pushhb"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({\"code\": 0, \"msg\": \"success\"}) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-pushhb",
|
||||
"name": "Respond to Jimi",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://tshook.rahamafresh.com/pushhb",
|
||||
"sendBody": true,
|
||||
"contentType": "form-urlencoded",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"value": "={{ $('Webhook').item.json.body.token }}"
|
||||
},
|
||||
{
|
||||
"name": "data_list",
|
||||
"value": "={{ $('Webhook').item.json.body.data_list }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
},
|
||||
"id": "http-pushhb",
|
||||
"name": "Forward to Webhook Receiver",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [700, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Jimi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Respond to Jimi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Forward to Webhook Receiver",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Jimi Tracksolid"
|
||||
}
|
||||
]
|
||||
}
|
||||
91
n8n-workflows/jimi_pushobd.json
Normal file
91
n8n-workflows/jimi_pushobd.json
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"name": "Jimi OBD Push",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "pushobd",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-pushobd",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300],
|
||||
"webhookId": "pushobd"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({\"code\": 0, \"msg\": \"success\"}) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-pushobd",
|
||||
"name": "Respond to Jimi",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://tshook.rahamafresh.com/pushobd",
|
||||
"sendBody": true,
|
||||
"contentType": "form-urlencoded",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"value": "={{ $('Webhook').item.json.body.token }}"
|
||||
},
|
||||
{
|
||||
"name": "data_list",
|
||||
"value": "={{ $('Webhook').item.json.body.data_list }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
},
|
||||
"id": "http-pushobd",
|
||||
"name": "Forward to Webhook Receiver",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [700, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Jimi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Respond to Jimi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Forward to Webhook Receiver",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Jimi Tracksolid"
|
||||
}
|
||||
]
|
||||
}
|
||||
91
n8n-workflows/jimi_pushtripreport.json
Normal file
91
n8n-workflows/jimi_pushtripreport.json
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"name": "Jimi Trip Report Push",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "pushtripreport",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-pushtripreport",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300],
|
||||
"webhookId": "pushtripreport"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({\"code\": 0, \"msg\": \"success\"}) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-pushtripreport",
|
||||
"name": "Respond to Jimi",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://tshook.rahamafresh.com/pushtripreport",
|
||||
"sendBody": true,
|
||||
"contentType": "form-urlencoded",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"value": "={{ $('Webhook').item.json.body.token }}"
|
||||
},
|
||||
{
|
||||
"name": "data_list",
|
||||
"value": "={{ $('Webhook').item.json.body.data_list }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
},
|
||||
"id": "http-pushtripreport",
|
||||
"name": "Forward to Webhook Receiver",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [700, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Jimi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Respond to Jimi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Forward to Webhook Receiver",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Jimi Tracksolid"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -41,8 +41,6 @@ dev = [
|
|||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "B", "UP", "SIM"]
|
||||
|
||||
[tool.mypy]
|
||||
|
|
|
|||
|
|
@ -40,15 +40,6 @@ MIGRATIONS = [
|
|||
"14_fleet_segment_and_vehicles_view.sql", # reporting.fn_fleet_segment + reporting.v_vehicles roster
|
||||
"15_map_exclude_cost_centres.sql", # hide personal/management/mtn vehicles from the live map
|
||||
"16_live_feed_vehicle_type.sql", # add vehicle_type + fleet_segment to fn_live_positions feed
|
||||
"17_fleetops_fuel_view.sql", # reporting.v_fuel_daily — FleetOps GET /analytics/fuel source
|
||||
"18_grant_reporting_ro.sql", # grant SELECT on reporting.* to grafana_ro (staging read-only role)
|
||||
"19_v_ingest_health.sql", # reporting.v_ingest_health — pipeline freshness (replaces Grafana panels)
|
||||
"20_restore_live_feed.sql", # re-assert v_live_positions exclusion + fn_live_positions vehicle_type (migration-order regression fix)
|
||||
"21_ingest_health_active_only.sql", # v_ingest_health: pipeline endpoints only (one-shot tools wedged /health/ingest at 'stale')
|
||||
# The `tickets` schema (INC/CRQ map) was migrations 21-23; it now lives in its
|
||||
# own repo — repo.rahamafresh.com/kianiadee/fleettickets.git (run its
|
||||
# run_migrations.py). dashboard_api still serves GET /webhook/tickets via
|
||||
# reporting.fn_tickets_for_map, which fleettickets defines.
|
||||
]
|
||||
|
||||
# ── Tables that must exist before the service is allowed to start ─────────────
|
||||
|
|
@ -188,6 +179,24 @@ def run_file(path, filename):
|
|||
return True
|
||||
|
||||
|
||||
def sync_role_passwords(conn):
|
||||
"""
|
||||
Keep DB role passwords in sync with env vars on every startup.
|
||||
Safe to run repeatedly — ALTER ROLE is idempotent.
|
||||
This fixes roles created with the placeholder 'SET_PASSWORD_IN_ENV'.
|
||||
"""
|
||||
roles = {
|
||||
"grafana_ro": os.getenv("GRAFANA_DB_RO_PASSWORD"),
|
||||
"pgbouncer": os.getenv("PGBOUNCER_AUTH_PASSWORD"),
|
||||
}
|
||||
with conn.cursor() as cur:
|
||||
for role, password in roles.items():
|
||||
if password:
|
||||
cur.execute(f"ALTER ROLE {role} WITH PASSWORD %s", (password,))
|
||||
print(f" Password synced for role: {role}")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def verify_schema(conn):
|
||||
"""Verify critical tables exist. Exit 1 if missing — blocks service start."""
|
||||
print("Verifying schema...")
|
||||
|
|
@ -240,6 +249,7 @@ def main():
|
|||
|
||||
print(f"\nMigrations: {applied} applied, {skipped} skipped.")
|
||||
|
||||
sync_role_passwords(conn)
|
||||
verify_schema(conn)
|
||||
conn.close()
|
||||
print("Startup checks passed.\n")
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# bootstrap_dashboard_ro.sh — create/refresh the dashboard_ro read-only role.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Run ON THE HOST. Generates a strong password into ~/.dashboard_ro.pw (0600) on
|
||||
# first run (reused thereafter), then applies scripts/dashboard_ro_role.sql to the
|
||||
# prod DB as the postgres superuser. The password is NEVER printed and never
|
||||
# leaves the host — the staging deploy script reads the same ~/.dashboard_ro.pw.
|
||||
#
|
||||
# Deploy:
|
||||
# scp scripts/dashboard_ro_role.sql scripts/bootstrap_dashboard_ro.sh \
|
||||
# kianiadee@twala.rahamafresh.com:~/
|
||||
# ssh kianiadee@twala.rahamafresh.com 'bash ~/bootstrap_dashboard_ro.sh'
|
||||
#
|
||||
# Idempotent: re-running rotates nothing unless ~/.dashboard_ro.pw is deleted
|
||||
# first (then it generates + sets a fresh password and you must redeploy the API).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
PW_FILE="${DASHBOARD_RO_PW_FILE:-$HOME/.dashboard_ro.pw}"
|
||||
SQL_FILE="${1:-$HOME/dashboard_ro_role.sql}"
|
||||
|
||||
test -f "$SQL_FILE" || { echo "ERROR: role SQL not found at $SQL_FILE (scp scripts/dashboard_ro_role.sql to ~ first)"; exit 1; }
|
||||
|
||||
if [ ! -s "$PW_FILE" ]; then
|
||||
( umask 077; openssl rand -hex 24 > "$PW_FILE" )
|
||||
chmod 600 "$PW_FILE"
|
||||
echo "Generated new dashboard_ro password -> $PW_FILE (0600)"
|
||||
else
|
||||
echo "Reusing existing dashboard_ro password from $PW_FILE"
|
||||
fi
|
||||
PW=$(cat "$PW_FILE")
|
||||
|
||||
DB=$(docker ps --filter name=timescale_db --format "{{.Names}}" | head -1)
|
||||
[ -n "$DB" ] || { echo "ERROR: timescale_db container not found"; exit 1; }
|
||||
|
||||
echo "Applying dashboard_ro role DDL to $DB as postgres ..."
|
||||
docker exec -i "$DB" psql -U postgres -d tracksolid_db -v ON_ERROR_STOP=1 -v ro_pw="$PW" < "$SQL_FILE"
|
||||
echo "dashboard_ro ready (password not printed). Now (re)run deploy_dashboard_api_staging.sh."
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
-- dashboard_ro_role.sql — dedicated read-only LOGIN role for dashboard_api.
|
||||
--
|
||||
-- Run as the postgres SUPERUSER (CREATE ROLE), NOT via run_migrations.py (which
|
||||
-- connects as the app role and may lack CREATEROLE). Apply with
|
||||
-- scripts/bootstrap_dashboard_ro.sh, which supplies the password as the psql
|
||||
-- variable :ro_pw from a host-only 0600 file — so no secret lives in this repo.
|
||||
--
|
||||
-- Purpose: a least-privilege role that can serve the FULL dashboard_api read
|
||||
-- surface, so it backs BOTH the staging instance now (stage 1) AND the live prod
|
||||
-- connection later (stage 2 — migrate fleetapi.rahamafresh.com off the app role).
|
||||
-- It therefore grants exactly what the API reads:
|
||||
-- * SELECT on reporting.* and tracksolid.* (tables + views)
|
||||
-- * SELECT on the reporting.v_trips MATERIALIZED VIEW — matviews are NOT
|
||||
-- covered by GRANT ... ON ALL TABLES, so it must be named explicitly
|
||||
-- * EXECUTE on the reporting.fn_* map functions (fn_live_positions, etc.)
|
||||
-- * DEFAULT PRIVILEGES so future objects created by the migration role are
|
||||
-- auto-readable ("dynamic" — no re-grant when we add views)
|
||||
-- Read-only: no INSERT/UPDATE/DELETE and not the matview owner, so dashboard_ro
|
||||
-- can never write or REFRESH. Idempotent -> safe to re-apply (also rotates pw).
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
DO $role$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
|
||||
CREATE ROLE dashboard_ro LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
END IF;
|
||||
END $role$;
|
||||
|
||||
ALTER ROLE dashboard_ro WITH LOGIN PASSWORD :'ro_pw';
|
||||
|
||||
GRANT CONNECT ON DATABASE tracksolid_db TO dashboard_ro;
|
||||
GRANT USAGE ON SCHEMA reporting, tracksolid TO dashboard_ro;
|
||||
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO dashboard_ro; -- tables + views
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA tracksolid TO dashboard_ro; -- tables + views
|
||||
GRANT SELECT ON reporting.v_trips TO dashboard_ro; -- MATERIALIZED VIEW (not in ALL TABLES)
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO dashboard_ro;
|
||||
|
||||
-- "dynamic": future objects created by the migration role (tracksolid_owner)
|
||||
-- are auto-granted. NOTE: matviews are still never covered — a new matview needs
|
||||
-- its own explicit GRANT SELECT (as above for v_trips).
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT SELECT ON TABLES TO dashboard_ro;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA tracksolid GRANT SELECT ON TABLES TO dashboard_ro;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT EXECUTE ON FUNCTIONS TO dashboard_ro;
|
||||
|
|
@ -14,9 +14,6 @@ os.environ.setdefault("TRACKSOLID_USER_ID", "test_user")
|
|||
os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5")
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
|
||||
os.environ.setdefault("JIMI_WEBHOOK_TOKEN", "")
|
||||
# Fixtures carry fixed 2024 timestamps; widen the FIX-W04 sanity window so
|
||||
# they stay valid regardless of the wall clock.
|
||||
os.environ.setdefault("WEBHOOK_EVENT_MAX_AGE_DAYS", "100000")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import webhook_receiver_rev
|
||||
|
|
@ -180,72 +177,3 @@ class TestPushObd:
|
|||
response = client.post("/pushobd", data={"token": "", "data_list": data_list})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["code"] == 0
|
||||
|
||||
|
||||
class TestJsonPushFormat:
|
||||
"""FIX-W02: the documented JSON push format must be parsed, not silently dropped."""
|
||||
|
||||
def test_json_data_list_accepted(self, client, mock_db):
|
||||
mock_conn, mock_cur = mock_db
|
||||
response = client.post(
|
||||
"/pushalarm",
|
||||
json={"token": "", "data_list": [WEBHOOK_ALARM_PAYLOAD]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["code"] == 0
|
||||
data_inserts = [
|
||||
c for c in mock_cur.execute.call_args_list
|
||||
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
|
||||
]
|
||||
assert len(data_inserts) == 1, "JSON-body push must insert the alarm"
|
||||
|
||||
def test_json_single_object_data_accepted(self, client, mock_db):
|
||||
mock_conn, mock_cur = mock_db
|
||||
response = client.post(
|
||||
"/pushalarm",
|
||||
json={"token": "", "data": WEBHOOK_ALARM_PAYLOAD},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data_inserts = [
|
||||
c for c in mock_cur.execute.call_args_list
|
||||
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
|
||||
]
|
||||
assert len(data_inserts) == 1
|
||||
|
||||
def test_malformed_json_returns_empty_success(self, client):
|
||||
response = client.post(
|
||||
"/pushalarm", content=b"{not json",
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestEventTimestampGuard:
|
||||
"""FIX-W04: device-clock garbage (e.g. 2019 alarm_time) must be rejected."""
|
||||
|
||||
def test_ancient_alarm_time_rejected(self, client, mock_db):
|
||||
mock_conn, mock_cur = mock_db
|
||||
stale = dict(WEBHOOK_ALARM_PAYLOAD, gateTime="2019-01-01 00:02:17")
|
||||
# Narrow the sanity window (module-level env default is widened for fixtures).
|
||||
from datetime import timedelta
|
||||
with patch.object(webhook_receiver_rev, "_EVENT_MAX_AGE", timedelta(days=30)):
|
||||
response = client.post(
|
||||
"/pushalarm", data={"token": "", "data_list": json.dumps([stale])}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data_inserts = [
|
||||
c for c in mock_cur.execute.call_args_list
|
||||
if "tracksolid.alarms" in str(c) and "INSERT" in str(c)
|
||||
]
|
||||
assert len(data_inserts) == 0, "2019 alarm_time must not be inserted"
|
||||
|
||||
def test_sane_ts_helper(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with patch.object(webhook_receiver_rev, "_EVENT_MAX_AGE", timedelta(days=30)):
|
||||
assert webhook_receiver_rev._is_sane_event_ts(recent) is True
|
||||
assert webhook_receiver_rev._is_sane_event_ts("2019-01-01 00:02:17") is False
|
||||
assert webhook_receiver_rev._is_sane_event_ts(None) is False
|
||||
future = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
assert webhook_receiver_rev._is_sane_event_ts(future) is False
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
# tools/ — manual & one-shot operator scripts
|
||||
|
||||
These are **not** part of the running stack (no service executes them). They are manual
|
||||
utilities, mostly one-shot tasks that have already been run. Kept here for reference and
|
||||
the occasional re-run, out of the production root.
|
||||
|
||||
They import `ts_shared_rev` (DB pool, API client, clean helpers) from the repo root, so run
|
||||
them **as modules from the image root** (`/app`), not as bare files:
|
||||
|
||||
```bash
|
||||
WK=$(docker ps --filter name=ingest_worker --format "{{.Names}}" | head -1)
|
||||
docker exec -it "$WK" python -m tools.<name> [args]
|
||||
```
|
||||
|
||||
| Script | What it does | One-shot? |
|
||||
|---|---|---|
|
||||
| `sync_driver_audit.py` | API↔DB driver/IMEI gap report + full device upsert across all sub-accounts | re-runnable audit |
|
||||
| `audit_device_reconciliation.py` | Read-only: reconcile the vehicle CSV (`data/`) against `tracksolid.devices`; reports gaps + NULL fields | re-runnable audit |
|
||||
| `import_drivers_csv.py` | Populate device names/plates from CSV (`--apply` to commit). The registry is already populated — kept for future bulk re-imports | one-shot (done) |
|
||||
| `backfill_trips_enrichment.py` | One-shot backfill of historical `tracksolid.trips` (route_geom/addresses/plate) for rows predating migration 09 (`--apply` to commit) | one-shot (done) |
|
||||
|
||||
> INC/CRQ ticket ingestion (`import_tickets.py`) moved to its own repo —
|
||||
> `repo.rahamafresh.com/kianiadee/fleettickets.git`.
|
||||
|
||||
`data/` holds the source CSVs the import/audit scripts read (default: `data/20260427_FSG_Vehicles_mitieng.csv`).
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# Manual / one-shot operator tools (NOT part of the runtime services).
|
||||
# Run from the repo root as a module so `import ts_shared_rev` resolves, e.g.:
|
||||
# docker exec ingest_worker python -m tools.sync_driver_audit
|
||||
# See tools/README.md.
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
"""
|
||||
import_drivers_csv.py — Fireside Communications · Driver & Vehicle CSV Import
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
One-shot script: reads the snake_case Fireside Group vehicle CSV
|
||||
(`20260427_FSG_Vehicles_mitieng.csv`), compares each row against the
|
||||
current `tracksolid.devices` values, and updates the DB.
|
||||
|
||||
The CSV columns mirror the DB schema directly — no inference. Cells with the
|
||||
literal string "NULL" are treated as missing.
|
||||
|
||||
Fields imported (per Phase 0.1 of the Business Analytics redesign plan):
|
||||
Identity : driver_name, driver_phone, vehicle_number, vehicle_name,
|
||||
vehicle_models, mc_type, device_name
|
||||
SIM : sim, iccid, imsi
|
||||
Lifecycle : activation_time, expiration
|
||||
Business meta : assigned_city, cost_centre, assigned_route,
|
||||
vehicle_category, vehicle_brand, fuel_100km, depot_address
|
||||
|
||||
`depot_geom` (PostGIS Point) is intentionally NOT imported — needs WKT and
|
||||
isn't present as coordinates in the CSV. Set it via a follow-up migration
|
||||
when geofences are loaded.
|
||||
|
||||
Usage:
|
||||
# Dry-run — shows diff, writes nothing
|
||||
python import_drivers_csv.py
|
||||
|
||||
# Filter to a single IMEI (dry-run)
|
||||
python import_drivers_csv.py --imei 862798052707896
|
||||
|
||||
# Apply all changes to DB
|
||||
python import_drivers_csv.py --apply
|
||||
|
||||
# Only fill fields that are currently NULL in the DB (never overwrite)
|
||||
python import_drivers_csv.py --only-null --apply
|
||||
|
||||
# Use a different CSV
|
||||
python import_drivers_csv.py --csv path/to/file.csv
|
||||
|
||||
Pre-requisite:
|
||||
Migrations 02, 05, 06 must be applied (they add the metadata columns).
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from ts_shared_rev import clean, clean_num, clean_ts, get_conn, get_logger
|
||||
|
||||
log = get_logger("csv_import")
|
||||
|
||||
DEFAULT_CSV_PATH = Path(__file__).parent / "data" / "20260427_FSG_Vehicles_mitieng.csv"
|
||||
|
||||
# Columns fetched from DB for diff comparison.
|
||||
DB_COLS = [
|
||||
"imei",
|
||||
# Identity
|
||||
"driver_name", "driver_phone", "vehicle_number", "vehicle_name",
|
||||
"vehicle_models", "mc_type", "device_name", "customer_name", "device_group",
|
||||
# SIM
|
||||
"sim", "iccid", "imsi",
|
||||
# Lifecycle
|
||||
"activation_time", "expiration",
|
||||
# Business metadata (Phase 0.1 additions)
|
||||
"assigned_city", "cost_centre", "assigned_route",
|
||||
"vehicle_category", "vehicle_brand", "fuel_100km", "depot_address",
|
||||
]
|
||||
|
||||
# Driver Name values that are placeholders — skip writing driver_name for these.
|
||||
_DRIVER_SKIP = {"identification", "ug", "ug crane", "unassigned"}
|
||||
|
||||
# Tracksolid Pro raw-export headers (Title-Case) → canonical snake_case DB columns.
|
||||
# Used when the CSV is the raw platform export rather than the snake_case schema-mirror.
|
||||
# Unmapped export columns (Mileage, Account, MAC, SN, VIN, install metadata, …) are ignored:
|
||||
# `Mileage`/`Account` are deliberately excluded (pipeline-owned), the rest have no DB column.
|
||||
_EXPORT_HEADER_ALIASES = {
|
||||
"IMEI": "imei",
|
||||
"Driver Name": "driver_name",
|
||||
"Telephone": "driver_phone",
|
||||
"LicensePlateNo.": "vehicle_number",
|
||||
"Device Name": "device_name",
|
||||
"Model": "mc_type",
|
||||
"Vehicle Model": "vehicle_models",
|
||||
"Vehicle Brand": "vehicle_brand",
|
||||
"Fuel/100km": "fuel_100km",
|
||||
"SIM": "sim",
|
||||
"ICCID": "iccid",
|
||||
"IMSI": "imsi",
|
||||
"Activated Date": "activation_time",
|
||||
"Subscription Expiration": "expiration",
|
||||
"Customer Name": "customer_name",
|
||||
"Group": "device_group",
|
||||
"Installation Address": "depot_address",
|
||||
}
|
||||
|
||||
# Columns intentionally NOT written from the raw Tracksolid export: its date
|
||||
# columns are date-only and would degrade the precise API-set timestamps, and
|
||||
# device_group differs only by capitalisation ("Default group"→"Default Group").
|
||||
# (User decision, 2026-06-25 quality-check import.)
|
||||
_EXCLUDE_COLS = {"activation_time", "expiration", "device_group"}
|
||||
|
||||
# Columns that need an explicit cast in the UPDATE statement.
|
||||
_TIMESTAMPTZ_COLS = {"activation_time", "expiration"}
|
||||
_NUMERIC_COLS = {"fuel_100km"}
|
||||
|
||||
|
||||
def _read(row: dict, col: str) -> str | None:
|
||||
"""Read a CSV column treating literal 'NULL'/'None' (case-insensitive) as missing."""
|
||||
v = clean(row.get(col))
|
||||
if v is None:
|
||||
return None
|
||||
return None if v.upper() in ("NULL", "NONE") else v
|
||||
|
||||
|
||||
def _read_num(row: dict, col: str) -> float | None:
|
||||
v = _read(row, col)
|
||||
return clean_num(v) if v is not None else None
|
||||
|
||||
|
||||
def _read_ts(row: dict, col: str) -> str | None:
|
||||
v = _read(row, col)
|
||||
return clean_ts(v) if v is not None else None
|
||||
|
||||
|
||||
def load_csv(csv_path: Path) -> dict[str, dict]:
|
||||
"""Load CSV into a dict keyed by IMEI.
|
||||
|
||||
Accepts two header styles: the snake_case schema-mirror CSV (original
|
||||
`tools/data/` workflow) and the raw Tracksolid Pro export (Title-Case
|
||||
headers). The export form is auto-detected and its headers are remapped to
|
||||
the canonical snake_case names via `_EXPORT_HEADER_ALIASES`; unmapped
|
||||
columns are dropped.
|
||||
"""
|
||||
rows: dict[str, dict] = {}
|
||||
with open(csv_path, encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
is_export = "IMEI" in (reader.fieldnames or [])
|
||||
if is_export:
|
||||
log.info("Detected Tracksolid raw-export header format; remapping columns.")
|
||||
for raw in reader:
|
||||
row = (
|
||||
{snake: raw.get(orig) for orig, snake in _EXPORT_HEADER_ALIASES.items()}
|
||||
if is_export
|
||||
else raw
|
||||
)
|
||||
imei = (row.get("imei") or "").strip()
|
||||
if not imei:
|
||||
continue
|
||||
rows[imei] = row
|
||||
log.info("CSV loaded: %d rows from %s", len(rows), csv_path.name)
|
||||
return rows
|
||||
|
||||
|
||||
def load_db_devices() -> dict[str, dict]:
|
||||
"""Fetch current device rows from DB, keyed by IMEI."""
|
||||
devices: dict[str, dict] = {}
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT {', '.join(DB_COLS)} FROM tracksolid.devices")
|
||||
col_names = [d[0] for d in cur.description]
|
||||
for row in cur.fetchall():
|
||||
rec = dict(zip(col_names, row))
|
||||
devices[rec["imei"]] = rec
|
||||
log.info("DB loaded: %d devices", len(devices))
|
||||
return devices
|
||||
|
||||
|
||||
def build_update(csv_row: dict, db_row: dict | None, only_null: bool) -> dict[str, object]:
|
||||
"""
|
||||
Return a dict of column→new_value for fields that need updating.
|
||||
When only_null=True, skip any DB column that already has a value.
|
||||
The driver_name column is skipped for placeholder-labelled devices.
|
||||
"""
|
||||
driver_raw = (_read(csv_row, "driver_name") or "")
|
||||
is_placeholder = driver_raw.lower() in _DRIVER_SKIP
|
||||
if driver_raw.lower() == "identification":
|
||||
return {}
|
||||
|
||||
proposed: dict[str, object] = {
|
||||
# Identity
|
||||
"driver_phone": _read(csv_row, "driver_phone"),
|
||||
"vehicle_number": _read(csv_row, "vehicle_number"),
|
||||
"vehicle_name": _read(csv_row, "vehicle_name"),
|
||||
"vehicle_models": _read(csv_row, "vehicle_models"),
|
||||
"mc_type": _read(csv_row, "mc_type"),
|
||||
"device_name": _read(csv_row, "device_name"),
|
||||
"customer_name": _read(csv_row, "customer_name"),
|
||||
"device_group": _read(csv_row, "device_group"),
|
||||
# SIM
|
||||
"sim": _read(csv_row, "sim"),
|
||||
"iccid": _read(csv_row, "iccid"),
|
||||
"imsi": _read(csv_row, "imsi"),
|
||||
# Lifecycle
|
||||
"activation_time": _read_ts(csv_row, "activation_time"),
|
||||
"expiration": _read_ts(csv_row, "expiration"),
|
||||
# Business metadata
|
||||
"assigned_city": _read(csv_row, "assigned_city"),
|
||||
"cost_centre": _read(csv_row, "cost_centre"),
|
||||
"assigned_route": _read(csv_row, "assigned_route"),
|
||||
"vehicle_category": _read(csv_row, "vehicle_category"),
|
||||
"vehicle_brand": _read(csv_row, "vehicle_brand"),
|
||||
"fuel_100km": _read_num(csv_row, "fuel_100km"),
|
||||
"depot_address": _read(csv_row, "depot_address"),
|
||||
}
|
||||
if not is_placeholder and driver_raw:
|
||||
proposed["driver_name"] = driver_raw
|
||||
|
||||
# Drop None values — no point sending NULL to overwrite NULL — and any
|
||||
# explicitly excluded columns (precision-degrading / cosmetic churn).
|
||||
proposed = {
|
||||
k: v for k, v in proposed.items()
|
||||
if v is not None and k not in _EXCLUDE_COLS
|
||||
}
|
||||
|
||||
if not only_null or db_row is None:
|
||||
return proposed
|
||||
|
||||
# only_null: drop any column that already has a non-null value in the DB.
|
||||
return {k: v for k, v in proposed.items() if db_row.get(k) is None}
|
||||
|
||||
|
||||
def print_diff(imei: str, updates: dict[str, object], db_row: dict | None) -> None:
|
||||
"""Pretty-print what will change for one device."""
|
||||
if not updates:
|
||||
return
|
||||
db = db_row or {}
|
||||
print(f"\n IMEI {imei}:")
|
||||
for col, new_val in sorted(updates.items()):
|
||||
old_val = db.get(col)
|
||||
if str(old_val) != str(new_val):
|
||||
print(f" {col:<20} {str(old_val):<30} → {new_val}")
|
||||
|
||||
|
||||
def _set_clause(col: str) -> str:
|
||||
"""SQL fragment for `col = ...` honouring per-column casts."""
|
||||
if col in _TIMESTAMPTZ_COLS:
|
||||
return f"{col} = COALESCE(%s::TIMESTAMPTZ, {col})"
|
||||
if col in _NUMERIC_COLS:
|
||||
# %s already a float; no NULLIF dance needed.
|
||||
return f"{col} = COALESCE(%s::NUMERIC, {col})"
|
||||
return f"{col} = COALESCE(NULLIF(%s, ''), {col})"
|
||||
|
||||
|
||||
def _placeholder(col: str) -> str:
|
||||
"""SQL fragment for a single VALUES placeholder honouring per-column casts."""
|
||||
if col in _TIMESTAMPTZ_COLS:
|
||||
return "%s::TIMESTAMPTZ"
|
||||
if col in _NUMERIC_COLS:
|
||||
return "%s::NUMERIC"
|
||||
return "%s"
|
||||
|
||||
|
||||
def run(apply: bool, only_null: bool, filter_imei: str | None, csv_path: Path) -> None:
|
||||
csv_rows = load_csv(csv_path)
|
||||
db_rows = load_db_devices()
|
||||
|
||||
if filter_imei:
|
||||
csv_rows = {k: v for k, v in csv_rows.items() if k == filter_imei}
|
||||
if not csv_rows:
|
||||
print(f"IMEI {filter_imei} not found in CSV.")
|
||||
return
|
||||
|
||||
updated = inserted = skipped = no_change = 0
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for imei, csv_row in csv_rows.items():
|
||||
db_row = db_rows.get(imei)
|
||||
updates = build_update(csv_row, db_row, only_null)
|
||||
|
||||
if not updates:
|
||||
driver_raw = (_read(csv_row, "driver_name") or "").lower()
|
||||
if driver_raw == "identification":
|
||||
skipped += 1
|
||||
else:
|
||||
no_change += 1
|
||||
continue
|
||||
|
||||
if db_row is None:
|
||||
# Device not yet synced from API — insert a stub row so
|
||||
# incoming alarms / positions don't trip the FK constraint.
|
||||
print(f"\n [NEW] IMEI {imei}:")
|
||||
for col, new_val in sorted(updates.items()):
|
||||
print(f" {col:<20} → {new_val}")
|
||||
if apply:
|
||||
cols = ["imei"] + list(updates.keys())
|
||||
vals = [imei] + list(updates.values())
|
||||
placeholders = ["%s"] + [_placeholder(c) for c in updates.keys()]
|
||||
cur.execute(
|
||||
f"INSERT INTO tracksolid.devices ({', '.join(cols)}) "
|
||||
f"VALUES ({', '.join(placeholders)}) "
|
||||
"ON CONFLICT (imei) DO NOTHING",
|
||||
vals,
|
||||
)
|
||||
inserted += 1
|
||||
continue
|
||||
|
||||
print_diff(imei, updates, db_row)
|
||||
|
||||
if apply:
|
||||
set_clauses = [_set_clause(c) for c in updates.keys()]
|
||||
params = list(updates.values())
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
params.append(imei)
|
||||
cur.execute(
|
||||
f"UPDATE tracksolid.devices SET {', '.join(set_clauses)} WHERE imei = %s",
|
||||
params,
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
updated += 1 # count as "would update" in dry-run
|
||||
|
||||
mode = "APPLIED" if apply else "DRY-RUN"
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {mode} COMPLETE")
|
||||
print(f"{'='*60}")
|
||||
print(f" Would update / updated : {updated}")
|
||||
print(f" Would insert / inserted : {inserted}")
|
||||
print(f" No change needed : {no_change}")
|
||||
print(f" Skipped (Identification): {skipped}")
|
||||
if not apply:
|
||||
print("\n Run with --apply to commit changes.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import driver/vehicle details from CSV into tracksolid.devices"
|
||||
)
|
||||
parser.add_argument("--apply", action="store_true", help="Write changes to DB (default: dry-run)")
|
||||
parser.add_argument("--only-null", action="store_true", help="Only update fields currently NULL in the DB")
|
||||
parser.add_argument("--imei", default=None, help="Limit to a single IMEI")
|
||||
parser.add_argument("--csv", default=str(DEFAULT_CSV_PATH),
|
||||
help=f"Path to the CSV (default: {DEFAULT_CSV_PATH.name})")
|
||||
args = parser.parse_args()
|
||||
|
||||
csv_path = Path(args.csv)
|
||||
if not csv_path.exists():
|
||||
log.error("CSV file not found: %s", csv_path)
|
||||
raise SystemExit(1)
|
||||
|
||||
run(apply=args.apply, only_null=args.only_null, filter_imei=args.imei, csv_path=csv_path)
|
||||
|
|
@ -408,11 +408,7 @@ def get_token() -> Optional[str]:
|
|||
|
||||
now = datetime.now(timezone.utc)
|
||||
if row:
|
||||
# timestamptz comes back timezone-aware from psycopg2 — only tag
|
||||
# naive values; .replace() on an aware value would relabel, not convert.
|
||||
expires_at = row['expires_at']
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
expires_at = row['expires_at'].replace(tzinfo=timezone.utc)
|
||||
diff = (expires_at - now).total_seconds()
|
||||
|
||||
if diff > 1800: return row['access_token']
|
||||
|
|
|
|||
980
uv.lock
980
uv.lock
|
|
@ -1,980 +0,0 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fireside-tracksolid-ingest"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "requests" },
|
||||
{ name = "schedule" },
|
||||
{ name = "urllib3" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "requests", specifier = ">=2.32.3" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
|
||||
{ name = "schedule", specifier = ">=1.2.2" },
|
||||
{ name = "urllib3", specifier = ">=2.2.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schedule"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
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 = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.49.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
|
@ -24,31 +24,17 @@ REVISIONS (QA-Verified):
|
|||
[BUG-03] push_trip_report: _parse_trip_ts handles Jimi BCD format YYMMDDHHmmss.
|
||||
[BUG-04] SAVEPOINT per item in all DB-writing endpoints (one bad item won't abort batch).
|
||||
[BUG-05] Added /pushevent endpoint → writes to tracksolid.device_events.
|
||||
[FIX-W01] 260702 SEC-02: startup CRITICAL warning when JIMI_WEBHOOK_TOKEN is
|
||||
empty; WEBHOOK_REQUIRE_TOKEN=1 refuses to start unauthenticated.
|
||||
[FIX-W02] 260702 BUG-P3: _parse_request handles application/json bodies
|
||||
({"token", "data_list"}) in addition to the observed form-encoded
|
||||
msgType/data format — a vendor-side format switch no longer
|
||||
silently discards pushes.
|
||||
[FIX-W03] 260702 BUG-P2: all DB work moved off the asyncio event loop into
|
||||
sync _process_* functions run via asyncio.to_thread — a large push
|
||||
no longer stalls /health and concurrent requests on the worker.
|
||||
[FIX-W04] 260702 BUG-P8: push_alarm rejects alarm_time outside a sane window
|
||||
(WEBHOOK_EVENT_MAX_AGE_DAYS / WEBHOOK_EVENT_MAX_FUTURE_DAYS) so
|
||||
devices with reset clocks (2019 timestamps observed live) stop
|
||||
polluting tracksolid.alarms.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Cap on items per webhook POST. Prevents a malformed/malicious push from
|
||||
|
|
@ -79,28 +65,11 @@ log = get_logger("webhook")
|
|||
|
||||
WEBHOOK_TOKEN = os.getenv("JIMI_WEBHOOK_TOKEN", "")
|
||||
|
||||
# [FIX-W04] Sanity window for pushed event timestamps. Devices with reset
|
||||
# clocks push alarm_time values years in the past (2019 observed live).
|
||||
_EVENT_MAX_AGE = timedelta(days=int(os.getenv("WEBHOOK_EVENT_MAX_AGE_DAYS", "30")))
|
||||
_EVENT_MAX_FUTURE = timedelta(days=int(os.getenv("WEBHOOK_EVENT_MAX_FUTURE_DAYS", "2")))
|
||||
|
||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# [FIX-W01] Fail loudly (or closed) when running without push authentication.
|
||||
if not WEBHOOK_TOKEN:
|
||||
if os.getenv("WEBHOOK_REQUIRE_TOKEN", "0") == "1":
|
||||
raise RuntimeError(
|
||||
"WEBHOOK_REQUIRE_TOKEN=1 but JIMI_WEBHOOK_TOKEN is empty — "
|
||||
"refusing to start an unauthenticated public webhook."
|
||||
)
|
||||
log.critical(
|
||||
"JIMI_WEBHOOK_TOKEN is EMPTY — every /push* endpoint accepts "
|
||||
"UNAUTHENTICATED writes. Configure a push token in the Tracksolid "
|
||||
"console and set JIMI_WEBHOOK_TOKEN (+ WEBHOOK_REQUIRE_TOKEN=1)."
|
||||
)
|
||||
log.info("Webhook receiver starting (v1.2)...")
|
||||
log.info("Webhook receiver starting (v1.1)...")
|
||||
yield
|
||||
log.info("Webhook receiver shutting down...")
|
||||
close_pool()
|
||||
|
|
@ -118,57 +87,38 @@ def _validate_token(token: str) -> None:
|
|||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
|
||||
def _cap_items(parsed) -> list[dict]:
|
||||
items = parsed if isinstance(parsed, list) else [parsed]
|
||||
if len(items) > MAX_ITEMS_PER_POST:
|
||||
log.warning("push: truncated %d → %d items", len(items), MAX_ITEMS_PER_POST)
|
||||
items = items[:MAX_ITEMS_PER_POST]
|
||||
return items
|
||||
def _parse_data_list(raw: str) -> list[dict]:
|
||||
"""Parse a JSON string into a list of dicts. raw may be a JSON array or single object."""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
items = parsed if isinstance(parsed, list) else [parsed]
|
||||
if len(items) > MAX_ITEMS_PER_POST:
|
||||
log.warning("data_list truncated: %d items exceeded cap of %d",
|
||||
len(items), MAX_ITEMS_PER_POST)
|
||||
items = items[:MAX_ITEMS_PER_POST]
|
||||
return items
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
async def _parse_request(request: Request) -> tuple[str, list[dict]]:
|
||||
"""Extract token + items from either a JSON body or a form-encoded body.
|
||||
"""Extract token + data_list from either a JSON body or form-encoded body.
|
||||
|
||||
Two formats exist in the wild:
|
||||
Jimi's integration push API sends:
|
||||
Content-Type: application/json
|
||||
Body: {"token": "...", "data_list": [{...}, ...]}
|
||||
|
||||
1. Observed live (integration push):
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: msgType=<topic>&data=<URL-encoded JSON object or array>
|
||||
(`data` holds one JSON object per event, not an array.)
|
||||
|
||||
2. Documented Data Push API [FIX-W02]:
|
||||
Content-Type: application/json
|
||||
Body: {"token": "...", "data_list": [{...}, ...]}
|
||||
|
||||
Both are handled here so no endpoint needs to know which format arrived —
|
||||
and a vendor-side format switch can't silently discard data.
|
||||
Some older/configured endpoints may still use form-encoded. This helper
|
||||
handles both so each endpoint doesn't need to know which format arrived.
|
||||
"""
|
||||
body = await request.body()
|
||||
if not body:
|
||||
return "", []
|
||||
|
||||
ctype = request.headers.get("content-type", "").lower()
|
||||
|
||||
# [FIX-W02] JSON push format.
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
log.warning("push: JSON body parse failed — %.200s", body)
|
||||
return "", []
|
||||
if not isinstance(payload, dict):
|
||||
return "", _cap_items(payload) if isinstance(payload, list) else []
|
||||
token = str(payload.get("token", "") or "")
|
||||
raw = payload.get("data_list") or payload.get("data") or []
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
log.warning("push: data JSON parse failed — %.200s", raw)
|
||||
return token, []
|
||||
return token, _cap_items(raw)
|
||||
|
||||
# Form-encoded push format (observed live).
|
||||
# Jimi integration push format (observed live):
|
||||
# Content-Type: application/x-www-form-urlencoded
|
||||
# Body: msgType=<topic>&data=<URL-encoded JSON object or array>
|
||||
# The `data` field holds a single JSON object per event, not an array.
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
|
|
@ -186,7 +136,11 @@ async def _parse_request(request: Request) -> tuple[str, list[dict]]:
|
|||
log.warning("push: data JSON parse failed — %.200s", raw_data)
|
||||
return token, []
|
||||
|
||||
return token, _cap_items(parsed)
|
||||
items = parsed if isinstance(parsed, list) else [parsed]
|
||||
if len(items) > MAX_ITEMS_PER_POST:
|
||||
log.warning("push: truncated %d → %d items", len(items), MAX_ITEMS_PER_POST)
|
||||
items = items[:MAX_ITEMS_PER_POST]
|
||||
return token, items
|
||||
|
||||
|
||||
def unix_to_ts(v) -> Optional[str]:
|
||||
|
|
@ -221,22 +175,6 @@ def _parse_trip_ts(v) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _is_sane_event_ts(ts: Optional[str]) -> bool:
|
||||
"""[FIX-W04] True when a pushed event timestamp falls inside the sane
|
||||
window (not older than WEBHOOK_EVENT_MAX_AGE_DAYS, not further ahead than
|
||||
WEBHOOK_EVENT_MAX_FUTURE_DAYS). Unparseable values count as insane."""
|
||||
if not ts:
|
||||
return False
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
return (now - _EVENT_MAX_AGE) <= dt <= (now + _EVENT_MAX_FUTURE)
|
||||
|
||||
|
||||
def _make_geom_params(lat, lng):
|
||||
"""Return (lng, lat, lng, lat) tuple for the CASE WHEN ST_MakePoint pattern."""
|
||||
return (lng, lat, lng, lat)
|
||||
|
|
@ -256,8 +194,13 @@ def health():
|
|||
|
||||
# ── 1. OBD Diagnostics (Priority 1) ──────────────────────────────────────────
|
||||
|
||||
def _process_obd(items: list[dict]) -> int:
|
||||
"""Blocking DB work for /pushobd — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushobd")
|
||||
async def push_obd(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
inserted = 0
|
||||
|
||||
|
|
@ -316,24 +259,19 @@ def _process_obd(items: list[dict]) -> int:
|
|||
|
||||
log_ingestion(cur, "webhook/pushobd", len(items), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushobd")
|
||||
async def push_obd(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_obd, items)
|
||||
log.info("pushobd: %d/%d items processed.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
# ── 2. DTC Fault Codes (Priority 1) ──────────────────────────────────────────
|
||||
|
||||
def _process_fault_info(items: list[dict]) -> int:
|
||||
"""Blocking DB work for /pushfaultinfo — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushfaultinfo")
|
||||
async def push_fault_info(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
inserted = 0
|
||||
|
||||
|
|
@ -391,24 +329,19 @@ def _process_fault_info(items: list[dict]) -> int:
|
|||
|
||||
log_ingestion(cur, "webhook/pushfaultinfo", len(items), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushfaultinfo")
|
||||
async def push_fault_info(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_fault_info, items)
|
||||
log.info("pushfaultinfo: %d fault codes from %d items.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
# ── 3. Alarm Events (Priority 2) ─────────────────────────────────────────────
|
||||
|
||||
def _process_alarms(items: list[dict]) -> int:
|
||||
"""Blocking DB work for /pushalarm — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushalarm")
|
||||
async def push_alarm(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
inserted = 0
|
||||
|
||||
|
|
@ -427,13 +360,6 @@ def _process_alarms(items: list[dict]) -> int:
|
|||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
continue
|
||||
|
||||
# [FIX-W04] Reject device-clock garbage (2019 timestamps observed).
|
||||
if not _is_sane_event_ts(alarm_time):
|
||||
log.warning("pushalarm: rejected insane alarm_time %r for %s",
|
||||
alarm_time, imei)
|
||||
cur.execute("RELEASE SAVEPOINT sp")
|
||||
continue
|
||||
|
||||
# Ensure parent devices row exists to satisfy FK constraint.
|
||||
_ensure_device(cur, imei, clean(item.get("deviceName")))
|
||||
|
||||
|
|
@ -476,24 +402,19 @@ def _process_alarms(items: list[dict]) -> int:
|
|||
|
||||
log_ingestion(cur, "webhook/pushalarm", len(items), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushalarm")
|
||||
async def push_alarm(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_alarms, items)
|
||||
log.info("pushalarm: %d/%d items processed.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
# ── 4. GPS Positions (Priority 2) ────────────────────────────────────────────
|
||||
|
||||
def _process_gps(items: list[dict]) -> int:
|
||||
"""Blocking validate+write for /pushgps — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushgps")
|
||||
async def push_gps(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
# Validation phase — pre-clean and filter without touching the DB.
|
||||
# Per-row INSERT with SAVEPOINT was ~1 ms/row overhead at this volume;
|
||||
|
|
@ -545,24 +466,19 @@ def _process_gps(items: list[dict]) -> int:
|
|||
with conn.cursor() as cur:
|
||||
log_ingestion(cur, "webhook/pushgps", len(items), 0, 0,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushgps")
|
||||
async def push_gps(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_gps, items)
|
||||
log.info("pushgps: %d/%d items inserted.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
# ── 5. Device Heartbeats (Priority 2) ────────────────────────────────────────
|
||||
|
||||
def _process_heartbeats(items: list[dict]) -> int:
|
||||
"""Blocking DB work for /pushhb — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushhb")
|
||||
async def push_heartbeat(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
inserted = 0
|
||||
|
||||
|
|
@ -599,24 +515,19 @@ def _process_heartbeats(items: list[dict]) -> int:
|
|||
|
||||
log_ingestion(cur, "webhook/pushhb", len(items), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushhb")
|
||||
async def push_heartbeat(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_heartbeats, items)
|
||||
log.info("pushhb: %d/%d items processed.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
# ── 6. Trip Reports (Priority 2) ─────────────────────────────────────────────
|
||||
|
||||
def _process_trip_reports(items: list[dict]) -> int:
|
||||
"""Blocking DB work for /pushtripreport — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushtripreport")
|
||||
async def push_trip_report(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
inserted = 0
|
||||
|
||||
|
|
@ -679,24 +590,19 @@ def _process_trip_reports(items: list[dict]) -> int:
|
|||
|
||||
log_ingestion(cur, "webhook/pushtripreport", len(items), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushtripreport")
|
||||
async def push_trip_report(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_trip_reports, items)
|
||||
log.info("pushtripreport: %d/%d items processed.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
# ── 7. Device Events (LOGIN / LOGOUT) ────────────────────────────────────────
|
||||
|
||||
def _process_events(items: list[dict]) -> int:
|
||||
"""Blocking DB work for /pushevent — runs in a worker thread [FIX-W03]."""
|
||||
@app.post("/pushevent")
|
||||
async def push_event(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
t0 = time.time()
|
||||
inserted = 0
|
||||
|
||||
|
|
@ -726,16 +632,6 @@ def _process_events(items: list[dict]) -> int:
|
|||
|
||||
log_ingestion(cur, "webhook/pushevent", len(items), 0, inserted,
|
||||
int((time.time() - t0) * 1000), True)
|
||||
return inserted
|
||||
|
||||
|
||||
@app.post("/pushevent")
|
||||
async def push_event(request: Request):
|
||||
token, items = await _parse_request(request)
|
||||
_validate_token(token)
|
||||
if not items:
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
||||
inserted = await asyncio.to_thread(_process_events, items)
|
||||
log.info("pushevent: %d/%d items processed.", inserted, len(items))
|
||||
return JSONResponse(content=SUCCESS)
|
||||
|
|
|
|||
Loading…
Reference in a new issue