init
This commit is contained in:
256
transport/README.md
Normal file
256
transport/README.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# 🚗 Система мониторинга транспорта
|
||||||
|
|
||||||
|
Веб-система для мониторинга местоположения транспортных средств в реальном времени.
|
||||||
|
|
||||||
|
## 🛠 Технологии
|
||||||
|
|
||||||
|
- **Backend**: FastAPI, SQLAlchemy, PostgreSQL
|
||||||
|
- **Frontend**: Vue 3, Leaflet, Naive UI
|
||||||
|
- **Инфраструктура**: Docker, Nginx
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
|
||||||
|
### Запуск (пошагово)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Перейти в папку проекта
|
||||||
|
cd transport
|
||||||
|
|
||||||
|
# 2. Собрать и запустить все сервисы
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# 3. Проверить что все сервисы запущены
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Должно быть 4 сервиса в статусе "Up":
|
||||||
|
# - transport-nginx-1
|
||||||
|
# - transport-backend-1
|
||||||
|
# - transport-frontend-1
|
||||||
|
# - transport-postgres-1
|
||||||
|
|
||||||
|
# 4. Проверить что API работает
|
||||||
|
curl http://localhost/api/vehicles
|
||||||
|
|
||||||
|
# Должен вернуть JSON со списком транспорта
|
||||||
|
|
||||||
|
# 5. Открыть приложение в браузере
|
||||||
|
open http://localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск симулятора
|
||||||
|
|
||||||
|
В **отдельном терминале** запустить симулятор данных:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec backend python -m simulator.run
|
||||||
|
```
|
||||||
|
|
||||||
|
Симулятор будет:
|
||||||
|
- Генерировать координаты для 5 транспортных средств
|
||||||
|
- Отправлять данные каждые 2 секунды
|
||||||
|
- Имитировать движение, остановки, изменение скорости
|
||||||
|
|
||||||
|
Для остановки: `Ctrl+C`
|
||||||
|
|
||||||
|
### Доступ
|
||||||
|
|
||||||
|
| Сервис | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| Приложение | http://localhost |
|
||||||
|
| API | http://localhost/api/ |
|
||||||
|
| API Docs (Swagger) | http://localhost/api/docs |
|
||||||
|
| WebSocket | ws://localhost/ws/positions |
|
||||||
|
|
||||||
|
## 🔧 Полезные команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Посмотреть логи всех сервисов
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Посмотреть логи только backend
|
||||||
|
docker-compose logs -f backend
|
||||||
|
|
||||||
|
# Перезапустить сервис
|
||||||
|
docker-compose restart backend
|
||||||
|
|
||||||
|
# Остановить все сервисы
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Остановить и удалить данные (включая БД)
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
# Пересобрать конкретный сервис
|
||||||
|
docker-compose up --build backend -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 Работа с API
|
||||||
|
|
||||||
|
### Создать транспорт
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost/api/vehicles \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Автобус А-999", "type": "bus"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отправить позицию
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost/api/ingest/position \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"vehicle_id": 1,
|
||||||
|
"lat": 55.0304,
|
||||||
|
"lon": 82.9204,
|
||||||
|
"speed": 45.5,
|
||||||
|
"heading": 180
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить список транспорта
|
||||||
|
```bash
|
||||||
|
curl http://localhost/api/vehicles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить историю позиций
|
||||||
|
```bash
|
||||||
|
curl "http://localhost/api/vehicles/1/positions?from=2025-12-18T00:00:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить события
|
||||||
|
```bash
|
||||||
|
curl http://localhost/api/events
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
transport/
|
||||||
|
├── docker-compose.yml # Конфигурация Docker
|
||||||
|
├── nginx/ # Nginx конфигурация
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ └── conf.d/default.conf
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── app/ # FastAPI приложение
|
||||||
|
│ │ ├── main.py # Точка входа
|
||||||
|
│ │ ├── models/ # SQLAlchemy модели
|
||||||
|
│ │ ├── schemas/ # Pydantic схемы
|
||||||
|
│ │ ├── routers/ # API эндпоинты
|
||||||
|
│ │ └── services/ # Бизнес-логика
|
||||||
|
│ ├── alembic/ # Миграции БД
|
||||||
|
│ └── simulator/ # Симулятор данных
|
||||||
|
│ └── run.py
|
||||||
|
└── frontend/
|
||||||
|
├── Dockerfile
|
||||||
|
├── package.json
|
||||||
|
└── src/
|
||||||
|
├── main.js
|
||||||
|
├── App.vue # Главный компонент
|
||||||
|
└── components/ # Vue компоненты
|
||||||
|
├── MapView.vue
|
||||||
|
├── VehicleList.vue
|
||||||
|
├── VehicleCard.vue
|
||||||
|
├── TrackHistory.vue
|
||||||
|
└── EventFeed.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 REST API
|
||||||
|
|
||||||
|
| Метод | URL | Описание |
|
||||||
|
|-------|-----|----------|
|
||||||
|
| GET | /api/vehicles | Список транспорта с последними позициями |
|
||||||
|
| GET | /api/vehicles/{id} | Информация о конкретном ТС |
|
||||||
|
| POST | /api/vehicles | Создать новое ТС |
|
||||||
|
| PUT | /api/vehicles/{id} | Обновить ТС |
|
||||||
|
| DELETE | /api/vehicles/{id} | Удалить ТС |
|
||||||
|
| GET | /api/vehicles/{id}/positions | История позиций ТС |
|
||||||
|
| POST | /api/ingest/position | Принять новую позицию |
|
||||||
|
| GET | /api/events | Список событий |
|
||||||
|
|
||||||
|
### Типы транспорта
|
||||||
|
|
||||||
|
| type | Иконка |
|
||||||
|
|------|--------|
|
||||||
|
| `bus` | 🚌 |
|
||||||
|
| `truck` | 🚚 |
|
||||||
|
| `car` | 🚗 |
|
||||||
|
|
||||||
|
## 📨 WebSocket API
|
||||||
|
|
||||||
|
Подключение: `ws://localhost/ws/positions`
|
||||||
|
|
||||||
|
### Формат сообщений
|
||||||
|
|
||||||
|
**Обновление позиции:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "position_update",
|
||||||
|
"data": {
|
||||||
|
"vehicle_id": 1,
|
||||||
|
"lat": 55.0304,
|
||||||
|
"lon": 82.9204,
|
||||||
|
"speed": 45.5,
|
||||||
|
"heading": 180,
|
||||||
|
"timestamp": "2025-12-18T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Событие:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"vehicle_id": 1,
|
||||||
|
"type": "OVERSPEED",
|
||||||
|
"payload": {"speed": 95, "limit": 60},
|
||||||
|
"timestamp": "2025-12-18T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Типы событий
|
||||||
|
|
||||||
|
| Тип | Описание |
|
||||||
|
|-----|----------|
|
||||||
|
| `OVERSPEED` | Превышение скорости (> 60 км/ч) |
|
||||||
|
| `LONG_STOP` | Остановка более 5 минут |
|
||||||
|
| `CONNECTION_LOST` | Нет данных более 5 минут |
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### API возвращает 404
|
||||||
|
```bash
|
||||||
|
# Проверить что backend запущен
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Перезапустить nginx
|
||||||
|
docker-compose restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Нет данных на карте
|
||||||
|
```bash
|
||||||
|
# Проверить что симулятор запущен
|
||||||
|
docker-compose exec backend python -m simulator.run
|
||||||
|
|
||||||
|
# Проверить логи backend
|
||||||
|
docker-compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибка подключения к БД
|
||||||
|
```bash
|
||||||
|
# Проверить что postgres запущен
|
||||||
|
docker-compose ps postgres
|
||||||
|
|
||||||
|
# Посмотреть логи postgres
|
||||||
|
docker-compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
18
transport/backend/Dockerfile
Normal file
18
transport/backend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Run migrations and start server
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]
|
||||||
42
transport/backend/alembic.ini
Normal file
42
transport/backend/alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = postgresql://postgres:postgres@postgres:5432/transport
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
61
transport/backend/alembic/env.py
Normal file
61
transport/backend/alembic/env.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import models to ensure they are registered with Base
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import Vehicle, Position, Event
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Override sqlalchemy.url from environment if available
|
||||||
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
if database_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
transport/backend/alembic/script.py.mako
Normal file
26
transport/backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
75
transport/backend/alembic/versions/001_initial.py
Normal file
75
transport/backend/alembic/versions/001_initial.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '001'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create vehicles table
|
||||||
|
op.create_table(
|
||||||
|
'vehicles',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(100), nullable=False),
|
||||||
|
sa.Column('type', sa.String(50), nullable=True, default='car'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create positions table
|
||||||
|
op.create_table(
|
||||||
|
'positions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vehicle_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('lat', sa.Float(), nullable=False),
|
||||||
|
sa.Column('lon', sa.Float(), nullable=False),
|
||||||
|
sa.Column('speed', sa.Float(), nullable=True, default=0.0),
|
||||||
|
sa.Column('heading', sa.Float(), nullable=True, default=0.0),
|
||||||
|
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_positions_vehicle_ts', 'positions', ['vehicle_id', 'timestamp'])
|
||||||
|
|
||||||
|
# Create events table
|
||||||
|
op.create_table(
|
||||||
|
'events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vehicle_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('payload', postgresql.JSONB(), nullable=True, default={}),
|
||||||
|
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_events_vehicle_ts', 'events', ['vehicle_id', 'timestamp'])
|
||||||
|
op.create_index('idx_events_type', 'events', ['type'])
|
||||||
|
|
||||||
|
# Insert demo vehicles
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO vehicles (name, type, created_at) VALUES
|
||||||
|
('Автобус А-101', 'bus', NOW()),
|
||||||
|
('Автобус А-102', 'bus', NOW()),
|
||||||
|
('Грузовик Г-201', 'truck', NOW()),
|
||||||
|
('Легковой Л-301', 'car', NOW()),
|
||||||
|
('Легковой Л-302', 'car', NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('events')
|
||||||
|
op.drop_table('positions')
|
||||||
|
op.drop_table('vehicles')
|
||||||
0
transport/backend/app/__init__.py
Normal file
0
transport/backend/app/__init__.py
Normal file
16
transport/backend/app/config.py
Normal file
16
transport/backend/app/config.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "postgresql://postgres:postgres@localhost:5432/transport"
|
||||||
|
|
||||||
|
# Event detection settings
|
||||||
|
long_stop_minutes: int = 5
|
||||||
|
overspeed_limit: float = 60.0 # km/h
|
||||||
|
connection_lost_minutes: int = 5
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
23
transport/backend/app/database.py
Normal file
23
transport/backend/app/database.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# Convert postgresql:// to postgresql+asyncpg://
|
||||||
|
database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://")
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, echo=False)
|
||||||
|
|
||||||
|
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
50
transport/backend/app/main.py
Normal file
50
transport/backend/app/main.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.routers import vehicles, positions, events
|
||||||
|
from app.websocket import router as ws_router
|
||||||
|
from app.services.connection_checker import connection_checker_loop
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup: запуск фоновых задач
|
||||||
|
task = asyncio.create_task(connection_checker_loop())
|
||||||
|
yield
|
||||||
|
# Shutdown: остановка фоновых задач
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Transport Monitoring API",
|
||||||
|
description="API для системы мониторинга транспорта",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
|
||||||
|
app.include_router(positions.router, tags=["positions"])
|
||||||
|
app.include_router(events.router, prefix="/events", tags=["events"])
|
||||||
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
5
transport/backend/app/models/__init__.py
Normal file
5
transport/backend/app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.models.position import Position
|
||||||
|
from app.models.event import Event
|
||||||
|
|
||||||
|
__all__ = ["Vehicle", "Position", "Event"]
|
||||||
24
transport/backend/app/models/event.py
Normal file
24
transport/backend/app/models/event.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, String, ForeignKey, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
vehicle_id: Mapped[int] = mapped_column(ForeignKey("vehicles.id", ondelete="CASCADE"))
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
type: Mapped[str] = mapped_column(String(50), nullable=False) # LONG_STOP, OVERSPEED, CONNECTION_LOST
|
||||||
|
payload: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship(back_populates="events")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_events_vehicle_ts", "vehicle_id", "timestamp"),
|
||||||
|
Index("idx_events_type", "type"),
|
||||||
|
)
|
||||||
24
transport/backend/app/models/position.py
Normal file
24
transport/backend/app/models/position.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, Float, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Position(Base):
|
||||||
|
__tablename__ = "positions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
vehicle_id: Mapped[int] = mapped_column(ForeignKey("vehicles.id", ondelete="CASCADE"))
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
lon: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
speed: Mapped[float] = mapped_column(Float, default=0.0) # km/h
|
||||||
|
heading: Mapped[float] = mapped_column(Float, default=0.0) # 0-360 degrees
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship(back_populates="positions")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_positions_vehicle_ts", "vehicle_id", "timestamp"),
|
||||||
|
)
|
||||||
18
transport/backend/app/models/vehicle.py
Normal file
18
transport/backend/app/models/vehicle.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Vehicle(Base):
|
||||||
|
__tablename__ = "vehicles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
type: Mapped[str] = mapped_column(String(50), default="car") # car, bus, truck
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
positions: Mapped[list["Position"]] = relationship(back_populates="vehicle", cascade="all, delete-orphan")
|
||||||
|
events: Mapped[list["Event"]] = relationship(back_populates="vehicle", cascade="all, delete-orphan")
|
||||||
0
transport/backend/app/routers/__init__.py
Normal file
0
transport/backend/app/routers/__init__.py
Normal file
61
transport/backend/app/routers/events.py
Normal file
61
transport/backend/app/routers/events.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Event
|
||||||
|
from app.schemas import EventResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[EventResponse])
|
||||||
|
async def get_events(
|
||||||
|
type: Optional[str] = None,
|
||||||
|
vehicle_id: Optional[int] = None,
|
||||||
|
from_time: Optional[datetime] = Query(None, alias="from"),
|
||||||
|
to_time: Optional[datetime] = Query(None, alias="to"),
|
||||||
|
limit: int = Query(100, le=1000),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Получить список событий"""
|
||||||
|
query = select(Event)
|
||||||
|
|
||||||
|
if type:
|
||||||
|
query = query.where(Event.type == type)
|
||||||
|
if vehicle_id:
|
||||||
|
query = query.where(Event.vehicle_id == vehicle_id)
|
||||||
|
if from_time:
|
||||||
|
query = query.where(Event.timestamp >= from_time)
|
||||||
|
if to_time:
|
||||||
|
query = query.where(Event.timestamp <= to_time)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Event.timestamp)).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
events = result.scalars().all()
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vehicles/{vehicle_id}/events", response_model=list[EventResponse])
|
||||||
|
async def get_vehicle_events(
|
||||||
|
vehicle_id: int,
|
||||||
|
type: Optional[str] = None,
|
||||||
|
limit: int = Query(100, le=1000),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Получить события конкретного транспортного средства"""
|
||||||
|
query = select(Event).where(Event.vehicle_id == vehicle_id)
|
||||||
|
|
||||||
|
if type:
|
||||||
|
query = query.where(Event.type == type)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Event.timestamp)).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
events = result.scalars().all()
|
||||||
|
|
||||||
|
return events
|
||||||
103
transport/backend/app/routers/positions.py
Normal file
103
transport/backend/app/routers/positions.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, desc, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Vehicle, Position
|
||||||
|
from app.schemas import PositionResponse, PositionIngest
|
||||||
|
from app.services.websocket_manager import manager
|
||||||
|
from app.services.event_detector import detect_events
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vehicles/{vehicle_id}/positions", response_model=list[PositionResponse])
|
||||||
|
async def get_vehicle_positions(
|
||||||
|
vehicle_id: int,
|
||||||
|
from_time: Optional[datetime] = Query(None, alias="from"),
|
||||||
|
to_time: Optional[datetime] = Query(None, alias="to"),
|
||||||
|
limit: int = Query(1000, le=10000),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Получить историю позиций транспортного средства"""
|
||||||
|
# Check vehicle exists
|
||||||
|
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = select(Position).where(Position.vehicle_id == vehicle_id)
|
||||||
|
|
||||||
|
if from_time:
|
||||||
|
query = query.where(Position.timestamp >= from_time)
|
||||||
|
if to_time:
|
||||||
|
query = query.where(Position.timestamp <= to_time)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Position.timestamp)).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
positions = result.scalars().all()
|
||||||
|
|
||||||
|
return positions
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vehicles/{vehicle_id}/last-position", response_model=Optional[PositionResponse])
|
||||||
|
async def get_last_position(vehicle_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить последнюю позицию транспортного средства"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.vehicle_id == vehicle_id)
|
||||||
|
.order_by(desc(Position.timestamp))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
position = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not position:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingest/position", response_model=PositionResponse, status_code=201)
|
||||||
|
async def ingest_position(position: PositionIngest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Принять новую позицию от трекера/симулятора"""
|
||||||
|
# Check vehicle exists
|
||||||
|
result = await db.execute(select(Vehicle).where(Vehicle.id == position.vehicle_id))
|
||||||
|
vehicle = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not vehicle:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
|
||||||
|
# Get previous position for event detection
|
||||||
|
prev_result = await db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.vehicle_id == position.vehicle_id)
|
||||||
|
.order_by(desc(Position.timestamp))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
prev_position = prev_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Create new position
|
||||||
|
db_position = Position(
|
||||||
|
vehicle_id=position.vehicle_id,
|
||||||
|
timestamp=position.timestamp or datetime.utcnow(),
|
||||||
|
lat=position.lat,
|
||||||
|
lon=position.lon,
|
||||||
|
speed=position.speed,
|
||||||
|
heading=position.heading
|
||||||
|
)
|
||||||
|
db.add(db_position)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_position)
|
||||||
|
|
||||||
|
# Detect events
|
||||||
|
events = await detect_events(db, vehicle, db_position, prev_position)
|
||||||
|
|
||||||
|
# Broadcast to WebSocket clients
|
||||||
|
await manager.broadcast_position(db_position)
|
||||||
|
for event in events:
|
||||||
|
await manager.broadcast_event(event)
|
||||||
|
|
||||||
|
return db_position
|
||||||
139
transport/backend/app/routers/vehicles.py
Normal file
139
transport/backend/app/routers/vehicles.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Vehicle, Position
|
||||||
|
from app.schemas import VehicleCreate, VehicleUpdate, VehicleResponse, VehicleWithPosition
|
||||||
|
from app.schemas.vehicle import LastPosition
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_vehicle_status(last_position: Optional[Position], now: datetime) -> str:
|
||||||
|
if not last_position:
|
||||||
|
return "offline"
|
||||||
|
|
||||||
|
time_diff = now - last_position.timestamp
|
||||||
|
if time_diff > timedelta(minutes=5):
|
||||||
|
return "offline"
|
||||||
|
elif last_position.speed < 2:
|
||||||
|
return "stopped"
|
||||||
|
else:
|
||||||
|
return "moving"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[VehicleWithPosition])
|
||||||
|
async def get_vehicles(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить список всех транспортных средств с последними позициями"""
|
||||||
|
result = await db.execute(select(Vehicle))
|
||||||
|
vehicles = result.scalars().all()
|
||||||
|
|
||||||
|
response = []
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
for vehicle in vehicles:
|
||||||
|
# Get last position
|
||||||
|
pos_result = await db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.vehicle_id == vehicle.id)
|
||||||
|
.order_by(desc(Position.timestamp))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_pos = pos_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
vehicle_data = VehicleWithPosition(
|
||||||
|
id=vehicle.id,
|
||||||
|
name=vehicle.name,
|
||||||
|
type=vehicle.type,
|
||||||
|
created_at=vehicle.created_at,
|
||||||
|
last_position=LastPosition(
|
||||||
|
lat=last_pos.lat,
|
||||||
|
lon=last_pos.lon,
|
||||||
|
speed=last_pos.speed,
|
||||||
|
heading=last_pos.heading,
|
||||||
|
timestamp=last_pos.timestamp
|
||||||
|
) if last_pos else None,
|
||||||
|
status=get_vehicle_status(last_pos, now)
|
||||||
|
)
|
||||||
|
response.append(vehicle_data)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}", response_model=VehicleWithPosition)
|
||||||
|
async def get_vehicle(vehicle_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Получить информацию о транспортном средстве"""
|
||||||
|
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
|
||||||
|
vehicle = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not vehicle:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
|
||||||
|
# Get last position
|
||||||
|
pos_result = await db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.vehicle_id == vehicle.id)
|
||||||
|
.order_by(desc(Position.timestamp))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_pos = pos_result.scalar_one_or_none()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
return VehicleWithPosition(
|
||||||
|
id=vehicle.id,
|
||||||
|
name=vehicle.name,
|
||||||
|
type=vehicle.type,
|
||||||
|
created_at=vehicle.created_at,
|
||||||
|
last_position=LastPosition(
|
||||||
|
lat=last_pos.lat,
|
||||||
|
lon=last_pos.lon,
|
||||||
|
speed=last_pos.speed,
|
||||||
|
heading=last_pos.heading,
|
||||||
|
timestamp=last_pos.timestamp
|
||||||
|
) if last_pos else None,
|
||||||
|
status=get_vehicle_status(last_pos, now)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=VehicleResponse, status_code=201)
|
||||||
|
async def create_vehicle(vehicle: VehicleCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Создать новое транспортное средство"""
|
||||||
|
db_vehicle = Vehicle(**vehicle.model_dump())
|
||||||
|
db.add(db_vehicle)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_vehicle)
|
||||||
|
return db_vehicle
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{vehicle_id}", response_model=VehicleResponse)
|
||||||
|
async def update_vehicle(vehicle_id: int, vehicle: VehicleUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Обновить транспортное средство"""
|
||||||
|
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
|
||||||
|
db_vehicle = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_vehicle:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
|
||||||
|
update_data = vehicle.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_vehicle, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_vehicle)
|
||||||
|
return db_vehicle
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{vehicle_id}", status_code=204)
|
||||||
|
async def delete_vehicle(vehicle_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Удалить транспортное средство"""
|
||||||
|
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
|
||||||
|
db_vehicle = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_vehicle:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
|
||||||
|
await db.delete(db_vehicle)
|
||||||
|
await db.commit()
|
||||||
9
transport/backend/app/schemas/__init__.py
Normal file
9
transport/backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from app.schemas.vehicle import VehicleCreate, VehicleUpdate, VehicleResponse, VehicleWithPosition
|
||||||
|
from app.schemas.position import PositionCreate, PositionResponse, PositionIngest
|
||||||
|
from app.schemas.event import EventResponse
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"VehicleCreate", "VehicleUpdate", "VehicleResponse", "VehicleWithPosition",
|
||||||
|
"PositionCreate", "PositionResponse", "PositionIngest",
|
||||||
|
"EventResponse"
|
||||||
|
]
|
||||||
14
transport/backend/app/schemas/event.py
Normal file
14
transport/backend/app/schemas/event.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class EventResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
vehicle_id: int
|
||||||
|
timestamp: datetime
|
||||||
|
type: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
29
transport/backend/app/schemas/position.py
Normal file
29
transport/backend/app/schemas/position.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PositionBase(BaseModel):
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
speed: float = 0.0
|
||||||
|
heading: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class PositionCreate(PositionBase):
|
||||||
|
vehicle_id: int
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PositionIngest(PositionBase):
|
||||||
|
vehicle_id: int
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PositionResponse(PositionBase):
|
||||||
|
id: int
|
||||||
|
vehicle_id: int
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
38
transport/backend/app/schemas/vehicle.py
Normal file
38
transport/backend/app/schemas/vehicle.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str = "car"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCreate(VehicleBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleResponse(VehicleBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LastPosition(BaseModel):
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
speed: float
|
||||||
|
heading: float
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleWithPosition(VehicleResponse):
|
||||||
|
last_position: Optional[LastPosition] = None
|
||||||
|
status: str = "unknown" # moving, stopped, offline
|
||||||
0
transport/backend/app/services/__init__.py
Normal file
0
transport/backend/app/services/__init__.py
Normal file
83
transport/backend/app/services/connection_checker.py
Normal file
83
transport/backend/app/services/connection_checker.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Фоновая задача для проверки потери связи с объектами.
|
||||||
|
Создаёт события CONNECTION_LOST если нет данных более N минут.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import async_session_maker
|
||||||
|
from app.models import Vehicle, Position, Event
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.websocket_manager import manager
|
||||||
|
|
||||||
|
|
||||||
|
async def check_connections():
|
||||||
|
"""Проверить все объекты на потерю связи"""
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
# Получить все объекты
|
||||||
|
result = await db.execute(select(Vehicle))
|
||||||
|
vehicles = result.scalars().all()
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
threshold = now - timedelta(minutes=settings.connection_lost_minutes)
|
||||||
|
|
||||||
|
for vehicle in vehicles:
|
||||||
|
# Получить последнюю позицию
|
||||||
|
pos_result = await db.execute(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.vehicle_id == vehicle.id)
|
||||||
|
.order_by(desc(Position.timestamp))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_pos = pos_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not last_pos:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверить, прошло ли достаточно времени
|
||||||
|
if last_pos.timestamp < threshold:
|
||||||
|
# Проверить, не было ли уже события CONNECTION_LOST за последние N минут
|
||||||
|
event_result = await db.execute(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.vehicle_id == vehicle.id)
|
||||||
|
.where(Event.type == "CONNECTION_LOST")
|
||||||
|
.where(Event.timestamp > threshold)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
existing_event = event_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not existing_event:
|
||||||
|
# Создать событие
|
||||||
|
minutes_ago = (now - last_pos.timestamp).total_seconds() / 60
|
||||||
|
event = Event(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
timestamp=now,
|
||||||
|
type="CONNECTION_LOST",
|
||||||
|
payload={
|
||||||
|
"last_seen": last_pos.timestamp.isoformat(),
|
||||||
|
"minutes_ago": round(minutes_ago, 1),
|
||||||
|
"lat": last_pos.lat,
|
||||||
|
"lon": last_pos.lon
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
|
||||||
|
# Отправить через WebSocket
|
||||||
|
await manager.broadcast_event(event)
|
||||||
|
print(f"⚠️ CONNECTION_LOST: {vehicle.name} (нет данных {minutes_ago:.0f} мин)")
|
||||||
|
|
||||||
|
|
||||||
|
async def connection_checker_loop():
|
||||||
|
"""Бесконечный цикл проверки соединений"""
|
||||||
|
print("🔍 Connection checker started")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await check_connections()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Connection checker error: {e}")
|
||||||
|
await asyncio.sleep(60) # Проверять каждую минуту
|
||||||
55
transport/backend/app/services/event_detector.py
Normal file
55
transport/backend/app/services/event_detector.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Vehicle, Position, Event
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def detect_events(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle: Vehicle,
|
||||||
|
current: Position,
|
||||||
|
previous: Optional[Position]
|
||||||
|
) -> list[Event]:
|
||||||
|
"""Обнаружение событий на основе новой позиции"""
|
||||||
|
events = []
|
||||||
|
|
||||||
|
# Check for overspeed
|
||||||
|
if current.speed > settings.overspeed_limit:
|
||||||
|
event = Event(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
timestamp=current.timestamp,
|
||||||
|
type="OVERSPEED",
|
||||||
|
payload={
|
||||||
|
"speed": current.speed,
|
||||||
|
"limit": settings.overspeed_limit,
|
||||||
|
"lat": current.lat,
|
||||||
|
"lon": current.lon
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Check for long stop (if speed is 0 and was 0 for a while)
|
||||||
|
if previous and current.speed < 2 and previous.speed < 2:
|
||||||
|
time_diff = current.timestamp - previous.timestamp
|
||||||
|
if time_diff >= timedelta(minutes=settings.long_stop_minutes):
|
||||||
|
event = Event(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
timestamp=current.timestamp,
|
||||||
|
type="LONG_STOP",
|
||||||
|
payload={
|
||||||
|
"duration_minutes": time_diff.total_seconds() / 60,
|
||||||
|
"lat": current.lat,
|
||||||
|
"lon": current.lon
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
return events
|
||||||
63
transport/backend/app/services/websocket_manager.py
Normal file
63
transport/backend/app/services/websocket_manager.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Set
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from app.models import Position, Event
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: Set[WebSocket] = set()
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections.add(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket):
|
||||||
|
self.active_connections.discard(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict):
|
||||||
|
"""Отправить сообщение всем подключенным клиентам"""
|
||||||
|
disconnected = set()
|
||||||
|
for connection in self.active_connections:
|
||||||
|
try:
|
||||||
|
await connection.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(connection)
|
||||||
|
|
||||||
|
# Remove disconnected clients
|
||||||
|
self.active_connections -= disconnected
|
||||||
|
|
||||||
|
async def broadcast_position(self, position: Position):
|
||||||
|
"""Отправить обновление позиции"""
|
||||||
|
message = {
|
||||||
|
"type": "position_update",
|
||||||
|
"data": {
|
||||||
|
"vehicle_id": position.vehicle_id,
|
||||||
|
"lat": position.lat,
|
||||||
|
"lon": position.lon,
|
||||||
|
"speed": position.speed,
|
||||||
|
"heading": position.heading,
|
||||||
|
"timestamp": position.timestamp.isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.broadcast(message)
|
||||||
|
|
||||||
|
async def broadcast_event(self, event: Event):
|
||||||
|
"""Отправить событие"""
|
||||||
|
message = {
|
||||||
|
"type": "event",
|
||||||
|
"data": {
|
||||||
|
"id": event.id,
|
||||||
|
"vehicle_id": event.vehicle_id,
|
||||||
|
"type": event.type,
|
||||||
|
"payload": event.payload,
|
||||||
|
"timestamp": event.timestamp.isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.broadcast(message)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
manager = ConnectionManager()
|
||||||
18
transport/backend/app/websocket.py
Normal file
18
transport/backend/app/websocket.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.services.websocket_manager import manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/positions")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
"""WebSocket эндпоинт для получения обновлений позиций в реальном времени"""
|
||||||
|
await manager.connect(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Keep connection alive, wait for messages (ping/pong)
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
# Can handle client messages here if needed
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
11
transport/backend/requirements.txt
Normal file
11
transport/backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
alembic==1.12.1
|
||||||
|
asyncpg==0.29.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.5.2
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
websockets==12.0
|
||||||
|
httpx==0.25.2
|
||||||
0
transport/backend/simulator/__init__.py
Normal file
0
transport/backend/simulator/__init__.py
Normal file
121
transport/backend/simulator/run.py
Normal file
121
transport/backend/simulator/run.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Симулятор движения транспортных средств.
|
||||||
|
Генерирует реалистичные данные о перемещении объектов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import math
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Backend API URL (внутри Docker сети, без /api — root_path только для nginx)
|
||||||
|
API_URL = "http://backend:8000"
|
||||||
|
|
||||||
|
# Начальные координаты (Новосибирск - центр)
|
||||||
|
START_COORDS = [
|
||||||
|
(55.0304, 82.9204), # Центр
|
||||||
|
(55.0411, 82.9344), # Север
|
||||||
|
(55.0198, 82.9064), # Юг
|
||||||
|
(55.0350, 82.8904), # Запад
|
||||||
|
(55.0250, 82.9504), # Восток
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleSimulator:
|
||||||
|
def __init__(self, vehicle_id: int, start_lat: float, start_lon: float):
|
||||||
|
self.vehicle_id = vehicle_id
|
||||||
|
self.lat = start_lat
|
||||||
|
self.lon = start_lon
|
||||||
|
self.speed = random.uniform(20, 60) # km/h
|
||||||
|
self.heading = random.uniform(0, 360) # degrees
|
||||||
|
self.is_stopped = False
|
||||||
|
self.stop_duration = 0
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""Обновить позицию транспортного средства"""
|
||||||
|
# Случайная остановка
|
||||||
|
if not self.is_stopped and random.random() < 0.02: # 2% шанс остановиться
|
||||||
|
self.is_stopped = True
|
||||||
|
self.stop_duration = random.randint(5, 30) # секунд
|
||||||
|
self.speed = 0
|
||||||
|
|
||||||
|
if self.is_stopped:
|
||||||
|
self.stop_duration -= 1
|
||||||
|
if self.stop_duration <= 0:
|
||||||
|
self.is_stopped = False
|
||||||
|
self.speed = random.uniform(20, 60)
|
||||||
|
|
||||||
|
if not self.is_stopped:
|
||||||
|
# Случайное изменение направления
|
||||||
|
self.heading += random.uniform(-15, 15)
|
||||||
|
self.heading = self.heading % 360
|
||||||
|
|
||||||
|
# Случайное изменение скорости
|
||||||
|
self.speed += random.uniform(-5, 5)
|
||||||
|
self.speed = max(10, min(90, self.speed)) # Ограничение 10-90 км/ч
|
||||||
|
|
||||||
|
# Расчёт нового положения
|
||||||
|
# Примерно: 1 градус широты = 111 км, 1 градус долготы = 111 * cos(lat) км
|
||||||
|
speed_ms = self.speed / 3.6 # м/с
|
||||||
|
distance = speed_ms * 2 # за 2 секунды
|
||||||
|
|
||||||
|
# Перевод в градусы
|
||||||
|
delta_lat = (distance * math.cos(math.radians(self.heading))) / 111000
|
||||||
|
delta_lon = (distance * math.sin(math.radians(self.heading))) / (111000 * math.cos(math.radians(self.lat)))
|
||||||
|
|
||||||
|
self.lat += delta_lat
|
||||||
|
self.lon += delta_lon
|
||||||
|
|
||||||
|
return {
|
||||||
|
"vehicle_id": self.vehicle_id,
|
||||||
|
"lat": round(self.lat, 6),
|
||||||
|
"lon": round(self.lon, 6),
|
||||||
|
"speed": round(self.speed, 1),
|
||||||
|
"heading": round(self.heading, 1),
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_position(client: httpx.AsyncClient, position: dict):
|
||||||
|
"""Отправить позицию на сервер"""
|
||||||
|
try:
|
||||||
|
response = await client.post(f"{API_URL}/ingest/position", json=position)
|
||||||
|
if response.status_code == 201:
|
||||||
|
print(f"✓ Vehicle {position['vehicle_id']}: ({position['lat']}, {position['lon']}) @ {position['speed']} km/h")
|
||||||
|
else:
|
||||||
|
print(f"✗ Vehicle {position['vehicle_id']}: Error {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Vehicle {position['vehicle_id']}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("🚗 Запуск симулятора транспорта...")
|
||||||
|
print(f"📡 API URL: {API_URL}")
|
||||||
|
|
||||||
|
# Создаём симуляторы для каждого транспортного средства
|
||||||
|
simulators = []
|
||||||
|
for i, (lat, lon) in enumerate(START_COORDS, start=1):
|
||||||
|
sim = VehicleSimulator(vehicle_id=i, start_lat=lat, start_lon=lon)
|
||||||
|
simulators.append(sim)
|
||||||
|
print(f" → Vehicle {i}: начальная позиция ({lat}, {lon})")
|
||||||
|
|
||||||
|
print("\n🔄 Начинаем отправку данных (Ctrl+C для остановки)...\n")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
while True:
|
||||||
|
# Обновляем и отправляем позиции всех транспортных средств
|
||||||
|
tasks = []
|
||||||
|
for sim in simulators:
|
||||||
|
position = sim.update()
|
||||||
|
tasks.append(send_position(client, position))
|
||||||
|
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
await asyncio.sleep(2) # Интервал 2 секунды
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⏹ Симулятор остановлен")
|
||||||
57
transport/docker-compose.yml
Normal file
57
transport/docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app # Для разработки — изменения без пересборки
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/transport
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=/api
|
||||||
|
- VITE_WS_URL=/ws
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=transport
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
477
transport/docs/TECHNICAL_SPECIFICATION.md
Normal file
477
transport/docs/TECHNICAL_SPECIFICATION.md
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
# Техническое задание
|
||||||
|
## Система мониторинга транспорта в реальном времени
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Общие сведения
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Название проекта | Transport Monitoring System |
|
||||||
|
| Тип | Учебный проект |
|
||||||
|
| Версия ТЗ | 1.0 |
|
||||||
|
| Дата | 2025-12-18 |
|
||||||
|
| Срок разработки | 2 недели |
|
||||||
|
| Frontend фреймворк | Vue 3 (выбран) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Цели и задачи проекта
|
||||||
|
|
||||||
|
### 2.1 Цель
|
||||||
|
Разработка веб-системы для мониторинга местоположения транспортных средств в реальном времени с визуализацией на карте и хранением истории перемещений.
|
||||||
|
|
||||||
|
### 2.2 Задачи
|
||||||
|
- Отображение текущего положения транспорта на интерактивной карте
|
||||||
|
- Обновление позиций в реальном времени (WebSocket)
|
||||||
|
- Хранение и просмотр истории перемещений
|
||||||
|
- Генерация событий (остановки, превышение скорости)
|
||||||
|
- Симуляция данных для демонстрации работы системы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Технологический стек
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
| Компонент | Технология | Версия |
|
||||||
|
|-----------|------------|--------|
|
||||||
|
| Язык | Python | 3.11+ |
|
||||||
|
| Фреймворк | FastAPI | 0.104+ |
|
||||||
|
| ORM | SQLAlchemy | 2.0+ |
|
||||||
|
| Миграции | Alembic | 1.12+ |
|
||||||
|
| База данных | PostgreSQL | 15+ |
|
||||||
|
| WebSocket | FastAPI WebSockets | встроено |
|
||||||
|
|
||||||
|
### 3.2 Frontend
|
||||||
|
| Компонент | Технология | Версия |
|
||||||
|
|-----------|------------|--------|
|
||||||
|
| Фреймворк | Vue 3 | 3.4+ |
|
||||||
|
| Сборщик | Vite | 5.0+ |
|
||||||
|
| Карты | Leaflet | 1.9+ |
|
||||||
|
| UI-компоненты | Naive UI | 2.35+ |
|
||||||
|
| HTTP-клиент | Axios | 1.6+ |
|
||||||
|
|
||||||
|
### 3.3 Инфраструктура
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|------------|
|
||||||
|
| Контейнеризация | Docker + Docker Compose |
|
||||||
|
| Reverse Proxy | Nginx 1.25+ |
|
||||||
|
| Тайлы карты | OpenStreetMap |
|
||||||
|
|
||||||
|
### 3.4 Nginx (Reverse Proxy)
|
||||||
|
Nginx выступает единой точкой входа:
|
||||||
|
- `/` → статика Vue (production) или проксирование на Vite (development)
|
||||||
|
- `/api/*` → проксирование на FastAPI backend
|
||||||
|
- `/ws/*` → проксирование WebSocket на backend
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nginx (:80) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ / → Frontend (статика или Vite dev:5173) │
|
||||||
|
│ /api/* → Backend (FastAPI :8000) │
|
||||||
|
│ /ws/* → Backend WebSocket (:8000) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Функциональные требования
|
||||||
|
|
||||||
|
### 4.1 Модуль "Диспетчер" (главный экран)
|
||||||
|
|
||||||
|
#### FR-1.1 Карта
|
||||||
|
- [ ] Отображение интерактивной карты на базе Leaflet + OSM
|
||||||
|
- [ ] Маркеры транспортных средств с иконками по типу (автобус, грузовик, легковой)
|
||||||
|
- [ ] Всплывающая подсказка при наведении на маркер (название, скорость)
|
||||||
|
- [ ] Центрирование карты по выбранному объекту
|
||||||
|
|
||||||
|
#### FR-1.2 Список объектов
|
||||||
|
- [ ] Боковая панель со списком всех транспортных средств
|
||||||
|
- [ ] Поиск/фильтрация по названию
|
||||||
|
- [ ] Индикатор статуса: движется (зелёный), стоит (жёлтый), нет связи (серый)
|
||||||
|
- [ ] Клик по объекту — выделение на карте + открытие карточки
|
||||||
|
|
||||||
|
#### FR-1.3 Карточка объекта
|
||||||
|
- [ ] Название и тип ТС
|
||||||
|
- [ ] Текущие координаты (lat, lon)
|
||||||
|
- [ ] Скорость (км/ч)
|
||||||
|
- [ ] Направление движения (heading)
|
||||||
|
- [ ] Время последней точки
|
||||||
|
- [ ] Статус (движется/стоит)
|
||||||
|
|
||||||
|
### 4.2 Модуль "История движения"
|
||||||
|
|
||||||
|
#### FR-2.1 Запрос истории
|
||||||
|
- [ ] Выбор временного диапазона: 30 мин / 1 час / 24 часа / произвольный
|
||||||
|
- [ ] Кнопка "Показать трек"
|
||||||
|
|
||||||
|
#### FR-2.2 Отображение трека
|
||||||
|
- [ ] Полилиния маршрута на карте
|
||||||
|
- [ ] Цветовая индикация скорости (опционально)
|
||||||
|
- [ ] Маркеры начала и конца маршрута
|
||||||
|
|
||||||
|
#### FR-2.3 Таблица точек
|
||||||
|
- [ ] Список точек: время, координаты, скорость
|
||||||
|
- [ ] Клик по строке — центрирование карты на точке
|
||||||
|
- [ ] Экспорт в CSV (опционально)
|
||||||
|
|
||||||
|
### 4.3 Модуль "Реалтайм обновления"
|
||||||
|
|
||||||
|
#### FR-3.1 WebSocket соединение
|
||||||
|
- [ ] Автоматическое подключение при загрузке страницы
|
||||||
|
- [ ] Переподключение при обрыве связи
|
||||||
|
- [ ] Индикатор статуса соединения в UI
|
||||||
|
|
||||||
|
#### FR-3.2 Обновление данных
|
||||||
|
- [ ] Плавное перемещение маркеров при получении новых координат
|
||||||
|
- [ ] Обновление данных в карточке объекта
|
||||||
|
- [ ] Обновление статусов в списке объектов
|
||||||
|
|
||||||
|
### 4.4 Модуль "События"
|
||||||
|
|
||||||
|
#### FR-4.1 Типы событий
|
||||||
|
- [ ] `LONG_STOP` — остановка более N минут (настраиваемый порог)
|
||||||
|
- [ ] `OVERSPEED` — превышение скорости (порог настраивается)
|
||||||
|
- [ ] `CONNECTION_LOST` — нет данных более 5 минут
|
||||||
|
|
||||||
|
#### FR-4.2 Лента событий
|
||||||
|
- [ ] Панель с последними событиями (10-20 штук)
|
||||||
|
- [ ] Фильтр по типу события
|
||||||
|
- [ ] Клик по событию — переход к объекту на карте
|
||||||
|
|
||||||
|
### 4.5 Модуль "Симулятор"
|
||||||
|
|
||||||
|
#### FR-5.1 Генерация данных
|
||||||
|
- [ ] Python-скрипт для имитации движения N объектов
|
||||||
|
- [ ] Реалистичное движение по координатам (не телепортация)
|
||||||
|
- [ ] Случайные остановки и изменения скорости
|
||||||
|
- [ ] Отправка данных через REST API или WebSocket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Архитектура системы
|
||||||
|
|
||||||
|
### 5.1 Общая схема
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Nginx │ :80
|
||||||
|
│ (reverse proxy)│
|
||||||
|
│ │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────┐
|
||||||
|
│ /api, /ws │ / │
|
||||||
|
▼ │ ▼
|
||||||
|
┌─────────────────┐ │ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ Симулятор │────▶ Backend │ │ Frontend │
|
||||||
|
│ (Python) │POST│ (FastAPI) │ │ (Vue 3) │
|
||||||
|
│ │ │ :8000 │ │ :5173 (dev) │
|
||||||
|
└─────────────────┘ └────────┬────────┘ └─────────────────┘
|
||||||
|
│
|
||||||
|
│ SQL
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ │
|
||||||
|
│ PostgreSQL │ :5432
|
||||||
|
│ │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Структура проекта
|
||||||
|
```
|
||||||
|
transport/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── docs/
|
||||||
|
│ └── TECHNICAL_SPECIFICATION.md
|
||||||
|
├── nginx/
|
||||||
|
│ ├── nginx.conf # Основной конфиг
|
||||||
|
│ ├── conf.d/
|
||||||
|
│ │ └── default.conf # Конфиг сервера
|
||||||
|
│ └── Dockerfile # (опционально, для кастомизации)
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── alembic/
|
||||||
|
│ │ └── versions/
|
||||||
|
│ ├── alembic.ini
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── main.py # FastAPI приложение
|
||||||
|
│ │ ├── config.py # Настройки
|
||||||
|
│ │ ├── database.py # Подключение к БД
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── vehicle.py
|
||||||
|
│ │ │ ├── position.py
|
||||||
|
│ │ │ └── event.py
|
||||||
|
│ │ ├── schemas/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── vehicle.py
|
||||||
|
│ │ │ ├── position.py
|
||||||
|
│ │ │ └── event.py
|
||||||
|
│ │ ├── routers/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── vehicles.py
|
||||||
|
│ │ │ ├── positions.py
|
||||||
|
│ │ │ └── events.py
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── event_detector.py
|
||||||
|
│ │ │ └── websocket_manager.py
|
||||||
|
│ │ └── websocket.py # WS эндпоинт
|
||||||
|
│ └── simulator/
|
||||||
|
│ └── run.py # Симулятор данных
|
||||||
|
├── frontend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.js
|
||||||
|
│ ├── App.vue
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── MapView.vue
|
||||||
|
│ │ ├── VehicleList.vue
|
||||||
|
│ │ ├── VehicleCard.vue
|
||||||
|
│ │ ├── TrackHistory.vue
|
||||||
|
│ │ └── EventFeed.vue
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useWebSocket.js
|
||||||
|
│ │ └── useVehicles.js
|
||||||
|
│ └── stores/
|
||||||
|
│ └── vehicles.js
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API спецификация
|
||||||
|
|
||||||
|
### 6.1 REST API
|
||||||
|
|
||||||
|
#### Транспортные средства
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| GET | `/api/vehicles` | Список всех ТС |
|
||||||
|
| GET | `/api/vehicles/{id}` | Информация о ТС |
|
||||||
|
| POST | `/api/vehicles` | Создать ТС |
|
||||||
|
| PUT | `/api/vehicles/{id}` | Обновить ТС |
|
||||||
|
| DELETE | `/api/vehicles/{id}` | Удалить ТС |
|
||||||
|
|
||||||
|
#### Позиции
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| GET | `/api/vehicles/{id}/positions` | История позиций ТС |
|
||||||
|
| POST | `/api/ingest/position` | Принять новую позицию |
|
||||||
|
| GET | `/api/vehicles/{id}/last-position` | Последняя позиция ТС |
|
||||||
|
|
||||||
|
**Query параметры для `/api/vehicles/{id}/positions`:**
|
||||||
|
- `from` — начало периода (ISO 8601)
|
||||||
|
- `to` — конец периода (ISO 8601)
|
||||||
|
- `limit` — максимум записей (default: 1000)
|
||||||
|
|
||||||
|
#### События
|
||||||
|
|
||||||
|
| Метод | Эндпоинт | Описание |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| GET | `/api/events` | Список событий |
|
||||||
|
| GET | `/api/vehicles/{id}/events` | События конкретного ТС |
|
||||||
|
|
||||||
|
**Query параметры для `/api/events`:**
|
||||||
|
- `type` — тип события (LONG_STOP, OVERSPEED, CONNECTION_LOST)
|
||||||
|
- `from` / `to` — временной диапазон
|
||||||
|
- `limit` — максимум записей
|
||||||
|
|
||||||
|
### 6.2 WebSocket API
|
||||||
|
|
||||||
|
#### Подключение
|
||||||
|
```
|
||||||
|
ws://localhost/ws/positions
|
||||||
|
```
|
||||||
|
*(Nginx проксирует на backend:8000)*
|
||||||
|
|
||||||
|
#### Формат сообщений (сервер → клиент)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "position_update",
|
||||||
|
"data": {
|
||||||
|
"vehicle_id": 1,
|
||||||
|
"lat": 55.7558,
|
||||||
|
"lon": 37.6173,
|
||||||
|
"speed": 45.5,
|
||||||
|
"heading": 180,
|
||||||
|
"timestamp": "2025-12-18T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"data": {
|
||||||
|
"id": 123,
|
||||||
|
"vehicle_id": 1,
|
||||||
|
"type": "OVERSPEED",
|
||||||
|
"payload": {"speed": 95, "limit": 60},
|
||||||
|
"timestamp": "2025-12-18T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Структура базы данных
|
||||||
|
|
||||||
|
### 7.1 Таблица `vehicles`
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | SERIAL PRIMARY KEY | Идентификатор |
|
||||||
|
| name | VARCHAR(100) NOT NULL | Название/номер ТС |
|
||||||
|
| type | VARCHAR(50) | Тип: bus, truck, car |
|
||||||
|
| created_at | TIMESTAMP | Дата создания |
|
||||||
|
|
||||||
|
### 7.2 Таблица `positions`
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | SERIAL PRIMARY KEY | Идентификатор |
|
||||||
|
| vehicle_id | INTEGER FK | Ссылка на vehicles |
|
||||||
|
| timestamp | TIMESTAMP NOT NULL | Время фиксации |
|
||||||
|
| lat | DOUBLE PRECISION | Широта |
|
||||||
|
| lon | DOUBLE PRECISION | Долгота |
|
||||||
|
| speed | REAL | Скорость (км/ч) |
|
||||||
|
| heading | REAL | Направление (0-360) |
|
||||||
|
|
||||||
|
**Индексы:**
|
||||||
|
- `idx_positions_vehicle_ts` ON (vehicle_id, timestamp DESC)
|
||||||
|
|
||||||
|
### 7.3 Таблица `events`
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | SERIAL PRIMARY KEY | Идентификатор |
|
||||||
|
| vehicle_id | INTEGER FK | Ссылка на vehicles |
|
||||||
|
| timestamp | TIMESTAMP NOT NULL | Время события |
|
||||||
|
| type | VARCHAR(50) NOT NULL | Тип события |
|
||||||
|
| payload | JSONB | Дополнительные данные |
|
||||||
|
|
||||||
|
**Индексы:**
|
||||||
|
- `idx_events_vehicle_ts` ON (vehicle_id, timestamp DESC)
|
||||||
|
- `idx_events_type` ON (type)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Нефункциональные требования
|
||||||
|
|
||||||
|
### 8.1 Производительность
|
||||||
|
- Система должна поддерживать минимум 100 одновременных объектов
|
||||||
|
- Частота обновления позиций: 1 раз в 1-2 секунды на объект
|
||||||
|
- Время отклика API: < 500 мс для 95% запросов
|
||||||
|
|
||||||
|
### 8.2 Надёжность
|
||||||
|
- WebSocket должен автоматически переподключаться при обрыве
|
||||||
|
- При недоступности БД — graceful degradation с логированием ошибок
|
||||||
|
|
||||||
|
### 8.3 Развёртывание
|
||||||
|
- Запуск всей системы одной командой: `docker-compose up`
|
||||||
|
- Автоматическое применение миграций при старте
|
||||||
|
|
||||||
|
### 8.4 Безопасность (упрощённо для учебного проекта)
|
||||||
|
- CORS настроен для локальной разработки
|
||||||
|
- Валидация входных данных на уровне Pydantic-схем
|
||||||
|
- (Опционально) Базовая авторизация через API-ключ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Этапы разработки
|
||||||
|
|
||||||
|
### Этап 1: Инфраструктура
|
||||||
|
- [ ] Настройка Docker Compose (Nginx + PostgreSQL + backend + frontend)
|
||||||
|
- [ ] Конфигурация Nginx (проксирование /api, /ws, статика)
|
||||||
|
- [ ] Базовая структура FastAPI приложения
|
||||||
|
- [ ] Подключение к БД, настройка Alembic
|
||||||
|
- [ ] Базовая структура Vue-приложения
|
||||||
|
|
||||||
|
### Этап 2: CRUD и карта
|
||||||
|
- [ ] Модели и миграции для vehicles, positions
|
||||||
|
- [ ] REST API для vehicles
|
||||||
|
- [ ] Endpoint POST /ingest/position
|
||||||
|
- [ ] Фронт: отображение карты с маркерами
|
||||||
|
- [ ] Фронт: список объектов + карточка
|
||||||
|
|
||||||
|
### Этап 3: Реалтайм
|
||||||
|
- [ ] WebSocket manager на бэкенде
|
||||||
|
- [ ] Broadcast новых позиций всем клиентам
|
||||||
|
- [ ] Фронт: подключение к WS, обновление маркеров
|
||||||
|
- [ ] Симулятор: базовая версия
|
||||||
|
|
||||||
|
### Этап 4: История и события
|
||||||
|
- [ ] GET /vehicles/{id}/positions с фильтрами
|
||||||
|
- [ ] Модель events + детектор событий
|
||||||
|
- [ ] Фронт: отображение трека на карте
|
||||||
|
- [ ] Фронт: таблица точек + лента событий
|
||||||
|
|
||||||
|
### Этап 5: Финализация
|
||||||
|
- [ ] Улучшение UI/UX
|
||||||
|
- [ ] Доработка симулятора (реалистичные маршруты)
|
||||||
|
- [ ] Тестирование под нагрузкой (100 объектов)
|
||||||
|
- [ ] Документация для запуска и демонстрации
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Запуск проекта
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
```bash
|
||||||
|
# Клонировать репозиторий
|
||||||
|
git clone <repo-url>
|
||||||
|
cd transport
|
||||||
|
|
||||||
|
# Запустить всё
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Доступ (всё через Nginx на порту 80)
|
||||||
|
# Приложение: http://localhost
|
||||||
|
# API: http://localhost/api
|
||||||
|
# API Docs: http://localhost/api/docs
|
||||||
|
# WebSocket: ws://localhost/ws/positions
|
||||||
|
|
||||||
|
# Запустить симулятор (в отдельном терминале)
|
||||||
|
docker-compose exec backend python -m simulator.run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose сервисы
|
||||||
|
| Сервис | Порт (внутренний) | Порт (внешний) | Описание |
|
||||||
|
|--------|-------------------|----------------|----------|
|
||||||
|
| nginx | 80 | 80 | Reverse proxy, точка входа |
|
||||||
|
| backend | 8000 | - | FastAPI, доступен только через nginx |
|
||||||
|
| frontend | 5173 (dev) | - | Vue dev server, доступен только через nginx |
|
||||||
|
| postgres | 5432 | 5432* | База данных |
|
||||||
|
|
||||||
|
*Порт PostgreSQL открыт наружу для удобства разработки (подключение через IDE/DBeaver)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Критерии приёмки
|
||||||
|
|
||||||
|
| № | Критерий | Приоритет |
|
||||||
|
|---|----------|-----------|
|
||||||
|
| 1 | Карта отображается с маркерами объектов | Обязательно |
|
||||||
|
| 2 | Позиции обновляются в реальном времени (WS) | Обязательно |
|
||||||
|
| 3 | История движения показывается на карте | Обязательно |
|
||||||
|
| 4 | Система запускается через docker-compose up | Обязательно |
|
||||||
|
| 5 | Симулятор генерирует тестовые данные | Обязательно |
|
||||||
|
| 6 | Лента событий отображается | Желательно |
|
||||||
|
| 7 | Поддержка 100+ объектов без лагов | Желательно |
|
||||||
|
| 8 | Экспорт истории в CSV | Опционально |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Документ составлен: 2025-12-18*
|
||||||
16
transport/frontend/Dockerfile
Normal file
16
transport/frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose Vite dev server port
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Start Vite dev server with host flag for Docker
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
14
transport/frontend/index.html
Normal file
14
transport/frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Мониторинг транспорта</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
transport/frontend/package.json
Normal file
23
transport/frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "transport-monitoring-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"naive-ui": "^2.35.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"@vueuse/core": "^10.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
289
transport/frontend/src/App.vue
Normal file
289
transport/frontend/src/App.vue
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<template>
|
||||||
|
<n-config-provider :theme="darkTheme">
|
||||||
|
<n-layout class="app-layout">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<n-layout-header class="app-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>🚗 Мониторинг транспорта</h1>
|
||||||
|
<n-space>
|
||||||
|
<n-tag :type="wsConnected ? 'success' : 'error'" size="small">
|
||||||
|
{{ wsConnected ? '● Онлайн' : '○ Офлайн' }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag type="info" size="small">
|
||||||
|
Объектов: {{ vehicles.length }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</n-layout-header>
|
||||||
|
|
||||||
|
<n-layout has-sider class="app-content">
|
||||||
|
<!-- Боковая панель -->
|
||||||
|
<n-layout-sider
|
||||||
|
:width="320"
|
||||||
|
:collapsed-width="0"
|
||||||
|
show-trigger="bar"
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<!-- Поиск -->
|
||||||
|
<n-input
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
clearable
|
||||||
|
class="search-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<span>🔍</span>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<!-- Список транспорта -->
|
||||||
|
<VehicleList
|
||||||
|
:vehicles="filteredVehicles"
|
||||||
|
:selected-id="selectedVehicleId"
|
||||||
|
@select="selectVehicle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Карточка выбранного объекта -->
|
||||||
|
<VehicleCard
|
||||||
|
v-if="selectedVehicle"
|
||||||
|
:vehicle="selectedVehicle"
|
||||||
|
@show-track="showTrack"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- История трека -->
|
||||||
|
<TrackHistory
|
||||||
|
v-if="currentTrack"
|
||||||
|
:track="currentTrack"
|
||||||
|
@close="currentTrack = null"
|
||||||
|
@select-point="centerOnPoint"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Лента событий -->
|
||||||
|
<EventFeed
|
||||||
|
:events="recentEvents"
|
||||||
|
:vehicles="vehicles"
|
||||||
|
@select-vehicle="selectVehicle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-layout-sider>
|
||||||
|
|
||||||
|
<!-- Карта -->
|
||||||
|
<n-layout-content>
|
||||||
|
<MapView
|
||||||
|
ref="mapRef"
|
||||||
|
:vehicles="vehicles"
|
||||||
|
:selected-id="selectedVehicleId"
|
||||||
|
:track="currentTrack"
|
||||||
|
@select="selectVehicle"
|
||||||
|
/>
|
||||||
|
</n-layout-content>
|
||||||
|
</n-layout>
|
||||||
|
</n-layout>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { darkTheme } from 'naive-ui'
|
||||||
|
import axios from 'axios'
|
||||||
|
import MapView from './components/MapView.vue'
|
||||||
|
import VehicleList from './components/VehicleList.vue'
|
||||||
|
import VehicleCard from './components/VehicleCard.vue'
|
||||||
|
import TrackHistory from './components/TrackHistory.vue'
|
||||||
|
import EventFeed from './components/EventFeed.vue'
|
||||||
|
|
||||||
|
// State
|
||||||
|
const vehicles = ref([])
|
||||||
|
const selectedVehicleId = ref(null)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const wsConnected = ref(false)
|
||||||
|
const recentEvents = ref([])
|
||||||
|
const currentTrack = ref(null)
|
||||||
|
const mapRef = ref(null)
|
||||||
|
|
||||||
|
let ws = null
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredVehicles = computed(() => {
|
||||||
|
if (!searchQuery.value) return vehicles.value
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return vehicles.value.filter(v =>
|
||||||
|
v.name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedVehicle = computed(() =>
|
||||||
|
vehicles.value.find(v => v.id === selectedVehicleId.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchVehicles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/vehicles')
|
||||||
|
vehicles.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch vehicles:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/events', {
|
||||||
|
params: { limit: 20 }
|
||||||
|
})
|
||||||
|
recentEvents.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch events:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectVehicle = (id) => {
|
||||||
|
selectedVehicleId.value = id
|
||||||
|
currentTrack.value = null
|
||||||
|
|
||||||
|
if (id && mapRef.value) {
|
||||||
|
const vehicle = vehicles.value.find(v => v.id === id)
|
||||||
|
if (vehicle?.last_position) {
|
||||||
|
mapRef.value.centerOn(vehicle.last_position.lat, vehicle.last_position.lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTrack = async (vehicleId, minutes = 30) => {
|
||||||
|
try {
|
||||||
|
const from = new Date(Date.now() - minutes * 60 * 1000).toISOString()
|
||||||
|
const response = await axios.get(`/api/vehicles/${vehicleId}/positions`, {
|
||||||
|
params: { from }
|
||||||
|
})
|
||||||
|
currentTrack.value = {
|
||||||
|
vehicleId,
|
||||||
|
positions: response.data.reverse() // От старых к новым
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch track:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerOnPoint = (point) => {
|
||||||
|
if (mapRef.value && point) {
|
||||||
|
mapRef.value.centerOn(point.lat, point.lon, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectWebSocket = () => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
ws = new WebSocket(`${protocol}//${window.location.host}/ws/positions`)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
wsConnected.value = true
|
||||||
|
console.log('WebSocket connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (message.type === 'position_update') {
|
||||||
|
updateVehiclePosition(message.data)
|
||||||
|
} else if (message.type === 'event') {
|
||||||
|
addEvent(message.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsConnected.value = false
|
||||||
|
console.log('WebSocket disconnected, reconnecting...')
|
||||||
|
setTimeout(connectWebSocket, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVehiclePosition = (data) => {
|
||||||
|
const vehicle = vehicles.value.find(v => v.id === data.vehicle_id)
|
||||||
|
if (vehicle) {
|
||||||
|
vehicle.last_position = {
|
||||||
|
lat: data.lat,
|
||||||
|
lon: data.lon,
|
||||||
|
speed: data.speed,
|
||||||
|
heading: data.heading,
|
||||||
|
timestamp: data.timestamp
|
||||||
|
}
|
||||||
|
vehicle.status = data.speed > 2 ? 'moving' : 'stopped'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEvent = (data) => {
|
||||||
|
recentEvents.value.unshift(data)
|
||||||
|
if (recentEvents.value.length > 20) {
|
||||||
|
recentEvents.value.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchVehicles()
|
||||||
|
fetchEvents()
|
||||||
|
connectWebSocket()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
padding: 12px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
transport/frontend/src/components/EventFeed.vue
Normal file
153
transport/frontend/src/components/EventFeed.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<n-card title="События" size="small" class="event-feed">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-select
|
||||||
|
v-model:value="filterType"
|
||||||
|
:options="filterOptions"
|
||||||
|
size="tiny"
|
||||||
|
style="width: 100px"
|
||||||
|
placeholder="Все"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-scrollbar style="max-height: 200px">
|
||||||
|
<div
|
||||||
|
v-for="event in filteredEvents"
|
||||||
|
:key="event.id"
|
||||||
|
class="event-item"
|
||||||
|
@click="handleClick(event)"
|
||||||
|
>
|
||||||
|
<div class="event-icon">
|
||||||
|
{{ getIcon(event.type) }}
|
||||||
|
</div>
|
||||||
|
<div class="event-content">
|
||||||
|
<div class="event-title">
|
||||||
|
{{ getTitle(event) }}
|
||||||
|
</div>
|
||||||
|
<div class="event-meta">
|
||||||
|
<span class="event-vehicle">{{ getVehicleName(event.vehicle_id) }}</span>
|
||||||
|
<span class="event-time">{{ formatTime(event.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-empty v-if="filteredEvents.length === 0" description="Нет событий" size="small" />
|
||||||
|
</n-scrollbar>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
events: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
vehicles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-vehicle'])
|
||||||
|
|
||||||
|
const filterType = ref(null)
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ label: '⚠️ Скорость', value: 'OVERSPEED' },
|
||||||
|
{ label: '⏸️ Остановка', value: 'LONG_STOP' },
|
||||||
|
{ label: '📡 Связь', value: 'CONNECTION_LOST' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredEvents = computed(() => {
|
||||||
|
if (!filterType.value) return props.events
|
||||||
|
return props.events.filter(e => e.type === filterType.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getIcon = (type) => {
|
||||||
|
const icons = {
|
||||||
|
OVERSPEED: '⚠️',
|
||||||
|
LONG_STOP: '⏸️',
|
||||||
|
CONNECTION_LOST: '📡'
|
||||||
|
}
|
||||||
|
return icons[type] || '📌'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTitle = (event) => {
|
||||||
|
const titles = {
|
||||||
|
OVERSPEED: `Превышение: ${event.payload?.speed?.toFixed(0) || '?'} км/ч`,
|
||||||
|
LONG_STOP: `Остановка: ${event.payload?.duration_minutes?.toFixed(0) || '?'} мин`,
|
||||||
|
CONNECTION_LOST: 'Потеря связи'
|
||||||
|
}
|
||||||
|
return titles[event.type] || event.type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVehicleName = (vehicleId) => {
|
||||||
|
const vehicle = props.vehicles.find(v => v.id === vehicleId)
|
||||||
|
return vehicle?.name || `#${vehicleId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (event) => {
|
||||||
|
emit('select-vehicle', event.vehicle_id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-feed {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-vehicle {
|
||||||
|
color: #6b9eff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
204
transport/frontend/src/components/MapView.vue
Normal file
204
transport/frontend/src/components/MapView.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="mapContainer" class="map-container"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch, defineExpose } from 'vue'
|
||||||
|
import L from 'leaflet'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
vehicles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
const mapContainer = ref(null)
|
||||||
|
let map = null
|
||||||
|
const markers = new Map()
|
||||||
|
let trackLine = null
|
||||||
|
|
||||||
|
// Иконки для разных типов и статусов
|
||||||
|
const createIcon = (type, status, isSelected) => {
|
||||||
|
const colors = {
|
||||||
|
moving: '#22c55e',
|
||||||
|
stopped: '#eab308',
|
||||||
|
offline: '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
bus: '🚌',
|
||||||
|
truck: '🚚',
|
||||||
|
car: '🚗'
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = colors[status] || colors.offline
|
||||||
|
const icon = icons[type] || icons.car
|
||||||
|
const size = isSelected ? 36 : 28
|
||||||
|
const border = isSelected ? '3px solid #3b82f6' : '2px solid #fff'
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'vehicle-marker',
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
width: ${size}px;
|
||||||
|
height: ${size}px;
|
||||||
|
background: ${color};
|
||||||
|
border-radius: 50%;
|
||||||
|
border: ${border};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: ${size * 0.5}px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
">
|
||||||
|
${icon}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map = L.map(mapContainer.value, {
|
||||||
|
center: [55.0304, 82.9204], // Новосибирск
|
||||||
|
zoom: 13,
|
||||||
|
zoomControl: true
|
||||||
|
})
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMarkers = () => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Обновляем существующие и добавляем новые маркеры
|
||||||
|
props.vehicles.forEach(vehicle => {
|
||||||
|
if (!vehicle.last_position) return
|
||||||
|
|
||||||
|
const { lat, lon } = vehicle.last_position
|
||||||
|
const isSelected = vehicle.id === props.selectedId
|
||||||
|
|
||||||
|
if (markers.has(vehicle.id)) {
|
||||||
|
// Обновляем существующий маркер
|
||||||
|
const marker = markers.get(vehicle.id)
|
||||||
|
marker.setLatLng([lat, lon])
|
||||||
|
marker.setIcon(createIcon(vehicle.type, vehicle.status, isSelected))
|
||||||
|
} else {
|
||||||
|
// Создаём новый маркер
|
||||||
|
const marker = L.marker([lat, lon], {
|
||||||
|
icon: createIcon(vehicle.type, vehicle.status, isSelected)
|
||||||
|
})
|
||||||
|
|
||||||
|
marker.on('click', () => {
|
||||||
|
emit('select', vehicle.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Всплывающая подсказка
|
||||||
|
marker.bindTooltip(`
|
||||||
|
<strong>${vehicle.name}</strong><br>
|
||||||
|
Скорость: ${vehicle.last_position.speed.toFixed(1)} км/ч
|
||||||
|
`, { direction: 'top', offset: [0, -10] })
|
||||||
|
|
||||||
|
marker.addTo(map)
|
||||||
|
markers.set(vehicle.id, marker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удаляем маркеры для несуществующих объектов
|
||||||
|
const vehicleIds = new Set(props.vehicles.map(v => v.id))
|
||||||
|
markers.forEach((marker, id) => {
|
||||||
|
if (!vehicleIds.has(id)) {
|
||||||
|
map.removeLayer(marker)
|
||||||
|
markers.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTrack = () => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Удаляем старый трек
|
||||||
|
if (trackLine) {
|
||||||
|
map.removeLayer(trackLine)
|
||||||
|
trackLine = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем новый трек
|
||||||
|
if (props.track && props.track.positions.length > 0) {
|
||||||
|
const points = props.track.positions.map(p => [p.lat, p.lon])
|
||||||
|
|
||||||
|
trackLine = L.polyline(points, {
|
||||||
|
color: '#3b82f6',
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.8
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
// Маркеры начала и конца
|
||||||
|
if (points.length > 1) {
|
||||||
|
L.circleMarker(points[0], {
|
||||||
|
radius: 8,
|
||||||
|
color: '#22c55e',
|
||||||
|
fillColor: '#22c55e',
|
||||||
|
fillOpacity: 1
|
||||||
|
}).bindTooltip('Начало').addTo(map)
|
||||||
|
|
||||||
|
L.circleMarker(points[points.length - 1], {
|
||||||
|
radius: 8,
|
||||||
|
color: '#ef4444',
|
||||||
|
fillColor: '#ef4444',
|
||||||
|
fillOpacity: 1
|
||||||
|
}).bindTooltip('Конец').addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подгоняем карту под трек
|
||||||
|
map.fitBounds(trackLine.getBounds(), { padding: [50, 50] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerOn = (lat, lon, zoom = 15) => {
|
||||||
|
if (map) {
|
||||||
|
map.setView([lat, lon], zoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose methods
|
||||||
|
defineExpose({ centerOn })
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(() => props.vehicles, updateMarkers, { deep: true })
|
||||||
|
watch(() => props.selectedId, updateMarkers)
|
||||||
|
watch(() => props.track, updateTrack, { deep: true })
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
initMap()
|
||||||
|
updateMarkers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.vehicle-marker) {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
155
transport/frontend/src/components/TrackHistory.vue
Normal file
155
transport/frontend/src/components/TrackHistory.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<n-card title="История трека" size="small" class="track-history" v-if="track">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-space>
|
||||||
|
<n-button size="tiny" @click="exportCsv">📥 CSV</n-button>
|
||||||
|
<n-button size="tiny" @click="$emit('close')">✕</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="track-info">
|
||||||
|
<n-tag type="info" size="small">
|
||||||
|
{{ track.positions.length }} точек
|
||||||
|
</n-tag>
|
||||||
|
<n-tag type="success" size="small" v-if="track.positions.length > 0">
|
||||||
|
{{ formatDuration }}
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-data-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="track.positions"
|
||||||
|
:max-height="200"
|
||||||
|
size="small"
|
||||||
|
:row-key="row => row.id"
|
||||||
|
:row-class-name="getRowClassName"
|
||||||
|
@update:checked-row-keys="handleRowClick"
|
||||||
|
striped
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, h } from 'vue'
|
||||||
|
import { NTag } from 'naive-ui'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
track: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'select-point'])
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Время',
|
||||||
|
key: 'timestamp',
|
||||||
|
width: 80,
|
||||||
|
render(row) {
|
||||||
|
return formatTime(row.timestamp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Координаты',
|
||||||
|
key: 'coords',
|
||||||
|
width: 140,
|
||||||
|
render(row) {
|
||||||
|
return `${row.lat.toFixed(4)}, ${row.lon.toFixed(4)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Скорость',
|
||||||
|
key: 'speed',
|
||||||
|
width: 70,
|
||||||
|
render(row) {
|
||||||
|
const speed = row.speed.toFixed(0)
|
||||||
|
const type = row.speed > 60 ? 'error' : row.speed > 0 ? 'success' : 'default'
|
||||||
|
return h(NTag, { size: 'small', type }, { default: () => `${speed}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = computed(() => {
|
||||||
|
if (!props.track || props.track.positions.length < 2) return ''
|
||||||
|
|
||||||
|
const positions = props.track.positions
|
||||||
|
const start = new Date(positions[0].timestamp)
|
||||||
|
const end = new Date(positions[positions.length - 1].timestamp)
|
||||||
|
const diffMs = end - start
|
||||||
|
const diffMins = Math.round(diffMs / 60000)
|
||||||
|
|
||||||
|
if (diffMins < 60) return `${diffMins} мин`
|
||||||
|
const hours = Math.floor(diffMins / 60)
|
||||||
|
const mins = diffMins % 60
|
||||||
|
return `${hours}ч ${mins}м`
|
||||||
|
})
|
||||||
|
|
||||||
|
const getRowClassName = (row, index) => {
|
||||||
|
return 'track-row'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (keys) => {
|
||||||
|
// Найти точку по индексу и отправить событие
|
||||||
|
if (keys.length > 0) {
|
||||||
|
const point = props.track.positions.find(p => p.id === keys[0])
|
||||||
|
if (point) {
|
||||||
|
emit('select-point', point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
if (!props.track || props.track.positions.length === 0) return
|
||||||
|
|
||||||
|
const headers = ['Время', 'Широта', 'Долгота', 'Скорость (км/ч)', 'Направление']
|
||||||
|
const rows = props.track.positions.map(p => [
|
||||||
|
new Date(p.timestamp).toISOString(),
|
||||||
|
p.lat,
|
||||||
|
p.lon,
|
||||||
|
p.speed,
|
||||||
|
p.heading
|
||||||
|
])
|
||||||
|
|
||||||
|
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `track_${props.track.vehicleId}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.track-history {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.track-row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.track-row:hover) {
|
||||||
|
background: rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
111
transport/frontend/src/components/VehicleCard.vue
Normal file
111
transport/frontend/src/components/VehicleCard.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<n-card :title="vehicle.name" size="small" class="vehicle-card">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-tag :type="getStatusType(vehicle.status)" size="small">
|
||||||
|
{{ getStatusText(vehicle.status) }}
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="card-content" v-if="vehicle.last_position">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">📍 Координаты:</span>
|
||||||
|
<span class="value">
|
||||||
|
{{ vehicle.last_position.lat.toFixed(5) }},
|
||||||
|
{{ vehicle.last_position.lon.toFixed(5) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">🚀 Скорость:</span>
|
||||||
|
<span class="value">{{ vehicle.last_position.speed.toFixed(1) }} км/ч</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">🧭 Направление:</span>
|
||||||
|
<span class="value">{{ vehicle.last_position.heading.toFixed(0) }}°</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">🕐 Обновлено:</span>
|
||||||
|
<span class="value">{{ formatTime(vehicle.last_position.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-divider />
|
||||||
|
|
||||||
|
<n-space>
|
||||||
|
<n-button size="small" @click="$emit('show-track', vehicle.id, 30)">
|
||||||
|
Трек 30 мин
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="$emit('show-track', vehicle.id, 60)">
|
||||||
|
Трек 1 час
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-empty v-else description="Нет данных о позиции" size="small" />
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
vehicle: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['show-track'])
|
||||||
|
|
||||||
|
const getStatusType = (status) => {
|
||||||
|
const types = {
|
||||||
|
moving: 'success',
|
||||||
|
stopped: 'warning',
|
||||||
|
offline: 'default'
|
||||||
|
}
|
||||||
|
return types[status] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const texts = {
|
||||||
|
moving: 'Движется',
|
||||||
|
stopped: 'Остановлен',
|
||||||
|
offline: 'Нет связи'
|
||||||
|
}
|
||||||
|
return texts[status] || 'Неизвестно'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vehicle-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
transport/frontend/src/components/VehicleList.vue
Normal file
121
transport/frontend/src/components/VehicleList.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<n-card title="Транспорт" size="small" class="vehicle-list">
|
||||||
|
<n-scrollbar style="max-height: 250px">
|
||||||
|
<div
|
||||||
|
v-for="vehicle in vehicles"
|
||||||
|
:key="vehicle.id"
|
||||||
|
class="vehicle-item"
|
||||||
|
:class="{ selected: vehicle.id === selectedId }"
|
||||||
|
@click="$emit('select', vehicle.id)"
|
||||||
|
>
|
||||||
|
<div class="vehicle-icon">
|
||||||
|
{{ getIcon(vehicle.type) }}
|
||||||
|
</div>
|
||||||
|
<div class="vehicle-info">
|
||||||
|
<div class="vehicle-name">{{ vehicle.name }}</div>
|
||||||
|
<div class="vehicle-speed" v-if="vehicle.last_position">
|
||||||
|
{{ vehicle.last_position.speed.toFixed(1) }} км/ч
|
||||||
|
</div>
|
||||||
|
<div class="vehicle-speed" v-else>Нет данных</div>
|
||||||
|
</div>
|
||||||
|
<div class="vehicle-status">
|
||||||
|
<n-badge
|
||||||
|
:type="getStatusType(vehicle.status)"
|
||||||
|
:value="getStatusText(vehicle.status)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-empty v-if="vehicles.length === 0" description="Нет объектов" />
|
||||||
|
</n-scrollbar>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
vehicles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['select'])
|
||||||
|
|
||||||
|
const getIcon = (type) => {
|
||||||
|
const icons = {
|
||||||
|
bus: '🚌',
|
||||||
|
truck: '🚚',
|
||||||
|
car: '🚗'
|
||||||
|
}
|
||||||
|
return icons[type] || '🚗'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status) => {
|
||||||
|
const types = {
|
||||||
|
moving: 'success',
|
||||||
|
stopped: 'warning',
|
||||||
|
offline: 'default'
|
||||||
|
}
|
||||||
|
return types[status] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const texts = {
|
||||||
|
moving: 'Едет',
|
||||||
|
stopped: 'Стоит',
|
||||||
|
offline: 'Офлайн'
|
||||||
|
}
|
||||||
|
return texts[status] || 'Неизвестно'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vehicle-list {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-item.selected {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-speed {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
transport/frontend/src/main.js
Normal file
11
transport/frontend/src/main.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import naive from 'naive-ui'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(naive)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
13
transport/frontend/vite.config.js
Normal file
13
transport/frontend/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
watch: {
|
||||||
|
usePolling: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
51
transport/nginx/conf.d/default.conf
Normal file
51
transport/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Upstream definitions
|
||||||
|
upstream backend {
|
||||||
|
server backend:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:5173;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Frontend (Vue dev server)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API (strip /api prefix)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket timeout settings
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
transport/nginx/nginx.conf
Normal file
29
transport/nginx/nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user