Refactor forecast and historian-driven meteo dashboard
@@ -1,8 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main and chart windows",
|
||||||
"windows": ["main", "chart-*"],
|
"windows": [
|
||||||
|
"main",
|
||||||
|
"chart-*",
|
||||||
|
"maincharts-*",
|
||||||
|
"climatecharts-*"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
@@ -19,6 +24,9 @@
|
|||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
|
|
||||||
"core:window:allow-start-dragging"
|
"core:window:allow-start-dragging",
|
||||||
|
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-emit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -9,24 +9,24 @@ import { ConsolePage } from "../features/console/pages/ConsolePage";
|
|||||||
import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage";
|
import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage";
|
||||||
import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage";
|
import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage";
|
||||||
import { SettingsPage } from "../features/settings/pages/SettingsPage";
|
import { SettingsPage } from "../features/settings/pages/SettingsPage";
|
||||||
|
import SynopticPage from "../features/synoptic/pages/SynopticPage";
|
||||||
|
|
||||||
export type AppPage =
|
export type AppPage =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "meteo"
|
| "meteo"
|
||||||
| "console"
|
| "console"
|
||||||
| "maincharts"
|
| "maincharts"
|
||||||
|
| "synoptic"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "climate"
|
| "climate"
|
||||||
| "climateCharts"
|
| "climateCharts"
|
||||||
| "climateLighting"
|
| "climateLighting"
|
||||||
| "climateVentilation"
|
| "climateVentilation"
|
||||||
| "climateSynoptic"
|
|
||||||
| "irrigation"
|
| "irrigation"
|
||||||
| "irrigationCharts"
|
| "irrigationCharts"
|
||||||
| "irrigationFilters"
|
| "irrigationFilters"
|
||||||
| "irrigationConsumption"
|
| "irrigationConsumption"
|
||||||
| "irrigationDrainage"
|
| "irrigationDrainage"
|
||||||
| "irrigationSynoptic";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||||
@@ -59,6 +59,10 @@ function App() {
|
|||||||
return <SettingsPage theme={theme} />;
|
return <SettingsPage theme={theme} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activePage === "synoptic") {
|
||||||
|
return <SynopticPage theme={theme} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -302,6 +302,9 @@ function pageTitle(page: AppPage | null) {
|
|||||||
case "meteo":
|
case "meteo":
|
||||||
return "Meteorologia";
|
return "Meteorologia";
|
||||||
|
|
||||||
|
case "synoptic":
|
||||||
|
return "Sinótico";
|
||||||
|
|
||||||
case "settings":
|
case "settings":
|
||||||
return "Configurações";
|
return "Configurações";
|
||||||
|
|
||||||
@@ -313,7 +316,6 @@ function pageTitle(page: AppPage | null) {
|
|||||||
case "climateCharts":
|
case "climateCharts":
|
||||||
case "climateLighting":
|
case "climateLighting":
|
||||||
case "climateVentilation":
|
case "climateVentilation":
|
||||||
case "climateSynoptic":
|
|
||||||
return "Clima";
|
return "Clima";
|
||||||
|
|
||||||
// ALL IRRIGATION / REGA PAGES
|
// ALL IRRIGATION / REGA PAGES
|
||||||
@@ -322,7 +324,6 @@ function pageTitle(page: AppPage | null) {
|
|||||||
case "irrigationFilters":
|
case "irrigationFilters":
|
||||||
case "irrigationConsumption":
|
case "irrigationConsumption":
|
||||||
case "irrigationDrainage":
|
case "irrigationDrainage":
|
||||||
case "irrigationSynoptic":
|
|
||||||
return "Rega";
|
return "Rega";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -37,19 +37,19 @@ const navigationItems: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ label: "Painel Principal", page: "dashboard", icon: Home },
|
{ label: "Painel Principal", page: "dashboard", icon: Home },
|
||||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
||||||
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 }
|
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 },
|
||||||
|
{ label: "Sinótico", page: "synoptic" , icon: MonitorDot }
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const climateItems = [
|
const climateItems = [
|
||||||
{ label: "Iluminação", icon: Lightbulb },
|
{ label: "Iluminação", icon: Lightbulb },
|
||||||
{ label: "Ventilação", icon: Wind },
|
{ label: "Ventilação", icon: Wind },
|
||||||
{ label: "Sinótico", icon: MonitorDot },
|
|
||||||
{ label: "Gráficos", icon: BarChart3 },
|
{ label: "Gráficos", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const irrigationItems = [
|
const irrigationItems = [
|
||||||
{ label: "Regas", icon: Droplet },
|
{ label: "Regas", icon: Droplet },
|
||||||
{ label: "Sinótico", icon: MonitorDot },
|
|
||||||
{ label: "Filtros de Rega", icon: Filter },
|
{ label: "Filtros de Rega", icon: Filter },
|
||||||
{ label: "Consumos", icon: Gauge },
|
{ label: "Consumos", icon: Gauge },
|
||||||
{ label: "Drenagem", icon: Waves },
|
{ label: "Drenagem", icon: Waves },
|
||||||
|
|||||||
@@ -262,8 +262,10 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
|
|||||||
>
|
>
|
||||||
<header className={titleBarClass}>
|
<header className={titleBarClass}>
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-full flex-1 items-center px-3 text-xs font-black"
|
className="flex h-full flex-1 items-center px-3 text-xs font-black"
|
||||||
|
onPointerDown={() => {
|
||||||
|
void currentWindow.startDragging();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{chart.title}
|
{chart.title}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeDetachedChartWindow = async (chartId: string) => {
|
const closeDetachedChartWindow = async (chartId: string) => {
|
||||||
const label = `chart-${chartId}`;
|
const label = `climatecharts-${chartId}`;
|
||||||
const existing = await WebviewWindow.getByLabel(label);
|
const existing = await WebviewWindow.getByLabel(label);
|
||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeDetachedChartWindow = async (chartId: string) => {
|
const closeDetachedChartWindow = async (chartId: string) => {
|
||||||
const label = `chart-${chartId}`;
|
const label = `maincharts-${chartId}`;
|
||||||
const existing = await WebviewWindow.getByLabel(label);
|
const existing = await WebviewWindow.getByLabel(label);
|
||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||||
|
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
||||||
|
|
||||||
|
const BACKEND_URL = "http://localhost:18450";
|
||||||
|
|
||||||
|
type SensorHistoryMap = Record<string, HistorianPoint[]>;
|
||||||
|
|
||||||
|
export function useMeteoMultiHistory(
|
||||||
|
sensors: Array<ModuleSensorResponse | null>,
|
||||||
|
hours = 6,
|
||||||
|
) {
|
||||||
|
const [pointsByKey, setPointsByKey] = useState<SensorHistoryMap>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const sensorKeys = sensors
|
||||||
|
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor))
|
||||||
|
.map((sensor) => sensor.key);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sensorKeys.length) {
|
||||||
|
setPointsByKey({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
sensorKeys.map(async (key) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
key,
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load history for ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as HistorianPoint[];
|
||||||
|
|
||||||
|
return [key, payload] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setPointsByKey(Object.fromEntries(entries));
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
console.error("Failed to load meteo histories", error);
|
||||||
|
setPointsByKey({});
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [sensorKeys.join("|"), hours]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pointsByKey,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Droplet,
|
||||||
|
Gauge,
|
||||||
|
Home,
|
||||||
|
Layers3,
|
||||||
|
Lightbulb,
|
||||||
|
Thermometer,
|
||||||
|
Wind,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||||
|
import { useClimateModuleStream } from "../../climate/hooks/useClimateModuleStream";
|
||||||
|
|
||||||
|
export type SynopticVariableGroup = "Clima" | "Rega";
|
||||||
|
|
||||||
|
export type SynopticVariable = {
|
||||||
|
id: string;
|
||||||
|
sensorId: string;
|
||||||
|
key: string;
|
||||||
|
group: SynopticVariableGroup;
|
||||||
|
label: string;
|
||||||
|
value: string | number | boolean | null;
|
||||||
|
unit: string;
|
||||||
|
timestamp?: string;
|
||||||
|
connected: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSynopticVariables() {
|
||||||
|
const climate = useClimateModuleStream();
|
||||||
|
|
||||||
|
const variables = useMemo<SynopticVariable[]>(
|
||||||
|
() =>
|
||||||
|
toSynopticVariables({
|
||||||
|
sensors: climate.sensors,
|
||||||
|
group: "Clima",
|
||||||
|
connected: climate.connected,
|
||||||
|
fallbackTimestamp: climate.lastTimestamp,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
climate.sensors,
|
||||||
|
climate.connected,
|
||||||
|
climate.lastTimestamp,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
variables,
|
||||||
|
groups: groupVariables(variables),
|
||||||
|
connected: climate.connected,
|
||||||
|
climateConnected: climate.connected,
|
||||||
|
loading: !climate.module,
|
||||||
|
total: variables.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSynopticVariables({
|
||||||
|
sensors,
|
||||||
|
group,
|
||||||
|
connected,
|
||||||
|
fallbackTimestamp,
|
||||||
|
}: {
|
||||||
|
sensors: ModuleSensorResponse[];
|
||||||
|
group: SynopticVariableGroup;
|
||||||
|
connected: boolean;
|
||||||
|
fallbackTimestamp: string | null;
|
||||||
|
}): SynopticVariable[] {
|
||||||
|
return sensors.map((sensor) => {
|
||||||
|
const visual = getVariableVisual(sensor, group);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${group}:${sensor.sensorId ?? sensor.key}`,
|
||||||
|
sensorId: String(sensor.sensorId ?? sensor.key),
|
||||||
|
key: sensor.key,
|
||||||
|
group,
|
||||||
|
label: sensor.name || sensor.key,
|
||||||
|
value: normalizeValue(sensor.value),
|
||||||
|
unit: sensor.unit ?? "",
|
||||||
|
timestamp: sensor.timestamp ?? fallbackTimestamp ?? undefined,
|
||||||
|
connected,
|
||||||
|
icon: visual.icon,
|
||||||
|
color: visual.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupVariables(variables: SynopticVariable[]) {
|
||||||
|
return variables.reduce<Record<SynopticVariableGroup, SynopticVariable[]>>(
|
||||||
|
(acc, variable) => {
|
||||||
|
acc[variable.group].push(variable);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Clima: [],
|
||||||
|
Rega: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value: unknown): string | number | boolean | null {
|
||||||
|
if (
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
value === null
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariableVisual(
|
||||||
|
sensor: ModuleSensorResponse,
|
||||||
|
group: SynopticVariableGroup,
|
||||||
|
): {
|
||||||
|
icon: LucideIcon;
|
||||||
|
color: string;
|
||||||
|
} {
|
||||||
|
const text = normalizeText(`${sensor.key} ${sensor.name} ${sensor.unit ?? ""}`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes("temperatura") ||
|
||||||
|
text.includes("temperature")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: Thermometer,
|
||||||
|
color: "#4FD1C5",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes("humidade") ||
|
||||||
|
text.includes("humidity")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: Droplet,
|
||||||
|
color: "#38BDF8",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes("vento") ||
|
||||||
|
text.includes("wind")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: Wind,
|
||||||
|
color: "#22D3EE",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes("radiacao") ||
|
||||||
|
text.includes("radiação") ||
|
||||||
|
text.includes("radiation")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: Lightbulb,
|
||||||
|
color: "#FACC15",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes("pressao") ||
|
||||||
|
text.includes("pressão") ||
|
||||||
|
text.includes("pressure")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: Gauge,
|
||||||
|
color: "#38BDF8",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes("nivel") ||
|
||||||
|
text.includes("nível") ||
|
||||||
|
text.includes("level")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: Layers3,
|
||||||
|
color: "#0EA5E9",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group === "Clima") {
|
||||||
|
return {
|
||||||
|
icon: Home,
|
||||||
|
color: "#22C55E",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: Gauge,
|
||||||
|
color: "#A3E635",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{Diacritic}/gu, "");
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export type WeatherForecastResponse = {
|
export type WeatherForecastResponse = {
|
||||||
location: WeatherLocation;
|
location: WeatherLocation;
|
||||||
current: WeatherCurrent;
|
|
||||||
daily: WeatherDaily[];
|
daily: WeatherDaily[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,19 +18,6 @@ export type WeatherCondition = {
|
|||||||
code: number;
|
code: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WeatherCurrent = {
|
|
||||||
temperatureC: number;
|
|
||||||
feelsLikeC: number;
|
|
||||||
humidity: number;
|
|
||||||
precipitationMm: number;
|
|
||||||
windKph: number;
|
|
||||||
windDegree: number;
|
|
||||||
windDirection: string;
|
|
||||||
pressureMb: number;
|
|
||||||
uv: number;
|
|
||||||
condition: WeatherCondition;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WeatherDaily = {
|
export type WeatherDaily = {
|
||||||
date: string;
|
date: string;
|
||||||
maxTemperatureC: number;
|
maxTemperatureC: number;
|
||||||
@@ -40,6 +26,11 @@ export type WeatherDaily = {
|
|||||||
totalPrecipitationMm: number;
|
totalPrecipitationMm: number;
|
||||||
dailyRainChance: number;
|
dailyRainChance: number;
|
||||||
maxWindKph: number;
|
maxWindKph: number;
|
||||||
|
averageWindKph: number;
|
||||||
|
averageWindDegree: number;
|
||||||
|
averageWindDirection: string;
|
||||||
|
averageHumidity: number;
|
||||||
|
averageVisibilityKm: number;
|
||||||
uv: number;
|
uv: number;
|
||||||
sunrise: string;
|
sunrise: string;
|
||||||
sunset: string;
|
sunset: string;
|
||||||
|
|||||||