feat: add mqtt raw data management with UTC+8 range UI
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s

This commit is contained in:
魏风
2026-03-20 17:46:37 +08:00
parent e81aadcd99
commit ff971e2cea
15 changed files with 1279 additions and 7 deletions

View File

@@ -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":

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

View File

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

View 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

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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<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 {
/**
* Create User

View File

@@ -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<ValidationError>;
};
@@ -40,6 +45,18 @@ export type Message = {
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 = {
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;
};

View File

@@ -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() {

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

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

View File

@@ -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({

View File

@@ -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,
}

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