feat: add mqtt raw data management with UTC+8 range UI
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import locations, login, private, telemetry, users, utils
|
from app.api.routes import (
|
||||||
|
locations,
|
||||||
|
login,
|
||||||
|
mqtt_raw,
|
||||||
|
private,
|
||||||
|
telemetry,
|
||||||
|
users,
|
||||||
|
utils,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -9,6 +17,7 @@ api_router.include_router(users.router)
|
|||||||
api_router.include_router(utils.router)
|
api_router.include_router(utils.router)
|
||||||
api_router.include_router(locations.router)
|
api_router.include_router(locations.router)
|
||||||
api_router.include_router(telemetry.router)
|
api_router.include_router(telemetry.router)
|
||||||
|
api_router.include_router(mqtt_raw.router)
|
||||||
|
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "local":
|
if settings.ENVIRONMENT == "local":
|
||||||
|
|||||||
300
backend/app/api/routes/mqtt_raw.py
Normal file
300
backend/app/api/routes/mqtt_raw.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlmodel import col, delete, func, select
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.models import (
|
||||||
|
DeleteResult,
|
||||||
|
MqttRawDaysPublic,
|
||||||
|
MqttRawDaySummary,
|
||||||
|
TerminalMessageRaw,
|
||||||
|
TerminalRawArchiveManifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mqtt-raw", tags=["mqtt_raw"])
|
||||||
|
UTC_PLUS_8 = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_day(value: Any) -> date:
|
||||||
|
if isinstance(value, date) and not isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, str):
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
raise ValueError(f"Unsupported day value: {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds(day: date) -> tuple[datetime, datetime]:
|
||||||
|
start_local = datetime.combine(day, time.min, tzinfo=UTC_PLUS_8)
|
||||||
|
end_local = start_local + timedelta(days=1)
|
||||||
|
return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_day_expr(session: SessionDep, field: Any) -> Any:
|
||||||
|
bind = session.get_bind()
|
||||||
|
dialect_name = bind.dialect.name if bind is not None else ""
|
||||||
|
|
||||||
|
if dialect_name == "sqlite":
|
||||||
|
return func.date(func.datetime(field, "+8 hours"))
|
||||||
|
if dialect_name in {"postgresql", "postgres"}:
|
||||||
|
return func.date(func.timezone("Asia/Shanghai", field))
|
||||||
|
return func.date(field)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_window(
|
||||||
|
*, day: date, start_seconds: int | None, end_seconds: int | None
|
||||||
|
) -> tuple[datetime, datetime, int, int]:
|
||||||
|
start_of_day_local = datetime.combine(day, time.min, tzinfo=UTC_PLUS_8)
|
||||||
|
start_sec = 0 if start_seconds is None else start_seconds
|
||||||
|
end_sec = 24 * 3600 if end_seconds is None else end_seconds
|
||||||
|
|
||||||
|
if not 0 <= start_sec <= 24 * 3600:
|
||||||
|
raise HTTPException(status_code=400, detail="start_seconds must be 0..86400")
|
||||||
|
if not 0 <= end_sec <= 24 * 3600:
|
||||||
|
raise HTTPException(status_code=400, detail="end_seconds must be 0..86400")
|
||||||
|
if start_sec >= end_sec:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="start_seconds must be smaller than end_seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
start = (start_of_day_local + timedelta(seconds=start_sec)).astimezone(timezone.utc)
|
||||||
|
end = (start_of_day_local + timedelta(seconds=end_sec)).astimezone(timezone.utc)
|
||||||
|
return start, end, start_sec, end_sec
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_file(path_str: str) -> None:
|
||||||
|
path = Path(path_str)
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/days",
|
||||||
|
response_model=MqttRawDaysPublic,
|
||||||
|
operation_id="mqtt_raw_list_days",
|
||||||
|
)
|
||||||
|
def list_mqtt_raw_days(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
skip: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=30, ge=1, le=200),
|
||||||
|
) -> MqttRawDaysPublic:
|
||||||
|
days: dict[date, MqttRawDaySummary] = {}
|
||||||
|
|
||||||
|
raw_day_expr = _local_day_expr(session, TerminalMessageRaw.received_at)
|
||||||
|
raw_statement = (
|
||||||
|
select(
|
||||||
|
raw_day_expr,
|
||||||
|
func.count(),
|
||||||
|
func.min(TerminalMessageRaw.received_at),
|
||||||
|
func.max(TerminalMessageRaw.received_at),
|
||||||
|
)
|
||||||
|
.group_by(raw_day_expr)
|
||||||
|
.order_by(raw_day_expr.desc())
|
||||||
|
)
|
||||||
|
for day_value, message_count, first_at, last_at in session.exec(
|
||||||
|
raw_statement
|
||||||
|
).all():
|
||||||
|
day_key = _normalize_day(day_value)
|
||||||
|
days[day_key] = MqttRawDaySummary(
|
||||||
|
day=day_key,
|
||||||
|
message_count=int(message_count or 0),
|
||||||
|
first_received_at=first_at,
|
||||||
|
last_received_at=last_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_day_expr = _local_day_expr(session, TerminalRawArchiveManifest.range_start)
|
||||||
|
archive_statement = (
|
||||||
|
select(
|
||||||
|
archive_day_expr,
|
||||||
|
func.sum(TerminalRawArchiveManifest.record_count),
|
||||||
|
func.min(TerminalRawArchiveManifest.range_start),
|
||||||
|
func.max(TerminalRawArchiveManifest.range_end),
|
||||||
|
)
|
||||||
|
.group_by(archive_day_expr)
|
||||||
|
.order_by(archive_day_expr.desc())
|
||||||
|
)
|
||||||
|
for day_value, message_count, first_at, last_at in session.exec(
|
||||||
|
archive_statement
|
||||||
|
).all():
|
||||||
|
day_key = _normalize_day(day_value)
|
||||||
|
existing = days.get(day_key)
|
||||||
|
archive_count = int(message_count or 0)
|
||||||
|
if existing is None:
|
||||||
|
days[day_key] = MqttRawDaySummary(
|
||||||
|
day=day_key,
|
||||||
|
message_count=archive_count,
|
||||||
|
first_received_at=first_at,
|
||||||
|
last_received_at=last_at,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
existing.message_count += archive_count
|
||||||
|
if first_at and (
|
||||||
|
existing.first_received_at is None or first_at < existing.first_received_at
|
||||||
|
):
|
||||||
|
existing.first_received_at = first_at
|
||||||
|
if last_at and (
|
||||||
|
existing.last_received_at is None or last_at > existing.last_received_at
|
||||||
|
):
|
||||||
|
existing.last_received_at = last_at
|
||||||
|
|
||||||
|
ordered = sorted(days.values(), key=lambda item: item.day, reverse=True)
|
||||||
|
paged = ordered[skip : skip + limit]
|
||||||
|
return MqttRawDaysPublic(data=paged, count=len(ordered))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/download",
|
||||||
|
operation_id="mqtt_raw_download",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"content": {
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {"type": "string", "format": "binary"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def download_mqtt_raw(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
day: date,
|
||||||
|
start_seconds: int | None = Query(default=None),
|
||||||
|
end_seconds: int | None = Query(default=None),
|
||||||
|
) -> Response:
|
||||||
|
range_start, range_end, start_sec, end_sec = _build_window(
|
||||||
|
day=day,
|
||||||
|
start_seconds=start_seconds,
|
||||||
|
end_seconds=end_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks: list[tuple[datetime, bytes]] = []
|
||||||
|
|
||||||
|
raw_statement = (
|
||||||
|
select(TerminalMessageRaw)
|
||||||
|
.where(TerminalMessageRaw.received_at >= range_start)
|
||||||
|
.where(TerminalMessageRaw.received_at < range_end)
|
||||||
|
.order_by(col(TerminalMessageRaw.received_at), col(TerminalMessageRaw.id))
|
||||||
|
)
|
||||||
|
raw_rows = session.exec(raw_statement).all()
|
||||||
|
for row in raw_rows:
|
||||||
|
chunks.append((row.received_at, row.payload_bytes))
|
||||||
|
|
||||||
|
manifest_statement = (
|
||||||
|
select(TerminalRawArchiveManifest)
|
||||||
|
.where(TerminalRawArchiveManifest.range_start >= range_start)
|
||||||
|
.where(TerminalRawArchiveManifest.range_end <= range_end)
|
||||||
|
.order_by(
|
||||||
|
col(TerminalRawArchiveManifest.range_start),
|
||||||
|
col(TerminalRawArchiveManifest.range_end),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manifests = session.exec(manifest_statement).all()
|
||||||
|
for manifest in manifests:
|
||||||
|
path = Path(manifest.file_path)
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
continue
|
||||||
|
chunks.append((manifest.range_start, path.read_bytes()))
|
||||||
|
|
||||||
|
chunks.sort(key=lambda item: item[0])
|
||||||
|
payload = b"".join(chunk for _, chunk in chunks)
|
||||||
|
|
||||||
|
name_suffix = (
|
||||||
|
day.isoformat()
|
||||||
|
if start_sec == 0 and end_sec == 24 * 3600
|
||||||
|
else f"{day.isoformat()}_{start_sec}_{end_sec}"
|
||||||
|
)
|
||||||
|
filename = f"mqtt_raw_{name_suffix}.txt"
|
||||||
|
return Response(
|
||||||
|
content=payload,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/day/{day}",
|
||||||
|
response_model=DeleteResult,
|
||||||
|
operation_id="mqtt_raw_delete_day",
|
||||||
|
)
|
||||||
|
def delete_mqtt_raw_by_day(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
day: date,
|
||||||
|
) -> DeleteResult:
|
||||||
|
range_start, range_end = _day_bounds(day)
|
||||||
|
|
||||||
|
raw_statement = (
|
||||||
|
select(TerminalMessageRaw.id)
|
||||||
|
.where(TerminalMessageRaw.received_at >= range_start)
|
||||||
|
.where(TerminalMessageRaw.received_at < range_end)
|
||||||
|
)
|
||||||
|
raw_ids = list(session.exec(raw_statement).all())
|
||||||
|
if raw_ids:
|
||||||
|
session.exec(
|
||||||
|
delete(TerminalMessageRaw).where(col(TerminalMessageRaw.id).in_(raw_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest_statement = (
|
||||||
|
select(TerminalRawArchiveManifest)
|
||||||
|
.where(TerminalRawArchiveManifest.range_start >= range_start)
|
||||||
|
.where(TerminalRawArchiveManifest.range_end <= range_end)
|
||||||
|
)
|
||||||
|
manifests = session.exec(manifest_statement).all()
|
||||||
|
for manifest in manifests:
|
||||||
|
_unlink_file(manifest.file_path)
|
||||||
|
session.delete(manifest)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
deleted_count = len(raw_ids) + len(manifests)
|
||||||
|
return DeleteResult(
|
||||||
|
deleted_count=deleted_count,
|
||||||
|
message=f"Deleted {deleted_count} record(s) for {day.isoformat()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/all",
|
||||||
|
response_model=DeleteResult,
|
||||||
|
operation_id="mqtt_raw_delete_all",
|
||||||
|
)
|
||||||
|
def delete_all_mqtt_raw(
|
||||||
|
session: SessionDep,
|
||||||
|
_current_user: CurrentUser,
|
||||||
|
confirm: bool = Query(default=False),
|
||||||
|
) -> DeleteResult:
|
||||||
|
if not confirm:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Set confirm=true to delete all saved MQTT raw data",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_ids = list(session.exec(select(TerminalMessageRaw.id)).all())
|
||||||
|
if raw_ids:
|
||||||
|
session.exec(
|
||||||
|
delete(TerminalMessageRaw).where(col(TerminalMessageRaw.id).in_(raw_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
manifests = session.exec(select(TerminalRawArchiveManifest)).all()
|
||||||
|
for manifest in manifests:
|
||||||
|
_unlink_file(manifest.file_path)
|
||||||
|
session.delete(manifest)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
deleted_count = len(raw_ids) + len(manifests)
|
||||||
|
return DeleteResult(
|
||||||
|
deleted_count=deleted_count,
|
||||||
|
message=f"Deleted {deleted_count} record(s) from MQTT raw storage",
|
||||||
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
@@ -111,6 +111,18 @@ class LocationsPublic(SQLModel):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MqttRawDaySummary(SQLModel):
|
||||||
|
day: date
|
||||||
|
message_count: int
|
||||||
|
first_received_at: datetime | None = None
|
||||||
|
last_received_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MqttRawDaysPublic(SQLModel):
|
||||||
|
data: list[MqttRawDaySummary]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
class TerminalMessageRaw(SQLModel, table=True):
|
class TerminalMessageRaw(SQLModel, table=True):
|
||||||
__tablename__ = "terminal_message_raw"
|
__tablename__ = "terminal_message_raw"
|
||||||
|
|
||||||
@@ -186,6 +198,11 @@ class Message(SQLModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteResult(SQLModel):
|
||||||
|
deleted_count: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# JSON payload containing access token
|
# JSON payload containing access token
|
||||||
class Token(SQLModel):
|
class Token(SQLModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|||||||
223
backend/tests/api/routes/test_mqtt_raw.py
Normal file
223
backend/tests/api/routes/test_mqtt_raw.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, delete, func, select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import TerminalMessageRaw
|
||||||
|
from app.mqtt.repository import insert_raw_message
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_raw_data(db: Session) -> None:
|
||||||
|
db.execute(delete(TerminalMessageRaw))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _count_raw_rows(db: Session) -> int:
|
||||||
|
statement = select(func.count()).select_from(TerminalMessageRaw)
|
||||||
|
return int(db.exec(statement).one())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_mqtt_raw_days(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A1",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 10, 8, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A2",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 10, 21, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-b",
|
||||||
|
topic="terminal/device-b/raw",
|
||||||
|
payload=b"B1",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 11, 9, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/days?skip=0&limit=10",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["count"] == 2
|
||||||
|
assert content["data"][0]["day"] == "2026-03-11"
|
||||||
|
assert content["data"][0]["message_count"] == 2
|
||||||
|
assert content["data"][1]["day"] == "2026-03-10"
|
||||||
|
assert content["data"][1]["message_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_mqtt_raw_by_day_or_time_range(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
target_day = date(2026, 3, 12)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"EARLY\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 11, 20, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"MID\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 12, 3, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"LATE\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 12, 12, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"NEXT_DAY\n",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 12, 18, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
full_day_response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/download",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
params={"day": target_day.isoformat()},
|
||||||
|
)
|
||||||
|
assert full_day_response.status_code == 200
|
||||||
|
assert full_day_response.content == b"EARLY\nMID\nLATE\n"
|
||||||
|
|
||||||
|
range_response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/download",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
params={
|
||||||
|
"day": target_day.isoformat(),
|
||||||
|
"start_seconds": 10 * 3600,
|
||||||
|
"end_seconds": 14 * 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert range_response.status_code == 200
|
||||||
|
assert range_response.content == b"MID\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_mqtt_raw_by_day(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-x",
|
||||||
|
topic="terminal/device-x/raw",
|
||||||
|
payload=b"X",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 14, 20, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-b",
|
||||||
|
topic="terminal/device-b/raw",
|
||||||
|
payload=b"B",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 12, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-c",
|
||||||
|
topic="terminal/device-c/raw",
|
||||||
|
payload=b"C",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 16, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/day/2026-03-15",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["deleted_count"] == 3
|
||||||
|
assert _count_raw_rows(db) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_all_mqtt_raw_requires_confirmation(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
_reset_raw_data(db)
|
||||||
|
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-a",
|
||||||
|
topic="terminal/device-a/raw",
|
||||||
|
payload=b"A",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 15, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
insert_raw_message(
|
||||||
|
session=db,
|
||||||
|
device_id="device-b",
|
||||||
|
topic="terminal/device-b/raw",
|
||||||
|
payload=b"B",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
received_at=datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
reject_response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/all",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert reject_response.status_code == 400
|
||||||
|
assert _count_raw_rows(db) == 2
|
||||||
|
|
||||||
|
confirmed_response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/mqtt-raw/all?confirm=true",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert confirmed_response.status_code == 200
|
||||||
|
content = confirmed_response.json()
|
||||||
|
assert content["deleted_count"] == 2
|
||||||
|
assert _count_raw_rows(db) == 0
|
||||||
9
bun.lock
9
bun.lock
@@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@@ -250,6 +251,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||||
@@ -848,6 +851,10 @@
|
|||||||
|
|
||||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
@@ -928,6 +935,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ export const Body_login_login_access_tokenSchema = {
|
|||||||
title: 'Body_login-login_access_token'
|
title: 'Body_login-login_access_token'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const DeleteResultSchema = {
|
||||||
|
properties: {
|
||||||
|
deleted_count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Deleted Count'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Message'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['deleted_count', 'message'],
|
||||||
|
title: 'DeleteResult'
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const HTTPValidationErrorSchema = {
|
export const HTTPValidationErrorSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -208,6 +224,66 @@ export const MessageSchema = {
|
|||||||
title: 'Message'
|
title: 'Message'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const MqttRawDaySummarySchema = {
|
||||||
|
properties: {
|
||||||
|
day: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date',
|
||||||
|
title: 'Day'
|
||||||
|
},
|
||||||
|
message_count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Message Count'
|
||||||
|
},
|
||||||
|
first_received_at: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'First Received At'
|
||||||
|
},
|
||||||
|
last_received_at: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Last Received At'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['day', 'message_count'],
|
||||||
|
title: 'MqttRawDaySummary'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MqttRawDaysPublicSchema = {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
items: {
|
||||||
|
'$ref': '#/components/schemas/MqttRawDaySummary'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Data'
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Count'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['data', 'count'],
|
||||||
|
title: 'MqttRawDaysPublic'
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const NewPasswordSchema = {
|
export const NewPasswordSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
token: {
|
token: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import type { CancelablePromise } from './core/CancelablePromise';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
import { request as __request } from './core/request';
|
import { request as __request } from './core/request';
|
||||||
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MqttRawListDaysData, MqttRawListDaysResponse, MqttRawDownloadData, MqttRawDownloadResponse, MqttRawDeleteDayData, MqttRawDeleteDayResponse, MqttRawDeleteAllData, MqttRawDeleteAllResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||||
|
|
||||||
export class LocationsService {
|
export class LocationsService {
|
||||||
/**
|
/**
|
||||||
@@ -213,6 +213,94 @@ export class LoginService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MqttRawService {
|
||||||
|
/**
|
||||||
|
* List Mqtt Raw Days
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.skip
|
||||||
|
* @param data.limit
|
||||||
|
* @returns MqttRawDaysPublic Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static listDays(data: MqttRawListDaysData = {}): CancelablePromise<MqttRawListDaysResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/mqtt-raw/days',
|
||||||
|
query: {
|
||||||
|
skip: data.skip,
|
||||||
|
limit: data.limit
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download Mqtt Raw
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.day
|
||||||
|
* @param data.startSeconds
|
||||||
|
* @param data.endSeconds
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static download(data: MqttRawDownloadData): CancelablePromise<MqttRawDownloadResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/mqtt-raw/download',
|
||||||
|
query: {
|
||||||
|
day: data.day,
|
||||||
|
start_seconds: data.startSeconds,
|
||||||
|
end_seconds: data.endSeconds
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Mqtt Raw By Day
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.day
|
||||||
|
* @returns DeleteResult Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static deleteDay(data: MqttRawDeleteDayData): CancelablePromise<MqttRawDeleteDayResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/mqtt-raw/day/{day}',
|
||||||
|
path: {
|
||||||
|
day: data.day
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete All Mqtt Raw
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.confirm
|
||||||
|
* @returns DeleteResult Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static deleteAll(data: MqttRawDeleteAllData = {}): CancelablePromise<MqttRawDeleteAllResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/mqtt-raw/all',
|
||||||
|
query: {
|
||||||
|
confirm: data.confirm
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PrivateService {
|
export class PrivateService {
|
||||||
/**
|
/**
|
||||||
* Create User
|
* Create User
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export type Body_login_login_access_token = {
|
|||||||
client_secret?: (string | null);
|
client_secret?: (string | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeleteResult = {
|
||||||
|
deleted_count: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type HTTPValidationError = {
|
export type HTTPValidationError = {
|
||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
@@ -40,6 +45,18 @@ export type Message = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MqttRawDaysPublic = {
|
||||||
|
data: Array<MqttRawDaySummary>;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDaySummary = {
|
||||||
|
day: string;
|
||||||
|
message_count: number;
|
||||||
|
first_received_at?: (string | null);
|
||||||
|
last_received_at?: (string | null);
|
||||||
|
};
|
||||||
|
|
||||||
export type NewPassword = {
|
export type NewPassword = {
|
||||||
token: string;
|
token: string;
|
||||||
new_password: string;
|
new_password: string;
|
||||||
@@ -171,6 +188,33 @@ export type LoginRecoverPasswordHtmlContentData = {
|
|||||||
|
|
||||||
export type LoginRecoverPasswordHtmlContentResponse = (string);
|
export type LoginRecoverPasswordHtmlContentResponse = (string);
|
||||||
|
|
||||||
|
export type MqttRawListDaysData = {
|
||||||
|
limit?: number;
|
||||||
|
skip?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawListDaysResponse = (MqttRawDaysPublic);
|
||||||
|
|
||||||
|
export type MqttRawDownloadData = {
|
||||||
|
day: string;
|
||||||
|
endSeconds?: (number | null);
|
||||||
|
startSeconds?: (number | null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDownloadResponse = (unknown);
|
||||||
|
|
||||||
|
export type MqttRawDeleteDayData = {
|
||||||
|
day: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDeleteDayResponse = (DeleteResult);
|
||||||
|
|
||||||
|
export type MqttRawDeleteAllData = {
|
||||||
|
confirm?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MqttRawDeleteAllResponse = (DeleteResult);
|
||||||
|
|
||||||
export type PrivateCreateUserData = {
|
export type PrivateCreateUserData = {
|
||||||
requestBody: PrivateUserCreate;
|
requestBody: PrivateUserCreate;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home, MapPin, Satellite, Users } from "lucide-react"
|
import { FileDown, Home, MapPin, Satellite, Users } from "lucide-react"
|
||||||
|
|
||||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||||
import { Logo } from "@/components/Common/Logo"
|
import { Logo } from "@/components/Common/Logo"
|
||||||
@@ -16,6 +16,7 @@ const baseItems: Item[] = [
|
|||||||
{ icon: Home, title: "Dashboard", path: "/" },
|
{ icon: Home, title: "Dashboard", path: "/" },
|
||||||
{ icon: MapPin, title: "Locations", path: "/locations" },
|
{ icon: MapPin, title: "Locations", path: "/locations" },
|
||||||
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
|
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
|
||||||
|
{ icon: FileDown, title: "MQTT Raw", path: "/mqtt-raw" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
31
frontend/src/components/ui/slider.tsx
Normal file
31
frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{props.value?.map((_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
key={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
26
frontend/src/lib/auth-error.ts
Normal file
26
frontend/src/lib/auth-error.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const AUTH_EXPIRED_STATUSES = new Set([401, 403])
|
||||||
|
const USER_NOT_FOUND_DETAIL = "User not found"
|
||||||
|
|
||||||
|
const extractDetail = (body: unknown): unknown => {
|
||||||
|
if (typeof body === "object" && body !== null && "detail" in body) {
|
||||||
|
return (body as { detail?: unknown }).detail
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldHandleAuthError = (
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
): boolean => {
|
||||||
|
if (AUTH_EXPIRED_STATUSES.has(status)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return status === 404 && extractDetail(body) === USER_NOT_FOUND_DETAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAuthAndRedirectToLogin = (): void => {
|
||||||
|
localStorage.removeItem("access_token")
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
window.location.href = "/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ import { ApiError, OpenAPI } from "./client"
|
|||||||
import { ThemeProvider } from "./components/theme-provider"
|
import { ThemeProvider } from "./components/theme-provider"
|
||||||
import { Toaster } from "./components/ui/sonner"
|
import { Toaster } from "./components/ui/sonner"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
import {
|
||||||
|
clearAuthAndRedirectToLogin,
|
||||||
|
shouldHandleAuthError,
|
||||||
|
} from "./lib/auth-error"
|
||||||
import { routeTree } from "./routeTree.gen"
|
import { routeTree } from "./routeTree.gen"
|
||||||
|
|
||||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
||||||
@@ -18,10 +22,21 @@ OpenAPI.TOKEN = async () => {
|
|||||||
return localStorage.getItem("access_token") || ""
|
return localStorage.getItem("access_token") || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OpenAPI.interceptors.response.use((response) => {
|
||||||
|
if (shouldHandleAuthError(response.status, response.data)) {
|
||||||
|
clearAuthAndRedirectToLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
|
||||||
const handleApiError = (error: Error) => {
|
const handleApiError = (error: Error) => {
|
||||||
if (error instanceof ApiError && [401, 403].includes(error.status)) {
|
if (!(error instanceof ApiError)) {
|
||||||
localStorage.removeItem("access_token")
|
return
|
||||||
window.location.href = "/login"
|
}
|
||||||
|
|
||||||
|
if (shouldHandleAuthError(error.status, error.body)) {
|
||||||
|
clearAuthAndRedirectToLogin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
|||||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
|
import { Route as LayoutMqttRawRouteImport } from './routes/_layout/mqtt-raw'
|
||||||
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
||||||
import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor'
|
import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor'
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
@@ -54,6 +55,11 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutMqttRawRoute = LayoutMqttRawRouteImport.update({
|
||||||
|
id: '/mqtt-raw',
|
||||||
|
path: '/mqtt-raw',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
||||||
id: '/locations',
|
id: '/locations',
|
||||||
path: '/locations',
|
path: '/locations',
|
||||||
@@ -79,6 +85,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||||
'/locations': typeof LayoutLocationsRoute
|
'/locations': typeof LayoutLocationsRoute
|
||||||
|
'/mqtt-raw': typeof LayoutMqttRawRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -89,6 +96,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||||
'/locations': typeof LayoutLocationsRoute
|
'/locations': typeof LayoutLocationsRoute
|
||||||
|
'/mqtt-raw': typeof LayoutMqttRawRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/': typeof LayoutIndexRoute
|
'/': typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -102,6 +110,7 @@ export interface FileRoutesById {
|
|||||||
'/_layout/admin': typeof LayoutAdminRoute
|
'/_layout/admin': typeof LayoutAdminRoute
|
||||||
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
|
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||||
'/_layout/locations': typeof LayoutLocationsRoute
|
'/_layout/locations': typeof LayoutLocationsRoute
|
||||||
|
'/_layout/mqtt-raw': typeof LayoutMqttRawRoute
|
||||||
'/_layout/settings': typeof LayoutSettingsRoute
|
'/_layout/settings': typeof LayoutSettingsRoute
|
||||||
'/_layout/': typeof LayoutIndexRoute
|
'/_layout/': typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -116,6 +125,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin'
|
| '/admin'
|
||||||
| '/gnss-monitor'
|
| '/gnss-monitor'
|
||||||
| '/locations'
|
| '/locations'
|
||||||
|
| '/mqtt-raw'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -126,6 +136,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin'
|
| '/admin'
|
||||||
| '/gnss-monitor'
|
| '/gnss-monitor'
|
||||||
| '/locations'
|
| '/locations'
|
||||||
|
| '/mqtt-raw'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/'
|
| '/'
|
||||||
id:
|
id:
|
||||||
@@ -138,6 +149,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_layout/admin'
|
| '/_layout/admin'
|
||||||
| '/_layout/gnss-monitor'
|
| '/_layout/gnss-monitor'
|
||||||
| '/_layout/locations'
|
| '/_layout/locations'
|
||||||
|
| '/_layout/mqtt-raw'
|
||||||
| '/_layout/settings'
|
| '/_layout/settings'
|
||||||
| '/_layout/'
|
| '/_layout/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -201,6 +213,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/mqtt-raw': {
|
||||||
|
id: '/_layout/mqtt-raw'
|
||||||
|
path: '/mqtt-raw'
|
||||||
|
fullPath: '/mqtt-raw'
|
||||||
|
preLoaderRoute: typeof LayoutMqttRawRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_layout/locations': {
|
'/_layout/locations': {
|
||||||
id: '/_layout/locations'
|
id: '/_layout/locations'
|
||||||
path: '/locations'
|
path: '/locations'
|
||||||
@@ -229,6 +248,7 @@ interface LayoutRouteChildren {
|
|||||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||||
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
|
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
|
||||||
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
||||||
|
LayoutMqttRawRoute: typeof LayoutMqttRawRoute
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -237,6 +257,7 @@ const LayoutRouteChildren: LayoutRouteChildren = {
|
|||||||
LayoutAdminRoute: LayoutAdminRoute,
|
LayoutAdminRoute: LayoutAdminRoute,
|
||||||
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
|
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
|
||||||
LayoutLocationsRoute: LayoutLocationsRoute,
|
LayoutLocationsRoute: LayoutLocationsRoute,
|
||||||
|
LayoutMqttRawRoute: LayoutMqttRawRoute,
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
411
frontend/src/routes/_layout/mqtt-raw.tsx
Normal file
411
frontend/src/routes/_layout/mqtt-raw.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { type MqttRawDaySummary, MqttRawService } from "@/client"
|
||||||
|
import { DataTable } from "@/components/Common/DataTable"
|
||||||
|
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { LoadingButton } from "@/components/ui/loading-button"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
|
||||||
|
const MINUTE_STEPS_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/mqtt-raw")({
|
||||||
|
component: MqttRawPage,
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: "MQTT Raw Data - FastAPI Template",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toUtc8Date(value: Date): Date {
|
||||||
|
const utcMillis = value.getTime() + value.getTimezoneOffset() * 60 * 1000
|
||||||
|
return new Date(utcMillis + 8 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIsoDateUtc8(): string {
|
||||||
|
const now = new Date()
|
||||||
|
return toUtc8Date(now).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
const utc8 = toUtc8Date(new Date(value))
|
||||||
|
return `${utc8.toISOString().slice(0, 19).replace("T", " ")} (UTC+8)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinuteLabel(minute: number): string {
|
||||||
|
const clamped = Math.max(0, Math.min(minute, MINUTE_STEPS_PER_DAY))
|
||||||
|
const hours = Math.floor(clamped / 60)
|
||||||
|
const minutes = clamped % 60
|
||||||
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDownloadBlob(value: unknown): Blob {
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return new Blob([value], { type: "application/octet-stream" })
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return new Blob([value], { type: "application/octet-stream" })
|
||||||
|
}
|
||||||
|
return new Blob([JSON.stringify(value)], { type: "application/octet-stream" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function MqttRawPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
|
||||||
|
const [selectedDay, setSelectedDay] = useState(todayIsoDateUtc8())
|
||||||
|
const [rangeStartMinute, setRangeStartMinute] = useState(0)
|
||||||
|
const [rangeEndMinute, setRangeEndMinute] = useState(MINUTE_STEPS_PER_DAY)
|
||||||
|
const [deleteDayOpen, setDeleteDayOpen] = useState(false)
|
||||||
|
const [deleteAllOpen, setDeleteAllOpen] = useState(false)
|
||||||
|
const [deleteAllConfirmText, setDeleteAllConfirmText] = useState("")
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: daysResponse,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryFn: () => MqttRawService.listDays({ limit: 200, skip: 0 }),
|
||||||
|
queryKey: ["mqtt-raw-days"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const days = daysResponse?.data ?? []
|
||||||
|
const isFullDayRange =
|
||||||
|
rangeStartMinute === 0 && rangeEndMinute === MINUTE_STEPS_PER_DAY
|
||||||
|
const startSeconds = rangeStartMinute * 60
|
||||||
|
const endSeconds = rangeEndMinute * 60
|
||||||
|
|
||||||
|
const downloadMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const data = await MqttRawService.download({
|
||||||
|
day: selectedDay,
|
||||||
|
startSeconds: isFullDayRange ? undefined : startSeconds,
|
||||||
|
endSeconds: isFullDayRange ? undefined : endSeconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filename = isFullDayRange
|
||||||
|
? `mqtt_raw_${selectedDay}.txt`
|
||||||
|
: `mqtt_raw_${selectedDay}_${startSeconds}_${endSeconds}.txt`
|
||||||
|
return {
|
||||||
|
blob: toDownloadBlob(data),
|
||||||
|
filename,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: ({ blob, filename }) => {
|
||||||
|
triggerFileDownload(blob, filename)
|
||||||
|
showSuccessToast("Raw MQTT data downloaded")
|
||||||
|
},
|
||||||
|
onError: (downloadError) => {
|
||||||
|
const message =
|
||||||
|
downloadError instanceof Error
|
||||||
|
? downloadError.message
|
||||||
|
: "Download failed"
|
||||||
|
showErrorToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteDayMutation = useMutation({
|
||||||
|
mutationFn: () => MqttRawService.deleteDay({ day: selectedDay }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
showSuccessToast(result.message)
|
||||||
|
setDeleteDayOpen(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
|
||||||
|
},
|
||||||
|
onError: (deleteError) => {
|
||||||
|
const message =
|
||||||
|
deleteError instanceof Error ? deleteError.message : "Delete failed"
|
||||||
|
showErrorToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteAllMutation = useMutation({
|
||||||
|
mutationFn: () => MqttRawService.deleteAll({ confirm: true }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
showSuccessToast(result.message)
|
||||||
|
setDeleteAllConfirmText("")
|
||||||
|
setDeleteAllOpen(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
|
||||||
|
},
|
||||||
|
onError: (deleteError) => {
|
||||||
|
const message =
|
||||||
|
deleteError instanceof Error ? deleteError.message : "Delete failed"
|
||||||
|
showErrorToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<MqttRawDaySummary>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "day",
|
||||||
|
header: "Day (UTC+8)",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
className="font-mono px-0"
|
||||||
|
onClick={() => setSelectedDay(row.original.day)}
|
||||||
|
variant="link"
|
||||||
|
>
|
||||||
|
{row.original.day}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "message_count",
|
||||||
|
header: "Messages",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.message_count}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "first_received_at",
|
||||||
|
header: "First Message",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.first_received_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "last_received_at",
|
||||||
|
header: "Last Message",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.last_received_at),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">MQTT Raw Data</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Download saved payload bytes by UTC+8 day/time range and clean old
|
||||||
|
data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Download</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Pick a UTC+8 day, then download the full day or a custom time range.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Date (UTC+8)</p>
|
||||||
|
<Input
|
||||||
|
onChange={(event) => setSelectedDay(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={selectedDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setRangeStartMinute(0)
|
||||||
|
setRangeEndMinute(MINUTE_STEPS_PER_DAY)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Full Day
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
loading={downloadMutation.isPending}
|
||||||
|
onClick={() => downloadMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
Start: {formatMinuteLabel(rangeStartMinute)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
End: {formatMinuteLabel(rangeEndMinute)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
max={MINUTE_STEPS_PER_DAY}
|
||||||
|
min={0}
|
||||||
|
minStepsBetweenThumbs={1}
|
||||||
|
onValueChange={(values) => {
|
||||||
|
if (values.length !== 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRangeStartMinute(values[0])
|
||||||
|
setRangeEndMinute(values[1])
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
value={[rangeStartMinute, rangeEndMinute]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isFullDayRange
|
||||||
|
? "Current selection: full day (00:00-24:00, UTC+8)."
|
||||||
|
: `Current selection: ${formatMinuteLabel(rangeStartMinute)}-${formatMinuteLabel(rangeEndMinute)}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Delete</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Delete data for one UTC+8 day or delete all saved MQTT raw data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => setDeleteDayOpen(true)}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete {selectedDay}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDeleteAllOpen(true)}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete All Data
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Saved Days (UTC+8)</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Click a day to load it into the controls above.
|
||||||
|
</p>
|
||||||
|
{isPending ? (
|
||||||
|
<PendingSkeleton />
|
||||||
|
) : isError ? (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load data days."}
|
||||||
|
</div>
|
||||||
|
) : days.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-12 rounded-lg border">
|
||||||
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">No raw data saved yet</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
MQTT payloads will appear here after ingestion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={days} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog onOpenChange={setDeleteDayOpen} open={deleteDayOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Day Data</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Delete all MQTT raw data for {selectedDay} (UTC+8). This action
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button disabled={deleteDayMutation.isPending} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton
|
||||||
|
loading={deleteDayMutation.isPending}
|
||||||
|
onClick={() => deleteDayMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete Day
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog onOpenChange={setDeleteAllOpen} open={deleteAllOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete All MQTT Raw Data</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will delete all saved raw MQTT payloads. To confirm, type
|
||||||
|
<span className="font-mono font-semibold"> DELETE ALL</span>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
onChange={(event) => setDeleteAllConfirmText(event.target.value)}
|
||||||
|
placeholder="DELETE ALL"
|
||||||
|
value={deleteAllConfirmText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button disabled={deleteAllMutation.isPending} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton
|
||||||
|
disabled={deleteAllConfirmText !== "DELETE ALL"}
|
||||||
|
loading={deleteAllMutation.isPending}
|
||||||
|
onClick={() => deleteAllMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete Everything
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user