Adds VNC Console page v1.0
This commit is contained in:
@@ -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<HTMLCanvasElement | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
|
||||
const framebufferRef = useRef({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const [state, setState] = useState<VncConnectionState>("IDLE");
|
||||
const [error, setError] = useState<string | null>(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<string | null>(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<ConnectVncInput>) => {
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "flex h-full flex-col bg-[#07101B] text-white"
|
||||
: "flex h-full flex-col bg-[#F3F6FA] text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between px-10 pb-6 pt-8">
|
||||
<div>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-black uppercase tracking-[0.24em] text-[#7F8CA3]"
|
||||
: "text-xs font-black uppercase tracking-[0.24em] text-slate-500"
|
||||
}
|
||||
>
|
||||
Consola remota
|
||||
</p>
|
||||
<div className={isDark ? "h-full min-h-0 text-slate-100" : "h-full min-h-0 text-slate-950"}>
|
||||
<section className="grid h-full min-h-0 gap-4 xl:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<aside className={panelClass(isDark)}>
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-black uppercase tracking-[0.20em] text-slate-500">
|
||||
ACESSO REMOTO
|
||||
</p>
|
||||
<h2 className={panelTitleClass(isDark)}>
|
||||
Controlador
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className={
|
||||
isDark
|
||||
? "mt-3 text-[34px] font-black tracking-[-0.04em] text-white"
|
||||
: "mt-3 text-[34px] font-black tracking-[-0.04em] text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
Consola VNC
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-3 max-w-[680px] text-sm leading-7 text-[#A8B3C7]"
|
||||
: "mt-3 max-w-[680px] text-sm leading-7 text-slate-600"
|
||||
}
|
||||
>
|
||||
Aceda remotamente ao controlador da instalação e acompanhe
|
||||
o estado operacional em tempo real.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-[#263247] bg-[#0E1726] px-5 py-3 text-sm font-bold text-[#4FD1C5]`
|
||||
: `${RADIUS} border border-[#D7DEE8] bg-white px-5 py-3 text-sm font-bold text-[#0F766E]`
|
||||
}
|
||||
>
|
||||
Em desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-[380px_minmax(0,1fr)] gap-6 px-10 pb-10">
|
||||
<aside
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-[#263247] bg-[#0E1726] p-6`
|
||||
: `${RADIUS} border border-[#D7DEE8] bg-white p-6`
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<StatusCard
|
||||
theme={theme}
|
||||
icon={<Wifi className="h-5 w-5" />}
|
||||
title="Ligação"
|
||||
value="Desconectado"
|
||||
color="blue"
|
||||
/>
|
||||
|
||||
<StatusCard
|
||||
theme={theme}
|
||||
icon={<ShieldCheck className="h-5 w-5" />}
|
||||
title="Estado"
|
||||
value="Seguro"
|
||||
color="green"
|
||||
/>
|
||||
|
||||
<StatusCard
|
||||
theme={theme}
|
||||
icon={<Wrench className="h-5 w-5" />}
|
||||
title="Controlador"
|
||||
value="Aguardando sessão"
|
||||
color="purple"
|
||||
icon={<Wifi className="h-4 w-4" />}
|
||||
title="Estado da sessão"
|
||||
value={connectionLabel}
|
||||
color={vnc.connected ? "green" : vnc.connecting ? "blue" : "purple"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-8 border-t border-[#263247] pt-6"
|
||||
: "mt-8 border-t border-[#D7DEE8] pt-6"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={
|
||||
isDark
|
||||
? `flex h-[54px] w-full items-center justify-center gap-3 ${RADIUS} border border-[#2A3950] bg-[#111A2B] text-sm font-extrabold text-[#7F8CA3]`
|
||||
: `flex h-[54px] w-full items-center justify-center gap-3 ${RADIUS} border border-[#CBD5E1] bg-[#F8FAFC] text-sm font-extrabold text-slate-500`
|
||||
}
|
||||
>
|
||||
<Monitor className="h-5 w-5" />
|
||||
Iniciar Sessão VNC
|
||||
</button>
|
||||
<div className="mt-5 space-y-4">
|
||||
<Field
|
||||
theme={theme}
|
||||
label="Host"
|
||||
value={vnc.host}
|
||||
onChange={vnc.setHost}
|
||||
disabled={vnc.connecting || vnc.connected}
|
||||
/>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-4 text-center text-xs leading-6 text-[#7F8CA3]"
|
||||
: "mt-4 text-center text-xs leading-6 text-slate-500"
|
||||
<Field
|
||||
theme={theme}
|
||||
label="Porta"
|
||||
value={String(vnc.port)}
|
||||
onChange={(value) => {
|
||||
const parsed = Number(value);
|
||||
vnc.setPort(Number.isFinite(parsed) ? parsed : 5900);
|
||||
}}
|
||||
disabled={vnc.connecting || vnc.connected}
|
||||
/>
|
||||
|
||||
<Field
|
||||
theme={theme}
|
||||
label="Password"
|
||||
type={passwordVisible ? "text" : "password"}
|
||||
value={vnc.password}
|
||||
onChange={vnc.setPassword}
|
||||
disabled={vnc.connecting || vnc.connected}
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordVisible((current) => !current)}
|
||||
disabled={vnc.connecting || vnc.connected}
|
||||
className={
|
||||
isDark
|
||||
? "grid h-8 w-8 place-items-center rounded-[5px] text-slate-400 transition hover:bg-white/5 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
: "grid h-8 w-8 place-items-center rounded-[5px] text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
}
|
||||
aria-label={passwordVisible ? "Esconder password" : "Mostrar password"}
|
||||
>
|
||||
{passwordVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
O módulo VNC será integrado futuramente para acesso remoto
|
||||
completo à instalação.
|
||||
</p>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={vnc.connecting}
|
||||
onClick={() => (vnc.connected ? vnc.disconnect() : vnc.connect())}
|
||||
className="mt-5 flex h-11 w-full items-center justify-center gap-2 rounded-[5px] bg-[#4FD1C5] text-sm font-black text-[#031014] transition hover:bg-[#5FE1D5] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plug className="h-4 w-4" />
|
||||
{vnc.connected ? "Desligar sessão" : vnc.connecting ? "A ligar..." : "Iniciar sessão"}
|
||||
</button>
|
||||
|
||||
{vnc.error && (
|
||||
<div className="mt-4 flex gap-3 rounded-[5px] border border-red-500/25 bg-red-500/10 p-4 text-xs font-semibold leading-5 text-red-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{vnc.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<SmallInfo theme={theme} icon={<ShieldCheck className="h-4 w-4" />} label="Modo" value="LAN direta" />
|
||||
<SmallInfo theme={theme} icon={<Wrench className="h-4 w-4" />} label="Controlador" value={vnc.host} />
|
||||
<SmallInfo theme={theme} icon={<MousePointerClick className="h-4 w-4" />} label="Interação" value="Cliques ativos" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} relative overflow-hidden border border-[#263247] bg-[#0B1220]`
|
||||
: `${RADIUS} relative overflow-hidden border border-[#D7DEE8] bg-white`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(79,209,197,0.10),transparent_60%)]"
|
||||
: "absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(15,118,110,0.08),transparent_60%)]"
|
||||
}
|
||||
/>
|
||||
<section className={`${panelClass(isDark)} min-w-0 p-0`}>
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/10 px-5">
|
||||
<div>
|
||||
<h2 className={panelTitleClass(isDark)}>
|
||||
Ecrã do controlador
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs font-semibold text-slate-500">
|
||||
{vnc.lastFrameAt ? `Último frame: ${vnc.lastFrameAt}` : "A aguardar imagem da consola"}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black">
|
||||
{!hasFrame && (
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<div className="grid h-16 w-16 place-items-center rounded-[5px] border border-sky-400/20 bg-sky-400/10">
|
||||
<Monitor className="h-8 w-8 text-[#4FD1C5]" />
|
||||
</div>
|
||||
|
||||
<h3 className="mt-5 text-xl font-black tracking-[-0.04em] text-white">
|
||||
{vnc.state === "DISCONNECTED" ? "Sessão terminada" : "Consola indisponível"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex h-full flex-col items-center justify-center px-10">
|
||||
<div
|
||||
onPointerDown={hasFrame ? vnc.handleCanvasPointerDown : undefined}
|
||||
onContextMenu={(event) => 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"
|
||||
}
|
||||
>
|
||||
<Monitor
|
||||
className={
|
||||
isDark
|
||||
? "h-12 w-12 text-[#4FD1C5]"
|
||||
: "h-12 w-12 text-[#0F766E]"
|
||||
}
|
||||
<canvas
|
||||
ref={vnc.canvasRef}
|
||||
className="pointer-events-none block max-h-full max-w-full bg-black object-contain [image-rendering:pixelated]"
|
||||
style={{
|
||||
aspectRatio: frameAspectRatio,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "mt-8 text-2xl font-black tracking-[-0.03em] text-white"
|
||||
: "mt-8 text-2xl font-black tracking-[-0.03em] text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
Consola indisponível
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-4 max-w-[560px] text-center text-sm leading-7 text-[#A8B3C7]"
|
||||
: "mt-4 max-w-[560px] text-center text-sm leading-7 text-slate-600"
|
||||
}
|
||||
>
|
||||
Esta área irá permitir visualização e controlo remoto do
|
||||
sistema através de ligação VNC segura diretamente pela
|
||||
plataforma.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<label className="block">
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "text-[11px] font-black uppercase tracking-[0.20em] text-[#7F8CA3]"
|
||||
: "text-[11px] font-black uppercase tracking-[0.20em] text-slate-500"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} h-10 w-full border border-[#263247] bg-[#07101B] px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-white outline-none transition placeholder:text-[#526074] focus:border-[#4FD1C5] disabled:cursor-not-allowed disabled:opacity-60`
|
||||
: `${RADIUS} h-10 w-full border border-slate-200 bg-white px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-[#0F172A] outline-none transition placeholder:text-slate-400 focus:border-[#0F766E] disabled:cursor-not-allowed disabled:opacity-60`
|
||||
}
|
||||
/>
|
||||
|
||||
{rightElement && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
||||
{rightElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-4`
|
||||
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] p-4`
|
||||
? `${RADIUS} min-w-0 border border-[#263247] bg-[#111A2B] p-3`
|
||||
: `${RADIUS} min-w-0 border border-[#D7DEE8] bg-[#F8FAFC] p-3`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={`grid h-12 w-12 place-items-center ${RADIUS} ${colors[color]}`}
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center ${RADIUS} ${colors[color]}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "text-xs font-bold uppercase tracking-[0.18em] text-[#7F8CA3]"
|
||||
: "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"
|
||||
? "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-[#7F8CA3]"
|
||||
: "truncate text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
@@ -252,8 +329,8 @@ function StatusCard({
|
||||
<h3
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-sm font-black text-white"
|
||||
: "mt-1 text-sm font-black text-[#0F172A]"
|
||||
? "mt-1 truncate text-sm font-black text-white"
|
||||
: "mt-1 truncate text-sm font-black text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
{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 (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} grid h-9 w-9 shrink-0 place-items-center bg-[#13202F] text-[#4FD1C5]`
|
||||
: `${RADIUS} grid h-9 w-9 shrink-0 place-items-center bg-[#ECFDF5] text-[#0F766E]`
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "truncate text-[10px] font-black uppercase tracking-[0.18em] text-[#7F8CA3]"
|
||||
: "truncate text-[10px] font-black uppercase tracking-[0.18em] text-slate-500"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 truncate text-xs font-black text-white"
|
||||
: "mt-1 truncate text-xs font-black text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsolePage;
|
||||
@@ -94,7 +94,7 @@ export function DashboardPage({ theme, onOpenMeteo, onNavigate }: DashboardPageP
|
||||
<section className="grid grid-cols-3 gap-3 xl:gap-4">
|
||||
<InfoCard theme={theme} icon={<CloudSun className="h-6 w-6 xl:h-7 xl:w-7" />} 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")} />
|
||||
<InfoCard theme={theme} icon={<TabletSmartphone className="h-6 w-6 xl:h-7 xl:w-7" />} 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")} />
|
||||
<InfoCard theme={theme} icon={<BarChart3 className="h-6 w-6 xl:h-7 xl:w-7" />} 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")} />
|
||||
<InfoCard theme={theme} icon={<BarChart3 className="h-6 w-6 xl:h-7 xl:w-7" />} 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")} />
|
||||
</section>
|
||||
|
||||
<section className={isDark ? `${RADIUS} min-h-0 border border-[#263247] bg-[#0E1726]/92 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-md xl:p-6` : `${RADIUS} min-h-0 border border-[#D7DEE8] bg-white p-5 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md xl:p-6`}>
|
||||
|
||||
Reference in New Issue
Block a user