diff --git a/src/features/console/hooks/useVncConsole.ts b/src/features/console/hooks/useVncConsole.ts new file mode 100644 index 0000000..82f8010 --- /dev/null +++ b/src/features/console/hooks/useVncConsole.ts @@ -0,0 +1,302 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type VncConnectionState = + | "IDLE" + | "CONNECTING_WS" + | "CONNECTING_VNC" + | "CONNECTED" + | "FIRST_FRAME" + | "DISCONNECTED" + | "ERROR"; + +export type UseVncConsoleOptions = { + websocketUrl?: string; + defaultHost?: string; + defaultPort?: number; +}; + +export type ConnectVncInput = { + host: string; + port: number; + password: string; +}; + +const DEFAULT_WEBSOCKET_URL = "ws://localhost:18450/ws/vnc"; +const DEFAULT_HOST = "198.19.0.176"; +const DEFAULT_PORT = 5900; + +export function useVncConsole(options: UseVncConsoleOptions = {}) { + const websocketUrl = options.websocketUrl ?? DEFAULT_WEBSOCKET_URL; + + const canvasRef = useRef(null); + const wsRef = useRef(null); + const ctxRef = useRef(null); + const rgbaRef = useRef(null); + const framebufferRef = useRef({ + width: 0, + height: 0, + }); + + const [state, setState] = useState("IDLE"); + const [error, setError] = useState(null); + const [host, setHost] = useState(options.defaultHost ?? DEFAULT_HOST); + const [port, setPort] = useState(options.defaultPort ?? DEFAULT_PORT); + const [password, setPassword] = useState(""); + const [frameSize, setFrameSize] = useState({ width: 0, height: 0 }); + const [lastFrameAt, setLastFrameAt] = useState(null); + + const closeSocket = useCallback(() => { + const ws = wsRef.current; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "disconnect" })); + ws.close(); + } else if (ws) { + ws.close(); + } + + wsRef.current = null; + }, []); + + const clearFrame = useCallback(() => { + const canvas = canvasRef.current; + const ctx = ctxRef.current; + + if (canvas && ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + + rgbaRef.current = null; + framebufferRef.current = { + width: 0, + height: 0, + }; + + setFrameSize({ width: 0, height: 0 }); + setLastFrameAt(null); + }, []); + + const drawFrame = useCallback((buffer: ArrayBuffer) => { + const canvas = canvasRef.current; + + if (!canvas) { + return; + } + + const view = new DataView(buffer); + const width = view.getInt32(0); + const height = view.getInt32(4); + const pixels = new Uint8ClampedArray(buffer, 8); + + framebufferRef.current = { + width, + height, + }; + + if (!ctxRef.current) { + ctxRef.current = canvas.getContext("2d", { + alpha: false, + desynchronized: true, + }); + } + + const ctx = ctxRef.current; + + if (!ctx) { + return; + } + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + rgbaRef.current = new Uint8ClampedArray(width * height * 4); + setFrameSize({ width, height }); + } + + if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) { + rgbaRef.current = new Uint8ClampedArray(width * height * 4); + } + + const rgba = rgbaRef.current; + + for (let index = 0; index < pixels.length; index += 4) { + rgba[index] = pixels[index + 2]; + rgba[index + 1] = pixels[index + 1]; + rgba[index + 2] = pixels[index]; + rgba[index + 3] = 255; + } + const imageData = ctx.createImageData(width, height); + imageData.data.set(rgba); + + ctx.putImageData(imageData, 0, 0); + setLastFrameAt(new Date().toLocaleTimeString()); + }, []); + + const connect = useCallback( + async (input?: Partial) => { + const nextHost = input?.host ?? host; + const nextPort = input?.port ?? port; + const nextPassword = input?.password ?? password; + + if (!nextHost.trim()) { + setError("Host VNC em falta."); + setState("ERROR"); + return; + } + + setError(null); + setState("CONNECTING_WS"); + + closeSocket(); + + const socket = new WebSocket(websocketUrl); + wsRef.current = socket; + socket.binaryType = "arraybuffer"; + + socket.onopen = () => { + setState("CONNECTING_VNC"); + + socket.send( + JSON.stringify({ + type: "connect", + host: nextHost, + port: nextPort, + password: nextPassword, + }), + ); + }; + + socket.onmessage = (event) => { + if (typeof event.data === "string") { + const message = JSON.parse(event.data) as { + type?: string; + state?: string; + message?: string; + }; + + if (message.type === "state") { + if (message.state === "CONNECTING") { + setState("CONNECTING_VNC"); + return; + } + + if (message.state === "CONNECTED") { + setState("CONNECTED"); + return; + } + + if (message.state === "FIRST_FRAME") { + setState("FIRST_FRAME"); + return; + } + + if (message.state === "DISCONNECTED") { + setState("DISCONNECTED"); + return; + } + } + + if (message.type === "error") { + setError(message.message ?? "Erro VNC desconhecido."); + setState("ERROR"); + } + + return; + } + + drawFrame(event.data); + }; + + socket.onerror = () => { + setError("Erro na ligação WebSocket da consola VNC."); + setState("ERROR"); + }; + + socket.onclose = () => { + wsRef.current = null; + clearFrame(); + + setState((current) => + current === "ERROR" ? "ERROR" : "DISCONNECTED", + ); + }; + }, + [closeSocket, drawFrame, host, password, port, websocketUrl], + ); + + const disconnect = useCallback(() => { + closeSocket(); + clearFrame(); + setState("DISCONNECTED"); + }, [clearFrame, closeSocket]); + + const sendClick = useCallback((clientX: number, clientY: number) => { + const canvas = canvasRef.current; + const ws = wsRef.current; + const framebuffer = framebufferRef.current; + + if (!canvas || !ws || ws.readyState !== WebSocket.OPEN) { + return; + } + + if (!framebuffer.width || !framebuffer.height) { + return; + } + + const rect = canvas.getBoundingClientRect(); + + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + + const x = Math.max( + 0, + Math.min(framebuffer.width - 1, Math.round(relativeX * framebuffer.width)), + ); + + const y = Math.max( + 0, + Math.min(framebuffer.height - 1, Math.round(relativeY * framebuffer.height)), + ); + + ws.send( + JSON.stringify({ + type: "click", + x, + y, + }), + ); + }, []); + + const handleCanvasPointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + sendClick(event.clientX, event.clientY); + }, + [sendClick], + ); + + useEffect(() => { + return () => { + closeSocket(); + }; + }, [closeSocket]); + + return { + canvasRef, + state, + error, + host, + setHost, + port, + setPort, + password, + setPassword, + frameSize, + lastFrameAt, + connect, + disconnect, + handleCanvasPointerDown, + connected: state === "CONNECTED" || state === "FIRST_FRAME", + connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC", + }; +} diff --git a/src/features/console/pages/ConsolePage.tsx b/src/features/console/pages/ConsolePage.tsx index 938ca8a..5cd78db 100644 --- a/src/features/console/pages/ConsolePage.tsx +++ b/src/features/console/pages/ConsolePage.tsx @@ -1,199 +1,276 @@ +import { useState, type ReactNode } from "react"; import { + AlertTriangle, + Eye, + EyeOff, Monitor, + MousePointerClick, + Plug, ShieldCheck, Wifi, Wrench, } from "lucide-react"; +import { useVncConsole, type VncConnectionState } from "../hooks/useVncConsole"; type ConsolePageProps = { theme: "dark" | "light"; }; -const RADIUS = "rounded-[6px]"; +const RADIUS = "rounded-[5px]"; export function ConsolePage({ theme }: ConsolePageProps) { const isDark = theme === "dark"; + const vnc = useVncConsole({ + websocketUrl: "ws://localhost:18450/ws/vnc", + defaultHost: "198.19.0.176", + defaultPort: 5900, + }); + const [passwordVisible, setPasswordVisible] = useState(false); + const connectionLabel = getConnectionLabel(vnc.state); + const hasFrame = vnc.frameSize.width > 0 && vnc.frameSize.height > 0; + const frameAspectRatio = hasFrame + ? `${vnc.frameSize.width} / ${vnc.frameSize.height}` + : "800 / 480"; + return ( -
-
-
-

- Consola remota -

+
+
+
- -
- Em desenvolvimento -
-
- -
- -
-
+
+
+
+

+ Ecrã do controlador +

+

+ {vnc.lastFrameAt ? `Último frame: ${vnc.lastFrameAt}` : "A aguardar imagem da consola"} +

+
+
+ +
+ {!hasFrame && ( +
+
+ +
+ +

+ {vnc.state === "DISCONNECTED" ? "Sessão terminada" : "Consola indisponível"} +

+

+ {vnc.state === "DISCONNECTED" + ? "Inicie uma nova sessão para voltar a aceder ao controlador." + : "Inicie uma sessão VNC para visualizar e controlar o controlador."} +

+
+ )} -
event.preventDefault()} className={ - isDark - ? "grid h-24 w-24 place-items-center rounded-2xl border border-[#2A3950] bg-[#111A2B]" - : "grid h-24 w-24 place-items-center rounded-2xl border border-[#CBD5E1] bg-[#F8FAFC]" + hasFrame + ? "flex h-full w-full cursor-crosshair items-center justify-center overflow-hidden bg-black" + : "pointer-events-none absolute h-px w-px overflow-hidden opacity-0" } > -
- -

- Consola indisponível -

- -

- Esta área irá permitir visualização e controlo remoto do - sistema através de ligação VNC segura diretamente pela - plataforma. -

-
+
); } +function panelClass(isDark: boolean) { + return isDark + ? `${RADIUS} flex h-full min-h-0 flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]` + : `${RADIUS} flex h-full min-h-0 flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`; +} + +function panelTitleClass(isDark: boolean) { + return isDark + ? "text-base font-black text-slate-100" + : "text-base font-black text-slate-950"; +} + +function getConnectionLabel(state: VncConnectionState) { + switch (state) { + case "CONNECTING_WS": + return "A ligar WS"; + case "CONNECTING_VNC": + return "A ligar VNC"; + case "CONNECTED": + return "Ligado"; + case "FIRST_FRAME": + return "Ativo"; + case "DISCONNECTED": + return "Desconectado"; + case "ERROR": + return "Erro"; + case "IDLE": + default: + return "A aguardar..."; + } +} + +function Field({ + theme, + label, + value, + onChange, + type = "text", + disabled, + rightElement, +}: { + theme: "dark" | "light"; + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + disabled?: boolean; + rightElement?: ReactNode; +}) { + const isDark = theme === "dark"; + + return ( + + ); +} + function StatusCard({ theme, icon, @@ -202,7 +279,7 @@ function StatusCard({ color, }: { theme: "dark" | "light"; - icon: React.ReactNode; + icon: ReactNode; title: string; value: string; color: "green" | "blue" | "purple"; @@ -227,23 +304,23 @@ function StatusCard({
-
+
{icon}
-
+

{title} @@ -252,8 +329,8 @@ function StatusCard({

{value} @@ -264,4 +341,53 @@ function StatusCard({ ); } +function SmallInfo({ + theme, + icon, + label, + value, +}: { + theme: "dark" | "light"; + icon: ReactNode; + label: string; + value: string; +}) { + const isDark = theme === "dark"; + + return ( +
+
+ {icon} +
+ +
+

+ {label} +

+

+ {value} +

+
+
+ ); +} + export default ConsolePage; \ No newline at end of file diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx index d10cbae..beb30ea 100644 --- a/src/features/dashboard/pages/DashboardPage.tsx +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -94,7 +94,7 @@ export function DashboardPage({ theme, onOpenMeteo, onNavigate }: DashboardPageP
} iconClass={isDark ? "bg-[#13202F] text-[#4FD1C5]" : "bg-[#ECFDF5] text-[#0F766E]"} title="Meteorologia" text="Consulte previsões, vento, radiação solar e condições meteorológicas." onClick={() => onNavigate("meteo")} /> } iconClass={isDark ? "bg-[#13202F] text-[#7DD3FC]" : "bg-[#EFF6FF] text-[#0369A1]"} title="Consola VNC" text="Aceda remotamente ao controlador e acompanhe a instalação em tempo real." onClick={() => onNavigate("console")} /> - } iconClass={isDark ? "bg-[#171B2B] text-[#A5B4FC]" : "bg-[#EEF2FF] text-[#4F46E5]"} title="Gráficos Climáticos" text="Visualize históricos, tendências e análise detalhada dos sensores." onClick={() => onNavigate("climateCharts")} /> + } iconClass={isDark ? "bg-[#171B2B] text-[#A5B4FC]" : "bg-[#EEF2FF] text-[#4F46E5]"} title="Gráficos Gerais" text="Visualize históricos, tendências e análise detalhada dos sensores." onClick={() => onNavigate("maincharts")} />