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

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