feat: add mqtt raw data management with UTC+8 range UI
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
31
frontend/src/components/ui/slider.tsx
Normal file
31
frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
{props.value?.map((_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
26
frontend/src/lib/auth-error.ts
Normal file
26
frontend/src/lib/auth-error.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const AUTH_EXPIRED_STATUSES = new Set([401, 403])
|
||||
const USER_NOT_FOUND_DETAIL = "User not found"
|
||||
|
||||
const extractDetail = (body: unknown): unknown => {
|
||||
if (typeof body === "object" && body !== null && "detail" in body) {
|
||||
return (body as { detail?: unknown }).detail
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const shouldHandleAuthError = (
|
||||
status: number,
|
||||
body: unknown,
|
||||
): boolean => {
|
||||
if (AUTH_EXPIRED_STATUSES.has(status)) {
|
||||
return true
|
||||
}
|
||||
return status === 404 && extractDetail(body) === USER_NOT_FOUND_DETAIL
|
||||
}
|
||||
|
||||
export const clearAuthAndRedirectToLogin = (): void => {
|
||||
localStorage.removeItem("access_token")
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { ApiError, OpenAPI } from "./client"
|
||||
import { ThemeProvider } from "./components/theme-provider"
|
||||
import { 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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
411
frontend/src/routes/_layout/mqtt-raw.tsx
Normal file
411
frontend/src/routes/_layout/mqtt-raw.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { Search } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { type MqttRawDaySummary, MqttRawService } from "@/client"
|
||||
import { DataTable } from "@/components/Common/DataTable"
|
||||
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
|
||||
const MINUTE_STEPS_PER_DAY = 24 * 60
|
||||
|
||||
export const Route = createFileRoute("/_layout/mqtt-raw")({
|
||||
component: MqttRawPage,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "MQTT Raw Data - FastAPI Template",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
function toUtc8Date(value: Date): Date {
|
||||
const utcMillis = value.getTime() + value.getTimezoneOffset() * 60 * 1000
|
||||
return new Date(utcMillis + 8 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
function todayIsoDateUtc8(): string {
|
||||
const now = new Date()
|
||||
return toUtc8Date(now).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "-"
|
||||
}
|
||||
const utc8 = toUtc8Date(new Date(value))
|
||||
return `${utc8.toISOString().slice(0, 19).replace("T", " ")} (UTC+8)`
|
||||
}
|
||||
|
||||
function formatMinuteLabel(minute: number): string {
|
||||
const clamped = Math.max(0, Math.min(minute, MINUTE_STEPS_PER_DAY))
|
||||
const hours = Math.floor(clamped / 60)
|
||||
const minutes = clamped % 60
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function triggerFileDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function toDownloadBlob(value: unknown): Blob {
|
||||
if (value instanceof Blob) {
|
||||
return value
|
||||
}
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Blob([value], { type: "application/octet-stream" })
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Blob([value], { type: "application/octet-stream" })
|
||||
}
|
||||
return new Blob([JSON.stringify(value)], { type: "application/octet-stream" })
|
||||
}
|
||||
|
||||
function MqttRawPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
|
||||
const [selectedDay, setSelectedDay] = useState(todayIsoDateUtc8())
|
||||
const [rangeStartMinute, setRangeStartMinute] = useState(0)
|
||||
const [rangeEndMinute, setRangeEndMinute] = useState(MINUTE_STEPS_PER_DAY)
|
||||
const [deleteDayOpen, setDeleteDayOpen] = useState(false)
|
||||
const [deleteAllOpen, setDeleteAllOpen] = useState(false)
|
||||
const [deleteAllConfirmText, setDeleteAllConfirmText] = useState("")
|
||||
|
||||
const {
|
||||
data: daysResponse,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryFn: () => MqttRawService.listDays({ limit: 200, skip: 0 }),
|
||||
queryKey: ["mqtt-raw-days"],
|
||||
})
|
||||
|
||||
const days = daysResponse?.data ?? []
|
||||
const isFullDayRange =
|
||||
rangeStartMinute === 0 && rangeEndMinute === MINUTE_STEPS_PER_DAY
|
||||
const startSeconds = rangeStartMinute * 60
|
||||
const endSeconds = rangeEndMinute * 60
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const data = await MqttRawService.download({
|
||||
day: selectedDay,
|
||||
startSeconds: isFullDayRange ? undefined : startSeconds,
|
||||
endSeconds: isFullDayRange ? undefined : endSeconds,
|
||||
})
|
||||
|
||||
const filename = isFullDayRange
|
||||
? `mqtt_raw_${selectedDay}.txt`
|
||||
: `mqtt_raw_${selectedDay}_${startSeconds}_${endSeconds}.txt`
|
||||
return {
|
||||
blob: toDownloadBlob(data),
|
||||
filename,
|
||||
}
|
||||
},
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
triggerFileDownload(blob, filename)
|
||||
showSuccessToast("Raw MQTT data downloaded")
|
||||
},
|
||||
onError: (downloadError) => {
|
||||
const message =
|
||||
downloadError instanceof Error
|
||||
? downloadError.message
|
||||
: "Download failed"
|
||||
showErrorToast(message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteDayMutation = useMutation({
|
||||
mutationFn: () => MqttRawService.deleteDay({ day: selectedDay }),
|
||||
onSuccess: (result) => {
|
||||
showSuccessToast(result.message)
|
||||
setDeleteDayOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
|
||||
},
|
||||
onError: (deleteError) => {
|
||||
const message =
|
||||
deleteError instanceof Error ? deleteError.message : "Delete failed"
|
||||
showErrorToast(message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteAllMutation = useMutation({
|
||||
mutationFn: () => MqttRawService.deleteAll({ confirm: true }),
|
||||
onSuccess: (result) => {
|
||||
showSuccessToast(result.message)
|
||||
setDeleteAllConfirmText("")
|
||||
setDeleteAllOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
|
||||
},
|
||||
onError: (deleteError) => {
|
||||
const message =
|
||||
deleteError instanceof Error ? deleteError.message : "Delete failed"
|
||||
showErrorToast(message)
|
||||
},
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<MqttRawDaySummary>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "day",
|
||||
header: "Day (UTC+8)",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
className="font-mono px-0"
|
||||
onClick={() => setSelectedDay(row.original.day)}
|
||||
variant="link"
|
||||
>
|
||||
{row.original.day}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "message_count",
|
||||
header: "Messages",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.message_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "first_received_at",
|
||||
header: "First Message",
|
||||
cell: ({ row }) => formatDateTime(row.original.first_received_at),
|
||||
},
|
||||
{
|
||||
accessorKey: "last_received_at",
|
||||
header: "Last Message",
|
||||
cell: ({ row }) => formatDateTime(row.original.last_received_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">MQTT Raw Data</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Download saved payload bytes by UTC+8 day/time range and clean old
|
||||
data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Download</CardTitle>
|
||||
<CardDescription>
|
||||
Pick a UTC+8 day, then download the full day or a custom time range.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Date (UTC+8)</p>
|
||||
<Input
|
||||
onChange={(event) => setSelectedDay(event.target.value)}
|
||||
type="date"
|
||||
value={selectedDay}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRangeStartMinute(0)
|
||||
setRangeEndMinute(MINUTE_STEPS_PER_DAY)
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Full Day
|
||||
</Button>
|
||||
<LoadingButton
|
||||
loading={downloadMutation.isPending}
|
||||
onClick={() => downloadMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
Start: {formatMinuteLabel(rangeStartMinute)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
End: {formatMinuteLabel(rangeEndMinute)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
max={MINUTE_STEPS_PER_DAY}
|
||||
min={0}
|
||||
minStepsBetweenThumbs={1}
|
||||
onValueChange={(values) => {
|
||||
if (values.length !== 2) {
|
||||
return
|
||||
}
|
||||
setRangeStartMinute(values[0])
|
||||
setRangeEndMinute(values[1])
|
||||
}}
|
||||
step={1}
|
||||
value={[rangeStartMinute, rangeEndMinute]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isFullDayRange
|
||||
? "Current selection: full day (00:00-24:00, UTC+8)."
|
||||
: `Current selection: ${formatMinuteLabel(rangeStartMinute)}-${formatMinuteLabel(rangeEndMinute)}.`}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Delete</CardTitle>
|
||||
<CardDescription>
|
||||
Delete data for one UTC+8 day or delete all saved MQTT raw data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={() => setDeleteDayOpen(true)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete {selectedDay}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDeleteAllOpen(true)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete All Data
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Saved Days (UTC+8)</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Click a day to load it into the controls above.
|
||||
</p>
|
||||
{isPending ? (
|
||||
<PendingSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-sm text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load data days."}
|
||||
</div>
|
||||
) : days.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12 rounded-lg border">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">No raw data saved yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
MQTT payloads will appear here after ingestion.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={columns} data={days} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog onOpenChange={setDeleteDayOpen} open={deleteDayOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Day Data</DialogTitle>
|
||||
<DialogDescription>
|
||||
Delete all MQTT raw data for {selectedDay} (UTC+8). This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={deleteDayMutation.isPending} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton
|
||||
loading={deleteDayMutation.isPending}
|
||||
onClick={() => deleteDayMutation.mutate()}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete Day
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog onOpenChange={setDeleteAllOpen} open={deleteAllOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All MQTT Raw Data</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will delete all saved raw MQTT payloads. To confirm, type
|
||||
<span className="font-mono font-semibold"> DELETE ALL</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
onChange={(event) => setDeleteAllConfirmText(event.target.value)}
|
||||
placeholder="DELETE ALL"
|
||||
value={deleteAllConfirmText}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button disabled={deleteAllMutation.isPending} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton
|
||||
disabled={deleteAllConfirmText !== "DELETE ALL"}
|
||||
loading={deleteAllMutation.isPending}
|
||||
onClick={() => deleteAllMutation.mutate()}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete Everything
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user