Add historian persistence and dashboard trend charts

This commit is contained in:
litoral05
2026-05-20 17:27:54 +01:00
parent 9fcf67c7ae
commit a30d41d031
16 changed files with 1304 additions and 48 deletions
+4 -1
View File
@@ -1,9 +1,12 @@
import { AppShell } from "../components/layout/AppShell";
import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
function App() {
return (
<AppShell>
<div />
{({ theme}) => (
<DashboardPage theme={theme} />
)}
</AppShell>
);
}
+108
View File
@@ -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 (
<div
className={
isDark
? "group relative overflow-hidden rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)] transition hover:border-white/20"
: "group relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300 hover:shadow-md"
}
>
<div
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${accentClass.glow} via-transparent to-transparent opacity-60`}
/>
<div className="relative flex items-center gap-4">
<div
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${accentClass.bg} ${accentClass.icon}`}
>
<Icon className="h-8 w-8 stroke-[1.8]" />
</div>
<div className="min-w-0 flex-1">
<p
className={
isDark
? "mb-2 truncate text-sm font-medium text-slate-300"
: "mb-2 truncate text-sm font-medium text-slate-600"
}
>
{title}
</p>
<div className="flex items-end gap-2">
<span
className={
isDark
? "text-3xl font-bold leading-none tracking-tight text-white"
: "text-3xl font-bold leading-none tracking-tight text-slate-950"
}
>
{value}
</span>
{unit && (
<span
className={
isDark
? "mb-1 text-sm font-semibold text-slate-300"
: "mb-1 text-sm font-semibold text-slate-600"
}
>
{unit}
</span>
)}
</div>
</div>
</div>
</div>
);
}
+63 -47
View File
@@ -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 (
<div className={isDark ? "min-h-screen bg-[#0E1A24] text-[#EAF2FA]" : "min-h-screen bg-[#F4F7FA] text-[#102030]"}>
<div className="flex min-h-screen">
<div className="relative">
<Sidebar theme={theme} />
<div className="pointer-events-none absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-[#3A5064]/6 to-transparent blur-sm" />
</div>
const toggleTheme = () => {
setTheme((current) => (current === "dark" ? "light" : "dark"));
};
<div className="flex min-h-screen flex-1 flex-col">
<TopBar
connected={telemetry.connected}
lastTimestamp={telemetry.lastTimestamp}
notificationCount={notifications.unreadCount}
userInitials={currentUser.initials}
theme={theme}
onToggleTheme={toggleTheme}
/>
const isDark = theme === "dark";
<main
className={
isDark
? "flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
: "flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
}
>
{children}
</main>
<BottomStatusBar
theme={theme}
backendPort={runtime.runtimeConfig?.backendPort?.toString()}
mode={runtime.runtimeConfig?.mode}
controllerName={runtime.runtimeConfig?.controllerName}
controllerIp={runtime.runtimeConfig?.controllerIp}
/>
</div>
</div>
return (
<div
className={
isDark
? "h-screen overflow-hidden bg-[#0E1A24] text-[#EAF2FA]"
: "h-screen overflow-hidden bg-[#F4F7FA] text-[#102030]"
}
>
<div className="flex h-full overflow-hidden">
<div className="relative h-full shrink-0 overflow-hidden">
<Sidebar theme={theme} />
<div className="pointer-events-none absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-[#3A5064]/6 to-transparent blur-sm" />
</div>
);
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<TopBar
connected={telemetry.connected}
lastTimestamp={telemetry.lastTimestamp}
notificationCount={notifications.unreadCount}
userInitials={currentUser.initials}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main
className={
isDark
? "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
: "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
}
>
{children({
theme,
snapshots: telemetry.snapshots,
})}
</main>
<BottomStatusBar
theme={theme}
backendPort={runtime.runtimeConfig?.backendPort.toString()}
mode={runtime.runtimeConfig?.mode}
controllerName={runtime.runtimeConfig?.controllerName}
controllerIp={runtime.runtimeConfig?.controllerIp}
/>
</div>
</div>
</div>
);
}
@@ -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 (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Clima por Zona
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Temperatura, humidade, CO e estados principais dos equipamentos.
</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{zones.map((zone) => (
<div
key={zone.zoneNumber}
className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-50">
Zona {zone.zoneNumber}
</h3>
<div className="flex gap-2">
<StatusPill active={zone.fansOn} activeLabel="Vent." inactiveLabel="Vent." />
<StatusPill active={zone.extractorsOn} activeLabel="Extr." inactiveLabel="Extr." />
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MiniValue icon={Thermometer} label="Temperatura" value={formatValue(zone.temperature, "°C")} />
<MiniValue icon={Waves} label="Humidade" value={formatValue(zone.humidity, "%", 0)} />
<MiniValue icon={Gauge} label="CO₂" value={formatValue(zone.co2, "ppm", 0)} />
</div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
<OpeningBar label="Zenital E" value={zone.zenitalLeftPercent} />
<OpeningBar label="Zenital D" value={zone.zenitalRightPercent} />
<OpeningBar label="Lateral E" value={zone.lateralLeftPercent} />
<OpeningBar label="Lateral D" value={zone.lateralRightPercent} />
</div>
</div>
))}
</div>
</section>
);
}
type MiniValueProps = {
icon: React.ElementType;
label: string;
value: string;
};
function MiniValue({ icon: Icon, label, value }: MiniValueProps) {
return (
<div className="rounded-xl bg-slate-50 p-3 dark:bg-slate-950">
<div className="mb-2 flex items-center gap-2 text-slate-500 dark:text-slate-400">
<Icon className="h-4 w-4" />
<span className="text-xs">{label}</span>
</div>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
{value}
</p>
</div>
);
}
type OpeningBarProps = {
label: string;
value: number | null;
};
function OpeningBar({ label, value }: OpeningBarProps) {
const safeValue = value ?? 0;
return (
<div>
<div className="mb-1 flex justify-between text-xs text-slate-500 dark:text-slate-400">
<span>{label}</span>
<span>{value === null ? "--" : `${value.toFixed(0)}%`}</span>
</div>
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-800">
<div
className="h-2 rounded-full bg-blue-500"
style={{ width: `${safeValue}%` }}
/>
</div>
</div>
);
}
@@ -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 (
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<OperationCard
title="Rega"
value={`${irrigation?.activeValveCount ?? 0}`}
subtitle={`válvulas ativas / ${irrigation?.controllerCount ?? 0} controladores`}
icon={Droplets}
/>
<OperationCard
title="Bombas"
value={`${irrigation?.activePumpCount ?? 0}`}
subtitle="bombas ativas"
icon={Power}
/>
<OperationCard
title="Iluminação"
value={`${lighting?.activeSectorCount ?? 0}`}
subtitle={`setores ativos / ${lighting?.sectorCount ?? 0} setores`}
icon={Lightbulb}
/>
</section>
);
}
type OperationCardProps = {
title: string;
value: string;
subtitle: string;
icon: React.ElementType;
};
function OperationCard({ title, value, subtitle, icon: Icon }: OperationCardProps) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">{title}</p>
<Icon className="h-5 w-5 text-slate-400" />
</div>
<p className="text-3xl font-semibold text-slate-900 dark:text-slate-50">
{value}
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{subtitle}
</p>
</div>
);
}
@@ -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 (
<div
className={
isDark
? "rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className={isDark ? "text-base font-semibold text-white" : "text-base font-semibold text-slate-950"}>
{title}
</h2>
<p className={isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"}>
{subtitle}
</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
{series.map((item) => (
<div key={item.key} className="flex items-center gap-1.5 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className={isDark ? "text-slate-300" : "text-slate-600"}>
{item.label}
</span>
</div>
))}
</div>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<defs>
{series.map((item) => (
<linearGradient key={item.key} id={gradientId(item.label)} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={item.color} stopOpacity={0.35} />
<stop offset="95%" stopColor={item.color} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
<CartesianGrid
strokeDasharray="4 4"
vertical={false}
stroke={isDark ? "rgba(148,163,184,0.16)" : "rgba(100,116,139,0.18)"}
/>
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
stroke={isDark ? "#94a3b8" : "#64748b"}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
stroke={isDark ? "#94a3b8" : "#64748b"}
fontSize={12}
/>
<Tooltip
cursor={{
stroke: isDark ? "#64748b" : "#94a3b8",
strokeWidth: 1,
}}
contentStyle={{
background: isDark ? "#0B1220" : "#ffffff",
border: isDark ? "1px solid rgba(255,255,255,0.12)" : "1px solid #e2e8f0",
borderRadius: "14px",
color: isDark ? "#f8fafc" : "#0f172a",
boxShadow: "0 18px 45px rgba(0,0,0,0.25)",
}}
labelStyle={{
color: isDark ? "#cbd5e1" : "#475569",
fontWeight: 600,
}}
/>
{series.map((item) => (
<Area
key={item.key}
type="monotone"
dataKey={item.label}
stroke={item.color}
fill={`url(#${gradientId(item.label)})`}
strokeWidth={2.5}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
function buildChartData(
data: HistorianDashboardResponse | null,
series: ChartSeries[],
) {
if (!data) return [];
const pointsByTimestamp = new Map<string, Record<string, string | number>>();
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, "")}`;
}
@@ -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 (
<span className="rounded-full bg-slate-200 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
--
</span>
);
}
return (
<span
className={
active
? "rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: "rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
}
>
{active ? activeLabel : inactiveLabel}
</span>
);
}
@@ -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<DashboardOverview | null>(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,
};
}
@@ -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<HistorianDashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}
@@ -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 (
<div className="space-y-6">
<section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-5">
<MetricCard
title="Temperatura Exterior"
value={formatNumber(meteo?.exteriorTemperature)}
unit="°C"
icon={Thermometer}
theme={theme}
accent="red"
/>
<MetricCard
title="Humidade Exterior"
value={formatNumber(meteo?.exteriorHumidity, 0)}
unit="%"
icon={Droplets}
theme={theme}
accent="cyan"
/>
<MetricCard
title="Radiação Solar"
value={formatNumber(meteo?.radiation, 0)}
unit="W/m²"
icon={Sun}
theme={theme}
accent="yellow"
/>
<MetricCard
title="Velocidade do Vento"
value={formatNumber(meteo?.windSpeed, 0)}
unit="Km/h"
icon={Wind}
theme={theme}
accent="blue"
/>
<MetricCard
title="Chuva"
value={formatBoolean(meteo?.raining)}
unit=""
icon={CloudRain}
theme={theme}
accent="green"
/>
</section>
<section className="grid grid-cols-1 gap-5 lg:grid-cols-4">
<DashboardModuleCard
theme={theme}
title="Clima"
icon={Fan}
main={`${onlineZoneCount} / ${zones.length}`}
label="zonas com dados"
accent="cyan"
items={[
["Temp. média", `${formatNumber(averageZoneTemperature)} °C`],
["CO₂ Zona 1", `${formatNumber(zoneOne?.co2, 0)} ppm`],
[
"Zenitais Zona 1",
`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}% / ${formatNumber(
zoneOne?.zenitalRightPercent,
0,
)}%`,
],
]}
/>
<DashboardModuleCard
theme={theme}
title="Rega"
icon={Droplets}
main={`${overview?.irrigation.activeValveCount ?? 0}`}
label={`válvulas ativas / ${overview?.irrigation.controllerCount ?? 0
} controladores`}
accent="green"
items={[
[
"Bombas ativas",
`${overview?.irrigation.activePumpCount ?? 0}`,
],
[
"Estado",
overview?.irrigation.activeValveCount ? "A regar" : "Parada",
],
]}
/>
<DashboardModuleCard
theme={theme}
title="Iluminação"
icon={Lightbulb}
main={`${overview?.lighting.activeSectorCount ?? 0}`}
label={`setores ativos / ${overview?.lighting.sectorCount ?? 0
} setores`}
accent="yellow"
items={[
[
"Estado",
overview?.lighting.activeSectorCount ? "Ligada" : "Desligada",
],
]}
/>
<DashboardModuleCard
theme={theme}
title="Sistema"
icon={Zap}
main={connected ? "Online" : "Offline"}
label="ligação em tempo real"
accent="blue"
items={[
["PLC", connected ? "Comunicando" : "Sem ligação"],
["Historiador", historianData ? "Ativo" : "A carregar"],
]}
/>
</section>
<section className="grid grid-cols-1 gap-5 xl:grid-cols-2">
<DashboardTrendChart
title="Meteorologia em Tempo Real"
subtitle="Temperatura exterior, humidade e radiação nos últimos 30 minutos."
theme={theme}
data={historianData}
series={[
{
key: "meteo.exterior_temperature",
label: "Temp. Exterior",
color: "#fb7185",
},
{
key: "meteo.exterior_humidity",
label: "Humidade",
color: "#22d3ee",
},
{
key: "meteo.radiation",
label: "Radiação",
color: "#facc15",
},
]}
/>
<DashboardTrendChart
title="Clima — Zona 1"
subtitle="Temperatura e humidade da zona principal nos últimos 30 minutos."
theme={theme}
data={historianData}
series={[
{
key: "climate.zone_1.temperature",
label: "Temp. Zona 1",
color: "#38bdf8",
},
{
key: "climate.zone_1.humidity",
label: "Humidade Zona 1",
color: "#2dd4bf",
},
]}
/>
</section>
<section className="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div
className={
isDark
? "xl:col-span-4 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "xl:col-span-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5 flex items-center justify-between">
<div>
<h2
className={
isDark
? "text-base font-semibold text-white"
: "text-base font-semibold text-slate-950"
}
>
Estado Operacional
</h2>
<p
className={
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
}
>
Resumo rápido da instalação.
</p>
</div>
<Activity
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-slate-500"}
/>
</div>
<div className="space-y-3">
<StatusRow
theme={theme}
label="Aquisição"
value={connected ? "Ativa" : "Inativa"}
good={connected}
/>
<StatusRow
theme={theme}
label="Rega"
value={overview?.irrigation.activeValveCount ? "Em curso" : "Parada"}
good={!overview?.irrigation.activeValveCount}
/>
<StatusRow
theme={theme}
label="Chuva"
value={formatBoolean(meteo?.raining)}
good={!meteo?.raining}
/>
<StatusRow
theme={theme}
label="Zenitais Zona 1"
value={`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}%`}
good
/>
</div>
</div>
<div
className={
isDark
? "xl:col-span-8 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "xl:col-span-8 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5">
<h2
className={
isDark
? "text-base font-semibold text-white"
: "text-base font-semibold text-slate-950"
}
>
Próximos Desenvolvimentos
</h2>
<p
className={
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
}
>
Espaço preparado para eventos, alarmes, programas e depósitos.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<PlaceholderPanel
theme={theme}
title="Eventos"
text="Últimos eventos do sistema"
/>
<PlaceholderPanel
theme={theme}
title="Alarmes"
text="Alarmes ativos e recentes"
/>
<PlaceholderPanel
theme={theme}
title="Depósitos"
text="Níveis, pH e CE"
/>
</div>
</div>
</section>
</div>
);
}
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 (
<div
className={
isDark
? "rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5 flex items-center justify-between">
<h2
className={
isDark
? "text-sm font-medium text-slate-300"
: "text-sm font-medium text-slate-600"
}
>
{title}
</h2>
<div
className={`flex h-10 w-10 items-center justify-center rounded-xl ${accentClass.bg} ${accentClass.icon}`}
>
<Icon className="h-5 w-5" />
</div>
</div>
<p
className={
isDark
? "text-3xl font-bold tracking-tight text-white"
: "text-3xl font-bold tracking-tight text-slate-950"
}
>
{main}
</p>
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
{label}
</p>
<div className="mt-5 space-y-2">
{items.map(([key, value]) => (
<div key={key} className="flex justify-between gap-4 text-sm">
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
{key}
</span>
<span
className={
isDark
? "font-medium text-slate-100"
: "font-medium text-slate-800"
}
>
{value}
</span>
</div>
))}
</div>
</div>
);
}
type StatusRowProps = {
theme: "dark" | "light";
label: string;
value: string;
good: boolean;
};
function StatusRow({ theme, label, value, good }: StatusRowProps) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
: "flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3"
}
>
<span className={isDark ? "text-sm text-slate-300" : "text-sm text-slate-600"}>
{label}
</span>
<span
className={
good
? "rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-500"
: "rounded-full bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-500"
}
>
{value}
</span>
</div>
);
}
type PlaceholderPanelProps = {
theme: "dark" | "light";
title: string;
text: string;
};
function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "rounded-xl border border-white/10 bg-white/5 p-4"
: "rounded-xl border border-slate-200 bg-slate-50 p-4"
}
>
<p
className={
isDark
? "text-sm font-semibold text-white"
: "text-sm font-semibold text-slate-950"
}
>
{title}
</p>
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
{text}
</p>
</div>
);
}
@@ -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;
};
};
@@ -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;
}
+25
View File
@@ -11,3 +11,28 @@ 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));
}
+25
View File
@@ -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<HistorianDashboardResponse> {
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();
}
+12
View File
@@ -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<string, HistorianSeriesPoint[]>;
};