From a30d41d031ab464fabb7c261de89d70568b4acca Mon Sep 17 00:00:00 2001 From: litoral05 Date: Wed, 20 May 2026 17:27:54 +0100 Subject: [PATCH] Add historian persistence and dashboard trend charts --- src/app/App.tsx | 5 +- src/components/cards/MetricCard.tsx | 108 ++++ src/components/layout/AppShell.tsx | 110 ++-- .../components/DashboardClimateSection.tsx | 105 ++++ .../components/DashboardMeteoSection.tsx | 0 .../components/DashboardOperationsSection.tsx | 60 ++ .../components/DashboardTrendChart.tsx | 180 ++++++ .../dashboard/components/StatusPill.tsx | 31 ++ .../hooks/useDashboardOverviewStream.ts | 46 ++ .../dashboard/hooks/useHistorianDashboard.ts | 60 ++ .../dashboard/pages/DashboardPage.tsx | 512 ++++++++++++++++++ .../dashboard/types/DashboardOverview.ts | 44 ++ .../telemetry/utils/telemetryLookup.ts | 29 + src/index.css | 25 + src/lib/api/historianApi.ts | 25 + src/types/historian.ts | 12 + 16 files changed, 1304 insertions(+), 48 deletions(-) create mode 100644 src/components/cards/MetricCard.tsx create mode 100644 src/features/climate/components/DashboardClimateSection.tsx create mode 100644 src/features/dashboard/components/DashboardMeteoSection.tsx create mode 100644 src/features/dashboard/components/DashboardOperationsSection.tsx create mode 100644 src/features/dashboard/components/DashboardTrendChart.tsx create mode 100644 src/features/dashboard/components/StatusPill.tsx create mode 100644 src/features/dashboard/hooks/useDashboardOverviewStream.ts create mode 100644 src/features/dashboard/hooks/useHistorianDashboard.ts create mode 100644 src/features/dashboard/pages/DashboardPage.tsx create mode 100644 src/features/dashboard/types/DashboardOverview.ts create mode 100644 src/features/telemetry/utils/telemetryLookup.ts create mode 100644 src/lib/api/historianApi.ts create mode 100644 src/types/historian.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 913ce06..a4e2681 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,9 +1,12 @@ import { AppShell } from "../components/layout/AppShell"; +import { DashboardPage } from "../features/dashboard/pages/DashboardPage"; function App() { return ( -
+ {({ theme}) => ( + + )} ); } diff --git a/src/components/cards/MetricCard.tsx b/src/components/cards/MetricCard.tsx new file mode 100644 index 0000000..d81d8d5 --- /dev/null +++ b/src/components/cards/MetricCard.tsx @@ -0,0 +1,108 @@ +import type { LucideIcon } from "lucide-react"; + +type MetricCardProps = { + title: string; + value: string | number; + unit?: string; + icon: LucideIcon; + theme: "dark" | "light"; + accent?: "blue" | "green" | "yellow" | "cyan" | "red"; +}; + +const accentClasses = { + blue: { + icon: "text-sky-400", + bg: "bg-sky-500/10", + glow: "from-sky-500/20", + }, + green: { + icon: "text-emerald-400", + bg: "bg-emerald-500/10", + glow: "from-emerald-500/20", + }, + yellow: { + icon: "text-yellow-400", + bg: "bg-yellow-500/10", + glow: "from-yellow-500/20", + }, + cyan: { + icon: "text-cyan-400", + bg: "bg-cyan-500/10", + glow: "from-cyan-500/20", + }, + red: { + icon: "text-red-400", + bg: "bg-red-500/10", + glow: "from-red-500/20", + }, +}; + +export function MetricCard({ + title, + value, + unit, + icon: Icon, + theme, + accent = "blue", +}: MetricCardProps) { + const isDark = theme === "dark"; + const accentClass = accentClasses[accent]; + + return ( +
+
+ +
+
+ +
+ +
+

+ {title} +

+ +
+ + {value} + + + {unit && ( + + {unit} + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ed1355a..85691f8 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,3 +1,4 @@ +import { ReactNode, useState } from "react"; import { Sidebar } from "../navigation/Sidebar"; import { TopBar } from "./TopBar"; import { BottomStatusBar } from "./BottomStatusBar"; @@ -5,62 +6,77 @@ import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryS import { useNotifications } from "../../features/notifications/hooks/useNotifications"; import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser"; import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig"; +import type { TelemetrySnapshot } from "../../types/telemetry"; -import { useState } from "react"; +type AppShellRenderProps = { + theme: "dark" | "light"; + snapshots: TelemetrySnapshot[]; +}; type AppShellProps = { - children: React.ReactNode; + children: (props: AppShellRenderProps) => ReactNode; }; export function AppShell({ children }: AppShellProps) { - const telemetry = useTelemetryStream(); - const notifications = useNotifications(); - const currentUser = useCurrentUser(); - const [theme, setTheme] = useState<"dark" | "light">("dark"); - const runtime = useRuntimeConfig(); + const telemetry = useTelemetryStream(); + const notifications = useNotifications(); + const currentUser = useCurrentUser(); + const runtime = useRuntimeConfig(); - const toggleTheme = () => { - setTheme((current) => (current === "dark" ? "light" : "dark")); - }; + const [theme, setTheme] = useState<"dark" | "light">("dark"); - const isDark = theme === "dark"; - return ( -
-
-
- -
-
+ const toggleTheme = () => { + setTheme((current) => (current === "dark" ? "light" : "dark")); + }; -
- + const isDark = theme === "dark"; -
- {children} -
- - -
-
+ return ( +
+
+
+ +
- ); + +
+ + +
+ {children({ + theme, + snapshots: telemetry.snapshots, + })} +
+ + +
+
+
+ ); } \ No newline at end of file diff --git a/src/features/climate/components/DashboardClimateSection.tsx b/src/features/climate/components/DashboardClimateSection.tsx new file mode 100644 index 0000000..4efd6d3 --- /dev/null +++ b/src/features/climate/components/DashboardClimateSection.tsx @@ -0,0 +1,105 @@ +import { Gauge, Thermometer, Waves } from "lucide-react"; +import type { DashboardOverview } from "../../dashboard/types/DashboardOverview"; +import { StatusPill } from "../../dashboard/components/StatusPill"; + +type Props = { + zones: DashboardOverview["climate"]["zones"]; +}; + +function formatValue(value: number | null, unit: string, decimals = 1) { + if (value === null) return "--"; + return `${value.toFixed(decimals)} ${unit}`; +} + +export function DashboardClimateSection({ zones }: Props) { + return ( +
+
+

+ Clima por Zona +

+

+ Temperatura, humidade, CO₂ e estados principais dos equipamentos. +

+
+ +
+ {zones.map((zone) => ( +
+
+

+ Zona {zone.zoneNumber} +

+ +
+ + +
+
+ +
+ + + +
+ +
+ + + + +
+
+ ))} +
+
+ ); +} + +type MiniValueProps = { + icon: React.ElementType; + label: string; + value: string; +}; + +function MiniValue({ icon: Icon, label, value }: MiniValueProps) { + return ( +
+
+ + {label} +
+

+ {value} +

+
+ ); +} + +type OpeningBarProps = { + label: string; + value: number | null; +}; + +function OpeningBar({ label, value }: OpeningBarProps) { + const safeValue = value ?? 0; + + return ( +
+
+ {label} + {value === null ? "--" : `${value.toFixed(0)}%`} +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/features/dashboard/components/DashboardMeteoSection.tsx b/src/features/dashboard/components/DashboardMeteoSection.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/features/dashboard/components/DashboardOperationsSection.tsx b/src/features/dashboard/components/DashboardOperationsSection.tsx new file mode 100644 index 0000000..71fadc8 --- /dev/null +++ b/src/features/dashboard/components/DashboardOperationsSection.tsx @@ -0,0 +1,60 @@ +import { Droplets, Lightbulb, Power } from "lucide-react"; +import type { DashboardOverview } from "../types/DashboardOverview"; + +type Props = { + irrigation?: DashboardOverview["irrigation"]; + lighting?: DashboardOverview["lighting"]; +}; + +export function DashboardOperationsSection({ irrigation, lighting }: Props) { + return ( +
+ + + + + +
+ ); +} + +type OperationCardProps = { + title: string; + value: string; + subtitle: string; + icon: React.ElementType; +}; + +function OperationCard({ title, value, subtitle, icon: Icon }: OperationCardProps) { + return ( +
+
+

{title}

+ +
+ +

+ {value} +

+ +

+ {subtitle} +

+
+ ); +} \ No newline at end of file diff --git a/src/features/dashboard/components/DashboardTrendChart.tsx b/src/features/dashboard/components/DashboardTrendChart.tsx new file mode 100644 index 0000000..87470f6 --- /dev/null +++ b/src/features/dashboard/components/DashboardTrendChart.tsx @@ -0,0 +1,180 @@ +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { HistorianDashboardResponse } from "../../../types/historian"; + +type ChartSeries = { + key: string; + label: string; + color: string; +}; + +type DashboardTrendChartProps = { + title: string; + subtitle: string; + data: HistorianDashboardResponse | null; + series: ChartSeries[]; + theme: "dark" | "light"; +}; + +export function DashboardTrendChart({ + title, + subtitle, + data, + series, + theme, +}: DashboardTrendChartProps) { + const isDark = theme === "dark"; + const chartData = buildChartData(data, series); + + return ( +
+
+
+

+ {title} +

+

+ {subtitle} +

+
+ +
+ {series.map((item) => ( +
+ + + {item.label} + +
+ ))} +
+
+ +
+ + + + {series.map((item) => ( + + + + + ))} + + + + + + + + + + + {series.map((item) => ( + + ))} + + +
+
+ ); +} + +function buildChartData( + data: HistorianDashboardResponse | null, + series: ChartSeries[], +) { + if (!data) return []; + + const pointsByTimestamp = new Map>(); + + for (const item of series) { + const points = data.series[item.key] ?? []; + + for (const point of points) { + const timestamp = point.timestamp; + const existing = pointsByTimestamp.get(timestamp) ?? { + timestamp, + time: formatTime(timestamp), + }; + + existing[item.label] = point.numericValue ?? 0; + pointsByTimestamp.set(timestamp, existing); + } + } + + return Array.from(pointsByTimestamp.values()).sort((a, b) => + String(a.timestamp).localeCompare(String(b.timestamp)), + ); +} + +function formatTime(timestamp: string) { + return new Date(timestamp).toLocaleTimeString("pt-PT", { + hour: "2-digit", + minute: "2-digit", + }); +} + +function gradientId(label: string) { + return `gradient-${label + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/\./g, "")}`; +} \ No newline at end of file diff --git a/src/features/dashboard/components/StatusPill.tsx b/src/features/dashboard/components/StatusPill.tsx new file mode 100644 index 0000000..cafeaf0 --- /dev/null +++ b/src/features/dashboard/components/StatusPill.tsx @@ -0,0 +1,31 @@ +type StatusPillProps = { + active: boolean | null | undefined; + activeLabel?: string; + inactiveLabel?: string; +}; + +export function StatusPill({ + active, + activeLabel = "Ativo", + inactiveLabel = "Inativo", +}: StatusPillProps) { + if (active === null || active === undefined) { + return ( + + -- + + ); + } + + return ( + + {active ? activeLabel : inactiveLabel} + + ); +} \ No newline at end of file diff --git a/src/features/dashboard/hooks/useDashboardOverviewStream.ts b/src/features/dashboard/hooks/useDashboardOverviewStream.ts new file mode 100644 index 0000000..e9f620e --- /dev/null +++ b/src/features/dashboard/hooks/useDashboardOverviewStream.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { Client } from "@stomp/stompjs"; +import type { DashboardOverview } from "../types/DashboardOverview"; + +export function useDashboardOverviewStream() { + const [overview, setOverview] = useState(null); + const [connected, setConnected] = useState(false); + + useEffect(() => { + const client = new Client({ + brokerURL: "ws://localhost:18450/ws", + reconnectDelay: 3000, + + onConnect: () => { + setConnected(true); + + client.subscribe("/topic/dashboard/overview", (frame) => { + const payload = JSON.parse(frame.body) as DashboardOverview; + + console.log("dashboard overview", payload); + + setOverview(payload); + }); + }, + + onWebSocketClose: () => { + setConnected(false); + }, + + onStompError: () => { + setConnected(false); + }, + }); + + client.activate(); + + return () => { + client.deactivate(); + }; + }, []); + + return { + connected, + overview, + }; +} \ No newline at end of file diff --git a/src/features/dashboard/hooks/useHistorianDashboard.ts b/src/features/dashboard/hooks/useHistorianDashboard.ts new file mode 100644 index 0000000..f574c10 --- /dev/null +++ b/src/features/dashboard/hooks/useHistorianDashboard.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { fetchHistorianDashboard } from "../../../lib/api/historianApi"; +import type { HistorianDashboardResponse } from "../../../types/historian"; + +type UseHistorianDashboardOptions = { + keys: string[]; + minutesBack?: number; + refreshIntervalMs?: number; +}; + +export function useHistorianDashboard({ + keys, + minutesBack = 30, + refreshIntervalMs = 15000, +}: UseHistorianDashboardOptions) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const to = new Date(); + const from = new Date(to.getTime() - minutesBack * 60 * 1000); + + const result = await fetchHistorianDashboard( + keys, + from.toISOString(), + to.toISOString(), + ); + + if (!cancelled) { + setData(result); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err : new Error("Historian error")); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + load(); + + const interval = window.setInterval(load, refreshIntervalMs); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [keys.join("|"), minutesBack, refreshIntervalMs]); + + return { data, loading, error }; +} \ No newline at end of file diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx new file mode 100644 index 0000000..a12d438 --- /dev/null +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -0,0 +1,512 @@ +import { + Activity, + CloudRain, + Droplets, + Fan, + Lightbulb, + Sun, + Thermometer, + Wind, + Zap, +} from "lucide-react"; +import { MetricCard } from "../../../components/cards/MetricCard"; +import { DashboardTrendChart } from "../components/DashboardTrendChart"; +import { useDashboardOverviewStream } from "../hooks/useDashboardOverviewStream"; +import { useHistorianDashboard } from "../hooks/useHistorianDashboard"; + +type DashboardPageProps = { + theme: "dark" | "light"; +}; + +const historianKeys = [ + "meteo.exterior_temperature", + "meteo.exterior_humidity", + "meteo.radiation", + "climate.zone_1.temperature", + "climate.zone_1.humidity", +]; + +function formatNumber(value: number | null | undefined, decimals = 1) { + if (value === null || value === undefined) return "--"; + return value.toFixed(decimals); +} + +function formatBoolean(value: boolean | null | undefined) { + if (value === null || value === undefined) return "--"; + return value ? "Sim" : "Não"; +} + +export function DashboardPage({ theme }: DashboardPageProps) { + const { overview, connected } = useDashboardOverviewStream(); + + const { data: historianData } = useHistorianDashboard({ + keys: historianKeys, + minutesBack: 30, + refreshIntervalMs: 15000, + }); + + const meteo = overview?.meteo; + const zones = overview?.climate.zones ?? []; + const zoneOne = zones[0]; + + const isDark = theme === "dark"; + + const onlineZoneCount = zones.filter( + (zone) => zone.temperature !== null || zone.humidity !== null, + ).length; + + const validZoneTemperatures = zones + .map((zone) => zone.temperature) + .filter((value): value is number => value !== null); + + const averageZoneTemperature = + validZoneTemperatures.length > 0 + ? validZoneTemperatures.reduce((sum, value) => sum + value, 0) / + validZoneTemperatures.length + : null; + + return ( +
+
+ + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + +
+ +
+
+
+
+

+ Estado Operacional +

+

+ Resumo rápido da instalação. +

+
+ + +
+ +
+ + + + +
+
+ +
+
+

+ Próximos Desenvolvimentos +

+

+ Espaço preparado para eventos, alarmes, programas e depósitos. +

+
+ +
+ + + +
+
+
+
+ ); +} + +type DashboardModuleCardProps = { + theme: "dark" | "light"; + title: string; + icon: React.ElementType; + main: string; + label: string; + accent: "blue" | "green" | "yellow" | "cyan"; + items: [string, string][]; +}; + +const moduleAccentClasses = { + blue: { + icon: "text-sky-400", + bg: "bg-sky-500/10", + }, + green: { + icon: "text-emerald-400", + bg: "bg-emerald-500/10", + }, + yellow: { + icon: "text-yellow-400", + bg: "bg-yellow-500/10", + }, + cyan: { + icon: "text-cyan-400", + bg: "bg-cyan-500/10", + }, +}; + +function DashboardModuleCard({ + theme, + title, + icon: Icon, + main, + label, + accent, + items, +}: DashboardModuleCardProps) { + const isDark = theme === "dark"; + const accentClass = moduleAccentClasses[accent]; + + return ( +
+
+

+ {title} +

+ +
+ +
+
+ +

+ {main} +

+ +

+ {label} +

+ +
+ {items.map(([key, value]) => ( +
+ + {key} + + + {value} + +
+ ))} +
+
+ ); +} + +type StatusRowProps = { + theme: "dark" | "light"; + label: string; + value: string; + good: boolean; +}; + +function StatusRow({ theme, label, value, good }: StatusRowProps) { + const isDark = theme === "dark"; + + return ( +
+ + {label} + + + + {value} + +
+ ); +} + +type PlaceholderPanelProps = { + theme: "dark" | "light"; + title: string; + text: string; +}; + +function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) { + const isDark = theme === "dark"; + + return ( +
+

+ {title} +

+

+ {text} +

+
+ ); +} \ No newline at end of file diff --git a/src/features/dashboard/types/DashboardOverview.ts b/src/features/dashboard/types/DashboardOverview.ts new file mode 100644 index 0000000..1e7fdb9 --- /dev/null +++ b/src/features/dashboard/types/DashboardOverview.ts @@ -0,0 +1,44 @@ +export type DashboardOverview = { + timestamp: string; + + meteo: { + exteriorTemperature: number | null; + exteriorHumidity: number | null; + radiation: number | null; + windSpeed: number | null; + windDirection: number | null; + raining: boolean | null; + }; + + climate: { + zoneCount: number; + + zones: { + zoneNumber: number; + + temperature: number | null; + humidity: number | null; + co2: number | null; + + fansOn: boolean | null; + extractorsOn: boolean | null; + + zenitalLeftPercent: number | null; + zenitalRightPercent: number | null; + + lateralLeftPercent: number | null; + lateralRightPercent: number | null; + }[]; + }; + + irrigation: { + controllerCount: number; + activeValveCount: number; + activePumpCount: number; + }; + + lighting: { + sectorCount: number; + activeSectorCount: number; + }; +}; \ No newline at end of file diff --git a/src/features/telemetry/utils/telemetryLookup.ts b/src/features/telemetry/utils/telemetryLookup.ts new file mode 100644 index 0000000..dd8c911 --- /dev/null +++ b/src/features/telemetry/utils/telemetryLookup.ts @@ -0,0 +1,29 @@ +import type { TelemetrySnapshot } from "../../../types/telemetry"; + +export function findTelemetryByName( + snapshots: TelemetrySnapshot[], + name: string, +): TelemetrySnapshot | null { + return snapshots.find((snapshot) => snapshot.name === name) ?? null; +} + +export function formatTelemetryValue( + snapshot: TelemetrySnapshot | null, + fallback = "--", +): string { + if (!snapshot || snapshot.value === null || snapshot.value === undefined) { + return fallback; + } + + if (typeof snapshot.value === "boolean") { + return snapshot.value ? "Sim" : "Não"; + } + + if (typeof snapshot.value === "number") { + return Number.isInteger(snapshot.value) + ? snapshot.value.toString() + : snapshot.value.toFixed(1); + } + + return snapshot.value; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index c777870..3dd2255 100644 --- a/src/index.css +++ b/src/index.css @@ -10,4 +10,29 @@ body, body { font-family: Inter, system-ui, sans-serif; +} + +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(56, 189, 248, 0.45) rgba(15, 23, 42, 0.35); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.35); + border-radius: 999px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(56, 189, 248, 0.7), rgba(14, 165, 233, 0.35)); + border: 2px solid rgba(15, 23, 42, 0.9); + border-radius: 999px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(56, 189, 248, 0.95), rgba(14, 165, 233, 0.55)); } \ No newline at end of file diff --git a/src/lib/api/historianApi.ts b/src/lib/api/historianApi.ts new file mode 100644 index 0000000..111f041 --- /dev/null +++ b/src/lib/api/historianApi.ts @@ -0,0 +1,25 @@ +import type { HistorianDashboardResponse } from "../../types/historian"; + +const API_BASE_URL = "http://localhost:18450"; + +export async function fetchHistorianDashboard( + keys: string[], + from: string, + to: string, +): Promise { + const params = new URLSearchParams(); + + keys.forEach((key) => params.append("keys", key)); + params.set("from", from); + params.set("to", to); + + const response = await fetch( + `${API_BASE_URL}/api/historian/dashboard?${params.toString()}`, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch historian dashboard: ${response.status}`); + } + + return response.json(); +} \ No newline at end of file diff --git a/src/types/historian.ts b/src/types/historian.ts new file mode 100644 index 0000000..f52b32f --- /dev/null +++ b/src/types/historian.ts @@ -0,0 +1,12 @@ +export type HistorianSeriesPoint = { + timestamp: string; + numericValue: number | null; + booleanValue: boolean | null; + textValue: string | null; +}; + +export type HistorianDashboardResponse = { + from: string; + to: string; + series: Record; +}; \ No newline at end of file