This commit is contained in:
2026-04-29 01:03:44 +03:30
parent fa832e14b5
commit 2ac51fe082
3 changed files with 1085 additions and 549 deletions
@@ -0,0 +1,312 @@
# Backend Requirements For Sensor Content Page
## Goal
This page is now focused on showing the content of a selected sensor, plus three analytics cards that each consume their own API:
- `SensorComparisonChart.tsx`
- `SensorValuesList.tsx`
- `SensorRadarChart.tsx`
The page currently uses mock data in the frontend. To switch it to real data, the backend needs to provide the APIs and payload contracts below.
---
## 1) Main Sensor Logs API
This API powers:
- sensor selector
- top summary cards
- health panel
- logs table
### Suggested endpoint
`GET /api/sensor-external-api/logs/`
### Required query params
- `farm_uuid` - required
- `page` - required for pagination
- `page_size` - required for pagination
### Recommended extra query params
- `physical_device_uuid` - to fetch only one sensor directly from backend
- `sensor_type` - optional future filter
- `date_from` - optional
- `date_to` - optional
### Required response shape
```json
{
"code": 200,
"msg": "success",
"count": 120,
"next": null,
"previous": null,
"data": [
{
"id": 108,
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_catalog_uuid": "catalog-7in1-01",
"physical_device_uuid": "device-7in1-0001",
"farm_sensor": {
"uuid": "farm-sensor-7in1-01",
"sensor_catalog_uuid": "catalog-7in1-01",
"physical_device_uuid": "device-7in1-0001",
"name": "سنسور خاک 7 در 1 - بلوک شمالی",
"sensor_type": "Soil 7 in 1",
"is_active": true,
"specifications": {},
"power_source": {},
"created_at": "2025-01-01T08:00:00Z",
"updated_at": "2025-01-10T08:00:00Z"
},
"sensor_catalog": {
"uuid": "catalog-7in1-01",
"code": "SOIL-7IN1",
"name": "کاتالوگ سنسور خاک 7 در 1",
"description": "string",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": [
"moisture",
"temperature",
"humidity",
"ph",
"ec",
"nitrogen",
"phosphorus",
"potassium"
],
"sample_payload": {},
"is_active": true,
"created_at": "2025-01-01T08:00:00Z",
"updated_at": "2025-01-10T08:00:00Z"
},
"payload": {
"moisture": 61,
"temperature": 27.4,
"humidity": 58,
"ph": 6.7,
"ec": 1.45,
"nitrogen": 44,
"phosphorus": 32,
"potassium": 39
},
"created_at": "2025-05-02T11:20:00Z"
}
]
}
```
### Notes
- `payload` must keep numeric values as numbers, not strings
- `physical_device_uuid` is required because the page groups and filters by sensor device
- `farm_sensor.name` and `farm_sensor.sensor_type` are needed for sensor dropdown labels
- `sensor_catalog.returned_data_fields` is needed to understand sensor structure
---
## 2) Comparison Chart API
This API powers `src/views/dashboards/farm/SensorComparisonChart.tsx`
### Suggested endpoint
`GET /api/sensors/comparison-chart/`
### Required query params
- `farm_uuid`
- `physical_device_uuid`
- `range` - example: `7d`, `30d`
### Required response shape
```json
{
"series": [
{
"name": "moisture",
"data": [56, 58, 55, 60, 62, 61, 59]
},
{
"name": "temperature",
"data": [26.2, 26.7, 27.0, 27.2, 27.5, 27.4, 27.1]
}
],
"categories": ["شنبه", "یکشنبه", "دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه"],
"currentValue": 61,
"vsLastWeek": "+5.4%"
}
```
### Notes
- This endpoint is independent and should not be derived in frontend from logs
- `currentValue` is shown in large text in the card
- `vsLastWeek` is shown as summary text in the card
---
## 3) Sensor Values List API
This API powers `src/views/dashboards/farm/SensorValuesList.tsx`
### Suggested endpoint
`GET /api/sensors/values-list/`
### Required query params
- `farm_uuid`
- `physical_device_uuid`
- `range` - example: `1h`, `24h`, `7d`
### Required response shape
```json
{
"sensors": [
{
"title": "Moisture",
"subtitle": "مقدار فعلی: 61%",
"trendNumber": 4.8,
"trend": "positive",
"unit": "%"
},
{
"title": "Temperature",
"subtitle": "مقدار فعلی: 27.4°C",
"trendNumber": -1.2,
"trend": "negative",
"unit": "°C"
}
]
}
```
### Notes
- `trend` must be either `positive` or `negative`
- `trendNumber` should be numeric
- `subtitle` can already be formatted by backend if preferred
---
## 4) Radar Chart API
This API powers `src/views/dashboards/farm/SensorRadarChart.tsx`
### Suggested endpoint
`GET /api/sensors/radar-chart/`
### Required query params
- `farm_uuid`
- `physical_device_uuid`
- `range` - example: `today`, `7d`, `30d`
### Required response shape
```json
{
"labels": [
"Moisture",
"Temperature",
"Humidity",
"PH",
"EC",
"Nitrogen",
"Potassium"
],
"series": [
{
"name": "وضعیت فعلی",
"data": [61, 27.4, 58, 6.7, 1.45, 44, 39]
},
{
"name": "بازه ایده آل",
"data": [60, 26, 55, 6.5, 1.3, 42, 38]
}
]
}
```
### Notes
- This endpoint is also independent
- `labels.length` must match every `series[i].data.length`
---
## 5) Recommended Shared Backend Rules
These rules should be consistent across all four APIs:
- all timestamps in ISO 8601 format
- all numeric sensor values returned as numbers
- all UUID-like identifiers stable and non-null when available
- responses filtered by `farm_uuid`
- responses filtered by `physical_device_uuid` when a single sensor page is opened
- clear 4xx/5xx error messages in `msg` or `detail`
---
## 6) Sensor Metadata Recommended For Future Use
These fields are not all required immediately, but they will help future UI work:
- sensor display name
- sensor type
- installation location
- block / zone / greenhouse name
- unit for each measurable field
- min / max ideal thresholds per field
- sensor online / offline state
- last sync timestamp
- battery status
- signal strength
Example:
```json
{
"field_meta": {
"moisture": { "label": "Moisture", "unit": "%", "ideal_min": 45, "ideal_max": 70 },
"temperature": { "label": "Temperature", "unit": "°C", "ideal_min": 18, "ideal_max": 30 },
"ph": { "label": "PH", "unit": "", "ideal_min": 5.8, "ideal_max": 7.2 }
}
}
```
---
## 7) Current Frontend State
Right now the page:
- uses full mock data for logs and sensor details
- uses mock-derived data for charts
- has the right-side details panel removed
- expects the three chart cards to be fed from three separate APIs later
---
## 8) Minimum Backend Delivery Checklist
To connect the page to real backend data, the minimum required items are:
- one paginated logs API with `farm_uuid` and ideally `physical_device_uuid`
- one API for comparison chart card
- one API for values list card
- one API for radar chart card
- numeric sensor payload values
- stable sensor identity using `physical_device_uuid`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,363 @@
"use client";
import { useMemo } from "react";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Chip from "@mui/material/Chip";
import Grid from "@mui/material/Grid2";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
type StatusColor = "success" | "warning" | "error" | "info";
type AccentTone = "primary" | "success" | "warning" | "info";
type HealthTone = "good" | "medium" | "critical" | "unknown";
export interface SensorHealthPanelProps {
payload?: Record<string, unknown> | null;
sensorName?: string | null;
title?: string;
description?: string;
emptyMessage?: string;
maxItems?: number;
}
const formatPersianNumber = (value: number): string =>
value.toLocaleString("fa-IR");
const formatScalar = (value: unknown): string => {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) return `[${value.length} item]`;
if (typeof value === "object") return "{...}";
return String(value);
};
const normalizeFieldLabel = (key: string): string =>
key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.trim();
const formatFieldValue = (value: unknown): string => {
if (typeof value === "number") {
return Number.isInteger(value)
? formatPersianNumber(value)
: value.toLocaleString("fa-IR", { maximumFractionDigits: 2 });
}
if (typeof value === "boolean") {
return value ? "فعال" : "غیرفعال";
}
return formatScalar(value);
};
const getHealthTone = (key: string, value: unknown): HealthTone => {
const normalizedKey = key.toLowerCase();
if (typeof value === "boolean") {
return value ? "good" : "critical";
}
if (typeof value !== "number" || Number.isNaN(value)) {
return "unknown";
}
if (
normalizedKey.includes("temperature") ||
normalizedKey.includes("temp") ||
normalizedKey.includes("soil_temp")
) {
if (value >= 16 && value <= 30) return "good";
if ((value >= 10 && value < 16) || (value > 30 && value <= 36)) return "medium";
return "critical";
}
if (
normalizedKey.includes("humidity") ||
normalizedKey.includes("moisture") ||
normalizedKey.includes("water")
) {
if (value >= 35 && value <= 75) return "good";
if ((value >= 20 && value < 35) || (value > 75 && value <= 90)) return "medium";
return "critical";
}
if (normalizedKey.includes("ph")) {
if (value >= 5.8 && value <= 7.2) return "good";
if ((value >= 5.2 && value < 5.8) || (value > 7.2 && value <= 7.8)) return "medium";
return "critical";
}
if (normalizedKey.includes("ec") || normalizedKey.includes("salinity")) {
if (value >= 0.8 && value <= 2.2) return "good";
if ((value >= 0.4 && value < 0.8) || (value > 2.2 && value <= 3.2)) return "medium";
return "critical";
}
if (
normalizedKey.includes("nitrogen") ||
normalizedKey === "n" ||
normalizedKey.includes("phosphorus") ||
normalizedKey === "p" ||
normalizedKey.includes("potassium") ||
normalizedKey === "k"
) {
if (value >= 20 && value <= 80) return "good";
if ((value >= 10 && value < 20) || (value > 80 && value <= 100)) return "medium";
return "critical";
}
if (value >= 0 && value <= 100) {
if (value >= 35 && value <= 75) return "good";
if ((value >= 20 && value < 35) || (value > 75 && value <= 90)) return "medium";
return "critical";
}
return "unknown";
};
const getHealthToneMeta = (
tone: HealthTone,
): { label: string; muiColor: StatusColor; paletteKey: AccentTone } => {
switch (tone) {
case "good":
return { label: "سالم", muiColor: "success", paletteKey: "success" };
case "medium":
return { label: "قابل بررسی", muiColor: "warning", paletteKey: "warning" };
case "critical":
return { label: "نیازمند رسیدگی", muiColor: "error", paletteKey: "warning" };
default:
return { label: "نامشخص", muiColor: "info", paletteKey: "info" };
}
};
const SensorHealthPanel = ({
payload,
sensorName,
title = "سلامت بخش های سنسور",
description = "این بخش یک نمای تصویری از وضعیت قسمت های مختلف سنسور و داده های آخرین payload را نشان می دهد.",
emptyMessage = "هنوز داده کافی برای ساخت نمای سلامت سنسور در آخرین payload وجود ندارد.",
maxItems = 6,
}: SensorHealthPanelProps) => {
const theme = useTheme();
const healthItems = useMemo(() => {
if (!payload || typeof payload !== "object") return [];
return Object.entries(payload)
.filter(([, value]) =>
["number", "string", "boolean"].includes(typeof value) || value === null,
)
.slice(0, maxItems)
.map(([key, value]) => {
const tone = getHealthTone(key, value);
return {
key,
label: normalizeFieldLabel(key),
value: formatFieldValue(value),
tone,
meta: getHealthToneMeta(tone),
};
});
}, [maxItems, payload]);
const goodCount = healthItems.filter((item) => item.tone === "good").length;
const warningCount = healthItems.filter((item) => item.tone === "medium").length;
const criticalCount = healthItems.filter((item) => item.tone === "critical").length;
return (
<Card
elevation={0}
sx={{
borderRadius: 5,
border: `1px solid ${alpha(theme.palette.primary.main, 0.14)}`,
background: `linear-gradient(135deg, ${alpha(
theme.palette.primary.main,
0.08,
)} 0%, ${alpha(theme.palette.info.main, 0.06)} 48%, ${alpha(
theme.palette.background.paper,
0.98,
)} 100%)`,
overflow: "hidden",
}}
>
<CardContent className="p-5 sm:p-6">
<Grid container spacing={3} alignItems="center">
<Grid size={{ xs: 12, lg: 5 }}>
<Box
sx={{
position: "relative",
minHeight: 260,
borderRadius: 5,
overflow: "hidden",
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
background: `radial-gradient(circle at 50% 20%, ${alpha(
theme.palette.info.light,
0.36,
)} 0%, ${alpha(theme.palette.primary.main, 0.14)} 30%, ${alpha(
theme.palette.background.paper,
0.92,
)} 75%)`,
}}
>
<Box
sx={{
position: "absolute",
inset: 0,
backgroundImage: `linear-gradient(${alpha(
theme.palette.primary.main,
0.08,
)} 1px, transparent 1px), linear-gradient(90deg, ${alpha(
theme.palette.primary.main,
0.08,
)} 1px, transparent 1px)`,
backgroundSize: "28px 28px",
}}
/>
{[0, 1, 2].map((layer) => (
<Box
key={layer}
sx={{
position: "absolute",
insetInline: "14%",
bottom: 30 + layer * 36,
height: 56,
borderRadius: "50%",
border: `1px solid ${alpha(theme.palette.primary.main, 0.18)}`,
background: `linear-gradient(180deg, ${alpha(
theme.palette.background.paper,
0.94 - layer * 0.14,
)} 0%, ${alpha(theme.palette.primary.main, 0.12 + layer * 0.04)} 100%)`,
transform: `perspective(900px) rotateX(68deg) scale(${1 - layer * 0.08})`,
boxShadow: `0 16px 30px ${alpha(theme.palette.primary.main, 0.08)}`,
}}
/>
))}
{healthItems.slice(0, 4).map((item, index) => {
const palette = theme.palette[item.meta.paletteKey];
return (
<Box
key={item.key}
sx={{
position: "absolute",
top: 38 + index * 34,
insetInlineStart: index % 2 === 0 ? "18%" : "56%",
px: 1.25,
py: 0.75,
borderRadius: 3,
border: `1px solid ${alpha(palette.main, 0.28)}`,
backgroundColor: alpha(palette.main, 0.12),
backdropFilter: "blur(8px)",
boxShadow: `0 10px 24px ${alpha(palette.main, 0.14)}`,
}}
>
<Typography variant="caption" display="block" fontWeight={700}>
{item.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.value}
</Typography>
</Box>
);
})}
<Box
sx={{
position: "absolute",
insetInline: 20,
bottom: 18,
display: "flex",
justifyContent: "space-between",
gap: 1,
}}
>
<Chip size="small" color="success" label={`${formatPersianNumber(goodCount)} سالم`} />
<Chip size="small" color="warning" label={`${formatPersianNumber(warningCount)} بررسی`} />
<Chip size="small" color="error" label={`${formatPersianNumber(criticalCount)} بحرانی`} />
</Box>
</Box>
</Grid>
<Grid size={{ xs: 12, lg: 7 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h5" className="font-semibold">
{title}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{description}
</Typography>
</Box>
<Box
sx={{
p: 2,
borderRadius: 4,
border: `1px solid ${theme.palette.divider}`,
backgroundColor: alpha(theme.palette.background.paper, 0.74),
}}
>
<Typography variant="caption" color="text.disabled">
سنسور فعال
</Typography>
<Typography variant="h6" className="mt-1">
{sensorName || "سنسور انتخاب نشده"}
</Typography>
</Box>
{healthItems.length ? (
<Grid container spacing={2}>
{healthItems.map((item) => (
<Grid key={item.key} size={{ xs: 12, sm: 6 }}>
<Box
sx={{
p: 2,
borderRadius: 4,
border: `1px solid ${alpha(
theme.palette[item.meta.paletteKey].main,
0.2,
)}`,
backgroundColor: alpha(
theme.palette[item.meta.paletteKey].main,
0.08,
),
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
className="mb-2"
>
<Typography variant="body2" fontWeight={700}>
{item.label}
</Typography>
<Chip size="small" color={item.meta.muiColor} label={item.meta.label} />
</Stack>
<Typography variant="h6">{item.value}</Typography>
</Box>
</Grid>
))}
</Grid>
) : (
<Alert severity="info">{emptyMessage}</Alert>
)}
</Stack>
</Grid>
</Grid>
</CardContent>
</Card>
);
};
export default SensorHealthPanel;