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