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 { SettingsPage } from "../features/settings/pages/SettingsPage";
|
||||||
import SynopticPage from "../features/synoptic/pages/SynopticPage";
|
import SynopticPage from "../features/synoptic/pages/SynopticPage";
|
||||||
import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage";
|
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";
|
import { TitleBar } from "../components/window/TitleBar";
|
||||||
|
|
||||||
export type AppPage =
|
export type AppPage =
|
||||||
@@ -32,6 +35,7 @@ export type AppPage =
|
|||||||
| "meteoCharts"
|
| "meteoCharts"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { authenticated } = useAuth();
|
||||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||||
|
|
||||||
const isChartWindow = window.location.pathname.startsWith("/chart-window/");
|
const isChartWindow = window.location.pathname.startsWith("/chart-window/");
|
||||||
@@ -43,58 +47,74 @@ function App() {
|
|||||||
return <ChartWindowPage theme={theme} />;
|
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 (
|
return (
|
||||||
<div className="h-screen overflow-hidden bg-[#071421]">
|
<div className="h-screen overflow-hidden bg-[#071421]">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
|
||||||
<div className="pt-9">
|
<div className="pt-9">
|
||||||
<div className="h-[calc(100vh-36px)] overflow-hidden">
|
<div className="h-[calc(100vh-36px)] overflow-hidden">
|
||||||
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
<RuntimeConfigProvider>
|
||||||
{({ theme }) => {
|
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
||||||
if (activePage === "meteo") {
|
{({ 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 (
|
return (
|
||||||
<MeteoPage
|
<DashboardPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onOpenMeteoCharts={() => setActivePage("meteoCharts")}
|
onOpenMeteo={() => setActivePage("meteo")}
|
||||||
|
onNavigate={setActivePage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}}
|
||||||
|
</AppShell>
|
||||||
if (activePage === "meteoCharts") {
|
</RuntimeConfigProvider>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
activePage={activePage}
|
activePage={activePage}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
userInitials={currentUser.initials}
|
userInitials={currentUser.initials}
|
||||||
|
userName={currentUser.name}
|
||||||
|
userRole={currentUser.role}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
@@ -152,4 +154,4 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ import {
|
|||||||
|
|
||||||
import logo from "../../assets/logo5.png";
|
import logo from "../../assets/logo5.png";
|
||||||
import type { AppPage } from "../../app/App";
|
import type { AppPage } from "../../app/App";
|
||||||
|
import { useAuth } from "../../features/auth/AuthContext";
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
activePage: AppPage;
|
activePage: AppPage;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
userInitials: string;
|
userInitials: string;
|
||||||
|
userName: string;
|
||||||
|
userRole: string;
|
||||||
onNavigate: (page: AppPage) => void;
|
onNavigate: (page: AppPage) => void;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
@@ -77,10 +80,13 @@ export function Sidebar({
|
|||||||
activePage,
|
activePage,
|
||||||
collapsed,
|
collapsed,
|
||||||
userInitials,
|
userInitials,
|
||||||
|
userName,
|
||||||
|
userRole,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const { logout } = useAuth();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const ThemeIcon = isDark ? Moon : Sun;
|
const ThemeIcon = isDark ? Moon : Sun;
|
||||||
|
|
||||||
@@ -394,7 +400,7 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
<CollapsedTooltipWrapper
|
<CollapsedTooltipWrapper
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
label="admin"
|
label={userName}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -418,7 +424,7 @@ export function Sidebar({
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
|
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
|
||||||
admin
|
{userName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -460,9 +466,9 @@ export function Sidebar({
|
|||||||
: "font-bold text-slate-950"
|
: "font-bold text-slate-950"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
admin
|
{userName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">Administrador</div>
|
<div className="text-xs text-slate-500">{userRole}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -501,6 +507,10 @@ export function Sidebar({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
className={
|
className={
|
||||||
isDark
|
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`
|
? `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>
|
</button>
|
||||||
</CollapsedTooltipWrapper>
|
</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() {
|
export function useCurrentUser() {
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "Administrador",
|
name: session?.username ?? "Administrador",
|
||||||
initials: "AD",
|
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,
|
WorkspaceChartMode,
|
||||||
WorkspaceChartTimeRange,
|
WorkspaceChartTimeRange,
|
||||||
} from "../../../components/charts/WorkspaceChart";
|
} from "../../../components/charts/WorkspaceChart";
|
||||||
|
import { authFetch } from "../../../lib/api/authFetch";
|
||||||
const API_BASE_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
|
||||||
const SAVE_DEBOUNCE_MS = 800;
|
const SAVE_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
export type ChartLayoutMode =
|
export type ChartLayoutMode =
|
||||||
@@ -69,8 +70,8 @@ export function useChartWorkspacePersistence({
|
|||||||
|
|
||||||
async function loadWorkspace() {
|
async function loadWorkspace() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${API_BASE_URL}/api/chart-workspaces/${scope}`,
|
getBackendApiUrl(`/api/chart-workspaces/${scope}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.status === 404 || response.status === 500) {
|
if (response.status === 404 || response.status === 500) {
|
||||||
@@ -81,7 +82,10 @@ export function useChartWorkspacePersistence({
|
|||||||
throw new Error(`Failed to load workspace: ${response.status}`);
|
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;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -122,8 +126,8 @@ export function useChartWorkspacePersistence({
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${API_BASE_URL}/api/chart-workspaces/${scope}`,
|
getBackendApiUrl(`/api/chart-workspaces/${scope}`),
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -164,4 +168,4 @@ export function useChartWorkspacePersistence({
|
|||||||
saving,
|
saving,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type {
|
|||||||
WorkspaceChartPoint,
|
WorkspaceChartPoint,
|
||||||
WorkspaceChartTimeRange,
|
WorkspaceChartTimeRange,
|
||||||
} from "../../../components/charts/WorkspaceChart";
|
} from "../../../components/charts/WorkspaceChart";
|
||||||
|
import { authFetch } from "../../../lib/api/authFetch";
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
|
||||||
|
|
||||||
type HistorianPoint = {
|
type HistorianPoint = {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -54,16 +55,16 @@ export function useClimateChartSeries(
|
|||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
|
||||||
throw new Error(`Failed to load climate history for ${key}`);
|
response,
|
||||||
}
|
`Failed to load climate history for ${key}`,
|
||||||
|
[],
|
||||||
const payload = (await response.json()) as HistorianPoint[];
|
);
|
||||||
|
|
||||||
const points = payload
|
const points = payload
|
||||||
.filter(
|
.filter(
|
||||||
@@ -130,4 +131,4 @@ function rangeToMs(range: WorkspaceChartTimeRange) {
|
|||||||
case "30d":
|
case "30d":
|
||||||
return 30 * 24 * 60 * 60 * 1000;
|
return 30 * 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { useEffect, useState } from "react";
|
|||||||
import { Client } from "@stomp/stompjs";
|
import { Client } from "@stomp/stompjs";
|
||||||
|
|
||||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
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 = {
|
export type ClimateModuleResponse = {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
sensors: ModuleSensorResponse[];
|
sensors: ModuleSensorResponse[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const WS_URL = "ws://localhost:18450/ws";
|
|
||||||
const TOPIC = "/topic/modules/climate/latest";
|
const TOPIC = "/topic/modules/climate/latest";
|
||||||
|
|
||||||
export function useClimateModuleStream() {
|
export function useClimateModuleStream() {
|
||||||
@@ -17,8 +20,38 @@ export function useClimateModuleStream() {
|
|||||||
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
|
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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({
|
const client = new Client({
|
||||||
brokerURL: WS_URL,
|
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
|
||||||
|
connectHeaders: getAuthHeaders(),
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
@@ -26,9 +59,10 @@ export function useClimateModuleStream() {
|
|||||||
|
|
||||||
client.subscribe(TOPIC, (message) => {
|
client.subscribe(TOPIC, (message) => {
|
||||||
const payload = JSON.parse(message.body) as ClimateModuleResponse;
|
const payload = JSON.parse(message.body) as ClimateModuleResponse;
|
||||||
|
const normalizedPayload = normalizeClimateModule(payload);
|
||||||
|
|
||||||
setModule(payload);
|
setModule(normalizedPayload);
|
||||||
setLastTimestamp(payload.timestamp);
|
setLastTimestamp(normalizedPayload.timestamp);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -45,6 +79,7 @@ export function useClimateModuleStream() {
|
|||||||
client.activate();
|
client.activate();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
controller.abort();
|
||||||
client.deactivate();
|
client.deactivate();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -56,4 +91,13 @@ export function useClimateModuleStream() {
|
|||||||
connected,
|
connected,
|
||||||
lastTimestamp,
|
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { appendAccessToken, getVncWebSocketUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
|
||||||
export type VncConnectionState =
|
export type VncConnectionState =
|
||||||
| "IDLE"
|
| "IDLE"
|
||||||
@@ -11,6 +12,7 @@ export type VncConnectionState =
|
|||||||
|
|
||||||
export type UseVncConsoleOptions = {
|
export type UseVncConsoleOptions = {
|
||||||
websocketUrl?: string;
|
websocketUrl?: string;
|
||||||
|
accessToken?: string | null;
|
||||||
defaultHost?: string;
|
defaultHost?: string;
|
||||||
defaultPort?: number;
|
defaultPort?: number;
|
||||||
};
|
};
|
||||||
@@ -21,31 +23,35 @@ export type ConnectVncInput = {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_WEBSOCKET_URL = "ws://localhost:18450/ws/vnc";
|
|
||||||
const DEFAULT_HOST = "198.19.0.176";
|
|
||||||
const DEFAULT_PORT = 5900;
|
const DEFAULT_PORT = 5900;
|
||||||
|
|
||||||
export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
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 canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||||
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
|
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
|
||||||
|
|
||||||
const framebufferRef = useRef({
|
const framebufferRef = useRef({ width: 0, height: 0 });
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [state, setState] = useState<VncConnectionState>("IDLE");
|
const [state, setState] = useState<VncConnectionState>("IDLE");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [port, setPort] = useState(options.defaultPort ?? DEFAULT_PORT);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
|
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
|
||||||
const [lastFrameAt, setLastFrameAt] = useState<string | null>(null);
|
const [lastFrameAt, setLastFrameAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const buildWebSocketUrl = useCallback(() => {
|
||||||
|
if (!accessToken) {
|
||||||
|
return websocketUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendAccessToken(websocketUrl, accessToken);
|
||||||
|
}, [websocketUrl, accessToken]);
|
||||||
|
|
||||||
const clearFrame = useCallback(() => {
|
const clearFrame = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = ctxRef.current;
|
const ctx = ctxRef.current;
|
||||||
@@ -58,11 +64,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
|
|
||||||
ctxRef.current = null;
|
ctxRef.current = null;
|
||||||
rgbaRef.current = null;
|
rgbaRef.current = null;
|
||||||
|
framebufferRef.current = { width: 0, height: 0 };
|
||||||
framebufferRef.current = {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
setFrameSize({ width: 0, height: 0 });
|
setFrameSize({ width: 0, height: 0 });
|
||||||
setLastFrameAt(null);
|
setLastFrameAt(null);
|
||||||
@@ -104,14 +106,16 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
const width = view.getInt32(0);
|
const width = view.getInt32(0);
|
||||||
const height = view.getInt32(4);
|
const height = view.getInt32(4);
|
||||||
|
|
||||||
|
console.log("[VNC] drawFrame", {
|
||||||
|
byteLength: buffer.byteLength,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
if (!width || !height || width <= 0 || height <= 0) return;
|
if (!width || !height || width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
const pixels = new Uint8ClampedArray(buffer, 8);
|
const pixels = new Uint8ClampedArray(buffer, 8);
|
||||||
|
framebufferRef.current = { width, height };
|
||||||
framebufferRef.current = {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!ctxRef.current) {
|
if (!ctxRef.current) {
|
||||||
ctxRef.current = canvas.getContext("2d", {
|
ctxRef.current = canvas.getContext("2d", {
|
||||||
@@ -156,6 +160,12 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
const nextPort = input?.port ?? port;
|
const nextPort = input?.port ?? port;
|
||||||
const nextPassword = input?.password ?? password;
|
const nextPassword = input?.password ?? password;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
setError("Token de autenticação em falta.");
|
||||||
|
setState("ERROR");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!nextHost.trim()) {
|
if (!nextHost.trim()) {
|
||||||
setError("Host VNC em falta.");
|
setError("Host VNC em falta.");
|
||||||
setState("ERROR");
|
setState("ERROR");
|
||||||
@@ -169,7 +179,7 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
setState("CONNECTING_WS");
|
setState("CONNECTING_WS");
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const socket = new WebSocket(websocketUrl);
|
const socket = new WebSocket(buildWebSocketUrl());
|
||||||
|
|
||||||
wsRef.current = socket;
|
wsRef.current = socket;
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
@@ -236,8 +246,19 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
|
|
||||||
return;
|
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 = () => {
|
socket.onerror = () => {
|
||||||
@@ -261,13 +282,14 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
accessToken,
|
||||||
|
buildWebSocketUrl,
|
||||||
clearFrame,
|
clearFrame,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
drawFrame,
|
drawFrame,
|
||||||
host,
|
host,
|
||||||
password,
|
password,
|
||||||
port,
|
port,
|
||||||
websocketUrl,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,26 +310,36 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
const relativeX = (clientX - rect.left) / rect.width;
|
const containerAspect = rect.width / rect.height;
|
||||||
const relativeY = (clientY - rect.top) / rect.height;
|
const frameAspect = framebuffer.width / framebuffer.height;
|
||||||
|
|
||||||
const x = Math.max(
|
let renderedWidth: number;
|
||||||
0,
|
let renderedHeight: number;
|
||||||
Math.min(framebuffer.width - 1, Math.round(relativeX * framebuffer.width)),
|
let offsetX: number;
|
||||||
);
|
let offsetY: number;
|
||||||
|
|
||||||
const y = Math.max(
|
if (containerAspect > frameAspect) {
|
||||||
0,
|
renderedHeight = rect.height;
|
||||||
Math.min(framebuffer.height - 1, Math.round(relativeY * framebuffer.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(
|
const relativeX = (clientX - offsetX) / renderedWidth;
|
||||||
JSON.stringify({
|
const relativeY = (clientY - offsetY) / renderedHeight;
|
||||||
type: "click",
|
|
||||||
x,
|
const clampedX = Math.max(0, Math.min(1, relativeX));
|
||||||
y,
|
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(
|
const handleCanvasPointerDown = useCallback(
|
||||||
@@ -343,4 +375,4 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) {
|
|||||||
connected: state === "CONNECTED" || state === "FIRST_FRAME",
|
connected: state === "CONNECTED" || state === "FIRST_FRAME",
|
||||||
connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC",
|
connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useVncConsole, type VncConnectionState } from "../hooks/useVncConsole";
|
import { useVncConsole, type VncConnectionState } from "../hooks/useVncConsole";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { useRuntimeConfig } from "../../system/RuntimeConfigProvider";
|
||||||
|
|
||||||
type ConsolePageProps = {
|
type ConsolePageProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
@@ -20,11 +22,13 @@ const RADIUS = "rounded-[5px]";
|
|||||||
|
|
||||||
export function ConsolePage({ theme }: ConsolePageProps) {
|
export function ConsolePage({ theme }: ConsolePageProps) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const { runtimeConfig } = useRuntimeConfig();
|
||||||
|
|
||||||
const vnc = useVncConsole({
|
const vnc = useVncConsole({
|
||||||
websocketUrl: "ws://localhost:18450/ws/vnc",
|
accessToken,
|
||||||
defaultHost: "198.19.0.176",
|
defaultHost: runtimeConfig.vnc.defaultHost,
|
||||||
defaultPort: 5900,
|
defaultPort: runtimeConfig.vnc.defaultPort,
|
||||||
});
|
});
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const connectionLabel = getConnectionLabel(vnc.state);
|
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 { useEffect, useState } from "react";
|
||||||
import { Client } from "@stomp/stompjs";
|
import { Client } from "@stomp/stompjs";
|
||||||
import type { DashboardOverview } from "../types/DashboardOverview";
|
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() {
|
export function useDashboardOverviewStream() {
|
||||||
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
||||||
@@ -8,7 +11,8 @@ export function useDashboardOverviewStream() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
brokerURL: "ws://localhost:18450/ws",
|
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
|
||||||
|
connectHeaders: getAuthHeaders(),
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
@@ -43,4 +47,4 @@ export function useDashboardOverviewStream() {
|
|||||||
connected,
|
connected,
|
||||||
overview,
|
overview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
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 = {
|
export type AccumulatedBucket = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -11,8 +14,6 @@ export type AccumulatedBucket = {
|
|||||||
|
|
||||||
type AccumulatedRange = "7d" | "30d" | "month" | "year";
|
type AccumulatedRange = "7d" | "30d" | "month" | "year";
|
||||||
|
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
|
||||||
|
|
||||||
export function useAccumulatedHistory(
|
export function useAccumulatedHistory(
|
||||||
sensor: ModuleSensorResponse | null,
|
sensor: ModuleSensorResponse | null,
|
||||||
range: AccumulatedRange,
|
range: AccumulatedRange,
|
||||||
@@ -22,11 +23,6 @@ export function useAccumulatedHistory(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sensor || !sensor.key) {
|
if (!sensor || !sensor.key) {
|
||||||
console.warn("[AccumulatedHistory SKIPPED] sensor is null or missing key", {
|
|
||||||
sensor,
|
|
||||||
range,
|
|
||||||
});
|
|
||||||
|
|
||||||
setBuckets([]);
|
setBuckets([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -34,7 +30,7 @@ export function useAccumulatedHistory(
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const sensorKey = sensor.key
|
const sensorKey = sensor.key;
|
||||||
|
|
||||||
async function loadAccumulated() {
|
async function loadAccumulated() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -45,32 +41,24 @@ export function useAccumulatedHistory(
|
|||||||
range,
|
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",
|
method: "GET",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
Pragma: "no-cache",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const parsed = await readOptionalJsonResponse<AccumulatedBucket[]>(
|
||||||
|
response,
|
||||||
if (!response.ok) {
|
`Failed to load accumulated history for ${sensorKey}`,
|
||||||
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 sortedPayload = [...parsed].sort(
|
const sortedPayload = [...parsed].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
@@ -102,4 +90,4 @@ export function useAccumulatedHistory(
|
|||||||
buckets,
|
buckets,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||||
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
||||||
|
import { authFetch } from "../../../lib/api/authFetch";
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
|
||||||
|
|
||||||
export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
|
export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
|
||||||
const [hours, setHours] = useState(1);
|
const [hours, setHours] = useState(1);
|
||||||
@@ -31,18 +32,18 @@ export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
|
||||||
{
|
{
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
|
||||||
throw new Error("Failed to load history");
|
response,
|
||||||
}
|
"Failed to load meteo history",
|
||||||
|
[],
|
||||||
const payload = (await response.json()) as HistorianPoint[];
|
);
|
||||||
setPoints(payload);
|
setPoints(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -69,4 +70,4 @@ export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
|
|||||||
hours,
|
hours,
|
||||||
setHours,
|
setHours,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Client } from "@stomp/stompjs";
|
import { Client } from "@stomp/stompjs";
|
||||||
import type { MeteoModuleResponse } from "../../../types/meteo";
|
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";
|
const TOPIC = "/topic/modules/meteo/latest";
|
||||||
|
|
||||||
export function useMeteoModuleStream() {
|
export function useMeteoModuleStream() {
|
||||||
@@ -11,8 +14,38 @@ export function useMeteoModuleStream() {
|
|||||||
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
|
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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({
|
const client = new Client({
|
||||||
brokerURL: WS_URL,
|
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
|
||||||
|
connectHeaders: getAuthHeaders(),
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
@@ -20,9 +53,10 @@ export function useMeteoModuleStream() {
|
|||||||
|
|
||||||
client.subscribe(TOPIC, (message) => {
|
client.subscribe(TOPIC, (message) => {
|
||||||
const payload = JSON.parse(message.body) as MeteoModuleResponse;
|
const payload = JSON.parse(message.body) as MeteoModuleResponse;
|
||||||
|
const normalizedPayload = normalizeMeteoModule(payload);
|
||||||
|
|
||||||
setModule(payload);
|
setModule(normalizedPayload);
|
||||||
setLastTimestamp(payload.timestamp);
|
setLastTimestamp(normalizedPayload.timestamp);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -39,6 +73,7 @@ export function useMeteoModuleStream() {
|
|||||||
client.activate();
|
client.activate();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
controller.abort();
|
||||||
client.deactivate();
|
client.deactivate();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -50,4 +85,16 @@ export function useMeteoModuleStream() {
|
|||||||
connected,
|
connected,
|
||||||
lastTimestamp,
|
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 { useEffect, useState } from "react";
|
||||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||||
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
||||||
|
import { authFetch } from "../../../lib/api/authFetch";
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
|
||||||
|
|
||||||
type SensorHistoryMap = Record<string, HistorianPoint[]>;
|
type SensorHistoryMap = Record<string, HistorianPoint[]>;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ export function useMeteoMultiHistory(
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const sensorKeys = sensors
|
const sensorKeys = sensors
|
||||||
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor))
|
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor?.key))
|
||||||
.map((sensor) => sensor.key);
|
.map((sensor) => sensor.key);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,16 +41,16 @@ export function useMeteoMultiHistory(
|
|||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
|
||||||
throw new Error(`Failed to load history for ${key}`);
|
response,
|
||||||
}
|
`Failed to load meteo history for ${key}`,
|
||||||
|
[],
|
||||||
const payload = (await response.json()) as HistorianPoint[];
|
);
|
||||||
|
|
||||||
return [key, payload] as const;
|
return [key, payload] as const;
|
||||||
}),
|
}),
|
||||||
@@ -77,4 +78,4 @@ export function useMeteoMultiHistory(
|
|||||||
pointsByKey,
|
pointsByKey,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { WeatherForecastResponse } from "../../../types/weather";
|
import type { WeatherForecastResponse } from "../../../types/weather";
|
||||||
|
import { authFetch } from "../../../lib/api/authFetch";
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
import { readJsonResponse } from "../../../lib/api/readJsonResponse";
|
||||||
|
|
||||||
type LocationState = {
|
type LocationState = {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
@@ -62,16 +63,15 @@ export function useWeatherForecast() {
|
|||||||
days: "7",
|
days: "7",
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${BACKEND_URL}/api/weather/forecast?${params.toString()}`,
|
getBackendApiUrl(`/api/weather/forecast?${params.toString()}`),
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await readJsonResponse<WeatherForecastResponse>(
|
||||||
throw new Error("Failed to load weather forecast");
|
response,
|
||||||
}
|
"Failed to load weather forecast",
|
||||||
|
);
|
||||||
const payload = (await response.json()) as WeatherForecastResponse;
|
|
||||||
setForecast(payload);
|
setForecast(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -96,4 +96,4 @@ export function useWeatherForecast() {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { ChartConfigModal } from "../../chartworkspace/components/ChartConfigModal";
|
||||||
import { openChartWindow } from "../../chartworkspace/utils/openChartWindow";
|
import { openChartWindow } from "../../chartworkspace/utils/openChartWindow";
|
||||||
@@ -72,7 +75,6 @@ type ChartWorkspaceItem = PersistedChartWorkspaceItem & {
|
|||||||
windowZIndex?: number;
|
windowZIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
|
||||||
const RADIUS = "rounded-[6px]";
|
const RADIUS = "rounded-[6px]";
|
||||||
const MAX_CHARTS = 10;
|
const MAX_CHARTS = 10;
|
||||||
const MAX_VARIABLES_PER_CHART = 6;
|
const MAX_VARIABLES_PER_CHART = 6;
|
||||||
@@ -1070,27 +1072,23 @@ function useMeteoChartSeries(
|
|||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
getBackendApiUrl(`/api/historian/series?${params.toString()}`),
|
||||||
{
|
{
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
|
...getAuthHeaders(),
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
Pragma: "no-cache",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
|
||||||
const text = await response.text();
|
response,
|
||||||
throw new Error(
|
`Failed to load meteo history for ${key}`,
|
||||||
`Failed to load meteo history for ${key}: ${response.status} ${text}`,
|
[],
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as HistorianPoint[];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
key,
|
key,
|
||||||
@@ -1824,4 +1822,4 @@ function getVariableColor(index: number) {
|
|||||||
return colors[index % colors.length];
|
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";
|
export { useRuntimeConfig } from "../RuntimeConfigProvider";
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type {
|
|||||||
WorkspaceChartPoint,
|
WorkspaceChartPoint,
|
||||||
WorkspaceChartTimeRange,
|
WorkspaceChartTimeRange,
|
||||||
} from "../../../components/charts/WorkspaceChart";
|
} from "../../../components/charts/WorkspaceChart";
|
||||||
|
import { authFetch } from "../../../lib/api/authFetch";
|
||||||
const BACKEND_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "../../../lib/api/gatewayConfig";
|
||||||
|
import { readOptionalJsonResponse } from "../../../lib/api/readJsonResponse";
|
||||||
|
|
||||||
type HistorianPoint = {
|
type HistorianPoint = {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -56,18 +57,20 @@ export function useTelemetryChartSeries(
|
|||||||
to: to.toISOString(),
|
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,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await readOptionalJsonResponse<HistorianPoint[]>(
|
||||||
throw new Error(`Failed to load history for ${key}: ${response.status}`);
|
response,
|
||||||
}
|
`Failed to load history for ${key}`,
|
||||||
|
[],
|
||||||
const payload = (await response.json()) as HistorianPoint[];
|
);
|
||||||
|
|
||||||
const points = payload
|
const points = payload
|
||||||
.filter(
|
.filter(
|
||||||
@@ -211,4 +214,4 @@ function average(values: number[]) {
|
|||||||
if (values.length === 0) return 0;
|
if (values.length === 0) return 0;
|
||||||
|
|
||||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Client } from "@stomp/stompjs";
|
import { Client } from "@stomp/stompjs";
|
||||||
import type { TelemetryBroadcastMessage } from "../../../types/telemetry";
|
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() {
|
export function useTelemetryStream() {
|
||||||
const [message, setMessage] = useState<TelemetryBroadcastMessage | null>(null);
|
const [message, setMessage] = useState<TelemetryBroadcastMessage | null>(null);
|
||||||
@@ -14,10 +22,11 @@ export function useTelemetryStream() {
|
|||||||
|
|
||||||
async function loadInitialLatest() {
|
async function loadInitialLatest() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BACKEND_URL}/api/telemetry/latest`, {
|
const response = await authFetch(getBackendApiUrl(LATEST_TELEMETRY_PATH), {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
|
...getAuthHeaders(),
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -26,9 +35,11 @@ export function useTelemetryStream() {
|
|||||||
throw new Error(`Failed to load latest telemetry: ${response.status}`);
|
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) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
@@ -43,7 +54,8 @@ export function useTelemetryStream() {
|
|||||||
loadInitialLatest();
|
loadInitialLatest();
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
brokerURL: "ws://localhost:18450/ws",
|
brokerURL: appendAccessToken(getStompWebSocketUrl(), getStoredAccessToken()),
|
||||||
|
connectHeaders: getAuthHeaders(),
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
@@ -52,7 +64,7 @@ export function useTelemetryStream() {
|
|||||||
client.subscribe("/topic/telemetry/latest", (frame) => {
|
client.subscribe("/topic/telemetry/latest", (frame) => {
|
||||||
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
|
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
|
||||||
|
|
||||||
setMessage(payload);
|
setMessage(normalizeTelemetryPayload(payload));
|
||||||
setInitialLoading(false);
|
setInitialLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -84,4 +96,28 @@ export function useTelemetryStream() {
|
|||||||
snapshots: message?.snapshots ?? [],
|
snapshots: message?.snapshots ?? [],
|
||||||
sensorCount: message?.sensorCount ?? 0,
|
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";
|
import type { HistorianDashboardResponse } from "../../types/historian";
|
||||||
|
import { authFetch } from "./authFetch";
|
||||||
const API_BASE_URL = "http://localhost:18450";
|
import { getBackendApiUrl } from "./gatewayConfig";
|
||||||
|
import { readJsonResponse } from "./readJsonResponse";
|
||||||
|
|
||||||
export async function fetchHistorianDashboard(
|
export async function fetchHistorianDashboard(
|
||||||
keys: string[],
|
keys: string[],
|
||||||
@@ -13,13 +14,12 @@ export async function fetchHistorianDashboard(
|
|||||||
params.set("from", from);
|
params.set("from", from);
|
||||||
params.set("to", to);
|
params.set("to", to);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await authFetch(
|
||||||
`${API_BASE_URL}/api/historian/dashboard?${params.toString()}`,
|
getBackendApiUrl(`/api/historian/dashboard?${params.toString()}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
return readJsonResponse<HistorianDashboardResponse>(
|
||||||
throw new Error(`Failed to fetch historian dashboard: ${response.status}`);
|
response,
|
||||||
}
|
"Failed to fetch historian dashboard",
|
||||||
|
);
|
||||||
return response.json();
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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";
|
import type { RuntimeConfig } from "../../types/system";
|
||||||
|
import { authFetch } from "./authFetch";
|
||||||
const API_BASE_URL = "http://localhost:18450";
|
import { getGatewayHttpUrl } from "./gatewayConfig";
|
||||||
|
import { readJsonResponse } from "./readJsonResponse";
|
||||||
|
|
||||||
export async function fetchRuntimeConfig(): Promise<RuntimeConfig> {
|
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) {
|
return readJsonResponse<RuntimeConfig>(
|
||||||
throw new Error("Failed to fetch runtime config.");
|
response,
|
||||||
}
|
"Failed to fetch runtime config",
|
||||||
|
);
|
||||||
return response.json() as Promise<RuntimeConfig>;
|
}
|
||||||
}
|
|
||||||
|
|||||||
+5
-2
@@ -1,10 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./app/App";
|
import App from "./app/App";
|
||||||
|
import { AuthProvider } from "./features/auth/AuthContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-4
@@ -1,6 +1,16 @@
|
|||||||
export type RuntimeConfig = {
|
export type RuntimeConfig = {
|
||||||
mode: string;
|
mode: string;
|
||||||
controllerName: string;
|
client: {
|
||||||
controllerIp: string;
|
id: number;
|
||||||
backendPort: number;
|
name: string;
|
||||||
};
|
};
|
||||||
|
gateway: {
|
||||||
|
backendApiBasePath: string;
|
||||||
|
stompWebSocketPath: string;
|
||||||
|
vncWebSocketPath: string;
|
||||||
|
};
|
||||||
|
vnc: {
|
||||||
|
defaultHost: string;
|
||||||
|
defaultPort: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user