This commit is contained in:
2026-04-29 01:27:40 +03:30
parent 2ac51fe082
commit 5c548bc6db
4 changed files with 703 additions and 373 deletions
@@ -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 },
);
}
}
+237
View File
@@ -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 {
uuid: string;
sensor_catalog_uuid: string | null;
@@ -49,13 +45,66 @@ export interface SensorExternalRequestLogsResponse {
data: SensorExternalRequestLog[];
}
export const sensorExternalApiService = {
listRequestLogs(params: {
interface SensorExternalLogsParams {
farmUuid: string;
page?: number;
pageSize?: number;
}): Promise<SensorExternalRequestLogsResponse> {
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid });
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 = {
listRequestLogs(params: SensorExternalLogsParams): Promise<SensorExternalRequestLogsResponse> {
const searchParams = new URLSearchParams({
farm_uuid: params.farmUuid,
});
if (typeof params.page === "number") {
searchParams.set("page", String(params.page));
@@ -65,8 +114,24 @@ export const sensorExternalApiService = {
searchParams.set("page_size", String(params.pageSize));
}
return apiClient.get<SensorExternalRequestLogsResponse>(
`${PREFIX}/logs/?${searchParams.toString()}`,
if (params.physicalDeviceUuid) {
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()}`,
);
},
};
+316 -359
View File
@@ -28,9 +28,19 @@ import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
import { useFarmHub } from "@/hooks/useFarmHub";
import {
type SensorExternalCatalog,
type SensorExternalFarmSensor,
normalizeComparisonChartResponse,
normalizeRadarChartResponse,
normalizeValuesListResponse,
sensor7Service,
type Sensor7SummaryData,
type SensorComparisonChartResponse,
type SensorRadarChartResponse,
type SensorValuesListResponse,
} from "@/libs/api/services/sensor7Service";
import {
sensorExternalApiService,
type SensorExternalRequestLog,
} from "@/libs/api/services/sensorExternalApiService";
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 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 AccentTone = "primary" | "success" | "warning" | "info";
const formatPersianNumber = (value: number): string =>
value.toLocaleString("fa-IR");
const formatPersianNumber = (value: number): string => value.toLocaleString("fa-IR");
const formatDateTime = (value?: string | null): string => {
if (!value) return "-";
@@ -64,6 +80,7 @@ const formatDateTime = (value?: string | null): string => {
const shortenUuid = (value?: string | null): string => {
if (!value) return "-";
if (value.length <= 16) return value;
return `${value.slice(0, 8)}...${value.slice(-6)}`;
};
@@ -72,12 +89,8 @@ const formatScalar = (value: unknown): string => {
if (value === undefined) return "undefined";
if (Array.isArray(value)) return `[${value.length} item]`;
if (typeof value === "object") return "{...}";
return String(value);
};
const getPayloadFieldCount = (payload?: Record<string, unknown> | null): number => {
if (!payload || typeof payload !== "object") return 0;
return Object.keys(payload).length;
return String(value);
};
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;
};
const normalizeMetricLabel = (key: string): string =>
key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.trim();
const getErrorMessage = (error: unknown, fallback: string): string => {
if (error && typeof error === "object") {
const candidate = error as {
message?: unknown;
details?: Record<string, unknown>;
};
const extractNumericPayloadEntries = (
payload?: Record<string, unknown> | null,
): Array<[string, number]> => {
if (!payload || typeof payload !== "object") return [];
if (typeof candidate.message === "string" && candidate.message) {
return candidate.message;
}
return Object.entries(payload).filter(
(entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]),
);
if (typeof candidate.details?.detail === "string" && candidate.details.detail) {
return candidate.details.detail;
}
}
return fallback;
};
const buildMockSeries = (baseValue: number): number[] =>
[0.92, 0.98, 0.94, 1.03, 0.97, 1.05, 1]
.map((multiplier) => Number((baseValue * multiplier).toFixed(1)));
const getStringRecordField = (
record: Record<string, unknown> | null | undefined,
key: string,
): string | null => {
if (!record) return null;
const createMockCatalog = (
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 value = record[key];
const createMockSensor = (
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",
});
return typeof value === "string" && value ? value : null;
};
const sensor7Catalog = createMockCatalog(
"catalog-7in1-01",
"SOIL-7IN1",
"کاتالوگ سنسور خاک 7 در 1",
["moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "potassium"],
);
const mapSummaryValuesList = (
valuesList?: Sensor7SummaryData["sensorValuesList"] | null,
): SensorValuesListResponse => {
if (!valuesList) {
return EMPTY_VALUES_LIST;
}
const weatherCatalog = createMockCatalog(
"catalog-weather-01",
"WEATHER-PRO",
"کاتالوگ سنسور اقلیم",
["air_temperature", "air_humidity", "wind_speed", "light"],
);
const sensor7FarmSensor = createMockSensor(
"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",
},
];
const sensors = Array.isArray(valuesList.sensors)
? valuesList.sensors.map((item) => ({
id: item.id,
title: item.subtitle || item.id || "-",
subtitle: item.title || "-",
trendNumber: item.trendNumber,
trend: item.trend,
unit: item.unit,
}))
: [];
return {
sensors,
};
};
const getLogStatus = (
log: SensorExternalRequestLog,
@@ -388,23 +278,188 @@ const LoadingState = () => (
const Sensor7Page = () => {
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 [count, setCount] = useState(MOCK_LOGS.length);
const [count, setCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [hasLoadedLogs, setHasLoadedLogs] = useState(false);
const [logsErrorMessage, setLogsErrorMessage] = useState<string | null>(null);
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const paginatedMockLogs = useMemo(() => {
const start = (page - 1) * pageSize;
return MOCK_LOGS.slice(start, start + pageSize);
}, [page, pageSize]);
useEffect(() => {
setPage(1);
setSelectedLogId(null);
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(
() => Math.max(1, Math.ceil(count / pageSize)),
() => Math.max(1, Math.ceil((count || 0) / pageSize)),
[count, pageSize],
);
@@ -423,65 +478,34 @@ const Sensor7Page = () => {
options.set(log.physical_device_uuid, {
value: log.physical_device_uuid,
sensorName: log.farm_sensor?.name || "سنسور بدون نام",
sensorType: log.farm_sensor?.sensor_type || "نوع نامشخص",
sensorName: log.farm_sensor?.name || summaryData?.sensor?.name || "سنسور بدون نام",
sensorType:
log.farm_sensor?.sensor_type || summaryData?.sensor?.sensorCatalogCode || "نوع نامشخص",
});
});
return Array.from(options.values());
}, [logs]);
}, [logs, summaryData?.sensor?.name, summaryData?.sensor?.sensorCatalogCode]);
const activeDeviceId = useMemo(() => {
if (selectedDeviceId && sensorOptions.some((option) => option.value === selectedDeviceId)) {
return selectedDeviceId;
}
const summaryDeviceId = summaryData?.sensor?.physicalDeviceUuid;
if (summaryDeviceId && sensorOptions.some((option) => option.value === summaryDeviceId)) {
return summaryDeviceId;
}
return sensorOptions[0]?.value ?? "";
}, [selectedDeviceId, sensorOptions]);
}, [selectedDeviceId, sensorOptions, summaryData?.sensor?.physicalDeviceUuid]);
const filteredLogs = useMemo(
() => logs.filter((item) => item.physical_device_uuid === activeDeviceId),
[activeDeviceId, logs],
);
const filteredLogs = useMemo(() => {
if (!activeDeviceId) return logs;
const selectedLog = useMemo(
() => filteredLogs.find((item) => item.id === selectedLogId) ?? filteredLogs[0] ?? null,
[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]);
return logs.filter((item) => item.physical_device_uuid === activeDeviceId);
}, [activeDeviceId, logs]);
useEffect(() => {
if (!filteredLogs.length) {
@@ -494,22 +518,35 @@ const Sensor7Page = () => {
);
}, [filteredLogs]);
const handlePageSizeChange = (event: SelectChangeEvent<number>) => {
const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
setPage(1);
};
const selectedLog = useMemo(
() => filteredLogs.find((item) => item.id === selectedLogId) ?? filteredLogs[0] ?? null,
[filteredLogs, selectedLogId],
);
const selectedSensorOption =
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 = [
{
icon: "tabler-sensor",
label: "سنسور انتخاب شده",
value: selectedSensorOption?.sensorName || "-",
helper:
selectedSensorOption?.sensorType || "پس از دریافت داده نمایش داده می شود",
value: selectedSensorName,
helper: selectedSensorType,
tone: "primary" as AccentTone,
},
{
@@ -522,123 +559,46 @@ const Sensor7Page = () => {
{
icon: "tabler-device-analytics",
label: "آخرین دریافت",
value: selectedLog ? formatDateTime(selectedLog.created_at) : "-",
helper: "جدیدترین لاگ همین سنسور در صفحه جاری",
value: formatDateTime(lastReceivedAt),
helper: "بر اساس summary یا جدیدترین لاگ موجود",
tone: "warning" as AccentTone,
},
{
icon: "tabler-link",
label: "پوشش اطلاعات سنسور",
value: `${formatPersianNumber(mappedLogsCount)} / ${formatPersianNumber(filteredLogs.length)}`,
helper: `${formatPersianNumber(mappedLogsCount)} مورد با سنسور مزرعه، ${formatPersianNumber(catalogedLogsCount)} مورد با کاتالوگ`,
icon: "tabler-droplet",
label: "میانگین رطوبت خاک",
value: avgSoilMoistureStats,
helper: avgSoilMoistureHelper,
tone: "success" as AccentTone,
},
];
const selectedFarmSensor = selectedLog?.farm_sensor ?? null;
const comparisonChartData = useMemo(() => {
const numericEntries = extractNumericPayloadEntries(selectedLog?.payload);
const primaryEntry = numericEntries[0] ?? ["moisture", 48];
const secondaryEntry = numericEntries[1] ?? ["temperature", 32];
const currentValue = Number(primaryEntry[1].toFixed(0));
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]),
},
],
const handlePageSizeChange = (event: SelectChangeEvent<number>) => {
const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
setPage(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]);
if (!farmUuid) {
return (
<Alert severity="info">
برای مشاهده داده های سنسور 7 در 1 ابتدا یک مزرعه را در Sensor Hub انتخاب کنید.
</Alert>
);
}
return (
<Box className="flex flex-col gap-6">
<Alert severity="info">
این صفحه فعلاً با داده های mock نمایش داده می شود تا طراحی و توسعه رابط کاربری سریع تر
انجام شود.
{dashboardErrorMessage ? (
<Alert
severity={
comparisonChartData.series.length || radarChartData.series.length || sensorValuesListData.sensors.length
? "warning"
: "error"
}
>
{dashboardErrorMessage}
</Alert>
) : null}
<Grid container spacing={3}>
{metrics.map((metric) => (
@@ -648,20 +608,17 @@ const Sensor7Page = () => {
))}
</Grid>
<SensorHealthPanel
payload={selectedLog?.payload}
sensorName={selectedFarmSensor?.name || selectedSensorOption?.sensorName}
/>
<SensorHealthPanel payload={selectedLog?.payload} sensorName={selectedSensorName} />
<Grid container spacing={3}>
<Grid size={{ xs: 12, lg: 7 }}>
<SensorComparisonChart data={comparisonChartData} />
<SensorComparisonChart data={comparisonChartData as unknown as Record<string, unknown>} />
</Grid>
<Grid size={{ xs: 12, lg: 5 }}>
<SensorValuesList data={sensorValuesListData} />
<SensorValuesList data={sensorValuesListData as unknown as Record<string, unknown>} />
</Grid>
<Grid size={{ xs: 12 }}>
<SensorRadarChart data={radarChartData} />
<SensorRadarChart data={radarChartData as unknown as Record<string, unknown>} />
</Grid>
</Grid>
@@ -676,7 +633,7 @@ const Sensor7Page = () => {
overflow: "hidden",
}}
>
{loading && hasLoadedOnce ? <LinearProgress /> : null}
{logsLoading || dashboardLoading ? <LinearProgress /> : null}
<CardContent className="p-5 sm:p-6 flex flex-col gap-5">
<Stack
@@ -690,8 +647,8 @@ const Sensor7Page = () => {
محتوای سنسور انتخاب شده
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
این صفحه فقط داده های مربوط به یک سنسور را نشان می دهد؛ سنسور را از
لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ باز شود.
داده های این بخش از endpoint لاگ سنسور خارجی خوانده می شوند؛ سنسور را از
لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ نمایش داده شود.
</Typography>
</Box>
@@ -707,6 +664,7 @@ const Sensor7Page = () => {
value={activeDeviceId}
label="سنسور"
onChange={(event) => setSelectedDeviceId(event.target.value)}
disabled={!sensorOptions.length}
>
{sensorOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
@@ -754,18 +712,16 @@ const Sensor7Page = () => {
<Divider />
{!hasLoadedOnce && loading ? (
{!hasLoadedLogs && logsLoading ? (
<LoadingState />
) : errorMessage ? (
<Alert severity="error">{errorMessage}</Alert>
) : logsErrorMessage ? (
<Alert severity="error">{logsErrorMessage}</Alert>
) : logs.length === 0 ? (
<Alert severity="info">
هنوز لاگی برای این مزرعه ثبت نشده است یا در این صفحه داده ای وجود ندارد.
</Alert>
) : filteredLogs.length === 0 ? (
<Alert severity="info">
برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد.
</Alert>
<Alert severity="info">برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد.</Alert>
) : (
<>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
@@ -854,6 +810,7 @@ const Sensor7Page = () => {
textOverflow: "ellipsis",
fontFamily: "var(--font-geist-mono), monospace",
}}
title={status.description}
>
{formatPayloadPreview(log.payload)}
</Typography>