UPDATE
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const resolveBackendBaseUrl = (): string => {
|
||||||
|
const publicApiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const serverApiUrl = process.env.ENVOY_GATEWAY_URL;
|
||||||
|
|
||||||
|
return (serverApiUrl || publicApiUrl || "http://node.crop-logic.ir").replace(/\/$/, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSensorExternalApiKey = (request: NextRequest): string | null => {
|
||||||
|
const requestApiKey = request.headers.get("x-api-key");
|
||||||
|
|
||||||
|
if (requestApiKey) {
|
||||||
|
return requestApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
process.env.SENSOR_EXTERNAL_API_KEY ||
|
||||||
|
process.env.SENSOR_EXTERNAL_API_LOGS_KEY ||
|
||||||
|
process.env.NEXT_PUBLIC_SENSOR_EXTERNAL_API_KEY ||
|
||||||
|
"12345"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const apiKey = resolveSensorExternalApiKey(request);
|
||||||
|
const authorization = request.headers.get("authorization");
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
detail:
|
||||||
|
"Sensor external API key is not configured on the frontend server.",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = request.nextUrl.searchParams.toString();
|
||||||
|
const endpoint = `${resolveBackendBaseUrl()}/api/sensor-external-api/logs/${
|
||||||
|
query ? `?${query}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...(authorization ? { Authorization: authorization } : {}),
|
||||||
|
"X-API-Key": apiKey,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"content-type": response.headers.get("content-type") || "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
detail: "Failed to fetch sensor external logs from backend.",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { apiClient } from "../client";
|
||||||
|
|
||||||
|
const SUMMARY_PREFIX = "/api/sensor-7-in-1";
|
||||||
|
const SENSORS_PREFIX = "/api/sensors";
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorSummaryMeta {
|
||||||
|
name: string;
|
||||||
|
physicalDeviceUuid: string | null;
|
||||||
|
sensorCatalogCode: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorValuesListItem {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
trendNumber: number;
|
||||||
|
trend?: "positive" | "negative";
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorValuesListResponse {
|
||||||
|
sensor?: SensorSummaryMeta | null;
|
||||||
|
sensors: SensorValuesListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorChartSeries {
|
||||||
|
name: string;
|
||||||
|
data: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorComparisonChartResponse {
|
||||||
|
currentValue: number;
|
||||||
|
vsLastWeek: string;
|
||||||
|
vsLastWeekValue?: number;
|
||||||
|
categories: string[];
|
||||||
|
series: SensorChartSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorRadarChartResponse {
|
||||||
|
labels: string[];
|
||||||
|
series: SensorChartSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sensor7SummaryData {
|
||||||
|
sensor?: SensorSummaryMeta | null;
|
||||||
|
sensorValuesList?: SensorValuesListResponse | null;
|
||||||
|
avgSoilMoisture?: Record<string, unknown> | null;
|
||||||
|
sensorRadarChart?: SensorRadarChartResponse | null;
|
||||||
|
sensorComparisonChart?: SensorComparisonChartResponse | null;
|
||||||
|
anomalyDetectionCard?: Record<string, unknown> | null;
|
||||||
|
soilMoistureHeatmap?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_COMPARISON_CHART: SensorComparisonChartResponse = {
|
||||||
|
currentValue: 0,
|
||||||
|
vsLastWeek: "+0.0%",
|
||||||
|
categories: [],
|
||||||
|
series: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RADAR_CHART: SensorRadarChartResponse = {
|
||||||
|
labels: [],
|
||||||
|
series: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_VALUES_LIST: SensorValuesListResponse = {
|
||||||
|
sensors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function extract<T>(response: ApiResponse<T> | T): T {
|
||||||
|
return response && typeof response === "object" && "data" in response
|
||||||
|
? (response as ApiResponse<T>).data
|
||||||
|
: (response as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSeries(series: unknown): SensorChartSeries[] {
|
||||||
|
if (!Array.isArray(series)) return [];
|
||||||
|
|
||||||
|
return series
|
||||||
|
.filter((item): item is { name?: unknown; data?: unknown } => !!item && typeof item === "object")
|
||||||
|
.map((item) => ({
|
||||||
|
name: typeof item.name === "string" ? item.name : "",
|
||||||
|
data: Array.isArray(item.data)
|
||||||
|
? item.data.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name || item.data.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeComparisonChartResponse(
|
||||||
|
data?: Partial<SensorComparisonChartResponse> | null,
|
||||||
|
): SensorComparisonChartResponse {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return { ...EMPTY_COMPARISON_CHART };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentValue:
|
||||||
|
typeof data.currentValue === "number" && Number.isFinite(data.currentValue)
|
||||||
|
? data.currentValue
|
||||||
|
: 0,
|
||||||
|
vsLastWeek: typeof data.vsLastWeek === "string" ? data.vsLastWeek : "+0.0%",
|
||||||
|
vsLastWeekValue:
|
||||||
|
typeof data.vsLastWeekValue === "number" && Number.isFinite(data.vsLastWeekValue)
|
||||||
|
? data.vsLastWeekValue
|
||||||
|
: undefined,
|
||||||
|
categories: Array.isArray(data.categories)
|
||||||
|
? data.categories.filter((value): value is string => typeof value === "string")
|
||||||
|
: [],
|
||||||
|
series: normalizeSeries(data.series),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRadarChartResponse(
|
||||||
|
data?: Partial<SensorRadarChartResponse> | null,
|
||||||
|
): SensorRadarChartResponse {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return { ...EMPTY_RADAR_CHART };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: Array.isArray(data.labels)
|
||||||
|
? data.labels.filter((value): value is string => typeof value === "string")
|
||||||
|
: [],
|
||||||
|
series: normalizeSeries(data.series),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeValuesListResponse(
|
||||||
|
data?: Partial<SensorValuesListResponse> | null,
|
||||||
|
): SensorValuesListResponse {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return { ...EMPTY_VALUES_LIST };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensors = Array.isArray(data.sensors)
|
||||||
|
? data.sensors
|
||||||
|
.filter((item): item is SensorValuesListItem => !!item && typeof item === "object")
|
||||||
|
.map((item) => {
|
||||||
|
const trend: SensorValuesListItem["trend"] =
|
||||||
|
item.trend === "negative"
|
||||||
|
? "negative"
|
||||||
|
: item.trend === "positive"
|
||||||
|
? "positive"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof item.id === "string" ? item.id : undefined,
|
||||||
|
title: typeof item.title === "string" ? item.title : "-",
|
||||||
|
subtitle: typeof item.subtitle === "string" ? item.subtitle : "-",
|
||||||
|
trendNumber:
|
||||||
|
typeof item.trendNumber === "number" && Number.isFinite(item.trendNumber)
|
||||||
|
? item.trendNumber
|
||||||
|
: 0,
|
||||||
|
trend,
|
||||||
|
unit: typeof item.unit === "string" ? item.unit : "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sensor: data.sensor ?? undefined,
|
||||||
|
sensors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sensor7Service = {
|
||||||
|
async getSummary(farmUuid: string): Promise<Sensor7SummaryData> {
|
||||||
|
const response = await apiClient.get<ApiResponse<Sensor7SummaryData> | Sensor7SummaryData>(
|
||||||
|
`${SUMMARY_PREFIX}/summary/?farm_uuid=${encodeURIComponent(farmUuid)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = extract(response) ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
sensorValuesList: normalizeValuesListResponse(data.sensorValuesList),
|
||||||
|
sensorComparisonChart: normalizeComparisonChartResponse(data.sensorComparisonChart),
|
||||||
|
sensorRadarChart: normalizeRadarChartResponse(data.sensorRadarChart),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComparisonChart(params: {
|
||||||
|
farmUuid: string;
|
||||||
|
range?: "7d" | "30d";
|
||||||
|
}): Promise<SensorComparisonChartResponse> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
farm_uuid: params.farmUuid,
|
||||||
|
range: params.range ?? "7d",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.get<Partial<SensorComparisonChartResponse>>(
|
||||||
|
`${SENSORS_PREFIX}/comparison-chart/?${searchParams.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeComparisonChartResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRadarChart(params: {
|
||||||
|
farmUuid: string;
|
||||||
|
range?: "today" | "7d" | "30d";
|
||||||
|
}): Promise<SensorRadarChartResponse> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
farm_uuid: params.farmUuid,
|
||||||
|
range: params.range ?? "7d",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.get<Partial<SensorRadarChartResponse>>(
|
||||||
|
`${SENSORS_PREFIX}/radar-chart/?${searchParams.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeRadarChartResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getValuesList(params: {
|
||||||
|
farmUuid: string;
|
||||||
|
range?: "1h" | "24h" | "7d";
|
||||||
|
}): Promise<SensorValuesListResponse> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
farm_uuid: params.farmUuid,
|
||||||
|
range: params.range ?? "7d",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.get<Partial<SensorValuesListResponse>>(
|
||||||
|
`${SENSORS_PREFIX}/values-list/?${searchParams.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeValuesListResponse(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import { apiClient } from "../client";
|
|
||||||
|
|
||||||
const PREFIX = "/api/sensor-external-api";
|
|
||||||
|
|
||||||
export interface SensorExternalFarmSensor {
|
export interface SensorExternalFarmSensor {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
sensor_catalog_uuid: string | null;
|
sensor_catalog_uuid: string | null;
|
||||||
@@ -49,13 +45,66 @@ export interface SensorExternalRequestLogsResponse {
|
|||||||
data: SensorExternalRequestLog[];
|
data: SensorExternalRequestLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SensorExternalLogsParams {
|
||||||
|
farmUuid: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
physicalDeviceUuid?: string;
|
||||||
|
sensorType?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAuthHeaders = (): Record<string, string> => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchLocalJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
errorData = (await response.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
errorData = { message: response.statusText };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
(typeof errorData.msg === "string" && errorData.msg) ||
|
||||||
|
(typeof errorData.detail === "string" && errorData.detail) ||
|
||||||
|
(typeof errorData.message === "string" && errorData.message) ||
|
||||||
|
"An error occurred";
|
||||||
|
|
||||||
|
throw {
|
||||||
|
message,
|
||||||
|
code: response.status,
|
||||||
|
details: errorData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
export const sensorExternalApiService = {
|
export const sensorExternalApiService = {
|
||||||
listRequestLogs(params: {
|
listRequestLogs(params: SensorExternalLogsParams): Promise<SensorExternalRequestLogsResponse> {
|
||||||
farmUuid: string;
|
const searchParams = new URLSearchParams({
|
||||||
page?: number;
|
farm_uuid: params.farmUuid,
|
||||||
pageSize?: number;
|
});
|
||||||
}): Promise<SensorExternalRequestLogsResponse> {
|
|
||||||
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid });
|
|
||||||
|
|
||||||
if (typeof params.page === "number") {
|
if (typeof params.page === "number") {
|
||||||
searchParams.set("page", String(params.page));
|
searchParams.set("page", String(params.page));
|
||||||
@@ -65,8 +114,24 @@ export const sensorExternalApiService = {
|
|||||||
searchParams.set("page_size", String(params.pageSize));
|
searchParams.set("page_size", String(params.pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiClient.get<SensorExternalRequestLogsResponse>(
|
if (params.physicalDeviceUuid) {
|
||||||
`${PREFIX}/logs/?${searchParams.toString()}`,
|
searchParams.set("physical_device_uuid", params.physicalDeviceUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.sensorType) {
|
||||||
|
searchParams.set("sensor_type", params.sensorType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.dateFrom) {
|
||||||
|
searchParams.set("date_from", params.dateFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.dateTo) {
|
||||||
|
searchParams.set("date_to", params.dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchLocalJson<SensorExternalRequestLogsResponse>(
|
||||||
|
`/api/sensor-external-api/logs/?${searchParams.toString()}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,9 +28,19 @@ import TableRow from "@mui/material/TableRow";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { useFarmHub } from "@/hooks/useFarmHub";
|
||||||
import {
|
import {
|
||||||
type SensorExternalCatalog,
|
normalizeComparisonChartResponse,
|
||||||
type SensorExternalFarmSensor,
|
normalizeRadarChartResponse,
|
||||||
|
normalizeValuesListResponse,
|
||||||
|
sensor7Service,
|
||||||
|
type Sensor7SummaryData,
|
||||||
|
type SensorComparisonChartResponse,
|
||||||
|
type SensorRadarChartResponse,
|
||||||
|
type SensorValuesListResponse,
|
||||||
|
} from "@/libs/api/services/sensor7Service";
|
||||||
|
import {
|
||||||
|
sensorExternalApiService,
|
||||||
type SensorExternalRequestLog,
|
type SensorExternalRequestLog,
|
||||||
} from "@/libs/api/services/sensorExternalApiService";
|
} from "@/libs/api/services/sensorExternalApiService";
|
||||||
import SensorComparisonChart from "@/views/dashboards/farm/SensorComparisonChart";
|
import SensorComparisonChart from "@/views/dashboards/farm/SensorComparisonChart";
|
||||||
@@ -40,13 +50,19 @@ import SensorHealthPanel from "@/views/dashboards/farm/shared-sensors/SensorHeal
|
|||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
const MOCK_FARM_UUID = "11111111-1111-1111-1111-111111111111";
|
const DEFAULT_COMPARISON_RANGE = "7d" as const;
|
||||||
|
const DEFAULT_RADAR_RANGE = "7d" as const;
|
||||||
|
const DEFAULT_VALUES_RANGE = "24h" as const;
|
||||||
|
|
||||||
|
const EMPTY_COMPARISON_CHART: SensorComparisonChartResponse =
|
||||||
|
normalizeComparisonChartResponse();
|
||||||
|
const EMPTY_RADAR_CHART: SensorRadarChartResponse = normalizeRadarChartResponse();
|
||||||
|
const EMPTY_VALUES_LIST: SensorValuesListResponse = normalizeValuesListResponse();
|
||||||
|
|
||||||
type StatusColor = "success" | "warning" | "error" | "info";
|
type StatusColor = "success" | "warning" | "error" | "info";
|
||||||
type AccentTone = "primary" | "success" | "warning" | "info";
|
type AccentTone = "primary" | "success" | "warning" | "info";
|
||||||
|
|
||||||
const formatPersianNumber = (value: number): string =>
|
const formatPersianNumber = (value: number): string => value.toLocaleString("fa-IR");
|
||||||
value.toLocaleString("fa-IR");
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null): string => {
|
const formatDateTime = (value?: string | null): string => {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
@@ -64,6 +80,7 @@ const formatDateTime = (value?: string | null): string => {
|
|||||||
const shortenUuid = (value?: string | null): string => {
|
const shortenUuid = (value?: string | null): string => {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
if (value.length <= 16) return value;
|
if (value.length <= 16) return value;
|
||||||
|
|
||||||
return `${value.slice(0, 8)}...${value.slice(-6)}`;
|
return `${value.slice(0, 8)}...${value.slice(-6)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,12 +89,8 @@ const formatScalar = (value: unknown): string => {
|
|||||||
if (value === undefined) return "undefined";
|
if (value === undefined) return "undefined";
|
||||||
if (Array.isArray(value)) return `[${value.length} item]`;
|
if (Array.isArray(value)) return `[${value.length} item]`;
|
||||||
if (typeof value === "object") return "{...}";
|
if (typeof value === "object") return "{...}";
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPayloadFieldCount = (payload?: Record<string, unknown> | null): number => {
|
return String(value);
|
||||||
if (!payload || typeof payload !== "object") return 0;
|
|
||||||
return Object.keys(payload).length;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPayloadPreview = (payload?: Record<string, unknown> | null): string => {
|
const formatPayloadPreview = (payload?: Record<string, unknown> | null): string => {
|
||||||
@@ -95,181 +108,58 @@ const formatPayloadPreview = (payload?: Record<string, unknown> | null): string
|
|||||||
return entries.length > 3 ? `${preview} | +${entries.length - 3}` : preview;
|
return entries.length > 3 ? `${preview} | +${entries.length - 3}` : preview;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeMetricLabel = (key: string): string =>
|
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
key
|
if (error && typeof error === "object") {
|
||||||
.replace(/_/g, " ")
|
const candidate = error as {
|
||||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
message?: unknown;
|
||||||
.trim();
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
const extractNumericPayloadEntries = (
|
if (typeof candidate.message === "string" && candidate.message) {
|
||||||
payload?: Record<string, unknown> | null,
|
return candidate.message;
|
||||||
): Array<[string, number]> => {
|
}
|
||||||
if (!payload || typeof payload !== "object") return [];
|
|
||||||
|
|
||||||
return Object.entries(payload).filter(
|
if (typeof candidate.details?.detail === "string" && candidate.details.detail) {
|
||||||
(entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]),
|
return candidate.details.detail;
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildMockSeries = (baseValue: number): number[] =>
|
const getStringRecordField = (
|
||||||
[0.92, 0.98, 0.94, 1.03, 0.97, 1.05, 1]
|
record: Record<string, unknown> | null | undefined,
|
||||||
.map((multiplier) => Number((baseValue * multiplier).toFixed(1)));
|
key: string,
|
||||||
|
): string | null => {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
const createMockCatalog = (
|
const value = record[key];
|
||||||
uuid: string,
|
|
||||||
code: string,
|
|
||||||
name: string,
|
|
||||||
returnedFields: string[],
|
|
||||||
): SensorExternalCatalog => ({
|
|
||||||
uuid,
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
description: `کاتالوگ نمونه برای ${name}`,
|
|
||||||
customizable_fields: [],
|
|
||||||
supported_power_sources: ["solar", "battery"],
|
|
||||||
returned_data_fields: returnedFields,
|
|
||||||
sample_payload: null,
|
|
||||||
is_active: true,
|
|
||||||
created_at: "2025-01-01T08:00:00Z",
|
|
||||||
updated_at: "2025-01-10T08:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMockSensor = (
|
return typeof value === "string" && value ? value : null;
|
||||||
uuid: string,
|
};
|
||||||
physicalDeviceUuid: string,
|
|
||||||
name: string,
|
|
||||||
sensorType: string,
|
|
||||||
catalogUuid: string,
|
|
||||||
): SensorExternalFarmSensor => ({
|
|
||||||
uuid,
|
|
||||||
sensor_catalog_uuid: catalogUuid,
|
|
||||||
physical_device_uuid: physicalDeviceUuid,
|
|
||||||
name,
|
|
||||||
sensor_type: sensorType,
|
|
||||||
is_active: true,
|
|
||||||
specifications: { protection: "IP68", install_depth_cm: 30 },
|
|
||||||
power_source: { primary: "solar", backup: "battery" },
|
|
||||||
created_at: "2025-01-01T08:00:00Z",
|
|
||||||
updated_at: "2025-01-10T08:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sensor7Catalog = createMockCatalog(
|
const mapSummaryValuesList = (
|
||||||
"catalog-7in1-01",
|
valuesList?: Sensor7SummaryData["sensorValuesList"] | null,
|
||||||
"SOIL-7IN1",
|
): SensorValuesListResponse => {
|
||||||
"کاتالوگ سنسور خاک 7 در 1",
|
if (!valuesList) {
|
||||||
["moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "potassium"],
|
return EMPTY_VALUES_LIST;
|
||||||
);
|
}
|
||||||
|
|
||||||
const weatherCatalog = createMockCatalog(
|
const sensors = Array.isArray(valuesList.sensors)
|
||||||
"catalog-weather-01",
|
? valuesList.sensors.map((item) => ({
|
||||||
"WEATHER-PRO",
|
id: item.id,
|
||||||
"کاتالوگ سنسور اقلیم",
|
title: item.subtitle || item.id || "-",
|
||||||
["air_temperature", "air_humidity", "wind_speed", "light"],
|
subtitle: item.title || "-",
|
||||||
);
|
trendNumber: item.trendNumber,
|
||||||
|
trend: item.trend,
|
||||||
const sensor7FarmSensor = createMockSensor(
|
unit: item.unit,
|
||||||
"farm-sensor-7in1-01",
|
}))
|
||||||
"device-7in1-0001",
|
: [];
|
||||||
"سنسور خاک 7 در 1 - بلوک شمالی",
|
|
||||||
"Soil 7 in 1",
|
|
||||||
sensor7Catalog.uuid,
|
|
||||||
);
|
|
||||||
|
|
||||||
const weatherFarmSensor = createMockSensor(
|
|
||||||
"farm-sensor-weather-01",
|
|
||||||
"device-weather-0002",
|
|
||||||
"سنسور اقلیم - گلخانه 1",
|
|
||||||
"Climate Station",
|
|
||||||
weatherCatalog.uuid,
|
|
||||||
);
|
|
||||||
|
|
||||||
const MOCK_LOGS: SensorExternalRequestLog[] = [
|
|
||||||
{
|
|
||||||
id: 108,
|
|
||||||
farm_uuid: MOCK_FARM_UUID,
|
|
||||||
sensor_catalog_uuid: sensor7Catalog.uuid,
|
|
||||||
physical_device_uuid: sensor7FarmSensor.physical_device_uuid,
|
|
||||||
farm_sensor: sensor7FarmSensor,
|
|
||||||
sensor_catalog: sensor7Catalog,
|
|
||||||
payload: {
|
|
||||||
moisture: 61,
|
|
||||||
temperature: 27.4,
|
|
||||||
humidity: 58,
|
|
||||||
ph: 6.7,
|
|
||||||
ec: 1.45,
|
|
||||||
nitrogen: 44,
|
|
||||||
potassium: 39,
|
|
||||||
},
|
|
||||||
created_at: "2025-05-02T11:20:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 107,
|
|
||||||
farm_uuid: MOCK_FARM_UUID,
|
|
||||||
sensor_catalog_uuid: sensor7Catalog.uuid,
|
|
||||||
physical_device_uuid: sensor7FarmSensor.physical_device_uuid,
|
|
||||||
farm_sensor: sensor7FarmSensor,
|
|
||||||
sensor_catalog: sensor7Catalog,
|
|
||||||
payload: {
|
|
||||||
moisture: 58,
|
|
||||||
temperature: 26.9,
|
|
||||||
humidity: 56,
|
|
||||||
ph: 6.6,
|
|
||||||
ec: 1.38,
|
|
||||||
nitrogen: 42,
|
|
||||||
potassium: 37,
|
|
||||||
},
|
|
||||||
created_at: "2025-05-02T08:15:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 106,
|
|
||||||
farm_uuid: MOCK_FARM_UUID,
|
|
||||||
sensor_catalog_uuid: sensor7Catalog.uuid,
|
|
||||||
physical_device_uuid: sensor7FarmSensor.physical_device_uuid,
|
|
||||||
farm_sensor: sensor7FarmSensor,
|
|
||||||
sensor_catalog: sensor7Catalog,
|
|
||||||
payload: {
|
|
||||||
moisture: 54,
|
|
||||||
temperature: 28.1,
|
|
||||||
humidity: 54,
|
|
||||||
ph: 6.8,
|
|
||||||
ec: 1.41,
|
|
||||||
nitrogen: 40,
|
|
||||||
potassium: 35,
|
|
||||||
},
|
|
||||||
created_at: "2025-05-01T17:40:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 105,
|
|
||||||
farm_uuid: MOCK_FARM_UUID,
|
|
||||||
sensor_catalog_uuid: weatherCatalog.uuid,
|
|
||||||
physical_device_uuid: weatherFarmSensor.physical_device_uuid,
|
|
||||||
farm_sensor: weatherFarmSensor,
|
|
||||||
sensor_catalog: weatherCatalog,
|
|
||||||
payload: {
|
|
||||||
air_temperature: 31.2,
|
|
||||||
air_humidity: 47,
|
|
||||||
wind_speed: 12.4,
|
|
||||||
light: 840,
|
|
||||||
},
|
|
||||||
created_at: "2025-05-02T10:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 104,
|
|
||||||
farm_uuid: MOCK_FARM_UUID,
|
|
||||||
sensor_catalog_uuid: weatherCatalog.uuid,
|
|
||||||
physical_device_uuid: weatherFarmSensor.physical_device_uuid,
|
|
||||||
farm_sensor: weatherFarmSensor,
|
|
||||||
sensor_catalog: weatherCatalog,
|
|
||||||
payload: {
|
|
||||||
air_temperature: 29.8,
|
|
||||||
air_humidity: 50,
|
|
||||||
wind_speed: 10.2,
|
|
||||||
light: 790,
|
|
||||||
},
|
|
||||||
created_at: "2025-05-02T07:30:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
sensors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getLogStatus = (
|
const getLogStatus = (
|
||||||
log: SensorExternalRequestLog,
|
log: SensorExternalRequestLog,
|
||||||
@@ -388,23 +278,188 @@ const LoadingState = () => (
|
|||||||
|
|
||||||
const Sensor7Page = () => {
|
const Sensor7Page = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { farmHub } = useFarmHub();
|
||||||
|
const farmUuid = farmHub?.farm_uuid ?? null;
|
||||||
|
|
||||||
|
const [summaryData, setSummaryData] = useState<Sensor7SummaryData | null>(null);
|
||||||
|
const [comparisonChartData, setComparisonChartData] =
|
||||||
|
useState<SensorComparisonChartResponse>(EMPTY_COMPARISON_CHART);
|
||||||
|
const [radarChartData, setRadarChartData] =
|
||||||
|
useState<SensorRadarChartResponse>(EMPTY_RADAR_CHART);
|
||||||
|
const [sensorValuesListData, setSensorValuesListData] =
|
||||||
|
useState<SensorValuesListResponse>(EMPTY_VALUES_LIST);
|
||||||
|
const [dashboardLoading, setDashboardLoading] = useState(false);
|
||||||
|
const [dashboardErrorMessage, setDashboardErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const [logs, setLogs] = useState<SensorExternalRequestLog[]>([]);
|
const [logs, setLogs] = useState<SensorExternalRequestLog[]>([]);
|
||||||
const [count, setCount] = useState(MOCK_LOGS.length);
|
const [count, setCount] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(true);
|
const [hasLoadedLogs, setHasLoadedLogs] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [logsErrorMessage, setLogsErrorMessage] = useState<string | null>(null);
|
||||||
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
|
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
|
||||||
|
|
||||||
const paginatedMockLogs = useMemo(() => {
|
useEffect(() => {
|
||||||
const start = (page - 1) * pageSize;
|
setPage(1);
|
||||||
return MOCK_LOGS.slice(start, start + pageSize);
|
setSelectedLogId(null);
|
||||||
}, [page, pageSize]);
|
setSelectedDeviceId("");
|
||||||
|
}, [farmUuid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!farmUuid) {
|
||||||
|
setSummaryData(null);
|
||||||
|
setComparisonChartData(EMPTY_COMPARISON_CHART);
|
||||||
|
setRadarChartData(EMPTY_RADAR_CHART);
|
||||||
|
setSensorValuesListData(EMPTY_VALUES_LIST);
|
||||||
|
setDashboardLoading(false);
|
||||||
|
setDashboardErrorMessage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const loadDashboardData = async () => {
|
||||||
|
setDashboardLoading(true);
|
||||||
|
setDashboardErrorMessage(null);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
sensor7Service.getSummary(farmUuid),
|
||||||
|
sensor7Service.getComparisonChart({
|
||||||
|
farmUuid,
|
||||||
|
range: DEFAULT_COMPARISON_RANGE,
|
||||||
|
}),
|
||||||
|
sensor7Service.getRadarChart({
|
||||||
|
farmUuid,
|
||||||
|
range: DEFAULT_RADAR_RANGE,
|
||||||
|
}),
|
||||||
|
sensor7Service.getValuesList({
|
||||||
|
farmUuid,
|
||||||
|
range: DEFAULT_VALUES_RANGE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const [summaryResult, comparisonResult, radarResult, valuesListResult] = results;
|
||||||
|
|
||||||
|
const nextSummaryData = summaryResult.status === "fulfilled" ? summaryResult.value : null;
|
||||||
|
const nextComparisonChartData =
|
||||||
|
comparisonResult.status === "fulfilled"
|
||||||
|
? comparisonResult.value
|
||||||
|
: normalizeComparisonChartResponse(nextSummaryData?.sensorComparisonChart);
|
||||||
|
const nextRadarChartData =
|
||||||
|
radarResult.status === "fulfilled"
|
||||||
|
? radarResult.value
|
||||||
|
: normalizeRadarChartResponse(nextSummaryData?.sensorRadarChart);
|
||||||
|
const nextValuesListData =
|
||||||
|
valuesListResult.status === "fulfilled"
|
||||||
|
? valuesListResult.value
|
||||||
|
: mapSummaryValuesList(nextSummaryData?.sensorValuesList);
|
||||||
|
|
||||||
|
setSummaryData(nextSummaryData);
|
||||||
|
setComparisonChartData(nextComparisonChartData);
|
||||||
|
setRadarChartData(nextRadarChartData);
|
||||||
|
setSensorValuesListData(nextValuesListData);
|
||||||
|
|
||||||
|
const failedResults = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failedResults.length === results.length) {
|
||||||
|
setDashboardErrorMessage(
|
||||||
|
getErrorMessage(failedResults[0]?.reason, "بارگذاری داده های سنسور انجام نشد."),
|
||||||
|
);
|
||||||
|
} else if (failedResults.length > 0) {
|
||||||
|
setDashboardErrorMessage(
|
||||||
|
"بخشی از داده های سنسور بارگذاری نشد و داده های در دسترس نمایش داده شدند.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setDashboardErrorMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadDashboardData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [farmUuid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!farmUuid) {
|
||||||
|
setLogs([]);
|
||||||
|
setCount(0);
|
||||||
|
setLogsLoading(false);
|
||||||
|
setLogsErrorMessage(null);
|
||||||
|
setHasLoadedLogs(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
setLogsLoading(true);
|
||||||
|
setLogsErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sensorExternalApiService.listRequestLogs({
|
||||||
|
farmUuid,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
setLogs(Array.isArray(response.data) ? response.data : []);
|
||||||
|
setCount(typeof response.count === "number" ? response.count : 0);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
setLogs([]);
|
||||||
|
setCount(0);
|
||||||
|
setLogsErrorMessage(getErrorMessage(error, "بارگذاری لاگ های سنسور ممکن نشد."));
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setLogsLoading(false);
|
||||||
|
setHasLoadedLogs(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadLogs();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [farmUuid, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logs.length) {
|
||||||
|
setSelectedDeviceId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDeviceId((current) => {
|
||||||
|
if (current && logs.some((item) => item.physical_device_uuid === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryDeviceId = summaryData?.sensor?.physicalDeviceUuid;
|
||||||
|
|
||||||
|
if (summaryDeviceId && logs.some((item) => item.physical_device_uuid === summaryDeviceId)) {
|
||||||
|
return summaryDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs[0]?.physical_device_uuid ?? "";
|
||||||
|
});
|
||||||
|
}, [logs, summaryData?.sensor?.physicalDeviceUuid]);
|
||||||
|
|
||||||
const totalPages = useMemo(
|
const totalPages = useMemo(
|
||||||
() => Math.max(1, Math.ceil(count / pageSize)),
|
() => Math.max(1, Math.ceil((count || 0) / pageSize)),
|
||||||
[count, pageSize],
|
[count, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -423,65 +478,34 @@ const Sensor7Page = () => {
|
|||||||
|
|
||||||
options.set(log.physical_device_uuid, {
|
options.set(log.physical_device_uuid, {
|
||||||
value: log.physical_device_uuid,
|
value: log.physical_device_uuid,
|
||||||
sensorName: log.farm_sensor?.name || "سنسور بدون نام",
|
sensorName: log.farm_sensor?.name || summaryData?.sensor?.name || "سنسور بدون نام",
|
||||||
sensorType: log.farm_sensor?.sensor_type || "نوع نامشخص",
|
sensorType:
|
||||||
|
log.farm_sensor?.sensor_type || summaryData?.sensor?.sensorCatalogCode || "نوع نامشخص",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(options.values());
|
return Array.from(options.values());
|
||||||
}, [logs]);
|
}, [logs, summaryData?.sensor?.name, summaryData?.sensor?.sensorCatalogCode]);
|
||||||
|
|
||||||
const activeDeviceId = useMemo(() => {
|
const activeDeviceId = useMemo(() => {
|
||||||
if (selectedDeviceId && sensorOptions.some((option) => option.value === selectedDeviceId)) {
|
if (selectedDeviceId && sensorOptions.some((option) => option.value === selectedDeviceId)) {
|
||||||
return selectedDeviceId;
|
return selectedDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const summaryDeviceId = summaryData?.sensor?.physicalDeviceUuid;
|
||||||
|
|
||||||
|
if (summaryDeviceId && sensorOptions.some((option) => option.value === summaryDeviceId)) {
|
||||||
|
return summaryDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
return sensorOptions[0]?.value ?? "";
|
return sensorOptions[0]?.value ?? "";
|
||||||
}, [selectedDeviceId, sensorOptions]);
|
}, [selectedDeviceId, sensorOptions, summaryData?.sensor?.physicalDeviceUuid]);
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
const filteredLogs = useMemo(() => {
|
||||||
() => logs.filter((item) => item.physical_device_uuid === activeDeviceId),
|
if (!activeDeviceId) return logs;
|
||||||
[activeDeviceId, logs],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedLog = useMemo(
|
return logs.filter((item) => item.physical_device_uuid === activeDeviceId);
|
||||||
() => filteredLogs.find((item) => item.id === selectedLogId) ?? filteredLogs[0] ?? null,
|
}, [activeDeviceId, logs]);
|
||||||
[filteredLogs, selectedLogId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const mappedLogsCount = useMemo(
|
|
||||||
() => filteredLogs.filter((item) => Boolean(item.farm_sensor)).length,
|
|
||||||
[filteredLogs],
|
|
||||||
);
|
|
||||||
|
|
||||||
const catalogedLogsCount = useMemo(
|
|
||||||
() => filteredLogs.filter((item) => Boolean(item.sensor_catalog)).length,
|
|
||||||
[filteredLogs],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
setLogs(paginatedMockLogs);
|
|
||||||
setCount(MOCK_LOGS.length);
|
|
||||||
setSelectedDeviceId((current) =>
|
|
||||||
paginatedMockLogs.some((item) => item.physical_device_uuid === current)
|
|
||||||
? current
|
|
||||||
: (paginatedMockLogs[0]?.physical_device_uuid ?? ""),
|
|
||||||
);
|
|
||||||
setSelectedLogId((current) =>
|
|
||||||
paginatedMockLogs.some((item) => item.id === current)
|
|
||||||
? current
|
|
||||||
: (paginatedMockLogs[0]?.id ?? null),
|
|
||||||
);
|
|
||||||
setHasLoadedOnce(true);
|
|
||||||
setLoading(false);
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
|
||||||
}, [paginatedMockLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filteredLogs.length) {
|
if (!filteredLogs.length) {
|
||||||
@@ -494,22 +518,35 @@ const Sensor7Page = () => {
|
|||||||
);
|
);
|
||||||
}, [filteredLogs]);
|
}, [filteredLogs]);
|
||||||
|
|
||||||
const handlePageSizeChange = (event: SelectChangeEvent<number>) => {
|
const selectedLog = useMemo(
|
||||||
const nextPageSize = Number(event.target.value);
|
() => filteredLogs.find((item) => item.id === selectedLogId) ?? filteredLogs[0] ?? null,
|
||||||
setPageSize(nextPageSize);
|
[filteredLogs, selectedLogId],
|
||||||
setPage(1);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const selectedSensorOption =
|
const selectedSensorOption =
|
||||||
sensorOptions.find((option) => option.value === activeDeviceId) ?? null;
|
sensorOptions.find((option) => option.value === activeDeviceId) ?? null;
|
||||||
|
|
||||||
|
const selectedSensorName =
|
||||||
|
selectedLog?.farm_sensor?.name || selectedSensorOption?.sensorName || summaryData?.sensor?.name || "-";
|
||||||
|
const selectedSensorType =
|
||||||
|
selectedLog?.farm_sensor?.sensor_type ||
|
||||||
|
selectedSensorOption?.sensorType ||
|
||||||
|
summaryData?.sensor?.sensorCatalogCode ||
|
||||||
|
"پس از دریافت داده نمایش داده می شود";
|
||||||
|
const lastReceivedAt = selectedLog?.created_at || summaryData?.sensor?.updatedAt || null;
|
||||||
|
const avgSoilMoistureStats =
|
||||||
|
getStringRecordField(summaryData?.avgSoilMoisture ?? null, "stats") || "-";
|
||||||
|
const avgSoilMoistureHelper =
|
||||||
|
getStringRecordField(summaryData?.avgSoilMoisture ?? null, "chipText") ||
|
||||||
|
getStringRecordField(summaryData?.avgSoilMoisture ?? null, "subtitle") ||
|
||||||
|
"از summary سنسور 7 در 1";
|
||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{
|
{
|
||||||
icon: "tabler-sensor",
|
icon: "tabler-sensor",
|
||||||
label: "سنسور انتخاب شده",
|
label: "سنسور انتخاب شده",
|
||||||
value: selectedSensorOption?.sensorName || "-",
|
value: selectedSensorName,
|
||||||
helper:
|
helper: selectedSensorType,
|
||||||
selectedSensorOption?.sensorType || "پس از دریافت داده نمایش داده می شود",
|
|
||||||
tone: "primary" as AccentTone,
|
tone: "primary" as AccentTone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -522,123 +559,46 @@ const Sensor7Page = () => {
|
|||||||
{
|
{
|
||||||
icon: "tabler-device-analytics",
|
icon: "tabler-device-analytics",
|
||||||
label: "آخرین دریافت",
|
label: "آخرین دریافت",
|
||||||
value: selectedLog ? formatDateTime(selectedLog.created_at) : "-",
|
value: formatDateTime(lastReceivedAt),
|
||||||
helper: "جدیدترین لاگ همین سنسور در صفحه جاری",
|
helper: "بر اساس summary یا جدیدترین لاگ موجود",
|
||||||
tone: "warning" as AccentTone,
|
tone: "warning" as AccentTone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "tabler-link",
|
icon: "tabler-droplet",
|
||||||
label: "پوشش اطلاعات سنسور",
|
label: "میانگین رطوبت خاک",
|
||||||
value: `${formatPersianNumber(mappedLogsCount)} / ${formatPersianNumber(filteredLogs.length)}`,
|
value: avgSoilMoistureStats,
|
||||||
helper: `${formatPersianNumber(mappedLogsCount)} مورد با سنسور مزرعه، ${formatPersianNumber(catalogedLogsCount)} مورد با کاتالوگ`,
|
helper: avgSoilMoistureHelper,
|
||||||
tone: "success" as AccentTone,
|
tone: "success" as AccentTone,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedFarmSensor = selectedLog?.farm_sensor ?? null;
|
const handlePageSizeChange = (event: SelectChangeEvent<number>) => {
|
||||||
|
const nextPageSize = Number(event.target.value);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const comparisonChartData = useMemo(() => {
|
if (!farmUuid) {
|
||||||
const numericEntries = extractNumericPayloadEntries(selectedLog?.payload);
|
return (
|
||||||
const primaryEntry = numericEntries[0] ?? ["moisture", 48];
|
<Alert severity="info">
|
||||||
const secondaryEntry = numericEntries[1] ?? ["temperature", 32];
|
برای مشاهده داده های سنسور 7 در 1 ابتدا یک مزرعه را در Sensor Hub انتخاب کنید.
|
||||||
const currentValue = Number(primaryEntry[1].toFixed(0));
|
</Alert>
|
||||||
const previousAverage =
|
);
|
||||||
filteredLogs.length > 1
|
}
|
||||||
? filteredLogs
|
|
||||||
.slice(1, 4)
|
|
||||||
.map((log) => {
|
|
||||||
const value = log.payload?.[primaryEntry[0]];
|
|
||||||
return typeof value === "number" ? value : null;
|
|
||||||
})
|
|
||||||
.filter((value): value is number => value !== null)
|
|
||||||
: [];
|
|
||||||
const baseline =
|
|
||||||
previousAverage.length > 0
|
|
||||||
? previousAverage.reduce((sum, value) => sum + value, 0) / previousAverage.length
|
|
||||||
: currentValue * 0.93;
|
|
||||||
const deltaPercent = baseline ? (((currentValue - baseline) / baseline) * 100).toFixed(1) : "0.0";
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentValue,
|
|
||||||
vsLastWeek: `${Number(deltaPercent) >= 0 ? "+" : ""}${deltaPercent}%`,
|
|
||||||
categories: ["شنبه", "یکشنبه", "دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه"],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: normalizeMetricLabel(primaryEntry[0]),
|
|
||||||
data: buildMockSeries(primaryEntry[1]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: normalizeMetricLabel(secondaryEntry[0]),
|
|
||||||
data: buildMockSeries(secondaryEntry[1]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}, [filteredLogs, selectedLog?.payload]);
|
|
||||||
|
|
||||||
const sensorValuesListData = useMemo(() => {
|
|
||||||
const numericEntries = extractNumericPayloadEntries(selectedLog?.payload);
|
|
||||||
const fallbackEntries: Array<[string, number]> = [
|
|
||||||
["moisture", 61],
|
|
||||||
["temperature", 27],
|
|
||||||
["ph", 6.8],
|
|
||||||
["ec", 1.4],
|
|
||||||
];
|
|
||||||
const entries = (numericEntries.length ? numericEntries : fallbackEntries).slice(0, 5);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sensors: entries.map(([key, value], index) => {
|
|
||||||
const previousValue =
|
|
||||||
filteredLogs[index + 1] && typeof filteredLogs[index + 1].payload?.[key] === "number"
|
|
||||||
? Number(filteredLogs[index + 1].payload?.[key])
|
|
||||||
: value * (0.9 + index * 0.03);
|
|
||||||
const trendNumber = Number((((value - previousValue) / (previousValue || 1)) * 100).toFixed(1));
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: normalizeMetricLabel(key),
|
|
||||||
subtitle: `مقدار فعلی: ${value.toLocaleString("fa-IR", { maximumFractionDigits: 2 })}`,
|
|
||||||
trendNumber,
|
|
||||||
trend: trendNumber < 0 ? "negative" : "positive",
|
|
||||||
unit: "",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}, [filteredLogs, selectedLog?.payload]);
|
|
||||||
|
|
||||||
const radarChartData = useMemo(() => {
|
|
||||||
const numericEntries = extractNumericPayloadEntries(selectedLog?.payload);
|
|
||||||
const entries = (numericEntries.length
|
|
||||||
? numericEntries
|
|
||||||
: [
|
|
||||||
["moisture", 61],
|
|
||||||
["temperature", 27],
|
|
||||||
["humidity", 58],
|
|
||||||
["ph", 68],
|
|
||||||
["ec", 42],
|
|
||||||
]).slice(0, 6);
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: entries.map(([key]) => normalizeMetricLabel(key)),
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: "وضعیت فعلی",
|
|
||||||
data: entries.map(([, value]) => Number(value.toFixed(1))),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "بازه ایده آل",
|
|
||||||
data: entries.map(([, value], index) =>
|
|
||||||
Number((value * (index % 2 === 0 ? 0.92 : 1.06)).toFixed(1)),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}, [selectedLog?.payload]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-6">
|
<Box className="flex flex-col gap-6">
|
||||||
<Alert severity="info">
|
{dashboardErrorMessage ? (
|
||||||
این صفحه فعلاً با داده های mock نمایش داده می شود تا طراحی و توسعه رابط کاربری سریع تر
|
<Alert
|
||||||
انجام شود.
|
severity={
|
||||||
</Alert>
|
comparisonChartData.series.length || radarChartData.series.length || sensorValuesListData.sensors.length
|
||||||
|
? "warning"
|
||||||
|
: "error"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dashboardErrorMessage}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{metrics.map((metric) => (
|
{metrics.map((metric) => (
|
||||||
@@ -648,20 +608,17 @@ const Sensor7Page = () => {
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<SensorHealthPanel
|
<SensorHealthPanel payload={selectedLog?.payload} sensorName={selectedSensorName} />
|
||||||
payload={selectedLog?.payload}
|
|
||||||
sensorName={selectedFarmSensor?.name || selectedSensorOption?.sensorName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid size={{ xs: 12, lg: 7 }}>
|
<Grid size={{ xs: 12, lg: 7 }}>
|
||||||
<SensorComparisonChart data={comparisonChartData} />
|
<SensorComparisonChart data={comparisonChartData as unknown as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, lg: 5 }}>
|
<Grid size={{ xs: 12, lg: 5 }}>
|
||||||
<SensorValuesList data={sensorValuesListData} />
|
<SensorValuesList data={sensorValuesListData as unknown as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12 }}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<SensorRadarChart data={radarChartData} />
|
<SensorRadarChart data={radarChartData as unknown as Record<string, unknown>} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -676,7 +633,7 @@ const Sensor7Page = () => {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading && hasLoadedOnce ? <LinearProgress /> : null}
|
{logsLoading || dashboardLoading ? <LinearProgress /> : null}
|
||||||
|
|
||||||
<CardContent className="p-5 sm:p-6 flex flex-col gap-5">
|
<CardContent className="p-5 sm:p-6 flex flex-col gap-5">
|
||||||
<Stack
|
<Stack
|
||||||
@@ -690,8 +647,8 @@ const Sensor7Page = () => {
|
|||||||
محتوای سنسور انتخاب شده
|
محتوای سنسور انتخاب شده
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
این صفحه فقط داده های مربوط به یک سنسور را نشان می دهد؛ سنسور را از
|
داده های این بخش از endpoint لاگ سنسور خارجی خوانده می شوند؛ سنسور را از
|
||||||
لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ باز شود.
|
لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ نمایش داده شود.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -707,6 +664,7 @@ const Sensor7Page = () => {
|
|||||||
value={activeDeviceId}
|
value={activeDeviceId}
|
||||||
label="سنسور"
|
label="سنسور"
|
||||||
onChange={(event) => setSelectedDeviceId(event.target.value)}
|
onChange={(event) => setSelectedDeviceId(event.target.value)}
|
||||||
|
disabled={!sensorOptions.length}
|
||||||
>
|
>
|
||||||
{sensorOptions.map((option) => (
|
{sensorOptions.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
@@ -754,18 +712,16 @@ const Sensor7Page = () => {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{!hasLoadedOnce && loading ? (
|
{!hasLoadedLogs && logsLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : errorMessage ? (
|
) : logsErrorMessage ? (
|
||||||
<Alert severity="error">{errorMessage}</Alert>
|
<Alert severity="error">{logsErrorMessage}</Alert>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
هنوز لاگی برای این مزرعه ثبت نشده است یا در این صفحه داده ای وجود ندارد.
|
هنوز لاگی برای این مزرعه ثبت نشده است یا در این صفحه داده ای وجود ندارد.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : filteredLogs.length === 0 ? (
|
) : filteredLogs.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Alert severity="info">برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد.</Alert>
|
||||||
برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
|
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
|
||||||
@@ -854,6 +810,7 @@ const Sensor7Page = () => {
|
|||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
fontFamily: "var(--font-geist-mono), monospace",
|
fontFamily: "var(--font-geist-mono), monospace",
|
||||||
}}
|
}}
|
||||||
|
title={status.description}
|
||||||
>
|
>
|
||||||
{formatPayloadPreview(log.payload)}
|
{formatPayloadPreview(log.payload)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
Reference in New Issue
Block a user