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:
2026-02-15 09:34:01 +00:00
parent e6f6dbbab6
commit a7967ecb4a
27 changed files with 3913 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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
View 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
View 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;

View 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 &mdash; {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&ntilde;ana &mdash; {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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
}

View 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
View 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
View 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>
);

View 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
View 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
View 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,
},
},
},
});