Add historian persistence and dashboard trend charts
This commit is contained in:
+4
-1
@@ -1,9 +1,12 @@
|
|||||||
import { AppShell } from "../components/layout/AppShell";
|
import { AppShell } from "../components/layout/AppShell";
|
||||||
|
import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div />
|
{({ theme}) => (
|
||||||
|
<DashboardPage theme={theme} />
|
||||||
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ReactNode, useState } from "react";
|
||||||
import { Sidebar } from "../navigation/Sidebar";
|
import { Sidebar } from "../navigation/Sidebar";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
import { BottomStatusBar } from "./BottomStatusBar";
|
import { BottomStatusBar } from "./BottomStatusBar";
|
||||||
@@ -5,34 +6,46 @@ import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryS
|
|||||||
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
|
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
|
||||||
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
||||||
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
|
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 = {
|
type AppShellProps = {
|
||||||
children: React.ReactNode;
|
children: (props: AppShellRenderProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const telemetry = useTelemetryStream();
|
const telemetry = useTelemetryStream();
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
|
||||||
const runtime = useRuntimeConfig();
|
const runtime = useRuntimeConfig();
|
||||||
|
|
||||||
|
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isDark ? "min-h-screen bg-[#0E1A24] text-[#EAF2FA]" : "min-h-screen bg-[#F4F7FA] text-[#102030]"}>
|
<div
|
||||||
<div className="flex min-h-screen">
|
className={
|
||||||
<div className="relative">
|
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} />
|
<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 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>
|
||||||
|
|
||||||
<div className="flex min-h-screen flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<TopBar
|
<TopBar
|
||||||
connected={telemetry.connected}
|
connected={telemetry.connected}
|
||||||
lastTimestamp={telemetry.lastTimestamp}
|
lastTimestamp={telemetry.lastTimestamp}
|
||||||
@@ -45,16 +58,19 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
|
? "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
|
||||||
: "flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
|
: "custom-scrollbar min-h-0 flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children({
|
||||||
|
theme,
|
||||||
|
snapshots: telemetry.snapshots,
|
||||||
|
})}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomStatusBar
|
<BottomStatusBar
|
||||||
theme={theme}
|
theme={theme}
|
||||||
backendPort={runtime.runtimeConfig?.backendPort?.toString()}
|
backendPort={runtime.runtimeConfig?.backendPort.toString()}
|
||||||
mode={runtime.runtimeConfig?.mode}
|
mode={runtime.runtimeConfig?.mode}
|
||||||
controllerName={runtime.runtimeConfig?.controllerName}
|
controllerName={runtime.runtimeConfig?.controllerName}
|
||||||
controllerIp={runtime.runtimeConfig?.controllerIp}
|
controllerIp={runtime.runtimeConfig?.controllerIp}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,3 +11,28 @@ body,
|
|||||||
body {
|
body {
|
||||||
font-family: Inter, system-ui, sans-serif;
|
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));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user