From 19df30326b605b053a643995c584d6246191d41c Mon Sep 17 00:00:00 2001 From: litoral05 Date: Mon, 8 Jun 2026 16:32:21 +0100 Subject: [PATCH] auth/runtime config, URLs centralizados, parsing/hardening dos hooks/charts --- .env | 1 + src/app/App.tsx | 98 +++++++++------- src/components/layout/AppShell.tsx | 4 +- src/components/navigation/Sidebar.tsx | 20 +++- src/features/auth/AuthContext.tsx | 88 ++++++++++++++ src/features/auth/authSessionStorage.ts | 34 ++++++ src/features/auth/hooks/useCurrentUser.ts | 23 +++- src/features/auth/pages/LoginPage.tsx | 102 ++++++++++++++++ .../hooks/useChartWorkspacePersistence.ts | 20 ++-- .../climate/hooks/useClimateChartSeries.ts | 21 ++-- .../climate/hooks/useClimateModuleStream.ts | 54 ++++++++- src/features/console/hooks/useVncConsole.ts | 110 +++++++++++------- src/features/console/pages/ConsolePage.tsx | 12 +- .../hooks/useDashboardOverviewStream.ts | 8 +- .../meteo/hooks/useAccumulatedHistory.ts | 40 +++---- src/features/meteo/hooks/useMeteoHistory.ts | 21 ++-- .../meteo/hooks/useMeteoModuleStream.ts | 57 ++++++++- .../meteo/hooks/useMeteoMultiHistory.ts | 23 ++-- .../meteo/hooks/useWeatherForecast.ts | 20 ++-- src/features/meteo/pages/MeteoChartsPage.tsx | 26 ++--- src/features/system/RuntimeConfigProvider.tsx | 95 +++++++++++++++ src/features/system/hooks/useRuntimeConfig.ts | 34 +----- .../hooks/useTelemetryChartSeries.ts | 23 ++-- .../telemetry/hooks/useTelemetryStream.ts | 50 ++++++-- src/lib/api/authFetch.ts | 20 ++++ src/lib/api/gatewayConfig.ts | 50 ++++++++ src/lib/api/historianApi.ts | 20 ++-- src/lib/api/readJsonResponse.ts | 30 +++++ src/lib/api/runtimeConfigStore.ts | 19 +++ src/lib/api/systemApi.ts | 18 +-- src/main.tsx | 7 +- src/types/system.ts | 18 ++- 32 files changed, 899 insertions(+), 267 deletions(-) create mode 100644 .env create mode 100644 src/features/auth/AuthContext.tsx create mode 100644 src/features/auth/authSessionStorage.ts create mode 100644 src/features/auth/pages/LoginPage.tsx create mode 100644 src/features/system/RuntimeConfigProvider.tsx create mode 100644 src/lib/api/authFetch.ts create mode 100644 src/lib/api/gatewayConfig.ts create mode 100644 src/lib/api/readJsonResponse.ts create mode 100644 src/lib/api/runtimeConfigStore.ts diff --git a/.env b/.env new file mode 100644 index 0000000..ab9f444 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_GATEWAY_BASE_URL=http://localhost:18080 diff --git a/src/app/App.tsx b/src/app/App.tsx index 93cd388..3225c41 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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("dashboard"); const isChartWindow = window.location.pathname.startsWith("/chart-window/"); @@ -43,58 +47,74 @@ function App() { return ; } + if (!authenticated) { + return ( +
+ + +
+
+ +
+
+
+ ); + } + return (
- - {({ theme }) => { - if (activePage === "meteo") { + + + {({ theme }) => { + if (activePage === "meteo") { + return ( + setActivePage("meteoCharts")} + /> + ); + } + + if (activePage === "meteoCharts") { + return ; + } + + if (activePage === "climateCharts") { + return ; + } + + if (activePage === "console") return ; + + if (activePage === "maincharts") { + return ; + } + + if (activePage === "settings") { + return ; + } + + if (activePage === "synoptic") { + return ; + } + return ( - setActivePage("meteoCharts")} + onOpenMeteo={() => setActivePage("meteo")} + onNavigate={setActivePage} /> ); - } - - if (activePage === "meteoCharts") { - return ; - } - - if (activePage === "climateCharts") { - return ; - } - - if (activePage === "console") return ; - - if (activePage === "maincharts") { - return ; - } - - if (activePage === "settings") { - return ; - } - - if (activePage === "synoptic") { - return ; - } - - return ( - setActivePage("meteo")} - onNavigate={setActivePage} - /> - ); - }} - + }} + +
); } -export default App; \ No newline at end of file +export default App; diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 2f5c0e3..ad4d00a 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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} @@ -152,4 +154,4 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) { ); -} \ No newline at end of file +} diff --git a/src/components/navigation/Sidebar.tsx b/src/components/navigation/Sidebar.tsx index 7951a24..2ca86a3 100644 --- a/src/components/navigation/Sidebar.tsx +++ b/src/components/navigation/Sidebar.tsx @@ -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({ > ); -} \ No newline at end of file +} diff --git a/src/features/auth/AuthContext.tsx b/src/features/auth/AuthContext.tsx new file mode 100644 index 0000000..b351872 --- /dev/null +++ b/src/features/auth/AuthContext.tsx @@ -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; + logout: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(() => + 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( + () => ({ + session, + accessToken: session?.accessToken ?? null, + authenticated: Boolean(session?.accessToken), + login, + logout, + }), + [session, login, logout], + ); + + return {children}; +} + +export function useAuth() { + const value = useContext(AuthContext); + + if (!value) { + throw new Error("useAuth must be used inside AuthProvider"); + } + + return value; +} diff --git a/src/features/auth/authSessionStorage.ts b/src/features/auth/authSessionStorage.ts new file mode 100644 index 0000000..c4dc20a --- /dev/null +++ b/src/features/auth/authSessionStorage.ts @@ -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; +} diff --git a/src/features/auth/hooks/useCurrentUser.ts b/src/features/auth/hooks/useCurrentUser.ts index 390c0a3..6ea4e4e 100644 --- a/src/features/auth/hooks/useCurrentUser.ts +++ b/src/features/auth/hooks/useCurrentUser.ts @@ -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, }; -} \ No newline at end of file +} diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx new file mode 100644 index 0000000..b0f68ec --- /dev/null +++ b/src/features/auth/pages/LoginPage.tsx @@ -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(null); + + const submit = async (event: FormEvent) => { + 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 ( +
+
+
+ Central LRX +
+

+ Litoral Regas +

+

Iniciar sessao

+
+
+ + + + + + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} diff --git a/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts b/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts index 6181225..3b3c402 100644 --- a/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts +++ b/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts @@ -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( + 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: { @@ -164,4 +168,4 @@ export function useChartWorkspacePersistence({ saving, error, }; -} \ No newline at end of file +} diff --git a/src/features/climate/hooks/useClimateChartSeries.ts b/src/features/climate/hooks/useClimateChartSeries.ts index b27cd30..c44c0c5 100644 --- a/src/features/climate/hooks/useClimateChartSeries.ts +++ b/src/features/climate/hooks/useClimateChartSeries.ts @@ -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( + response, + `Failed to load climate history for ${key}`, + [], + ); const points = payload .filter( @@ -130,4 +131,4 @@ function rangeToMs(range: WorkspaceChartTimeRange) { case "30d": return 30 * 24 * 60 * 60 * 1000; } -} \ No newline at end of file +} diff --git a/src/features/climate/hooks/useClimateModuleStream.ts b/src/features/climate/hooks/useClimateModuleStream.ts index 47cae83..a044d87 100644 --- a/src/features/climate/hooks/useClimateModuleStream.ts +++ b/src/features/climate/hooks/useClimateModuleStream.ts @@ -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(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( + 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(); }; }, []); @@ -56,4 +91,13 @@ export function useClimateModuleStream() { connected, lastTimestamp, }; -} \ No newline at end of file +} + +function normalizeClimateModule(payload: ClimateModuleResponse): ClimateModuleResponse { + return { + timestamp: payload.timestamp, + sensors: Array.isArray(payload.sensors) + ? payload.sensors.filter((sensor) => Boolean(sensor?.key)) + : [], + }; +} diff --git a/src/features/console/hooks/useVncConsole.ts b/src/features/console/hooks/useVncConsole.ts index b099dd9..7203b11 100644 --- a/src/features/console/hooks/useVncConsole.ts +++ b/src/features/console/hooks/useVncConsole.ts @@ -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(null); const wsRef = useRef(null); const ctxRef = useRef(null); const rgbaRef = useRef(null); - const framebufferRef = useRef({ - width: 0, - height: 0, - }); + 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 [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(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( @@ -343,4 +375,4 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { connected: state === "CONNECTED" || state === "FIRST_FRAME", connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC", }; -} \ No newline at end of file +} diff --git a/src/features/console/pages/ConsolePage.tsx b/src/features/console/pages/ConsolePage.tsx index e1a8c99..a6fa80f 100644 --- a/src/features/console/pages/ConsolePage.tsx +++ b/src/features/console/pages/ConsolePage.tsx @@ -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); @@ -390,4 +394,4 @@ function SmallInfo({ ); } -export default ConsolePage; \ No newline at end of file +export default ConsolePage; diff --git a/src/features/dashboard/hooks/useDashboardOverviewStream.ts b/src/features/dashboard/hooks/useDashboardOverviewStream.ts index e9f620e..8c728f6 100644 --- a/src/features/dashboard/hooks/useDashboardOverviewStream.ts +++ b/src/features/dashboard/hooks/useDashboardOverviewStream.ts @@ -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(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: () => { @@ -43,4 +47,4 @@ export function useDashboardOverviewStream() { connected, overview, }; -} \ No newline at end of file +} diff --git a/src/features/meteo/hooks/useAccumulatedHistory.ts b/src/features/meteo/hooks/useAccumulatedHistory.ts index 065c8f3..33d083f 100644 --- a/src/features/meteo/hooks/useAccumulatedHistory.ts +++ b/src/features/meteo/hooks/useAccumulatedHistory.ts @@ -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( + response, + `Failed to load accumulated history for ${sensorKey}`, + [], + ); const sortedPayload = [...parsed].sort( (a, b) => @@ -102,4 +90,4 @@ export function useAccumulatedHistory( buckets, loading, }; -} \ No newline at end of file +} diff --git a/src/features/meteo/hooks/useMeteoHistory.ts b/src/features/meteo/hooks/useMeteoHistory.ts index ee54ec0..ca18e74 100644 --- a/src/features/meteo/hooks/useMeteoHistory.ts +++ b/src/features/meteo/hooks/useMeteoHistory.ts @@ -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( + response, + "Failed to load meteo history", + [], + ); setPoints(payload); } catch (error) { if (controller.signal.aborted) return; @@ -69,4 +70,4 @@ export function useMeteoHistory(sensor: ModuleSensorResponse | null) { hours, setHours, }; -} \ No newline at end of file +} diff --git a/src/features/meteo/hooks/useMeteoModuleStream.ts b/src/features/meteo/hooks/useMeteoModuleStream.ts index 3a344a4..84b1272 100644 --- a/src/features/meteo/hooks/useMeteoModuleStream.ts +++ b/src/features/meteo/hooks/useMeteoModuleStream.ts @@ -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(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( + 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(); }; }, []); @@ -50,4 +85,16 @@ export function useMeteoModuleStream() { connected, lastTimestamp, }; -} \ No newline at end of file +} + +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, + }; +} diff --git a/src/features/meteo/hooks/useMeteoMultiHistory.ts b/src/features/meteo/hooks/useMeteoMultiHistory.ts index 1c8429e..11f49de 100644 --- a/src/features/meteo/hooks/useMeteoMultiHistory.ts +++ b/src/features/meteo/hooks/useMeteoMultiHistory.ts @@ -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; @@ -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( + response, + `Failed to load meteo history for ${key}`, + [], + ); return [key, payload] as const; }), @@ -77,4 +78,4 @@ export function useMeteoMultiHistory( pointsByKey, loading, }; -} \ No newline at end of file +} diff --git a/src/features/meteo/hooks/useWeatherForecast.ts b/src/features/meteo/hooks/useWeatherForecast.ts index 00a8dd5..ed60b05 100644 --- a/src/features/meteo/hooks/useWeatherForecast.ts +++ b/src/features/meteo/hooks/useWeatherForecast.ts @@ -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( + response, + "Failed to load weather forecast", + ); setForecast(payload); } catch (error) { if (controller.signal.aborted) return; @@ -96,4 +96,4 @@ export function useWeatherForecast() { loading, error, }; -} \ No newline at end of file +} diff --git a/src/features/meteo/pages/MeteoChartsPage.tsx b/src/features/meteo/pages/MeteoChartsPage.tsx index eadaf52..5d21116 100644 --- a/src/features/meteo/pages/MeteoChartsPage.tsx +++ b/src/features/meteo/pages/MeteoChartsPage.tsx @@ -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( + response, + `Failed to load meteo history for ${key}`, + [], + ); return [ key, @@ -1824,4 +1822,4 @@ function getVariableColor(index: number) { return colors[index % colors.length]; } -export default MeteoChartsPage; \ No newline at end of file +export default MeteoChartsPage; diff --git a/src/features/system/RuntimeConfigProvider.tsx b/src/features/system/RuntimeConfigProvider.tsx new file mode 100644 index 0000000..c0514a9 --- /dev/null +++ b/src/features/system/RuntimeConfigProvider.tsx @@ -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( + null, +); + +export function RuntimeConfigProvider({ children }: { children: ReactNode }) { + const [runtimeConfig, setRuntimeConfig] = useState(null); + const [error, setError] = useState(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( + () => (runtimeConfig ? { runtimeConfig } : null), + [runtimeConfig], + ); + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + if (!value) { + return ( +
+ A carregar configuracao... +
+ ); + } + + return ( + + {children} + + ); +} + +export function useRuntimeConfig() { + const value = useContext(RuntimeConfigContext); + + if (!value) { + throw new Error("useRuntimeConfig must be used inside RuntimeConfigProvider"); + } + + return value; +} diff --git a/src/features/system/hooks/useRuntimeConfig.ts b/src/features/system/hooks/useRuntimeConfig.ts index f232fbe..0e2a45d 100644 --- a/src/features/system/hooks/useRuntimeConfig.ts +++ b/src/features/system/hooks/useRuntimeConfig.ts @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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, - }; -} \ No newline at end of file +export { useRuntimeConfig } from "../RuntimeConfigProvider"; diff --git a/src/features/telemetry/hooks/useTelemetryChartSeries.ts b/src/features/telemetry/hooks/useTelemetryChartSeries.ts index 26962a6..35a3697 100644 --- a/src/features/telemetry/hooks/useTelemetryChartSeries.ts +++ b/src/features/telemetry/hooks/useTelemetryChartSeries.ts @@ -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( + response, + `Failed to load history for ${key}`, + [], + ); const points = payload .filter( @@ -211,4 +214,4 @@ function average(values: number[]) { if (values.length === 0) return 0; return values.reduce((sum, value) => sum + value, 0) / values.length; -} \ No newline at end of file +} diff --git a/src/features/telemetry/hooks/useTelemetryStream.ts b/src/features/telemetry/hooks/useTelemetryStream.ts index c79b476..9911a9b 100644 --- a/src/features/telemetry/hooks/useTelemetryStream.ts +++ b/src/features/telemetry/hooks/useTelemetryStream.ts @@ -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(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); }); }, @@ -84,4 +96,28 @@ export function useTelemetryStream() { snapshots: message?.snapshots ?? [], sensorCount: message?.sensorCount ?? 0, }; -} \ No newline at end of file +} + +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; +} diff --git a/src/lib/api/authFetch.ts b/src/lib/api/authFetch.ts new file mode 100644 index 0000000..27b5070 --- /dev/null +++ b/src/lib/api/authFetch.ts @@ -0,0 +1,20 @@ +import { getAuthAuthorizationHeader } from "../../features/auth/authSessionStorage"; + +export function getAuthHeaders(): Record { + 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, + }); +} diff --git a/src/lib/api/gatewayConfig.ts b/src/lib/api/gatewayConfig.ts new file mode 100644 index 0000000..1bb39b2 --- /dev/null +++ b/src/lib/api/gatewayConfig.ts @@ -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)}`; +} diff --git a/src/lib/api/historianApi.ts b/src/lib/api/historianApi.ts index 111f041..f3ae1e9 100644 --- a/src/lib/api/historianApi.ts +++ b/src/lib/api/historianApi.ts @@ -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(); -} \ No newline at end of file + return readJsonResponse( + response, + "Failed to fetch historian dashboard", + ); +} diff --git a/src/lib/api/readJsonResponse.ts b/src/lib/api/readJsonResponse.ts new file mode 100644 index 0000000..b112658 --- /dev/null +++ b/src/lib/api/readJsonResponse.ts @@ -0,0 +1,30 @@ +export async function readJsonResponse( + response: Response, + context: string, +): Promise { + 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( + response: Response, + context: string, + fallback: T, +): Promise { + const text = await response.text(); + + if (!response.ok) { + throw new Error(`${context}: ${response.status} ${text}`.trim()); + } + + return text ? (JSON.parse(text) as T) : fallback; +} diff --git a/src/lib/api/runtimeConfigStore.ts b/src/lib/api/runtimeConfigStore.ts new file mode 100644 index 0000000..704376d --- /dev/null +++ b/src/lib/api/runtimeConfigStore.ts @@ -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; +} diff --git a/src/lib/api/systemApi.ts b/src/lib/api/systemApi.ts index d0fbefc..7e43468 100644 --- a/src/lib/api/systemApi.ts +++ b/src/lib/api/systemApi.ts @@ -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 { - 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; -} \ No newline at end of file + return readJsonResponse( + response, + "Failed to fetch runtime config", + ); +} diff --git a/src/main.tsx b/src/main.tsx index 030217b..4570202 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + , -); \ No newline at end of file +); diff --git a/src/types/system.ts b/src/types/system.ts index c34e7d6..354d894 100644 --- a/src/types/system.ts +++ b/src/types/system.ts @@ -1,6 +1,16 @@ export type RuntimeConfig = { mode: string; - controllerName: string; - controllerIp: string; - backendPort: number; -}; \ No newline at end of file + client: { + id: number; + name: string; + }; + gateway: { + backendApiBasePath: string; + stompWebSocketPath: string; + vncWebSocketPath: string; + }; + vnc: { + defaultHost: string; + defaultPort: number; + }; +};