auth/runtime config, URLs centralizados, parsing/hardening dos hooks/charts
This commit is contained in:
+59
-39
@@ -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,58 +47,74 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -821,4 +831,4 @@ function NavItem({
|
||||
</button>
|
||||
</CollapsedTooltipWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
@@ -164,4 +168,4 @@ export function useChartWorkspacePersistence({
|
||||
saving,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -130,4 +131,4 @@ function rangeToMs(range: WorkspaceChartTimeRange) {
|
||||
case "30d":
|
||||
return 30 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
@@ -56,4 +91,13 @@ export function useClimateModuleStream() {
|
||||
connected,
|
||||
lastTimestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeClimateModule(payload: ClimateModuleResponse): ClimateModuleResponse {
|
||||
return {
|
||||
timestamp: payload.timestamp,
|
||||
sensors: Array.isArray(payload.sensors)
|
||||
? payload.sensors.filter((sensor) => Boolean(sensor?.key))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -343,4 +375,4 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
||||
connected: state === "CONNECTED" || state === "FIRST_FRAME",
|
||||
connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
export default ConsolePage;
|
||||
|
||||
@@ -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: () => {
|
||||
@@ -43,4 +47,4 @@ export function useDashboardOverviewStream() {
|
||||
connected,
|
||||
overview,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
@@ -102,4 +90,4 @@ export function useAccumulatedHistory(
|
||||
buckets,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -69,4 +70,4 @@ export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
|
||||
hours,
|
||||
setHours,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
@@ -50,4 +85,16 @@ export function useMeteoModuleStream() {
|
||||
connected,
|
||||
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;
|
||||
}),
|
||||
@@ -77,4 +78,4 @@ export function useMeteoMultiHistory(
|
||||
pointsByKey,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -96,4 +96,4 @@ export function useWeatherForecast() {
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1824,4 +1822,4 @@ function getVariableColor(index: number) {
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
export default MeteoChartsPage;
|
||||
export default MeteoChartsPage;
|
||||
|
||||
@@ -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 +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(
|
||||
@@ -211,4 +214,4 @@ function average(values: number[]) {
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
@@ -84,4 +96,28 @@ export function useTelemetryStream() {
|
||||
snapshots: message?.snapshots ?? [],
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
+10
-10
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
+5
-2
@@ -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>,
|
||||
);
|
||||
);
|
||||
|
||||
+14
-4
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user