feat: add complete frontend with React, Tailwind 4K, and Docker setup
- Vite + React 18 + TypeScript scaffolding - Tailwind CSS configured for 4K dark theme (24px base) - Three full-screen rotating views: Network Topology (D3.js), Kanban Board (Odoo tasks), Calendar (Odoo events) - Hooks for data fetching, WebSocket, and view rotation - Header with live clock and connection status - Framer Motion fade transitions between views - Docker Compose with backend (host network for nmap) and frontend (nginx proxy to backend API/WS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=3840, initial-scale=1.0" />
|
||||
<title>TV Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
2966
frontend/package-lock.json
generated
Normal file
2966
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
"d3": "^7.9.0",
|
||||
"framer-motion": "^12.34.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
82
frontend/src/App.tsx
Normal file
82
frontend/src/App.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useCallback } from "react";
|
||||
import { Header } from "./components/Layout/Header";
|
||||
import { ViewRotator } from "./components/Layout/ViewRotator";
|
||||
import { NetworkGraph } from "./components/Topology/NetworkGraph";
|
||||
import { KanbanBoard } from "./components/Tasks/KanbanBoard";
|
||||
import { CalendarView } from "./components/Calendar/CalendarView";
|
||||
import {
|
||||
useTopology,
|
||||
useTasks,
|
||||
useCalendar,
|
||||
useDisplayConfig,
|
||||
} from "./hooks/useOdooData";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useRotation } from "./hooks/useRotation";
|
||||
import type { WSMessage } from "./types";
|
||||
|
||||
const VIEW_NAMES = ["Topologia de Red", "Proyectos Odoo", "Calendario"];
|
||||
|
||||
function LoadingScreen({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-2xl text-text-secondary animate-pulse">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const config = useDisplayConfig();
|
||||
const intervalMs = (config?.rotation_interval_seconds ?? 30) * 1000;
|
||||
|
||||
const activeView = useRotation(3, intervalMs);
|
||||
const topology = useTopology();
|
||||
const tasks = useTasks();
|
||||
const calendar = useCalendar();
|
||||
|
||||
const handleWsMessage = useCallback(
|
||||
(msg: WSMessage) => {
|
||||
if (msg.type === "ping_update") {
|
||||
topology.refetch();
|
||||
} else if (msg.type === "odoo_refresh") {
|
||||
tasks.refetch();
|
||||
calendar.refetch();
|
||||
}
|
||||
},
|
||||
[topology, tasks, calendar]
|
||||
);
|
||||
|
||||
useWebSocket(handleWsMessage);
|
||||
|
||||
const connected = !topology.error && !tasks.error && !calendar.error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-[3840px] h-[2160px]">
|
||||
<Header viewName={VIEW_NAMES[activeView]} connected={connected} />
|
||||
<ViewRotator activeView={activeView}>
|
||||
<div className="h-full">
|
||||
{topology.data ? (
|
||||
<NetworkGraph nodes={topology.data.nodes} />
|
||||
) : (
|
||||
<LoadingScreen label="Cargando topologia..." />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full">
|
||||
{tasks.data ? (
|
||||
<KanbanBoard projects={tasks.data.projects} />
|
||||
) : (
|
||||
<LoadingScreen label="Cargando tareas..." />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full">
|
||||
{calendar.data ? (
|
||||
<CalendarView events={calendar.data.events} />
|
||||
) : (
|
||||
<LoadingScreen label="Cargando calendario..." />
|
||||
)}
|
||||
</div>
|
||||
</ViewRotator>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
76
frontend/src/components/Calendar/CalendarView.tsx
Normal file
76
frontend/src/components/Calendar/CalendarView.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { EventCard } from "./EventCard";
|
||||
import type { CalendarEvent } from "../../types";
|
||||
|
||||
interface CalendarViewProps {
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
export function CalendarView({ events }: CalendarViewProps) {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split("T")[0];
|
||||
const tomorrow = new Date(now.getTime() + 86400000).toISOString().split("T")[0];
|
||||
|
||||
const todayEvents = events.filter((e) => e.start.startsWith(today));
|
||||
const tomorrowEvents = events.filter((e) => e.start.startsWith(tomorrow));
|
||||
const laterEvents = events.filter(
|
||||
(e) => !e.start.startsWith(today) && !e.start.startsWith(tomorrow)
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("es-MX", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-12 overflow-hidden">
|
||||
<section className="mb-10">
|
||||
<h2 className="text-3xl font-bold text-text-primary mb-6">
|
||||
Hoy — {formatDate(today)}
|
||||
</h2>
|
||||
{todayEvents.length === 0 ? (
|
||||
<p className="text-xl text-text-secondary">Sin eventos programados</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{todayEvents.map((e) => (
|
||||
<EventCard key={e.id} event={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-4">
|
||||
Mañana — {formatDate(tomorrow)}
|
||||
</h2>
|
||||
{tomorrowEvents.length === 0 ? (
|
||||
<p className="text-lg text-text-secondary">Sin eventos programados</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tomorrowEvents.map((e) => (
|
||||
<EventCard key={e.id} event={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{laterEvents.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-4">Esta semana</h2>
|
||||
<div className="space-y-3">
|
||||
{laterEvents.map((e) => (
|
||||
<div key={e.id} className="flex gap-4 text-lg text-text-secondary">
|
||||
<span className="font-mono min-w-[200px] capitalize">
|
||||
{formatDate(e.start)}
|
||||
</span>
|
||||
<span className="text-text-primary">{e.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/Calendar/EventCard.tsx
Normal file
31
frontend/src/components/Calendar/EventCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { CalendarEvent } from "../../types";
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
const startTime = new Date(event.start).toLocaleTimeString("es-MX", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const endTime = new Date(event.stop).toLocaleTimeString("es-MX", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-6 bg-bg-card rounded-xl p-6 border border-border">
|
||||
<div className="text-2xl font-mono font-bold text-accent min-w-[140px]">
|
||||
{startTime}
|
||||
<span className="text-text-secondary text-lg"> - {endTime}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-text-primary">{event.name}</h3>
|
||||
{event.location && (
|
||||
<p className="text-base text-text-secondary mt-1">{event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/Layout/Header.tsx
Normal file
45
frontend/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface HeaderProps {
|
||||
viewName: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export function Header({ viewName, connected }: HeaderProps) {
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const dateStr = time.toLocaleDateString("es-MX", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const timeStr = time.toLocaleTimeString("es-MX", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-12 py-6 bg-bg-secondary border-b border-border">
|
||||
<div className="flex items-center gap-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<span className="text-lg text-text-secondary">{viewName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="text-lg text-text-secondary capitalize">{dateStr}</span>
|
||||
<span className="text-2xl font-mono font-bold">{timeStr}</span>
|
||||
<span
|
||||
className={`w-4 h-4 rounded-full ${connected ? "bg-success" : "bg-danger"}`}
|
||||
title={connected ? "Conectado" : "Desconectado"}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/Layout/ViewRotator.tsx
Normal file
26
frontend/src/components/Layout/ViewRotator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface ViewRotatorProps {
|
||||
activeView: number;
|
||||
children: ReactNode[];
|
||||
}
|
||||
|
||||
export function ViewRotator({ activeView, children }: ViewRotatorProps) {
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{children[activeView]}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/Tasks/KanbanBoard.tsx
Normal file
58
frontend/src/components/Tasks/KanbanBoard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { Project } from "../../types";
|
||||
|
||||
interface KanbanBoardProps {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export function KanbanBoard({ projects }: KanbanBoardProps) {
|
||||
const allStages = new Set<string>();
|
||||
for (const project of projects) {
|
||||
for (const stage of Object.keys(project.stages)) {
|
||||
allStages.add(stage);
|
||||
}
|
||||
}
|
||||
const stageList = Array.from(allStages);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-8 overflow-hidden">
|
||||
<div
|
||||
className="grid gap-4 mb-6"
|
||||
style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }}
|
||||
>
|
||||
<div className="text-lg font-bold text-text-secondary uppercase tracking-wider">
|
||||
Proyecto
|
||||
</div>
|
||||
{stageList.map((stage) => (
|
||||
<div
|
||||
key={stage}
|
||||
className="text-lg font-bold text-text-secondary uppercase tracking-wider text-center"
|
||||
>
|
||||
{stage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-4">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="grid gap-4 bg-bg-secondary rounded-xl p-4 border border-border"
|
||||
style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<span className="text-xl font-bold text-text-primary">{project.name}</span>
|
||||
</div>
|
||||
{stageList.map((stage) => (
|
||||
<div key={stage} className="space-y-2 min-h-[80px]">
|
||||
{(project.stages[stage] || []).map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/Tasks/TaskCard.tsx
Normal file
24
frontend/src/components/Tasks/TaskCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { OdooTask } from "../../types";
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
"0": "border-border",
|
||||
"1": "border-warning",
|
||||
"2": "border-danger",
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: OdooTask;
|
||||
}
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const borderColor = PRIORITY_COLORS[task.priority] || "border-border";
|
||||
|
||||
return (
|
||||
<div className={`bg-bg-card border-l-4 ${borderColor} rounded-lg p-4 mb-3`}>
|
||||
<p className="text-base font-medium text-text-primary leading-tight">{task.name}</p>
|
||||
{task.deadline && (
|
||||
<span className="text-sm text-text-secondary mt-2 block">{task.deadline}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/Topology/NetworkGraph.tsx
Normal file
111
frontend/src/components/Topology/NetworkGraph.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { NodeCard } from "./NodeCard";
|
||||
import type { NetworkNode } from "../../types";
|
||||
|
||||
interface NetworkGraphProps {
|
||||
nodes: NetworkNode[];
|
||||
}
|
||||
|
||||
interface SimNode extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
data: NetworkNode;
|
||||
}
|
||||
|
||||
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
|
||||
source: SimNode;
|
||||
target: SimNode;
|
||||
}
|
||||
|
||||
export function NetworkGraph({ nodes }: NetworkGraphProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || nodes.length === 0) return;
|
||||
|
||||
const width = 3840;
|
||||
const height = 1900;
|
||||
|
||||
const simNodes: SimNode[] = nodes.map((n) => ({
|
||||
id: n.ip,
|
||||
data: n,
|
||||
x: width / 2 + (Math.random() - 0.5) * 800,
|
||||
y: height / 2 + (Math.random() - 0.5) * 600,
|
||||
}));
|
||||
|
||||
const nodeMap = new Map(simNodes.map((n) => [n.data.name, n]));
|
||||
|
||||
const simLinks: SimLink[] = [];
|
||||
for (const node of nodes) {
|
||||
for (const connName of node.connections || []) {
|
||||
const target = nodeMap.get(connName);
|
||||
const source = nodeMap.get(node.name);
|
||||
if (source && target) {
|
||||
simLinks.push({ source, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sim = d3
|
||||
.forceSimulation(simNodes)
|
||||
.force(
|
||||
"link",
|
||||
d3.forceLink(simLinks).id((d: d3.SimulationNodeDatum) => (d as SimNode).id).distance(350)
|
||||
)
|
||||
.force("charge", d3.forceManyBody().strength(-2000))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide(150))
|
||||
.on("tick", () => {
|
||||
// Update link positions via D3
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg
|
||||
.selectAll<SVGLineElement, SimLink>("line.link")
|
||||
.data(simLinks)
|
||||
.join("line")
|
||||
.attr("class", "link")
|
||||
.attr("x1", (d) => d.source.x!)
|
||||
.attr("y1", (d) => d.source.y!)
|
||||
.attr("x2", (d) => d.target.x!)
|
||||
.attr("y2", (d) => d.target.y!)
|
||||
.attr("stroke", "#2a2a3e")
|
||||
.attr("stroke-width", 3);
|
||||
|
||||
// Update React state for node positions
|
||||
const newPositions = new Map<string, { x: number; y: number }>();
|
||||
for (const n of simNodes) {
|
||||
newPositions.set(n.id, { x: n.x!, y: n.y! });
|
||||
}
|
||||
setPositions(newPositions);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sim.stop();
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
const onlineCount = nodes.filter((n) => n.status === "up").length;
|
||||
const offlineCount = nodes.filter((n) => n.status === "down").length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<svg ref={svgRef} width="3840" height="1900" className="flex-1">
|
||||
{nodes.map((node) => {
|
||||
const pos = positions.get(node.ip);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<NodeCard key={node.ip} node={node} x={pos.x} y={pos.y} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex items-center justify-center gap-8 py-4 bg-bg-secondary border-t border-border text-lg">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 rounded-full bg-success" /> {onlineCount} online
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 rounded-full bg-danger" /> {offlineCount} offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/Topology/NodeCard.tsx
Normal file
60
frontend/src/components/Topology/NodeCard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import type { NetworkNode } from "../../types";
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
router: "\uD83C\uDF10",
|
||||
server: "\uD83D\uDDA5\uFE0F",
|
||||
switch: "\uD83D\uDD00",
|
||||
ap: "\uD83D\uDCE1",
|
||||
pc: "\uD83D\uDCBB",
|
||||
nas: "\uD83D\uDCBE",
|
||||
device: "\uD83D\uDCF1",
|
||||
};
|
||||
|
||||
interface NodeCardProps {
|
||||
node: NetworkNode;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function NodeCard({ node, x, y }: NodeCardProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const statusColor =
|
||||
node.status === "up"
|
||||
? "bg-success"
|
||||
: node.status === "down"
|
||||
? "bg-danger"
|
||||
: "bg-warning";
|
||||
|
||||
return (
|
||||
<foreignObject x={x - 120} y={y - 80} width={240} height={180}>
|
||||
<div
|
||||
className="bg-bg-card border border-border rounded-xl p-4 flex flex-col items-center gap-1 shadow-lg cursor-pointer select-none"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
>
|
||||
<span className="text-3xl">{ICON_MAP[node.icon] || "\uD83D\uDCE6"}</span>
|
||||
<span className="text-base font-bold text-text-primary truncate w-full text-center">
|
||||
{node.name}
|
||||
</span>
|
||||
<span className="text-sm text-text-secondary font-mono">{node.ip}</span>
|
||||
{node.username && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{node.username} / {showPassword ? node.password : "\u2022\u2022\u2022\u2022"}
|
||||
</span>
|
||||
)}
|
||||
{node.public_url && (
|
||||
<span className="text-xs text-accent truncate w-full text-center">
|
||||
{node.public_url}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`w-3 h-3 rounded-full ${statusColor}`} />
|
||||
<span className="text-xs text-text-secondary">
|
||||
{node.status === "up" ? "Online" : node.status === "down" ? "Offline" : "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
87
frontend/src/hooks/useOdooData.ts
Normal file
87
frontend/src/hooks/useOdooData.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { TopologyData, TasksData, CalendarData, DisplayConfig } from "../types";
|
||||
|
||||
export function useTopology(refreshMs: number = 600_000) {
|
||||
const [data, setData] = useState<TopologyData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/network/topology");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setData(await res.json());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
const id = setInterval(refetch, refreshMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refetch, refreshMs]);
|
||||
|
||||
return { data, error, refetch };
|
||||
}
|
||||
|
||||
export function useTasks(refreshMs: number = 300_000) {
|
||||
const [data, setData] = useState<TasksData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/tasks/by-project");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setData(await res.json());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
const id = setInterval(refetch, refreshMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refetch, refreshMs]);
|
||||
|
||||
return { data, error, refetch };
|
||||
}
|
||||
|
||||
export function useCalendar(refreshMs: number = 300_000) {
|
||||
const [data, setData] = useState<CalendarData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/calendar/events");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setData(await res.json());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
const id = setInterval(refetch, refreshMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refetch, refreshMs]);
|
||||
|
||||
return { data, error, refetch };
|
||||
}
|
||||
|
||||
export function useDisplayConfig() {
|
||||
const [config, setConfig] = useState<DisplayConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/services/config")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setConfig(d.display))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return config;
|
||||
}
|
||||
14
frontend/src/hooks/useRotation.ts
Normal file
14
frontend/src/hooks/useRotation.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useRotation(totalViews: number, intervalMs: number = 30_000) {
|
||||
const [activeView, setActiveView] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setActiveView((prev) => (prev + 1) % totalViews);
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [totalViews, intervalMs]);
|
||||
|
||||
return activeView;
|
||||
}
|
||||
36
frontend/src/hooks/useWebSocket.ts
Normal file
36
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { WSMessage } from "../types";
|
||||
|
||||
export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(event.data);
|
||||
onMessage(msg);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
reconnectTimeout.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
wsRef.current = ws;
|
||||
}, [onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
}
|
||||
28
frontend/src/index.css
Normal file
28
frontend/src/index.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: #0a0a0f;
|
||||
--color-bg-secondary: #12121a;
|
||||
--color-bg-card: #1a1a2e;
|
||||
--color-border: #2a2a3e;
|
||||
--color-text-primary: #e4e4e7;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-accent: #3b82f6;
|
||||
--color-success: #22c55e;
|
||||
--color-danger: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
width: 3840px;
|
||||
height: 2160px;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
68
frontend/src/types/index.ts
Normal file
68
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface NetworkNode {
|
||||
name: string;
|
||||
ip: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
public_url?: string;
|
||||
icon: string;
|
||||
status: "up" | "down" | "unknown";
|
||||
connections: string[];
|
||||
auto_discovered?: boolean;
|
||||
vendor?: string;
|
||||
}
|
||||
|
||||
export interface TopologyData {
|
||||
nodes: NetworkNode[];
|
||||
scan_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface OdooTask {
|
||||
id: number;
|
||||
name: string;
|
||||
assigned: number[];
|
||||
priority: string;
|
||||
deadline: string | null;
|
||||
kanban_state: string;
|
||||
}
|
||||
|
||||
export interface ProjectStages {
|
||||
[stageName: string]: OdooTask[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
color: number;
|
||||
stages: ProjectStages;
|
||||
}
|
||||
|
||||
export interface TasksData {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: number;
|
||||
name: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CalendarData {
|
||||
events: CalendarEvent[];
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
resolution: string;
|
||||
rotation_interval_seconds: number;
|
||||
transition: string;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/ws": {
|
||||
target: "ws://localhost:8000",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user