diff --git a/backend/app/api/main.py b/backend/app/api/main.py index ff1e1cb..9b8c989 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,14 @@ 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 api_router = APIRouter() @@ -9,6 +17,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(locations.router) api_router.include_router(telemetry.router) +api_router.include_router(mqtt_raw.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/mqtt_raw.py b/backend/app/api/routes/mqtt_raw.py new file mode 100644 index 0000000..fcd9c2b --- /dev/null +++ b/backend/app/api/routes/mqtt_raw.py @@ -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", + ) diff --git a/backend/app/models.py b/backend/app/models.py index 1dc3847..e61a315 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timezone +from datetime import date, datetime, timezone from typing import Any from pydantic import EmailStr @@ -111,6 +111,18 @@ class LocationsPublic(SQLModel): 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): __tablename__ = "terminal_message_raw" @@ -186,6 +198,11 @@ class Message(SQLModel): message: str +class DeleteResult(SQLModel): + deleted_count: int + message: str + + # JSON payload containing access token class Token(SQLModel): access_token: str diff --git a/backend/tests/api/routes/test_mqtt_raw.py b/backend/tests/api/routes/test_mqtt_raw.py new file mode 100644 index 0000000..82fef46 --- /dev/null +++ b/backend/tests/api/routes/test_mqtt_raw.py @@ -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 diff --git a/bun.lock b/bun.lock index f2f7b4b..c7e36a1 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@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-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-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-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-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-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-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=="], diff --git a/frontend/package.json b/frontend/package.json index c9b346d..4ed71ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 41d0eda..172ba3f 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -57,6 +57,22 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } 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 = { properties: { detail: { @@ -208,6 +224,66 @@ export const MessageSchema = { title: 'Message' } 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 = { properties: { token: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 6b09ee9..59bbde8 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; 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 { /** @@ -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 { + 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 { + 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 { + 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 { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/mqtt-raw/all', + query: { + confirm: data.confirm + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class PrivateService { /** * Create User diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 856cc86..f47bb0f 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,11 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type DeleteResult = { + deleted_count: number; + message: string; +}; + export type HTTPValidationError = { detail?: Array; }; @@ -40,6 +45,18 @@ export type Message = { message: string; }; +export type MqttRawDaysPublic = { + data: Array; + count: number; +}; + +export type MqttRawDaySummary = { + day: string; + message_count: number; + first_received_at?: (string | null); + last_received_at?: (string | null); +}; + export type NewPassword = { token: string; new_password: string; @@ -171,6 +188,33 @@ export type LoginRecoverPasswordHtmlContentData = { 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 = { requestBody: PrivateUserCreate; }; diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 6014804..a05f10d 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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 { Logo } from "@/components/Common/Logo" @@ -16,6 +16,7 @@ const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/" }, { icon: MapPin, title: "Locations", path: "/locations" }, { icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" }, + { icon: FileDown, title: "MQTT Raw", path: "/mqtt-raw" }, ] export function AppSidebar() { diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx new file mode 100644 index 0000000..add799e --- /dev/null +++ b/frontend/src/components/ui/slider.tsx @@ -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) { + return ( + + + + + {props.value?.map((_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/frontend/src/lib/auth-error.ts b/frontend/src/lib/auth-error.ts new file mode 100644 index 0000000..50e825e --- /dev/null +++ b/frontend/src/lib/auth-error.ts @@ -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" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8afe946..c4ff907 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,10 @@ import { ApiError, OpenAPI } from "./client" import { ThemeProvider } from "./components/theme-provider" import { Toaster } from "./components/ui/sonner" import "./index.css" +import { + clearAuthAndRedirectToLogin, + shouldHandleAuthError, +} from "./lib/auth-error" import { routeTree } from "./routeTree.gen" OpenAPI.BASE = import.meta.env.VITE_API_URL @@ -18,10 +22,21 @@ OpenAPI.TOKEN = async () => { return localStorage.getItem("access_token") || "" } +OpenAPI.interceptors.response.use((response) => { + if (shouldHandleAuthError(response.status, response.data)) { + clearAuthAndRedirectToLogin() + } + + return response +}) + const handleApiError = (error: Error) => { - if (error instanceof ApiError && [401, 403].includes(error.status)) { - localStorage.removeItem("access_token") - window.location.href = "/login" + if (!(error instanceof ApiError)) { + return + } + + if (shouldHandleAuthError(error.status, error.body)) { + clearAuthAndRedirectToLogin() } } const queryClient = new QueryClient({ diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 048ba49..10a5939 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' 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 LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' @@ -54,6 +55,11 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ path: '/settings', getParentRoute: () => LayoutRoute, } as any) +const LayoutMqttRawRoute = LayoutMqttRawRouteImport.update({ + id: '/mqtt-raw', + path: '/mqtt-raw', + getParentRoute: () => LayoutRoute, +} as any) const LayoutLocationsRoute = LayoutLocationsRouteImport.update({ id: '/locations', path: '/locations', @@ -79,6 +85,7 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAdminRoute '/gnss-monitor': typeof LayoutGnssMonitorRoute '/locations': typeof LayoutLocationsRoute + '/mqtt-raw': typeof LayoutMqttRawRoute '/settings': typeof LayoutSettingsRoute } export interface FileRoutesByTo { @@ -89,6 +96,7 @@ export interface FileRoutesByTo { '/admin': typeof LayoutAdminRoute '/gnss-monitor': typeof LayoutGnssMonitorRoute '/locations': typeof LayoutLocationsRoute + '/mqtt-raw': typeof LayoutMqttRawRoute '/settings': typeof LayoutSettingsRoute '/': typeof LayoutIndexRoute } @@ -102,6 +110,7 @@ export interface FileRoutesById { '/_layout/admin': typeof LayoutAdminRoute '/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute '/_layout/locations': typeof LayoutLocationsRoute + '/_layout/mqtt-raw': typeof LayoutMqttRawRoute '/_layout/settings': typeof LayoutSettingsRoute '/_layout/': typeof LayoutIndexRoute } @@ -116,6 +125,7 @@ export interface FileRouteTypes { | '/admin' | '/gnss-monitor' | '/locations' + | '/mqtt-raw' | '/settings' fileRoutesByTo: FileRoutesByTo to: @@ -126,6 +136,7 @@ export interface FileRouteTypes { | '/admin' | '/gnss-monitor' | '/locations' + | '/mqtt-raw' | '/settings' | '/' id: @@ -138,6 +149,7 @@ export interface FileRouteTypes { | '/_layout/admin' | '/_layout/gnss-monitor' | '/_layout/locations' + | '/_layout/mqtt-raw' | '/_layout/settings' | '/_layout/' fileRoutesById: FileRoutesById @@ -201,6 +213,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsRouteImport parentRoute: typeof LayoutRoute } + '/_layout/mqtt-raw': { + id: '/_layout/mqtt-raw' + path: '/mqtt-raw' + fullPath: '/mqtt-raw' + preLoaderRoute: typeof LayoutMqttRawRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/locations': { id: '/_layout/locations' path: '/locations' @@ -229,6 +248,7 @@ interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute LayoutLocationsRoute: typeof LayoutLocationsRoute + LayoutMqttRawRoute: typeof LayoutMqttRawRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute } @@ -237,6 +257,7 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, LayoutGnssMonitorRoute: LayoutGnssMonitorRoute, LayoutLocationsRoute: LayoutLocationsRoute, + LayoutMqttRawRoute: LayoutMqttRawRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, } diff --git a/frontend/src/routes/_layout/mqtt-raw.tsx b/frontend/src/routes/_layout/mqtt-raw.tsx new file mode 100644 index 0000000..4530dbe --- /dev/null +++ b/frontend/src/routes/_layout/mqtt-raw.tsx @@ -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[]>( + () => [ + { + accessorKey: "day", + header: "Day (UTC+8)", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "message_count", + header: "Messages", + cell: ({ row }) => ( + {row.original.message_count} + ), + }, + { + 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 ( +
+
+

MQTT Raw Data

+

+ Download saved payload bytes by UTC+8 day/time range and clean old + data. +

+
+ + + + Download + + Pick a UTC+8 day, then download the full day or a custom time range. + + + +
+
+

Date (UTC+8)

+ setSelectedDay(event.target.value)} + type="date" + value={selectedDay} + /> +
+
+ + downloadMutation.mutate()} + type="button" + > + Download + +
+
+ +
+
+ + Start: {formatMinuteLabel(rangeStartMinute)} + + + End: {formatMinuteLabel(rangeEndMinute)} + +
+ { + if (values.length !== 2) { + return + } + setRangeStartMinute(values[0]) + setRangeEndMinute(values[1]) + }} + step={1} + value={[rangeStartMinute, rangeEndMinute]} + /> +

+ {isFullDayRange + ? "Current selection: full day (00:00-24:00, UTC+8)." + : `Current selection: ${formatMinuteLabel(rangeStartMinute)}-${formatMinuteLabel(rangeEndMinute)}.`} +

+
+
+
+ + + + Delete + + Delete data for one UTC+8 day or delete all saved MQTT raw data. + + + + + + + + +
+

Saved Days (UTC+8)

+

+ Click a day to load it into the controls above. +

+ {isPending ? ( + + ) : isError ? ( +
+ {error instanceof Error + ? error.message + : "Failed to load data days."} +
+ ) : days.length === 0 ? ( +
+
+ +
+

No raw data saved yet

+

+ MQTT payloads will appear here after ingestion. +

+
+ ) : ( + + )} +
+ + + + + Delete Day Data + + Delete all MQTT raw data for {selectedDay} (UTC+8). This action + cannot be undone. + + + + + + + deleteDayMutation.mutate()} + type="button" + variant="destructive" + > + Delete Day + + + + + + + + + Delete All MQTT Raw Data + + This will delete all saved raw MQTT payloads. To confirm, type + DELETE ALL. + + +
+ setDeleteAllConfirmText(event.target.value)} + placeholder="DELETE ALL" + value={deleteAllConfirmText} + /> +
+ + + + + deleteAllMutation.mutate()} + type="button" + variant="destructive" + > + Delete Everything + + +
+
+
+ ) +}