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 { DashboardPage } from "../features/dashboard/pages/DashboardPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div />
|
||||
{({ theme}) => (
|
||||
<DashboardPage theme={theme} />
|
||||
)}
|
||||
</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 { 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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