Build application shell with live runtime status
This commit is contained in:
+5
-1
@@ -1 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
background: red;
|
||||
}
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import "../App.css";
|
||||
import { AppShell } from "../components/layout/AppShell";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950 text-slate-100">
|
||||
LitoralRegas Frontend
|
||||
</main>
|
||||
<AppShell>
|
||||
<div />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: #0f1720;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./app/App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
export type RuntimeConfig = {
|
||||
mode: string;
|
||||
controllerName: string;
|
||||
controllerIp: string;
|
||||
backendPort: number;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user