Compare commits
9 Commits
f1bd0b8139
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd15ffe3a | ||
|
|
4caf19fcc0 | ||
|
|
89c13e8cad | ||
|
|
e195b26b5a | ||
|
|
20355b0804 | ||
|
|
ff971e2cea | ||
|
|
e81aadcd99 | ||
|
|
e8846f4831 | ||
|
|
8717b9c07b |
@@ -1 +0,0 @@
|
|||||||
{{ _copier_answers|to_json -}}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Update the .env file with the answers from the .copier-answers.yml file
|
|
||||||
# without using Jinja2 templates in the .env file, this way the code works as is
|
|
||||||
# without needing Copier, but if Copier is used, the .env file will be updated
|
|
||||||
root_path = Path(__file__).parent.parent
|
|
||||||
answers_path = Path(__file__).parent / ".copier-answers.yml"
|
|
||||||
answers = json.loads(answers_path.read_text())
|
|
||||||
env_path = root_path / ".env"
|
|
||||||
env_content = env_path.read_text()
|
|
||||||
lines = []
|
|
||||||
for line in env_content.splitlines():
|
|
||||||
for key, value in answers.items():
|
|
||||||
upper_key = key.upper()
|
|
||||||
if line.startswith(f"{upper_key}="):
|
|
||||||
if " " in value:
|
|
||||||
content = f"{upper_key}={value!r}"
|
|
||||||
else:
|
|
||||||
content = f"{upper_key}={value}"
|
|
||||||
new_line = line.replace(line, content)
|
|
||||||
lines.append(new_line)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
env_path.write_text("\n".join(lines))
|
|
||||||
7
.env
7
.env
@@ -43,3 +43,10 @@ SENTRY_DSN=
|
|||||||
# Configure these with your own Docker registry images
|
# Configure these with your own Docker registry images
|
||||||
DOCKER_IMAGE_BACKEND=backend
|
DOCKER_IMAGE_BACKEND=backend
|
||||||
DOCKER_IMAGE_FRONTEND=frontend
|
DOCKER_IMAGE_FRONTEND=frontend
|
||||||
|
|
||||||
|
# MQTT Config
|
||||||
|
MQTT_HOST=101.35.119.226
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=weefnn
|
||||||
|
MQTT_PASSWORD=123456
|
||||||
|
MQTT_CLIENT_ID=spatialhub-local-dev-weifeng
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ FRONTEND_HOST=https://makefire.fun
|
|||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
|
|
||||||
PROJECT_NAME="Full Stack FastAPI Project"
|
PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
STACK_NAME=full-stack-fastapi-project
|
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
BACKEND_CORS_ORIGINS="https://makefire.fun,https://api.makefire.fun"
|
BACKEND_CORS_ORIGINS="https://makefire.fun,https://api.makefire.fun"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ jobs:
|
|||||||
FRONTEND_HOST=${{ secrets.FRONTEND_HOST }}
|
FRONTEND_HOST=${{ secrets.FRONTEND_HOST }}
|
||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
PROJECT_NAME=${{ secrets.PROJECT_NAME }}
|
PROJECT_NAME=${{ secrets.PROJECT_NAME }}
|
||||||
STACK_NAME=${{ secrets.STACK_NAME }}
|
|
||||||
BACKEND_CORS_ORIGINS=${{ secrets.BACKEND_CORS_ORIGINS }}
|
BACKEND_CORS_ORIGINS=${{ secrets.BACKEND_CORS_ORIGINS }}
|
||||||
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
||||||
FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
|
FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
|
||||||
@@ -40,6 +39,16 @@ jobs:
|
|||||||
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
|
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
|
||||||
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||||
|
MQTT_HOST=${{ secrets.MQTT_HOST }}
|
||||||
|
MQTT_PORT=${{ secrets.MQTT_PORT }}
|
||||||
|
MQTT_USERNAME=${{ secrets.MQTT_USERNAME }}
|
||||||
|
MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }}
|
||||||
|
MQTT_TOPIC_PATTERN=${{ secrets.MQTT_TOPIC_PATTERN }}
|
||||||
|
MQTT_CLIENT_ID=${{ secrets.MQTT_CLIENT_ID }}
|
||||||
|
RAW_HOT_RETENTION_DAYS=${{ secrets.RAW_HOT_RETENTION_DAYS }}
|
||||||
|
RAW_ARCHIVE_BASE_DIR=${{ secrets.RAW_ARCHIVE_BASE_DIR }}
|
||||||
|
PARSER_BATCH_SIZE=${{ secrets.PARSER_BATCH_SIZE }}
|
||||||
|
PARSER_POLL_INTERVAL_MS=${{ secrets.PARSER_POLL_INTERVAL_MS }}
|
||||||
DOCKER_IMAGE_BACKEND=${{ secrets.DOCKER_IMAGE_BACKEND }}
|
DOCKER_IMAGE_BACKEND=${{ secrets.DOCKER_IMAGE_BACKEND }}
|
||||||
DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }}
|
DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
@@ -47,6 +56,16 @@ jobs:
|
|||||||
# Fallback defaults if image name secrets are empty
|
# Fallback defaults if image name secrets are empty
|
||||||
sed -i 's/^DOCKER_IMAGE_BACKEND=$/DOCKER_IMAGE_BACKEND=backend/' .env.production
|
sed -i 's/^DOCKER_IMAGE_BACKEND=$/DOCKER_IMAGE_BACKEND=backend/' .env.production
|
||||||
sed -i 's/^DOCKER_IMAGE_FRONTEND=$/DOCKER_IMAGE_FRONTEND=frontend/' .env.production
|
sed -i 's/^DOCKER_IMAGE_FRONTEND=$/DOCKER_IMAGE_FRONTEND=frontend/' .env.production
|
||||||
|
sed -i 's/^MQTT_HOST=$/MQTT_HOST=1Panel-emqx-Rniy/' .env.production
|
||||||
|
sed -i 's/^MQTT_PORT=$/MQTT_PORT=1883/' .env.production
|
||||||
|
sed -i 's/^MQTT_USERNAME=$/MQTT_USERNAME=weefnn/' .env.production
|
||||||
|
sed -i 's/^MQTT_PASSWORD=$/MQTT_PASSWORD=123456/' .env.production
|
||||||
|
sed -i 's%^MQTT_TOPIC_PATTERN=$%MQTT_TOPIC_PATTERN=terminal/+/raw%' .env.production
|
||||||
|
sed -i 's/^MQTT_CLIENT_ID=$/MQTT_CLIENT_ID=spatialhub-mqtt-ingestor/' .env.production
|
||||||
|
sed -i 's/^RAW_HOT_RETENTION_DAYS=$/RAW_HOT_RETENTION_DAYS=90/' .env.production
|
||||||
|
sed -i 's%^RAW_ARCHIVE_BASE_DIR=$%RAW_ARCHIVE_BASE_DIR=/data/archive/gnss%' .env.production
|
||||||
|
sed -i 's/^PARSER_BATCH_SIZE=$/PARSER_BATCH_SIZE=200/' .env.production
|
||||||
|
sed -i 's/^PARSER_POLL_INTERVAL_MS=$/PARSER_POLL_INTERVAL_MS=300/' .env.production
|
||||||
|
|
||||||
- name: Build Docker images
|
- name: Build Docker images
|
||||||
run: docker compose --env-file .env.production -f compose.prod.yml build
|
run: docker compose --env-file .env.production -f compose.prod.yml build
|
||||||
@@ -83,5 +102,22 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Verify mqtt workers
|
||||||
|
run: |
|
||||||
|
if docker compose --env-file .env.production -f compose.prod.yml ps mqtt-ingestor --status running | grep -q mqtt-ingestor; then
|
||||||
|
echo "✅ mqtt-ingestor is running!"
|
||||||
|
else
|
||||||
|
echo "❌ mqtt-ingestor is not running"
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml logs mqtt-ingestor
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if docker compose --env-file .env.production -f compose.prod.yml ps mqtt-parser-worker --status running | grep -q mqtt-parser-worker; then
|
||||||
|
echo "✅ mqtt-parser-worker is running!"
|
||||||
|
else
|
||||||
|
echo "❌ mqtt-parser-worker is not running"
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml logs mqtt-parser-worker
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Cleanup old Docker images
|
- name: Cleanup old Docker images
|
||||||
run: docker image prune -f || true
|
run: docker image prune -f || true
|
||||||
|
|||||||
65
README.md
65
README.md
@@ -22,8 +22,7 @@
|
|||||||
- 📫 Email based password recovery.
|
- 📫 Email based password recovery.
|
||||||
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
||||||
- ✅ Tests with [Pytest](https://pytest.org).
|
- ✅ Tests with [Pytest](https://pytest.org).
|
||||||
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
|
- 🚢 Deployment instructions using Docker Compose.
|
||||||
- 🚢 Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates.
|
|
||||||
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
||||||
|
|
||||||
### Dashboard Login
|
### Dashboard Login
|
||||||
@@ -146,66 +145,6 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||||
|
|
||||||
## How To Use It - Alternative With Copier
|
|
||||||
|
|
||||||
This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
|
|
||||||
|
|
||||||
It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
|
|
||||||
|
|
||||||
### Install Copier
|
|
||||||
|
|
||||||
You can install Copier with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
|
|
||||||
|
|
||||||
### Generate a Project With Copier
|
|
||||||
|
|
||||||
Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
|
|
||||||
|
|
||||||
Go to the directory that will be the parent of your project, and run the command with your project's name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have `pipx` and you didn't install `copier`, you can run it directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
|
|
||||||
|
|
||||||
### Input Variables
|
|
||||||
|
|
||||||
Copier will ask you for some data, you might want to have at hand before generating the project.
|
|
||||||
|
|
||||||
But don't worry, you can just update any of that in the `.env` files afterwards.
|
|
||||||
|
|
||||||
The input variables, with their default values (some auto generated) are:
|
|
||||||
|
|
||||||
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
|
|
||||||
- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env).
|
|
||||||
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
|
|
||||||
- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
|
|
||||||
- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
|
|
||||||
- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
|
|
||||||
- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
|
|
||||||
- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env.
|
|
||||||
- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
|
|
||||||
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
|
|
||||||
- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
|
|
||||||
|
|
||||||
## Backend Development
|
## Backend Development
|
||||||
|
|
||||||
Backend docs: [backend/README.md](./backend/README.md).
|
Backend docs: [backend/README.md](./backend/README.md).
|
||||||
@@ -222,7 +161,7 @@ Deployment docs: [deployment.md](./deployment.md).
|
|||||||
|
|
||||||
General development docs: [development.md](./development.md).
|
General development docs: [development.md](./development.md).
|
||||||
|
|
||||||
This includes using Docker Compose, custom local domains, `.env` configurations, etc.
|
This includes using Docker Compose and `.env` configurations.
|
||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ Once you have the MJML extension installed, you can create a new email template
|
|||||||
This project includes a two-stage MQTT pipeline:
|
This project includes a two-stage MQTT pipeline:
|
||||||
|
|
||||||
- `mqtt-ingestor` subscribes to `terminal/{device_id}/raw` and stores payload bytes losslessly in `terminal_message_raw`.
|
- `mqtt-ingestor` subscribes to `terminal/{device_id}/raw` and stores payload bytes losslessly in `terminal_message_raw`.
|
||||||
- `mqtt-parser-worker` consumes pending rows and writes parsed events/state.
|
- `mqtt-parser-worker` consumes unparsed rows (`parsed_at IS NULL` and `parse_error IS NULL`) and updates `terminal_device_state`.
|
||||||
|
- Parse is single-pass by design in the current implementation: failed rows keep `parse_error` and are not auto-retried with backoff.
|
||||||
|
|
||||||
### Run locally with Docker Compose
|
### Run locally with Docker Compose
|
||||||
|
|
||||||
@@ -199,7 +200,6 @@ $ PYTHONPATH=. ../.venv/bin/pytest tests/mqtt --confcutdir=tests/mqtt -q
|
|||||||
2. Publish a sample message to `terminal/device-001/raw`.
|
2. Publish a sample message to `terminal/device-001/raw`.
|
||||||
3. Query PostgreSQL tables:
|
3. Query PostgreSQL tables:
|
||||||
- `terminal_message_raw`
|
- `terminal_message_raw`
|
||||||
- `terminal_observation_event`
|
|
||||||
- `terminal_device_state`
|
- `terminal_device_state`
|
||||||
|
|
||||||
### Archive run (manual trigger)
|
### Archive run (manual trigger)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""simplify mqtt parse tracking
|
||||||
|
|
||||||
|
Revision ID: b4c5d6e7f809
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-03-19 17:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b4c5d6e7f809"
|
||||||
|
down_revision = "a1b2c3d4e5f6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"terminal_message_raw",
|
||||||
|
sa.Column("parsed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_message_raw_parsed_at"),
|
||||||
|
"terminal_message_raw",
|
||||||
|
["parsed_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve historical parse outcomes before removing state-machine columns.
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE terminal_message_raw
|
||||||
|
SET parsed_at = COALESCE(received_at, created_at)
|
||||||
|
WHERE parse_status <> 'pending'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_terminal_message_raw_next_retry_at"),
|
||||||
|
table_name="terminal_message_raw",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_terminal_message_raw_parse_status"),
|
||||||
|
table_name="terminal_message_raw",
|
||||||
|
)
|
||||||
|
op.drop_column("terminal_message_raw", "next_retry_at")
|
||||||
|
op.drop_column("terminal_message_raw", "parse_attempts")
|
||||||
|
op.drop_column("terminal_message_raw", "parse_status")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"terminal_message_raw",
|
||||||
|
sa.Column(
|
||||||
|
"parse_status",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"terminal_message_raw",
|
||||||
|
sa.Column("parse_attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"terminal_message_raw",
|
||||||
|
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_message_raw_parse_status"),
|
||||||
|
"terminal_message_raw",
|
||||||
|
["parse_status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_message_raw_next_retry_at"),
|
||||||
|
"terminal_message_raw",
|
||||||
|
["next_retry_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE terminal_message_raw
|
||||||
|
SET parse_status = CASE
|
||||||
|
WHEN parsed_at IS NULL THEN 'pending'
|
||||||
|
WHEN parse_error IS NULL THEN 'succeeded'
|
||||||
|
ELSE 'failed'
|
||||||
|
END,
|
||||||
|
parse_attempts = CASE
|
||||||
|
WHEN parsed_at IS NULL THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_terminal_message_raw_parsed_at"),
|
||||||
|
table_name="terminal_message_raw",
|
||||||
|
)
|
||||||
|
op.drop_column("terminal_message_raw", "parsed_at")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""drop terminal observation event table
|
||||||
|
|
||||||
|
Revision ID: c6d7e8f90123
|
||||||
|
Revises: b4c5d6e7f809
|
||||||
|
Create Date: 2026-03-19 18:20:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c6d7e8f90123"
|
||||||
|
down_revision = "b4c5d6e7f809"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("terminal_observation_event")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"terminal_observation_event",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("raw_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("device_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("event_time", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("payload_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["raw_id"], ["terminal_message_raw.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_observation_event_device_id"),
|
||||||
|
"terminal_observation_event",
|
||||||
|
["device_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_observation_event_event_time"),
|
||||||
|
"terminal_observation_event",
|
||||||
|
["event_time"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_observation_event_event_type"),
|
||||||
|
"terminal_observation_event",
|
||||||
|
["event_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_terminal_observation_event_raw_id"),
|
||||||
|
"terminal_observation_event",
|
||||||
|
["raw_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
@@ -27,7 +27,7 @@ SessionDep = Annotated[Session, Depends(get_db)]
|
|||||||
TokenDep = Annotated[str, Depends(reusable_oauth2)]
|
TokenDep = Annotated[str, Depends(reusable_oauth2)]
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
def resolve_user_from_token(*, session: Session, token: str) -> User:
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||||
@@ -46,6 +46,10 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||||
|
return resolve_user_from_token(session=session, token=token)
|
||||||
|
|
||||||
|
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import locations, login, private, users, utils
|
from app.api.routes import (
|
||||||
|
locations,
|
||||||
|
login,
|
||||||
|
mqtt_raw,
|
||||||
|
private,
|
||||||
|
telemetry,
|
||||||
|
users,
|
||||||
|
utils,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -8,8 +16,9 @@ api_router.include_router(login.router)
|
|||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(utils.router)
|
api_router.include_router(utils.router)
|
||||||
api_router.include_router(locations.router)
|
api_router.include_router(locations.router)
|
||||||
|
api_router.include_router(telemetry.router)
|
||||||
|
api_router.include_router(mqtt_raw.router)
|
||||||
|
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "local":
|
if settings.ENVIRONMENT == "local":
|
||||||
api_router.include_router(private.router)
|
api_router.include_router(private.router)
|
||||||
|
|
||||||
|
|||||||
300
backend/app/api/routes/mqtt_raw.py
Normal file
300
backend/app/api/routes/mqtt_raw.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlmodel import col, delete, func, select
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.models import (
|
||||||
|
DeleteResult,
|
||||||
|
MqttRawDaysPublic,
|
||||||
|
MqttRawDaySummary,
|
||||||
|
TerminalMessageRaw,
|
||||||
|
TerminalRawArchiveManifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mqtt-raw", tags=["mqtt_raw"])
|
||||||
|
UTC_PLUS_8 = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_day(value: Any) -> date:
|
||||||
|
if isinstance(value, date) and not isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, str):
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
raise ValueError(f"Unsupported day value: {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds(day: date) -> tuple[datetime, datetime]:
|
||||||
|
start_local = datetime.combine(day, time.min, tzinfo=UTC_PLUS_8)
|
||||||
|
end_local = start_local + timedelta(days=1)
|
||||||
|
return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_day_expr(session: SessionDep, field: Any) -> Any:
|
||||||
|
bind = session.get_bind()
|
||||||
|
dialect_name = bind.dialect.name if bind is not None else ""
|
||||||
|
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
return func.date(func.datetime(field, "+8 hours"))
|
||||||
|
if dialect_name in {"postgresql", "postgres"}:
|
||||||
|
return func.date(func.timezone("Asia/Shanghai", field))
|
||||||
|
return func.date(field)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_window(
|
||||||
|
*, day: date, start_seconds: int | None, end_seconds: int | None
|
||||||
|
) -> tuple[datetime, datetime, int, int]:
|
||||||
|
start_of_day_local = datetime.combine(day, time.min, tzinfo=UTC_PLUS_8)
|
||||||
|
start_sec = 0 if start_seconds is None else start_seconds
|
||||||
|
end_sec = 24 * 3600 if end_seconds is None else end_seconds
|
||||||
|
|
||||||
|
if not 0 <= start_sec <= 24 * 3600:
|
||||||
|
raise HTTPException(status_code=400, detail="start_seconds must be 0..86400")
|
||||||
|
if not 0 <= end_sec <= 24 * 3600:
|
||||||
|
raise HTTPException(status_code=400, detail="end_seconds must be 0..86400")
|
||||||
|
if start_sec >= end_sec:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="start_seconds must be smaller than end_seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
start = (start_of_day_local + timedelta(seconds=start_sec)).astimezone(timezone.utc)
|
||||||
|
end = (start_of_day_local + timedelta(seconds=end_sec)).astimezone(timezone.utc)
|
||||||
|
return start, end, start_sec, end_sec
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_file(path_str: str) -> None:
|
||||||
|
path = Path(path_str)
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/days",
|
||||||
|
response_model=MqttRawDaysPublic,
|
||||||
|
operation_id="mqtt_raw_list_days",
|
||||||
|
)
|
||||||
|
def list_mqtt_raw_days(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
skip: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=30, ge=1, le=200),
|
||||||
|
) -> MqttRawDaysPublic:
|
||||||
|
days: dict[date, MqttRawDaySummary] = {}
|
||||||
|
|
||||||
|
raw_day_expr = _local_day_expr(session, TerminalMessageRaw.received_at)
|
||||||
|
raw_statement = (
|
||||||
|
select(
|
||||||
|
raw_day_expr,
|
||||||
|
func.count(),
|
||||||
|
func.min(TerminalMessageRaw.received_at),
|
||||||
|
func.max(TerminalMessageRaw.received_at),
|
||||||
|
)
|
||||||
|
.group_by(raw_day_expr)
|
||||||
|
.order_by(raw_day_expr.desc())
|
||||||
|
)
|
||||||
|
for day_value, message_count, first_at, last_at in session.exec(
|
||||||
|
raw_statement
|
||||||
|
).all():
|
||||||
|
day_key = _normalize_day(day_value)
|
||||||
|
days[day_key] = MqttRawDaySummary(
|
||||||
|
day=day_key,
|
||||||
|
message_count=int(message_count or 0),
|
||||||
|
first_received_at=first_at,
|
||||||
|
last_received_at=last_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_day_expr = _local_day_expr(session, TerminalRawArchiveManifest.range_start)
|
||||||
|
archive_statement = (
|
||||||
|
select(
|
||||||
|
archive_day_expr,
|
||||||
|
func.sum(TerminalRawArchiveManifest.record_count),
|
||||||
|
func.min(TerminalRawArchiveManifest.range_start),
|
||||||
|
func.max(TerminalRawArchiveManifest.range_end),
|
||||||
|
)
|
||||||
|
.group_by(archive_day_expr)
|
||||||
|
.order_by(archive_day_expr.desc())
|
||||||
|
)
|
||||||
|
for day_value, message_count, first_at, last_at in session.exec(
|
||||||
|
archive_statement
|
||||||
|
).all():
|
||||||
|
day_key = _normalize_day(day_value)
|
||||||
|
existing = days.get(day_key)
|
||||||
|
archive_count = int(message_count or 0)
|
||||||
|
if existing is None:
|
||||||
|
days[day_key] = MqttRawDaySummary(
|
||||||
|
day=day_key,
|
||||||
|
message_count=archive_count,
|
||||||
|
first_received_at=first_at,
|
||||||
|
last_received_at=last_at,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
existing.message_count += archive_count
|
||||||
|
if first_at and (
|
||||||
|
existing.first_received_at is None or first_at < existing.first_received_at
|
||||||
|
):
|
||||||
|
existing.first_received_at = first_at
|
||||||
|
if last_at and (
|
||||||
|
existing.last_received_at is None or last_at > existing.last_received_at
|
||||||
|
):
|
||||||
|
existing.last_received_at = last_at
|
||||||
|
|
||||||
|
ordered = sorted(days.values(), key=lambda item: item.day, reverse=True)
|
||||||
|
paged = ordered[skip : skip + limit]
|
||||||
|
return MqttRawDaysPublic(data=paged, count=len(ordered))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/download",
|
||||||
|
operation_id="mqtt_raw_download",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"content": {
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {"type": "string", "format": "binary"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def download_mqtt_raw(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
day: date,
|
||||||
|
start_seconds: int | None = Query(default=None),
|
||||||
|
end_seconds: int | None = Query(default=None),
|
||||||
|
) -> Response:
|
||||||
|
range_start, range_end, start_sec, end_sec = _build_window(
|
||||||
|
day=day,
|
||||||
|
start_seconds=start_seconds,
|
||||||
|
end_seconds=end_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks: list[tuple[datetime, bytes]] = []
|
||||||
|
|
||||||
|
raw_statement = (
|
||||||
|
select(TerminalMessageRaw)
|
||||||
|
.where(TerminalMessageRaw.received_at >= range_start)
|
||||||
|
.where(TerminalMessageRaw.received_at < range_end)
|
||||||
|
.order_by(col(TerminalMessageRaw.received_at), col(TerminalMessageRaw.id))
|
||||||
|
)
|
||||||
|
raw_rows = session.exec(raw_statement).all()
|
||||||
|
for row in raw_rows:
|
||||||
|
chunks.append((row.received_at, row.payload_bytes))
|
||||||
|
|
||||||
|
manifest_statement = (
|
||||||
|
select(TerminalRawArchiveManifest)
|
||||||
|
.where(TerminalRawArchiveManifest.range_start >= range_start)
|
||||||
|
.where(TerminalRawArchiveManifest.range_end <= range_end)
|
||||||
|
.order_by(
|
||||||
|
col(TerminalRawArchiveManifest.range_start),
|
||||||
|
col(TerminalRawArchiveManifest.range_end),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manifests = session.exec(manifest_statement).all()
|
||||||
|
for manifest in manifests:
|
||||||
|
path = Path(manifest.file_path)
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
continue
|
||||||
|
chunks.append((manifest.range_start, path.read_bytes()))
|
||||||
|
|
||||||
|
chunks.sort(key=lambda item: item[0])
|
||||||
|
payload = b"".join(chunk for _, chunk in chunks)
|
||||||
|
|
||||||
|
name_suffix = (
|
||||||
|
day.isoformat()
|
||||||
|
if start_sec == 0 and end_sec == 24 * 3600
|
||||||
|
else f"{day.isoformat()}_{start_sec}_{end_sec}"
|
||||||
|
)
|
||||||
|
filename = f"mqtt_raw_{name_suffix}.txt"
|
||||||
|
return Response(
|
||||||
|
content=payload,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/day/{day}",
|
||||||
|
response_model=DeleteResult,
|
||||||
|
operation_id="mqtt_raw_delete_day",
|
||||||
|
)
|
||||||
|
def delete_mqtt_raw_by_day(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
day: date,
|
||||||
|
) -> DeleteResult:
|
||||||
|
range_start, range_end = _day_bounds(day)
|
||||||
|
|
||||||
|
raw_statement = (
|
||||||
|
select(TerminalMessageRaw.id)
|
||||||
|
.where(TerminalMessageRaw.received_at >= range_start)
|
||||||
|
.where(TerminalMessageRaw.received_at < range_end)
|
||||||
|
)
|
||||||
|
raw_ids = list(session.exec(raw_statement).all())
|
||||||
|
if raw_ids:
|
||||||
|
session.exec(
|
||||||
|
delete(TerminalMessageRaw).where(col(TerminalMessageRaw.id).in_(raw_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest_statement = (
|
||||||
|
select(TerminalRawArchiveManifest)
|
||||||
|
.where(TerminalRawArchiveManifest.range_start >= range_start)
|
||||||
|
.where(TerminalRawArchiveManifest.range_end <= range_end)
|
||||||
|
)
|
||||||
|
manifests = session.exec(manifest_statement).all()
|
||||||
|
for manifest in manifests:
|
||||||
|
_unlink_file(manifest.file_path)
|
||||||
|
session.delete(manifest)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
deleted_count = len(raw_ids) + len(manifests)
|
||||||
|
return DeleteResult(
|
||||||
|
deleted_count=deleted_count,
|
||||||
|
message=f"Deleted {deleted_count} record(s) for {day.isoformat()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/all",
|
||||||
|
response_model=DeleteResult,
|
||||||
|
operation_id="mqtt_raw_delete_all",
|
||||||
|
)
|
||||||
|
def delete_all_mqtt_raw(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
confirm: bool = Query(default=False),
|
||||||
|
) -> DeleteResult:
|
||||||
|
if not confirm:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Set confirm=true to delete all saved MQTT raw data",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_ids = list(session.exec(select(TerminalMessageRaw.id)).all())
|
||||||
|
if raw_ids:
|
||||||
|
session.exec(
|
||||||
|
delete(TerminalMessageRaw).where(col(TerminalMessageRaw.id).in_(raw_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
manifests = session.exec(select(TerminalRawArchiveManifest)).all()
|
||||||
|
for manifest in manifests:
|
||||||
|
_unlink_file(manifest.file_path)
|
||||||
|
session.delete(manifest)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
deleted_count = len(raw_ids) + len(manifests)
|
||||||
|
return DeleteResult(
|
||||||
|
deleted_count=deleted_count,
|
||||||
|
message=f"Deleted {deleted_count} record(s) from MQTT raw storage",
|
||||||
|
)
|
||||||
63
backend/app/api/routes/telemetry.py
Normal file
63
backend/app/api/routes/telemetry.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, WebSocket, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.websockets import WebSocketDisconnect
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep, resolve_user_from_token
|
||||||
|
from app.core.db import engine
|
||||||
|
from app.mqtt.telemetry import build_telemetry_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
|
||||||
|
STREAM_INTERVAL_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/latest")
|
||||||
|
def read_telemetry_latest(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
device_id: str | None = None,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return the latest parsed GNSS telemetry snapshot.
|
||||||
|
"""
|
||||||
|
return build_telemetry_snapshot(session=session, device_id=device_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def telemetry_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
token: str | None = Query(default=None),
|
||||||
|
device_id: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
) -> None:
|
||||||
|
if not token:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
try:
|
||||||
|
resolve_user_from_token(session=session, token=token)
|
||||||
|
except HTTPException:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
with Session(engine) as session:
|
||||||
|
payload = build_telemetry_snapshot(
|
||||||
|
session=session,
|
||||||
|
device_id=device_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
await websocket.send_json(jsonable_encoder(payload))
|
||||||
|
await asyncio.sleep(STREAM_INTERVAL_SECONDS)
|
||||||
|
except (WebSocketDisconnect, RuntimeError):
|
||||||
|
return
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
@@ -111,6 +111,18 @@ class LocationsPublic(SQLModel):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MqttRawDaySummary(SQLModel):
|
||||||
|
day: date
|
||||||
|
message_count: int
|
||||||
|
first_received_at: datetime | None = None
|
||||||
|
last_received_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MqttRawDaysPublic(SQLModel):
|
||||||
|
data: list[MqttRawDaySummary]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
class TerminalMessageRaw(SQLModel, table=True):
|
class TerminalMessageRaw(SQLModel, table=True):
|
||||||
__tablename__ = "terminal_message_raw"
|
__tablename__ = "terminal_message_raw"
|
||||||
|
|
||||||
@@ -126,9 +138,7 @@ class TerminalMessageRaw(SQLModel, table=True):
|
|||||||
payload_sha256: str = Field(default="", max_length=64, index=True)
|
payload_sha256: str = Field(default="", max_length=64, index=True)
|
||||||
qos: int = 0
|
qos: int = 0
|
||||||
retain: bool = False
|
retain: bool = False
|
||||||
parse_status: str = Field(default="pending", max_length=32, index=True)
|
parsed_at: datetime | None = Field(
|
||||||
parse_attempts: int = 0
|
|
||||||
next_retry_at: datetime | None = Field(
|
|
||||||
default=None,
|
default=None,
|
||||||
sa_type=DateTime(timezone=True), # type: ignore
|
sa_type=DateTime(timezone=True), # type: ignore
|
||||||
index=True,
|
index=True,
|
||||||
@@ -140,27 +150,6 @@ class TerminalMessageRaw(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TerminalObservationEvent(SQLModel, table=True):
|
|
||||||
__tablename__ = "terminal_observation_event"
|
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
||||||
raw_id: uuid.UUID = Field(
|
|
||||||
foreign_key="terminal_message_raw.id", nullable=False, index=True
|
|
||||||
)
|
|
||||||
device_id: str = Field(max_length=255, index=True)
|
|
||||||
event_time: datetime = Field(
|
|
||||||
default_factory=get_datetime_utc,
|
|
||||||
sa_type=DateTime(timezone=True), # type: ignore
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
event_type: str = Field(max_length=64, index=True)
|
|
||||||
payload_json: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default_factory=get_datetime_utc,
|
|
||||||
sa_type=DateTime(timezone=True), # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDeviceState(SQLModel, table=True):
|
class TerminalDeviceState(SQLModel, table=True):
|
||||||
__tablename__ = "terminal_device_state"
|
__tablename__ = "terminal_device_state"
|
||||||
|
|
||||||
@@ -209,6 +198,11 @@ class Message(SQLModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteResult(SQLModel):
|
||||||
|
deleted_count: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# JSON payload containing access token
|
# JSON payload containing access token
|
||||||
class Token(SQLModel):
|
class Token(SQLModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt # type: ignore[import-not-found,import-untyped]
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import engine
|
from app.core.db import engine
|
||||||
from app.mqtt.repository import insert_raw_message
|
from app.mqtt.repository import insert_raw_message
|
||||||
from app.mqtt.topic import extract_device_id
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
TOPIC_PATTERN = re.compile(r"^terminal/(?P<device_id>[^/]+)/raw$")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_device_id(topic: str) -> str:
|
||||||
|
match = TOPIC_PATTERN.match(topic)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("topic must match terminal/{device_id}/raw")
|
||||||
|
return match.group("device_id")
|
||||||
|
|
||||||
|
|
||||||
def _on_connect(
|
def _on_connect(
|
||||||
client: mqtt.Client,
|
client: Any,
|
||||||
_userdata: object,
|
_userdata: object,
|
||||||
_flags: dict[str, int],
|
_flags: dict[str, int],
|
||||||
reason_code: int,
|
reason_code: int,
|
||||||
_properties: mqtt.Properties | None = None,
|
_properties: object | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if reason_code != 0:
|
if reason_code != 0:
|
||||||
logger.error("MQTT connect failed: rc=%s", reason_code)
|
logger.error("MQTT connect failed: rc=%s", reason_code)
|
||||||
@@ -31,9 +39,9 @@ def _on_connect(
|
|||||||
|
|
||||||
|
|
||||||
def _on_message(
|
def _on_message(
|
||||||
_client: mqtt.Client,
|
_client: Any,
|
||||||
_userdata: object,
|
_userdata: object,
|
||||||
message: mqtt.MQTTMessage,
|
message: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
device_id = extract_device_id(message.topic)
|
device_id = extract_device_id(message.topic)
|
||||||
@@ -52,7 +60,14 @@ def _on_message(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_client() -> mqtt.Client:
|
def create_client() -> Any:
|
||||||
|
try:
|
||||||
|
import paho.mqtt.client as mqtt # type: ignore[import-not-found,import-untyped]
|
||||||
|
except ModuleNotFoundError as exc: # pragma: no cover - runtime guard
|
||||||
|
raise RuntimeError(
|
||||||
|
"paho-mqtt is required to run mqtt-ingestor. Install backend dependencies first."
|
||||||
|
) from exc
|
||||||
|
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
client_id=settings.MQTT_CLIENT_ID,
|
client_id=settings.MQTT_CLIENT_ID,
|
||||||
|
|||||||
@@ -2,45 +2,33 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import engine
|
from app.core.db import engine
|
||||||
from app.models import TerminalMessageRaw, get_datetime_utc
|
from app.models import TerminalMessageRaw
|
||||||
from app.mqtt.parsers.mixed import parse_mixed_payload
|
from app.mqtt.parsers.mixed import parse_mixed_payload
|
||||||
from app.mqtt.repository import (
|
from app.mqtt.repository import (
|
||||||
fetch_pending_rows_for_update,
|
fetch_pending_rows_for_update,
|
||||||
mark_failed,
|
mark_failed,
|
||||||
mark_processing,
|
|
||||||
mark_succeeded,
|
mark_succeeded,
|
||||||
write_events_and_update_state,
|
update_device_state_from_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _next_retry_delay_seconds(attempts: int) -> int:
|
|
||||||
# Bounded exponential backoff: 30s, 60s, 120s, ... capped to 15m.
|
|
||||||
return int(min(30 * (2 ** max(attempts - 1, 0)), 900))
|
|
||||||
|
|
||||||
|
|
||||||
def _process_one_row(*, session: Session, row: TerminalMessageRaw) -> None:
|
def _process_one_row(*, session: Session, row: TerminalMessageRaw) -> None:
|
||||||
mark_processing(session=session, row=row)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = parse_mixed_payload(row.payload_bytes)
|
events = parse_mixed_payload(row.payload_bytes)
|
||||||
write_events_and_update_state(session=session, row=row, events=events)
|
update_device_state_from_events(session=session, row=row, events=events)
|
||||||
mark_succeeded(session=session, row=row)
|
mark_succeeded(session=session, row=row)
|
||||||
except Exception as exc: # pragma: no cover - safety net path
|
except Exception as exc: # pragma: no cover - safety net path
|
||||||
delay = _next_retry_delay_seconds(row.parse_attempts)
|
|
||||||
mark_failed(
|
mark_failed(
|
||||||
session=session,
|
session=session,
|
||||||
row=row,
|
row=row,
|
||||||
parse_error=str(exc),
|
parse_error=str(exc),
|
||||||
next_retry_at=get_datetime_utc() + timedelta(seconds=delay),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from app.mqtt.parsers.mixed import parse_mixed_payload
|
from app.mqtt.parsers.mixed import parse_mixed_payload, summarize_rtcm_types
|
||||||
from app.mqtt.parsers.nmea import parse_nmea_sentences
|
from app.mqtt.parsers.nmea import parse_nmea_sentences
|
||||||
from app.mqtt.parsers.rtcm import summarize_rtcm_types
|
|
||||||
|
|
||||||
__all__ = ["parse_mixed_payload", "parse_nmea_sentences", "summarize_rtcm_types"]
|
__all__ = ["parse_mixed_payload", "parse_nmea_sentences", "summarize_rtcm_types"]
|
||||||
|
|||||||
@@ -3,7 +3,27 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.mqtt.parsers.nmea import parse_nmea_sentences
|
from app.mqtt.parsers.nmea import parse_nmea_sentences
|
||||||
from app.mqtt.parsers.rtcm import summarize_rtcm_types
|
|
||||||
|
|
||||||
|
def summarize_rtcm_types(payload: bytes) -> dict[int, int]:
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i + 6 <= len(payload):
|
||||||
|
if payload[i] != 0xD3:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
length = ((payload[i + 1] & 0x03) << 8) | payload[i + 2]
|
||||||
|
frame_end = i + 3 + length + 3
|
||||||
|
if frame_end > len(payload):
|
||||||
|
break
|
||||||
|
|
||||||
|
message_type = ((payload[i + 3] << 4) | (payload[i + 4] >> 4)) & 0x0FFF
|
||||||
|
counts[message_type] = counts.get(message_type, 0) + 1
|
||||||
|
i = frame_end
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def parse_mixed_payload(payload: bytes) -> list[dict[str, Any]]:
|
def parse_mixed_payload(payload: bytes) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SUPPORTED_NMEA_TYPES = {"GGA", "RMC", "GSV", "GSA", "GST"}
|
SUPPORTED_NMEA_TYPES = {"GGA", "RMC", "GSV", "GSA", "GST"}
|
||||||
|
TALKER_TO_CONSTELLATION = {
|
||||||
|
"GP": "GPS",
|
||||||
|
"GL": "GLONASS",
|
||||||
|
"GA": "Galileo",
|
||||||
|
"GB": "BeiDou",
|
||||||
|
"BD": "BeiDou",
|
||||||
|
"GQ": "QZSS",
|
||||||
|
"GI": "NavIC",
|
||||||
|
"GN": "Mixed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _to_float(value: str) -> float | None:
|
def _to_float(value: str) -> float | None:
|
||||||
@@ -40,19 +50,46 @@ def _parse_lat_lon(value: str, hemisphere: str) -> float | None:
|
|||||||
return decimal
|
return decimal
|
||||||
|
|
||||||
|
|
||||||
def _extract_sentence_type(sentence: str) -> str | None:
|
def _extract_sentence_meta(sentence: str) -> tuple[str, str] | None:
|
||||||
if not sentence.startswith("$"):
|
if not sentence.startswith("$"):
|
||||||
return None
|
return None
|
||||||
if len(sentence) < 6:
|
if len(sentence) < 6:
|
||||||
return None
|
return None
|
||||||
|
talker = sentence[1:3].upper()
|
||||||
nmea_type = sentence[3:6].upper()
|
nmea_type = sentence[3:6].upper()
|
||||||
if nmea_type not in SUPPORTED_NMEA_TYPES:
|
if nmea_type not in SUPPORTED_NMEA_TYPES:
|
||||||
return None
|
return None
|
||||||
return nmea_type
|
return talker, nmea_type
|
||||||
|
|
||||||
|
|
||||||
def _parse_nmea_fields(nmea_type: str, fields: list[str]) -> dict[str, Any]:
|
def _parse_satellite_blocks(fields: list[str]) -> list[dict[str, Any]]:
|
||||||
payload: dict[str, Any] = {"sentence_type": nmea_type, "raw_fields": fields}
|
satellites: list[dict[str, Any]] = []
|
||||||
|
for i in range(4, len(fields), 4):
|
||||||
|
if i + 3 >= len(fields):
|
||||||
|
continue
|
||||||
|
satellite_id = fields[i]
|
||||||
|
if not satellite_id:
|
||||||
|
continue
|
||||||
|
satellites.append(
|
||||||
|
{
|
||||||
|
"satellite_id": satellite_id,
|
||||||
|
"elevation_deg": _to_int(fields[i + 1]),
|
||||||
|
"azimuth_deg": _to_int(fields[i + 2]),
|
||||||
|
"snr_dbhz": _to_int(fields[i + 3]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return satellites
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nmea_fields(
|
||||||
|
*, talker: str, nmea_type: str, fields: list[str]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sentence_type": nmea_type,
|
||||||
|
"talker": talker,
|
||||||
|
"constellation": TALKER_TO_CONSTELLATION.get(talker, "Unknown"),
|
||||||
|
"raw_fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
if nmea_type == "GGA":
|
if nmea_type == "GGA":
|
||||||
payload.update(
|
payload.update(
|
||||||
@@ -91,23 +128,27 @@ def _parse_nmea_fields(nmea_type: str, fields: list[str]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif nmea_type == "GSV":
|
elif nmea_type == "GSV":
|
||||||
|
satellites = _parse_satellite_blocks(fields)
|
||||||
payload.update(
|
payload.update(
|
||||||
{
|
{
|
||||||
"total_messages": _to_int(fields[1] if len(fields) > 1 else ""),
|
"total_messages": _to_int(fields[1] if len(fields) > 1 else ""),
|
||||||
"message_index": _to_int(fields[2] if len(fields) > 2 else ""),
|
"message_index": _to_int(fields[2] if len(fields) > 2 else ""),
|
||||||
"satellites_in_view": _to_int(fields[3] if len(fields) > 3 else ""),
|
"satellites_in_view": _to_int(fields[3] if len(fields) > 3 else ""),
|
||||||
|
"satellites": satellites,
|
||||||
"snr_values": [
|
"snr_values": [
|
||||||
_to_int(fields[i])
|
satellite["snr_dbhz"]
|
||||||
for i in range(7, len(fields), 4)
|
for satellite in satellites
|
||||||
if i < len(fields)
|
if satellite["snr_dbhz"] is not None
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif nmea_type == "GSA":
|
elif nmea_type == "GSA":
|
||||||
|
used_satellite_ids = [sat_id for sat_id in fields[3:15] if sat_id]
|
||||||
payload.update(
|
payload.update(
|
||||||
{
|
{
|
||||||
"mode": fields[1] if len(fields) > 1 else None,
|
"mode": fields[1] if len(fields) > 1 else None,
|
||||||
"fix_type": _to_int(fields[2] if len(fields) > 2 else ""),
|
"fix_type": _to_int(fields[2] if len(fields) > 2 else ""),
|
||||||
|
"used_satellite_ids": used_satellite_ids,
|
||||||
"pdop": _to_float(fields[15] if len(fields) > 15 else ""),
|
"pdop": _to_float(fields[15] if len(fields) > 15 else ""),
|
||||||
"hdop": _to_float(fields[16] if len(fields) > 16 else ""),
|
"hdop": _to_float(fields[16] if len(fields) > 16 else ""),
|
||||||
"vdop": _to_float(fields[17] if len(fields) > 17 else ""),
|
"vdop": _to_float(fields[17] if len(fields) > 17 else ""),
|
||||||
@@ -134,13 +175,16 @@ def parse_nmea_sentences(payload: bytes) -> list[dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
sentence = raw_line.decode("ascii", errors="ignore").strip()
|
sentence = raw_line.decode("ascii", errors="ignore").strip()
|
||||||
nmea_type = _extract_sentence_type(sentence)
|
sentence_meta = _extract_sentence_meta(sentence)
|
||||||
if nmea_type is None:
|
if sentence_meta is None:
|
||||||
continue
|
continue
|
||||||
|
talker, nmea_type = sentence_meta
|
||||||
|
|
||||||
main_part = sentence.split("*", 1)[0]
|
main_part = sentence.split("*", 1)[0]
|
||||||
fields = main_part.split(",")
|
fields = main_part.split(",")
|
||||||
parsed_payload = _parse_nmea_fields(nmea_type, fields)
|
parsed_payload = _parse_nmea_fields(
|
||||||
|
talker=talker, nmea_type=nmea_type, fields=fields
|
||||||
|
)
|
||||||
events.append(
|
events.append(
|
||||||
{"event_type": f"nmea_{nmea_type.lower()}", "payload": parsed_payload}
|
{"event_type": f"nmea_{nmea_type.lower()}", "payload": parsed_payload}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_rtcm_types(payload: bytes) -> dict[int, int]:
|
|
||||||
counts: dict[int, int] = {}
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
while i + 6 <= len(payload):
|
|
||||||
if payload[i] != 0xD3:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
length = ((payload[i + 1] & 0x03) << 8) | payload[i + 2]
|
|
||||||
frame_end = i + 3 + length + 3
|
|
||||||
if frame_end > len(payload):
|
|
||||||
break
|
|
||||||
|
|
||||||
message_type = ((payload[i + 3] << 4) | (payload[i + 4] >> 4)) & 0x0FFF
|
|
||||||
counts[message_type] = counts.get(message_type, 0) + 1
|
|
||||||
i = frame_end
|
|
||||||
|
|
||||||
return counts
|
|
||||||
@@ -9,7 +9,6 @@ from sqlmodel import Session, col, select
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
TerminalDeviceState,
|
TerminalDeviceState,
|
||||||
TerminalMessageRaw,
|
TerminalMessageRaw,
|
||||||
TerminalObservationEvent,
|
|
||||||
get_datetime_utc,
|
get_datetime_utc,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ def insert_raw_message(
|
|||||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
qos=qos,
|
qos=qos,
|
||||||
retain=retain,
|
retain=retain,
|
||||||
parse_status="pending",
|
|
||||||
created_at=ts,
|
created_at=ts,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
@@ -48,7 +46,8 @@ def fetch_pending_rows_for_update(
|
|||||||
) -> list[TerminalMessageRaw]:
|
) -> list[TerminalMessageRaw]:
|
||||||
statement = (
|
statement = (
|
||||||
select(TerminalMessageRaw)
|
select(TerminalMessageRaw)
|
||||||
.where(TerminalMessageRaw.parse_status == "pending")
|
.where(col(TerminalMessageRaw.parsed_at).is_(None))
|
||||||
|
.where(col(TerminalMessageRaw.parse_error).is_(None))
|
||||||
.order_by(col(TerminalMessageRaw.received_at))
|
.order_by(col(TerminalMessageRaw.received_at))
|
||||||
.limit(batch_size)
|
.limit(batch_size)
|
||||||
)
|
)
|
||||||
@@ -61,17 +60,9 @@ def fetch_pending_rows_for_update(
|
|||||||
return list(rows)
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
def mark_processing(*, session: Session, row: TerminalMessageRaw) -> None:
|
|
||||||
row.parse_status = "processing"
|
|
||||||
row.parse_attempts += 1
|
|
||||||
row.parse_error = None
|
|
||||||
session.add(row)
|
|
||||||
|
|
||||||
|
|
||||||
def mark_succeeded(*, session: Session, row: TerminalMessageRaw) -> None:
|
def mark_succeeded(*, session: Session, row: TerminalMessageRaw) -> None:
|
||||||
row.parse_status = "succeeded"
|
row.parsed_at = get_datetime_utc()
|
||||||
row.parse_error = None
|
row.parse_error = None
|
||||||
row.next_retry_at = None
|
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,15 +71,81 @@ def mark_failed(
|
|||||||
session: Session,
|
session: Session,
|
||||||
row: TerminalMessageRaw,
|
row: TerminalMessageRaw,
|
||||||
parse_error: str,
|
parse_error: str,
|
||||||
next_retry_at: datetime | None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
row.parse_status = "failed"
|
row.parsed_at = get_datetime_utc()
|
||||||
row.parse_error = parse_error[:1024]
|
row.parse_error = parse_error[:1024]
|
||||||
row.next_retry_at = next_retry_at
|
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
|
||||||
|
|
||||||
def write_events_and_update_state(
|
def _build_satellite_signal_summary(
|
||||||
|
*,
|
||||||
|
gsv_satellites: list[dict[str, Any]],
|
||||||
|
satellites_in_view: int | None,
|
||||||
|
used_satellite_ids: set[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
deduped: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for satellite in gsv_satellites:
|
||||||
|
constellation = str(satellite.get("constellation", "Unknown"))
|
||||||
|
satellite_id = str(satellite.get("satellite_id", "")).strip()
|
||||||
|
if not satellite_id:
|
||||||
|
continue
|
||||||
|
key = (constellation, satellite_id)
|
||||||
|
existing = deduped.get(key)
|
||||||
|
if existing is None:
|
||||||
|
deduped[key] = satellite
|
||||||
|
continue
|
||||||
|
|
||||||
|
incoming_snr = satellite.get("snr_dbhz")
|
||||||
|
existing_snr = existing.get("snr_dbhz")
|
||||||
|
if isinstance(incoming_snr, int) and (
|
||||||
|
not isinstance(existing_snr, int) or incoming_snr > existing_snr
|
||||||
|
):
|
||||||
|
deduped[key] = satellite
|
||||||
|
|
||||||
|
satellites = list(deduped.values())
|
||||||
|
satellites.sort(
|
||||||
|
key=lambda sat: (str(sat["constellation"]), str(sat["satellite_id"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
snr_values: list[int] = []
|
||||||
|
cn0_values: list[int] = []
|
||||||
|
constellation_summary: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
|
for satellite in gsv_satellites:
|
||||||
|
cn0_value = satellite.get("snr_dbhz")
|
||||||
|
if isinstance(cn0_value, int):
|
||||||
|
cn0_values.append(cn0_value)
|
||||||
|
|
||||||
|
for satellite in satellites:
|
||||||
|
constellation = str(satellite["constellation"])
|
||||||
|
sat_id = str(satellite["satellite_id"])
|
||||||
|
used_in_navigation = sat_id in used_satellite_ids
|
||||||
|
satellite["used_in_navigation"] = used_in_navigation
|
||||||
|
|
||||||
|
constellation_item = constellation_summary.setdefault(
|
||||||
|
constellation,
|
||||||
|
{"count": 0, "with_signal": 0, "used_in_navigation": 0},
|
||||||
|
)
|
||||||
|
constellation_item["count"] += 1
|
||||||
|
if used_in_navigation:
|
||||||
|
constellation_item["used_in_navigation"] += 1
|
||||||
|
|
||||||
|
snr_dbhz = satellite.get("snr_dbhz")
|
||||||
|
if isinstance(snr_dbhz, int):
|
||||||
|
snr_values.append(snr_dbhz)
|
||||||
|
constellation_item["with_signal"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"satellites_in_view": satellites_in_view or len(satellites),
|
||||||
|
"snr_values": snr_values,
|
||||||
|
"cn0_values": cn0_values,
|
||||||
|
"used_satellite_ids": sorted(used_satellite_ids),
|
||||||
|
"constellations": constellation_summary,
|
||||||
|
"satellites": satellites,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_device_state_from_events(
|
||||||
*,
|
*,
|
||||||
session: Session,
|
session: Session,
|
||||||
row: TerminalMessageRaw,
|
row: TerminalMessageRaw,
|
||||||
@@ -107,17 +164,13 @@ def write_events_and_update_state(
|
|||||||
if state is None:
|
if state is None:
|
||||||
state = TerminalDeviceState(device_id=row.device_id)
|
state = TerminalDeviceState(device_id=row.device_id)
|
||||||
|
|
||||||
|
gsv_satellites: list[dict[str, Any]] = []
|
||||||
|
satellites_in_view: int | None = None
|
||||||
|
used_satellite_ids: set[str] = set()
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
payload = event.get("payload", {})
|
payload = event.get("payload", {})
|
||||||
event_type = str(event.get("event_type", "unknown"))
|
event_type = str(event.get("event_type", "unknown"))
|
||||||
db_event = TerminalObservationEvent(
|
|
||||||
raw_id=row.id,
|
|
||||||
device_id=row.device_id,
|
|
||||||
event_time=row.received_at,
|
|
||||||
event_type=event_type,
|
|
||||||
payload_json=payload if isinstance(payload, dict) else {"value": payload},
|
|
||||||
)
|
|
||||||
session.add(db_event)
|
|
||||||
|
|
||||||
if event_type == "nmea_gga":
|
if event_type == "nmea_gga":
|
||||||
state.lat = payload.get("lat")
|
state.lat = payload.get("lat")
|
||||||
@@ -133,16 +186,52 @@ def write_events_and_update_state(
|
|||||||
if payload.get("lon") is not None:
|
if payload.get("lon") is not None:
|
||||||
state.lon = payload.get("lon")
|
state.lon = payload.get("lon")
|
||||||
status = payload.get("status")
|
status = payload.get("status")
|
||||||
if status is not None:
|
if status is not None and state.fix_status is None:
|
||||||
state.fix_status = str(status)
|
state.fix_status = str(status)
|
||||||
elif event_type == "nmea_gsv":
|
elif event_type == "nmea_gsv":
|
||||||
state.satellite_signal_summary = {
|
payload_satellites = payload.get("satellites", [])
|
||||||
"snr_values": payload.get("snr_values", []),
|
if isinstance(payload_satellites, list):
|
||||||
"satellites_in_view": payload.get("satellites_in_view"),
|
for sat in payload_satellites:
|
||||||
}
|
if not isinstance(sat, dict):
|
||||||
|
continue
|
||||||
|
gsv_satellites.append(
|
||||||
|
{
|
||||||
|
"satellite_id": str(sat.get("satellite_id", "")),
|
||||||
|
"elevation_deg": sat.get("elevation_deg"),
|
||||||
|
"azimuth_deg": sat.get("azimuth_deg"),
|
||||||
|
"snr_dbhz": sat.get("snr_dbhz"),
|
||||||
|
"constellation": payload.get("constellation", "Unknown"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
satellites_count = payload.get("satellites_in_view")
|
||||||
|
if isinstance(satellites_count, int):
|
||||||
|
satellites_in_view = max(satellites_in_view or 0, satellites_count)
|
||||||
|
elif event_type == "nmea_gsa":
|
||||||
|
for satellite_id in payload.get("used_satellite_ids", []):
|
||||||
|
if satellite_id is None:
|
||||||
|
continue
|
||||||
|
used_satellite_ids.add(str(satellite_id))
|
||||||
elif event_type == "rtcm_summary":
|
elif event_type == "rtcm_summary":
|
||||||
state.rtcm_type_summary = payload.get("rtcm_types", {})
|
state.rtcm_type_summary = payload.get("rtcm_types", {})
|
||||||
|
|
||||||
|
if gsv_satellites:
|
||||||
|
state.satellite_signal_summary = _build_satellite_signal_summary(
|
||||||
|
gsv_satellites=gsv_satellites,
|
||||||
|
satellites_in_view=satellites_in_view,
|
||||||
|
used_satellite_ids=used_satellite_ids,
|
||||||
|
)
|
||||||
|
elif used_satellite_ids and state.satellite_signal_summary:
|
||||||
|
current_summary = dict(state.satellite_signal_summary)
|
||||||
|
current_summary["used_satellite_ids"] = sorted(used_satellite_ids)
|
||||||
|
satellites = current_summary.get("satellites", [])
|
||||||
|
if isinstance(satellites, list):
|
||||||
|
for satellite in satellites:
|
||||||
|
if not isinstance(satellite, dict):
|
||||||
|
continue
|
||||||
|
sat_id = str(satellite.get("satellite_id", ""))
|
||||||
|
satellite["used_in_navigation"] = sat_id in used_satellite_ids
|
||||||
|
state.satellite_signal_summary = current_summary
|
||||||
|
|
||||||
state.last_event_time = row.received_at
|
state.last_event_time = row.received_at
|
||||||
state.updated_at = get_datetime_utc()
|
state.updated_at = get_datetime_utc()
|
||||||
session.add(state)
|
session.add(state)
|
||||||
|
|||||||
158
backend/app/mqtt/telemetry.py
Normal file
158
backend/app/mqtt/telemetry.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
|
from app.models import TerminalDeviceState, get_datetime_utc
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value: Any) -> int | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
if stripped.isdigit():
|
||||||
|
return int(stripped)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_satellite_summary(summary: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
return {
|
||||||
|
"satellites_in_view": None,
|
||||||
|
"snr_values": [],
|
||||||
|
"cn0_values": [],
|
||||||
|
"constellations": {},
|
||||||
|
"used_satellite_ids": [],
|
||||||
|
"satellites": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_satellites = summary.get("satellites", [])
|
||||||
|
satellites: list[dict[str, Any]] = []
|
||||||
|
if isinstance(raw_satellites, list):
|
||||||
|
for satellite in raw_satellites:
|
||||||
|
if not isinstance(satellite, dict):
|
||||||
|
continue
|
||||||
|
satellite_id = str(satellite.get("satellite_id", "")).strip()
|
||||||
|
if not satellite_id:
|
||||||
|
continue
|
||||||
|
satellites.append(
|
||||||
|
{
|
||||||
|
"satellite_id": satellite_id,
|
||||||
|
"constellation": str(satellite.get("constellation", "Unknown")),
|
||||||
|
"azimuth_deg": _to_int(satellite.get("azimuth_deg")),
|
||||||
|
"elevation_deg": _to_int(satellite.get("elevation_deg")),
|
||||||
|
"snr_dbhz": _to_int(satellite.get("snr_dbhz")),
|
||||||
|
"used_in_navigation": bool(
|
||||||
|
satellite.get("used_in_navigation", False)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
snr_values: list[int] = []
|
||||||
|
raw_snr_values = summary.get("snr_values", [])
|
||||||
|
if isinstance(raw_snr_values, list):
|
||||||
|
for value in raw_snr_values:
|
||||||
|
converted = _to_int(value)
|
||||||
|
if converted is not None:
|
||||||
|
snr_values.append(converted)
|
||||||
|
|
||||||
|
cn0_values: list[int] = []
|
||||||
|
raw_cn0_values = summary.get("cn0_values", [])
|
||||||
|
if isinstance(raw_cn0_values, list):
|
||||||
|
for value in raw_cn0_values:
|
||||||
|
converted = _to_int(value)
|
||||||
|
if converted is not None:
|
||||||
|
cn0_values.append(converted)
|
||||||
|
if not cn0_values:
|
||||||
|
cn0_values = list(snr_values)
|
||||||
|
|
||||||
|
satellites_in_view = _to_int(summary.get("satellites_in_view"))
|
||||||
|
if satellites_in_view is None:
|
||||||
|
satellites_in_view = len(satellites)
|
||||||
|
|
||||||
|
constellations = summary.get("constellations", {})
|
||||||
|
if not isinstance(constellations, dict):
|
||||||
|
constellations = {}
|
||||||
|
|
||||||
|
used_satellite_ids = summary.get("used_satellite_ids", [])
|
||||||
|
if not isinstance(used_satellite_ids, list):
|
||||||
|
used_satellite_ids = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"satellites_in_view": satellites_in_view,
|
||||||
|
"snr_values": snr_values,
|
||||||
|
"cn0_values": cn0_values,
|
||||||
|
"constellations": constellations,
|
||||||
|
"used_satellite_ids": used_satellite_ids,
|
||||||
|
"satellites": satellites,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signal_health(snr_values: list[int]) -> dict[str, Any]:
|
||||||
|
strong_signal_count = sum(1 for value in snr_values if value >= 40)
|
||||||
|
average_snr = (sum(snr_values) / len(snr_values)) if snr_values else None
|
||||||
|
best_snr = max(snr_values) if snr_values else None
|
||||||
|
|
||||||
|
if strong_signal_count >= 20:
|
||||||
|
level = "good"
|
||||||
|
message = "At least 20 satellites have CN0 >= 40"
|
||||||
|
elif strong_signal_count >= 10:
|
||||||
|
level = "fair"
|
||||||
|
message = "At least 10 satellites have CN0 >= 40"
|
||||||
|
else:
|
||||||
|
level = "poor"
|
||||||
|
message = "Less than 10 satellites have CN0 >= 40"
|
||||||
|
|
||||||
|
score = min(strong_signal_count * 5, 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": level,
|
||||||
|
"score": score,
|
||||||
|
"strong_signal_count": strong_signal_count,
|
||||||
|
"average_snr_dbhz": round(average_snr, 1) if average_snr is not None else None,
|
||||||
|
"best_snr_dbhz": best_snr,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_device_state(state: TerminalDeviceState) -> dict[str, Any]:
|
||||||
|
signal_summary = _normalize_satellite_summary(state.satellite_signal_summary)
|
||||||
|
signal_health = _build_signal_health(signal_summary["snr_values"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"device_id": state.device_id,
|
||||||
|
"last_event_time": state.last_event_time,
|
||||||
|
"updated_at": state.updated_at,
|
||||||
|
"fix_status": state.fix_status,
|
||||||
|
"lat": state.lat,
|
||||||
|
"lon": state.lon,
|
||||||
|
"alt_m": state.alt_m,
|
||||||
|
"satellites_used": state.satellites_used,
|
||||||
|
"satellite_signal_summary": signal_summary,
|
||||||
|
"signal_health": signal_health,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_telemetry_snapshot(
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
device_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
statement = select(TerminalDeviceState).order_by(
|
||||||
|
col(TerminalDeviceState.updated_at).desc()
|
||||||
|
)
|
||||||
|
if device_id:
|
||||||
|
statement = statement.where(TerminalDeviceState.device_id == device_id)
|
||||||
|
statement = statement.limit(limit)
|
||||||
|
|
||||||
|
states = session.exec(statement).all()
|
||||||
|
devices = [_serialize_device_state(state) for state in states]
|
||||||
|
|
||||||
|
return {"generated_at": get_datetime_utc(), "devices": devices}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
TOPIC_PATTERN = re.compile(r"^terminal/(?P<device_id>[^/]+)/raw$")
|
|
||||||
|
|
||||||
|
|
||||||
def extract_device_id(topic: str) -> str:
|
|
||||||
match = TOPIC_PATTERN.match(topic)
|
|
||||||
if match is None:
|
|
||||||
raise ValueError("topic must match terminal/{device_id}/raw")
|
|
||||||
return match.group("device_id")
|
|
||||||
223
backend/tests/api/routes/test_mqtt_raw.py
Normal file
223
backend/tests/api/routes/test_mqtt_raw.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, delete, func, select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import TerminalMessageRaw
|
||||||
|
from app.mqtt.repository import insert_raw_message
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_raw_data(db: Session) -> None:
|
||||||
|
db.execute(delete(TerminalMessageRaw))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _count_raw_rows(db: Session) -> int:
|
||||||
|
statement = select(func.count()).select_from(TerminalMessageRaw)
|
||||||
|
return int(db.exec(statement).one())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_mqtt_raw_days(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A1",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 10, 8, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A2",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 10, 21, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-b",
|
||||||
|
topic="terminal/device-b/raw",
|
||||||
|
payload=b"B1",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 11, 9, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/days?skip=0&limit=10",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["count"] == 2
|
||||||
|
assert content["data"][0]["day"] == "2026-03-11"
|
||||||
|
assert content["data"][0]["message_count"] == 2
|
||||||
|
assert content["data"][1]["day"] == "2026-03-10"
|
||||||
|
assert content["data"][1]["message_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_mqtt_raw_by_day_or_time_range(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
target_day = date(2026, 3, 12)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"EARLY\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 11, 20, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"MID\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 12, 3, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"LATE\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 12, 12, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"NEXT_DAY\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 12, 18, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
full_day_response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/download",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
params={"day": target_day.isoformat()},
|
||||||
|
)
|
||||||
|
assert full_day_response.status_code == 200
|
||||||
|
assert full_day_response.content == b"EARLY\nMID\nLATE\n"
|
||||||
|
|
||||||
|
range_response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/download",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
params={
|
||||||
|
"day": target_day.isoformat(),
|
||||||
|
"start_seconds": 10 * 3600,
|
||||||
|
"end_seconds": 14 * 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert range_response.status_code == 200
|
||||||
|
assert range_response.content == b"MID\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_mqtt_raw_by_day(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-x",
|
||||||
|
topic="terminal/device-x/raw",
|
||||||
|
payload=b"X",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 14, 20, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-b",
|
||||||
|
topic="terminal/device-b/raw",
|
||||||
|
payload=b"B",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 12, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-c",
|
||||||
|
topic="terminal/device-c/raw",
|
||||||
|
payload=b"C",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 16, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/day/2026-03-15",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["deleted_count"] == 3
|
||||||
|
assert _count_raw_rows(db) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_all_mqtt_raw_requires_confirmation(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-b",
|
||||||
|
topic="terminal/device-b/raw",
|
||||||
|
payload=b"B",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
reject_response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/all",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert reject_response.status_code == 400
|
||||||
|
assert _count_raw_rows(db) == 2
|
||||||
|
|
||||||
|
confirmed_response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/all?confirm=true",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert confirmed_response.status_code == 200
|
||||||
|
content = confirmed_response.json()
|
||||||
|
assert content["deleted_count"] == 2
|
||||||
|
assert _count_raw_rows(db) == 0
|
||||||
127
backend/tests/api/routes/test_telemetry.py
Normal file
127
backend/tests/api/routes/test_telemetry.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import TerminalDeviceState
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_device_state(
|
||||||
|
db: Session, device_id: str, snr_values: list[int] | None = None
|
||||||
|
) -> None:
|
||||||
|
state = db.get(TerminalDeviceState, device_id)
|
||||||
|
if state is None:
|
||||||
|
state = TerminalDeviceState(device_id=device_id)
|
||||||
|
|
||||||
|
signal_values = snr_values if snr_values is not None else [28, 31, 36, 41, 44]
|
||||||
|
|
||||||
|
state.fix_status = "5"
|
||||||
|
state.lat = 31.359591
|
||||||
|
state.lon = 120.714489
|
||||||
|
state.alt_m = 52.1
|
||||||
|
state.satellites_used = 17
|
||||||
|
state.satellite_signal_summary = {
|
||||||
|
"satellites_in_view": 22,
|
||||||
|
"snr_values": signal_values,
|
||||||
|
"constellations": {
|
||||||
|
"GPS": {"count": 8, "used_in_navigation": 6},
|
||||||
|
"BeiDou": {"count": 10, "used_in_navigation": 8},
|
||||||
|
"Galileo": {"count": 4, "used_in_navigation": 3},
|
||||||
|
},
|
||||||
|
"satellites": [
|
||||||
|
{
|
||||||
|
"satellite_id": "G05",
|
||||||
|
"constellation": "GPS",
|
||||||
|
"azimuth_deg": 121,
|
||||||
|
"elevation_deg": 29,
|
||||||
|
"snr_dbhz": 37,
|
||||||
|
"used_in_navigation": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
state.last_event_time = datetime.now(timezone.utc)
|
||||||
|
db.add(state)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_telemetry_latest(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
device_id = "device-api-001"
|
||||||
|
_seed_device_state(db, device_id)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/telemetry/latest",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "generated_at" in payload
|
||||||
|
assert len(payload["devices"]) >= 1
|
||||||
|
device = next(item for item in payload["devices"] if item["device_id"] == device_id)
|
||||||
|
assert device["fix_status"] == "5"
|
||||||
|
assert device["signal_health"]["level"] == "poor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_health_levels_based_on_count_of_40_plus_signals(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_seed_device_state(db, "device-health-good", [40] * 20 + [15] * 3)
|
||||||
|
_seed_device_state(db, "device-health-fair", [40] * 10 + [20] * 3)
|
||||||
|
_seed_device_state(db, "device-health-poor", [40] * 9 + [39])
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/telemetry/latest",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
params={"limit": 200},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
devices = {item["device_id"]: item for item in response.json()["devices"]}
|
||||||
|
|
||||||
|
assert devices["device-health-good"]["signal_health"]["level"] == "good"
|
||||||
|
assert devices["device-health-fair"]["signal_health"]["level"] == "fair"
|
||||||
|
assert devices["device-health-poor"]["signal_health"]["level"] == "poor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_health_uses_satellite_level_values_for_ucenter_view(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
state = db.get(TerminalDeviceState, "device-health-ucenter")
|
||||||
|
if state is None:
|
||||||
|
state = TerminalDeviceState(device_id="device-health-ucenter")
|
||||||
|
state.fix_status = "5"
|
||||||
|
state.satellite_signal_summary = {
|
||||||
|
# u-center signal bars are per-satellite bars, not per-frequency points
|
||||||
|
"snr_values": [41] * 9 + [30] * 4,
|
||||||
|
# keep raw CN0 points for diagnostics, but health should follow bar view
|
||||||
|
"cn0_values": [41] * 20 + [25] * 5,
|
||||||
|
}
|
||||||
|
db.add(state)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/telemetry/latest",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
params={"device_id": "device-health-ucenter"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
device = response.json()["devices"][0]
|
||||||
|
assert device["signal_health"]["strong_signal_count"] == 9
|
||||||
|
assert device["signal_health"]["level"] == "poor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_websocket_stream(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_seed_device_state(db, "device-api-002")
|
||||||
|
token = superuser_token_headers["Authorization"].split(" ", 1)[1]
|
||||||
|
|
||||||
|
with client.websocket_connect(
|
||||||
|
f"{settings.API_V1_STR}/telemetry/ws?token={token}"
|
||||||
|
) as websocket:
|
||||||
|
payload = websocket.receive_json()
|
||||||
|
|
||||||
|
assert "generated_at" in payload
|
||||||
|
assert len(payload["devices"]) >= 1
|
||||||
448
backend/tests/mqtt/nmea.txt
Normal file
448
backend/tests/mqtt/nmea.txt
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
$GNRMC,015903.000,A,3121.575445,N,12042.869341,E,0.015,51.12,080126,,E,A,V*48
|
||||||
|
$GNGGA,015903.000,3121.575445,N,12042.869341,E,1,17,1.09,51.800,M,0.000,M,,*79
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A
|
||||||
|
$GPGSV,3,3,9,32,34,146,20,1*6A
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,251,00,34,12,157,00,35,11,059,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,251,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,36,75,13,044,43,1*47
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015903.000,0.86,8.38,1.39,81.42,32.311,7.315,13.683*43
|
||||||
|
$GNSVD,0,0,0,118,22,22*40
|
||||||
|
$GNRMC,015904.000,A,3121.575704,N,12042.869303,E,0.050,334.82,080126,,E,A,V*77
|
||||||
|
$GNGGA,015904.000,3121.575704,N,12042.869303,E,1,17,1.09,51.838,M,0.000,M,,*75
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A
|
||||||
|
$GPGSV,3,3,9,32,34,146,19,1*60
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,30,8*5A
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,41,1*76
|
||||||
|
$GBGSV,8,5,29,33,58,251,00,34,12,157,00,35,11,059,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,251,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
|
||||||
|
$GLGSV,2,2,8,76,53,352,42,77,39,277,00,85,09,055,41,86,08,112,00,1*48
|
||||||
|
$GNGST,015904.000,0.93,8.38,1.38,81.41,32.302,7.294,13.653*45
|
||||||
|
$GNSVD,-1,2,0,350,68,71*68
|
||||||
|
$GNRMC,015905.000,A,3121.575859,N,12042.869263,E,0.039,345.46,080126,,E,A,V*77
|
||||||
|
$GNGGA,015905.000,3121.575859,N,12042.869263,E,1,17,1.09,51.796,M,0.000,M,,*7F
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A
|
||||||
|
$GPGSV,3,3,9,32,34,146,19,1*60
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015905.000,0.68,8.41,1.37,81.41,32.384,7.264,13.612*45
|
||||||
|
$GNSVD,0,1,0,409,76,76*44
|
||||||
|
$GNRMC,015906.000,A,3121.576069,N,12042.869216,E,0.042,336.34,080126,,E,A,V*73
|
||||||
|
$GNGGA,015906.000,3121.576069,N,12042.869216,E,1,17,1.09,51.776,M,0.000,M,,*78
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,357,39,1*5A
|
||||||
|
$GPGSV,3,3,9,32,34,146,20,1*6A
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,41,1*76
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015906.000,0.55,8.42,1.36,81.36,32.417,7.258,13.592*43
|
||||||
|
$GNSVD,0,2,0,407,84,79*4B
|
||||||
|
$GNRMC,015907.000,A,3121.576167,N,12042.869218,E,0.058,344.47,080126,,E,A,V*79
|
||||||
|
$GNGGA,015907.000,3121.576167,N,12042.869218,E,1,17,1.09,51.827,M,0.000,M,,*73
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,39,1*55
|
||||||
|
$GPGSV,3,3,9,32,34,146,20,1*6A
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015907.000,0.74,8.46,1.36,81.39,32.556,7.267,13.652*4D
|
||||||
|
$GNSVD,0,2,1,351,70,80*43
|
||||||
|
$GNRMC,015908.000,A,3121.576274,N,12042.869205,E,0.037,270.24,080126,,E,A,V*71
|
||||||
|
$GNGGA,015908.000,3121.576274,N,12042.869205,E,1,17,1.09,51.877,M,0.000,M,,*74
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54
|
||||||
|
$GPGSV,3,3,9,32,34,146,20,1*6A
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,250,00,4*7A
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015908.000,0.73,8.42,1.35,81.41,32.460,7.222,13.595*40
|
||||||
|
$GNSVD,-1,0,8,345,70,74*6A
|
||||||
|
$GNRMC,015909.000,A,3121.576447,N,12042.869168,E,0.038,223.29,080126,,E,A,V*7A
|
||||||
|
$GNGGA,015909.000,3121.576447,N,12042.869168,E,1,17,1.09,51.981,M,0.000,M,,*73
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54
|
||||||
|
$GPGSV,3,3,9,32,34,146,20,1*6A
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015909.000,0.79,8.42,1.35,81.34,32.445,7.223,13.571*45
|
||||||
|
$GNSVD,-1,-1,10,379,80,76*7D
|
||||||
|
$GNRMC,015910.000,A,3121.576443,N,12042.869180,E,0.053,156.87,080126,,E,A,V*78
|
||||||
|
$GNGGA,015910.000,3121.576443,N,12042.869180,E,1,17,1.09,52.067,M,0.000,M,,*7B
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54
|
||||||
|
$GPGSV,3,3,9,32,34,146,19,1*60
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43
|
||||||
|
$GLGSV,2,2,8,76,53,352,42,77,39,277,00,85,09,055,41,86,08,112,00,1*48
|
||||||
|
$GNGST,015910.000,0.96,8.50,1.36,81.36,32.672,7.253,13.641*4F
|
||||||
|
$GNSVD,1,-2,8,400,77,78*65
|
||||||
|
$GNRMC,015911.000,A,3121.576500,N,12042.869206,E,0.064,148.17,080126,,E,A,V*70
|
||||||
|
$GNGGA,015911.000,3121.576500,N,12042.869206,E,1,17,1.09,52.146,M,0.000,M,,*73
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B
|
||||||
|
$GPGSV,3,3,9,32,34,146,19,1*60
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70
|
||||||
|
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,003,37,4*74
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015911.000,0.75,8.51,1.34,81.40,32.724,7.216,13.607*40
|
||||||
|
$GNSVD,1,-2,6,394,71,77*68
|
||||||
|
$GNRMC,015912.000,A,3121.576672,N,12042.869211,E,0.050,221.67,080126,,E,A,V*7F
|
||||||
|
$GNGGA,015912.000,3121.576672,N,12042.869211,E,1,17,1.09,52.182,M,0.000,M,,*78
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,40,1*5A
|
||||||
|
$GPGSV,3,3,9,32,34,146,20,1*6A
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,059,34,33,58,250,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015912.000,0.81,8.56,1.33,81.41,32.863,7.193,13.598*4E
|
||||||
|
$GNSVD,-1,-1,2,463,81,80*4A
|
||||||
|
$GNRMC,015913.000,A,3121.576893,N,12042.869268,E,0.038,116.71,080126,,E,A,V*7F
|
||||||
|
$GNGGA,015913.000,3121.576893,N,12042.869268,E,1,17,1.09,52.385,M,0.000,M,,*73
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,39,1*55
|
||||||
|
$GPGSV,3,3,9,32,34,146,21,1*6B
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015913.000,0.85,8.57,1.32,81.45,32.915,7.164,13.564*44
|
||||||
|
$GNSVD,1,0,3,396,70,75*43
|
||||||
|
$GNRMC,015914.000,A,3121.576888,N,12042.869261,E,0.042,124.95,080126,,E,A,V*7D
|
||||||
|
$GNGGA,015914.000,3121.576888,N,12042.869261,E,1,17,1.09,52.400,M,0.000,M,,*7D
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,29,1*55
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B
|
||||||
|
$GPGSV,3,3,9,32,34,146,22,1*68
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015914.000,1.04,8.64,1.32,81.48,33.129,7.180,13.627*4E
|
||||||
|
$GNSVD,1,-1,2,115,20,22*60
|
||||||
|
$GNRMC,015915.000,A,3121.576930,N,12042.869269,E,0.082,170.97,080126,,E,A,V*79
|
||||||
|
$GNGGA,015915.000,3121.576930,N,12042.869269,E,1,17,1.09,52.478,M,0.000,M,,*79
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B
|
||||||
|
$GPGSV,3,3,9,32,34,146,23,1*69
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
|
||||||
|
$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,059,34,33,58,250,00,4*7B
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015915.000,1.04,8.66,1.32,81.53,33.219,7.158,13.611*47
|
||||||
|
$GNSVD,0,-4,0,372,67,69*69
|
||||||
|
$GNRMC,015916.000,A,3121.576981,N,12042.869285,E,0.093,164.37,080126,,E,A,V*7D
|
||||||
|
$GNGGA,015916.000,3121.576981,N,12042.869285,E,1,17,1.09,52.469,M,0.000,M,,*72
|
||||||
|
$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C
|
||||||
|
$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04
|
||||||
|
$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04
|
||||||
|
$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,45,31,59,358,39,1*54
|
||||||
|
$GPGSV,3,3,9,32,34,146,23,1*69
|
||||||
|
$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B
|
||||||
|
$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50
|
||||||
|
$GPGSV,3,3,9,32,34,146,00,8*61
|
||||||
|
$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78
|
||||||
|
$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71
|
||||||
|
$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A
|
||||||
|
$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77
|
||||||
|
$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71
|
||||||
|
$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E
|
||||||
|
$GBGSV,8,7,29,44,23,104,00,50,09,164,00,59,48,145,00,60,33,239,00,1*77
|
||||||
|
$GBGSV,8,8,29,61,51,199,00,1*4F
|
||||||
|
$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A
|
||||||
|
$GBGSV,4,2,13,34,12,157,00,35,11,058,36,38,17,196,00,39,62,003,37,4*74
|
||||||
|
$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,104,00,4*7B
|
||||||
|
$GBGSV,4,4,13,50,09,164,00,4*4E
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,7*79
|
||||||
|
$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43
|
||||||
|
$GAGSV,2,2,5,31,78,255,00,1*7F
|
||||||
|
$GLGSV,2,1,8,65,39,176,00,66,72,274,00,67,21,329,35,75,13,044,43,1*45
|
||||||
|
$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49
|
||||||
|
$GNGST,015916.000,1.05,8.75,1.31,81.60,33.492,7.151,13.627*4D
|
||||||
|
$GNSVD,1,-4,-1,478,76,80*4E
|
||||||
@@ -25,3 +25,24 @@ def test_parse_mixed_payload_returns_rtcm_summary() -> None:
|
|||||||
|
|
||||||
event_types = {event["event_type"] for event in events}
|
event_types = {event["event_type"] for event in events}
|
||||||
assert "rtcm_summary" in event_types
|
assert "rtcm_summary" in event_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nmea_sentences_extracts_satellite_details_and_used_ids() -> None:
|
||||||
|
payload = (
|
||||||
|
b"$GPGSA,A,3,02,05,12,,,,,,,,,,1.50,0.90,1.20*00\r\n"
|
||||||
|
b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
events = parse_nmea_sentences(payload)
|
||||||
|
gsa_event = next(event for event in events if event["event_type"] == "nmea_gsa")
|
||||||
|
gsv_event = next(event for event in events if event["event_type"] == "nmea_gsv")
|
||||||
|
|
||||||
|
assert gsa_event["payload"]["used_satellite_ids"] == ["02", "05", "12"]
|
||||||
|
assert gsv_event["payload"]["constellation"] == "GPS"
|
||||||
|
assert len(gsv_event["payload"]["satellites"]) == 4
|
||||||
|
assert gsv_event["payload"]["satellites"][0] == {
|
||||||
|
"satellite_id": "02",
|
||||||
|
"elevation_deg": 17,
|
||||||
|
"azimuth_deg": 213,
|
||||||
|
"snr_dbhz": 40,
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ def test_terminal_message_raw_model_can_persist() -> None:
|
|||||||
session.refresh(row)
|
session.refresh(row)
|
||||||
|
|
||||||
assert row.id is not None
|
assert row.id is not None
|
||||||
assert row.parse_status == "pending"
|
assert row.parsed_at is None
|
||||||
|
assert row.parse_error is None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlmodel import Session, SQLModel, create_engine, select
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
from app.models import TerminalDeviceState, TerminalMessageRaw, TerminalObservationEvent
|
from app.models import TerminalDeviceState, TerminalMessageRaw
|
||||||
from app.mqtt.parser_worker import process_pending_batch
|
from app.mqtt.parser_worker import process_pending_batch
|
||||||
from app.mqtt.repository import insert_raw_message
|
from app.mqtt.repository import insert_raw_message
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ def _session() -> Session:
|
|||||||
return Session(engine)
|
return Session(engine)
|
||||||
|
|
||||||
|
|
||||||
def test_insert_raw_message_sets_pending_status() -> None:
|
def test_insert_raw_message_sets_unparsed_defaults() -> None:
|
||||||
with _session() as session:
|
with _session() as session:
|
||||||
row = insert_raw_message(
|
row = insert_raw_message(
|
||||||
session=session,
|
session=session,
|
||||||
@@ -22,12 +22,13 @@ def test_insert_raw_message_sets_pending_status() -> None:
|
|||||||
retain=False,
|
retain=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert row.parse_status == "pending"
|
assert row.parsed_at is None
|
||||||
|
assert row.parse_error is None
|
||||||
assert row.payload_size > 0
|
assert row.payload_size > 0
|
||||||
assert len(row.payload_sha256) == 64
|
assert len(row.payload_sha256) == 64
|
||||||
|
|
||||||
|
|
||||||
def test_worker_marks_success_and_writes_events() -> None:
|
def test_worker_marks_success_and_updates_state() -> None:
|
||||||
with _session() as session:
|
with _session() as session:
|
||||||
raw = insert_raw_message(
|
raw = insert_raw_message(
|
||||||
session=session,
|
session=session,
|
||||||
@@ -44,10 +45,105 @@ def test_worker_marks_success_and_writes_events() -> None:
|
|||||||
|
|
||||||
refreshed = session.get(TerminalMessageRaw, raw.id)
|
refreshed = session.get(TerminalMessageRaw, raw.id)
|
||||||
assert refreshed is not None
|
assert refreshed is not None
|
||||||
assert refreshed.parse_status == "succeeded"
|
assert refreshed.parsed_at is not None
|
||||||
|
assert refreshed.parse_error is None
|
||||||
events = session.exec(select(TerminalObservationEvent)).all()
|
|
||||||
assert len(events) >= 1
|
|
||||||
|
|
||||||
state = session.get(TerminalDeviceState, "device-001")
|
state = session.get(TerminalDeviceState, "device-001")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
|
assert state.lat is not None
|
||||||
|
assert state.lon is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_marks_failed_without_retry_schedule(monkeypatch) -> None:
|
||||||
|
def _raise_parse_error(_payload: bytes) -> list[dict[str, object]]:
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.mqtt.parser_worker.parse_mixed_payload", _raise_parse_error
|
||||||
|
)
|
||||||
|
|
||||||
|
with _session() as session:
|
||||||
|
raw = insert_raw_message(
|
||||||
|
session=session,
|
||||||
|
device_id="device-err",
|
||||||
|
topic="terminal/device-err/raw",
|
||||||
|
payload=b"$GPGGA,broken*00\r\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
processed = process_pending_batch(session=session, batch_size=10)
|
||||||
|
|
||||||
|
assert processed == 1
|
||||||
|
refreshed = session.get(TerminalMessageRaw, raw.id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.parsed_at is not None
|
||||||
|
assert refreshed.parse_error == "boom"
|
||||||
|
processed_again = process_pending_batch(session=session, batch_size=10)
|
||||||
|
assert processed_again == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_excludes_terminal_observation_event_table() -> None:
|
||||||
|
assert "terminal_observation_event" not in SQLModel.metadata.tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_and_parse_real_nmea_sample() -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sample_path = Path(__file__).parent / "nmea.txt"
|
||||||
|
payload = sample_path.read_bytes()
|
||||||
|
|
||||||
|
with _session() as session:
|
||||||
|
raw = insert_raw_message(
|
||||||
|
session=session,
|
||||||
|
device_id="device-real-sample",
|
||||||
|
topic="terminal/device-real-sample/raw",
|
||||||
|
payload=payload,
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
processed = process_pending_batch(session=session, batch_size=10)
|
||||||
|
assert processed == 1
|
||||||
|
|
||||||
|
refreshed = session.get(TerminalMessageRaw, raw.id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.parsed_at is not None
|
||||||
|
assert refreshed.parse_error is None
|
||||||
|
|
||||||
|
state = session.get(TerminalDeviceState, "device-real-sample")
|
||||||
|
assert state is not None
|
||||||
|
assert state.lat is not None
|
||||||
|
assert state.lon is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_builds_satellite_distribution_summary() -> None:
|
||||||
|
payload = (
|
||||||
|
b"$GNGGA,123519,3723.2475,N,12158.3416,W,4,12,0.8,10.0,M,-25.0,M,,*65\r\n"
|
||||||
|
b"$GPGSA,A,3,02,05,12,,,,,,,,,,1.50,0.90,1.20*00\r\n"
|
||||||
|
b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with _session() as session:
|
||||||
|
insert_raw_message(
|
||||||
|
session=session,
|
||||||
|
device_id="device-gsv",
|
||||||
|
topic="terminal/device-gsv/raw",
|
||||||
|
payload=payload,
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
processed = process_pending_batch(session=session, batch_size=10)
|
||||||
|
|
||||||
|
assert processed == 1
|
||||||
|
state = session.get(TerminalDeviceState, "device-gsv")
|
||||||
|
assert state is not None
|
||||||
|
assert state.fix_status == "4"
|
||||||
|
summary = state.satellite_signal_summary
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["satellites_in_view"] == 4
|
||||||
|
assert summary["snr_values"] == [40, 37, 44, 39]
|
||||||
|
assert summary["used_satellite_ids"] == ["02", "05", "12"]
|
||||||
|
assert summary["constellations"]["GPS"]["count"] == 4
|
||||||
|
assert summary["constellations"]["GPS"]["used_in_navigation"] == 3
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.mqtt.topic import extract_device_id
|
from app.mqtt.ingestor import extract_device_id
|
||||||
|
|
||||||
|
|
||||||
def test_extract_device_id_success() -> None:
|
def test_extract_device_id_success() -> None:
|
||||||
|
|||||||
31
bun.lock
31
bun.lock
@@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@@ -28,10 +29,13 @@
|
|||||||
"@tanstack/react-router": "^1.163.3",
|
"@tanstack/react-router": "^1.163.3",
|
||||||
"@tanstack/react-router-devtools": "^1.163.3",
|
"@tanstack/react-router-devtools": "^1.163.3",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"form-data": "4.0.5",
|
"form-data": "4.0.5",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -39,6 +43,8 @@
|
|||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-cluster": "^4.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
@@ -50,6 +56,7 @@
|
|||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@tanstack/router-devtools": "^1.159.10",
|
"@tanstack/router-devtools": "^1.159.10",
|
||||||
"@tanstack/router-plugin": "^1.140.0",
|
"@tanstack/router-plugin": "^1.140.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.2",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -250,6 +257,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||||
@@ -278,6 +287,8 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
|
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.2", "", { "os": "android", "cpu": "arm" }, "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.2", "", { "os": "android", "cpu": "arm" }, "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA=="],
|
||||||
@@ -426,8 +437,14 @@
|
|||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
|
||||||
|
|
||||||
|
"@types/leaflet.markercluster": ["@types/leaflet.markercluster@1.5.6", "", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
||||||
@@ -610,6 +627,10 @@
|
|||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||||
|
|
||||||
|
"leaflet.markercluster": ["leaflet.markercluster@1.5.3", "", { "peerDependencies": { "leaflet": "^1.3.1" } }, "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
@@ -710,6 +731,10 @@
|
|||||||
|
|
||||||
"react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
|
"react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
|
||||||
|
|
||||||
|
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
|
||||||
|
|
||||||
|
"react-leaflet-cluster": ["react-leaflet-cluster@4.0.0", "", { "dependencies": { "leaflet.markercluster": "^1.5.3" }, "peerDependencies": { "@react-leaflet/core": "^3.0.0", "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0" } }, "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw=="],
|
||||||
|
|
||||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
@@ -848,6 +873,10 @@
|
|||||||
|
|
||||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
@@ -928,6 +957,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# Local services are available on their ports, but also available on:
|
|
||||||
# http://api.localhost.tiangolo.com: backend
|
|
||||||
# http://dashboard.localhost.tiangolo.com: frontend
|
|
||||||
# etc. To enable it, update .env, set:
|
|
||||||
# DOMAIN=localhost.tiangolo.com
|
|
||||||
proxy:
|
|
||||||
image: traefik:3.6
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "8090:8080"
|
|
||||||
# Duplicate the command from compose.yml to add --api.insecure=true
|
|
||||||
command:
|
|
||||||
# Enable Docker in Traefik, so that it reads labels from Docker services
|
|
||||||
- --providers.docker
|
|
||||||
# Add a constraint to only use services with the label for this stack
|
|
||||||
- --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`)
|
|
||||||
# Do not expose all Docker services, only the ones explicitly exposed
|
|
||||||
- --providers.docker.exposedbydefault=false
|
|
||||||
# Create an entrypoint "http" listening on port 80
|
|
||||||
- --entrypoints.http.address=:80
|
|
||||||
# Create an entrypoint "https" listening on port 443
|
|
||||||
- --entrypoints.https.address=:443
|
|
||||||
# Enable the access log, with HTTP requests
|
|
||||||
- --accesslog
|
|
||||||
# Enable the Traefik log, for configurations and errors
|
|
||||||
- --log
|
|
||||||
# Enable debug logging for local development
|
|
||||||
- --log.level=DEBUG
|
|
||||||
# Enable the Dashboard and API
|
|
||||||
- --api
|
|
||||||
# Enable the Dashboard and API in insecure mode for local development
|
|
||||||
- --api.insecure=true
|
|
||||||
labels:
|
|
||||||
# Enable Traefik for this service, to make it available in the public network
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
# Dummy https-redirect middleware that doesn't really redirect, only to
|
|
||||||
# allow running it locally
|
|
||||||
- traefik.http.middlewares.https-redirect.contenttype.autodetect=false
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
restart: "no"
|
restart: "no"
|
||||||
ports:
|
ports:
|
||||||
@@ -114,9 +69,31 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
|
target: dev-stage
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=http://localhost:8000
|
- VITE_API_URL=http://localhost:8000
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8000
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
- CHOKIDAR_INTERVAL=150
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- path: ./frontend
|
||||||
|
action: sync
|
||||||
|
target: /app/frontend
|
||||||
|
ignore:
|
||||||
|
- node_modules/
|
||||||
|
- dist/
|
||||||
|
- blob-report/
|
||||||
|
- test-results/
|
||||||
|
- package.json
|
||||||
|
- path: ./frontend/package.json
|
||||||
|
action: rebuild
|
||||||
|
- path: ./package.json
|
||||||
|
action: rebuild
|
||||||
|
- path: ./bun.lock
|
||||||
|
action: rebuild
|
||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
build:
|
build:
|
||||||
@@ -136,14 +113,9 @@ services:
|
|||||||
- MAILCATCHER_HOST=http://mailcatcher:1080
|
- MAILCATCHER_HOST=http://mailcatcher:1080
|
||||||
# For the reports when run locally
|
# For the reports when run locally
|
||||||
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
||||||
- CI=${CI}
|
- CI=${CI:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/blob-report:/app/frontend/blob-report
|
- ./frontend/blob-report:/app/frontend/blob-report
|
||||||
- ./frontend/test-results:/app/frontend/test-results
|
- ./frontend/test-results:/app/frontend/test-results
|
||||||
ports:
|
ports:
|
||||||
- 9323:9323
|
- 9323:9323
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
# For local dev, don't expect an external Traefik network
|
|
||||||
external: false
|
|
||||||
|
|||||||
@@ -70,6 +70,77 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
|
|
||||||
|
mqtt-ingestor:
|
||||||
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- 1panel-network
|
||||||
|
depends_on:
|
||||||
|
prestart:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
- ENVIRONMENT=${ENVIRONMENT}
|
||||||
|
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
||||||
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
||||||
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
||||||
|
- POSTGRES_SERVER=${POSTGRES_SERVER}
|
||||||
|
- POSTGRES_PORT=${POSTGRES_PORT}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
||||||
|
- MQTT_HOST=${MQTT_HOST-1Panel-emqx-Rniy}
|
||||||
|
- MQTT_PORT=${MQTT_PORT-1883}
|
||||||
|
- MQTT_USERNAME=${MQTT_USERNAME}
|
||||||
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
|
- MQTT_TOPIC_PATTERN=${MQTT_TOPIC_PATTERN-terminal/+/raw}
|
||||||
|
- MQTT_CLIENT_ID=${MQTT_CLIENT_ID-spatialhub-mqtt-ingestor}
|
||||||
|
- RAW_HOT_RETENTION_DAYS=${RAW_HOT_RETENTION_DAYS-90}
|
||||||
|
- RAW_ARCHIVE_BASE_DIR=${RAW_ARCHIVE_BASE_DIR-/data/archive/gnss}
|
||||||
|
- PARSER_BATCH_SIZE=${PARSER_BATCH_SIZE-200}
|
||||||
|
- PARSER_POLL_INTERVAL_MS=${PARSER_POLL_INTERVAL_MS-300}
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- app.mqtt.ingestor
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- mqtt-archive-data:/data/archive/gnss
|
||||||
|
|
||||||
|
mqtt-parser-worker:
|
||||||
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- 1panel-network
|
||||||
|
depends_on:
|
||||||
|
prestart:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
- ENVIRONMENT=${ENVIRONMENT}
|
||||||
|
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
||||||
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
||||||
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
||||||
|
- POSTGRES_SERVER=${POSTGRES_SERVER}
|
||||||
|
- POSTGRES_PORT=${POSTGRES_PORT}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
||||||
|
- RAW_HOT_RETENTION_DAYS=${RAW_HOT_RETENTION_DAYS-90}
|
||||||
|
- PARSER_BATCH_SIZE=${PARSER_BATCH_SIZE-200}
|
||||||
|
- PARSER_POLL_INTERVAL_MS=${PARSER_POLL_INTERVAL_MS-300}
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- app.mqtt.parser_worker
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
||||||
restart: always
|
restart: always
|
||||||
@@ -87,3 +158,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
1panel-network:
|
1panel-network:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mqtt-archive-data:
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
services:
|
|
||||||
traefik:
|
|
||||||
image: traefik:3.6
|
|
||||||
ports:
|
|
||||||
# Listen on port 80, default for HTTP, necessary to redirect to HTTPS
|
|
||||||
- 80:80
|
|
||||||
# Listen on port 443, default for HTTPS
|
|
||||||
- 443:443
|
|
||||||
restart: always
|
|
||||||
labels:
|
|
||||||
# Enable Traefik for this service, to make it available in the public network
|
|
||||||
- traefik.enable=true
|
|
||||||
# Use the traefik-public network (declared below)
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
# Define the port inside of the Docker service to use
|
|
||||||
- traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080
|
|
||||||
# Make Traefik use this domain (from an environment variable) in HTTP
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.entrypoints=http
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.rule=Host(`traefik.${DOMAIN?Variable not set}`)
|
|
||||||
# traefik-https the actual router using HTTPS
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.entrypoints=https
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.rule=Host(`traefik.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.tls=true
|
|
||||||
# Use the "le" (Let's Encrypt) resolver created below
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.tls.certresolver=le
|
|
||||||
# Use the special Traefik service api@internal with the web UI/Dashboard
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.service=api@internal
|
|
||||||
# https-redirect middleware to redirect HTTP to HTTPS
|
|
||||||
- traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
|
|
||||||
- traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
|
|
||||||
# traefik-http set up only to use the middleware to redirect to https
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.middlewares=https-redirect
|
|
||||||
# admin-auth middleware with HTTP Basic auth
|
|
||||||
# Using the environment variables USERNAME and HASHED_PASSWORD
|
|
||||||
- traefik.http.middlewares.admin-auth.basicauth.users=${USERNAME?Variable not set}:${HASHED_PASSWORD?Variable not set}
|
|
||||||
# Enable HTTP Basic auth, using the middleware created above
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.middlewares=admin-auth
|
|
||||||
volumes:
|
|
||||||
# Add Docker as a mounted volume, so that Traefik can read the labels of other services
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# Mount the volume to store the certificates
|
|
||||||
- traefik-public-certificates:/certificates
|
|
||||||
command:
|
|
||||||
# Enable Docker in Traefik, so that it reads labels from Docker services
|
|
||||||
- --providers.docker
|
|
||||||
# Do not expose all Docker services, only the ones explicitly exposed
|
|
||||||
- --providers.docker.exposedbydefault=false
|
|
||||||
# Create an entrypoint "http" listening on port 80
|
|
||||||
- --entrypoints.http.address=:80
|
|
||||||
# Create an entrypoint "https" listening on port 443
|
|
||||||
- --entrypoints.https.address=:443
|
|
||||||
# Create the certificate resolver "le" for Let's Encrypt, uses the environment variable EMAIL
|
|
||||||
- --certificatesresolvers.le.acme.email=${EMAIL?Variable not set}
|
|
||||||
# Store the Let's Encrypt certificates in the mounted volume
|
|
||||||
- --certificatesresolvers.le.acme.storage=/certificates/acme.json
|
|
||||||
# Use the TLS Challenge for Let's Encrypt
|
|
||||||
- --certificatesresolvers.le.acme.tlschallenge=true
|
|
||||||
# Enable the access log, with HTTP requests
|
|
||||||
- --accesslog
|
|
||||||
# Enable the Traefik log, for configurations and errors
|
|
||||||
- --log
|
|
||||||
# Enable the Dashboard and API
|
|
||||||
- --api
|
|
||||||
networks:
|
|
||||||
# Use the public network created to be shared between Traefik and
|
|
||||||
# any other service that needs to be publicly available with HTTPS
|
|
||||||
- traefik-public
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Create a volume to store the certificates, even if the container is recreated
|
|
||||||
traefik-public-certificates:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
# Use the previously created public network "traefik-public", shared with other
|
|
||||||
# services that need to be publicly available via this Traefik
|
|
||||||
traefik-public:
|
|
||||||
external: true
|
|
||||||
63
compose.yml
63
compose.yml
@@ -22,34 +22,16 @@ services:
|
|||||||
adminer:
|
adminer:
|
||||||
image: adminer
|
image: adminer
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
- ADMINER_DESIGN=pepa-linha-dark
|
- ADMINER_DESIGN=pepa-linha-dark
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080
|
|
||||||
|
|
||||||
prestart:
|
prestart:
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -79,9 +61,6 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -118,23 +97,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le
|
|
||||||
|
|
||||||
# Enable redirection for HTTP and HTTPS
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect
|
|
||||||
|
|
||||||
mqtt-ingestor:
|
mqtt-ingestor:
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||||
@@ -216,37 +178,12 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=https://api.${DOMAIN?Variable not set}
|
- VITE_API_URL=https://api.${DOMAIN?Variable not set}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le
|
|
||||||
|
|
||||||
# Enable redirection for HTTP and HTTPS
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
|
|
||||||
volumes:
|
volumes:
|
||||||
app-db-data:
|
app-db-data:
|
||||||
mqtt-archive-data:
|
mqtt-archive-data:
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
# Allow setting it to false for testing
|
|
||||||
external: true
|
|
||||||
|
|||||||
100
copier.yml
100
copier.yml
@@ -1,100 +0,0 @@
|
|||||||
project_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the project, shown to API users (in .env)
|
|
||||||
default: FastAPI Project
|
|
||||||
|
|
||||||
stack_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the stack used for Docker Compose labels (no spaces) (in .env)
|
|
||||||
default: fastapi-project
|
|
||||||
|
|
||||||
secret_key:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The secret key for the project, used for security,
|
|
||||||
stored in .env, you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
first_superuser:
|
|
||||||
type: str
|
|
||||||
help: The email of the first superuser (in .env)
|
|
||||||
default: admin@example.com
|
|
||||||
|
|
||||||
first_superuser_password:
|
|
||||||
type: str
|
|
||||||
help: The password of the first superuser (in .env)
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
smtp_host:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server host to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_user:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server user to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_password:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server password to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
emails_from_email:
|
|
||||||
type: str
|
|
||||||
help: The email account to send emails from, you can set it later in .env
|
|
||||||
default: info@example.com
|
|
||||||
|
|
||||||
postgres_password:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The password for the PostgreSQL database, stored in .env,
|
|
||||||
you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
sentry_dsn:
|
|
||||||
type: str
|
|
||||||
help: The DSN for Sentry, if you are using it, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
_exclude:
|
|
||||||
# Global
|
|
||||||
- .vscode
|
|
||||||
- .mypy_cache
|
|
||||||
# Python
|
|
||||||
- __pycache__
|
|
||||||
- app.egg-info
|
|
||||||
- "*.pyc"
|
|
||||||
- .mypy_cache
|
|
||||||
- .coverage
|
|
||||||
- htmlcov
|
|
||||||
- .cache
|
|
||||||
- .venv
|
|
||||||
# Frontend
|
|
||||||
# Logs
|
|
||||||
- logs
|
|
||||||
- "*.log"
|
|
||||||
- npm-debug.log*
|
|
||||||
- yarn-debug.log*
|
|
||||||
- yarn-error.log*
|
|
||||||
- pnpm-debug.log*
|
|
||||||
- lerna-debug.log*
|
|
||||||
- node_modules
|
|
||||||
- dist
|
|
||||||
- dist-ssr
|
|
||||||
- "*.local"
|
|
||||||
# Editor directories and files
|
|
||||||
- .idea
|
|
||||||
- .DS_Store
|
|
||||||
- "*.suo"
|
|
||||||
- "*.ntvs*"
|
|
||||||
- "*.njsproj"
|
|
||||||
- "*.sln"
|
|
||||||
- "*.sw?"
|
|
||||||
|
|
||||||
_answers_file: .copier/.copier-answers.yml
|
|
||||||
|
|
||||||
_tasks:
|
|
||||||
- ["{{ _copier_python }}", .copier/update_dotenv.py]
|
|
||||||
@@ -33,7 +33,7 @@ OpenResty (1Panel 管理, SSL, 端口 80/443)
|
|||||||
**关键设计决策:**
|
**关键设计决策:**
|
||||||
- 后端和前端容器只绑定 `127.0.0.1`,不对外暴露,由 OpenResty 统一反代
|
- 后端和前端容器只绑定 `127.0.0.1`,不对外暴露,由 OpenResty 统一反代
|
||||||
- 所有容器加入 `1panel-network`,可直接通过 `postgresql` 主机名访问已有数据库
|
- 所有容器加入 `1panel-network`,可直接通过 `postgresql` 主机名访问已有数据库
|
||||||
- 不使用 Traefik(用 1Panel 自带的 OpenResty 替代)
|
- 使用 1Panel 自带的 OpenResty 作为反向代理
|
||||||
- 不启动独立 PostgreSQL 容器(复用已有的)
|
- 不启动独立 PostgreSQL 容器(复用已有的)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -324,7 +324,6 @@ sudo systemctl status gitea-runner
|
|||||||
| `DOMAIN` | `makefire.fun` |
|
| `DOMAIN` | `makefire.fun` |
|
||||||
| `FRONTEND_HOST` | `https://makefire.fun` |
|
| `FRONTEND_HOST` | `https://makefire.fun` |
|
||||||
| `PROJECT_NAME` | `SpatialHub` |
|
| `PROJECT_NAME` | `SpatialHub` |
|
||||||
| `STACK_NAME` | `spatialhub` |
|
|
||||||
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
|
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
|
||||||
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
|
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
|
||||||
| `FIRST_SUPERUSER` | `admin@makefire.fun` |
|
| `FIRST_SUPERUSER` | `admin@makefire.fun` |
|
||||||
|
|||||||
387
deployment.md
387
deployment.md
@@ -1,344 +1,87 @@
|
|||||||
# FastAPI Project - Deployment
|
# FastAPI Project - Deployment
|
||||||
|
|
||||||
You can deploy the project using Docker Compose to a remote server.
|
You can deploy this project with Docker Compose and put it behind an external reverse proxy (for example OpenResty/Nginx).
|
||||||
|
|
||||||
This project expects you to have a Traefik proxy handling communication to the outside world and HTTPS certificates.
|
This repository uses an external reverse proxy setup and keeps Compose deployment focused on app services.
|
||||||
|
|
||||||
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
|
|
||||||
|
|
||||||
But you have to configure a couple things first. 🤓
|
|
||||||
|
|
||||||
## Preparation
|
## Preparation
|
||||||
|
|
||||||
* Have a remote server ready and available.
|
* Have a remote server ready.
|
||||||
* Configure the DNS records of your domain to point to the IP of the server you just created.
|
* Configure DNS records to point your domain to that server.
|
||||||
* Configure a wildcard subdomain for your domain, so that you can have multiple subdomains for different services, e.g. `*.fastapi-project.example.com`. This will be useful for accessing different components, like `dashboard.fastapi-project.example.com`, `api.fastapi-project.example.com`, `traefik.fastapi-project.example.com`, `adminer.fastapi-project.example.com`, etc. And also for `staging`, like `dashboard.staging.fastapi-project.example.com`, `adminer.staging.fastapi-project.example.com`, etc.
|
* Install Docker Engine on the server.
|
||||||
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
* Configure OpenResty (or your reverse proxy) to forward traffic:
|
||||||
|
* frontend domain/path -> frontend container port
|
||||||
## Public Traefik
|
* API domain/path -> backend container port
|
||||||
|
|
||||||
We need a Traefik proxy to handle incoming connections and HTTPS certificates.
|
|
||||||
|
|
||||||
You need to do these next steps only once.
|
|
||||||
|
|
||||||
### Traefik Docker Compose
|
|
||||||
|
|
||||||
* Create a remote directory to store your Traefik Docker Compose file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the Traefik Docker Compose file to your server. You could do it by running the command `rsync` in your local terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -a compose.traefik.yml root@your-server.example.com:/root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traefik Public Network
|
|
||||||
|
|
||||||
This Traefik will expect a Docker "public network" named `traefik-public` to communicate with your stack(s).
|
|
||||||
|
|
||||||
This way, there will be a single public Traefik proxy that handles the communication (HTTP and HTTPS) with the outside world, and then behind that, you could have one or more stacks with different domains, even if they are on the same single server.
|
|
||||||
|
|
||||||
To create a Docker "public network" named `traefik-public` run the following command in your remote server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network create traefik-public
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traefik Environment Variables
|
|
||||||
|
|
||||||
The Traefik Docker Compose file expects some environment variables to be set in your terminal before starting it. You can do it by running the following commands in your remote server.
|
|
||||||
|
|
||||||
* Create the username for HTTP Basic Auth, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export USERNAME=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the password for HTTP Basic Auth, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PASSWORD=changethis
|
|
||||||
```
|
|
||||||
|
|
||||||
* Use openssl to generate the "hashed" version of the password for HTTP Basic Auth and store it in an environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export HASHED_PASSWORD=$(openssl passwd -apr1 $PASSWORD)
|
|
||||||
```
|
|
||||||
|
|
||||||
To verify that the hashed password is correct, you can print it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo $HASHED_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the domain name for your server, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOMAIN=fastapi-project.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the email for Let's Encrypt, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export EMAIL=admin@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: you need to set a different email, an email `@example.com` won't work.
|
|
||||||
|
|
||||||
### Start the Traefik Docker Compose
|
|
||||||
|
|
||||||
Go to the directory where you copied the Traefik Docker Compose file in your remote server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
Now with the environment variables set and the `compose.traefik.yml` in place, you can start the Traefik Docker Compose running the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose.traefik.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy the FastAPI Project
|
|
||||||
|
|
||||||
Now that you have Traefik in place you can deploy your FastAPI project with Docker Compose.
|
|
||||||
|
|
||||||
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
|
|
||||||
|
|
||||||
## Copy the Code
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -av --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `--filter=":- .gitignore"` tells `rsync` to use the same rules as git, ignore files ignored by git, like the Python virtual environment.
|
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
You need to set some environment variables first.
|
Create `.env.production` based on `.env.production.example` and fill in real values.
|
||||||
|
|
||||||
### Generate secret keys
|
Minimum required values:
|
||||||
|
|
||||||
Some environment variables in the `.env` file have a default value of `changethis`.
|
* `DOMAIN`
|
||||||
|
* `FRONTEND_HOST`
|
||||||
You have to change them with a secret key, to generate secret keys you can run the following command:
|
* `ENVIRONMENT=production`
|
||||||
|
* `SECRET_KEY`
|
||||||
```bash
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
|
||||||
|
|
||||||
### Required Environment Variables
|
|
||||||
|
|
||||||
Set the `ENVIRONMENT`, by default `local` (for development), but when deploying to a server you would put something like `staging` or `production`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ENVIRONMENT=production
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `DOMAIN`, by default `localhost` (for development), but when deploying you would use your own domain, for example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOMAIN=fastapi-project.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `POSTGRES_PASSWORD` to something different than `changethis`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export POSTGRES_PASSWORD="changethis"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `SECRET_KEY`, used to sign tokens:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SECRET_KEY="changethis"
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: you can use the Python command above to generate a secure secret key.
|
|
||||||
|
|
||||||
Set the `FIRST_SUPER_USER_PASSWORD` to something different than `changethis`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export FIRST_SUPERUSER_PASSWORD="changethis"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `BACKEND_CORS_ORIGINS` to include your domain:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export BACKEND_CORS_ORIGINS="https://dashboard.${DOMAIN?Variable not set},https://api.${DOMAIN?Variable not set}"
|
|
||||||
```
|
|
||||||
|
|
||||||
You can set several other environment variables:
|
|
||||||
|
|
||||||
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
|
||||||
* `STACK_NAME`: The name of the stack used for Docker Compose labels and project name, this should be different for `staging`, `production`, etc. You could use the same domain replacing dots with dashes, e.g. `fastapi-project-example-com` and `staging-fastapi-project-example-com`.
|
|
||||||
* `BACKEND_CORS_ORIGINS`: A list of allowed CORS origins separated by commas.
|
|
||||||
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
|
|
||||||
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
|
|
||||||
* `SMTP_USER`: The SMTP server user to send emails.
|
|
||||||
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
|
||||||
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
|
||||||
* `POSTGRES_SERVER`: The hostname of the PostgreSQL server. You can leave the default of `db`, provided by the same Docker Compose. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_PORT`: The port of the PostgreSQL server. You can leave the default. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_USER`: The Postgres user, you can leave the default.
|
|
||||||
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
|
|
||||||
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
|
||||||
|
|
||||||
## GitHub Actions Environment Variables
|
|
||||||
|
|
||||||
There are some environment variables only used by GitHub Actions that you can configure:
|
|
||||||
|
|
||||||
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
|
|
||||||
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
|
|
||||||
|
|
||||||
### Deploy with Docker Compose
|
|
||||||
|
|
||||||
With the environment variables in place, you can deploy with Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/code/app/
|
|
||||||
docker compose -f compose.yml build
|
|
||||||
docker compose -f compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
For production you wouldn't want to have the overrides in `compose.override.yml`, that's why we explicitly specify `compose.yml` as the file to use.
|
|
||||||
|
|
||||||
## Continuous Deployment (CD)
|
|
||||||
|
|
||||||
You can use GitHub Actions to deploy your project automatically. 😎
|
|
||||||
|
|
||||||
You can have multiple environment deployments.
|
|
||||||
|
|
||||||
There are already two environments configured, `staging` and `production`. 🚀
|
|
||||||
|
|
||||||
### Install GitHub Actions Runner
|
|
||||||
|
|
||||||
* On your remote server, create a user for your GitHub Actions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo adduser github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Add Docker permissions to the `github` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG docker github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Temporarily switch to the `github` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo su - github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Go to the `github` user's home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd
|
|
||||||
```
|
|
||||||
|
|
||||||
* [Install a GitHub Action self-hosted runner following the official guide](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners#adding-a-self-hosted-runner-to-a-repository).
|
|
||||||
|
|
||||||
* When asked about labels, add a label for the environment, e.g. `production`. You can also add labels later.
|
|
||||||
|
|
||||||
After installing, the guide would tell you to run a command to start the runner. Nevertheless, it would stop once you terminate that process or if your local connection to your server is lost.
|
|
||||||
|
|
||||||
To make sure it runs on startup and continues running, you can install it as a service. To do that, exit the `github` user and go back to the `root` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
exit
|
|
||||||
```
|
|
||||||
|
|
||||||
After you do it, you will be on the previous user again. And you will be on the previous directory, belonging to that user.
|
|
||||||
|
|
||||||
Before being able to go the `github` user directory, you need to become the `root` user (you might already be):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo su
|
|
||||||
```
|
|
||||||
|
|
||||||
* As the `root` user, go to the `actions-runner` directory inside of the `github` user's home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/github/actions-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
* Install the self-hosted runner as a service with the user `github`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh install github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
* Check the status of the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh status
|
|
||||||
```
|
|
||||||
|
|
||||||
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
|
|
||||||
|
|
||||||
### Set Secrets
|
|
||||||
|
|
||||||
On your repository, configure secrets for the environment variables you need, the same ones described above, including `SECRET_KEY`, etc. Follow the [official GitHub guide for setting repository secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository).
|
|
||||||
|
|
||||||
The current Github Actions workflows expect these secrets:
|
|
||||||
|
|
||||||
* `DOMAIN_PRODUCTION`
|
|
||||||
* `DOMAIN_STAGING`
|
|
||||||
* `STACK_NAME_PRODUCTION`
|
|
||||||
* `STACK_NAME_STAGING`
|
|
||||||
* `EMAILS_FROM_EMAIL`
|
|
||||||
* `FIRST_SUPERUSER`
|
* `FIRST_SUPERUSER`
|
||||||
* `FIRST_SUPERUSER_PASSWORD`
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
|
* `POSTGRES_SERVER`
|
||||||
|
* `POSTGRES_PORT`
|
||||||
|
* `POSTGRES_DB`
|
||||||
|
* `POSTGRES_USER`
|
||||||
* `POSTGRES_PASSWORD`
|
* `POSTGRES_PASSWORD`
|
||||||
* `SECRET_KEY`
|
|
||||||
* `LATEST_CHANGES`
|
|
||||||
* `SMOKESHOW_AUTH_KEY`
|
|
||||||
|
|
||||||
## GitHub Action Deployment Workflows
|
Optional values:
|
||||||
|
|
||||||
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
|
* `SMTP_*` and `EMAILS_FROM_EMAIL`
|
||||||
|
* `SENTRY_DSN`
|
||||||
|
* `MQTT_*` pipeline settings
|
||||||
|
* `DOCKER_IMAGE_BACKEND`
|
||||||
|
* `DOCKER_IMAGE_FRONTEND`
|
||||||
|
|
||||||
* `staging`: after pushing (or merging) to the branch `master`.
|
## Deploy with Docker Compose
|
||||||
* `production`: after publishing a release.
|
|
||||||
|
|
||||||
If you need to add extra environments you could use those as a starting point.
|
```bash
|
||||||
|
cd /path/to/project
|
||||||
|
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml build
|
||||||
|
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To update after code changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml down --remove-orphans
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml ps
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml logs backend --tail 100
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml logs frontend --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend health check endpoint:
|
||||||
|
|
||||||
|
* `http://<backend-host>:18000/api/v1/utils/health-check/` (or your internal mapped port)
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
The Gitea workflow at `.gitea/workflows/deploy.yml` deploys using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.prod.yml ...
|
||||||
|
```
|
||||||
|
|
||||||
|
So production deploys are isolated from local development overrides.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Replace `fastapi-project.example.com` with your domain.
|
Replace with your real domains configured in OpenResty:
|
||||||
|
|
||||||
### Main Traefik Dashboard
|
* Frontend: `https://<your-frontend-domain>`
|
||||||
|
* Backend API docs: `https://<your-api-domain>/docs`
|
||||||
Traefik UI: `https://traefik.fastapi-project.example.com`
|
* Backend API base URL: `https://<your-api-domain>`
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
Frontend: `https://dashboard.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Backend API docs: `https://api.fastapi-project.example.com/docs`
|
|
||||||
|
|
||||||
Backend API base URL: `https://api.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Adminer: `https://adminer.fastapi-project.example.com`
|
|
||||||
|
|
||||||
### Staging
|
|
||||||
|
|
||||||
Frontend: `https://dashboard.staging.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Backend API docs: `https://api.staging.fastapi-project.example.com/docs`
|
|
||||||
|
|
||||||
Backend API base URL: `https://api.staging.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ Automatic interactive documentation with Swagger UI (from the OpenAPI backend):
|
|||||||
|
|
||||||
Adminer, database web administration: <http://localhost:8080>
|
Adminer, database web administration: <http://localhost:8080>
|
||||||
|
|
||||||
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
|
||||||
|
|
||||||
**Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it.
|
**Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it.
|
||||||
|
|
||||||
To check the logs, run (in another terminal):
|
To check the logs, run (in another terminal):
|
||||||
@@ -79,34 +77,6 @@ cd backend
|
|||||||
fastapi dev app/main.py
|
fastapi dev app/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Compose in `localhost.tiangolo.com`
|
|
||||||
|
|
||||||
When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, frontend, adminer, etc).
|
|
||||||
|
|
||||||
When you deploy it to production (or staging), it will deploy each service in a different subdomain, like `api.example.com` for the backend and `dashboard.example.com` for the frontend.
|
|
||||||
|
|
||||||
In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to each service based on the subdomain.
|
|
||||||
|
|
||||||
If you want to test that it's all working locally, you can edit the local `.env` file, and change:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DOMAIN=localhost.tiangolo.com
|
|
||||||
```
|
|
||||||
|
|
||||||
That will be used by the Docker Compose files to configure the base domain for the services.
|
|
||||||
|
|
||||||
Traefik will use this to transmit traffic at `api.localhost.tiangolo.com` to the backend, and traffic at `dashboard.localhost.tiangolo.com` to the frontend.
|
|
||||||
|
|
||||||
The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development.
|
|
||||||
|
|
||||||
After you update it, run again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
When deploying, for example in production, the main Traefik is configured outside of the Docker Compose files. For local development, there's an included Traefik in `compose.override.yml`, just to let you test that the domains work as expected, for example with `api.localhost.tiangolo.com` and `dashboard.localhost.tiangolo.com`.
|
|
||||||
|
|
||||||
## Docker Compose files and env vars
|
## Docker Compose files and env vars
|
||||||
|
|
||||||
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
||||||
@@ -198,24 +168,4 @@ Automatic Alternative Docs (ReDoc): <http://localhost:8000/redoc>
|
|||||||
|
|
||||||
Adminer: <http://localhost:8080>
|
Adminer: <http://localhost:8080>
|
||||||
|
|
||||||
Traefik UI: <http://localhost:8090>
|
|
||||||
|
|
||||||
MailCatcher: <http://localhost:1080>
|
MailCatcher: <http://localhost:1080>
|
||||||
|
|
||||||
### Development URLs with `localhost.tiangolo.com` Configured
|
|
||||||
|
|
||||||
Development URLs, for local development.
|
|
||||||
|
|
||||||
Frontend: <http://dashboard.localhost.tiangolo.com>
|
|
||||||
|
|
||||||
Backend: <http://api.localhost.tiangolo.com>
|
|
||||||
|
|
||||||
Automatic Interactive Docs (Swagger UI): <http://api.localhost.tiangolo.com/docs>
|
|
||||||
|
|
||||||
Automatic Alternative Docs (ReDoc): <http://api.localhost.tiangolo.com/redoc>
|
|
||||||
|
|
||||||
Adminer: <http://localhost.tiangolo.com:8080>
|
|
||||||
|
|
||||||
Traefik UI: <http://localhost.tiangolo.com:8090>
|
|
||||||
|
|
||||||
MailCatcher: <http://localhost.tiangolo.com:1080>
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# Stage 0, "build-stage", based on Bun, to build and compile the frontend
|
# Shared Bun stage for frontend dependencies and sources
|
||||||
FROM oven/bun:1 AS build-stage
|
FROM oven/bun:1 AS bun-base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json bun.lock /app/
|
COPY package.json bun.lock /app/
|
||||||
|
|
||||||
COPY frontend/package.json /app/frontend/
|
COPY frontend/package.json /app/frontend/
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
@@ -14,10 +13,22 @@ RUN bun install
|
|||||||
COPY ./frontend /app/frontend
|
COPY ./frontend /app/frontend
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
|
|
||||||
|
|
||||||
|
# Development stage for docker compose watch + Vite HMR
|
||||||
|
FROM bun-base AS dev-stage
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["bun", "run", "dev", "--host", "0.0.0.0", "--port", "80"]
|
||||||
|
|
||||||
|
|
||||||
|
# Build stage for production assets
|
||||||
|
FROM bun-base AS build-stage
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
|
# Production stage serving compiled assets with Nginx
|
||||||
FROM nginx:1
|
FROM nginx:1
|
||||||
|
|
||||||
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
|
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@@ -32,10 +33,13 @@
|
|||||||
"@tanstack/react-router": "^1.163.3",
|
"@tanstack/react-router": "^1.163.3",
|
||||||
"@tanstack/react-router-devtools": "^1.163.3",
|
"@tanstack/react-router-devtools": "^1.163.3",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"form-data": "4.0.5",
|
"form-data": "4.0.5",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -43,6 +47,8 @@
|
|||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-cluster": "^4.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@tanstack/router-devtools": "^1.159.10",
|
"@tanstack/router-devtools": "^1.159.10",
|
||||||
"@tanstack/router-plugin": "^1.140.0",
|
"@tanstack/router-plugin": "^1.140.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.2",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ export const Body_login_login_access_tokenSchema = {
|
|||||||
title: 'Body_login-login_access_token'
|
title: 'Body_login-login_access_token'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const DeleteResultSchema = {
|
||||||
|
properties: {
|
||||||
|
deleted_count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Deleted Count'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Message'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['deleted_count', 'message'],
|
||||||
|
title: 'DeleteResult'
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const HTTPValidationErrorSchema = {
|
export const HTTPValidationErrorSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -208,6 +224,66 @@ export const MessageSchema = {
|
|||||||
title: 'Message'
|
title: 'Message'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const MqttRawDaySummarySchema = {
|
||||||
|
properties: {
|
||||||
|
day: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date',
|
||||||
|
title: 'Day'
|
||||||
|
},
|
||||||
|
message_count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Message Count'
|
||||||
|
},
|
||||||
|
first_received_at: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'First Received At'
|
||||||
|
},
|
||||||
|
last_received_at: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Last Received At'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['day', 'message_count'],
|
||||||
|
title: 'MqttRawDaySummary'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MqttRawDaysPublicSchema = {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
items: {
|
||||||
|
'$ref': '#/components/schemas/MqttRawDaySummary'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Data'
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Count'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['data', 'count'],
|
||||||
|
title: 'MqttRawDaysPublic'
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const NewPasswordSchema = {
|
export const NewPasswordSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
token: {
|
token: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import type { CancelablePromise } from './core/CancelablePromise';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
import { request as __request } from './core/request';
|
import { request as __request } from './core/request';
|
||||||
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MqttRawListDaysData, MqttRawListDaysResponse, MqttRawDownloadData, MqttRawDownloadResponse, MqttRawDeleteDayData, MqttRawDeleteDayResponse, MqttRawDeleteAllData, MqttRawDeleteAllResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||||
|
|
||||||
export class LocationsService {
|
export class LocationsService {
|
||||||
/**
|
/**
|
||||||
@@ -213,6 +213,94 @@ export class LoginService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MqttRawService {
|
||||||
|
/**
|
||||||
|
* List Mqtt Raw Days
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.skip
|
||||||
|
* @param data.limit
|
||||||
|
* @returns MqttRawDaysPublic Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static listDays(data: MqttRawListDaysData = {}): CancelablePromise<MqttRawListDaysResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/mqtt-raw/days',
|
||||||
|
query: {
|
||||||
|
skip: data.skip,
|
||||||
|
limit: data.limit
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download Mqtt Raw
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.day
|
||||||
|
* @param data.startSeconds
|
||||||
|
* @param data.endSeconds
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static download(data: MqttRawDownloadData): CancelablePromise<MqttRawDownloadResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/mqtt-raw/download',
|
||||||
|
query: {
|
||||||
|
day: data.day,
|
||||||
|
start_seconds: data.startSeconds,
|
||||||
|
end_seconds: data.endSeconds
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Mqtt Raw By Day
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.day
|
||||||
|
* @returns DeleteResult Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static deleteDay(data: MqttRawDeleteDayData): CancelablePromise<MqttRawDeleteDayResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/mqtt-raw/day/{day}',
|
||||||
|
path: {
|
||||||
|
day: data.day
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete All Mqtt Raw
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.confirm
|
||||||
|
* @returns DeleteResult Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static deleteAll(data: MqttRawDeleteAllData = {}): CancelablePromise<MqttRawDeleteAllResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/mqtt-raw/all',
|
||||||
|
query: {
|
||||||
|
confirm: data.confirm
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PrivateService {
|
export class PrivateService {
|
||||||
/**
|
/**
|
||||||
* Create User
|
* Create User
|
||||||
@@ -235,6 +323,31 @@ export class PrivateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TelemetryService {
|
||||||
|
/**
|
||||||
|
* Read Telemetry Latest
|
||||||
|
* Return the latest parsed GNSS telemetry snapshot.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.deviceId
|
||||||
|
* @param data.limit
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static readTelemetryLatest(data: TelemetryReadTelemetryLatestData = {}): CancelablePromise<TelemetryReadTelemetryLatestResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/telemetry/latest',
|
||||||
|
query: {
|
||||||
|
device_id: data.deviceId,
|
||||||
|
limit: data.limit
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
/**
|
/**
|
||||||
* Read Users
|
* Read Users
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export type Body_login_login_access_token = {
|
|||||||
client_secret?: (string | null);
|
client_secret?: (string | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeleteResult = {
|
||||||
|
deleted_count: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type HTTPValidationError = {
|
export type HTTPValidationError = {
|
||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
@@ -40,6 +45,18 @@ export type Message = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MqttRawDaysPublic = {
|
||||||
|
data: Array<MqttRawDaySummary>;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDaySummary = {
|
||||||
|
day: string;
|
||||||
|
message_count: number;
|
||||||
|
first_received_at?: (string | null);
|
||||||
|
last_received_at?: (string | null);
|
||||||
|
};
|
||||||
|
|
||||||
export type NewPassword = {
|
export type NewPassword = {
|
||||||
token: string;
|
token: string;
|
||||||
new_password: string;
|
new_password: string;
|
||||||
@@ -171,12 +188,48 @@ export type LoginRecoverPasswordHtmlContentData = {
|
|||||||
|
|
||||||
export type LoginRecoverPasswordHtmlContentResponse = (string);
|
export type LoginRecoverPasswordHtmlContentResponse = (string);
|
||||||
|
|
||||||
|
export type MqttRawListDaysData = {
|
||||||
|
limit?: number;
|
||||||
|
skip?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawListDaysResponse = (MqttRawDaysPublic);
|
||||||
|
|
||||||
|
export type MqttRawDownloadData = {
|
||||||
|
day: string;
|
||||||
|
endSeconds?: (number | null);
|
||||||
|
startSeconds?: (number | null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDownloadResponse = (unknown);
|
||||||
|
|
||||||
|
export type MqttRawDeleteDayData = {
|
||||||
|
day: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDeleteDayResponse = (DeleteResult);
|
||||||
|
|
||||||
|
export type MqttRawDeleteAllData = {
|
||||||
|
confirm?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDeleteAllResponse = (DeleteResult);
|
||||||
|
|
||||||
export type PrivateCreateUserData = {
|
export type PrivateCreateUserData = {
|
||||||
requestBody: PrivateUserCreate;
|
requestBody: PrivateUserCreate;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PrivateCreateUserResponse = (UserPublic);
|
export type PrivateCreateUserResponse = (UserPublic);
|
||||||
|
|
||||||
|
export type TelemetryReadTelemetryLatestData = {
|
||||||
|
deviceId?: (string | null);
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelemetryReadTelemetryLatestResponse = ({
|
||||||
|
[key: string]: unknown;
|
||||||
|
});
|
||||||
|
|
||||||
export type UsersReadUsersData = {
|
export type UsersReadUsersData = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home, MapPin, Users } from "lucide-react"
|
import { FileDown, Home, MapPin, Satellite, Users } from "lucide-react"
|
||||||
|
|
||||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||||
import { Logo } from "@/components/Common/Logo"
|
import { Logo } from "@/components/Common/Logo"
|
||||||
@@ -15,6 +15,8 @@ import { User } from "./User"
|
|||||||
const baseItems: Item[] = [
|
const baseItems: Item[] = [
|
||||||
{ icon: Home, title: "Dashboard", path: "/" },
|
{ icon: Home, title: "Dashboard", path: "/" },
|
||||||
{ icon: MapPin, title: "Locations", path: "/locations" },
|
{ icon: MapPin, title: "Locations", path: "/locations" },
|
||||||
|
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
|
||||||
|
{ icon: FileDown, title: "MQTT Raw", path: "/mqtt-raw" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
31
frontend/src/components/ui/slider.tsx
Normal file
31
frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{props.value?.map((_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
key={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
26
frontend/src/lib/auth-error.ts
Normal file
26
frontend/src/lib/auth-error.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const AUTH_EXPIRED_STATUSES = new Set([401, 403])
|
||||||
|
const USER_NOT_FOUND_DETAIL = "User not found"
|
||||||
|
|
||||||
|
const extractDetail = (body: unknown): unknown => {
|
||||||
|
if (typeof body === "object" && body !== null && "detail" in body) {
|
||||||
|
return (body as { detail?: unknown }).detail
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldHandleAuthError = (
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
): boolean => {
|
||||||
|
if (AUTH_EXPIRED_STATUSES.has(status)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return status === 404 && extractDetail(body) === USER_NOT_FOUND_DETAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAuthAndRedirectToLogin = (): void => {
|
||||||
|
localStorage.removeItem("access_token")
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
window.location.href = "/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
76
frontend/src/lib/coord-transform.ts
Normal file
76
frontend/src/lib/coord-transform.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* WGS-84 ↔ GCJ-02 coordinate transformation.
|
||||||
|
*
|
||||||
|
* GCJ-02 (国测局坐标系 / "Mars coordinates") is the mandatory coordinate system
|
||||||
|
* used by Chinese mapping services including Gaode (AutoNavi / 高德地图).
|
||||||
|
*
|
||||||
|
* GPS devices output WGS-84 coordinates. When overlaying markers on a GCJ-02
|
||||||
|
* base map the coordinates must be transformed, otherwise markers will appear
|
||||||
|
* shifted by tens to hundreds of metres in mainland China.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PI = Math.PI
|
||||||
|
const SEMI_MAJOR = 6378245.0 // Semi-major axis of Krasovsky ellipsoid
|
||||||
|
const EE = 0.006693421622965943 // Eccentricity squared
|
||||||
|
|
||||||
|
function outOfChina(lat: number, lon: number): boolean {
|
||||||
|
return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLat(x: number, y: number): number {
|
||||||
|
let ret =
|
||||||
|
-100.0 +
|
||||||
|
2.0 * x +
|
||||||
|
3.0 * y +
|
||||||
|
0.2 * y * y +
|
||||||
|
0.1 * x * y +
|
||||||
|
0.2 * Math.sqrt(Math.abs(x))
|
||||||
|
ret +=
|
||||||
|
((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) /
|
||||||
|
3.0
|
||||||
|
ret +=
|
||||||
|
((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0
|
||||||
|
ret +=
|
||||||
|
((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) *
|
||||||
|
2.0) /
|
||||||
|
3.0
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLon(x: number, y: number): number {
|
||||||
|
let ret =
|
||||||
|
300.0 +
|
||||||
|
x +
|
||||||
|
2.0 * y +
|
||||||
|
0.1 * x * x +
|
||||||
|
0.1 * x * y +
|
||||||
|
0.1 * Math.sqrt(Math.abs(x))
|
||||||
|
ret +=
|
||||||
|
((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) /
|
||||||
|
3.0
|
||||||
|
ret +=
|
||||||
|
((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0
|
||||||
|
ret +=
|
||||||
|
((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) *
|
||||||
|
2.0) /
|
||||||
|
3.0
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert WGS-84 → GCJ-02 */
|
||||||
|
export function wgs84ToGcj02(wgsLat: number, wgsLon: number): [number, number] {
|
||||||
|
if (outOfChina(wgsLat, wgsLon)) {
|
||||||
|
return [wgsLat, wgsLon]
|
||||||
|
}
|
||||||
|
|
||||||
|
let dLat = transformLat(wgsLon - 105.0, wgsLat - 35.0)
|
||||||
|
let dLon = transformLon(wgsLon - 105.0, wgsLat - 35.0)
|
||||||
|
const radLat = (wgsLat / 180.0) * PI
|
||||||
|
let magic = Math.sin(radLat)
|
||||||
|
magic = 1 - EE * magic * magic
|
||||||
|
const sqrtMagic = Math.sqrt(magic)
|
||||||
|
dLat = (dLat * 180.0) / (((SEMI_MAJOR * (1 - EE)) / (magic * sqrtMagic)) * PI)
|
||||||
|
dLon = (dLon * 180.0) / ((SEMI_MAJOR / sqrtMagic) * Math.cos(radLat) * PI)
|
||||||
|
|
||||||
|
return [wgsLat + dLat, wgsLon + dLon]
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ import { ApiError, OpenAPI } from "./client"
|
|||||||
import { ThemeProvider } from "./components/theme-provider"
|
import { ThemeProvider } from "./components/theme-provider"
|
||||||
import { Toaster } from "./components/ui/sonner"
|
import { Toaster } from "./components/ui/sonner"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
import {
|
||||||
|
clearAuthAndRedirectToLogin,
|
||||||
|
shouldHandleAuthError,
|
||||||
|
} from "./lib/auth-error"
|
||||||
import { routeTree } from "./routeTree.gen"
|
import { routeTree } from "./routeTree.gen"
|
||||||
|
|
||||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
||||||
@@ -18,10 +22,21 @@ OpenAPI.TOKEN = async () => {
|
|||||||
return localStorage.getItem("access_token") || ""
|
return localStorage.getItem("access_token") || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OpenAPI.interceptors.response.use((response) => {
|
||||||
|
if (shouldHandleAuthError(response.status, response.data)) {
|
||||||
|
clearAuthAndRedirectToLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
|
||||||
const handleApiError = (error: Error) => {
|
const handleApiError = (error: Error) => {
|
||||||
if (error instanceof ApiError && [401, 403].includes(error.status)) {
|
if (!(error instanceof ApiError)) {
|
||||||
localStorage.removeItem("access_token")
|
return
|
||||||
window.location.href = "/login"
|
}
|
||||||
|
|
||||||
|
if (shouldHandleAuthError(error.status, error.body)) {
|
||||||
|
clearAuthAndRedirectToLogin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { Route as LoginRouteImport } from './routes/login'
|
|||||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
|
import { Route as LayoutMqttRawRouteImport } from './routes/_layout/mqtt-raw'
|
||||||
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
||||||
|
import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor'
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SignupRoute = SignupRouteImport.update({
|
||||||
@@ -53,11 +55,21 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutMqttRawRoute = LayoutMqttRawRouteImport.update({
|
||||||
|
id: '/mqtt-raw',
|
||||||
|
path: '/mqtt-raw',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
||||||
id: '/locations',
|
id: '/locations',
|
||||||
path: '/locations',
|
path: '/locations',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutGnssMonitorRoute = LayoutGnssMonitorRouteImport.update({
|
||||||
|
id: '/gnss-monitor',
|
||||||
|
path: '/gnss-monitor',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@@ -71,7 +83,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
|
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||||
'/locations': typeof LayoutLocationsRoute
|
'/locations': typeof LayoutLocationsRoute
|
||||||
|
'/mqtt-raw': typeof LayoutMqttRawRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -80,7 +94,9 @@ export interface FileRoutesByTo {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
|
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||||
'/locations': typeof LayoutLocationsRoute
|
'/locations': typeof LayoutLocationsRoute
|
||||||
|
'/mqtt-raw': typeof LayoutMqttRawRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/': typeof LayoutIndexRoute
|
'/': typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -92,7 +108,9 @@ export interface FileRoutesById {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/_layout/admin': typeof LayoutAdminRoute
|
'/_layout/admin': typeof LayoutAdminRoute
|
||||||
|
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||||
'/_layout/locations': typeof LayoutLocationsRoute
|
'/_layout/locations': typeof LayoutLocationsRoute
|
||||||
|
'/_layout/mqtt-raw': typeof LayoutMqttRawRoute
|
||||||
'/_layout/settings': typeof LayoutSettingsRoute
|
'/_layout/settings': typeof LayoutSettingsRoute
|
||||||
'/_layout/': typeof LayoutIndexRoute
|
'/_layout/': typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -105,7 +123,9 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/gnss-monitor'
|
||||||
| '/locations'
|
| '/locations'
|
||||||
|
| '/mqtt-raw'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -114,7 +134,9 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/gnss-monitor'
|
||||||
| '/locations'
|
| '/locations'
|
||||||
|
| '/mqtt-raw'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/'
|
| '/'
|
||||||
id:
|
id:
|
||||||
@@ -125,7 +147,9 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/_layout/admin'
|
| '/_layout/admin'
|
||||||
|
| '/_layout/gnss-monitor'
|
||||||
| '/_layout/locations'
|
| '/_layout/locations'
|
||||||
|
| '/_layout/mqtt-raw'
|
||||||
| '/_layout/settings'
|
| '/_layout/settings'
|
||||||
| '/_layout/'
|
| '/_layout/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -189,6 +213,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/mqtt-raw': {
|
||||||
|
id: '/_layout/mqtt-raw'
|
||||||
|
path: '/mqtt-raw'
|
||||||
|
fullPath: '/mqtt-raw'
|
||||||
|
preLoaderRoute: typeof LayoutMqttRawRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_layout/locations': {
|
'/_layout/locations': {
|
||||||
id: '/_layout/locations'
|
id: '/_layout/locations'
|
||||||
path: '/locations'
|
path: '/locations'
|
||||||
@@ -196,6 +227,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutLocationsRouteImport
|
preLoaderRoute: typeof LayoutLocationsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/gnss-monitor': {
|
||||||
|
id: '/_layout/gnss-monitor'
|
||||||
|
path: '/gnss-monitor'
|
||||||
|
fullPath: '/gnss-monitor'
|
||||||
|
preLoaderRoute: typeof LayoutGnssMonitorRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_layout/admin': {
|
'/_layout/admin': {
|
||||||
id: '/_layout/admin'
|
id: '/_layout/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
@@ -208,14 +246,18 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface LayoutRouteChildren {
|
interface LayoutRouteChildren {
|
||||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||||
|
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
|
||||||
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
||||||
|
LayoutMqttRawRoute: typeof LayoutMqttRawRoute
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||||
LayoutAdminRoute: LayoutAdminRoute,
|
LayoutAdminRoute: LayoutAdminRoute,
|
||||||
|
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
|
||||||
LayoutLocationsRoute: LayoutLocationsRoute,
|
LayoutLocationsRoute: LayoutLocationsRoute,
|
||||||
|
LayoutMqttRawRoute: LayoutMqttRawRoute,
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
useRouterState,
|
||||||
|
} from "@tanstack/react-router"
|
||||||
|
|
||||||
import { Footer } from "@/components/Common/Footer"
|
import { Footer } from "@/components/Common/Footer"
|
||||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||||
@@ -21,19 +26,28 @@ export const Route = createFileRoute("/_layout")({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
})
|
||||||
|
const isFullScreenPage = pathname === "/gnss-monitor" || pathname === "/"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset className="flex flex-col">
|
||||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 p-6 md:p-8">
|
<main
|
||||||
<div className="mx-auto max-w-7xl">
|
className={`flex-1 flex flex-col ${isFullScreenPage ? "p-0" : "p-6 md:p-8"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex-1 flex flex-col mx-auto w-full ${isFullScreenPage ? "max-w-none" : "max-w-7xl"}`}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
{!isFullScreenPage && <Footer />}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
826
frontend/src/routes/_layout/gnss-monitor.tsx
Normal file
826
frontend/src/routes/_layout/gnss-monitor.tsx
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Compass, Gauge, MapPin, Radio, Satellite, Signal } from "lucide-react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
type SignalHealthLevel = "good" | "fair" | "poor" | "unknown"
|
||||||
|
|
||||||
|
type SignalHealth = {
|
||||||
|
level: SignalHealthLevel
|
||||||
|
score: number
|
||||||
|
strong_signal_count?: number
|
||||||
|
average_snr_dbhz: number | null
|
||||||
|
best_snr_dbhz: number | null
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SatellitePoint = {
|
||||||
|
satellite_id: string
|
||||||
|
constellation: string
|
||||||
|
azimuth_deg: number | null
|
||||||
|
elevation_deg: number | null
|
||||||
|
snr_dbhz: number | null
|
||||||
|
used_in_navigation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SatelliteSummary = {
|
||||||
|
satellites_in_view: number | null
|
||||||
|
snr_values: number[]
|
||||||
|
constellations: Record<
|
||||||
|
string,
|
||||||
|
{ count?: number; with_signal?: number; used_in_navigation?: number }
|
||||||
|
>
|
||||||
|
used_satellite_ids: string[]
|
||||||
|
satellites: SatellitePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetryDevice = {
|
||||||
|
device_id: string
|
||||||
|
last_event_time: string | null
|
||||||
|
updated_at: string
|
||||||
|
fix_status: string | null
|
||||||
|
lat: number | null
|
||||||
|
lon: number | null
|
||||||
|
alt_m: number | null
|
||||||
|
satellites_used: number | null
|
||||||
|
satellite_signal_summary: SatelliteSummary
|
||||||
|
signal_health: SignalHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetrySnapshot = {
|
||||||
|
generated_at: string
|
||||||
|
devices: TelemetryDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSTELLATION_COLORS: Record<string, string> = {
|
||||||
|
GPS: "#f59e0b",
|
||||||
|
SBAS: "#9ca3af",
|
||||||
|
BeiDou: "#8b5cf6",
|
||||||
|
Galileo: "#84cc16",
|
||||||
|
GLONASS: "#06b6d4",
|
||||||
|
QZSS: "#f97316",
|
||||||
|
NavIC: "#ec4899",
|
||||||
|
Mixed: "#64748b",
|
||||||
|
Unknown: "#94a3b8",
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSTELLATION_ORDER = [
|
||||||
|
"GPS",
|
||||||
|
"SBAS",
|
||||||
|
"Galileo",
|
||||||
|
"BeiDou",
|
||||||
|
"GLONASS",
|
||||||
|
"QZSS",
|
||||||
|
"NavIC",
|
||||||
|
"Mixed",
|
||||||
|
"Unknown",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const CONSTELLATION_PREFIX: Record<string, string> = {
|
||||||
|
GPS: "G",
|
||||||
|
SBAS: "S",
|
||||||
|
Galileo: "E",
|
||||||
|
BeiDou: "B",
|
||||||
|
GLONASS: "R",
|
||||||
|
QZSS: "Q",
|
||||||
|
NavIC: "I",
|
||||||
|
Mixed: "M",
|
||||||
|
Unknown: "U",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNAL_STATUS_CLASS: Record<SignalHealthLevel, string> = {
|
||||||
|
good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
|
||||||
|
fair: "text-amber-500 bg-amber-500/10 border-amber-500/20",
|
||||||
|
poor: "text-red-500 bg-red-500/10 border-red-500/20",
|
||||||
|
unknown: "text-muted-foreground bg-muted border-border",
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIX_STATUS_META: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; className: string; description: string }
|
||||||
|
> = {
|
||||||
|
"0": {
|
||||||
|
label: "No Fix",
|
||||||
|
className: "text-red-500 bg-red-500/10 border-red-500/20",
|
||||||
|
description: "No position solution",
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
label: "Single",
|
||||||
|
className: "text-amber-500 bg-amber-500/10 border-amber-500/20",
|
||||||
|
description: "Standalone GNSS fix",
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
label: "DGPS",
|
||||||
|
className: "text-sky-500 bg-sky-500/10 border-sky-500/20",
|
||||||
|
description: "Differential correction active",
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
label: "RTK Fixed",
|
||||||
|
className: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
|
||||||
|
description: "Carrier fixed solution",
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
label: "RTK Float",
|
||||||
|
className: "text-orange-500 bg-orange-500/10 border-orange-500/20",
|
||||||
|
description: "Carrier float solution",
|
||||||
|
},
|
||||||
|
A: {
|
||||||
|
label: "Valid",
|
||||||
|
className: "text-sky-500 bg-sky-500/10 border-sky-500/20",
|
||||||
|
description: "Valid navigation status",
|
||||||
|
},
|
||||||
|
V: {
|
||||||
|
label: "Invalid",
|
||||||
|
className: "text-red-500 bg-red-500/10 border-red-500/20",
|
||||||
|
description: "Invalid navigation status",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/gnss-monitor")({
|
||||||
|
component: GnssMonitor,
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: "GNSS Monitor - FastAPI Template",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildTelemetryWsUrl(token: string): string {
|
||||||
|
const apiBase = (
|
||||||
|
import.meta.env.VITE_API_URL || window.location.origin
|
||||||
|
).replace(/\/$/, "")
|
||||||
|
const wsBase = apiBase.startsWith("https://")
|
||||||
|
? `wss://${apiBase.slice("https://".length)}`
|
||||||
|
: apiBase.startsWith("http://")
|
||||||
|
? `ws://${apiBase.slice("http://".length)}`
|
||||||
|
: apiBase
|
||||||
|
return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoord(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(value: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
const diffSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((Date.now() - new Date(value).getTime()) / 1000),
|
||||||
|
)
|
||||||
|
if (diffSeconds < 60) {
|
||||||
|
return `${diffSeconds}s`
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(diffSeconds / 60)
|
||||||
|
const seconds = diffSeconds % 60
|
||||||
|
return `${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFixMeta(fixStatus: string | null) {
|
||||||
|
if (!fixStatus) {
|
||||||
|
return {
|
||||||
|
label: "Unknown",
|
||||||
|
className: "text-muted-foreground bg-muted border-border",
|
||||||
|
description: "No fix state received yet",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
FIX_STATUS_META[fixStatus] ?? {
|
||||||
|
label: `Fix ${fixStatus}`,
|
||||||
|
className: "text-muted-foreground bg-muted border-border",
|
||||||
|
description: "Unrecognized fix code",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function satColor(constellation: string): string {
|
||||||
|
return CONSTELLATION_COLORS[constellation] ?? CONSTELLATION_COLORS.Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function satPrefix(constellation: string): string {
|
||||||
|
return CONSTELLATION_PREFIX[constellation] ?? CONSTELLATION_PREFIX.Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function satelliteSortValue(satelliteId: string): number {
|
||||||
|
const digits = satelliteId.replace(/\D+/g, "")
|
||||||
|
return digits ? Number(digits) : Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
|
||||||
|
function satelliteName(satellite: SatellitePoint): string {
|
||||||
|
const satId = satellite.satellite_id.trim()
|
||||||
|
if (/^[A-Za-z]/.test(satId)) {
|
||||||
|
return satId
|
||||||
|
}
|
||||||
|
return `${satPrefix(satellite.constellation)}${satId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalBars({ satellites }: { satellites: SatellitePoint[] }) {
|
||||||
|
const withSnr = satellites.filter(
|
||||||
|
(sat) => sat.snr_dbhz !== null && sat.snr_dbhz > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (withSnr.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground border rounded-lg">
|
||||||
|
No SNR data yet
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = CONSTELLATION_ORDER.map((name) => ({
|
||||||
|
name,
|
||||||
|
satellites: withSnr
|
||||||
|
.filter((sat) => sat.constellation === name)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
satelliteSortValue(a.satellite_id) -
|
||||||
|
satelliteSortValue(b.satellite_id),
|
||||||
|
),
|
||||||
|
})).filter((group) => group.satellites.length > 0)
|
||||||
|
|
||||||
|
const unknownGroup = withSnr
|
||||||
|
.filter(
|
||||||
|
(sat) =>
|
||||||
|
!CONSTELLATION_ORDER.includes(
|
||||||
|
sat.constellation as (typeof CONSTELLATION_ORDER)[number],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
satelliteSortValue(a.satellite_id) - satelliteSortValue(b.satellite_id),
|
||||||
|
)
|
||||||
|
if (unknownGroup.length > 0) {
|
||||||
|
grouped.push({ name: "Unknown", satellites: unknownGroup })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bars = grouped.flatMap((group, groupIndex) =>
|
||||||
|
group.satellites.map((satellite, index) => ({
|
||||||
|
groupName: group.name,
|
||||||
|
groupStart: groupIndex > 0 && index === 0,
|
||||||
|
satellite,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columnStyle = {
|
||||||
|
gridTemplateColumns: `repeat(${bars.length}, minmax(0, 1fr))`,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5"
|
||||||
|
key={group.name}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: satColor(group.name) }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{group.name} ({satPrefix(group.name)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||||
|
<span className="size-2 rounded-full border-2 border-blue-500" />
|
||||||
|
<span>Not used in navigation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 min-w-0">
|
||||||
|
<div className="w-10 h-[200px] text-[11px] text-muted-foreground shrink-0 flex flex-col justify-between">
|
||||||
|
{[50, 40, 30, 20, 10, 0].map((tick) => (
|
||||||
|
<span className="leading-none text-right" key={tick}>
|
||||||
|
{tick}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-[250px] flex-1 min-w-0">
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{[0, 10, 20, 30, 40, 50].map((tick) => (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 border-t border-dashed border-border/70"
|
||||||
|
key={tick}
|
||||||
|
style={{ bottom: `${(tick / 50) * 200}px` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative h-full grid gap-x-1 min-w-0"
|
||||||
|
style={columnStyle}
|
||||||
|
>
|
||||||
|
{bars.map((entry) => {
|
||||||
|
const snr = entry.satellite.snr_dbhz ?? 0
|
||||||
|
const color = satColor(entry.groupName)
|
||||||
|
const height = Math.max(Math.round((snr / 50) * 200), 4)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-0 ${entry.groupStart ? "border-l border-border/80 pl-1" : ""}`}
|
||||||
|
key={`${entry.groupName}-${entry.satellite.satellite_id}`}
|
||||||
|
>
|
||||||
|
<div className="h-[200px] flex items-end justify-center">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-4 rounded-sm border"
|
||||||
|
style={{
|
||||||
|
height: `${height}px`,
|
||||||
|
backgroundColor: entry.satellite.used_in_navigation
|
||||||
|
? color
|
||||||
|
: `${color}33`,
|
||||||
|
borderColor: entry.satellite.used_in_navigation
|
||||||
|
? color
|
||||||
|
: "#2563eb",
|
||||||
|
borderWidth: entry.satellite.used_in_navigation
|
||||||
|
? "1px"
|
||||||
|
: "2px",
|
||||||
|
}}
|
||||||
|
title={`${satelliteName(entry.satellite)} • ${entry.groupName} • ${snr} dB-Hz`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-col items-center leading-none gap-1">
|
||||||
|
<span className="text-[10px] text-center whitespace-nowrap">
|
||||||
|
{satelliteName(entry.satellite)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground text-center">
|
||||||
|
{snr}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkyPlot({ satellites }: { satellites: SatellitePoint[] }) {
|
||||||
|
const points = satellites.filter(
|
||||||
|
(sat) =>
|
||||||
|
sat.azimuth_deg !== null &&
|
||||||
|
sat.elevation_deg !== null &&
|
||||||
|
sat.elevation_deg >= 0 &&
|
||||||
|
sat.elevation_deg <= 90,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full max-w-[420px] mx-auto aspect-square rounded-full border bg-muted/20">
|
||||||
|
<div className="absolute inset-[16%] rounded-full border border-dashed border-border" />
|
||||||
|
<div className="absolute inset-[33%] rounded-full border border-dashed border-border" />
|
||||||
|
<div className="absolute inset-[50%] rounded-full border border-dashed border-border" />
|
||||||
|
<div className="absolute left-1/2 top-0 -translate-x-1/2 text-xs text-muted-foreground">
|
||||||
|
N
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1/2 bottom-0 -translate-x-1/2 text-xs text-muted-foreground">
|
||||||
|
S
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||||
|
W
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
{points.map((satellite) => {
|
||||||
|
const azimuth = satellite.azimuth_deg ?? 0
|
||||||
|
const elevation = satellite.elevation_deg ?? 0
|
||||||
|
const radialPercent = ((90 - elevation) / 90) * 45
|
||||||
|
const angle = ((azimuth - 90) * Math.PI) / 180
|
||||||
|
const x = Math.cos(angle) * radialPercent
|
||||||
|
const y = Math.sin(angle) * radialPercent
|
||||||
|
const color = satColor(satellite.constellation)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute size-4 rounded-full border-2"
|
||||||
|
key={`${satellite.constellation}-${satellite.satellite_id}-sky`}
|
||||||
|
style={{
|
||||||
|
left: `${50 + x}%`,
|
||||||
|
top: `${50 + y}%`,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor: satellite.used_in_navigation
|
||||||
|
? color
|
||||||
|
: "var(--border)",
|
||||||
|
opacity: satellite.used_in_navigation ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
title={`${satellite.constellation}-${satellite.satellite_id} (${satellite.snr_dbhz ?? "-"} dB-Hz)`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: "connecting" | "live" | "retrying"
|
||||||
|
}) {
|
||||||
|
const statusText =
|
||||||
|
status === "live"
|
||||||
|
? "Live"
|
||||||
|
: status === "retrying"
|
||||||
|
? "Reconnecting"
|
||||||
|
: "Connecting"
|
||||||
|
const statusClass =
|
||||||
|
status === "live"
|
||||||
|
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
|
||||||
|
: "text-amber-500 bg-amber-500/10 border-amber-500/20"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={statusClass}>
|
||||||
|
<Radio className="size-3" />
|
||||||
|
{statusText}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GnssMonitor() {
|
||||||
|
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
|
||||||
|
const [status, setStatus] = useState<"connecting" | "live" | "retrying">(
|
||||||
|
"connecting",
|
||||||
|
)
|
||||||
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("access_token")
|
||||||
|
if (!token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let reconnectTimer: number | undefined
|
||||||
|
let socket: WebSocket | undefined
|
||||||
|
let shouldReconnect = true
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
setStatus((prev) => (prev === "live" ? prev : "connecting"))
|
||||||
|
socket = new WebSocket(buildTelemetryWsUrl(token))
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setStatus("live")
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as TelemetrySnapshot
|
||||||
|
setSnapshot(payload)
|
||||||
|
setStatus("live")
|
||||||
|
} catch {
|
||||||
|
setStatus("retrying")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (!shouldReconnect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus("retrying")
|
||||||
|
reconnectTimer = window.setTimeout(connect, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
shouldReconnect = false
|
||||||
|
if (reconnectTimer) {
|
||||||
|
window.clearTimeout(reconnectTimer)
|
||||||
|
}
|
||||||
|
socket?.close()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshot?.devices.length) {
|
||||||
|
setSelectedDeviceId("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = snapshot.devices.some(
|
||||||
|
(device) => device.device_id === selectedDeviceId,
|
||||||
|
)
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedDeviceId(snapshot.devices[0].device_id)
|
||||||
|
}
|
||||||
|
}, [snapshot, selectedDeviceId])
|
||||||
|
|
||||||
|
const currentDevice = useMemo(() => {
|
||||||
|
if (!snapshot?.devices.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
snapshot.devices.find(
|
||||||
|
(device) => device.device_id === selectedDeviceId,
|
||||||
|
) ?? snapshot.devices[0]
|
||||||
|
)
|
||||||
|
}, [selectedDeviceId, snapshot])
|
||||||
|
|
||||||
|
const fixMeta = getFixMeta(currentDevice?.fix_status ?? null)
|
||||||
|
const satellites = currentDevice?.satellite_signal_summary.satellites ?? []
|
||||||
|
const constellations =
|
||||||
|
currentDevice?.satellite_signal_summary.constellations ?? {}
|
||||||
|
const signalHealth = currentDevice?.signal_health
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">GNSS Monitor</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Real-time positioning and satellite signal watch
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ConnectionBadge status={status} />
|
||||||
|
<Badge variant="outline">
|
||||||
|
{snapshot?.generated_at
|
||||||
|
? `Updated ${formatTime(snapshot.generated_at)}`
|
||||||
|
: "Waiting for stream"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3">
|
||||||
|
<CardTitle>Device</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose a device to inspect live fix and satellite conditions
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-md border bg-background px-3 text-sm min-w-[280px]"
|
||||||
|
onChange={(event) => setSelectedDeviceId(event.target.value)}
|
||||||
|
value={selectedDeviceId}
|
||||||
|
>
|
||||||
|
{snapshot?.devices.map((device) => (
|
||||||
|
<option key={device.device_id} value={device.device_id}>
|
||||||
|
{device.device_id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!currentDevice ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-muted-foreground">
|
||||||
|
Waiting for MQTT parsed telemetry...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Gauge className="size-4" />
|
||||||
|
Fix Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Badge variant="outline" className={fixMeta.className}>
|
||||||
|
{fixMeta.label}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{fixMeta.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Signal className="size-4" />
|
||||||
|
Signal Health
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
SIGNAL_STATUS_CLASS[signalHealth?.level ?? "unknown"]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(signalHealth?.level ?? "unknown").toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Score</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{signalHealth?.score ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${signalHealth?.score ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{signalHealth?.message ?? "No signal data"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Satellite className="size-4" />
|
||||||
|
Satellites
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Used in fix</span>
|
||||||
|
<span>{currentDevice.satellites_used ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">In view</span>
|
||||||
|
<span>
|
||||||
|
{currentDevice.satellite_signal_summary
|
||||||
|
.satellites_in_view ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Best SNR</span>
|
||||||
|
<span>{signalHealth?.best_snr_dbhz ?? "-"} dB-Hz</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<MapPin className="size-4" />
|
||||||
|
Position
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Latitude</span>
|
||||||
|
<span>{formatCoord(currentDevice.lat)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Longitude</span>
|
||||||
|
<span>{formatCoord(currentDevice.lon)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Altitude</span>
|
||||||
|
<span>
|
||||||
|
{currentDevice.alt_m === null
|
||||||
|
? "-"
|
||||||
|
: `${currentDevice.alt_m.toFixed(2)} m`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Signal className="size-4" />
|
||||||
|
Satellite Signal View
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Grouped by constellation, with per-satellite CN0 bars and clear
|
||||||
|
non-navigation marking.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SignalBars satellites={satellites} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[460px_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Compass className="size-4" />
|
||||||
|
Satellite Position View
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sky plot by azimuth/elevation for quick distribution checks.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<SkyPlot satellites={satellites} />
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{Object.entries(constellations).map(([name, detail]) => (
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-2 py-1 flex items-center justify-between"
|
||||||
|
key={name}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: satColor(name) }}
|
||||||
|
/>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{detail.used_in_navigation ?? 0}/{detail.count ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data View</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Lightweight runtime details for monitoring, without offline
|
||||||
|
analysis.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Device ID</p>
|
||||||
|
<p className="font-medium break-all">
|
||||||
|
{currentDevice.device_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Last Event Time</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatTime(currentDevice.last_event_time)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Data Age</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatAge(currentDevice.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Average SNR</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{signalHealth?.average_snr_dbhz ?? "-"} dB-Hz
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{"Strong CN0 (>=40)"}</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{signalHealth?.strong_signal_count ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
|
<p className="text-muted-foreground mb-2">Map Jump</p>
|
||||||
|
{currentDevice.lat === null || currentDevice.lon === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Waiting for valid coordinates
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a
|
||||||
|
href={`https://www.openstreetmap.org/?mlat=${currentDevice.lat}&mlon=${currentDevice.lon}&zoom=16`}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Open in OpenStreetMap
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,779 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import L from "leaflet"
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Layers,
|
||||||
|
Maximize,
|
||||||
|
Mountain,
|
||||||
|
Radio,
|
||||||
|
Satellite,
|
||||||
|
Signal,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"
|
||||||
|
import "leaflet/dist/leaflet.css"
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { wgs84ToGcj02 } from "@/lib/coord-transform"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_layout/")({
|
export const Route = createFileRoute("/_layout/")({
|
||||||
component: Dashboard,
|
component: Dashboard,
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
title: "Dashboard - FastAPI Template",
|
title: "Device Map - SpatialHub",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
function Dashboard() {
|
/* ---------- types ---------- */
|
||||||
const { user: currentUser } = useAuth()
|
|
||||||
|
|
||||||
|
type SignalHealth = {
|
||||||
|
level: string
|
||||||
|
score: number
|
||||||
|
strong_signal_count: number
|
||||||
|
average_snr_dbhz: number | null
|
||||||
|
best_snr_dbhz: number | null
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetryDevice = {
|
||||||
|
device_id: string
|
||||||
|
last_event_time: string | null
|
||||||
|
updated_at: string
|
||||||
|
fix_status: string | null
|
||||||
|
lat: number | null
|
||||||
|
lon: number | null
|
||||||
|
alt_m: number | null
|
||||||
|
satellites_used: number | null
|
||||||
|
signal_health: SignalHealth | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetrySnapshot = {
|
||||||
|
generated_at: string
|
||||||
|
devices: TelemetryDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionState = "connecting" | "live" | "retrying"
|
||||||
|
type BaseMapType = "osm" | "amap" | "amap-satellite"
|
||||||
|
type MapControlBridge = {
|
||||||
|
__mapFitAll?: () => void
|
||||||
|
__mapFocusDevice?: (coordinate: [number, number]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- constants ---------- */
|
||||||
|
|
||||||
|
const BASE_MAPS: Record<
|
||||||
|
BaseMapType,
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
attribution: string
|
||||||
|
subdomains: string
|
||||||
|
gcj02: boolean
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
osm: {
|
||||||
|
label: "OpenStreetMap",
|
||||||
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
attribution: "© OpenStreetMap contributors",
|
||||||
|
subdomains: "abc",
|
||||||
|
gcj02: false,
|
||||||
|
},
|
||||||
|
amap: {
|
||||||
|
label: "高德矢量",
|
||||||
|
url: "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}",
|
||||||
|
attribution: "© AutoNavi",
|
||||||
|
subdomains: "1234",
|
||||||
|
gcj02: true,
|
||||||
|
},
|
||||||
|
"amap-satellite": {
|
||||||
|
label: "高德卫星",
|
||||||
|
url: "https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
|
||||||
|
attribution: "© AutoNavi",
|
||||||
|
subdomains: "1234",
|
||||||
|
gcj02: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CENTER: [number, number] = [31.2304, 121.4737]
|
||||||
|
const DEFAULT_ZOOM = 5
|
||||||
|
|
||||||
|
const FIX_STATUS_LABEL: Record<string, string> = {
|
||||||
|
"0": "No Fix",
|
||||||
|
"1": "Single",
|
||||||
|
"2": "DGPS",
|
||||||
|
"4": "RTK Fixed",
|
||||||
|
"5": "RTK Float",
|
||||||
|
A: "Valid",
|
||||||
|
V: "Invalid",
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- custom marker icons ---------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an SVG DivIcon for a device marker.
|
||||||
|
* - Online devices: pin shape with colored gradient + subtle pulse ring
|
||||||
|
* - Offline devices: muted grey, no pulse
|
||||||
|
* - Selected device: larger + bright border ring
|
||||||
|
*/
|
||||||
|
function buildDeviceIcon(
|
||||||
|
fillColor: string,
|
||||||
|
isSelected: boolean,
|
||||||
|
isOffline: boolean,
|
||||||
|
): L.DivIcon {
|
||||||
|
const size = isSelected ? 36 : 28
|
||||||
|
const half = size / 2
|
||||||
|
const dotR = isSelected ? 7 : 5.5
|
||||||
|
const ringR = isSelected ? 12 : 9
|
||||||
|
|
||||||
|
// Pulse ring only for online + selected-or-not
|
||||||
|
const pulseRing = isOffline
|
||||||
|
? ""
|
||||||
|
: `<circle cx="${half}" cy="${half}" r="${ringR}"
|
||||||
|
fill="none" stroke="${fillColor}" stroke-width="2" opacity="0.5">
|
||||||
|
<animate attributeName="r" from="${ringR}" to="${half - 1}" dur="1.8s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="opacity" from="0.5" to="0" dur="1.8s" repeatCount="indefinite"/>
|
||||||
|
</circle>`
|
||||||
|
|
||||||
|
const borderStroke = isSelected
|
||||||
|
? `stroke="${fillColor}" stroke-width="2.5"`
|
||||||
|
: `stroke="rgba(255,255,255,0.85)" stroke-width="1.5"`
|
||||||
|
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
${pulseRing}
|
||||||
|
<circle cx="${half}" cy="${half}" r="${dotR}" fill="${fillColor}" ${borderStroke}
|
||||||
|
style="filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35));" />
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
html: svg,
|
||||||
|
className: "", // remove default leaflet-div-icon styling
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [half, half],
|
||||||
|
popupAnchor: [0, -half],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function buildTelemetryWsUrl(token: string): string {
|
||||||
|
const apiBase = (
|
||||||
|
import.meta.env.VITE_API_URL || window.location.origin
|
||||||
|
).replace(/\/$/, "")
|
||||||
|
const wsBase = apiBase.startsWith("https://")
|
||||||
|
? `wss://${apiBase.slice("https://".length)}`
|
||||||
|
: apiBase.startsWith("http://")
|
||||||
|
? `ws://${apiBase.slice("http://".length)}`
|
||||||
|
: apiBase
|
||||||
|
return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoord(value: number | null): string {
|
||||||
|
if (value === null) return "-"
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFixStatusLabel(value: string | null): string {
|
||||||
|
if (!value) return "Unknown"
|
||||||
|
return FIX_STATUS_LABEL[value] ?? `Fix ${value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFixStatusColor(value: string | null): string {
|
||||||
|
if (value === "4") return "text-emerald-500"
|
||||||
|
if (value === "5") return "text-orange-400"
|
||||||
|
if (value === "2" || value === "A") return "text-sky-500"
|
||||||
|
if (value === "1") return "text-amber-500"
|
||||||
|
if (value === "0" || value === "V") return "text-red-500"
|
||||||
|
return "text-muted-foreground"
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceMarkerColor(
|
||||||
|
fixStatus: string | null,
|
||||||
|
isOffline: boolean,
|
||||||
|
): string {
|
||||||
|
if (isOffline) return "#94a3b8"
|
||||||
|
if (fixStatus === "4") return "#10b981"
|
||||||
|
if (fixStatus === "5") return "#fb923c"
|
||||||
|
if (fixStatus === "2" || fixStatus === "A") return "#0ea5e9"
|
||||||
|
if (fixStatus === "1") return "#f59e0b"
|
||||||
|
if (fixStatus === "0" || fixStatus === "V") return "#ef4444"
|
||||||
|
return "#64748b"
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
function checkIsOffline(lastEventTime: string | null): boolean {
|
||||||
|
if (!lastEventTime) return true
|
||||||
|
return Date.now() - new Date(lastEventTime).getTime() > OFFLINE_THRESHOLD_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(isoString: string | null): string {
|
||||||
|
if (!isoString) return "Unknown"
|
||||||
|
const diffMs = Date.now() - new Date(isoString).getTime()
|
||||||
|
if (diffMs < 0) return "just now"
|
||||||
|
const seconds = Math.floor(diffMs / 1000)
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ${minutes % 60}m ago`
|
||||||
|
return new Date(isoString).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalHealthBadge(health: SignalHealth | null) {
|
||||||
|
if (!health) return null
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
|
||||||
|
fair: "text-amber-500 bg-amber-500/10 border-amber-500/20",
|
||||||
|
poor: "text-red-500 bg-red-500/10 border-red-500/20",
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<Badge variant="outline" className={colors[health.level] ?? ""}>
|
||||||
<div>
|
<Signal className="size-3" />
|
||||||
<h1 className="text-2xl truncate max-w-sm">
|
{health.level === "good"
|
||||||
Hi, {currentUser?.full_name || currentUser?.email} 👋
|
? "Good"
|
||||||
</h1>
|
: health.level === "fair"
|
||||||
<p className="text-muted-foreground">
|
? "Fair"
|
||||||
Welcome back, nice to see you again!!!
|
: "Poor"}{" "}
|
||||||
</p>
|
({health.score}%)
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectCoord(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
useGcj02: boolean,
|
||||||
|
): [number, number] {
|
||||||
|
if (!useGcj02) return [lat, lon]
|
||||||
|
return wgs84ToGcj02(lat, lon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- map sub-components ---------- */
|
||||||
|
|
||||||
|
function FitBoundsToDevices({
|
||||||
|
coordinates,
|
||||||
|
}: {
|
||||||
|
coordinates: Array<[number, number]>
|
||||||
|
}) {
|
||||||
|
const map = useMap()
|
||||||
|
const hasFitted = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coordinates.length === 0 || hasFitted.current) return
|
||||||
|
hasFitted.current = true
|
||||||
|
if (coordinates.length === 1) {
|
||||||
|
map.setView(coordinates[0], 14, { animate: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map.fitBounds(coordinates, { padding: [40, 40] })
|
||||||
|
}, [coordinates, map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badges-only overlay — lives inside MapContainer, no dropdown needed */
|
||||||
|
function MapStatusBadges({
|
||||||
|
isLive,
|
||||||
|
status,
|
||||||
|
deviceCount,
|
||||||
|
disconnectedDuration,
|
||||||
|
}: {
|
||||||
|
isLive: boolean
|
||||||
|
status: ConnectionState
|
||||||
|
deviceCount: number
|
||||||
|
disconnectedDuration: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="leaflet-bottom leaflet-left ml-3 mb-5"
|
||||||
|
style={{ pointerEvents: "auto", zIndex: 800 }}
|
||||||
|
>
|
||||||
|
<div className="leaflet-control flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`backdrop-blur-md bg-background/80 shadow-sm ${
|
||||||
|
isLive
|
||||||
|
? "text-emerald-500 border-emerald-500/30"
|
||||||
|
: "text-amber-500 border-amber-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLive && (
|
||||||
|
<span className="relative mr-1.5 flex size-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isLive && <Radio className="size-3" />}
|
||||||
|
{isLive
|
||||||
|
? "Live"
|
||||||
|
: status === "retrying"
|
||||||
|
? `Reconnecting${disconnectedDuration > 0 ? ` (${disconnectedDuration}s)` : ""}`
|
||||||
|
: "Connecting"}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="backdrop-blur-md bg-background/80 shadow-sm"
|
||||||
|
>
|
||||||
|
Devices: {deviceCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FitBounds helper — must live inside MapContainer to call useMap() */
|
||||||
|
function FitBoundsControl({
|
||||||
|
coordinates,
|
||||||
|
}: {
|
||||||
|
coordinates: Array<[number, number]>
|
||||||
|
}) {
|
||||||
|
const map = useMap()
|
||||||
|
const fitAll = useCallback(() => {
|
||||||
|
if (!coordinates.length) return
|
||||||
|
if (coordinates.length === 1) {
|
||||||
|
map.setView(coordinates[0], 14, { animate: true })
|
||||||
|
} else {
|
||||||
|
map.fitBounds(coordinates, { padding: [40, 40] })
|
||||||
|
}
|
||||||
|
}, [coordinates, map])
|
||||||
|
|
||||||
|
const focusDevice = useCallback(
|
||||||
|
(coordinate: [number, number]) => {
|
||||||
|
const maxZoom = map.getMaxZoom()
|
||||||
|
const targetZoom = Number.isFinite(maxZoom) ? maxZoom : 18
|
||||||
|
map.setView(coordinate, targetZoom, { animate: true })
|
||||||
|
},
|
||||||
|
[map],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ref = window as unknown as MapControlBridge
|
||||||
|
ref.__mapFitAll = fitAll
|
||||||
|
ref.__mapFocusDevice = focusDevice
|
||||||
|
return () => {
|
||||||
|
if (ref.__mapFitAll === fitAll) {
|
||||||
|
delete ref.__mapFitAll
|
||||||
|
}
|
||||||
|
if (ref.__mapFocusDevice === focusDevice) {
|
||||||
|
delete ref.__mapFocusDevice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fitAll, focusDevice])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- WebSocket hook ---------- */
|
||||||
|
|
||||||
|
function useTelemetryWS() {
|
||||||
|
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
|
||||||
|
const [status, setStatus] = useState<ConnectionState>("connecting")
|
||||||
|
const [disconnectedAt, setDisconnectedAt] = useState<number | null>(null)
|
||||||
|
const retryCount = useRef(0)
|
||||||
|
const lastMessageAt = useRef<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("access_token")
|
||||||
|
if (!token) {
|
||||||
|
setStatus("retrying")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let reconnectTimer: number | undefined
|
||||||
|
let staleTimer: number | undefined
|
||||||
|
let socket: WebSocket | undefined
|
||||||
|
let shouldReconnect = true
|
||||||
|
|
||||||
|
const STALE_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
const resetStaleTimer = () => {
|
||||||
|
if (staleTimer) window.clearTimeout(staleTimer)
|
||||||
|
staleTimer = window.setTimeout(() => {
|
||||||
|
socket?.close()
|
||||||
|
}, STALE_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
setStatus((prev) => (prev === "live" ? prev : "connecting"))
|
||||||
|
socket = new WebSocket(buildTelemetryWsUrl(token))
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setStatus("live")
|
||||||
|
setDisconnectedAt(null)
|
||||||
|
retryCount.current = 0
|
||||||
|
lastMessageAt.current = Date.now()
|
||||||
|
resetStaleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as TelemetrySnapshot
|
||||||
|
setSnapshot(payload)
|
||||||
|
setStatus("live")
|
||||||
|
setDisconnectedAt(null)
|
||||||
|
lastMessageAt.current = Date.now()
|
||||||
|
resetStaleTimer()
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (staleTimer) window.clearTimeout(staleTimer)
|
||||||
|
if (!shouldReconnect) return
|
||||||
|
setStatus("retrying")
|
||||||
|
setDisconnectedAt((prev) => prev ?? Date.now())
|
||||||
|
const backoffMs = Math.min(2000 * 2 ** retryCount.current, 30000)
|
||||||
|
retryCount.current++
|
||||||
|
reconnectTimer = window.setTimeout(connect, backoffMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
shouldReconnect = false
|
||||||
|
if (reconnectTimer) window.clearTimeout(reconnectTimer)
|
||||||
|
if (staleTimer) window.clearTimeout(staleTimer)
|
||||||
|
if (socket) socket.close()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { snapshot, status, disconnectedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dashboard ---------- */
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { snapshot, status, disconnectedAt } = useTelemetryWS()
|
||||||
|
const [baseMap, setBaseMap] = useState<BaseMapType>("amap")
|
||||||
|
const [activeDeviceId, setActiveDeviceId] = useState<string>("")
|
||||||
|
const [isPanelExpanded, setIsPanelExpanded] = useState(true)
|
||||||
|
const activeDeviceIdRef = useRef(activeDeviceId)
|
||||||
|
activeDeviceIdRef.current = activeDeviceId
|
||||||
|
|
||||||
|
const isGcj02 = BASE_MAPS[baseMap].gcj02
|
||||||
|
|
||||||
|
const devicesWithCoords = useMemo(
|
||||||
|
() =>
|
||||||
|
(snapshot?.devices ?? []).filter(
|
||||||
|
(device) =>
|
||||||
|
device.lat !== null &&
|
||||||
|
device.lon !== null &&
|
||||||
|
device.lat >= -90 &&
|
||||||
|
device.lat <= 90 &&
|
||||||
|
device.lon >= -180 &&
|
||||||
|
device.lon <= 180,
|
||||||
|
),
|
||||||
|
[snapshot],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!devicesWithCoords.length) {
|
||||||
|
if (activeDeviceIdRef.current) setActiveDeviceId("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exists = devicesWithCoords.some(
|
||||||
|
(d) => d.device_id === activeDeviceIdRef.current,
|
||||||
|
)
|
||||||
|
if (!exists && !activeDeviceIdRef.current) {
|
||||||
|
setActiveDeviceId(devicesWithCoords[0].device_id)
|
||||||
|
}
|
||||||
|
}, [devicesWithCoords])
|
||||||
|
|
||||||
|
const activeDevice = useMemo(
|
||||||
|
() =>
|
||||||
|
devicesWithCoords.find((d) => d.device_id === activeDeviceId) ??
|
||||||
|
devicesWithCoords[0] ??
|
||||||
|
null,
|
||||||
|
[devicesWithCoords, activeDeviceId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const mapPoints = useMemo(
|
||||||
|
() =>
|
||||||
|
devicesWithCoords.map((d) =>
|
||||||
|
projectCoord(d.lat as number, d.lon as number, isGcj02),
|
||||||
|
),
|
||||||
|
[devicesWithCoords, isGcj02],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeviceClick = useCallback(
|
||||||
|
(deviceId: string, coordinate: [number, number]) => {
|
||||||
|
setActiveDeviceId(deviceId)
|
||||||
|
const mapBridge = window as unknown as MapControlBridge
|
||||||
|
mapBridge.__mapFocusDevice?.(coordinate)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLive = status === "live"
|
||||||
|
|
||||||
|
// Disconnected time ticker
|
||||||
|
const [, forceUpdate] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disconnectedAt) return
|
||||||
|
const interval = window.setInterval(() => forceUpdate((n) => n + 1), 1000)
|
||||||
|
return () => window.clearInterval(interval)
|
||||||
|
}, [disconnectedAt])
|
||||||
|
|
||||||
|
const disconnectedDuration = disconnectedAt
|
||||||
|
? Math.floor((Date.now() - disconnectedAt) / 1000)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background overflow-hidden">
|
||||||
|
{/* Map area — fills available space */}
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
|
{/* Controls live OUTSIDE MapContainer to avoid Leaflet z-index stacking issues */}
|
||||||
|
<div className="absolute top-3 right-3 z-9999 flex flex-col gap-2 pointer-events-auto">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shadow-md backdrop-blur-md bg-background/80"
|
||||||
|
>
|
||||||
|
<Layers className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="z-10001">
|
||||||
|
{(
|
||||||
|
Object.entries(BASE_MAPS) as Array<
|
||||||
|
[BaseMapType, (typeof BASE_MAPS)[BaseMapType]]
|
||||||
|
>
|
||||||
|
).map(([key, provider]) => (
|
||||||
|
<DropdownMenuItem key={key} onClick={() => setBaseMap(key)}>
|
||||||
|
{provider.label} {baseMap === key && "✓"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shadow-md backdrop-blur-md bg-background/80"
|
||||||
|
title="Fit all devices"
|
||||||
|
onClick={() =>
|
||||||
|
(window as unknown as Record<string, () => void>).__mapFitAll?.()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Maximize className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<MapContainer
|
||||||
|
center={DEFAULT_CENTER}
|
||||||
|
className="h-full w-full"
|
||||||
|
scrollWheelZoom
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
key={baseMap}
|
||||||
|
attribution={BASE_MAPS[baseMap].attribution}
|
||||||
|
subdomains={BASE_MAPS[baseMap].subdomains}
|
||||||
|
url={BASE_MAPS[baseMap].url}
|
||||||
|
/>
|
||||||
|
<FitBoundsToDevices coordinates={mapPoints} />
|
||||||
|
<FitBoundsControl coordinates={mapPoints} />
|
||||||
|
<MapStatusBadges
|
||||||
|
isLive={isLive}
|
||||||
|
status={status}
|
||||||
|
deviceCount={devicesWithCoords.length}
|
||||||
|
disconnectedDuration={disconnectedDuration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{devicesWithCoords.map((device) => {
|
||||||
|
const isOffline = checkIsOffline(device.last_event_time)
|
||||||
|
const isSelected = activeDeviceId === device.device_id
|
||||||
|
const projectedCoordinate = projectCoord(
|
||||||
|
device.lat as number,
|
||||||
|
device.lon as number,
|
||||||
|
isGcj02,
|
||||||
|
)
|
||||||
|
const [projLat, projLon] = projectedCoordinate
|
||||||
|
const color = deviceMarkerColor(device.fix_status, isOffline)
|
||||||
|
const icon = buildDeviceIcon(color, isSelected, isOffline)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
position={[projLat, projLon]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () =>
|
||||||
|
handleDeviceClick(device.device_id, projectedCoordinate),
|
||||||
|
}}
|
||||||
|
key={device.device_id}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="space-y-1 text-sm font-sans min-w-[200px]">
|
||||||
|
<div className="font-semibold text-base mb-2 border-b pb-1">
|
||||||
|
{device.device_id}
|
||||||
|
{isOffline && (
|
||||||
|
<span className="ml-2 text-xs text-rose-500 font-medium tracking-wide">
|
||||||
|
(Offline)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5">
|
||||||
|
<span className="text-muted-foreground">Lat:</span>
|
||||||
|
<span>{formatCoord(device.lat)}</span>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground">Lon:</span>
|
||||||
|
<span>{formatCoord(device.lon)}</span>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground">Alt:</span>
|
||||||
|
<span>
|
||||||
|
{device.alt_m !== null
|
||||||
|
? `${device.alt_m.toFixed(1)} m`
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground">Fix:</span>
|
||||||
|
<span>{getFixStatusLabel(device.fix_status)}</span>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground">Sats:</span>
|
||||||
|
<span>{device.satellites_used ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground pt-2 mt-2 border-t">
|
||||||
|
Last seen:{" "}
|
||||||
|
{device.last_event_time
|
||||||
|
? new Date(device.last_event_time).toLocaleString()
|
||||||
|
: "Unknown"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Device Panel Overlay */}
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-6 right-6 z-9999 w-80 max-w-[calc(100%-3rem)] transition-all duration-300 ${isPanelExpanded ? "opacity-80" : "opacity-100"}`}
|
||||||
|
>
|
||||||
|
<Card className="shadow-lg border bg-background/50 backdrop-blur-md overflow-hidden py-0 gap-0">
|
||||||
|
<CardHeader
|
||||||
|
className={`flex flex-row items-center justify-between space-y-0 cursor-pointer select-none transition-all duration-300 ${
|
||||||
|
isPanelExpanded ? "p-3 min-h-11" : "px-3 py-0 min-h-0"
|
||||||
|
}`}
|
||||||
|
onClick={() => setIsPanelExpanded(!isPanelExpanded)}
|
||||||
|
>
|
||||||
|
<CardTitle className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
Selected Device
|
||||||
|
{activeDevice?.signal_health &&
|
||||||
|
!isPanelExpanded &&
|
||||||
|
signalHealthBadge(activeDevice.signal_health)}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{activeDevice?.signal_health &&
|
||||||
|
isPanelExpanded &&
|
||||||
|
signalHealthBadge(activeDevice.signal_health)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
{isPanelExpanded ? (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{isPanelExpanded && (
|
||||||
|
<CardContent className="p-3 pt-0 grid grid-cols-2 gap-x-4 gap-y-3 text-[11px]">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground mb-0.5">Device ID</p>
|
||||||
|
<p className="font-medium break-all text-xs">
|
||||||
|
{activeDevice?.device_id ?? "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-0.5">Latitude</p>
|
||||||
|
<p className="font-medium font-mono">
|
||||||
|
{formatCoord(activeDevice?.lat ?? null)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-0.5">Longitude</p>
|
||||||
|
<p className="font-medium font-mono">
|
||||||
|
{formatCoord(activeDevice?.lon ?? null)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||||
|
<Mountain className="size-3" />
|
||||||
|
Altitude
|
||||||
|
</p>
|
||||||
|
<p className="font-medium font-mono">
|
||||||
|
{activeDevice?.alt_m !== null &&
|
||||||
|
activeDevice?.alt_m !== undefined
|
||||||
|
? `${activeDevice.alt_m.toFixed(1)} m`
|
||||||
|
: "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-0.5">Fix Status</p>
|
||||||
|
<p
|
||||||
|
className={`font-medium ${getFixStatusColor(activeDevice?.fix_status ?? null)}`}
|
||||||
|
>
|
||||||
|
{getFixStatusLabel(activeDevice?.fix_status ?? null)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||||
|
<Satellite className="size-3" />
|
||||||
|
Satellites
|
||||||
|
</p>
|
||||||
|
<p className="font-medium font-mono">
|
||||||
|
{activeDevice?.satellites_used ?? "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||||
|
<Signal className="size-3" />
|
||||||
|
SNR
|
||||||
|
</p>
|
||||||
|
<p className="font-medium font-mono">
|
||||||
|
{activeDevice?.signal_health
|
||||||
|
? `${activeDevice.signal_health.score}%`
|
||||||
|
: "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
Last Seen
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{timeAgo(activeDevice?.last_event_time ?? null)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
411
frontend/src/routes/_layout/mqtt-raw.tsx
Normal file
411
frontend/src/routes/_layout/mqtt-raw.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { type MqttRawDaySummary, MqttRawService } from "@/client"
|
||||||
|
import { DataTable } from "@/components/Common/DataTable"
|
||||||
|
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { LoadingButton } from "@/components/ui/loading-button"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
|
||||||
|
const MINUTE_STEPS_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/mqtt-raw")({
|
||||||
|
component: MqttRawPage,
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: "MQTT Raw Data - FastAPI Template",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toUtc8Date(value: Date): Date {
|
||||||
|
const utcMillis = value.getTime() + value.getTimezoneOffset() * 60 * 1000
|
||||||
|
return new Date(utcMillis + 8 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIsoDateUtc8(): string {
|
||||||
|
const now = new Date()
|
||||||
|
return toUtc8Date(now).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
const utc8 = toUtc8Date(new Date(value))
|
||||||
|
return `${utc8.toISOString().slice(0, 19).replace("T", " ")} (UTC+8)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinuteLabel(minute: number): string {
|
||||||
|
const clamped = Math.max(0, Math.min(minute, MINUTE_STEPS_PER_DAY))
|
||||||
|
const hours = Math.floor(clamped / 60)
|
||||||
|
const minutes = clamped % 60
|
||||||
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDownloadBlob(value: unknown): Blob {
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return new Blob([value], { type: "application/octet-stream" })
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return new Blob([value], { type: "application/octet-stream" })
|
||||||
|
}
|
||||||
|
return new Blob([JSON.stringify(value)], { type: "application/octet-stream" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function MqttRawPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
|
||||||
|
const [selectedDay, setSelectedDay] = useState(todayIsoDateUtc8())
|
||||||
|
const [rangeStartMinute, setRangeStartMinute] = useState(0)
|
||||||
|
const [rangeEndMinute, setRangeEndMinute] = useState(MINUTE_STEPS_PER_DAY)
|
||||||
|
const [deleteDayOpen, setDeleteDayOpen] = useState(false)
|
||||||
|
const [deleteAllOpen, setDeleteAllOpen] = useState(false)
|
||||||
|
const [deleteAllConfirmText, setDeleteAllConfirmText] = useState("")
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: daysResponse,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryFn: () => MqttRawService.listDays({ limit: 200, skip: 0 }),
|
||||||
|
queryKey: ["mqtt-raw-days"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const days = daysResponse?.data ?? []
|
||||||
|
const isFullDayRange =
|
||||||
|
rangeStartMinute === 0 && rangeEndMinute === MINUTE_STEPS_PER_DAY
|
||||||
|
const startSeconds = rangeStartMinute * 60
|
||||||
|
const endSeconds = rangeEndMinute * 60
|
||||||
|
|
||||||
|
const downloadMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const data = await MqttRawService.download({
|
||||||
|
day: selectedDay,
|
||||||
|
startSeconds: isFullDayRange ? undefined : startSeconds,
|
||||||
|
endSeconds: isFullDayRange ? undefined : endSeconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filename = isFullDayRange
|
||||||
|
? `mqtt_raw_${selectedDay}.txt`
|
||||||
|
: `mqtt_raw_${selectedDay}_${startSeconds}_${endSeconds}.txt`
|
||||||
|
return {
|
||||||
|
blob: toDownloadBlob(data),
|
||||||
|
filename,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: ({ blob, filename }) => {
|
||||||
|
triggerFileDownload(blob, filename)
|
||||||
|
showSuccessToast("Raw MQTT data downloaded")
|
||||||
|
},
|
||||||
|
onError: (downloadError) => {
|
||||||
|
const message =
|
||||||
|
downloadError instanceof Error
|
||||||
|
? downloadError.message
|
||||||
|
: "Download failed"
|
||||||
|
showErrorToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteDayMutation = useMutation({
|
||||||
|
mutationFn: () => MqttRawService.deleteDay({ day: selectedDay }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
showSuccessToast(result.message)
|
||||||
|
setDeleteDayOpen(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
|
||||||
|
},
|
||||||
|
onError: (deleteError) => {
|
||||||
|
const message =
|
||||||
|
deleteError instanceof Error ? deleteError.message : "Delete failed"
|
||||||
|
showErrorToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteAllMutation = useMutation({
|
||||||
|
mutationFn: () => MqttRawService.deleteAll({ confirm: true }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
showSuccessToast(result.message)
|
||||||
|
setDeleteAllConfirmText("")
|
||||||
|
setDeleteAllOpen(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
|
||||||
|
},
|
||||||
|
onError: (deleteError) => {
|
||||||
|
const message =
|
||||||
|
deleteError instanceof Error ? deleteError.message : "Delete failed"
|
||||||
|
showErrorToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<MqttRawDaySummary>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "day",
|
||||||
|
header: "Day (UTC+8)",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
className="font-mono px-0"
|
||||||
|
onClick={() => setSelectedDay(row.original.day)}
|
||||||
|
variant="link"
|
||||||
|
>
|
||||||
|
{row.original.day}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "message_count",
|
||||||
|
header: "Messages",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.message_count}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "first_received_at",
|
||||||
|
header: "First Message",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.first_received_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "last_received_at",
|
||||||
|
header: "Last Message",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.last_received_at),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">MQTT Raw Data</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Download saved payload bytes by UTC+8 day/time range and clean old
|
||||||
|
data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Download</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Pick a UTC+8 day, then download the full day or a custom time range.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Date (UTC+8)</p>
|
||||||
|
<Input
|
||||||
|
onChange={(event) => setSelectedDay(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={selectedDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setRangeStartMinute(0)
|
||||||
|
setRangeEndMinute(MINUTE_STEPS_PER_DAY)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Full Day
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
loading={downloadMutation.isPending}
|
||||||
|
onClick={() => downloadMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
Start: {formatMinuteLabel(rangeStartMinute)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
End: {formatMinuteLabel(rangeEndMinute)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
max={MINUTE_STEPS_PER_DAY}
|
||||||
|
min={0}
|
||||||
|
minStepsBetweenThumbs={1}
|
||||||
|
onValueChange={(values) => {
|
||||||
|
if (values.length !== 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRangeStartMinute(values[0])
|
||||||
|
setRangeEndMinute(values[1])
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
value={[rangeStartMinute, rangeEndMinute]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isFullDayRange
|
||||||
|
? "Current selection: full day (00:00-24:00, UTC+8)."
|
||||||
|
: `Current selection: ${formatMinuteLabel(rangeStartMinute)}-${formatMinuteLabel(rangeEndMinute)}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Delete</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Delete data for one UTC+8 day or delete all saved MQTT raw data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => setDeleteDayOpen(true)}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete {selectedDay}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDeleteAllOpen(true)}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete All Data
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Saved Days (UTC+8)</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Click a day to load it into the controls above.
|
||||||
|
</p>
|
||||||
|
{isPending ? (
|
||||||
|
<PendingSkeleton />
|
||||||
|
) : isError ? (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load data days."}
|
||||||
|
</div>
|
||||||
|
) : days.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-12 rounded-lg border">
|
||||||
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">No raw data saved yet</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
MQTT payloads will appear here after ingestion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={days} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog onOpenChange={setDeleteDayOpen} open={deleteDayOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Day Data</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Delete all MQTT raw data for {selectedDay} (UTC+8). This action
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button disabled={deleteDayMutation.isPending} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton
|
||||||
|
loading={deleteDayMutation.isPending}
|
||||||
|
onClick={() => deleteDayMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete Day
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog onOpenChange={setDeleteAllOpen} open={deleteAllOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete All MQTT Raw Data</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will delete all saved raw MQTT payloads. To confirm, type
|
||||||
|
<span className="font-mono font-semibold"> DELETE ALL</span>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
onChange={(event) => setDeleteAllConfirmText(event.target.value)}
|
||||||
|
placeholder="DELETE ALL"
|
||||||
|
value={deleteAllConfirmText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button disabled={deleteAllMutation.isPending} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton
|
||||||
|
disabled={deleteAllConfirmText !== "DELETE ALL"}
|
||||||
|
loading={deleteAllMutation.isPending}
|
||||||
|
onClick={() => deleteAllMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete Everything
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user