demo version prepped
BIN
.drafts/Screenshot 2026-04-01 115640.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
.drafts/Screenshot 2026-04-01 115715.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
.drafts/Screenshot 2026-04-01 115738.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
.drafts/Screenshot 2026-04-01 115801.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
.drafts/Screenshot 2026-04-01 115832.png
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
.drafts/Screenshot 2026-04-01 115849.png
Normal file
|
After Width: | Height: | Size: 584 KiB |
BIN
.drafts/draft-three-60-design-2025-10-24-1028.png
Normal file
|
After Width: | Height: | Size: 724 KiB |
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.vscode
|
||||
/data
|
||||
.env
|
||||
**/.env
|
||||
*.env
|
||||
40
README.md
@@ -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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
3.14
|
||||
0
backend/README.md
Normal file
62
backend/api/v1/__init__.py
Normal 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
|
||||
21
backend/api/v1/alarms/__init__.py
Normal 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()
|
||||
32
backend/api/v1/cellsites/__init__.py
Normal 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()
|
||||
34
backend/api/v1/incidents/__init__.py
Normal 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()
|
||||
30
backend/api/v1/robots/__init__.py
Normal 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
|
||||
23
backend/api/v1/simulator/__init__.py
Normal 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
@@ -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()
|
||||
132
backend/database/__init__.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
253
backend/simulator/__init__.py
Normal 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)
|
||||
30
backend/simulator/sim_config.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
34
frontend/package.json
Normal 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
|
After Width: | Height: | Size: 4.2 KiB |
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
27
frontend/src/ajax_requests.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
83
frontend/src/assets/base.css
Normal 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;
|
||||
}
|
||||
BIN
frontend/src/assets/fonts/DepartureMono-Regular.woff2
Normal file
1
frontend/src/assets/logo.svg
Normal 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 |
87
frontend/src/assets/main.css
Normal 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
@@ -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;
|
||||
}
|
||||
169
frontend/src/components/AlarmsCard.vue
Normal 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>
|
||||
85
frontend/src/components/FilterRow.vue
Normal 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>
|
||||
123
frontend/src/components/IncidentCard.vue
Normal 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>
|
||||
392
frontend/src/components/Map.vue
Normal 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: '© OpenStreetMap © 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>
|
||||
78
frontend/src/components/Nav.vue
Normal 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>
|
||||
116
frontend/src/components/SimulationBanner.vue
Normal 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>
|
||||
89
frontend/src/components/Table.vue
Normal 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>
|
||||
205
frontend/src/components/TimelineRow.vue
Normal 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>
|
||||
39
frontend/src/composables/use_filtered_data.ts
Normal 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 }
|
||||
}
|
||||
107
frontend/src/composables/use_timeline.ts
Normal 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
@@ -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')
|
||||
248
frontend/src/pages/Cellsite.vue
Normal 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
@@ -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>
|
||||
272
frontend/src/pages/Incident.vue
Normal 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>
|
||||
234
frontend/src/pages/Robots.vue
Normal 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
@@ -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
|
||||
},
|
||||
})
|
||||
18
frontend/tsconfig.app.json
Normal 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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
27
frontend/tsconfig.node.json
Normal 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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||