diff --git a/src/app/api/sensor-external-api/logs/route.ts b/src/app/api/sensor-external-api/logs/route.ts new file mode 100644 index 0000000..2b23ff1 --- /dev/null +++ b/src/app/api/sensor-external-api/logs/route.ts @@ -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 }, + ); + } +} diff --git a/src/libs/api/services/sensor7Service.ts b/src/libs/api/services/sensor7Service.ts new file mode 100644 index 0000000..f26f21e --- /dev/null +++ b/src/libs/api/services/sensor7Service.ts @@ -0,0 +1,237 @@ +import { apiClient } from "../client"; + +const SUMMARY_PREFIX = "/api/sensor-7-in-1"; +const SENSORS_PREFIX = "/api/sensors"; + +export interface ApiResponse { + 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 | null; + sensorRadarChart?: SensorRadarChartResponse | null; + sensorComparisonChart?: SensorComparisonChartResponse | null; + anomalyDetectionCard?: Record | null; + soilMoistureHeatmap?: Record | 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(response: ApiResponse | T): T { + return response && typeof response === "object" && "data" in response + ? (response as ApiResponse).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 | 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 | 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 | 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 { + const response = await apiClient.get | 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 { + const searchParams = new URLSearchParams({ + farm_uuid: params.farmUuid, + range: params.range ?? "7d", + }); + + const response = await apiClient.get>( + `${SENSORS_PREFIX}/comparison-chart/?${searchParams.toString()}`, + ); + + return normalizeComparisonChartResponse(response); + }, + + async getRadarChart(params: { + farmUuid: string; + range?: "today" | "7d" | "30d"; + }): Promise { + const searchParams = new URLSearchParams({ + farm_uuid: params.farmUuid, + range: params.range ?? "7d", + }); + + const response = await apiClient.get>( + `${SENSORS_PREFIX}/radar-chart/?${searchParams.toString()}`, + ); + + return normalizeRadarChartResponse(response); + }, + + async getValuesList(params: { + farmUuid: string; + range?: "1h" | "24h" | "7d"; + }): Promise { + const searchParams = new URLSearchParams({ + farm_uuid: params.farmUuid, + range: params.range ?? "7d", + }); + + const response = await apiClient.get>( + `${SENSORS_PREFIX}/values-list/?${searchParams.toString()}`, + ); + + return normalizeValuesListResponse(response); + }, +}; diff --git a/src/libs/api/services/sensorExternalApiService.ts b/src/libs/api/services/sensorExternalApiService.ts index 135727d..d1b9c68 100644 --- a/src/libs/api/services/sensorExternalApiService.ts +++ b/src/libs/api/services/sensorExternalApiService.ts @@ -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[]; } +interface SensorExternalLogsParams { + farmUuid: string; + page?: number; + pageSize?: number; + physicalDeviceUuid?: string; + sensorType?: string; + dateFrom?: string; + dateTo?: string; +} + +const getAuthHeaders = (): Record => { + if (typeof window === "undefined") { + return {}; + } + + const token = localStorage.getItem("auth_token"); + + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +async function fetchLocalJson(url: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + ...getAuthHeaders(), + }, + cache: "no-store", + }); + + if (!response.ok) { + let errorData: Record = {}; + + try { + errorData = (await response.json()) as Record; + } 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; +} + export const sensorExternalApiService = { - listRequestLogs(params: { - farmUuid: string; - page?: number; - pageSize?: number; - }): Promise { - const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid }); + listRequestLogs(params: SensorExternalLogsParams): Promise { + 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( - `${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( + `/api/sensor-external-api/logs/?${searchParams.toString()}`, ); }, }; diff --git a/src/views/dashboards/farm/sensor7/Sensor7Page.tsx b/src/views/dashboards/farm/sensor7/Sensor7Page.tsx index dd74c1f..9e4aeac 100644 --- a/src/views/dashboards/farm/sensor7/Sensor7Page.tsx +++ b/src/views/dashboards/farm/sensor7/Sensor7Page.tsx @@ -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 | null): number => { - if (!payload || typeof payload !== "object") return 0; - return Object.keys(payload).length; + return String(value); }; const formatPayloadPreview = (payload?: Record | null): string => { @@ -95,181 +108,58 @@ const formatPayloadPreview = (payload?: Record | 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; + }; -const extractNumericPayloadEntries = ( - payload?: Record | 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 | 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(null); + const [comparisonChartData, setComparisonChartData] = + useState(EMPTY_COMPARISON_CHART); + const [radarChartData, setRadarChartData] = + useState(EMPTY_RADAR_CHART); + const [sensorValuesListData, setSensorValuesListData] = + useState(EMPTY_VALUES_LIST); + const [dashboardLoading, setDashboardLoading] = useState(false); + const [dashboardErrorMessage, setDashboardErrorMessage] = useState(null); + const [logs, setLogs] = useState([]); - 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(null); + const [logsLoading, setLogsLoading] = useState(false); + const [hasLoadedLogs, setHasLoadedLogs] = useState(false); + const [logsErrorMessage, setLogsErrorMessage] = useState(null); const [selectedLogId, setSelectedLogId] = useState(null); const [selectedDeviceId, setSelectedDeviceId] = useState(""); - 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) => { - 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 handlePageSizeChange = (event: SelectChangeEvent) => { + const nextPageSize = Number(event.target.value); + setPageSize(nextPageSize); + setPage(1); + }; - 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]), - }, - ], - }; - }, [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 ( + + برای مشاهده داده های سنسور 7 در 1 ابتدا یک مزرعه را در Sensor Hub انتخاب کنید. + + ); + } return ( - - این صفحه فعلاً با داده های mock نمایش داده می شود تا طراحی و توسعه رابط کاربری سریع تر - انجام شود. - + {dashboardErrorMessage ? ( + + {dashboardErrorMessage} + + ) : null} {metrics.map((metric) => ( @@ -648,20 +608,17 @@ const Sensor7Page = () => { ))} - + - + } /> - + } /> - + } /> @@ -676,7 +633,7 @@ const Sensor7Page = () => { overflow: "hidden", }} > - {loading && hasLoadedOnce ? : null} + {logsLoading || dashboardLoading ? : null} { محتوای سنسور انتخاب شده - این صفحه فقط داده های مربوط به یک سنسور را نشان می دهد؛ سنسور را از - لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ باز شود. + داده های این بخش از endpoint لاگ سنسور خارجی خوانده می شوند؛ سنسور را از + لیست انتخاب کنید و روی هر ردیف بزنید تا جزئیات همان لاگ نمایش داده شود. @@ -707,6 +664,7 @@ const Sensor7Page = () => { value={activeDeviceId} label="سنسور" onChange={(event) => setSelectedDeviceId(event.target.value)} + disabled={!sensorOptions.length} > {sensorOptions.map((option) => ( @@ -754,18 +712,16 @@ const Sensor7Page = () => { - {!hasLoadedOnce && loading ? ( + {!hasLoadedLogs && logsLoading ? ( - ) : errorMessage ? ( - {errorMessage} + ) : logsErrorMessage ? ( + {logsErrorMessage} ) : logs.length === 0 ? ( هنوز لاگی برای این مزرعه ثبت نشده است یا در این صفحه داده ای وجود ندارد. ) : filteredLogs.length === 0 ? ( - - برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد. - + برای سنسور انتخاب شده در این صفحه لاگی پیدا نشد. ) : ( <> @@ -854,6 +810,7 @@ const Sensor7Page = () => { textOverflow: "ellipsis", fontFamily: "var(--font-geist-mono), monospace", }} + title={status.description} > {formatPayloadPreview(log.payload)}