This commit is contained in:
2025-12-18 21:13:49 +03:00
parent 84b934036b
commit 030af7ca83
45 changed files with 3106 additions and 0 deletions

256
transport/README.md Normal file
View 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

View 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"]

View 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

View 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()

View 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"}

View 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')

View File

View 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()

View 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()

View 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"}

View 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"]

View 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"),
)

View 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"),
)

View 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")

View 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

View 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

View 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()

View 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"
]

View 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

View 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

View 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

View 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) # Проверять каждую минуту

View 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

View 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()

View 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)

View 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

View File

View 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⏹ Симулятор остановлен")

View 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:

View 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*

View 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"]

View 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>

View 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"
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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')

View 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
}
}
})

View 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;
}
}

View 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;
}