demo version prepped

This commit is contained in:
2026-04-01 12:40:40 -04:00
parent d44e5f0ad1
commit ed319a6423
62 changed files with 8362 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.vscode
/data
.env
**/.env
*.env

View File

@@ -1,3 +1,43 @@
# three_60
NOTE: Not a realistic network simulation.
Simulates a "realistic" live cellular / telecommunications network. Generates alarms, incident tickets off of the alarms, and dispatches flying robot drones to fix the issues.
Can be viewed in LIVE mode, historical mode, and frozen mode.
Written w/ Fast API and Vue 3.
## Live Demo
- [three60.nicholassurmava.com](https://three60.nicholassurmava.com)
## Screenshots
![alt text](<.drafts/Screenshot 2026-04-01 115640.png>)
![alt text](<.drafts/Screenshot 2026-04-01 115715.png>)
![alt text](<.drafts/Screenshot 2026-04-01 115738.png>)
![alt text](<.drafts/Screenshot 2026-04-01 115801.png>)
![alt text](<.drafts/Screenshot 2026-04-01 115832.png>)
![alt text](<.drafts/Screenshot 2026-04-01 115849.png>)
## Quick start
TODO
## Resources
- [Opencellid](https://opencellid.org/)
## Todos
- [ ] Scale it, right now it's demo size
- [ ] Add weather data
- [ ] Add simulation controls like natural disasters on demand, etc.
- [ ] Add smarter, more varied drones. Road, flying, etc. That are assigned based on proximity, fuel, backlog, etc.
- [ ] ???

3
backend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# API_KEY="SEE README"
# CORS_ORIGINS=https://yourdomain.com
# DB_PATH="absolute path to db"

216
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,216 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml

1
backend/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

0
backend/README.md Normal file
View File

View File

@@ -0,0 +1,62 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings
from simulator import alarm_simulator, ticket_simulator, cleanup_alarms, cleanup_incidents, robot_simulator
import asyncio
from contextlib import asynccontextmanager
import time
from .incidents import router as incidents_router
from .alarms import router as alarms_router
from .cellsites import router as cellsites_router
from .simulator import router as simulator_router
from .robots import router as robots_router
origins: list[str] = settings.cors_origins.split(",")
ALARM_CLEANUP_INTERVAL = 180
INCIDENT_CLEANUP_INTERVAL = 240
# NOTE: All 5 simulators run on a single thread using Python's event loop. Using this instead of multithreading to keep things simple. Good enough for demoing the idea.
@asynccontextmanager
async def lifespan(app: FastAPI):
task1 = asyncio.create_task(alarm_simulator(5))
task2 = asyncio.create_task(ticket_simulator(20))
task3 = asyncio.create_task(cleanup_alarms(interval=ALARM_CLEANUP_INTERVAL, max_age_seconds=5))
task4 = asyncio.create_task(cleanup_incidents(interval=INCIDENT_CLEANUP_INTERVAL, max_age_seconds=10))
task5 = asyncio.create_task(robot_simulator(8))
# NOTE: This is for the stats endpoint, to send to frontend
simulator.next_alarm_cleanup_at = time.time() + ALARM_CLEANUP_INTERVAL
simulator.next_incident_cleanup_at = time.time() + INCIDENT_CLEANUP_INTERVAL
yield
task1.cancel()
task2.cancel()
task3.cancel()
task4.cancel()
task5.cancel()
def create_app() -> FastAPI:
# TODO: Add a toggle / Flag or endpoint to turn off the simulator
# app = FastAPI()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(incidents_router, prefix="/api/v1")
app.include_router(cellsites_router, prefix="/api/v1")
app.include_router(alarms_router, prefix="/api/v1")
app.include_router(simulator_router, prefix="/api/v1")
app.include_router(robots_router, prefix="/api/v1")
return app

View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Query
import database as db
from typing import Any
from logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/alarms", tags=["alarms"])
@router.get("/")
def get_alarms(
id: int | None = Query(None),
before: int | None = Query(None)
) -> list[dict[Any, Any]]:
conn = db.connect_to_db()
try:
return db.get_alarms(conn, id=id, before=before)
except Exception as e:
logger.error(f"Error fetching alarms (id={id}, before={before}): {e}", exc_info=True)
return []
finally:
conn.close()

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter
import database as db
from typing import Any
from logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/cellsites", tags=["cellsites"])
@router.get("/")
def get_cellsites() -> list[dict[Any, Any]]: # type: ignore
conn = db.connect_to_db()
try:
with db.connect_to_db() as conn:
return db.get_cellsites(conn)
except Exception as e:
logger.error(f"Error fetching cellsites: {e}", exc_info=True)
return []
finally:
conn.close()
@router.get("/{id}")
def get_cellsite(id: int) -> list[dict[str, Any]]: # type: ignore
conn = db.connect_to_db()
try:
with db.connect_to_db() as conn:
return db.get_cellsite(conn, id)
except Exception as e:
logger.error(f"Error fetching cellsite {id}: {e}", exc_info=True)
return []
finally:
conn.close()

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, Query
import database as db
from typing import Any
from logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/incidents", tags=["incidents"])
@router.get("/{incident_id}")
def get_incident(incident_id: int) -> dict[str, Any] | None:
conn = db.connect_to_db()
try:
return db.get_incident_by_id(conn, incident_id)
except Exception as e:
logger.error(f"Error fetching incident {incident_id}: {e}", exc_info=True)
return None
finally:
conn.close()
@router.get("/")
def get_incidents(
id: int | None = Query(None),
before: int | None = Query(None)
) -> list[dict[str, Any]]:
conn = db.connect_to_db()
try:
return db.get_incidents(conn, id=id, before=before)
except Exception as e:
logger.error(f"Error fetching incidents (id={id}, before={before}): {e}", exc_info=True)
return []
finally:
conn.close()

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter
import database as db
from logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/robots", tags=["robots"])
@router.get("/")
def get_robots():
conn = db.connect_to_db()
try:
return db.get_robots(conn)
except Exception as e:
logger.error(f"Error fetching robots: {e}", exc_info=True)
return []
finally:
conn.close()
@router.get("/{robot_id}")
def get_robot(robot_id: int):
conn = db.connect_to_db()
try:
return db.get_robot_by_id(conn, robot_id)
except Exception as e:
logger.error(f"Error fetching robot {robot_id}: {e}", exc_info=True)
return None
finally:
conn.close()
# TODO: Add worklog + history to view when in historical mode in FE

View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter
import database as db
import simulator
from logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/simulator", tags=["simulator"])
@router.get("/status")
def get_status():
conn = db.connect_to_db()
try:
stats = db.get_simulator_stats(conn)
return {
**stats,
"next_alarm_cleanup_at": simulator.next_alarm_cleanup_at,
"next_incident_cleanup_at": simulator.next_incident_cleanup_at,
}
except Exception as e:
logger.error(f"Ticket simulator /api/v1/status error: {e}", exc_info=True)
return {}
finally:
conn.close()

11
backend/config.py Normal file
View File

@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_key: str = "" # NOTE: API key is only for getting the file through the etl. You do not need it in the app. You do need one to get the csv file SEE README.
cors_origins: str = "http://localhost,http://localhost:5173"
db_path: str = ""
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,132 @@
import sqlite3 as sql
import os
from datetime import datetime, timezone
from typing import Any
from config import settings
from logger import get_logger
logger = get_logger(__name__)
def connect_to_db() -> sql.Connection:
DB: str = settings.db_path or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "three60.db"
)
conn: sql.Connection = sql.connect(DB, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def convert_timestamps(row: dict) -> dict:
for key in ('created', 'updated'):
if row.get(key) is not None:
row[key] = datetime.fromtimestamp(row[key], tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
return row
# TODO: Break out into separate modules per thing
def get_cellsites(conn: sql.Connection) -> list[dict[Any, Any]]:
conn.row_factory = sql.Row
cur: sql.Cursor = conn.cursor()
# NOTE: Data is massive. Let's limit to the US. Still massive but less massive. This app isn't setup to handle this at the moment.
# TODO: Add chunking / pagination to handle the data
# data = cur.execute("""SELECT *
# FROM cellsites
# WHERE cellsites.lat BETWEEN 24.396308 AND 49.384358
# AND cellsites.lon BETWEEN -124.848974 AND -66.885444
# LIMIT 500;
# """).fetchall()
# NOTE: Testing out specific US states. Getting a better spread.
data: list[Any] = cur.execute("""
SELECT *
FROM cellsites
WHERE (
(lat BETWEEN 34.98 AND 36.68 AND lon BETWEEN -90.31 AND -81.65) -- Tennessee
OR (lat BETWEEN 30.36 AND 35.00 AND lon BETWEEN -85.61 AND -80.84) -- Georgia
OR (lat BETWEEN 36.97 AND 42.51 AND lon BETWEEN -91.51 AND -87.02) -- Illinois
OR (lat BETWEEN 36.50 AND 39.15 AND lon BETWEEN -89.57 AND -81.96) -- Kentucky
OR (lat BETWEEN 37.77 AND 41.76 AND lon BETWEEN -88.10 AND -84.78) -- Indiana
OR (lat BETWEEN 33.84 AND 36.59 AND lon BETWEEN -84.32 AND -75.46) -- North Carolina
OR (lat BETWEEN 32.05 AND 35.22 AND lon BETWEEN -83.35 AND -78.54) -- South Carolina
)
LIMIT 500;
""").fetchall()
logger.debug(f"get_cellsites → {len(data)} rows")
return [convert_timestamps(dict(row)) for row in data]
def get_cellsite(conn: sql.Connection, id: int) -> list[dict[Any, Any]]:
conn.row_factory = sql.Row
cur = conn.cursor()
data = cur.execute("SELECT * FROM cellsites WHERE id = ?", (id,)).fetchall()
if not data:
logger.warning(f"get_cellsite → no result for id={id}")
return [convert_timestamps(dict(row)) for row in data]
def get_alarms(conn: sql.Connection, id: int | None = None, before: int | None = None):
conn.row_factory = sql.Row
cur: sql.Cursor = conn.cursor()
query = "SELECT * FROM alarms"
params = []
conditions = []
if id is not None:
conditions.append("site_id = ?")
params.append(id)
if before is not None:
conditions.append("created <= ?")
params.append(before)
if conditions:
query += " WHERE " + " AND ".join(conditions)
data: list[Any] = cur.execute(query, params).fetchall()
logger.debug(f"get_alarms (site_id={id}, before={before}) → {len(data)} rows")
return [convert_timestamps(dict(row)) for row in data]
def get_incident_by_id(conn: sql.Connection, incident_id: int):
conn.row_factory = sql.Row
cur: sql.Cursor = conn.cursor()
row = cur.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,)).fetchone()
if not row:
logger.warning(f"get_incident_by_id → no result for id={incident_id}")
return convert_timestamps(dict(row)) if row else None
def get_incidents(conn: sql.Connection, id: int | None = None, before: int | None = None):
conn.row_factory = sql.Row
cur: sql.Cursor = conn.cursor()
query = "SELECT * FROM incidents"
params = []
conditions = []
if id is not None:
conditions.append("site_id = ?")
params.append(id)
if before is not None:
conditions.append("created <= ?")
params.append(before)
if conditions:
query += " WHERE " + " AND ".join(conditions)
data: list[Any] = cur.execute(query, params).fetchall()
logger.debug(f"get_incidents (site_id={id}, before={before}) → {len(data)} rows")
return [convert_timestamps(dict(row)) for row in data]
# TODO: Probably leave out of here. The sim shouldn't be part of the main queries.
def get_simulator_stats(conn):
cur = conn.cursor()
alarm_count = cur.execute("SELECT COUNT(*) FROM alarms WHERE created_by = 'simulator'").fetchone()[0]
incident_count = cur.execute("SELECT COUNT(*) FROM incidents WHERE created_by = 'simulator'").fetchone()[0]
return { "alarm_count": alarm_count, "incident_count": incident_count }
def get_robots(conn):
conn.row_factory = sql.Row
cur = conn.cursor()
data = cur.execute("SELECT * FROM robots").fetchall()
logger.debug(f"get_robots → {len(data)} rows")
return [convert_timestamps(dict(row)) for row in data]
def get_robot_by_id(conn, robot_id: int):
conn.row_factory = sql.Row
cur = conn.cursor()
row = cur.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)).fetchone()
if not row:
logger.warning(f"get_robot_by_id → no result for id={robot_id}")
return convert_timestamps(dict(row)) if row else None

246
backend/etl.py Normal file
View File

@@ -0,0 +1,246 @@
from database import connect_to_db
import random
from datetime import datetime, timezone, timedelta
from simulator.sim_config import ALARM_TEXTS, TICKET_TEXTS, ASSIGNEES, SITE_IDS
from config import settings
def main() -> None:
# create_cellsites_table()
create_alarms_table()
create_incidents_table()
# create_change_table()
create_robots_table()
def create_alarms_table():
conn = connect_to_db()
print("Dropping any existing table")
conn.execute("DROP TABLE IF EXISTS alarms")
print("Creating table")
conn.execute("""
CREATE TABLE IF NOT EXISTS alarms
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT,
severity INT,
site_id INT,
incident_id INTEGER,
created INT,
updated INT,
status TEXT,
created_by TEXT
)
""")
print("Done creating table.")
conn.commit()
conn.close()
def create_incidents_table():
conn = connect_to_db()
print("Dropping any existing table")
conn.execute("DROP TABLE IF EXISTS incidents")
print("Creating table")
conn.execute("""
CREATE TABLE IF NOT EXISTS incidents
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT,
severity INTEGER,
site_id INTEGER,
created INTEGER,
updated INTEGER,
assigned_to TEXT,
status TEXT,
created_by TEXT
)
""")
conn.commit()
print("Seeding historical incidents...")
number_to_seed = 500
now = datetime.now(timezone.utc)
records = []
for i in range(number_to_seed):
created_dt = now - timedelta(days=random.randint(1, 730), hours=random.randint(0, 23), minutes=random.randint(0, 59))
updated_dt = created_dt + timedelta(hours=random.randint(1, 72))
created = int(created_dt.timestamp())
updated = int(updated_dt.timestamp())
records.append((
random.choice(TICKET_TEXTS),
random.randint(1, 5),
random.choice(SITE_IDS),
created,
updated,
random.choice(ASSIGNEES),
'closed',
'etl'
))
conn.executemany("""
INSERT INTO incidents (text, severity, site_id, created, updated, assigned_to, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", records)
conn.commit()
conn.close()
print(f"Done. Seeded {len(records)} historical incidents.")
# TODO: Implement change requests simulation
# def create_change_table():
# conn = connect_to_db()
# # Create table
# print("Dropping any existing table")
# conn.execute("DROP TABLE IF EXISTS changes")
# print("Creating table")
# conn.execute("""
# CREATE TABLE IF NOT EXISTS changes
# (
# id INTEGER PRIMARY KEY AUTOINCREMENT,
# text TEXT,
# severity INTEGER
# site_id INTEGER
# created INTEGER
# updated INTEGER
# assigned_to TEXT,
# status TEXT,
# created_by TEXT
# )
# """)
# print("Done creating table.")
# conn.commit()
# conn.close()
def create_robots_table():
from simulator.sim_config import ASSIGNEES
conn = connect_to_db()
print("Dropping any existing table")
conn.execute("DROP TABLE IF EXISTS robots")
print("Creating table")
conn.execute("""
CREATE TABLE IF NOT EXISTS robots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
base_lat REAL,
base_lon REAL,
current_incident_id INTEGER,
current_site_id INTEGER,
lat REAL,
lon REAL,
target_lat REAL,
target_lon REAL,
updated INT
)
""")
# NOTE: Seeding attempts.
import random
now = int(__import__('time').time())
records = []
for name in ASSIGNEES:
base_lat = round(random.uniform(32.0, 42.0), 6)
base_lon = round(random.uniform(-91.5, -75.5), 6)
records.append((name, base_lat, base_lon, base_lat, base_lon, now))
conn.executemany(
"INSERT INTO robots (name, base_lat, base_lon, lat, lon, updated) VALUES (?, ?, ?, ?, ?, ?)",
records
)
conn.commit()
conn.close()
print(f"Done. Inserted {len(ASSIGNEES)} robots.")
def create_cellsites_table():
file_path = "../data/cell_towers_2026-03-21-T000000.csv"
conn = connect_to_db()
# Create table
print("Dropping any existing table")
conn.execute("DROP TABLE IF EXISTS cellsites")
print("Creating table")
conn.execute("""
CREATE TABLE IF NOT EXISTS cellsites
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
radio TEXT,
mcc INTEGER,
net INTEGER,
area INTEGER,
cell INTEGER,
unit INTEGER,
lon REAL,
lat REAL,
range INTEGER,
samples INTEGER,
changeable INTEGER,
created INTEGER,
updated INTEGER,
averageSignal INTEGER
)
""")
print("Done creating table.")
print("Starting data insertion")
import csv
with open(file_path, 'r') as file:
dr = csv.DictReader(file)
print(next(dr))
to_db = [(i['radio'], i['mcc'], i['net'], i['area'], i['cell'], i['unit'], i['lon'], i['lat'], i['range'], i['samples'], i['changeable'], i['created'], i['updated'], i['averageSignal']) for i in dr]
cur = conn.cursor()
cur.executemany("INSERT INTO cellsites (radio, mcc, net, area, cell, unit, lon, lat, range, samples, changeable, created, updated, averageSignal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", to_db)
conn.commit()
conn.close()
print("Done inserting data")
# TODO: Probably don't need this
def get_csv_file():
# NOTE: They rate limit two files per day.
"""
200
application/json
None
{'status': 'error', 'message': 'RATE_LIMITED', 'help': 'To ensure fair usage for all users, we only allow 2 downloads per file, per day.'}
"""
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def download_database(api_key: str, output_path: str) -> None:
"""Downloads the OpenCellID database CSV.
Args:
api_key (str): OpenCellID API key
output_path (str): path to save the downloaded file
"""
url = f"https://opencellid.org/ocid/downloads?token={api_key}&type=full&file=cell_towers.csv.gz"
with requests.get(url, stream=True, timeout=120, verify=False) as response:
print(response.status_code)
print(response.headers.get("Content-Type"))
print(response.headers.get("Content-Length"))
print(response.json())
response.raise_for_status()
if response.headers.get("Content-Length") == None:
print("ERROR")
return
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# TODO: add .env support and hide the key
API_KEY = settings.api_key
download_database(API_KEY, "../data/cell_towers.csv.gz")
if __name__ == "__main__":
main()
# get_csv_file()

13
backend/logger.py Normal file
View File

@@ -0,0 +1,13 @@
import logging
import sys
def setup_logger():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s%(pathname)s:%(lineno)d%(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)]
)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

7
backend/main.py Normal file
View File

@@ -0,0 +1,7 @@
from logger import setup_logger
setup_logger()
from fastapi import FastAPI
from api.v1 import create_app
api: FastAPI = create_app()

11
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,11 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi[standard]>=0.135.2",
"pydantic-settings>=2.13.1",
"requests>=2.32.5",
]

View File

@@ -0,0 +1,253 @@
import database as db
import sqlite3
from datetime import datetime, timezone
import random
import asyncio
import time
from .sim_config import ALARM_TEXTS, TICKET_TEXTS, ASSIGNEES, SITE_IDS
from logger import get_logger
logger = get_logger(__name__)
next_alarm_cleanup_at: float = 0.0
next_incident_cleanup_at: float = 0.0
# TODO: Simulate weather / natural disasters
async def ticket_simulator(interval=60):
# Generates tickets off of alarming sites.
while True:
conn = db.connect_to_db()
try:
logger.debug("Simulating tickets...")
cur = conn.cursor()
now = int(datetime.now(timezone.utc).timestamp())
# Find sites that have unassigned simulator alarms. Will be used to assigned to bot
rows = cur.execute("""
SELECT DISTINCT site_id FROM alarms
WHERE incident_id IS NULL AND created_by = 'simulator'
""").fetchall()
if not rows:
logger.debug("No unassigned alarms — skipping ticket creation.")
conn.close()
await asyncio.sleep(interval)
continue
site_id = random.choice(rows)[0]
text = random.choice(TICKET_TEXTS)
assigned_to = random.choice(ASSIGNEES)
# NOTE: Get the worse severity alarm that isn't assigned out.
worst = cur.execute("""
SELECT MIN(severity) FROM alarms
WHERE site_id = ? AND incident_id IS NULL
""", (site_id,)).fetchone()[0]
severity = worst or random.randint(1, 5)
cur.execute("""
INSERT INTO incidents (text, severity, site_id, created, updated, assigned_to, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, 'active', 'simulator')
""", (text, severity, site_id, now, now, assigned_to))
incident_id = cur.lastrowid
cur.execute("""
UPDATE alarms SET incident_id = ?, updated = ?
WHERE site_id = ? AND incident_id IS NULL
""", (incident_id, now, site_id))
conn.commit()
# Scan for idle robots for tickets
idle = cur.execute("""
SELECT * FROM robots WHERE current_incident_id IS NULL ORDER BY RANDOM() LIMIT 1
""").fetchone()
# when found, assign them a ticket.
# TODO: Make this smarter, maybe path or proximity. right now they fly everywhere.
if idle:
site = cur.execute("SELECT lat, lon FROM cellsites WHERE id = ?", (site_id,)).fetchone()
cur.execute("""
UPDATE robots SET current_incident_id = ?, target_lat = ?, target_lon = ?, updated = ?
WHERE id = ?
""", (incident_id, site[0], site[1], now, idle[0]))
cur.execute("""
UPDATE incidents SET assigned_to = ? WHERE id = ?
""", (idle[1], incident_id))
conn.commit()
logger.info(f"Robot {idle[1]} dispatched to site {site_id}")
conn.close()
logger.info(f"Ticket created — INC #{incident_id} site={site_id} sev={severity}")
except Exception as e:
logger.error(f"Ticket simulator error: {e}", exc_info=True)
finally:
conn.close()
await asyncio.sleep(interval)
async def alarm_simulator(interval=60):
# Generates random alarms off sites
while True:
conn = db.connect_to_db()
try:
logger.debug("Simulating alarms...")
cur = conn.cursor()
now = int(datetime.now(timezone.utc).timestamp())
site_id = random.choice(SITE_IDS)
severity = random.randint(1, 5)
text = random.choice(ALARM_TEXTS)
status = "active"
created_by = "simulator"
cur.execute("""
INSERT INTO alarms (text, severity, site_id, created, updated, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (text, severity, site_id, now, now, status, created_by))
conn.commit()
conn.close()
logger.debug(f"Alarm created — site={site_id} sev={severity}")
except Exception as e:
logger.error(f"Alarm simulator error: {e}", exc_info=True)
finally:
conn.close()
await asyncio.sleep(interval)
async def cleanup_incidents(interval=300, max_age_seconds=3600):
global next_incident_cleanup_at
while True:
next_incident_cleanup_at = time.time() + interval
await asyncio.sleep(interval)
conn = db.connect_to_db()
try:
cur = conn.cursor()
cutoff = int(datetime.now(timezone.utc).timestamp()) - max_age_seconds
cur.execute("DELETE FROM incidents WHERE created_by = 'simulator' AND created < ?", (cutoff,))
# NOTE: robots need to be reset or else will have deleted tickets
cur.execute("""
UPDATE robots SET
current_incident_id = NULL,
current_site_id = NULL,
target_lat = base_lat,
target_lon = base_lon
WHERE current_incident_id IS NOT NULL
AND current_incident_id NOT IN (SELECT id FROM incidents)
""")
affected = cur.rowcount
if affected:
logger.info(f"Reset {affected} robots orphaned by incident cleanup")
conn.commit()
conn.close()
logger.info(f"Incident cleanup done — removed records older than {max_age_seconds}s")
except Exception as e:
logger.error(f"Incident cleanup error: {e}", exc_info=True)
finally:
conn.close()
async def cleanup_alarms(interval=300, max_age_seconds=3600):
global next_alarm_cleanup_at
while True:
next_alarm_cleanup_at = time.time() + interval
await asyncio.sleep(interval)
conn = db.connect_to_db()
try:
cur = conn.cursor()
cutoff = int(datetime.now(timezone.utc).timestamp()) - max_age_seconds
cur.execute("DELETE FROM alarms WHERE created_by = 'simulator' AND created < ?", (cutoff,))
# NOTE: Here we clear any oprhaned incident_ids off the alarm after deleted.
cur.execute("""
UPDATE alarms SET incident_id = NULL
WHERE incident_id IS NOT NULL
AND incident_id NOT IN (SELECT id FROM incidents)
""")
conn.commit()
conn.close()
logger.info(f"Alarm cleanup done — removed records older than {max_age_seconds}s")
except Exception as e:
logger.error(f"Alarm cleanup error: {e}", exc_info=True)
finally:
conn.close()
async def robot_simulator(interval=3):
"""
This is the robot sim that will fly around and "fix" sites after being assigned tickets.
The robot has a fixed time to "work" and then will close the ticket and move on.
"""
while True:
conn = db.connect_to_db()
try:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
now = int(time.time())
in_transit = cur.execute("""
SELECT r.id, r.lat, r.lon, r.target_lat, r.target_lon,
r.current_incident_id, i.site_id as target_site_id
FROM robots r
JOIN incidents i ON r.current_incident_id = i.id
WHERE r.current_incident_id IS NOT NULL
AND r.current_site_id IS NULL
AND r.target_lat IS NOT NULL
""").fetchall()
STEP = 0.2
THRESHOLD = 0.05
for robot in in_transit:
dlat = robot['target_lat'] - robot['lat']
dlon = robot['target_lon'] - robot['lon']
dist = (dlat**2 + dlon**2) ** 0.5
if dist < THRESHOLD:
cur.execute("""
UPDATE robots SET lat = ?, lon = ?, current_site_id = ?, updated = ?
WHERE id = ?
""", (robot['target_lat'], robot['target_lon'], robot['target_site_id'], now, robot['id']))
logger.info(f"Robot {robot['id']} arrived at site {robot['target_site_id']}")
else:
factor = min(STEP / dist, 1.0)
new_lat = robot['lat'] + dlat * factor
new_lon = robot['lon'] + dlon * factor
cur.execute("""
UPDATE robots SET lat = ?, lon = ?, updated = ? WHERE id = ?
""", (new_lat, new_lon, now, robot['id']))
WORK_DURATION = 60 # seconds on site before closing ticket
on_site = cur.execute("""
SELECT id, current_incident_id, current_site_id, base_lat, base_lon, updated
FROM robots
WHERE current_site_id IS NOT NULL
AND current_incident_id IS NOT NULL
""").fetchall()
for robot in on_site:
time_on_site = now - robot['updated']
if time_on_site < WORK_DURATION:
continue
# Close the incident <- not delete
cur.execute("""
UPDATE incidents SET status = 'closed', updated = ? WHERE id = ?
""", (now, robot['current_incident_id']))
# End assignment and return, for more work
cur.execute("""
UPDATE robots SET
current_incident_id = NULL,
current_site_id = NULL,
target_lat = base_lat,
target_lon = base_lon,
updated = ?
WHERE id = ?
""", (now, robot['id']))
logger.info(f"Robot {robot['id']} finished INC #{robot['current_incident_id']} at site {robot['current_site_id']} — returning to base")
conn.commit()
except Exception as e:
logger.error(f"Robot simulator error: {e}", exc_info=True)
finally:
conn.close()
await asyncio.sleep(interval)

View File

@@ -0,0 +1,30 @@
TICKET_TEXTS: list[str] = [
"Investigate signal degradation at site",
"Replace faulty hardware unit",
"Perform antenna realignment",
"Inspect backhaul connection",
"Review capacity thresholds",
"Check power supply unit",
"Diagnose interference source",
"Schedule maintenance window"
]
ASSIGNEES: list[str] = [
"r2d2", "c3po", "hal9000", "glados", "tars",
"case", "wall_e", "data", "bishop", "ash"
]
SITE_IDS: list[int] = [
30859, 31387, 31388, 31389, 31390, 31391, 42041, 42913, 43330, 44117, 44900, 45124, 45356, 46716, 46774, 46780, 46781, 46782, 46827, 47084, 47146, 47157, 47164, 47165, 47240, 48329, 51843, 52032, 53375, 53573, 54492, 56076, 56861, 57316, 57319, 57424, 57564, 57601, 57616, 57805, 57958, 57977, 57978, 58023, 58024, 58033, 58741, 58808, 58874, 58875, 58876, 58877, 58878, 58879, 58880, 58881, 58882, 58883, 58932, 59232, 59258, 59297, 59453, 59566, 59656, 59659, 59865, 59884, 59887, 60402, 60427, 61121, 61122, 61353, 61823, 62029, 62030, 62149, 62210, 62217, 62299, 62463, 62641, 63249, 63295, 64077, 64202, 64361, 64384, 64385, 65709, 65949, 65950, 66608, 67052, 67096, 67097, 68042, 68695, 68696, 68697, 68698, 68699, 68700, 68726, 68779, 69597, 69872, 69873, 70161, 70233, 70234, 70235, 70236, 70237, 70308, 70550, 71094, 71673, 71674, 71675, 71676, 71677, 71678, 71679, 71680, 71969, 72006, 72233, 72316, 72513, 72719, 72761, 72762, 72763, 72764, 73114, 73125, 73141, 73142, 73143, 73213, 73413, 73414, 74068, 74492, 74584, 75083, 75693, 76850, 77873, 78320, 78435, 78644, 79086, 79292, 79305, 79658, 80752, 82021, 82416, 82454, 83444, 83706, 83793, 84308, 84499, 84656, 85437, 85438, 86094, 86424, 86576, 87114, 87115, 87663, 87839, 88598, 88599, 88600, 88782, 88783, 88877, 88878, 89112, 89113, 89266, 89285, 89286, 89287, 89288, 89711, 89712, 90471, 90472, 90473, 90474, 90475, 90476, 90477, 90527, 90528, 90695, 90696, 90697, 90698, 91013, 91159, 91160, 91586, 92163, 93413, 93414, 93415, 93416, 93503, 94041, 94042, 94046, 94047, 94194, 94285, 95112, 95113, 95114, 95115, 95116, 95117, 95118, 95119, 95120, 95121, 95122, 95123, 95124, 95125, 95126, 95127, 95128, 95129, 95130, 95131, 95132, 95133, 95134, 95135, 95136, 95137, 95138, 95139, 96041, 96723, 96808, 97416, 97503, 97505, 97557, 97558, 97594, 97596, 97602, 97853, 97862, 97873, 98150, 98164, 98165, 98208, 98209, 98210, 98211, 98212, 98226, 98227, 98228, 98229, 98602, 98662, 98663, 98664, 98665, 98666, 98667, 98668, 98669, 98670, 98671, 98672, 98673, 98674, 98675, 98676, 98677, 98678, 98679, 98680, 98681, 98760, 98772, 98773, 98774, 98775, 98776, 98901, 98902, 98903, 98904, 98927, 99525, 99528, 99529, 99531, 99758, 99965, 100356, 100568, 100570, 100614, 100615, 101221, 101398, 101469, 101470, 101471, 101602, 101720, 101790, 101814, 102059, 102065, 102108, 102109, 102110, 102163, 102255, 102805, 104288, 104289, 104303, 104307, 104960, 105551, 105552, 105554, 105555, 105620, 105621, 106103, 107003, 107163, 107164, 107165, 107166, 107167, 107168, 109188, 110321, 110322, 110323, 110324, 111233, 111462, 112488, 116358, 116359, 116360, 116361, 116362, 116363, 116364, 116365, 116366, 116367, 116368, 116369, 116370, 116371, 116372, 116373, 116374, 116375, 116376, 116377, 116378, 116379, 116380, 116381, 116382, 116383, 116384, 116385, 116386, 116387, 116388, 116389, 116390, 116391, 116392, 116393, 116394, 116395, 116396, 116397, 116398, 116399, 116400, 116401, 116402, 116403, 116404, 116405, 116406, 116407, 116408, 116409, 116410, 116411, 116412, 116413, 116414, 116415, 116416, 116417, 116418, 116419, 116420, 116421, 116422, 116423, 116424, 116425, 116426, 116427, 116428, 116429, 116430, 116431, 116432, 116433, 116434, 116435, 116436, 116437, 116438, 116439, 116440, 116441, 116442, 116443, 116444, 116445, 116446, 116447, 116448, 116449, 116450, 116451, 116452, 116453, 116454, 116455, 116456, 116457, 116458, 116459, 116460, 116461, 116462, 116463, 116464, 116465, 116466, 116467, 116468, 116469, 116470, 116471, 116472, 116473, 116474, 116475, 116476, 116477, 116478, 116479, 116480, 116481, 116482, 116483, 116484, 116485, 116486, 116487, 116488, 116489, 116490, 116491, 116492, 116493, 116494
]
ALARM_TEXTS: list[str] = [
"Signal degradation detected",
"High interference on frequency",
"Connection timeout",
"Hardware fault reported",
"Capacity threshold exceeded",
"Power supply warning",
"Antenna misalignment detected",
"Backhaul link degraded"
]

777
backend/uv.lock generated Normal file
View File

@@ -0,0 +1,777 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[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.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "pydantic-settings" },
{ name = "requests" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.2" },
{ name = "pydantic-settings", specifier = ">=2.13.1" },
{ name = "requests", specifier = ">=2.32.5" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "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 = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "fastapi"
version = "0.135.2"
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/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
]
[package.optional-dependencies]
standard = [
{ name = "email-validator" },
{ name = "fastapi-cli", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic-extra-types" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cli"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" },
]
[package.optional-dependencies]
standard = [
{ name = "fastapi-cloud-cli" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cloud-cli"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastar" },
{ name = "httpx" },
{ name = "pydantic", extra = ["email"] },
{ name = "rich-toolkit" },
{ name = "rignore" },
{ name = "sentry-sdk" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/e1/05c44e7bbc619e980fab0236cff9f5f323ac1aaa79434b4906febf98b1d3/fastapi_cloud_cli-0.15.0.tar.gz", hash = "sha256:d02515231f3f505f7669c20920343934570a88a08af9f9a6463ca2807f27ffe5", size = 45309, upload-time = "2026-03-11T22:31:32.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/cc/1ccca747f5609be27186ea8c9219449142f40e3eded2c6089bba6a6ecc82/fastapi_cloud_cli-0.15.0-py3-none-any.whl", hash = "sha256:9ffcf90bd713747efa65447620d29cfbb7b3f7de38d97467952ca6346e418d70", size = 32267, upload-time = "2026-03-11T22:31:33.499Z" },
]
[[package]]
name = "fastar"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/00/dab9ca274cf1fde19223fea7104631bea254751026e75bf99f2b6d0d1568/fastar-0.9.0.tar.gz", hash = "sha256:d49114d5f0b76c5cc242875d90fa4706de45e0456ddedf416608ecd0787fb410", size = 70124, upload-time = "2026-03-20T14:26:34.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/00/99700dd33273c118d7d9ab7ad5db6650b430448d4cfae62aec6ef6ca4cb7/fastar-0.9.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ccb2289f24ee6555330eb77149486d3a2ec8926450a96157dd20c636a0eec085", size = 707059, upload-time = "2026-03-20T14:25:35.086Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a4/4808dcfa8dddb9d7f50d830a39a9084d9d148ed06fcac8b040620848bc24/fastar-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2bfee749a46666785151b33980aef8f916e6e0341c3d241bde4d3de6be23f00c", size = 627135, upload-time = "2026-03-20T14:25:23.134Z" },
{ url = "https://files.pythonhosted.org/packages/da/cb/9c92e97d760d769846cae6ce53332a5f2a9246eb07b369ac2a4ebf10480c/fastar-0.9.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f6096ec3f216a21fa9ac430ce509447f56c5bd979170c4c0c3b4f3cb2051c1a8", size = 864974, upload-time = "2026-03-20T14:24:58.624Z" },
{ url = "https://files.pythonhosted.org/packages/84/38/9dadebd0b7408b4f415827db35169bbd0741e726e38e3afd3e491b589c61/fastar-0.9.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7a806e54d429f7f57e35dc709e801da8c0ba9095deb7331d6574c05ae4537ea", size = 760262, upload-time = "2026-03-20T14:23:53.275Z" },
{ url = "https://files.pythonhosted.org/packages/d6/7d/7afc5721429515aa0873b268513f656f905d27ff1ca54d875af6be9e9bc6/fastar-0.9.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9a06abf8c7f74643a75003334683eb6e94fabef05f60449b7841eeb093a47b0", size = 757575, upload-time = "2026-03-20T14:24:06.143Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5d/7498842c62bd6057553aa598cd175a0db41fdfeda7bdfde48dab63ffb285/fastar-0.9.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e9b5c155946f20ce3f999fb1362ed102876156ad6539e1b73a921f14efb758c", size = 924827, upload-time = "2026-03-20T14:24:19.364Z" },
{ url = "https://files.pythonhosted.org/packages/69/ab/13322e98fe1a00ed6efbfa5bf06fcfff8a6979804ef7fcef884b5e0c6f85/fastar-0.9.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdedac6a84ef9ebc1cee6d777599ad51c9e98ceb8ebb386159483dcd60d0e16", size = 816536, upload-time = "2026-03-20T14:24:44.844Z" },
{ url = "https://files.pythonhosted.org/packages/fe/fd/0aa5b9994c8dba75b73a9527be4178423cb926db9f7eca562559e27ccdfd/fastar-0.9.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51df60a2f7af09f75b2a4438b25cb903d8774e24c492acf2bca8b0863026f34c", size = 818686, upload-time = "2026-03-20T14:25:10.799Z" },
{ url = "https://files.pythonhosted.org/packages/46/d6/e000cd49ef85c11a8350e461e6c48a4345ace94fb52242ac8c1d5dad1dfc/fastar-0.9.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:15016d0da7dbc664f09145fc7db549ba8fe32628c6e44e20926655b82de10658", size = 885043, upload-time = "2026-03-20T14:24:32.231Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/ee734fe273475b9b25554370d92a21fc809376cf79aa072de29d23c17518/fastar-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c66a8e1f7dae6357be8c1f83ce6330febbc08e49fc40a5a2e91061e7867bbcbf", size = 967965, upload-time = "2026-03-20T14:25:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/c1/35/165b3a75f1ee8045af9478c8aae5b5e20913cca2d4a5adb1be445e8d015a/fastar-0.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1c6829be3f55d2978cb62921ef4d7c3dd58fe68ee994f81d49bd0a3c5240c977", size = 1034507, upload-time = "2026-03-20T14:26:01.518Z" },
{ url = "https://files.pythonhosted.org/packages/ba/4e/4097b5015da02484468c16543db2f8dec2fe827d321a798acbd9068e0f13/fastar-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:68db849e01d49543f31d56ef2fe15527afe2b9e0fb21794edc4d772553d83407", size = 1073388, upload-time = "2026-03-20T14:26:14.448Z" },
{ url = "https://files.pythonhosted.org/packages/07/d7/3b86af4e63a551398763a1bbbbac91e1c0754ece7ac7157218b33a065f4c/fastar-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5569510407c0ded580cfeec99e46ebe85ce27e199e020c5c1ea6f570e302c946", size = 1025190, upload-time = "2026-03-20T14:26:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/39/07/8c50a60f03e095053306fcf57d9d99343bce0e99d5b758bf96de31aec849/fastar-0.9.0-cp314-cp314-win32.whl", hash = "sha256:3f7be0a34ffbead52ab5f4a1e445e488bf39736acb006298d3b3c5b4f2c5915e", size = 452301, upload-time = "2026-03-20T14:26:59.234Z" },
{ url = "https://files.pythonhosted.org/packages/ee/69/aa6d67b09485ba031408296d6ff844c7d83cdcb9f8fcc240422c6f83be87/fastar-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf7f68b98ed34ce628994c9bbd4f56cf6b4b175b3f7b8cbe35c884c8efec0a5b", size = 484948, upload-time = "2026-03-20T14:26:48.45Z" },
{ url = "https://files.pythonhosted.org/packages/20/6d/dba29d87ca929f95a5a7025c7d30720ad8478beed29fff482f29e1e8b045/fastar-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:155dae97aca4b245eabb25e23fd16bfd42a0447f9db7f7789ab1299b02d94487", size = 461170, upload-time = "2026-03-20T14:26:39.191Z" },
{ url = "https://files.pythonhosted.org/packages/96/8f/c3ea0adac50a8037987ee7f15ff94767ebb604faf6008cbd2b8efa46c372/fastar-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a63df018232623e136178953031057c7ac0dbf0acc6f0e8c1dc7dbc19e64c22f", size = 705857, upload-time = "2026-03-20T14:25:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b3/e0e1aad1778065559680a73cdf982ed07b04300c2e5bf778dec8668eda6f/fastar-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6fb44f8675ef87087cb08f9bf4dfa15e818571a5f567ff692f3ea007cff867b5", size = 626210, upload-time = "2026-03-20T14:25:24.361Z" },
{ url = "https://files.pythonhosted.org/packages/94/f3/3c117335cbea26b3bc05382c27e6028278ed048d610b8de427c68f2fec84/fastar-0.9.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81092daa991d0f095424e0e28ed589e03c81a21eeddc9b981184ddda5869bf9d", size = 864879, upload-time = "2026-03-20T14:25:00.131Z" },
{ url = "https://files.pythonhosted.org/packages/26/5d/e8d00ec3b2692d14ea111ddae25bf10e0cb60d5d79915c3d8ea393a87d5c/fastar-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e8793e2618d0d6d5a7762d6007371f57f02544364864e40e6b9d304b0f151b2", size = 759117, upload-time = "2026-03-20T14:23:54.826Z" },
{ url = "https://files.pythonhosted.org/packages/1a/61/6e080fdbc28c72dded8b6ff396035d6dc292f9b1c67b8797ac2372ca5733/fastar-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83f7ef7056791fc95b6afa987238368c9a73ad0edcedc6bc80076f9fbd3a2a78", size = 756527, upload-time = "2026-03-20T14:24:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/e8/97/2cf1a07884d171c028bd4ae5ecf7ded6f31581f79ab26711dcdad0a3d5ab/fastar-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3a456230fcc0e560823f5d04ae8e4c867300d8ee710b14ddcdd1b316ac3dd8d", size = 921763, upload-time = "2026-03-20T14:24:20.787Z" },
{ url = "https://files.pythonhosted.org/packages/f6/e3/c1d698a45f9f5dc892ed7d64badc9c38f1e5c1667048191969c438d2b428/fastar-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a60b117ebadc46c10c87852d2158a4d6489adbfbbec37be036b4cfbeca07b449", size = 815493, upload-time = "2026-03-20T14:24:46.482Z" },
{ url = "https://files.pythonhosted.org/packages/25/38/e124a404043fba75a8cb2f755ca49e4f01e18400bb6607a5f76526e07164/fastar-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6199b4ca0c092a7ae47f5f387492d46a0a2d82cb3b7aa0bf50d7f7d5d8d57f", size = 819166, upload-time = "2026-03-20T14:25:12.027Z" },
{ url = "https://files.pythonhosted.org/packages/85/4a/5b1ea5c8d0dbdfcec2fd1e6a243d6bb5a1c7cd55e132cc532eb8b1cbd6d9/fastar-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:34efe114caf10b4d5ea404069ff1f6cc0e55a708c7091059b0fc087f65c0a331", size = 883618, upload-time = "2026-03-20T14:24:33.552Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/ae46e5722a67a3c2e0ff83d539b0907d6e5092f6395840c0eb6ede81c5d6/fastar-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4d44c1f8d9c5a3e4e58e6ffb77f4ca023ba9d9ddd88e7c613b3419a8feaa3db7", size = 966294, upload-time = "2026-03-20T14:25:50.024Z" },
{ url = "https://files.pythonhosted.org/packages/98/58/b161cf8711f4a50a3e57b6f89bc703c1aed282cad50434b3bc8524738b20/fastar-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d2af970a1f773965b05f1765017a417380ad080ea49590516eb25b23c039158a", size = 1033177, upload-time = "2026-03-20T14:26:02.868Z" },
{ url = "https://files.pythonhosted.org/packages/e2/76/faac7292bce9b30106a6b6a9f5ddb658fdb03abe2644688b82023c8f76b9/fastar-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1675346d7cbdde0d21869c3b597be19b5e31a36442bdf3a48d83a49765b269dc", size = 1073620, upload-time = "2026-03-20T14:26:16.121Z" },
{ url = "https://files.pythonhosted.org/packages/b8/be/dd55ffcc302d6f0ff4aba1616a0da3edc8fcefb757869cad81de74604a35/fastar-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc440daa28591aeb4d387c171e824f179ad2ab256ce7a315472395b8d5f80392", size = 1025147, upload-time = "2026-03-20T14:26:28.767Z" },
{ url = "https://files.pythonhosted.org/packages/4b/c7/080bbb2b3c4e739fe6486fd765a09905f6c16c1068b2fcf2bb51a5e83937/fastar-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:32787880600a988d11547628034993ef948499ae4514a30509817242c4eb98b1", size = 452317, upload-time = "2026-03-20T14:27:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/00553739a7e9e35f78a0c5911d181acf6b6e132337adc9bbc3575f5f6f04/fastar-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92fa18ec4958f33473259980685d29248ac44c96eed34026ad7550f93dd9ee23", size = 483994, upload-time = "2026-03-20T14:26:52.76Z" },
{ url = "https://files.pythonhosted.org/packages/4f/36/a7af08d233624515d9a0f5d41b7a01a51fd825b8c795e41800215a3200e7/fastar-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:34f646ac4f5bed3661a106ca56c1744e7146a02aacf517d47b24fd3f25dc1ff6", size = 460604, upload-time = "2026-03-20T14:26:40.771Z" },
]
[[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.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[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.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-extra-types"
version = "2.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[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.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[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/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.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-toolkit"
version = "0.19.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" },
]
[[package]]
name = "rignore"
version = "0.7.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
{ url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
{ url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
{ url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
{ url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
{ url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
{ url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
{ url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
{ url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
{ url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
{ url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
{ url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
{ url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
{ url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
{ url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.55.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[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.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
version = "0.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
]
[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/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.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[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/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" },
]

39
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

42
frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# three60
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

6
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>three60</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3172
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "three60",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"@types/leaflet.markercluster": "^1.5.6",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/tsconfig": "^0.9.0",
"npm-run-all2": "^8.0.4",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.7",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,27 @@
import { store } from './store'
export const get_request = (url: string) => {
// TODO: Progress bar
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onload = () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText)
resolve(data)
} else {
console.error('Failed to get request data', xhr.status)
reject(xhr.status)
}
}
xhr.onerror = () => {
console.error('Request error')
reject('Network error')
}
xhr.send()
})
}

View File

@@ -0,0 +1,83 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
@font-face {
font-family: 'Departure Mono';
src: url('./fonts/DepartureMono.woff2') format('woff2');
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
'Departure Mono',
'monospace'
;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin-bottom: 20rem;
}

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,87 @@
@import './base.css';
:root {
--theme: rgba(255, 100, 0, 1);
--theme-dim: rgba(255, 100, 0, 0.6);
--theme-faint: rgba(255, 100, 0, 0.3);
--theme-bg: rgba(255, 100, 0, 0.15);
--theme-glow: rgba(255, 100, 0, 0.2);
}
.mode_historical {
--theme: rgba(200, 160, 100, 1);
--theme-dim: rgba(200, 160, 100, 0.6);
--theme-faint: rgba(200, 160, 100, 0.3);
--theme-bg: rgba(200, 160, 100, 0.15);
--theme-glow: rgba(200, 160, 100, 0.2);
}
.mode_frozen {
--theme: rgba(150, 220, 255, 1);
--theme-dim: rgba(150, 220, 255, 0.6);
--theme-faint: rgba(150, 220, 255, 0.3);
--theme-bg: rgba(150, 220, 255, 0.15);
--theme-glow: rgba(150, 220, 255, 0.2);
}
#app {
margin: 0 auto;
padding: 2rem 2rem;
}
.tab_btn {
background: transparent;
border: 1px solid var(--theme-faint);
color: var(--theme-dim);
padding: 0.4rem 1.2rem;
cursor: pointer;
font-family: 'Departure Mono', monospace;
font-size: 0.85rem;
border-radius: 4px;
transition: all 0.2s;
margin-right: 0.5rem;
}
.tab_btn:hover {
border-color: var(--theme);
color: var(--theme);
}
.tab_btn.active {
background-color: var(--theme-bg);
border-color: var(--theme);
color: var(--theme);
box-shadow: 0 0 8px var(--theme-glow);
}
a,
.amber {
text-decoration: none;
color: rgba(255, 100, 0, 1);
transition: 0.4s;
padding: 3px;
}
a {
font-style: italic;
}
a:hover {
color: rgba(180, 70, 0, 1);
}
@media (hover: hover) {
a:hover {
background-color: rgba(180, 70, 0, 1);
color: white;
}
}
@media (min-width: 1024px) {
main {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
}

349
frontend/src/assets/normalize.css vendored Normal file
View File

@@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps<{
data: []
}>()
const assigned = computed(() => props.data.filter(a => a.incident_id != null))
const unassigned = computed(() => props.data.filter(a => a.incident_id == null))
</script>
<template>
<div class="alarm_groups">
<div v-if="unassigned.length > 0" class="alarm_group">
<div class="group_label">UNASSIGNED <span class="group_count">{{ unassigned.length }}</span></div>
<ul class="alarm_list">
<li v-for="item in unassigned" :key="item.id"
class="alarm_item" :class="`sev_${item.severity}`"
@click="router.push(`/cellsites/${item.site_id}`)">
<div class="alarm_left">
<span class="alarm_sev">SEV {{ item.severity }}</span>
<span class="alarm_site">SITE {{ item.site_id }}</span>
</div>
<div class="alarm_body">
<span class="alarm_text">{{ item.text }}</span>
<span class="alarm_time">{{ item.created }}</span>
</div>
</li>
</ul>
</div>
<div v-if="assigned.length > 0" class="alarm_group">
<div class="group_label">ASSIGNED <span class="group_count">{{ assigned.length }}</span></div>
<ul class="alarm_list">
<li v-for="item in assigned" :key="item.id"
class="alarm_item assigned" :class="`sev_${item.severity}`"
@click="router.push(`/cellsites/${item.site_id}`)">
<div class="alarm_left">
<span class="alarm_sev">SEV {{ item.severity }}</span>
<span class="alarm_site">SITE {{ item.site_id }}</span>
</div>
<div class="alarm_body">
<span class="alarm_text">{{ item.text }}</span>
<span class="alarm_time">{{ item.created }}</span>
</div>
<span class="alarm_incident" @click.stop="router.push(`/incidents/${item.incident_id}`)">
INC #{{ item.incident_id }}
</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.alarm_list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.alarm_groups { display: flex; flex-direction: column; gap: 1rem; }
.group_label {
font-size: 0.65rem;
letter-spacing: 0.1em;
color: var(--theme-dim);
margin-bottom: 0.4rem;
}
.group_count {
color: var(--theme);
margin-left: 0.5rem;
}
.alarm_item.assigned {
opacity: 0.6;
}
.alarm_incident {
font-size: 0.7rem;
color: var(--theme-dim);
white-space: nowrap;
transition: color 0.15s;
}
.alarm_incident:hover {
color: var(--theme);
}
.alarm_item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
border-left: 4px solid transparent;
background: rgba(20, 20, 20, 0.9);
border-radius: 3px;
cursor: pointer;
transition: background 0.15s;
}
.alarm_item:hover {
background: rgba(40, 40, 40, 0.9);
}
.alarm_left {
display: flex;
flex-direction: column;
align-items: center;
min-width: 70px;
gap: 0.2rem;
}
.alarm_sev {
font-size: 0.7rem;
font-weight: bold;
letter-spacing: 0.05em;
}
.alarm_site {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.4);
}
.alarm_body {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
}
.alarm_text {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.85);
}
.alarm_time {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.3);
}
/* severity colors */
.sev_1 { border-left-color: rgb(220, 30, 30); }
.sev_1 .alarm_sev { color: rgb(220, 30, 30); box-shadow: -4px 0 8px rgba(220, 30, 30, 0.4); }
.sev_1 { box-shadow: inset -1px 0 0 0 rgba(220,30,30,0.1); }
.sev_2 { border-left-color: rgb(220, 120, 30); }
.sev_2 .alarm_sev { color: rgb(220, 120, 30); }
.sev_3 { border-left-color: rgb(200, 180, 30); }
.sev_3 .alarm_sev { color: rgb(200, 180, 30); }
.sev_4 { border-left-color: rgb(60, 140, 200); }
.sev_4 .alarm_sev { color: rgb(60, 140, 200); }
.sev_5 { border-left-color: rgb(80, 160, 180); }
.sev_5 .alarm_sev { color: rgb(80, 160, 180); }
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { store } from '../store'
import { SEVERITY_COLORS } from '../main'
const props = defineProps<{
count: number
label: string
}>()
</script>
<template>
<div class="filter_row" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }">
<span class="alarm_count" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen, live: store.is_live && !store.is_frozen }">
{{ count }} {{ label }}
</span>
<div class="severity_filters">
<button class="tab_btn" :class="{ active: store.severity_filter === null }" @click="store.severity_filter = null">ALL</button>
<button
v-for="n in [1,2,3,4,5]"
:key="n"
class="tab_btn sev_btn"
:class="{ active: store.severity_filter === n }"
:style="{ '--sev-color': SEVERITY_COLORS[n] }"
@click="store.severity_filter = n"
>{{ n }}</button>
</div>
</div>
</template>
<style scoped>
.filter_row {
position: fixed;
bottom: calc(10vh + 60px);
left: 50%;
transform: translateX(-50%);
width: min(1280px, 100vw - 4rem);
z-index: 1000;
background: rgba(10, 10, 10, 0.85);
padding: 0.5rem 1rem;
border: 1px solid rgba(255, 100, 0, 0.2);
border-radius: 6px;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.filter_row.historical { border-color: rgba(200, 160, 100, 0.2); }
.filter_row.frozen { border-color: rgba(150, 220, 255, 0.2); }
.alarm_count {
font-size: 0.8rem;
/* color: rgba(255, 100, 0, 0.6); */
}
.alarm_count.live { color: rgba(255, 100, 0, 0.6); }
.alarm_count.historical { color: rgba(200, 160, 100, 0.6); }
.alarm_count.frozen { color: rgba(150, 220, 255, 0.6); }
.severity_filters {
display: flex;
gap: 0.3rem;
}
.sev_btn {
border-color: color-mix(in srgb, var(--sev-color) 50%, transparent);
color: color-mix(in srgb, var(--sev-color) 70%, transparent);
}
.sev_btn:hover {
border-color: var(--sev-color);
color: var(--sev-color);
}
.sev_btn.active {
background-color: color-mix(in srgb, var(--sev-color) 20%, transparent);
border-color: var(--sev-color);
color: var(--sev-color);
box-shadow: 0 0 8px color-mix(in srgb, var(--sev-color) 40%, transparent);
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { store } from '../store'
const router = useRouter()
defineProps<{
data: []
}>()
</script>
<template>
<div class="incident_list">
<div
v-for="item in data"
:key="item.id"
class="incident_item"
:class="`sev_${item.severity}`"
@click="router.push(`/incidents/${item.id}`)"
>
<div class="incident_header">
<span
class="incident_site"
@click.stop="router.push(`/cellsites/${item.site_id}`)"
>Site {{ item.site_id }}</span>
<span
class="incident_assignee"
@click.stop="() => { const r = store.robots.find(r => r.name === item.assigned_to); if (r) router.push(`/robots/${r.id}`) }"
>{{ item.assigned_to }}</span>
<span class="incident_status" :class="item.status">{{ item.status }}</span>
</div>
<div class="incident_text">{{ item.text }}</div>
<div class="incident_meta">{{ item.created }}</div>
<div class="incident_meta">{{ item.created_by }}</div>
</div>
<p v-if="data.length === 0">No tickets here... for now.</p>
</div>
</template>
<style scoped>
.incident_list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.incident_item {
background: rgba(20, 20, 20, 0.9);
border-left: 4px solid transparent;
border-radius: 3px;
padding: 0.6rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.incident_item:hover {
background: rgba(40, 40, 40, 0.9);
}
.incident_header {
display: flex;
gap: 1rem;
margin-bottom: 0.3rem;
}
.incident_site {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
border: 1px solid transparent;
border-radius: 3px;
padding: 0 0.3rem;
}
.incident_site:hover {
color: var(--theme, rgba(255, 100, 0, 1));
text-decoration: underline;
text-underline-offset: 3px;
}
.incident_item:hover .incident_site {
border-color: var(--theme, rgba(255, 100, 0, 0.5));
color: var(--theme, rgba(255, 100, 0, 1));
}
.incident_status { margin-left: auto; font-size: 0.75rem; }
.incident_status.closed { color: rgba(100, 200, 100, 0.7); }
.incident_status.active { color: rgba(220, 30, 30, 0.9); }
.incident_text { color: rgba(255, 255, 255, 0.6); margin-bottom: 0.2rem; }
.incident_meta { font-size: 0.75rem; color: rgba(255, 255, 255, 0.3); }
.sev_1 { border-left-color: rgb(220, 30, 30); }
.sev_2 { border-left-color: rgb(220, 120, 30); }
.sev_3 { border-left-color: rgb(200, 180, 30); }
.sev_4 { border-left-color: rgb(60, 140, 200); }
.sev_5 { border-left-color: rgb(80, 160, 180); }
.incident_assignee {
color: rgba(255, 100, 0, 0.7);
cursor: pointer;
border: 1px solid transparent;
border-radius: 3px;
padding: 0 0.3rem;
transition: color 0.15s, border-color 0.15s;
}
.incident_assignee:hover {
color: rgba(255, 255, 255, 1);
text-decoration: underline;
text-underline-offset: 3px;
}
.incident_item:hover .incident_assignee {
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.9);
}
</style>

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { store } from '../store'
import { useRouter } from 'vue-router'
import { SEVERITY_COLORS } from '../main'
import L from 'leaflet'
import 'leaflet.markercluster'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
const router = useRouter()
const props = defineProps<{
center?: [number, number],
zoom?: number,
site_id?: number,
alarms?: Record<string, any>[],
// TODO: Idk if passing map size as props is the best idea. Maybe there's a better way?
map_height?: string,
map_width?: string,
robots?: Record<string, any>[]
}>()
const center = props.center ?? [39.8283, -98.5795]
const zoom = props.zoom ?? 4
const show_alarm_layer = ref(true)
const show_robot_layer = ref(true)
const DEFAULT_COLOR = 'rgba(255, 255, 255, 1)'
const get_alarms_for_site = (site_id: number) =>
show_alarm_layer.value === false ? [] : (props.alarms ?? store.alarms).filter(a => a.site_id === site_id)
const get_marker_color = (site_id: number): string => {
const alarms = get_alarms_for_site(site_id)
if (alarms.length === 0) return DEFAULT_COLOR
const worst = Math.min(...alarms.map(a => a.severity))
return SEVERITY_COLORS[worst] ?? DEFAULT_COLOR
}
const get_site_worst_severity = (site_id: number): number | null => {
const alarms = get_alarms_for_site(site_id)
if (alarms.length === 0) return null
return Math.min(...alarms.map(a => a.severity))
}
const create_icon = (color: string) => L.divIcon({
className: '',
html: `<div class="map_marker" style="--marker-color: ${color}"><span></span></div>`,
iconSize: [28, 40],
iconAnchor: [14, 40],
popupAnchor: [0, -42]
})
const create_robot_icon = (on_call: boolean) => L.divIcon({
className: '',
html: `<div class="robot_marker ${on_call ? 'on_call' : 'idle'}"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -10]
})
onMounted(() => {
const map = L.map('map').setView(center, zoom)
L.tileLayer(
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
{
// attribution: '&copy; OpenStreetMap &copy; CARTO', // Ugly
subdomains: 'abcd',
maxZoom: 19
}
).addTo(map)
const markersLayer = L.markerClusterGroup({
polygonOptions: {
fillColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
weight: 2,
opacity: 0.8,
fillOpacity: 0.2
},
iconCreateFunction: (cluster) => {
const severities = cluster.getAllChildMarkers()
.map(m => (m.options as any).severity)
.filter(s => s !== null)
const worst = severities.length > 0 ? Math.min(...severities) : null
const color = worst !== null ? SEVERITY_COLORS[worst] : 'rgba(255,255,255,0.8)'
return L.divIcon({
html: `<div class="cluster_icon" style="--cluster-color: ${color}">
<span>${cluster.getChildCount()}</span>
</div>`,
className: '',
iconSize: [40, 40]
})
}
}).addTo(map)
const robotsLayer = L.layerGroup().addTo(map)
const update_robot_markers = () => {
robotsLayer.clearLayers()
if (!props.robots || !show_robot_layer.value) return
props.robots.forEach(robot => {
if (robot.lat == null || robot.lon == null) return
L.marker([robot.lat, robot.lon], {
icon: create_robot_icon(!!robot.current_incident_id)
})
.addTo(robotsLayer)
// TODO: Maybe make the entire popup clickable instead of the text itself? Looks odd like this.
.bindPopup(`
<a href="#" data-id="${robot.id}" class="robot-link">${robot.name.toUpperCase()}</a><br/>
${robot.current_incident_id ? `INC #${robot.current_incident_id}` : 'IDLE'}<br/>
${robot.current_site_id ? `SITE ${robot.current_site_id}` : ''}
`)
})
}
const update_markers = () => {
markersLayer.clearLayers()
const bounds = map.getBounds()
const sites = props.site_id != null
? store.cellsites.filter(s => s.id === props.site_id)
: store.cellsites
sites.forEach(site => {
if (site.lat != null && site.lon != null) {
const latLng = L.latLng(site.lat, site.lon)
if (bounds.contains(latLng)) {
const marker = L.marker([site.lat, site.lon], {
icon: create_icon(get_marker_color(site.id)),
severity: get_site_worst_severity(site.id)
})
marker.addTo(markersLayer)
.bindPopup(`<a href="#" data-id="${site.id}" class="marker-link">Site ${site.id}</a>`)
}
}
})
}
map.on('popupopen', (e) => {
const el = e.popup.getElement()
const site_link = el.querySelector('.marker-link')
site_link?.addEventListener('click', (event) => {
event.preventDefault()
router.push(`/cellsites/${site_link.dataset.id}`)
}, { once: true }) // NOTE: To avoid accumulating listeners
const robot_link = el.querySelector('.robot-link')
robot_link?.addEventListener('click', (event) => {
event.preventDefault()
router.push(`/robots/${robot_link.dataset.id}`)
}, { once: true }) // NOTE: To avoid accumulating listeners
})
map.whenReady(() => {
update_markers()
update_robot_markers()
map.on('moveend', update_markers)
map.on('moveend', update_robot_markers)
})
watch(() => store.cellsites, () => update_markers(), { deep: true })
watch(() => props.alarms, () => update_markers(), { deep: true })
watch(show_alarm_layer, () => update_markers())
watch(() => props.robots, () => update_robot_markers(), { deep: true })
watch(show_robot_layer, () => update_robot_markers())
watch(() => props.center, (newCenter) => {
if (newCenter) map.setView(newCenter, map.getZoom())
})
})
</script>
<template>
<div style="position: relative;">
<div class="map_wrapper">
<div class="layer_panel">
<div class="layer_title">LAYERS</div>
<label class="layer_item">
<input type="checkbox" v-model="show_alarm_layer" />
<span>Alarms</span>
</label>
<label class="layer_item">
<input type="checkbox" v-model="show_robot_layer" />
<span>Robots</span>
</label>
</div>
<div id="map" :style="{ height: props.map_height ?? '500px', width: props.map_width ?? '100%' }"></div>
</div>
</div>
</template>
<!-- NOTE: style must be global for the leaflet map to pick it up (reminder: vue adds data attribute to the scoped styles) -->
<style >
.leaflet-container {
font-family: 'Departure Mono', monospace !important;
}
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large {
background-color: rgba(255,255,255,0.2)
}
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div {
background-color: rgba(255,255,255,0.8);
color: #000;
}
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
background-color: rgba(20, 20, 20, 1) !important;
color: var(--theme, rgba(255, 100, 0, 1)) !important;
border: 1px solid var(--theme-faint, rgba(255, 100, 0, 0.4)) !important;
font-family: 'Departure Mono', monospace !important;
transition: all 0.2s;
}
.leaflet-control-zoom-in:hover,
.leaflet-control-zoom-out:hover {
background-color: var(--theme-bg, rgba(255, 100, 0, 0.15)) !important;
color: var(--theme, rgba(255, 100, 0, 1)) !important;
border-color: var(--theme-dim, rgba(255, 100, 0, 0.8)) !important;
}
.leaflet-control-zoom {
border: 1px solid var(--theme-faint, rgba(255, 100, 0, 0.4)) !important;
border-radius: 4px !important;
overflow: hidden;
}
.layer_panel {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1000;
background: rgba(20, 20, 20, 1);
border: 1px solid var(--theme-faint, rgba(255, 100, 0, 0.4));
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
backdrop-filter: blur(4px);
}
.layer_title {
color: var(--theme-dim, rgba(255, 100, 0, 0.6));
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
}
.layer_item {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--theme, rgba(255, 100, 0, 1));
cursor: pointer;
}
.layer_item input[type="checkbox"] {
accent-color: var(--theme, rgba(255, 100, 0, 1));
}
.map_marker {
position: relative;
width: 28px;
height: 40px;
}
/* tower body */
.map_marker::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 3px;
height: 28px;
background-color: var(--marker-color);
}
/* signal arc outer */
.map_marker::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 16px;
border-radius: 24px 24px 0 0;
border: 3px solid var(--marker-color);
border-bottom: none;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
}
.map_marker span {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 8px;
border-radius: 12px 12px 0 0;
border: 3px solid rgba(255, 255, 255, 0.6);
border-bottom: none;
}
.map_wrapper {
position: relative;
}
.map_wrapper::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 400;
background: transparent;
transition: background 0.4s;
}
.robot_marker {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.9);
background: rgba(40, 40, 40, 0.9);
box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);
}
.robot_marker.on_call {
border-color: var(--theme, rgba(255, 100, 0, 1));
background: rgba(255, 100, 0, 0.2);
box-shadow: 0 0 8px rgba(255, 100, 0, 0.6);
}
.mode_historical .map_wrapper::after {
background: rgba(200, 160, 100, 0.06);
}
.mode_frozen .map_wrapper::after {
background: rgba(150, 220, 255, 0.06);
}
.leaflet-popup-content-wrapper {
background-color: #2a2a2a;
color: rgba(255, 100, 0, 1);
border: 1px solid rgba(255, 100, 0, 0.4);
box-shadow: 0 0 10px rgba(255, 100, 0, 0.2);
}
.leaflet-popup-tip {
background-color: #2a2a2a;
}
.leaflet-popup-content a {
color: rgba(255, 100, 0, 1);
}
.leaflet-control-attribution {
display: none;
}
.cluster_icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--cluster-color) 30%, transparent);
border: 2px solid var(--cluster-color);
display: flex;
align-items: center;
justify-content: center;
}
.cluster_icon span {
color: #000;
/* NOTE: Need the font here */
font-family: 'Departure Mono', monospace;
font-size: 0.75rem;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
import SimulationBanner from './SimulationBanner.vue'
const searchQuery = ref('')
const popupOpen = ref(false)
// TODO: Add search for site w/ modal popup
const togglePopup = () => {
popupOpen.value = !popupOpen.value
}
</script>
<template>
<SimulationBanner />
<nav>
<ul>
<li class="nav-back" :style="{ visibility: route.path !== '/' ? 'visible' : 'hidden' }">
<button @click="router.back()"> back</button>
</li>
<li class="nav-center">
<a href="/">three60</a>
</li>
</ul>
</nav>
</template>
<style scoped>
nav {
background: #333;
color: #fff;
padding: 0.5rem 1rem;
margin: 0 0 1rem 0;
}
nav ul {
display: flex;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
.nav-back {
flex: 1;
}
nav ul::after {
content: '';
flex: 1;
}
.nav-center a {
color: var(--theme);
text-decoration: none;
transition: color 0.3s ease;
}
.nav-back button {
background: transparent;
border: none;
color: var(--theme-dim, rgba(255, 100, 0, 0.6));
font-size: 0.8rem;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.nav-back button:hover {
color: var(--theme, rgba(255, 100, 0, 1));
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { get_request } from '../ajax_requests'
import { store } from '../store'
const stats = ref(null)
const countdown_alarm = ref(0)
const countdown_incident = ref(0)
let poll_interval: ReturnType<typeof setInterval> | null = null
let tick_interval: ReturnType<typeof setInterval> | null = null
// FIXME: I don't know if i need this type of check in two spots? I also check in the timeline intervals.
// Possibly worth having in case I add a "non simulation mode"?
const fetch_status = async () => {
try {
const data = await get_request('/api/v1/simulator/status')
stats.value = data
countdown_alarm.value = Math.max(0, Math.round(data.next_alarm_cleanup_at - Date.now() / 1000))
countdown_incident.value = Math.max(0, Math.round(data.next_incident_cleanup_at - Date.now() / 1000))
if (store.server_unreachable) {
store.server_unreachable = false
store.unfreeze()
}
} catch (e) {
console.error('SimulationBanner fetch failed:', e)
store.server_unreachable = true
store.freeze()
if (poll_interval) { clearInterval(poll_interval); poll_interval = null }
if (tick_interval) { clearInterval(tick_interval); tick_interval = null }
if (!poll_interval) {
poll_interval = setInterval(fetch_status, 10000)
}
}
}
const fmt = (s: number) => {
const m = Math.floor(s / 60)
const sec = s % 60
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
}
onMounted(async () => {
await fetch_status()
poll_interval = setInterval(fetch_status, 30000)
tick_interval = setInterval(() => {
if (countdown_alarm.value > 0) countdown_alarm.value--
if (countdown_incident.value > 0) countdown_incident.value--
if (countdown_alarm.value === 0 || countdown_incident.value === 0) fetch_status()
}, 1000)
})
onUnmounted(() => {
if (poll_interval) clearInterval(poll_interval)
if (tick_interval) clearInterval(tick_interval)
})
</script>
<template>
<div v-if="store.server_unreachable" class="unreachable_banner">
SERVER UNREACHABLE polling paused
</div>
<div class="sim_banner" v-if="stats">
<span class="sim_dot"></span>
<span class="sim_label">SIMULATION ACTIVE</span>
<span class="sim_stat">{{ stats.alarm_count }} alarms</span>
<span class="sim_divider">|</span>
<span class="sim_stat">{{ stats.incident_count }} incidents</span>
<span class="sim_divider">|</span>
<span class="sim_stat">alarm reset in <span class="sim_countdown">{{ fmt(countdown_alarm) }}</span></span>
<span class="sim_divider">|</span>
<span class="sim_stat">incident reset in <span class="sim_countdown">{{ fmt(countdown_incident) }}</span></span>
</div>
</template>
<style scoped>
.sim_banner {
width: 100%;
background: rgba(10, 10, 10, 0.9);
border-bottom: 1px solid rgba(255, 100, 0, 0.15);
padding: 0.3rem 2rem;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 1rem;
}
.sim_dot {
color: rgba(100, 220, 100, 0.8);
font-size: 0.6rem;
}
.sim_label {
color: rgba(100, 220, 100, 0.6);
letter-spacing: 0.08em;
}
.sim_divider {
opacity: 0.2;
}
.sim_countdown {
color: rgba(255, 255, 255, 0.6);
}
.unreachable_banner {
width: 100%;
background: rgba(220, 30, 30, 0.15);
border-bottom: 1px solid rgba(220, 30, 30, 0.4);
padding: 0.3rem 2rem;
font-size: 0.72rem;
color: rgba(220, 30, 30, 0.9);
text-align: center;
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
defineProps<{
data: []
link_prefix?: string
}>()
</script>
<template>
<div class="table_wrapper">
<table v-if="data.length > 0">
<thead>
<tr>
<th v-for="key in Object.keys(data[0])" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in data" :key="index">
<td v-for="key in Object.keys(data[0])" :key="key">
<a
v-if="key === 'id' && link_prefix"
href="#"
@click.prevent="router.push(`${link_prefix}/${row[key]}`)"
>
{{ row[key] }}
</a>
<span v-else>{{ row[key] }}</span>
</td>
</tr>
</tbody>
</table>
<p v-else>No data.</p>
</div>
</template>
<style scoped>
.table_wrapper {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
min-width: 600px;
}
thead {
border-bottom: 2px solid var(--theme-dim);
}
th {
text-align: left;
padding: 0.75rem 1rem;
color: var(--theme);
font-weight: normal;
white-space: nowrap;
}
td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--theme-faint);
white-space: nowrap;
}
tbody tr:hover {
background-color: var(--theme-bg);
}
a {
color: var(--theme);
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: white;
}
@media (max-width: 768px) {
th, td {
padding: 0.5rem 0.6rem;
font-size: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import { store } from '../store'
const emit = defineEmits<{
refresh: []
freeze: []
snap_to_live: []
}>()
</script>
<template>
<div class="timeline_row" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }">
<!-- NOTE: @input="store.timeline_position = Number(($event.target as HTMLInputElement).value)" is used for timeline position scrolling + link w/ map and data filters, it fires after a brief debounce -->
<input
type="range"
class="timeline_slider"
:min="store.timeline_min"
:max="store.timeline_max"
:value="store.timeline_position"
@input="store.timeline_position = Number(($event.target as HTMLInputElement).value)"
@change="store.is_live ? store.timeline_position = store.timeline_max : null"
:disabled="store.is_frozen"
:style="{ '--slider-color': store.is_frozen ? 'rgba(150, 220, 255, 1)' : store.is_historical ? 'rgba(200, 160, 100, 1)' : 'rgba(255, 100, 0, 1)' }"
/>
<div class="interval_select" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }">
<button class="tab_btn" :class="{ active: store.refresh_interval_seconds === 5 }" :disabled="store.is_frozen || store.is_historical" @click="store.refresh_interval_seconds = 5">5s</button>
<button class="tab_btn" :class="{ active: store.refresh_interval_seconds === 30 }" :disabled="store.is_frozen || store.is_historical" @click="store.refresh_interval_seconds = 30">30s</button>
<button class="tab_btn" :class="{ active: store.refresh_interval_seconds === 60 }" :disabled="store.is_frozen || store.is_historical" @click="store.refresh_interval_seconds = 60">60s</button>
</div>
<span class="timeline_label"
:class="{ live: store.is_live && !store.is_frozen, frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }"
@click="store.is_frozen ? emit('snap_to_live') : store.is_live ? emit('freeze') : emit('snap_to_live')"
>
{{ store.is_frozen ? 'FROZEN' : store.is_live ? `LIVE ${store.live_countdown}s` : new Date(store.timeline_position).toLocaleString() }}
<span v-if="store.is_frozen && store.frozen_at" class="frozen_since">
since {{ new Date(store.frozen_at).toLocaleTimeString() }}
</span>
<span class="live_hint">{{ store.is_frozen ? 'LIVE' : store.is_live ? 'FREEZE' : 'LIVE' }}</span>
</span>
<button class="tab_btn refresh_btn" @click="emit('refresh')"></button>
</div>
</template>
<style scoped>
.timeline_row {
position: fixed;
bottom: 10vh;
left: 50%;
transform: translateX(-50%);
width: min(1280px, 100vw - 4rem);
z-index: 1000;
background: rgba(10, 10, 10, 0.85);
padding: 0.6rem 1rem;
border: 1px solid rgba(255, 100, 0, 0.2);
border-radius: 6px;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 1rem;
}
.frozen_since {
display: block;
font-size: 0.65rem;
opacity: 0.6;
margin-top: 0.1rem;
}
.timeline_row.historical { border-color: rgba(200, 160, 100, 0.2); }
.timeline_row.frozen { border-color: rgba(150, 220, 255, 0.2); }
.timeline_slider {
flex: 1;
accent-color: var(--slider-color, rgba(255, 100, 0, 1));
cursor: pointer;
}
.timeline_slider:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.interval_select {
display: flex;
gap: 0.3rem;
}
.interval_select.frozen .tab_btn,
.interval_select.historical .tab_btn {
cursor: not-allowed;
pointer-events: none;
}
.interval_select.frozen .tab_btn {
border-color: rgba(150, 220, 255, 0.3);
color: rgba(150, 220, 255, 0.4);
}
.interval_select.frozen .tab_btn.active {
background-color: rgba(150, 220, 255, 0.08);
border-color: rgba(150, 220, 255, 0.5);
color: rgba(150, 220, 255, 0.6);
box-shadow: 0 0 6px rgba(150, 220, 255, 0.2);
}
.interval_select.historical .tab_btn {
border-color: rgba(200, 160, 100, 0.3);
color: rgba(200, 160, 100, 0.4);
}
.interval_select.historical .tab_btn.active {
background-color: rgba(200, 160, 100, 0.08);
border-color: rgba(200, 160, 100, 0.5);
color: rgba(200, 160, 100, 0.6);
box-shadow: 0 0 6px rgba(200, 160, 100, 0.2);
}
.timeline_label {
font-size: 0.8rem;
color: rgba(255, 100, 0, 0.6);
white-space: nowrap;
min-width: 160px;
min-height: 2.4rem;
text-align: center;
position: relative;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.timeline_label.live {
color: rgba(255, 100, 0, 1);
text-shadow: 0 0 6px rgba(255, 100, 0, 0.6);
}
.timeline_label.frozen {
color: rgba(150, 220, 255, 1);
text-shadow: 0 0 8px rgba(150, 220, 255, 0.7), 0 0 20px rgba(100, 180, 255, 0.3);
}
.timeline_label.historical {
color: rgba(200, 160, 100, 0.8);
}
.live_hint {
opacity: 0;
pointer-events: none;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease;
}
.timeline_label:not(.live):not(.frozen):hover .live_hint {
opacity: 1;
color: rgba(255, 255, 255, 0.25);
}
.timeline_label:not(.live):not(.frozen):hover {
display: flex;
color: transparent;
}
.timeline_label.live:hover .live_hint {
opacity: 1;
color: rgba(150, 220, 255, 0.6);
}
.timeline_label.live:hover {
color: transparent;
text-shadow: none;
}
.timeline_label.frozen:hover .live_hint {
opacity: 1;
color: rgba(255, 100, 0, 0.8);
}
.timeline_label.frozen:hover {
color: transparent;
text-shadow: none;
}
.refresh_btn {
padding: 0.4rem 0.6rem;
font-size: 1rem;
line-height: 1;
}
.refresh_btn:hover {
color: rgba(255, 100, 0, 1);
border-color: rgba(255, 100, 0, 1);
box-shadow: 0 0 6px rgba(255, 100, 0, 0.4);
}
</style>

View File

@@ -0,0 +1,39 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import { store } from '../store'
// Common filters, refactored for reuse.
export function use_filtered_data(
is_live_debounced: Ref<boolean>,
timeline_position_debounced: Ref<number>
) {
const visible_alarms = computed(() => {
if (is_live_debounced.value) return store.alarms
const cutoff = timeline_position_debounced.value / 1000
return store.alarms.filter(a => new Date(a.created + ' UTC').getTime() / 1000 <= cutoff)
})
const visible_incidents = computed(() => {
if (is_live_debounced.value) return store.incidents
const cutoff = timeline_position_debounced.value / 1000
return store.incidents.filter(a => new Date(a.created + ' UTC').getTime() / 1000 <= cutoff)
})
const filtered_alarms = computed(() => {
const sorted = [...visible_alarms.value].sort((a, b) =>
new Date(b.created + ' UTC').getTime() - new Date(a.created + ' UTC').getTime()
)
if (store.severity_filter === null) return sorted
return sorted.filter(a => a.severity === store.severity_filter)
})
const filtered_incidents = computed(() => {
const sorted = [...visible_incidents.value].sort((a, b) =>
new Date(b.created + ' UTC').getTime() - new Date(a.created + ' UTC').getTime()
)
if (store.severity_filter === null) return sorted
return sorted.filter(a => a.severity === store.severity_filter)
})
return { visible_alarms, visible_incidents, filtered_alarms, filtered_incidents }
}

View File

@@ -0,0 +1,107 @@
import { ref, computed, watch, onUnmounted } from 'vue'
import { nextTick } from 'vue'
import { store } from '../store'
export function use_timeline(fetch_fn: (before?: number) => Promise<void>) {
let live_interval: ReturnType<typeof setInterval> | null = null
let countdown_interval: ReturnType<typeof setInterval> | null = null
let debounce_timer: ReturnType<typeof setTimeout> | null = null
const MAX_FAILURES = 3
const failure_count = ref(0)
const timeline_position_debounced = ref(store.timeline_position)
const is_live_debounced = computed(() =>
timeline_position_debounced.value >= store.timeline_max - 2000
)
const manual_refresh = async () => {
store.unfreeze()
await fetch_fn()
await nextTick()
store.timeline_position = store.timeline_max
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
store.live_countdown = store.refresh_interval_seconds
live_interval = setInterval(async () => {
await safe_fetch()
store.live_countdown = store.refresh_interval_seconds
}, store.refresh_interval_seconds * 1000)
countdown_interval = setInterval(() => {
if (store.live_countdown > 0) store.live_countdown--
}, 1000)
}
const freeze = () => store.freeze()
const snap_to_live = async () => {
store.unfreeze()
await fetch_fn()
await nextTick()
store.timeline_position = store.timeline_max
}
// NOTE: This is in place to avoid the intervals breaking on server issues and constantly polling the backend.
const safe_fetch = async () => {
try {
await fetch_fn()
failure_count.value = 0
} catch (e) {
failure_count.value++
console.error(`Fetch failed (${failure_count.value}/${MAX_FAILURES})`, e)
if (failure_count.value >= MAX_FAILURES) {
store.freeze()
store.server_unreachable = true
if (live_interval) { clearInterval(live_interval); live_interval = null }
if (countdown_interval) { clearInterval(countdown_interval); countdown_interval = null }
}
}
}
watch(() => store.is_live, (live) => {
if (live && !store.is_frozen) {
store.live_countdown = store.refresh_interval_seconds
live_interval = setInterval(async () => {
await safe_fetch()
store.live_countdown = store.refresh_interval_seconds
}, store.refresh_interval_seconds * 1000)
countdown_interval = setInterval(() => {
if (store.live_countdown > 0) store.live_countdown--
}, 1000)
} else {
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
store.live_countdown = store.refresh_interval_seconds
}
}, { immediate: true })
watch(() => store.refresh_interval_seconds, () => {
if (store.is_live) {
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
store.live_countdown = store.refresh_interval_seconds
live_interval = setInterval(async () => {
await safe_fetch()
store.live_countdown = store.refresh_interval_seconds
}, store.refresh_interval_seconds * 1000)
countdown_interval = setInterval(() => {
if (store.live_countdown > 0) store.live_countdown--
}, 1000)
}
})
watch(() => store.timeline_position, (val) => {
if (debounce_timer) clearTimeout(debounce_timer)
debounce_timer = setTimeout(() => {
timeline_position_debounced.value = val
}, 150)
})
onUnmounted(() => {
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
if (debounce_timer) clearTimeout(debounce_timer)
})
return { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live }
}

59
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,59 @@
import './assets/normalize.css'
import './assets/main.css'
import 'leaflet/dist/leaflet.css'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
export const BASE_URL = '/api/v1'
export const ALARMS_ENDPOINT = `${BASE_URL}/alarms`
export const ROBOTS_ENDPOINT = `${BASE_URL}/robots`
export const CELLSITES_ENDPOINT = `${BASE_URL}/cellsites`
export const INCIDENTS_ENDPOINT = `${BASE_URL}/incidents`
export const SEVERITY_COLORS: Record<number, string> = {
1: 'rgb(220, 30, 30)',
2: 'rgb(220, 120, 30)',
3: 'rgb(200, 180, 30)',
4: 'rgb(60, 140, 200)',
5: 'rgb(80, 160, 180)',
}
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('./pages/Home.vue'),
// meta: { requiresAuth: true }
},
// {
// path: '/login',
// // component: () => import ('./pages/Login.vue')
// },
{
path: '/cellsites/:id',
component: () => import('./pages/Cellsite.vue')
// meta: { requiresAuth: true }
},
{ path: '/incidents/:id',
component: () => import('./pages/Incident.vue')
},
{ path: '/robots/:id',
component: () => import('./pages/Robots.vue')
}
]
})
// FIXME: Disabling for demo, not hoooked up anyway
// router.beforeEach((to, from, next) => {
// const isAuthenticated = !!localStorage.getItem('token')
// if (to.meta.requiresAuth && !isAuthenticated) {
// next('/login')
// } else {
// next()
// }
// })
import App from './App.vue'
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { store } from '../store'
import { get_request } from '../ajax_requests'
import { ALARMS_ENDPOINT, CELLSITES_ENDPOINT, INCIDENTS_ENDPOINT, ROBOTS_ENDPOINT } from '../main'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import AlarmsCard from '../components/AlarmsCard.vue'
import IncidentCard from '../components/IncidentCard.vue'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
const active_tab = computed({
get: () => (store.detail_tabs[`cellsite_${id}`] ?? 'alarms') as 'alarms' | 'incidents',
set: (val) => { store.detail_tabs[`cellsite_${id}`] = val }
})
const route = useRoute()
const loading = ref(true)
const id = Number(route.params.id)
// FIXME: Hardcoding stat fields for now. They probably won't change from opencellid
const stats_fields = computed(() => [
['RADIO', site.value?.radio],
['MCC', site.value?.mcc],
['NET', site.value?.net],
['AREA', site.value?.area],
['CELL', site.value?.cell],
['UNIT', site.value?.unit],
['LAT', site.value?.lat],
['LON', site.value?.lon],
['RANGE', site.value?.range],
['SAMPLES', site.value?.samples],
['SIGNAL', site.value?.averageSignal],
['CREATED', site.value?.created],
['UPDATED', site.value?.updated],
])
const site = computed(() =>
store.cellsites.find(s => s.id === id)
)
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const alarm_url = before ? `${ALARMS_ENDPOINT}?id=${id}&before=${before}` : `${ALARMS_ENDPOINT}?id=${id}`
const incident_url = before ? `${INCIDENTS_ENDPOINT}?id=${id}&before=${before}` : `${INCIDENTS_ENDPOINT}?id=${id}`
const [alarms, incidents, robots] = await Promise.all([
get_request(alarm_url),
get_request(incident_url),
get_request(ROBOTS_ENDPOINT),
])
store.set_alarms(alarms)
store.set_incidents(incidents)
store.set_robots(robots)
if (before === undefined && !store.is_frozen && was_live) {
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { visible_alarms, visible_incidents, filtered_alarms, filtered_incidents } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
console.log(store.is_live)
const site_exists = store.cellsites.some(s => s.id === id)
if (!site_exists) {
const data = await get_request(`${CELLSITES_ENDPOINT}/${id}`)
store.set_cellsites(data)
}
// Incase you refresh or nav to the page directly.
try {
await fetch_all()
const all = [...store.alarms, ...store.incidents]
if (all.length > 0) {
const oldest = Math.min(...all.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0 ? oldest : Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
} catch (error) {
console.error('Error fetching data:', error)
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div class="site_container">
<div v-if="loading" class="loading-overlay">
<p>Loading...</p>
</div>
<div v-else-if="!site" class="not-found">
<p>Not found</p>
</div>
<div v-else class="site_content">
<div class="stats">
<div class="site_id">SITE {{ site.id }}</div>
<div class="stat_row" v-for="[key, val] in stats_fields" :key="key">
<span class="stat_key">{{ key }}</span>
<span class="stat_val">{{ val }}</span>
</div>
</div>
<!-- FIXME: Map container leftover from previous version. Map can take height props. Refactor this. -->
<div class="map_container">
<Map
v-if="site.lat != null && site.lon != null"
:center="[site.lat, site.lon]"
:zoom="12"
:site_id="site.id"
:robots="store.robots"
/>
</div>
<div class="data">
<div class="tabs">
<button class="tab_btn" :class="{ active: active_tab === 'alarms' }" @click="active_tab = 'alarms'">
Alarms <span class="tab_count">{{ filtered_alarms.length }}</span>
</button>
<button class="tab_btn" :class="{ active: active_tab === 'incidents' }" @click="active_tab = 'incidents'">
Incidents <span class="tab_count">{{ filtered_incidents.length }}</span>
</button>
</div>
<div class="data_scroll">
<AlarmsCard v-if="active_tab === 'alarms' && filtered_alarms.length > 0" :data="filtered_alarms" />
<p v-else-if="active_tab === 'alarms'">All quiet here... too quiet...</p>
<IncidentCard v-if="active_tab === 'incidents'" :data="filtered_incidents" />
</div>
</div>
</div>
<FilterRow
:count="active_tab === 'incidents' ? filtered_incidents.length : filtered_alarms.length"
:label="active_tab === 'incidents' ? 'tickets' : 'alarms'"
/>
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</template>
<style scoped>
.site_container {
position: relative;
padding: 1rem;
}
.site_content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"stats map"
"data data";
gap: 1rem;
align-items: start;
}
.site_id {
font-size: 1.4rem;
color: var(--theme, rgba(255, 100, 0, 1));
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.stat_row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--theme-faint);
font-size: 0.8rem;
}
.stat_key {
color: var(--theme-dim);
}
.stat_val {
color: rgba(255, 255, 255, 0.8);
}
.stats {
grid-area: stats;
}
.map_container {
grid-area: map;
min-height: 400px;
}
.data {
grid-area: data;
margin-top: 1rem;
}
.tabs {
margin-bottom: 1rem;
}
.loading-overlay,
.not-found {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10;
font-size: 1.5rem;
text-align: center;
color: rgba(255, 100, 0, 0.8);
}
@media (max-width: 768px) {
.site_content {
grid-template-columns: 1fr;
grid-template-areas:
"stats"
"map"
"data";
}
.map_container {
height: 300px;
}
}
</style>

193
frontend/src/pages/Home.vue Normal file
View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { store } from '../store'
import { get_request } from '../ajax_requests'
import { ALARMS_ENDPOINT, CELLSITES_ENDPOINT, INCIDENTS_ENDPOINT, ROBOTS_ENDPOINT } from '../main'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import Table from '../components/Table.vue'
import AlarmsCard from '../components/AlarmsCard.vue'
import IncidentCard from '../components/IncidentCard.vue'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
// TODO: Add search, sort, filter on alarms + sites
const loading = ref(true)
const active_tab = computed({
get: () => (store.detail_tabs['home'] ?? 'map') as 'map' | 'alarms' | 'incidents' | 'sites' | 'robots',
set: (val) => { store.detail_tabs['home'] = val }
})
// const severity_filter = ref<number | null>(null) // NOTE: Null here means show all severities.
const status_filter = ref<string | null>(null)
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const alarm_url = before ? `${ALARMS_ENDPOINT}?before=${before}` : `${ALARMS_ENDPOINT}`
const incident_url = before ? `${INCIDENTS_ENDPOINT}?before=${before}` : `${INCIDENTS_ENDPOINT}`
const robot_url = `${ROBOTS_ENDPOINT}`
const [alarms, incidents, robots] = await Promise.all([
get_request(alarm_url),
get_request(incident_url),
get_request(robot_url),
])
store.set_alarms(alarms)
store.set_incidents(incidents)
store.set_robots(robots)
if (before === undefined && !store.is_frozen && was_live) {
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { visible_alarms, visible_incidents, filtered_alarms, filtered_incidents } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
// store.unfreeze() // TODO: EXPERIMENT: My current idea is that FREEZING should freeze the entire app. So a user can investigate at a point in time throughout. A full page reload will reset this.
try {
const [sites, robots] = await Promise.all([
get_request(`${CELLSITES_ENDPOINT}`),
get_request(`${ROBOTS_ENDPOINT}`),
])
store.set_cellsites(sites)
store.set_robots(robots)
await fetch_all()
const all = [...store.alarms, ...store.incidents]
if (all.length > 0) {
const oldest = Math.min(...all.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0
? oldest
: Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
} catch (error) {
console.error('Error on initial load:', error)
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div v-if="loading">
<p>loading...</p>
</div>
<div v-else>
<div class="tabs">
<button
@click="active_tab = 'map'"
:class="{ active: active_tab === 'map' }"
class="tab_btn"
>Map
</button>
<button
@click="active_tab = 'alarms'"
:class="{ active: active_tab === 'alarms' }"
class="tab_btn"
>Alarms
</button>
<button
@click="active_tab = 'incidents'"
:class="{ active: active_tab === 'incidents' }"
class="tab_btn"
>Incident Tickets
</button>
<button
@click="active_tab = 'sites'"
:class="{ active: active_tab === 'sites' }"
class="tab_btn"
>Sites
</button>
<button
@click="active_tab = 'robots'"
:class="{ active: active_tab === 'robots' }"
class="tab_btn"
>Robots
</button>
</div>
<div class="content_frame">
<div v-if="active_tab === 'map'">
<Map :alarms="filtered_alarms" :robots="store.robots" map_height="80vh" />
</div>
<div v-if="active_tab === 'alarms'">
<div class="alarm_scroll">
<AlarmsCard v-if="filtered_alarms.length > 0" :data="filtered_alarms" />
<p v-else>All quiet in here... too quiet...</p>
</div>
</div>
<div v-if="active_tab === 'incidents'">
<div class="alarm_toolbar">
<div class="severity_filters">
<button class="tab_btn" :class="{ active: status_filter === null }" @click="status_filter = null">ALL</button>
<button class="tab_btn" :class="{ active: status_filter === 'active' }" @click="status_filter = 'active'">ACTIVE</button>
<button class="tab_btn" :class="{ active: status_filter === 'closed' }" @click="status_filter = 'closed'">CLOSED</button>
</div>
</div>
<div class="alarm_scroll">
<IncidentCard :data="filtered_incidents" />
</div>
</div>
<div v-if="active_tab === 'sites'">
<div class="alarm_scroll">
<Table :data="store.cellsites" link_prefix="/cellsites" />
</div>
</div>
<div v-if="active_tab === 'robots'">
<div class="alarm_scroll">
<Table :data="store.robots" link_prefix="/robots" />
</div>
</div>
<FilterRow
:count="active_tab === 'incidents' ? filtered_incidents.length : active_tab === 'robots' ? store.robots.length : filtered_alarms.length"
:label="active_tab === 'incidents' ? 'tickets' : active_tab === 'robots' ? 'robots' : 'alarms'"
/>
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</div>
</template>
<style scoped>
.content_frame {
border: 1px solid var(--theme-faint);
border-radius: 6px;
padding: 1rem;
transition: border-color 0.4s, box-shadow 0.4s;
box-shadow: 0 0 12px var(--theme-glow);
}
#map, .tabs {
margin-bottom: 2rem;
}
.alarm_toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.alarm_scroll {
height: 60vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { store } from '../store'
import { ALARMS_ENDPOINT, CELLSITES_ENDPOINT, INCIDENTS_ENDPOINT, ROBOTS_ENDPOINT } from '../main'
import { get_request } from '../ajax_requests'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
import Table from '../components/Table.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const id = Number(route.params.id)
const incident = ref(null)
const associated_site = computed(() =>
store.cellsites.find(s => s.id === incident.value?.site_id)
)
const stats_fields = computed(() => [
['ID', incident.value?.id],
['STATUS', incident.value?.status],
['SEVERITY', incident.value?.severity],
['ASSIGNED TO', incident.value?.assigned_to],
['SITE', incident.value?.site_id],
['CREATED', incident.value?.created],
['UPDATED', incident.value?.updated],
['TEXT', incident.value?.text],
])
// TODO: Maybe instead of only fetching the specific site, we also fetch the other sites IF the user scrolls the map?
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const site_id = incident.value?.site_id
if (!site_id) return
const alarm_url = before
? `${ALARMS_ENDPOINT}?id=${site_id}&before=${before}`
: `${ALARMS_ENDPOINT}?id=${site_id}`
const [alarms, robots] = await Promise.all([
get_request(alarm_url),
get_request(ROBOTS_ENDPOINT),
])
store.set_alarms(alarms)
store.set_robots(robots)
if (before === undefined && !store.is_frozen && was_live) {
// FIXME: repeated again
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { visible_alarms, filtered_alarms } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
try {
incident.value = await get_request(`${INCIDENTS_ENDPOINT}/${id}`)
await fetch_all()
const all = store.alarms
if (all.length > 0) {
const oldest = Math.min(...all.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0 ? oldest : Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
const site_exists = store.cellsites.some(s => s.id === incident.value?.site_id)
if (!site_exists && incident.value?.site_id) {
const data = await get_request(`${CELLSITES_ENDPOINT}/${incident.value.site_id}`)
store.set_cellsites(data)
}
} catch (error) {
console.error('Error fetching incident:', error)
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div class="site_container">
<div v-if="loading" class="loading-overlay">
<p>Loading...</p>
</div>
<div v-else-if="!incident" class="not-found">
<p>Not found</p>
</div>
<div v-else class="site_content">
<div class="stats">
<div class="site_id">INCIDENT {{ incident.id }}</div>
<div class="stat_row" v-for="[key, val] in stats_fields" :key="key">
<span class="stat_key">{{ key }}</span>
<span class="stat_val" :class="key === 'STATUS' ? incident.status : ''">{{ val }}</span>
</div>
</div>
<div class="map_container">
<Map
v-if="associated_site?.lat != null && associated_site?.lon != null"
:center="[associated_site.lat, associated_site.lon]"
:zoom="12"
:site_id="incident.site_id"
:robots="store.robots"
/>
</div>
<div class="data">
<div class="section_label">ASSOCIATED SITES</div>
<div class="site_list">
<div v-if="associated_site" class="site_link" @click="router.push(`/cellsites/${associated_site.id}`)">
<span class="site_link_id">SITE {{ associated_site.id }}</span>
<span class="site_link_meta">{{ associated_site.radio }} · {{ associated_site.lat }}, {{ associated_site.lon }}</span>
<span class="site_link_arrow"></span>
</div>
<p v-else>No associated site found.</p>
</div>
</div>
</div>
<FilterRow :count="filtered_alarms.length" label="alarms" />
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</template>
<style scoped>
.site_container {
position: relative;
padding: 1rem;
}
.site_content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"stats map"
"data data";
gap: 1rem;
align-items: start;
}
.site_id {
font-size: 1.4rem;
color: var(--theme, rgba(255, 100, 0, 1));
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.stat_row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--theme-faint);
font-size: 0.8rem;
}
.stat_key {
color: var(--theme-dim);
}
.stat_val {
color: rgba(255, 255, 255, 0.8);
}
.stat_val.active { color: rgba(220, 30, 30, 0.9); }
.stat_val.closed { color: rgba(100, 200, 100, 0.7); }
.stats { grid-area: stats; }
.map_container { grid-area: map; min-height: 400px; }
.data {
grid-area: data;
margin-top: 1rem;
}
.data_scroll {
height: 60vh;
overflow-y: auto;
}
.loading-overlay,
.not-found {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10;
font-size: 1.5rem;
text-align: center;
color: rgba(255, 100, 0, 0.8);
}
@media (max-width: 768px) {
.site_content {
grid-template-columns: 1fr;
grid-template-areas:
"stats"
"map"
"data";
}
.map_container { height: 300px; }
}
.section_label {
font-size: 0.7rem;
color: var(--theme-dim);
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.site_list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.site_link {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: rgba(20, 20, 20, 0.9);
border-left: 3px solid var(--theme-faint);
border-radius: 3px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.site_link:hover {
background: rgba(40, 40, 40, 0.9);
border-left-color: var(--theme);
}
.site_link_id {
color: var(--theme);
font-size: 0.85rem;
}
.site_link_meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.35);
flex: 1;
}
.site_link_arrow {
color: var(--theme-dim);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { store } from '../store'
import { ALARMS_ENDPOINT, ROBOTS_ENDPOINT, CELLSITES_ENDPOINT } from '../main'
import { get_request } from '../ajax_requests'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const id = Number(route.params.id)
const robot = ref(null)
const stats_fields = computed(() => [
['NAME', robot.value?.name],
['STATUS', robot.value?.current_incident_id ? 'ON CALL' : 'IDLE'],
['INCIDENT', robot.value?.current_incident_id ?? '—'],
['SITE', robot.value?.current_site_id ?? '—'],
['TARGET SITE', robot.value?.target_site_id ?? '—'], // FIXME:
['LAT', robot.value?.lat],
['LON', robot.value?.lon],
['BASE LAT', robot.value?.base_lat],
['BASE LON', robot.value?.base_lon],
['UPDATED', robot.value?.updated],
])
const associated_site = computed(() =>
store.cellsites.find(s => s.id === robot.value?.current_site_id)
)
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const alarm_url = before ? `${ALARMS_ENDPOINT}?before=${before}` : `${ALARMS_ENDPOINT}`
const [alarms, robotData] = await Promise.all([
get_request(alarm_url),
get_request(`${ROBOTS_ENDPOINT}/${id}`),
])
store.set_alarms(alarms)
robot.value = robotData
if (before === undefined && !store.is_frozen && was_live) {
// TODO: Repeated this fix, should break it out somewhere
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { filtered_alarms } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
try {
// NOTE: Currently thinking we display the cellsites even on the robots page?
const [robotData, sitesData] = await Promise.all([
get_request(`${ROBOTS_ENDPOINT}/${id}`),
store.cellsites.length === 0 ? get_request(`${CELLSITES_ENDPOINT}`) : Promise.resolve(null),
])
robot.value = robotData
if (sitesData) store.set_cellsites(sitesData)
await fetch_all()
if (store.alarms.length > 0) {
const oldest = Math.min(...store.alarms.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0 ? oldest : Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
} catch (error) {
console.error('Error fetching robot:', error)
// TODO: toasts or some banner for errors
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div class="robot_container">
<div v-if="loading" class="loading-overlay"><p>Loading...</p></div>
<div v-else-if="!robot" class="not-found"><p>Not found</p></div>
<div v-else class="robot_content">
<div class="stats">
<div class="page_id">{{ robot.name.toUpperCase() }}</div>
<div class="stat_row" v-for="[key, val] in stats_fields" :key="key">
<span class="stat_key">{{ key }}</span>
<span class="stat_val" :class="key === 'STATUS' ? (robot.current_incident_id ? 'on_call' : 'idle') : ''">
{{ val }}
</span>
</div>
</div>
<div class="map_container">
<Map
v-if="robot.lat != null && robot.lon != null"
:center="[robot.lat, robot.lon]"
:zoom="10"
:robots="[robot]"
:alarms="filtered_alarms"
/>
</div>
<div class="data">
<div class="section_label">CURRENT ASSIGNMENT</div>
<div class="link_list">
<div v-if="robot.current_incident_id" class="entity_link"
@click="router.push(`/incidents/${robot.current_incident_id}`)">
<span class="link_id">INC #{{ robot.current_incident_id }}</span>
<span class="link_arrow"></span>
</div>
<div v-if="associated_site" class="entity_link"
@click="router.push(`/cellsites/${associated_site.id}`)">
<span class="link_id">SITE {{ associated_site.id }}</span>
<span class="link_meta">{{ associated_site.radio }} · {{ associated_site.lat }}, {{ associated_site.lon }}</span>
<span class="link_arrow"></span>
</div>
<p v-if="!robot.current_incident_id && !associated_site">No active assignment.</p>
</div>
</div>
</div>
<FilterRow :count="filtered_alarms.length" label="alarms" />
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</template>
<style scoped>
.robot_container { position: relative; padding: 1rem; }
.robot_content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"stats map"
"data data";
gap: 1rem;
align-items: start;
}
.page_id {
font-size: 1.4rem;
color: var(--theme);
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.stat_row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--theme-faint);
font-size: 0.8rem;
}
.stat_key { color: var(--theme-dim); }
.stat_val { color: rgba(255, 255, 255, 0.8); }
.stat_val.on_call { color: rgba(220, 30, 30, 0.9); }
.stat_val.idle { color: rgba(100, 200, 100, 0.7); }
.stats { grid-area: stats; }
.map_container { grid-area: map; min-height: 400px; }
.data { grid-area: data; margin-top: 1rem; }
.section_label {
font-size: 0.7rem;
color: var(--theme-dim);
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.link_list { display: flex; flex-direction: column; gap: 0.3rem; }
.entity_link {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: rgba(20, 20, 20, 0.9);
border-left: 3px solid var(--theme-faint);
border-radius: 3px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.entity_link:hover {
background: rgba(40, 40, 40, 0.9);
border-left-color: var(--theme);
}
.link_id { color: var(--theme); font-size: 0.85rem; }
.link_meta { font-size: 0.75rem; color: rgba(255, 255, 255, 0.35); flex: 1; }
.link_arrow { color: var(--theme-dim); font-size: 0.85rem; }
.loading-overlay, .not-found {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10; font-size: 1.5rem;
color: rgba(255, 100, 0, 0.8);
}
@media (max-width: 768px) {
.robot_content {
grid-template-columns: 1fr;
grid-template-areas: "stats" "map" "data";
}
.map_container { height: 300px; }
}
</style>

67
frontend/src/store.ts Normal file
View File

@@ -0,0 +1,67 @@
import { reactive } from 'vue'
export const store = reactive({
cellsites: [],
alarms: [],
incidents: [],
robots: [],
// FIXME: I am replacing the entire array, should merge when can. But it's okay for demo.
set_robots(data) {
this.robots = data
},
set_cellsites(data) {
this.cellsites = data
},
set_alarms(data) {
this.alarms = data
},
set_incidents(data) {
this.incidents = data
},
alarms_for_site(site_id) {
return this.alarms.filter(a => a.site_id === site_id)
},
severity_filter: null as number | null,
// active_tab: 'map' as 'map' | 'alarms' | 'incidents' | 'sites' | 'robots',
// NOTE: This is to retain the state when hitting the back button and be able to handle dynamic active tabs cross-component
detail_tabs: {},
// NOTE: The below manage state for the MODES: LIVE, Historical, Frozen.
is_frozen: false,
frozen_at: null as number | null,
refresh_interval_seconds: 30,
live_countdown: 30,
timeline_min: 0,
timeline_max: Date.now(),
timeline_position: Date.now(),
// NOTE: this is just in case the backend breaks, we FREEZE to avoid polling infinitely.
server_unreachable: false,
get is_live() {
return !this.is_frozen && this.timeline_position >= this.timeline_max - 2000 // NOTE: Buffer to deal with the flash of is_live, do not remove!
},
get is_historical() {
return !this.is_live
},
freeze() {
this.is_frozen = true
this.frozen_at = Date.now()
},
unfreeze() {
this.is_frozen = false
this.frozen_at = null
this.server_unreachable = false
},
set_timeline_position(val: number) {
this.timeline_position = val
},
set_refresh_interval(val: number) {
this.refresh_interval_seconds = val
},
})

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

12
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

26
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api/v1': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})