Build application shell with live runtime status

This commit is contained in:
litoral05
2026-05-20 12:22:40 +01:00
parent b38c374a81
commit 9fcf67c7ae
16 changed files with 672 additions and 6 deletions
+4
View File
@@ -1 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
body {
background: red;
}
+4 -4
View File
@@ -1,10 +1,10 @@
import "../App.css"; import { AppShell } from "../components/layout/AppShell";
function App() { function App() {
return ( return (
<main className="min-h-screen bg-slate-950 text-slate-100"> <AppShell>
LitoralRegas Frontend <div />
</main> </AppShell>
); );
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+66
View File
@@ -0,0 +1,66 @@
import { Sidebar } from "../navigation/Sidebar";
import { TopBar } from "./TopBar";
import { BottomStatusBar } from "./BottomStatusBar";
import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream";
import { useNotifications } from "../../features/notifications/hooks/useNotifications";
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
import { useState } from "react";
type AppShellProps = {
children: React.ReactNode;
};
export function AppShell({ children }: AppShellProps) {
const telemetry = useTelemetryStream();
const notifications = useNotifications();
const currentUser = useCurrentUser();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const runtime = useRuntimeConfig();
const toggleTheme = () => {
setTheme((current) => (current === "dark" ? "light" : "dark"));
};
const isDark = theme === "dark";
return (
<div className={isDark ? "min-h-screen bg-[#0E1A24] text-[#EAF2FA]" : "min-h-screen bg-[#F4F7FA] text-[#102030]"}>
<div className="flex min-h-screen">
<div className="relative">
<Sidebar theme={theme} />
<div className="pointer-events-none absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-[#3A5064]/6 to-transparent blur-sm" />
</div>
<div className="flex min-h-screen flex-1 flex-col">
<TopBar
connected={telemetry.connected}
lastTimestamp={telemetry.lastTimestamp}
notificationCount={notifications.unreadCount}
userInitials={currentUser.initials}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main
className={
isDark
? "flex-1 overflow-y-auto bg-[#0E1A24] px-6 py-5"
: "flex-1 overflow-y-auto bg-[#F4F7FA] px-6 py-5"
}
>
{children}
</main>
<BottomStatusBar
theme={theme}
backendPort={runtime.runtimeConfig?.backendPort?.toString()}
mode={runtime.runtimeConfig?.mode}
controllerName={runtime.runtimeConfig?.controllerName}
controllerIp={runtime.runtimeConfig?.controllerIp}
/>
</div>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
type BottomStatusBarProps = {
theme: "dark" | "light";
backendPort?: string;
mode?: string;
controllerName?: string;
controllerIp?: string;
};
export function BottomStatusBar({
theme,
backendPort = "18450",
mode = "Local",
controllerName = "PLC_Principal",
controllerIp = "198.19.0.176",
}: BottomStatusBarProps) {
const isDark = theme === "dark";
return (
<footer
className={
isDark
? "flex h-10 items-center justify-between bg-[#0E1A24] px-6 text-sm text-[#D8E2EC]"
: "flex h-10 items-center justify-between bg-[#F4F7FA] px-6 text-sm text-[#445569]"
}
>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>Porto Backend: {backendPort}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>Modo: {mode}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>Controlador: {controllerName}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>IP Controlador: {controllerIp}</span>
</div>
</footer>
);
}
+309
View File
@@ -0,0 +1,309 @@
import {
Bell,
CalendarDays,
ChevronDown,
CircleHelp,
Clock,
Info,
LogOut,
Moon,
Settings2,
SlidersHorizontal,
Sun,
User,
} from "lucide-react";
import { useState } from "react";
type TopBarProps = {
connected: boolean;
lastTimestamp: string | null;
notificationCount: number;
userInitials: string;
theme: "dark" | "light";
onToggleTheme: () => void;
};
export function TopBar({
connected,
lastTimestamp,
notificationCount,
userInitials,
theme,
onToggleTheme,
}: TopBarProps) {
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const isDark = theme === "dark";
const ThemeIcon = isDark ? Moon : Sun;
const systemDate = lastTimestamp
? new Date(lastTimestamp)
: null;
const formattedTime = systemDate
? systemDate.toLocaleTimeString("pt-PT", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
: "--:--:--";
const formattedDate = systemDate
? systemDate.toLocaleDateString("pt-PT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: "--/--/----";
const dropdownClass = isDark
? "absolute right-0 top-12 z-50 rounded-2xl border border-[#24394A] bg-[#0F1D29] shadow-2xl shadow-black/40"
: "absolute right-0 top-12 z-50 rounded-2xl border border-[#D5DDE6] bg-white shadow-2xl shadow-slate-300/40";
const dropdownTitleClass = isDark
? "text-sm font-semibold text-[#E4EDF6]"
: "text-sm font-semibold text-[#162434]";
const mutedTextClass = isDark
? "text-[#8FA3B8]"
: "text-[#607284]";
const menuItemClass = isDark
? "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#D4DEE8] transition-colors hover:bg-[#182B3B]"
: "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#162434] transition-colors hover:bg-[#EEF3F7]";
const dividerClass = isDark
? "my-2 h-px bg-[#24394A]"
: "my-2 h-px bg-[#D5DDE6]";
return (
<header
className={
isDark
? "flex h-16 items-center justify-between bg-[#0E1A24] px-6"
: "flex h-16 items-center justify-between bg-[#F4F7FA] px-6"
}
>
<div>
<h1
className={
isDark
? "text-[28px] font-bold tracking-tight text-[#D4DEE8]"
: "text-[28px] font-bold tracking-tight text-[#162434]"
}
>
Painel Principal
</h1>
</div>
<div
className={
isDark
? "flex items-center gap-6 text-sm text-slate-300"
: "flex items-center gap-6 text-sm text-[#445569]"
}
>
<div className="flex items-center gap-2">
<span
className={
connected
? "h-2.5 w-2.5 rounded-full bg-emerald-500"
: "h-2.5 w-2.5 rounded-full bg-red-500"
}
/>
<span>
{connected
? "Ligado ao sistema"
: "Sistema desligado"}
</span>
</div>
<div className="flex items-center gap-2">
<Clock
className={
isDark
? "h-4 w-4 text-slate-400"
: "h-4 w-4 text-[#607284]"
}
/>
<span>{formattedTime}</span>
</div>
<div className="flex items-center gap-2">
<CalendarDays
className={
isDark
? "h-4 w-4 text-slate-400"
: "h-4 w-4 text-[#607284]"
}
/>
<span>{formattedDate}</span>
</div>
<div className="relative">
<button
onClick={() => {
setNotificationsOpen(!notificationsOpen);
setUserMenuOpen(false);
}}
className={
isDark
? "relative flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-[#182531]"
: "relative flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:bg-[#E2E8F0]"
}
>
<Bell
className={
isDark
? "h-5 w-5 text-[#C8D3DF]"
: "h-5 w-5 text-[#445569]"
}
/>
{notificationCount > 0 && (
<span className="absolute right-1 top-0 rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
{notificationCount}
</span>
)}
</button>
{notificationsOpen && (
<div className={`${dropdownClass} w-80 p-4`}>
<div className="mb-3 flex items-center justify-between">
<span className={dropdownTitleClass}>
Notificações
</span>
<span className={`text-xs ${mutedTextClass}`}>
{notificationCount} novas
</span>
</div>
<div
className={
isDark
? "rounded-xl bg-[#132331] px-4 py-4 text-sm text-[#8FA3B8]"
: "rounded-xl bg-[#EEF3F7] px-4 py-4 text-sm text-[#607284]"
}
>
Sem notificações.
</div>
</div>
)}
</div>
<div className="relative">
<button
onClick={() => {
setUserMenuOpen(!userMenuOpen);
setNotificationsOpen(false);
}}
className={
isDark
? "flex items-center gap-2 rounded-full bg-[#182531] py-1 pl-1 pr-3 transition-colors hover:bg-[#223445]"
: "flex items-center gap-2 rounded-full bg-[#E2E8F0] py-1 pl-1 pr-3 transition-colors hover:bg-[#D5DDE6]"
}
>
<span
className={
isDark
? "flex h-8 w-8 items-center justify-center rounded-full bg-[#24394A] text-xs font-semibold text-[#D4DEE8]"
: "flex h-8 w-8 items-center justify-center rounded-full bg-[#CBD5E1] text-xs font-semibold text-[#162434]"
}
>
{userInitials}
</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${isDark
? "text-[#8FA3B8]"
: "text-[#607284]"
} ${userMenuOpen ? "rotate-180" : ""}`}
/>
</button>
{userMenuOpen && (
<div className={`${dropdownClass} w-64 p-3`}>
<div className="mb-3 flex items-center gap-3 rounded-xl px-2 py-2">
<div
className={
isDark
? "flex h-10 w-10 items-center justify-center rounded-full bg-[#24394A] text-sm font-semibold text-[#D4DEE8]"
: "flex h-10 w-10 items-center justify-center rounded-full bg-[#CBD5E1] text-sm font-semibold text-[#162434]"
}
>
{userInitials}
</div>
<div>
<div className={dropdownTitleClass}>
admin
</div>
<div className={`text-xs ${mutedTextClass}`}>
Administrador
</div>
</div>
</div>
<div className={dividerClass} />
<button className={menuItemClass}>
<User className={`h-4 w-4 ${mutedTextClass}`} />
Perfil
</button>
<button className={menuItemClass}>
<SlidersHorizontal className={`h-4 w-4 ${mutedTextClass}`} />
Preferências
</button>
<button
onClick={onToggleTheme}
className={
isDark
? "flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm text-[#D4DEE8] transition-colors hover:bg-[#182B3B]"
: "flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm text-[#162434] transition-colors hover:bg-[#EEF3F7]"
}
>
<span className="flex items-center gap-3">
<Settings2 className={`h-4 w-4 ${mutedTextClass}`} />
Tema
</span>
<ThemeIcon
className={
isDark
? "h-4 w-4 text-[#8FA3B8]"
: "h-4 w-4 text-[#607284]"
}
/>
</button>
<div className={dividerClass} />
<button className={menuItemClass}>
<CircleHelp className={`h-4 w-4 ${mutedTextClass}`} />
Ajuda
</button>
<button className={menuItemClass}>
<Info className={`h-4 w-4 ${mutedTextClass}`} />
Sobre
</button>
<div className={dividerClass} />
<button className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10">
<LogOut className="h-4 w-4" />
Terminar sessão
</button>
</div>
)}
</div>
</div>
</header>
);
}
+101
View File
@@ -0,0 +1,101 @@
import {
CloudSun,
Droplet,
Home,
Settings,
TabletSmartphone,
Wind,
} from "lucide-react";
import logo from "../../assets/logo.png";
type SidebarProps = {
theme: "dark" | "light";
};
const navigationItems = [
{ label: "Painel Principal", icon: Home, active: true },
{ label: "Meteorologia", icon: CloudSun },
{ label: "Consola (VNC)", icon: TabletSmartphone },
{ label: "Rega", icon: Droplet },
{ label: "Clima", icon: Wind },
{ label: "Configurações", icon: Settings },
];
export function Sidebar({ theme }: SidebarProps) {
const isDark = theme === "dark";
return (
<aside
className={
isDark
? "flex min-h-screen w-64 flex-col bg-[#0B1620] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(80,100,120,0.25),2px_0_12px_rgba(0,0,0,0.15)]"
: "flex min-h-screen w-64 flex-col bg-[#EEF3F7] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(180,190,200,0.5)]"
}
>
<div className="mb-10 flex items-center gap-3 px-2">
<img
src={logo}
alt="LitoralRegas"
className="h-12 w-12 shrink-0 object-contain"
/>
<div className="min-w-0">
<div
className={
isDark
? "truncate text-[16px] font-bold tracking-wide text-white"
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
}
>
LITORAL CENTRAL
</div>
<div
className={
isDark
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
}
>
OPERAÇÕES AGRÍCOLAS
</div>
</div>
</div>
<nav className="space-y-2">
{navigationItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.label}
className={
item.active
? isDark
? "flex w-full items-center gap-3 rounded-xl bg-[#18304B] px-4 py-3 text-left text-sm font-semibold text-white"
: "flex w-full items-center gap-3 rounded-xl bg-[#DCE8F5] px-4 py-3 text-left text-sm font-semibold text-[#162434]"
: isDark
? "flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#D8E2EC] hover:bg-[#132434] hover:text-white"
: "flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#445569] hover:bg-[#E2E8F0] hover:text-[#162434]"
}
>
<Icon className="h-5 w-5" />
{item.label}
</button>
);
})}
</nav>
<div
className={
isDark
? "mt-auto px-4 text-sm text-slate-400"
: "mt-auto px-4 text-sm text-[#607284]"
}
>
Recolher menu
</div>
</aside>
);
}
@@ -0,0 +1,6 @@
export function useCurrentUser() {
return {
name: "Administrador",
initials: "AD",
};
}
@@ -0,0 +1,5 @@
export function useNotifications() {
return {
unreadCount: 0,
};
}
@@ -0,0 +1,33 @@
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,
};
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { Client } from "@stomp/stompjs";
import type { TelemetryBroadcastMessage } from "../../../types/telemetry";
export function useTelemetryStream() {
const [message, setMessage] = useState<TelemetryBroadcastMessage | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const client = new Client({
brokerURL: "ws://localhost:18450/ws",
reconnectDelay: 3000,
onConnect: () => {
setConnected(true);
client.subscribe("/topic/telemetry/latest", (frame) => {
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
setMessage(payload);
});
},
onWebSocketClose: () => {
setConnected(false);
},
onStompError: () => {
setConnected(false);
},
});
client.activate();
return () => {
client.deactivate();
};
}, []);
return {
connected,
message,
lastTimestamp: message?.timestamp ?? null,
snapshots: message?.snapshots ?? [],
sensorCount: message?.sensorCount ?? 0,
};
}
+13
View File
@@ -0,0 +1,13 @@
@import "tailwindcss";
html,
body,
#root {
margin: 0;
min-height: 100%;
background: #0f1720;
}
body {
font-family: Inter, system-ui, sans-serif;
}
+13
View File
@@ -0,0 +1,13 @@
import type { RuntimeConfig } from "../../types/system";
const API_BASE_URL = "http://localhost:18450";
export async function fetchRuntimeConfig(): Promise<RuntimeConfig> {
const response = await fetch(`${API_BASE_URL}/api/system/runtime-config`);
if (!response.ok) {
throw new Error("Failed to fetch runtime config.");
}
return response.json() as Promise<RuntimeConfig>;
}
+1
View File
@@ -1,6 +1,7 @@
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 "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
+6
View File
@@ -0,0 +1,6 @@
export type RuntimeConfig = {
mode: string;
controllerName: string;
controllerIp: string;
backendPort: number;
};
+16
View File
@@ -0,0 +1,16 @@
export type TelemetrySnapshot = {
sensorId: number;
name: string;
modbusAddress: number;
bitOffset: number | null;
rawValue: number;
value: number | boolean | string | null;
unit: string | null;
timestamp: string;
};
export type TelemetryBroadcastMessage = {
timestamp: string;
sensorCount: number;
snapshots: TelemetrySnapshot[];
};