auth/runtime config, URLs centralizados, parsing/hardening dos hooks/charts

This commit is contained in:
litoral05
2026-06-08 16:32:21 +01:00
parent d6daac97c7
commit 19df30326b
32 changed files with 899 additions and 267 deletions
+1
View File
@@ -0,0 +1 @@
VITE_GATEWAY_BASE_URL=http://localhost:18080
+58 -38
View File
@@ -11,6 +11,9 @@ import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPag
import { SettingsPage } from "../features/settings/pages/SettingsPage";
import SynopticPage from "../features/synoptic/pages/SynopticPage";
import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage";
import { useAuth } from "../features/auth/AuthContext";
import { LoginPage } from "../features/auth/pages/LoginPage";
import { RuntimeConfigProvider } from "../features/system/RuntimeConfigProvider";
import { TitleBar } from "../components/window/TitleBar";
export type AppPage =
@@ -32,6 +35,7 @@ export type AppPage =
| "meteoCharts"
function App() {
const { authenticated } = useAuth();
const [activePage, setActivePage] = useState<AppPage>("dashboard");
const isChartWindow = window.location.pathname.startsWith("/chart-window/");
@@ -43,54 +47,70 @@ function App() {
return <ChartWindowPage theme={theme} />;
}
if (!authenticated) {
return (
<div className="h-screen overflow-hidden bg-[#071421]">
<TitleBar />
<div className="pt-9">
<div className="h-[calc(100vh-36px)] overflow-hidden">
<LoginPage />
</div>
</div>
</div>
);
}
return (
<div className="h-screen overflow-hidden bg-[#071421]">
<TitleBar />
<div className="pt-9">
<div className="h-[calc(100vh-36px)] overflow-hidden">
<AppShell activePage={activePage} onNavigate={setActivePage}>
{({ theme }) => {
if (activePage === "meteo") {
<RuntimeConfigProvider>
<AppShell activePage={activePage} onNavigate={setActivePage}>
{({ theme }) => {
if (activePage === "meteo") {
return (
<MeteoPage
theme={theme}
onOpenMeteoCharts={() => setActivePage("meteoCharts")}
/>
);
}
if (activePage === "meteoCharts") {
return <MeteoChartsPage theme={theme} />;
}
if (activePage === "climateCharts") {
return <ClimateChartsPage theme={theme} />;
}
if (activePage === "console") return <ConsolePage theme={theme} />;
if (activePage === "maincharts") {
return <MainChartsPage theme={theme} />;
}
if (activePage === "settings") {
return <SettingsPage theme={theme} />;
}
if (activePage === "synoptic") {
return <SynopticPage theme={theme} />;
}
return (
<MeteoPage
<DashboardPage
theme={theme}
onOpenMeteoCharts={() => setActivePage("meteoCharts")}
onOpenMeteo={() => setActivePage("meteo")}
onNavigate={setActivePage}
/>
);
}
if (activePage === "meteoCharts") {
return <MeteoChartsPage theme={theme} />;
}
if (activePage === "climateCharts") {
return <ClimateChartsPage theme={theme} />;
}
if (activePage === "console") return <ConsolePage theme={theme} />;
if (activePage === "maincharts") {
return <MainChartsPage theme={theme} />;
}
if (activePage === "settings") {
return <SettingsPage theme={theme} />;
}
if (activePage === "synoptic") {
return <SynopticPage theme={theme} />;
}
return (
<DashboardPage
theme={theme}
onOpenMeteo={() => setActivePage("meteo")}
onNavigate={setActivePage}
/>
);
}}
</AppShell>
}}
</AppShell>
</RuntimeConfigProvider>
</div>
</div>
</div>
+2
View File
@@ -105,6 +105,8 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
activePage={activePage}
collapsed={sidebarCollapsed}
userInitials={currentUser.initials}
userName={currentUser.name}
userRole={currentUser.role}
onNavigate={onNavigate}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onToggleTheme={toggleTheme}
+14 -4
View File
@@ -25,12 +25,15 @@ import {
import logo from "../../assets/logo5.png";
import type { AppPage } from "../../app/App";
import { useAuth } from "../../features/auth/AuthContext";
type SidebarProps = {
theme: "dark" | "light";
activePage: AppPage;
collapsed: boolean;
userInitials: string;
userName: string;
userRole: string;
onNavigate: (page: AppPage) => void;
onToggleCollapsed: () => void;
onToggleTheme: () => void;
@@ -77,10 +80,13 @@ export function Sidebar({
activePage,
collapsed,
userInitials,
userName,
userRole,
onNavigate,
onToggleCollapsed,
onToggleTheme,
}: SidebarProps) {
const { logout } = useAuth();
const isDark = theme === "dark";
const ThemeIcon = isDark ? Moon : Sun;
@@ -394,7 +400,7 @@ export function Sidebar({
>
<CollapsedTooltipWrapper
collapsed={collapsed}
label="admin"
label={userName}
isDark={isDark}
>
<button
@@ -418,7 +424,7 @@ export function Sidebar({
{!collapsed && (
<>
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
admin
{userName}
</span>
<ChevronDown
@@ -460,9 +466,9 @@ export function Sidebar({
: "font-bold text-slate-950"
}
>
admin
{userName}
</div>
<div className="text-xs text-slate-500">Administrador</div>
<div className="text-xs text-slate-500">{userRole}</div>
</div>
</div>
@@ -501,6 +507,10 @@ export function Sidebar({
<button
type="button"
onClick={() => {
setUserMenuOpen(false);
logout();
}}
className={
isDark
? `flex w-full cursor-pointer items-center gap-3 ${RADIUS} px-3 py-2 text-left text-sm font-medium text-red-300 transition hover:bg-red-500/10`
+88
View File
@@ -0,0 +1,88 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import {
clearStoredSession,
readStoredSession,
storeSession,
} from "./authSessionStorage";
import { getGatewayHttpUrl } from "../../lib/api/gatewayConfig";
export type AuthSession = {
accessToken: string;
tokenType: string;
userId: number;
clientId: number;
clientName: string;
username: string;
role: string;
};
type AuthContextValue = {
session: AuthSession | null;
accessToken: string | null;
authenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(() =>
readStoredSession(),
);
const login = useCallback(async (username: string, password: string) => {
const response = await fetch(getGatewayHttpUrl("/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error("Credenciais invalidas.");
}
const data = (await response.json()) as AuthSession;
storeSession(data);
setSession(data);
}, []);
const logout = useCallback(() => {
clearStoredSession();
setSession(null);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
accessToken: session?.accessToken ?? null,
authenticated: Boolean(session?.accessToken),
login,
logout,
}),
[session, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) {
throw new Error("useAuth must be used inside AuthProvider");
}
return value;
}
+34
View File
@@ -0,0 +1,34 @@
import type { AuthSession } from "./AuthContext";
export const AUTH_STORAGE_KEY = "auth-session";
export function readStoredSession(): AuthSession | null {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as AuthSession;
} catch {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
}
export function storeSession(session: AuthSession) {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
}
export function clearStoredSession() {
localStorage.removeItem(AUTH_STORAGE_KEY);
}
export function getAuthAuthorizationHeader() {
const session = readStoredSession();
if (!session?.accessToken) return null;
return `${session.tokenType || "Bearer"} ${session.accessToken}`;
}
export function getStoredAccessToken() {
return readStoredSession()?.accessToken ?? null;
}
+19 -2
View File
@@ -1,6 +1,23 @@
import { useAuth } from "../AuthContext";
function getInitials(username?: string) {
if (!username) return "AD";
return username
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
export function useCurrentUser() {
const { session } = useAuth();
return {
name: "Administrador",
initials: "AD",
name: session?.username ?? "Administrador",
initials: getInitials(session?.username) || "AD",
role: session?.role ?? "Administrador",
clientName: session?.clientName ?? null,
};
}
+102
View File
@@ -0,0 +1,102 @@
import { useState, type FormEvent } from "react";
import { Lock, LogIn, User } from "lucide-react";
import logo from "../../../assets/logo5.png";
import { useAuth } from "../AuthContext";
export function LoginPage() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError(null);
try {
await login(username.trim(), password);
} catch (error) {
setError(
error instanceof Error ? error.message : "Erro ao iniciar sessao.",
);
} finally {
setLoading(false);
}
};
return (
<main className="grid h-full place-items-center bg-[#07101B] px-4 text-slate-100">
<form
onSubmit={submit}
className="w-full max-w-sm rounded-[6px] border border-white/10 bg-[#0B1220] p-6 shadow-2xl"
>
<div className="mb-6 flex items-center gap-3">
<img
src={logo}
alt="Central LRX"
className="h-9 w-9 object-contain"
/>
<div>
<p className="text-xs font-black uppercase tracking-[0.22em] text-[#4FD1C5]">
Litoral Regas
</p>
<h1 className="mt-1 text-2xl font-black">Iniciar sessao</h1>
</div>
</div>
<label className="block">
<span className="text-[11px] font-black uppercase tracking-[0.20em] text-slate-500">
Utilizador
</span>
<div className="relative mt-2">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={loading}
autoComplete="username"
className="h-11 w-full rounded-[5px] border border-[#263247] bg-[#07101B] pl-10 pr-3 text-sm font-bold text-white outline-none transition focus:border-[#4FD1C5] disabled:opacity-60"
/>
</div>
</label>
<label className="mt-4 block">
<span className="text-[11px] font-black uppercase tracking-[0.20em] text-slate-500">
Password
</span>
<div className="relative mt-2">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={loading}
autoComplete="current-password"
className="h-11 w-full rounded-[5px] border border-[#263247] bg-[#07101B] pl-10 pr-3 text-sm font-bold text-white outline-none transition focus:border-[#4FD1C5] disabled:opacity-60"
/>
</div>
</label>
{error && (
<div className="mt-4 rounded-[5px] border border-red-500/25 bg-red-500/10 p-3 text-xs font-bold text-red-200">
{error}
</div>
)}
<button
type="submit"
disabled={loading || !username.trim() || !password}
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-60"
>
<LogIn className="h-4 w-4" />
{loading ? "A entrar..." : "Entrar"}
</button>
</form>
</main>
);
}
@@ -4,8 +4,9 @@ import type {
WorkspaceChartMode,
WorkspaceChartTimeRange,
} from "../../../components/charts/WorkspaceChart";
const API_BASE_URL = "http://localhost:18450";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
const SAVE_DEBOUNCE_MS = 800;
export type ChartLayoutMode =
@@ -69,8 +70,8 @@ export function useChartWorkspacePersistence({
async function loadWorkspace() {
try {
const response = await fetch(
`${API_BASE_URL}/api/chart-workspaces/${scope}`,
const response = await authFetch(
getBackendApiUrl(`/api/chart-workspaces/${scope}`),
);
if (response.status === 404 || response.status === 500) {
@@ -81,7 +82,10 @@ export function useChartWorkspacePersistence({
throw new Error(`Failed to load workspace: ${response.status}`);
}
const payload = (await response.json()) as ChartWorkspaceResponse;
const payload = await readJsonResponse<ChartWorkspaceResponse>(
response,
"Failed to load chart workspace",
);
if (cancelled) return;
@@ -122,8 +126,8 @@ export function useChartWorkspacePersistence({
try {
setSaving(true);
const response = await fetch(
`${API_BASE_URL}/api/chart-workspaces/${scope}`,
const response = await authFetch(
getBackendApiUrl(`/api/chart-workspaces/${scope}`),
{
method: "PUT",
headers: {
@@ -4,8 +4,9 @@ import type {
WorkspaceChartPoint,
WorkspaceChartTimeRange,
} from "../../../components/charts/WorkspaceChart";
const BACKEND_URL = "http://localhost:18450";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
type HistorianPoint = {
timestamp: string;
@@ -54,16 +55,16 @@ export function useClimateChartSeries(
to: to.toISOString(),
});
const response = await fetch(
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
const response = await authFetch(
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Failed to load climate history for ${key}`);
}
const payload = (await response.json()) as HistorianPoint[];
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
response,
`Failed to load climate history for ${key}`,
[],
);
const points = payload
.filter(
@@ -2,13 +2,16 @@ import { useEffect, useState } from "react";
import { Client } from "@stomp/stompjs";
import type { ModuleSensorResponse } from "../../../types/meteo";
import { authFetch, getAuthHeaders } from "../../../lib/api/authFetch";
import { getStoredAccessToken } from "../../auth/authSessionStorage";
import { appendAccessToken, getBackendApiUrl, getStompWebSocketUrl } from "../../../lib/api/gatewayConfig";
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
export type ClimateModuleResponse = {
timestamp: string;
sensors: ModuleSensorResponse[];
};
const WS_URL = "ws://localhost:18450/ws";
const TOPIC = "/topic/modules/climate/latest";
export function useClimateModuleStream() {
@@ -17,8 +20,38 @@ export function useClimateModuleStream() {
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function loadInitialLatest() {
try {
const response = await authFetch(getBackendApiUrl("/api/modules/climate"), {
signal: controller.signal,
cache: "no-store",
headers: {
Accept: "application/json",
},
});
const payload = await readJsonResponse<ClimateModuleResponse>(
response,
"Failed to load latest climate module",
);
const normalizedPayload = normalizeClimateModule(payload);
setModule(normalizedPayload);
setLastTimestamp(normalizedPayload.timestamp);
} catch (error) {
if (controller.signal.aborted) return;
console.error("[ClimateModuleStream INITIAL ERROR]", error);
}
}
void loadInitialLatest();
const client = new Client({
brokerURL: WS_URL,
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
connectHeaders: getAuthHeaders(),
reconnectDelay: 3000,
onConnect: () => {
@@ -26,9 +59,10 @@ export function useClimateModuleStream() {
client.subscribe(TOPIC, (message) => {
const payload = JSON.parse(message.body) as ClimateModuleResponse;
const normalizedPayload = normalizeClimateModule(payload);
setModule(payload);
setLastTimestamp(payload.timestamp);
setModule(normalizedPayload);
setLastTimestamp(normalizedPayload.timestamp);
});
},
@@ -45,6 +79,7 @@ export function useClimateModuleStream() {
client.activate();
return () => {
controller.abort();
client.deactivate();
};
}, []);
@@ -57,3 +92,12 @@ export function useClimateModuleStream() {
lastTimestamp,
};
}
function normalizeClimateModule(payload: ClimateModuleResponse): ClimateModuleResponse {
return {
timestamp: payload.timestamp,
sensors: Array.isArray(payload.sensors)
? payload.sensors.filter((sensor) => Boolean(sensor?.key))
: [],
};
}
+70 -38
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { appendAccessToken, getVncWebSocketUrl } from "../../../lib/api/gatewayConfig";
export type VncConnectionState =
| "IDLE"
@@ -11,6 +12,7 @@ export type VncConnectionState =
export type UseVncConsoleOptions = {
websocketUrl?: string;
accessToken?: string | null;
defaultHost?: string;
defaultPort?: number;
};
@@ -21,31 +23,35 @@ export type ConnectVncInput = {
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 websocketUrl = options.websocketUrl ?? getVncWebSocketUrl();
const accessToken = options.accessToken ?? null;
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 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 [host, setHost] = useState(options.defaultHost ?? "");
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 buildWebSocketUrl = useCallback(() => {
if (!accessToken) {
return websocketUrl;
}
return appendAccessToken(websocketUrl, accessToken);
}, [websocketUrl, accessToken]);
const clearFrame = useCallback(() => {
const canvas = canvasRef.current;
const ctx = ctxRef.current;
@@ -58,11 +64,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
ctxRef.current = null;
rgbaRef.current = null;
framebufferRef.current = {
width: 0,
height: 0,
};
framebufferRef.current = { width: 0, height: 0 };
setFrameSize({ width: 0, height: 0 });
setLastFrameAt(null);
@@ -104,14 +106,16 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const width = view.getInt32(0);
const height = view.getInt32(4);
console.log("[VNC] drawFrame", {
byteLength: buffer.byteLength,
width,
height,
});
if (!width || !height || width <= 0 || height <= 0) return;
const pixels = new Uint8ClampedArray(buffer, 8);
framebufferRef.current = {
width,
height,
};
framebufferRef.current = { width, height };
if (!ctxRef.current) {
ctxRef.current = canvas.getContext("2d", {
@@ -156,6 +160,12 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const nextPort = input?.port ?? port;
const nextPassword = input?.password ?? password;
if (!accessToken) {
setError("Token de autenticação em falta.");
setState("ERROR");
return;
}
if (!nextHost.trim()) {
setError("Host VNC em falta.");
setState("ERROR");
@@ -169,7 +179,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
setState("CONNECTING_WS");
window.setTimeout(() => {
const socket = new WebSocket(websocketUrl);
const socket = new WebSocket(buildWebSocketUrl());
wsRef.current = socket;
socket.binaryType = "arraybuffer";
@@ -236,8 +246,19 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
return;
}
if (event.data instanceof ArrayBuffer) {
console.log("[VNC] binary ArrayBuffer", event.data.byteLength);
drawFrame(event.data);
return;
}
drawFrame(event.data);
if (event.data instanceof Blob) {
console.log("[VNC] binary Blob", event.data.size);
event.data.arrayBuffer().then(drawFrame);
return;
}
console.warn("[VNC] unknown binary payload", event.data);
};
socket.onerror = () => {
@@ -261,13 +282,14 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
}, 100);
},
[
accessToken,
buildWebSocketUrl,
clearFrame,
closeSocket,
drawFrame,
host,
password,
port,
websocketUrl,
],
);
@@ -288,26 +310,36 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
const rect = canvas.getBoundingClientRect();
const relativeX = (clientX - rect.left) / rect.width;
const relativeY = (clientY - rect.top) / rect.height;
const containerAspect = rect.width / rect.height;
const frameAspect = framebuffer.width / framebuffer.height;
const x = Math.max(
0,
Math.min(framebuffer.width - 1, Math.round(relativeX * framebuffer.width)),
);
let renderedWidth: number;
let renderedHeight: number;
let offsetX: number;
let offsetY: number;
const y = Math.max(
0,
Math.min(framebuffer.height - 1, Math.round(relativeY * framebuffer.height)),
);
if (containerAspect > frameAspect) {
renderedHeight = rect.height;
renderedWidth = rect.height * frameAspect;
offsetX = rect.left + (rect.width - renderedWidth) / 2;
offsetY = rect.top;
} else {
renderedWidth = rect.width;
renderedHeight = rect.width / frameAspect;
offsetX = rect.left;
offsetY = rect.top + (rect.height - renderedHeight) / 2;
}
ws.send(
JSON.stringify({
type: "click",
x,
y,
}),
);
const relativeX = (clientX - offsetX) / renderedWidth;
const relativeY = (clientY - offsetY) / renderedHeight;
const clampedX = Math.max(0, Math.min(1, relativeX));
const clampedY = Math.max(0, Math.min(1, relativeY));
const x = Math.round(clampedX * (framebuffer.width - 1));
const y = Math.round(clampedY * (framebuffer.height - 1));
ws.send(JSON.stringify({ type: "click", x, y }));
}, []);
const handleCanvasPointerDown = useCallback(
+7 -3
View File
@@ -11,6 +11,8 @@ import {
Wrench,
} from "lucide-react";
import { useVncConsole, type VncConnectionState } from "../hooks/useVncConsole";
import { useAuth } from "../../auth/AuthContext";
import { useRuntimeConfig } from "../../system/RuntimeConfigProvider";
type ConsolePageProps = {
theme: "dark" | "light";
@@ -20,11 +22,13 @@ const RADIUS = "rounded-[5px]";
export function ConsolePage({ theme }: ConsolePageProps) {
const isDark = theme === "dark";
const { accessToken } = useAuth();
const { runtimeConfig } = useRuntimeConfig();
const vnc = useVncConsole({
websocketUrl: "ws://localhost:18450/ws/vnc",
defaultHost: "198.19.0.176",
defaultPort: 5900,
accessToken,
defaultHost: runtimeConfig.vnc.defaultHost,
defaultPort: runtimeConfig.vnc.defaultPort,
});
const [passwordVisible, setPasswordVisible] = useState(false);
const connectionLabel = getConnectionLabel(vnc.state);
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { Client } from "@stomp/stompjs";
import type { DashboardOverview } from "../types/DashboardOverview";
import { getAuthHeaders } from "../../../lib/api/authFetch";
import { getStoredAccessToken } from "../../auth/authSessionStorage";
import { appendAccessToken, getStompWebSocketUrl } from "../../../lib/api/gatewayConfig";
export function useDashboardOverviewStream() {
const [overview, setOverview] = useState<DashboardOverview | null>(null);
@@ -8,7 +11,8 @@ export function useDashboardOverviewStream() {
useEffect(() => {
const client = new Client({
brokerURL: "ws://localhost:18450/ws",
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
connectHeaders: getAuthHeaders(),
reconnectDelay: 3000,
onConnect: () => {
@@ -1,5 +1,8 @@
import { useEffect, useState } from "react";
import type { ModuleSensorResponse } from "../../../types/meteo";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
export type AccumulatedBucket = {
label: string;
@@ -11,8 +14,6 @@ export type AccumulatedBucket = {
type AccumulatedRange = "7d" | "30d" | "month" | "year";
const BACKEND_URL = "http://localhost:18450";
export function useAccumulatedHistory(
sensor: ModuleSensorResponse | null,
range: AccumulatedRange,
@@ -22,11 +23,6 @@ export function useAccumulatedHistory(
useEffect(() => {
if (!sensor || !sensor.key) {
console.warn("[AccumulatedHistory SKIPPED] sensor is null or missing key", {
sensor,
range,
});
setBuckets([]);
setLoading(false);
return;
@@ -34,7 +30,7 @@ export function useAccumulatedHistory(
const controller = new AbortController();
const sensorKey = sensor.key
const sensorKey = sensor.key;
async function loadAccumulated() {
setLoading(true);
@@ -45,32 +41,24 @@ export function useAccumulatedHistory(
range,
});
const url = `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`;
const url = getBackendApiUrl(
`/api/historian/accumulated?${params.toString()}`,
);
const response = await fetch(url, {
const response = await authFetch(url, {
method: "GET",
signal: controller.signal,
cache: "no-store",
headers: {
Accept: "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const text = await response.text();
if (!response.ok) {
throw new Error(
`Failed to load accumulated history: ${response.status} ${text}`,
);
}
const parsed = JSON.parse(text) as AccumulatedBucket[];
if (!Array.isArray(parsed)) {
throw new Error("Accumulated history response is not an array");
}
const parsed = await readOptionalJsonResponse<AccumulatedBucket[]>(
response,
`Failed to load accumulated history for ${sensorKey}`,
[],
);
const sortedPayload = [...parsed].sort(
(a, b) =>
+10 -9
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import type { ModuleSensorResponse } from "../../../types/meteo";
import type { HistorianPoint } from "../components/MeteoHistoryModal";
const BACKEND_URL = "http://localhost:18450";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
const [hours, setHours] = useState(1);
@@ -31,18 +32,18 @@ export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
setLoading(true);
const response = await fetch(
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
const response = await authFetch(
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
{
signal: controller.signal,
},
);
if (!response.ok) {
throw new Error("Failed to load history");
}
const payload = (await response.json()) as HistorianPoint[];
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
response,
"Failed to load meteo history",
[],
);
setPoints(payload);
} catch (error) {
if (controller.signal.aborted) return;
@@ -1,8 +1,11 @@
import { useEffect, useState } from "react";
import { Client } from "@stomp/stompjs";
import type { MeteoModuleResponse } from "../../../types/meteo";
import { authFetch, getAuthHeaders } from "../../../lib/api/authFetch";
import { getStoredAccessToken } from "../../auth/authSessionStorage";
import { appendAccessToken, getBackendApiUrl, getStompWebSocketUrl } from "../../../lib/api/gatewayConfig";
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
const WS_URL = "ws://localhost:18450/ws";
const TOPIC = "/topic/modules/meteo/latest";
export function useMeteoModuleStream() {
@@ -11,8 +14,38 @@ export function useMeteoModuleStream() {
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function loadInitialLatest() {
try {
const response = await authFetch(getBackendApiUrl("/api/modules/meteo"), {
signal: controller.signal,
cache: "no-store",
headers: {
Accept: "application/json",
},
});
const payload = await readJsonResponse<MeteoModuleResponse>(
response,
"Failed to load latest meteo module",
);
const normalizedPayload = normalizeMeteoModule(payload);
setModule(normalizedPayload);
setLastTimestamp(normalizedPayload.timestamp);
} catch (error) {
if (controller.signal.aborted) return;
console.error("[MeteoModuleStream INITIAL ERROR]", error);
}
}
void loadInitialLatest();
const client = new Client({
brokerURL: WS_URL,
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
connectHeaders: getAuthHeaders(),
reconnectDelay: 3000,
onConnect: () => {
@@ -20,9 +53,10 @@ export function useMeteoModuleStream() {
client.subscribe(TOPIC, (message) => {
const payload = JSON.parse(message.body) as MeteoModuleResponse;
const normalizedPayload = normalizeMeteoModule(payload);
setModule(payload);
setLastTimestamp(payload.timestamp);
setModule(normalizedPayload);
setLastTimestamp(normalizedPayload.timestamp);
});
},
@@ -39,6 +73,7 @@ export function useMeteoModuleStream() {
client.activate();
return () => {
controller.abort();
client.deactivate();
};
}, []);
@@ -51,3 +86,15 @@ export function useMeteoModuleStream() {
lastTimestamp,
};
}
function normalizeMeteoModule(payload: MeteoModuleResponse): MeteoModuleResponse {
const sensors = Array.isArray(payload.sensors)
? payload.sensors.filter((sensor) => Boolean(sensor?.key))
: [];
return {
timestamp: payload.timestamp,
sensorCount: payload.sensorCount ?? sensors.length,
sensors,
};
}
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import type { ModuleSensorResponse } from "../../../types/meteo";
import type { HistorianPoint } from "../components/MeteoHistoryModal";
const BACKEND_URL = "http://localhost:18450";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
type SensorHistoryMap = Record<string, HistorianPoint[]>;
@@ -14,7 +15,7 @@ export function useMeteoMultiHistory(
const [loading, setLoading] = useState(false);
const sensorKeys = sensors
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor))
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor?.key))
.map((sensor) => sensor.key);
useEffect(() => {
@@ -40,16 +41,16 @@ export function useMeteoMultiHistory(
to: to.toISOString(),
});
const response = await fetch(
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
const response = await authFetch(
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Failed to load history for ${key}`);
}
const payload = (await response.json()) as HistorianPoint[];
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
response,
`Failed to load meteo history for ${key}`,
[],
);
return [key, payload] as const;
}),
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import type { WeatherForecastResponse } from "../../../types/weather";
const BACKEND_URL = "http://localhost:18450";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
type LocationState = {
latitude: number;
@@ -62,16 +63,15 @@ export function useWeatherForecast() {
days: "7",
});
const response = await fetch(
`${BACKEND_URL}/api/weather/forecast?${params.toString()}`,
const response = await authFetch(
getBackendApiUrl(`/api/weather/forecast?${params.toString()}`),
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error("Failed to load weather forecast");
}
const payload = (await response.json()) as WeatherForecastResponse;
const payload = await readJsonResponse<WeatherForecastResponse>(
response,
"Failed to load weather forecast",
);
setForecast(payload);
} catch (error) {
if (controller.signal.aborted) return;
+11 -13
View File
@@ -20,6 +20,9 @@ import {
Trash2,
X,
} from "lucide-react";
import { authFetch, getAuthHeaders } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
import { ChartConfigModal } from "../../chartworkspace/components/ChartConfigModal";
import { openChartWindow } from "../../chartworkspace/utils/openChartWindow";
@@ -72,7 +75,6 @@ type ChartWorkspaceItem = PersistedChartWorkspaceItem & {
windowZIndex?: number;
};
const BACKEND_URL = "http://localhost:18450";
const RADIUS = "rounded-[6px]";
const MAX_CHARTS = 10;
const MAX_VARIABLES_PER_CHART = 6;
@@ -1070,27 +1072,23 @@ function useMeteoChartSeries(
to: to.toISOString(),
});
const response = await fetch(
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
const response = await authFetch(
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
{
signal: controller.signal,
cache: "no-store",
headers: {
...getAuthHeaders(),
Accept: "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Failed to load meteo history for ${key}: ${response.status} ${text}`,
);
}
const payload = (await response.json()) as HistorianPoint[];
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
response,
`Failed to load meteo history for ${key}`,
[],
);
return [
key,
@@ -0,0 +1,95 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { fetchRuntimeConfig } from "../../lib/api/systemApi";
import {
clearRuntimeConfig,
setRuntimeConfig as setRuntimeConfigSnapshot,
} from "../../lib/api/runtimeConfigStore";
import type { RuntimeConfig } from "../../types/system";
type RuntimeConfigContextValue = {
runtimeConfig: RuntimeConfig;
};
const RuntimeConfigContext = createContext<RuntimeConfigContextValue | null>(
null,
);
export function RuntimeConfigProvider({ children }: { children: ReactNode }) {
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchRuntimeConfig()
.then((config) => {
if (cancelled) return;
setRuntimeConfigSnapshot(config);
setRuntimeConfig(config);
setError(null);
})
.catch((exception: unknown) => {
if (cancelled) return;
clearRuntimeConfig();
setError(
exception instanceof Error
? exception.message
: "Failed to fetch runtime config.",
);
});
return () => {
cancelled = true;
clearRuntimeConfig();
};
}, []);
const value = useMemo<RuntimeConfigContextValue | null>(
() => (runtimeConfig ? { runtimeConfig } : null),
[runtimeConfig],
);
if (error) {
return (
<div className="grid h-full place-items-center bg-[#07101B] px-4 text-slate-100">
<div className="max-w-sm rounded-[6px] border border-red-500/25 bg-red-500/10 p-4 text-sm font-bold text-red-200">
{error}
</div>
</div>
);
}
if (!value) {
return (
<div className="grid h-full place-items-center bg-[#07101B] text-sm font-bold text-slate-400">
A carregar configuracao...
</div>
);
}
return (
<RuntimeConfigContext.Provider value={value}>
{children}
</RuntimeConfigContext.Provider>
);
}
export function useRuntimeConfig() {
const value = useContext(RuntimeConfigContext);
if (!value) {
throw new Error("useRuntimeConfig must be used inside RuntimeConfigProvider");
}
return value;
}
+1 -33
View File
@@ -1,33 +1 @@
import { useEffect, useState } from "react";
import { fetchRuntimeConfig } from "../../../lib/api/systemApi";
import type { RuntimeConfig } from "../../../types/system";
export function useRuntimeConfig() {
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchRuntimeConfig()
.then((data) => {
setRuntimeConfig(data);
setError(null);
})
.catch((exception: unknown) => {
setError(
exception instanceof Error
? exception.message
: "Failed to fetch runtime config.",
);
})
.finally(() => {
setLoading(false);
});
}, []);
return {
runtimeConfig,
loading,
error,
};
}
export { useRuntimeConfig } from "../RuntimeConfigProvider";
@@ -4,8 +4,9 @@ import type {
WorkspaceChartPoint,
WorkspaceChartTimeRange,
} from "../../../components/charts/WorkspaceChart";
const BACKEND_URL = "http://localhost:18450";
import { authFetch } from "../../../lib/api/authFetch";
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
type HistorianPoint = {
timestamp: string;
@@ -56,18 +57,20 @@ export function useTelemetryChartSeries(
to: to.toISOString(),
});
const url = `${BACKEND_URL}/api/historian/series?${params.toString()}`;
const url = getBackendApiUrl(
`/api/historian/series?${params.toString()}`,
);
const response = await fetch(url, {
const response = await authFetch(url, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to load history for ${key}: ${response.status}`);
}
const payload = (await response.json()) as HistorianPoint[];
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
response,
`Failed to load history for ${key}`,
[],
);
const points = payload
.filter(
@@ -1,8 +1,16 @@
import { useEffect, useState } from "react";
import { Client } from "@stomp/stompjs";
import type { TelemetryBroadcastMessage } from "../../../types/telemetry";
import { authFetch, getAuthHeaders } from "../../../lib/api/authFetch";
import { getStoredAccessToken } from "../../auth/authSessionStorage";
import {
appendAccessToken,
getBackendApiUrl,
getStompWebSocketUrl,
} from "../../../lib/api/gatewayConfig";
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
const BACKEND_URL = "http://localhost:18450";
const LATEST_TELEMETRY_PATH = "/api/telemetry/latest";
export function useTelemetryStream() {
const [message, setMessage] = useState<TelemetryBroadcastMessage | null>(null);
@@ -14,10 +22,11 @@ export function useTelemetryStream() {
async function loadInitialLatest() {
try {
const response = await fetch(`${BACKEND_URL}/api/telemetry/latest`, {
const response = await authFetch(getBackendApiUrl(LATEST_TELEMETRY_PATH), {
signal: controller.signal,
cache: "no-store",
headers: {
...getAuthHeaders(),
Accept: "application/json",
},
});
@@ -26,9 +35,11 @@ export function useTelemetryStream() {
throw new Error(`Failed to load latest telemetry: ${response.status}`);
}
const payload = (await response.json()) as TelemetryBroadcastMessage;
const payload = await readJsonResponse<
TelemetryBroadcastMessage | TelemetryBroadcastMessage["snapshots"]
>(response, "Failed to load latest telemetry");
setMessage(payload);
setMessage(normalizeTelemetryPayload(payload));
} catch (error) {
if (controller.signal.aborted) return;
@@ -43,7 +54,8 @@ export function useTelemetryStream() {
loadInitialLatest();
const client = new Client({
brokerURL: "ws://localhost:18450/ws",
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
connectHeaders: getAuthHeaders(),
reconnectDelay: 3000,
onConnect: () => {
@@ -52,7 +64,7 @@ export function useTelemetryStream() {
client.subscribe("/topic/telemetry/latest", (frame) => {
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
setMessage(payload);
setMessage(normalizeTelemetryPayload(payload));
setInitialLoading(false);
});
},
@@ -85,3 +97,27 @@ export function useTelemetryStream() {
sensorCount: message?.sensorCount ?? 0,
};
}
function normalizeTelemetryPayload(
payload: TelemetryBroadcastMessage | TelemetryBroadcastMessage["snapshots"],
): TelemetryBroadcastMessage {
if (Array.isArray(payload)) {
const timestamps = payload
.map((snapshot) => snapshot.timestamp)
.filter(Boolean)
.sort();
const latestTimestamp =
timestamps.length > 0
? timestamps[timestamps.length - 1]
: new Date().toISOString();
return {
timestamp: latestTimestamp,
sensorCount: payload.length,
snapshots: payload,
};
}
return payload;
}
+20
View File
@@ -0,0 +1,20 @@
import { getAuthAuthorizationHeader } from "../../features/auth/authSessionStorage";
export function getAuthHeaders(): Record<string, string> {
const authorization = getAuthAuthorizationHeader();
return authorization ? { Authorization: authorization } : {};
}
export function authFetch(input: RequestInfo | URL, init: RequestInit = {}) {
const headers = new Headers(init.headers);
const authorization = getAuthAuthorizationHeader();
if (authorization && !headers.has("Authorization")) {
headers.set("Authorization", authorization);
}
return fetch(input, {
...init,
headers,
});
}
+50
View File
@@ -0,0 +1,50 @@
import { getRuntimeConfigSnapshot } from "./runtimeConfigStore";
export function getGatewayBaseUrl() {
const baseUrl = import.meta.env.VITE_GATEWAY_BASE_URL;
if (!baseUrl) {
throw new Error("Missing VITE_GATEWAY_BASE_URL.");
}
return baseUrl;
}
export function getGatewayHttpUrl(path: string) {
const baseUrl = getGatewayBaseUrl().replace(/\/$/, "");
return `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
}
export function getGatewayWebSocketUrl(path: string) {
const baseUrl = getGatewayBaseUrl().replace(/\/$/, "");
const wsBaseUrl = baseUrl
.replace(/^http:\/\//, "ws://")
.replace(/^https:\/\//, "wss://");
return `${wsBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
}
export function getBackendApiUrl(path: string) {
const { gateway } = getRuntimeConfigSnapshot();
const basePath = gateway.backendApiBasePath.replace(/\/$/, "");
const resourcePath = path.startsWith("/") ? path : `/${path}`;
return getGatewayHttpUrl(`${basePath}${resourcePath}`);
}
export function getStompWebSocketUrl() {
const { gateway } = getRuntimeConfigSnapshot();
return getGatewayWebSocketUrl(gateway.stompWebSocketPath);
}
export function getVncWebSocketUrl() {
const { gateway } = getRuntimeConfigSnapshot();
return getGatewayWebSocketUrl(gateway.vncWebSocketPath);
}
export function appendAccessToken(url: string, accessToken: string | null) {
if (!accessToken) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}access_token=${encodeURIComponent(accessToken)}`;
}
+9 -9
View File
@@ -1,6 +1,7 @@
import type { HistorianDashboardResponse } from "../../types/historian";
const API_BASE_URL = "http://localhost:18450";
import { authFetch } from "./authFetch";
import { getBackendApiUrl } from "./gatewayConfig";
import { readJsonResponse } from "./readJsonResponse";
export async function fetchHistorianDashboard(
keys: string[],
@@ -13,13 +14,12 @@ export async function fetchHistorianDashboard(
params.set("from", from);
params.set("to", to);
const response = await fetch(
`${API_BASE_URL}/api/historian/dashboard?${params.toString()}`,
const response = await authFetch(
getBackendApiUrl(`/api/historian/dashboard?${params.toString()}`),
);
if (!response.ok) {
throw new Error(`Failed to fetch historian dashboard: ${response.status}`);
}
return response.json();
return readJsonResponse<HistorianDashboardResponse>(
response,
"Failed to fetch historian dashboard",
);
}
+30
View File
@@ -0,0 +1,30 @@
export async function readJsonResponse<T>(
response: Response,
context: string,
): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(`${context}: ${response.status} ${text}`.trim());
}
if (!text) {
throw new Error(`${context}: empty response body`);
}
return JSON.parse(text) as T;
}
export async function readOptionalJsonResponse<T>(
response: Response,
context: string,
fallback: T,
): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(`${context}: ${response.status} ${text}`.trim());
}
return text ? (JSON.parse(text) as T) : fallback;
}
+19
View File
@@ -0,0 +1,19 @@
import type { RuntimeConfig } from "../../types/system";
let runtimeConfig: RuntimeConfig | null = null;
export function setRuntimeConfig(config: RuntimeConfig) {
runtimeConfig = config;
}
export function clearRuntimeConfig() {
runtimeConfig = null;
}
export function getRuntimeConfigSnapshot() {
if (!runtimeConfig) {
throw new Error("Runtime config has not been loaded.");
}
return runtimeConfig;
}
+8 -8
View File
@@ -1,13 +1,13 @@
import type { RuntimeConfig } from "../../types/system";
const API_BASE_URL = "http://localhost:18450";
import { authFetch } from "./authFetch";
import { getGatewayHttpUrl } from "./gatewayConfig";
import { readJsonResponse } from "./readJsonResponse";
export async function fetchRuntimeConfig(): Promise<RuntimeConfig> {
const response = await fetch(`${API_BASE_URL}/api/system/runtime-config`);
const response = await authFetch(getGatewayHttpUrl("/api/runtime/config"));
if (!response.ok) {
throw new Error("Failed to fetch runtime config.");
}
return response.json() as Promise<RuntimeConfig>;
return readJsonResponse<RuntimeConfig>(
response,
"Failed to fetch runtime config",
);
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./app/App";
import { AuthProvider } from "./features/auth/AuthContext";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>,
);
+13 -3
View File
@@ -1,6 +1,16 @@
export type RuntimeConfig = {
mode: string;
controllerName: string;
controllerIp: string;
backendPort: number;
client: {
id: number;
name: string;
};
gateway: {
backendApiBasePath: string;
stompWebSocketPath: string;
vncWebSocketPath: string;
};
vnc: {
defaultHost: string;
defaultPort: number;
};
};