Adds VNC Console page v1.0

This commit is contained in:
litoral05
2026-06-02 08:51:39 +01:00
parent f197898fbb
commit ae15b8d3f6
3 changed files with 595 additions and 167 deletions
+292 -166
View File
@@ -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;