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 { 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[];
} }
export const sensorExternalApiService = { interface SensorExternalLogsParams {
listRequestLogs(params: {
farmUuid: string; farmUuid: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}): Promise<SensorExternalRequestLogsResponse> { physicalDeviceUuid?: string;
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid }); 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") { 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()}`,
); );
}, },
}; };
+315 -358
View File
@@ -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 = (
payload?: Record<string, unknown> | null,
): Array<[string, number]> => {
if (!payload || typeof payload !== "object") return [];
return Object.entries(payload).filter(
(entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]),
);
}; };
const buildMockSeries = (baseValue: number): number[] => if (typeof candidate.message === "string" && candidate.message) {
[0.92, 0.98, 0.94, 1.03, 0.97, 1.05, 1] return candidate.message;
.map((multiplier) => Number((baseValue * multiplier).toFixed(1))); }
const createMockCatalog = ( if (typeof candidate.details?.detail === "string" && candidate.details.detail) {
uuid: string, return candidate.details.detail;
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 fallback;
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 getStringRecordField = (
"catalog-7in1-01", record: Record<string, unknown> | null | undefined,
"SOIL-7IN1", key: string,
"کاتالوگ سنسور خاک 7 در 1", ): string | null => {
["moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "potassium"], if (!record) return null;
);
const weatherCatalog = createMockCatalog( const value = record[key];
"catalog-weather-01",
"WEATHER-PRO",
"کاتالوگ سنسور اقلیم",
["air_temperature", "air_humidity", "wind_speed", "light"],
);
const sensor7FarmSensor = createMockSensor( return typeof value === "string" && value ? value : null;
"farm-sensor-7in1-01", };
"device-7in1-0001",
"سنسور خاک 7 در 1 - بلوک شمالی",
"Soil 7 in 1",
sensor7Catalog.uuid,
);
const weatherFarmSensor = createMockSensor( const mapSummaryValuesList = (
"farm-sensor-weather-01", valuesList?: Sensor7SummaryData["sensorValuesList"] | null,
"device-weather-0002", ): SensorValuesListResponse => {
"سنسور اقلیم - گلخانه 1", if (!valuesList) {
"Climate Station", return EMPTY_VALUES_LIST;
weatherCatalog.uuid, }
);
const MOCK_LOGS: SensorExternalRequestLog[] = [ const sensors = Array.isArray(valuesList.sensors)
{ ? valuesList.sensors.map((item) => ({
id: 108, id: item.id,
farm_uuid: MOCK_FARM_UUID, title: item.subtitle || item.id || "-",
sensor_catalog_uuid: sensor7Catalog.uuid, subtitle: item.title || "-",
physical_device_uuid: sensor7FarmSensor.physical_device_uuid, trendNumber: item.trendNumber,
farm_sensor: sensor7FarmSensor, trend: item.trend,
sensor_catalog: sensor7Catalog, unit: item.unit,
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);
const comparisonChartData = useMemo(() => { setPageSize(nextPageSize);
const numericEntries = extractNumericPayloadEntries(selectedLog?.payload); setPage(1);
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]),
},
],
}; };
}, [filteredLogs, selectedLog?.payload]);
const sensorValuesListData = useMemo(() => { if (!farmUuid) {
const numericEntries = extractNumericPayloadEntries(selectedLog?.payload); return (
const fallbackEntries: Array<[string, number]> = [ <Alert severity="info">
["moisture", 61], برای مشاهده داده های سنسور 7 در 1 ابتدا یک مزرعه را در Sensor Hub انتخاب کنید.
["temperature", 27], </Alert>
["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={
comparisonChartData.series.length || radarChartData.series.length || sensorValuesListData.sensors.length
? "warning"
: "error"
}
>
{dashboardErrorMessage}
</Alert> </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>