From 8db6ec081e01fdf3ab4e85479f43e985bd9e7d36 Mon Sep 17 00:00:00 2001 From: Abhi Date: Mon, 23 Jun 2025 19:42:38 -0400 Subject: [PATCH 001/197] CSS backup --- frontend/src/app/Home.module.css | 35 +++++ .../src/app/devices/[id]/Devices.module.css | 86 +++++++++++ .../components/ConnectionDetails.module.css | 9 ++ frontend/src/components/ConnectionDetails.tsx | 140 ++++++++++++++++++ frontend/src/components/DevicesOverview.tsx | 126 ++++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 frontend/src/app/Home.module.css create mode 100644 frontend/src/app/devices/[id]/Devices.module.css create mode 100644 frontend/src/components/ConnectionDetails.module.css create mode 100644 frontend/src/components/ConnectionDetails.tsx create mode 100644 frontend/src/components/DevicesOverview.tsx diff --git a/frontend/src/app/Home.module.css b/frontend/src/app/Home.module.css new file mode 100644 index 000000000..e5432f3e6 --- /dev/null +++ b/frontend/src/app/Home.module.css @@ -0,0 +1,35 @@ +.sidebar { + top: 0; + left: 0; + width: 240px; + height: 100vh; + padding: 2rem 1rem; + position: sticky; + border-right: 1px solid var(--border-color); +} + +.dashboardLink { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.titleContainer { + display: flex; + flex-direction: row; + gap: 1rem; +} + +.pageContainer { + display: flex; + flex-direction: row; + height: 100vh; + overflow-y: hidden; +} + +.deviceSection { + height: 100vh; + margin-bottom: 2rem; + padding: 2rem; +} \ No newline at end of file diff --git a/frontend/src/app/devices/[id]/Devices.module.css b/frontend/src/app/devices/[id]/Devices.module.css new file mode 100644 index 000000000..3621c8fec --- /dev/null +++ b/frontend/src/app/devices/[id]/Devices.module.css @@ -0,0 +1,86 @@ +.devicePage{ + display: flex; + height: 100vh; +} +.sidebar{ + transition: width 0.2s; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap:1rem; + padding: 8px 0px; +} +.sidebarHeader{ + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + width:95%; + align-self: flex-end; + margin-bottom: 2rem; +} +.sidebarHeaderCollapsed{ + display: flex; + flex-direction: column; + gap: 1rem;; + margin-bottom: 2rem; +} +.sidebarToggle{ + margin: 8px 0; + padding-right: 0; + padding-left: 0; + background: none; + font-size: 1.2rem; + align-self: center; +} +.deviceName{ + padding: 12px 15px; + font-size: 1.2rem; + width: 80%; + word-break: normal; + overflow-wrap: break-word; + white-space: normal; +} +.deviceNameCollapsed{ + display: none; +} +.tabs{ + width: 100%; + display: flex; + flex-direction: column; +} +.tabButton { + background: none; + padding: 12px 15px; + font-weight: 400; + text-align: left; + font-size: 1rem; +} +.tabContent{ + display: flex; + flex-direction: row; + gap:1rem; +} + +.activeTab { + background: var(--select-bg); +} +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + position: relative; +} +.homeButton { + position: absolute; + top: 16px; + right: 24px; + background: none; + font-size: 1.2rem; +} + +.tabSection { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} diff --git a/frontend/src/components/ConnectionDetails.module.css b/frontend/src/components/ConnectionDetails.module.css new file mode 100644 index 000000000..c846c4977 --- /dev/null +++ b/frontend/src/components/ConnectionDetails.module.css @@ -0,0 +1,9 @@ +.detailsTable{ + margin-top: 5rem; + width: 100%; + overflow-x: auto; + +} +.container{ + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/components/ConnectionDetails.tsx b/frontend/src/components/ConnectionDetails.tsx new file mode 100644 index 000000000..74cb0378e --- /dev/null +++ b/frontend/src/components/ConnectionDetails.tsx @@ -0,0 +1,140 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import styles from "./ConnectionDetails.module.css"; + +const QUERY = ` + query Device($id: ID!) { + device(id: $id) { + l1interfaces { + edges { + node { + idxDevice + ifname + nativevlan + ifoperstatus + tsIdle + ifspeed + duplex + ifalias + trunk + cdpcachedeviceid + cdpcachedeviceport + cdpcacheplatform + lldpremportdesc + lldpremsysname + lldpremsysdesc + lldpremsyscapenabled + } + } + } + } + } +`; + +function ConnectionDetails({ deviceId }: { deviceId?: string }) { + const params = useParams(); + const id = + deviceId ?? + (typeof params?.id === "string" + ? decodeURIComponent(params.id) + : Array.isArray(params?.id) + ? decodeURIComponent(params.id[0]) + : undefined); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + + setLoading(true); + setError(null); + const globalId = id && typeof id === "string" ? btoa(`Device:${id}`) : id; + + fetch("http://localhost:7000/switchmap/api/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: QUERY, + variables: { id: globalId }, + }), + }) + .then((res) => { + if (!res.ok) throw new Error(`Network error: ${res.status}`); + return res.json(); + }) + .then((json) => { + if (json.errors) throw new Error(json.errors[0].message); + setData(json.data); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + if (!id) return

Error: No device ID provided.

; + if (loading) return

Loading...

; + if (error) return

Error: {error}

; + if (!data) return null; + if (!data.device || !data.device.l1interfaces) + return

No interface data available.

; + + const interfaces = data.device.l1interfaces.edges.map( + ({ node }: any) => node + ); + + return ( +
+

Connection Details

+ + + + {[ + "Port", + "VLAN", + "State", + "Days Inactive", + "Speed", + "Duplex", + "Port Label", + "Trunk", + "CDP", + "LLDP", + "Mac Address", + "Manufacturer", + "IP Address", + "DNS Name", + ].map((title) => ( + + ))} + + + + {interfaces.map((iface: any) => ( + + + + + + + + + + + + + + + + + ))} + +
{title}
{iface.ifname || "N/A"}{iface.nativevlan ?? "N/A"}{iface.ifoperstatus ?? "N/A"}{iface.tsIdle ?? "N/A"}{iface.ifspeed ?? "N/A"}{iface.duplex ?? "N/A"}{iface.ifalias || "N/A"}{iface.trunk ? "Yes" : "No"}{iface.cdpcachedeviceid || ""}{iface.lldpremportdesc || ""}{"—"}{"—"}{"—"}{"—"}
+
+ ); +} + +export default ConnectionDetails; diff --git a/frontend/src/components/DevicesOverview.tsx b/frontend/src/components/DevicesOverview.tsx new file mode 100644 index 000000000..f562ba3ae --- /dev/null +++ b/frontend/src/components/DevicesOverview.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; + +type Device = { + idxDevice: string; + id: string; + sysName: string | null; + hostname: string | null; + sysObjectid: string | null; + sysUptime: number; + l1interfaces: { + edges: { node: { ifoperstatus: number } }[]; + }; +}; + +const formatUptime = (hundredths: number) => { + const seconds = Math.floor(hundredths / 100); + const days = Math.floor(seconds / 86400); + const hrs = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return `${days}d ${hrs}h ${mins}m ${secs}s`; +}; +const mapOidToType = (oid: string | null) => { + if (!oid) return "-"; + // TODO: Add device type such as router, switch, etc. + if (oid.startsWith(".1.3.6.1.4.1.9.1.")) return "Cisco"; + if (oid.startsWith(".1.3.6.1.4.1.2636.1.")) return "Juniper"; + // Add more mappings as needed + return "Unknown"; +}; + +export default function DevicesOverview() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchDevices = async () => { + const res = await fetch("http://localhost:7000/switchmap/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + { + devices { + edges { + node { + id + idxDevice + sysName + hostname + sysObjectid + sysUptime + l1interfaces { + edges { + node { + ifoperstatus + } + } + } + } + } + } + } + `, + }), + }); + const json = await res.json(); + const rawDevices = json.data.devices.edges.map((edge: any) => edge.node); + setDevices(rawDevices); + setLoading(false); + }; + + fetchDevices(); + }, []); + + if (loading) return

Loading...

; + + return ( +
+

Devices Overview

+ + + + + + + + + + + + {devices.map((device) => { + const interfaces = device.l1interfaces.edges.map((e) => e.node); + const total = interfaces.length; + const active = interfaces.filter( + (p) => p.ifoperstatus === 1 + ).length; + + return ( + + + + + + + + ); + })} + +
Device NameHostnameType (OID)Active PortsUptime
+ + {device.sysName || "-"} + + {device.hostname || "-"}{mapOidToType(device.sysObjectid)}{`${active}/${total}`}{formatUptime(device.sysUptime)}
+
+ ); +} From ee6259187bd21c0c31648fbbe2cac992e2740f84 Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 25 Jun 2025 15:04:54 -0400 Subject: [PATCH 002/197] Update on devices overview table --- frontend/src/components/DevicesOverview.tsx | 277 +++++++++++++++----- frontend/src/components/Sidebar.tsx | 47 ++++ frontend/src/components/ZoneDropdown.tsx | 107 ++++++++ 3 files changed, 360 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/ZoneDropdown.tsx diff --git a/frontend/src/components/DevicesOverview.tsx b/frontend/src/components/DevicesOverview.tsx index f562ba3ae..7e4fefb6e 100644 --- a/frontend/src/components/DevicesOverview.tsx +++ b/frontend/src/components/DevicesOverview.tsx @@ -1,7 +1,16 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + flexRender, + createColumnHelper, + SortingState, +} from "@tanstack/react-table"; type Device = { idxDevice: string; @@ -23,40 +32,38 @@ const formatUptime = (hundredths: number) => { const secs = seconds % 60; return `${days}d ${hrs}h ${mins}m ${secs}s`; }; -const mapOidToType = (oid: string | null) => { - if (!oid) return "-"; - // TODO: Add device type such as router, switch, etc. - if (oid.startsWith(".1.3.6.1.4.1.9.1.")) return "Cisco"; - if (oid.startsWith(".1.3.6.1.4.1.2636.1.")) return "Juniper"; - // Add more mappings as needed - return "Unknown"; -}; -export default function DevicesOverview() { +export default function DevicesOverview({ zoneId }: { zoneId: string }) { const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); + const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); useEffect(() => { const fetchDevices = async () => { - const res = await fetch("http://localhost:7000/switchmap/api/graphql", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - { - devices { - edges { - node { - id - idxDevice - sysName - hostname - sysObjectid - sysUptime - l1interfaces { - edges { - node { - ifoperstatus + try { + setLoading(true); + + const res = await fetch("http://localhost:7000/switchmap/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + query GetZoneDevices($id: ID!) { + zone(id: $id) { + devices { + edges { + node { + idxDevice + sysObjectid + sysUptime + sysName + hostname + l1interfaces { + edges { + node { + ifoperstatus + } } } } @@ -65,60 +72,188 @@ export default function DevicesOverview() { } } `, - }), - }); - const json = await res.json(); - const rawDevices = json.data.devices.edges.map((edge: any) => edge.node); - setDevices(rawDevices); - setLoading(false); + variables: { + id: zoneId, + }, + }), + }); + + const json = await res.json(); + const rawDevices = json.data.zone.devices.edges.map( + (edge: any) => edge.node + ); + setDevices(rawDevices); + } catch (err) { + console.error("Error fetching devices:", err); + } finally { + setLoading(false); + } }; fetchDevices(); - }, []); + }, [zoneId]); + + const columnHelper = createColumnHelper(); + + const data = useMemo(() => { + return devices.map((device) => { + const interfaces = device.l1interfaces.edges.map((e) => e.node); + const total = interfaces.length; + const active = interfaces.filter((p) => p.ifoperstatus === 1).length; + + return { + id: device.id, + name: device.sysName || "-", + hostname: device.hostname || "-", + ports: `${active}/${total}`, + uptime: formatUptime(device.sysUptime), + link: `/devices/${encodeURIComponent( + device.idxDevice ?? device.id + )}?sysName=${encodeURIComponent( + device.sysName ?? device.hostname ?? "" + )}#devices-overview`, + }; + }); + }, [devices]); + + const columns = [ + columnHelper.accessor("name", { + header: "Device Name", + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor("hostname", { + header: "Hostname", + }), + columnHelper.accessor("ports", { + header: "Active Ports", + }), + columnHelper.accessor("uptime", { + header: "Uptime", + }), + ]; + + const table = useReactTable({ + data, + columns, + state: { + sorting, + globalFilter, + }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); if (loading) return

Loading...

; return (
-

Devices Overview

- +

Devices Overview

+ + setGlobalFilter(e.target.value)} + placeholder="Search..." + className="mb-4 p-2 border rounded w-full max-w-sm" + /> +

+ DEVICES MONITORED BY SWITCHMAP +

+
- - - - - - - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - {devices.map((device) => { - const interfaces = device.l1interfaces.edges.map((e) => e.node); - const total = interfaces.length; - const active = interfaces.filter( - (p) => p.ifoperstatus === 1 - ).length; - - return ( - - + {row.getVisibleCells().map((cell) => ( + - - - - - - ); - })} + ))} + + ))} + +
Device NameHostnameType (OID)Active PortsUptime
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + + ⯅ + + + ⯆ + + +
- - {device.sysName || "-"} - + {table.getRowModel().rows.map((row) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} {device.hostname || "-"}{mapOidToType(device.sysObjectid)}{`${active}/${total}`}{formatUptime(device.sysUptime)}
+

+ DEVICES NOT MONITORED BY SWITCHMAP +

+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ No data +
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 000000000..5886f7d56 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import Link from "next/link"; +import { FiLayout, FiClock, FiSettings } from "react-icons/fi"; +import ThemeToggle from "@/app/theme-toggle"; + +export default function Sidebar() { + return ( + + ); +} diff --git a/frontend/src/components/ZoneDropdown.tsx b/frontend/src/components/ZoneDropdown.tsx new file mode 100644 index 000000000..c18f470ea --- /dev/null +++ b/frontend/src/components/ZoneDropdown.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; + +type Zone = { idxZone: string; id: string }; + +type ZoneDropdownProps = { + selectedZoneId: string; + onChange: (zoneId: string) => void; +}; + +export default function ZoneDropdown({ + selectedZoneId, + onChange, +}: ZoneDropdownProps) { + const [zones, setZones] = useState([]); + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const fetchZones = async () => { + const res = await fetch("http://localhost:7000/switchmap/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + { + zones { + edges { + node { + idxZone + id + } + } + } + } + `, + }), + }); + const json = await res.json(); + const rawZones = json.data.zones.edges.map((edge: any) => edge.node); + setZones(rawZones); + }; + + fetchZones(); + }, []); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const selected = zones.find((z) => z.id === selectedZoneId); + + return ( +
+ + + {open && ( +
+ {zones.map((zone) => ( + + ))} +
+ )} +
+ ); +} From 3fc6e2bef27593b6ec3d4154fd1e9fc8b3dba5a5 Mon Sep 17 00:00:00 2001 From: Abhi Date: Mon, 7 Jul 2025 20:50:31 -0400 Subject: [PATCH 003/197] Move data fetching to page level --- frontend/src/components/DevicesOverview.tsx | 260 +++++++++----------- 1 file changed, 122 insertions(+), 138 deletions(-) diff --git a/frontend/src/components/DevicesOverview.tsx b/frontend/src/components/DevicesOverview.tsx index 7e4fefb6e..08d3ac08b 100644 --- a/frontend/src/components/DevicesOverview.tsx +++ b/frontend/src/components/DevicesOverview.tsx @@ -12,6 +12,7 @@ import { SortingState, } from "@tanstack/react-table"; +// Device type definition type Device = { idxDevice: string; id: string; @@ -23,7 +24,13 @@ type Device = { edges: { node: { ifoperstatus: number } }[]; }; }; +interface DevicesOverviewProps { + devices: any[]; // adapt type as needed + loading: boolean; + error: string | null; +} +// Format uptime from hundredths of seconds to readable string const formatUptime = (hundredths: number) => { const seconds = Math.floor(hundredths / 100); const days = Math.floor(seconds / 86400); @@ -33,68 +40,13 @@ const formatUptime = (hundredths: number) => { return `${days}d ${hrs}h ${mins}m ${secs}s`; }; -export default function DevicesOverview({ zoneId }: { zoneId: string }) { - const [devices, setDevices] = useState([]); - const [loading, setLoading] = useState(true); +export default function DevicesOverview({ devices, loading, error }) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); - useEffect(() => { - const fetchDevices = async () => { - try { - setLoading(true); - - const res = await fetch("http://localhost:7000/switchmap/api/graphql", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - query GetZoneDevices($id: ID!) { - zone(id: $id) { - devices { - edges { - node { - idxDevice - sysObjectid - sysUptime - sysName - hostname - l1interfaces { - edges { - node { - ifoperstatus - } - } - } - } - } - } - } - } - `, - variables: { - id: zoneId, - }, - }), - }); - - const json = await res.json(); - const rawDevices = json.data.zone.devices.edges.map( - (edge: any) => edge.node - ); - setDevices(rawDevices); - } catch (err) { - console.error("Error fetching devices:", err); - } finally { - setLoading(false); - } - }; - - fetchDevices(); - }, [zoneId]); - const columnHelper = createColumnHelper(); + // Prepare table data from devices const data = useMemo(() => { return devices.map((device) => { const interfaces = device.l1interfaces.edges.map((e) => e.node); @@ -116,6 +68,7 @@ export default function DevicesOverview({ zoneId }: { zoneId: string }) { }); }, [devices]); + // Table columns definition const columns = [ columnHelper.accessor("name", { header: "Device Name", @@ -139,6 +92,7 @@ export default function DevicesOverview({ zoneId }: { zoneId: string }) { }), ]; + // Create table instance const table = useReactTable({ data, columns, @@ -153,12 +107,15 @@ export default function DevicesOverview({ zoneId }: { zoneId: string }) { getFilteredRowModel: getFilteredRowModel(), }); - if (loading) return

Loading...

; + if (loading) return

Loading devices...

; + if (error) return

Error loading devices: {error}

; + if (!devices.length) return

No devices found.

; return (
-

Devices Overview

+

Devices Overview

+ {/* Global search filter */} setGlobalFilter(e.target.value)} @@ -168,94 +125,121 @@ export default function DevicesOverview({ zoneId }: { zoneId: string }) {

DEVICES MONITORED BY SWITCHMAP

- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} - - + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + - ))} - - ))} - -
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + header.column.getToggleSortingHandler()?.(e); } - > - ⯅ - + }} + className="cursor-pointer px-4 py-2" + tabIndex={0} + role="button" + aria-label={`Sort by ${header.column.columnDef.header}`} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} - ⯆ + + ⯅ + + + ⯆ + - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} +
+ No data
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+

DEVICES NOT MONITORED BY SWITCHMAP

- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} +
+
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} -
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + + - ))} - - - - - - -
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ No data +
- No data -
+ + + ); } From 3a8123d44ae1cfb296f12bb51680f7825a903bbb Mon Sep 17 00:00:00 2001 From: Abhi Date: Tue, 8 Jul 2025 13:26:59 -0400 Subject: [PATCH 004/197] Latest code on topology chart(working) --- frontend/src/app/Home.module.css | 35 --- .../src/app/devices/[id]/Devices.module.css | 86 ------- .../components/ConnectionDetails.module.css | 9 - frontend/src/components/ConnectionDetails.tsx | 219 ++++++++++++++---- frontend/src/components/DevicesOverview.tsx | 10 +- frontend/src/components/Sidebar.tsx | 141 +++++++---- frontend/src/components/TopologyChart.tsx | 219 ++++++++++++++++++ frontend/src/components/ZoneDropdown.tsx | 123 ++++++---- 8 files changed, 582 insertions(+), 260 deletions(-) delete mode 100644 frontend/src/app/Home.module.css delete mode 100644 frontend/src/app/devices/[id]/Devices.module.css delete mode 100644 frontend/src/components/ConnectionDetails.module.css create mode 100644 frontend/src/components/TopologyChart.tsx diff --git a/frontend/src/app/Home.module.css b/frontend/src/app/Home.module.css deleted file mode 100644 index e5432f3e6..000000000 --- a/frontend/src/app/Home.module.css +++ /dev/null @@ -1,35 +0,0 @@ -.sidebar { - top: 0; - left: 0; - width: 240px; - height: 100vh; - padding: 2rem 1rem; - position: sticky; - border-right: 1px solid var(--border-color); -} - -.dashboardLink { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -.titleContainer { - display: flex; - flex-direction: row; - gap: 1rem; -} - -.pageContainer { - display: flex; - flex-direction: row; - height: 100vh; - overflow-y: hidden; -} - -.deviceSection { - height: 100vh; - margin-bottom: 2rem; - padding: 2rem; -} \ No newline at end of file diff --git a/frontend/src/app/devices/[id]/Devices.module.css b/frontend/src/app/devices/[id]/Devices.module.css deleted file mode 100644 index 3621c8fec..000000000 --- a/frontend/src/app/devices/[id]/Devices.module.css +++ /dev/null @@ -1,86 +0,0 @@ -.devicePage{ - display: flex; - height: 100vh; -} -.sidebar{ - transition: width 0.2s; - border-right: 1px solid var(--border-color); - display: flex; - flex-direction: column; - gap:1rem; - padding: 8px 0px; -} -.sidebarHeader{ - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - width:95%; - align-self: flex-end; - margin-bottom: 2rem; -} -.sidebarHeaderCollapsed{ - display: flex; - flex-direction: column; - gap: 1rem;; - margin-bottom: 2rem; -} -.sidebarToggle{ - margin: 8px 0; - padding-right: 0; - padding-left: 0; - background: none; - font-size: 1.2rem; - align-self: center; -} -.deviceName{ - padding: 12px 15px; - font-size: 1.2rem; - width: 80%; - word-break: normal; - overflow-wrap: break-word; - white-space: normal; -} -.deviceNameCollapsed{ - display: none; -} -.tabs{ - width: 100%; - display: flex; - flex-direction: column; -} -.tabButton { - background: none; - padding: 12px 15px; - font-weight: 400; - text-align: left; - font-size: 1rem; -} -.tabContent{ - display: flex; - flex-direction: row; - gap:1rem; -} - -.activeTab { - background: var(--select-bg); -} -.mainContent { - flex: 1; - display: flex; - flex-direction: column; - position: relative; -} -.homeButton { - position: absolute; - top: 16px; - right: 24px; - background: none; - font-size: 1.2rem; -} - -.tabSection { - display: flex; - align-items: center; - justify-content: center; - width: 100%; -} diff --git a/frontend/src/components/ConnectionDetails.module.css b/frontend/src/components/ConnectionDetails.module.css deleted file mode 100644 index c846c4977..000000000 --- a/frontend/src/components/ConnectionDetails.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.detailsTable{ - margin-top: 5rem; - width: 100%; - overflow-x: auto; - -} -.container{ - width: 100%; -} \ No newline at end of file diff --git a/frontend/src/components/ConnectionDetails.tsx b/frontend/src/components/ConnectionDetails.tsx index 74cb0378e..52c5af3ef 100644 --- a/frontend/src/components/ConnectionDetails.tsx +++ b/frontend/src/components/ConnectionDetails.tsx @@ -1,14 +1,59 @@ "use client"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import styles from "./ConnectionDetails.module.css"; +interface MacAddress { + mac: string; + oui?: { + organization: string; + }; +} + +interface L1Interface { + idxL1interface: string; + idxDevice: string; + ifname: string; + nativevlan?: number; + ifoperstatus: number; + tsIdle?: number; + ifspeed?: number; + duplex?: string; + ifalias?: string; + trunk?: boolean; + cdpcachedeviceid?: string; + cdpcachedeviceport?: string; + cdpcacheplatform?: string; + lldpremportdesc?: string; + lldpremsysname?: string; + lldpremsysdesc?: string; + lldpremsyscapenabled?: string; + macports?: { + edges: { + node: { + macs: MacAddress[]; + }; + }[]; + }; +} + +interface DeviceData { + device: { + l1interfaces: { + edges: { + node: L1Interface; + }[]; + }; + }; +} + +// GraphQL query to fetch device interface details const QUERY = ` query Device($id: ID!) { device(id: $id) { l1interfaces { edges { node { + idxL1interface idxDevice ifname nativevlan @@ -25,6 +70,18 @@ const QUERY = ` lldpremsysname lldpremsysdesc lldpremsyscapenabled + macports{ + edges{ + node{ + macs{ + mac + oui{ + organization + } + } + } + } + } } } } @@ -34,18 +91,20 @@ const QUERY = ` function ConnectionDetails({ deviceId }: { deviceId?: string }) { const params = useParams(); + // Determine device ID from props or URL params const id = deviceId ?? (typeof params?.id === "string" ? decodeURIComponent(params.id) - : Array.isArray(params?.id) + : Array.isArray(params?.id) && params.id.length > 0 ? decodeURIComponent(params.id[0]) : undefined); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Fetch device data when ID changes useEffect(() => { if (!id) return; @@ -75,6 +134,7 @@ function ConnectionDetails({ deviceId }: { deviceId?: string }) { .finally(() => setLoading(false)); }, [id]); + // Handle loading, error, and missing data states if (!id) return

Error: No device ID provided.

; if (loading) return

Loading...

; if (error) return

Error: {error}

; @@ -82,57 +142,120 @@ function ConnectionDetails({ deviceId }: { deviceId?: string }) { if (!data.device || !data.device.l1interfaces) return

No interface data available.

; + // Extract interface list const interfaces = data.device.l1interfaces.edges.map( ({ node }: any) => node ); return ( -
-

Connection Details

- - - - {[ - "Port", - "VLAN", - "State", - "Days Inactive", - "Speed", - "Duplex", - "Port Label", - "Trunk", - "CDP", - "LLDP", - "Mac Address", - "Manufacturer", - "IP Address", - "DNS Name", - ].map((title) => ( - - ))} - - - - {interfaces.map((iface: any) => ( - - - - - - - - - - - - - - - +
+

Connection Details

+
+
{title}
{iface.ifname || "N/A"}{iface.nativevlan ?? "N/A"}{iface.ifoperstatus ?? "N/A"}{iface.tsIdle ?? "N/A"}{iface.ifspeed ?? "N/A"}{iface.duplex ?? "N/A"}{iface.ifalias || "N/A"}{iface.trunk ? "Yes" : "No"}{iface.cdpcachedeviceid || ""}{iface.lldpremportdesc || ""}{"—"}{"—"}{"—"}{"—"}
+ + + {[ + "Port", + "VLAN", + "State", + "Days Inactive", + "Speed", + "Duplex", + "Port Label", + "Trunk", + "CDP", + "LLDP", + "Mac Address", + "Manufacturer", + "IP Address", + "DNS Name", + ].map((title) => ( + + ))} - ))} - -
{title}
+ + + {interfaces.map((iface: any) => ( + + {iface.ifname || "N/A"} + {iface.nativevlan ?? "N/A"} + + {iface.ifoperstatus == 1 + ? "Active" + : iface.ifoperstatus == 2 + ? "Disabled" + : "N/A"} + + {iface.tsIdle ?? "N/A"} + {iface.ifspeed ?? "N/A"} + {iface.duplex ?? "N/A"} + {iface.ifalias || "N/A"} + {iface.trunk ? "Trunk" : ""} + {iface.cdpcachedeviceid || ""} + {iface.lldpremportdesc || ""} + {/* Render MAC addresses */} + + {Array.isArray(iface.macports?.edges) && + iface.macports.edges.length > 0 + ? iface.macports.edges + .flatMap((edge: any) => { + const macs = edge?.node?.macs; + const macList = Array.isArray(macs) + ? macs + : macs + ? [macs] + : []; + return macList + .map((macObj: any) => macObj?.mac) + .filter(Boolean); + }) + .join(", ") + : ""} + + {/* Render MAC manufacturers */} + + {Array.isArray(iface.macports?.edges) && + iface.macports.edges.length > 0 + ? iface.macports.edges + .flatMap((edge: any) => { + const macs = edge?.node?.macs; + const macList = Array.isArray(macs) + ? macs + : macs + ? [macs] + : []; + return macList + .map( + (macObj: any) => macObj?.oui?.organization || "" + ) + .filter(Boolean); + }) + .join(", ") + : ""} + + {/* Placeholders for IP Address and DNS Name */} + + + + ))} + + +
); } diff --git a/frontend/src/components/DevicesOverview.tsx b/frontend/src/components/DevicesOverview.tsx index 08d3ac08b..fd306629f 100644 --- a/frontend/src/components/DevicesOverview.tsx +++ b/frontend/src/components/DevicesOverview.tsx @@ -40,7 +40,11 @@ const formatUptime = (hundredths: number) => { return `${days}d ${hrs}h ${mins}m ${secs}s`; }; -export default function DevicesOverview({ devices, loading, error }) { +export default function DevicesOverview({ + devices, + loading, + error, +}: DevicesOverviewProps) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); @@ -49,9 +53,9 @@ export default function DevicesOverview({ devices, loading, error }) { // Prepare table data from devices const data = useMemo(() => { return devices.map((device) => { - const interfaces = device.l1interfaces.edges.map((e) => e.node); + const interfaces = device.l1interfaces.edges.map((e: any) => e.node); const total = interfaces.length; - const active = interfaces.filter((p) => p.ifoperstatus === 1).length; + const active = interfaces.filter((p: any) => p.ifoperstatus === 1).length; return { id: device.id, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5886f7d56..349a9d36c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,47 +1,110 @@ -import React from "react"; +"use client"; + +import React, { useState, useRef, useEffect } from "react"; import Link from "next/link"; import { FiLayout, FiClock, FiSettings } from "react-icons/fi"; +import { RxHamburgerMenu } from "react-icons/rx"; import ThemeToggle from "@/app/theme-toggle"; export default function Sidebar() { + const [open, setOpen] = useState(false); + const sidebarRef = useRef(null); + + // Close sidebar on outside click + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + sidebarRef.current && + !sidebarRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + if (open) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + // Sidebar content (shared) + const sidebarContent = ( + + ); + return ( - + <> + {/* Hamburger button (only visible on small screens) */} + + + {/* Sidebar for large screens */} + + + {/* Slide-in Sidebar for small/medium screens */} + {open && ( + <> +
+ + + )} + ); } diff --git a/frontend/src/components/TopologyChart.tsx b/frontend/src/components/TopologyChart.tsx new file mode 100644 index 000000000..0f37cd177 --- /dev/null +++ b/frontend/src/components/TopologyChart.tsx @@ -0,0 +1,219 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { + Network, + DataSet, + Node, + Edge, + Options, +} from "vis-network/standalone/esm/vis-network"; + +interface TopologyChartProps { + devices: any[]; + loading: boolean; + error: string | null; +} + +const TopologyChart: React.FC = ({ + devices, + loading, + error, +}) => { + const [graph, setGraph] = useState<{ nodes: Node[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + const [searchTerm, setSearchTerm] = useState(""); + const containerRef = useRef(null); + const networkRef = useRef(null); + const nodesData = useRef | null>(null); + const edgesData = useRef | null>(null); + + const options: Options = { + layout: { hierarchical: false }, + physics: { + enabled: true, + solver: "barnesHut", + stabilization: { iterations: 100, updateInterval: 25 }, + }, + edges: { color: "#BBBBBB", width: 2 }, + nodes: { + shape: "dot", + size: 15, + color: "#1E90FF", + font: { size: 12, color: "black" }, + }, + interaction: { + hover: true, + tooltipDelay: 100, + dragNodes: true, + zoomView: true, + }, + }; + + useEffect(() => { + if (!devices || devices.length === 0) { + setGraph({ nodes: [], edges: [] }); + return; + } + + const nodesSet = new Set(); + const edgesArray: Edge[] = []; + + devices.forEach((device) => { + const sysName = device?.sysName; + if (!sysName) return; + + nodesSet.add(sysName); + + (device.l1interfaces?.edges ?? []).forEach( + ({ node: iface }: { node: any }) => { + const target = iface?.cdpcachedeviceid; + const port = iface?.cdpcachedeviceport; + + if (target) { + nodesSet.add(target); + edgesArray.push({ + from: sysName, + to: target, + label: port, + color: "#BBBBBB", + }); + } + } + ); + }); + + const nodesArray: Node[] = devices.map((device) => ({ + id: device.sysName ?? "", // use sysName as the node ID (to match edge `cdpcachedeviceid`) + label: device.sysName ?? device.idxDevice?.toString() ?? "", + color: "#1E90FF", + idxDevice: device.idxDevice?.toString(), // custom field for navigation + })); + + setGraph({ nodes: nodesArray, edges: edgesArray }); + }, [devices]); + + useEffect(() => { + if (!containerRef.current || graph.nodes.length === 0) return; + + nodesData.current = new DataSet(graph.nodes); + edgesData.current = new DataSet(graph.edges); + + networkRef.current = new Network( + containerRef.current, + { + nodes: nodesData.current, + edges: edgesData.current, + }, + options + ); + // networkRef.current.on("click", (params) => { + // if (params.nodes.length === 1) { + // const nodeId = params.nodes[0]; + // const nodeData = nodesData.current?.get(nodeId); + // const node = Array.isArray(nodeData) ? nodeData[0] : nodeData; + // const idxDevice = (node as any)?.idxDevice ?? nodeId; + // const sysName = (node as any)?.label ?? ""; + // window.location.href = `/devices/${encodeURIComponent( + // idxDevice + // )}?sysName=${encodeURIComponent(sysName)}#devices-overview`; + // } + // }); + + // Node selection highlighting + networkRef.current.on("selectNode", ({ nodes }) => { + const selected = nodes[0]; + if (!nodesData.current || !edgesData.current) return; + + nodesData.current.forEach((node) => { + nodesData.current!.update({ + id: node.id, + color: { + background: node.id === selected ? "#FF6347" : "#D3D3D3", + border: "#555", + }, + font: { + color: node.id === selected ? "black" : "#A9A9A9", + }, + }); + }); + + edgesData.current.forEach((edge) => { + const connected = edge.from === selected || edge.to === selected; + edgesData.current!.update({ + id: edge.id, + color: connected ? "#555" : "#DDD", + }); + }); + }); + + // Reset highlight on deselect + networkRef.current.on("deselectNode", () => { + if (!nodesData.current || !edgesData.current) return; + + nodesData.current.forEach((node) => { + nodesData.current!.update({ + id: node.id, + color: { background: "#1E90FF", border: "#555" }, + font: { color: "black" }, + }); + }); + + edgesData.current.forEach((edge) => { + edgesData.current!.update({ + id: edge.id, + color: "#BBBBBB", + }); + }); + }); + }, [graph]); + + useEffect(() => { + if (!searchTerm || !nodesData.current || !networkRef.current) return; + + const node = nodesData.current.get(searchTerm); + if (!node) { + console.warn(`Node "${searchTerm}" not found.`); + return; + } + + networkRef.current.focus(searchTerm, { scale: 1.5, animation: true }); + + nodesData.current.get().forEach((n) => { + nodesData.current!.update({ + id: n.id, + color: { + background: n.id === searchTerm ? "#FF6347" : "#D3D3D3", + border: "#555", + }, + font: { + color: n.id === searchTerm ? "black" : "#A9A9A9", + }, + }); + }); + }, [searchTerm]); + + if (loading) return

Loading topology...

; + if (error) return

Error loading topology: {error}

; + + return ( +
+

Network Topology

+ setSearchTerm(e.target.value)} + /> +
+
+ ); +}; + +export default TopologyChart; diff --git a/frontend/src/components/ZoneDropdown.tsx b/frontend/src/components/ZoneDropdown.tsx index c18f470ea..84d8b2ea0 100644 --- a/frontend/src/components/ZoneDropdown.tsx +++ b/frontend/src/components/ZoneDropdown.tsx @@ -5,7 +5,7 @@ import { useEffect, useState, useRef } from "react"; type Zone = { idxZone: string; id: string }; type ZoneDropdownProps = { - selectedZoneId: string; + selectedZoneId: string | null; onChange: (zoneId: string) => void; }; @@ -16,30 +16,46 @@ export default function ZoneDropdown({ const [zones, setZones] = useState([]); const [open, setOpen] = useState(false); const dropdownRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); useEffect(() => { const fetchZones = async () => { - const res = await fetch("http://localhost:7000/switchmap/api/graphql", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - { - zones { - edges { - node { - idxZone - id - } - } - } - } - `, - }), - }); - const json = await res.json(); - const rawZones = json.data.zones.edges.map((edge: any) => edge.node); - setZones(rawZones); + setLoading(true); + setError(null); + try { + const res = await fetch("http://localhost:7000/switchmap/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + { + zones { + edges { + node { + idxZone + id + } + } + } + } + `, + }), + }); + if (!res.ok) { + throw new Error(`Network error: ${res.status}`); + } + const json = await res.json(); + if (json.errors) { + throw new Error(json.errors[0].message); + } + const rawZones = json.data.zones.edges.map((edge: any) => edge.node); + setZones(rawZones); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch zones"); + } finally { + setLoading(false); + } }; fetchZones(); @@ -58,16 +74,36 @@ export default function ZoneDropdown({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - const selected = zones.find((z) => z.id === selectedZoneId); + // If selectedZoneId is null, pick the first zone (if available) + const selected = + (selectedZoneId && zones.find((z) => z.id === selectedZoneId)) || + (zones.length > 0 ? zones[0] : undefined); + + // If selectedZoneId is null and zones are loaded, notify parent + useEffect(() => { + if (zones.length > 0) { + // Always call onChange with the first zone if selectedZoneId is null or not found in zones + const found = zones.find((z) => z.id === selectedZoneId); + if (!found) { + onChange(zones[0].id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zones, selectedZoneId]); return ( -
+
- ))} -
+ <> + {error && ( +
+ Error: {error} +
+ )} +
+ {zones.map((zone) => ( + + ))} +
+ )}
); From 17442d3551f0345d2cd68529b7908fc0ba198d5c Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 9 Jul 2025 09:22:42 -0400 Subject: [PATCH 005/197] Improve TypeScript type safety in Devices Overview component --- frontend/src/components/DevicesOverview.tsx | 42 ++++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/DevicesOverview.tsx b/frontend/src/components/DevicesOverview.tsx index fd306629f..3418037e3 100644 --- a/frontend/src/components/DevicesOverview.tsx +++ b/frontend/src/components/DevicesOverview.tsx @@ -11,27 +11,29 @@ import { createColumnHelper, SortingState, } from "@tanstack/react-table"; +import { + DeviceNode, + InterfaceEdge, + InterfaceNode, + L1Interfaces, +} from "@/types/graphql/GetZoneDevices"; -// Device type definition -type Device = { - idxDevice: string; - id: string; - sysName: string | null; - hostname: string | null; - sysObjectid: string | null; - sysUptime: number; - l1interfaces: { - edges: { node: { ifoperstatus: number } }[]; - }; -}; interface DevicesOverviewProps { - devices: any[]; // adapt type as needed + devices: DeviceNode[]; loading: boolean; error: string | null; } +interface DeviceRow { + name: string; + hostname: string; + ports: string; + uptime: string; + link: string; +} + // Format uptime from hundredths of seconds to readable string -const formatUptime = (hundredths: number) => { +const formatUptime = (hundredths: number): string => { const seconds = Math.floor(hundredths / 100); const days = Math.floor(seconds / 86400); const hrs = Math.floor((seconds % 86400) / 3600); @@ -46,16 +48,20 @@ export default function DevicesOverview({ error, }: DevicesOverviewProps) { const [sorting, setSorting] = useState([]); - const [globalFilter, setGlobalFilter] = useState(""); + const [globalFilter, setGlobalFilter] = useState(""); - const columnHelper = createColumnHelper(); + const columnHelper = createColumnHelper(); // Prepare table data from devices const data = useMemo(() => { return devices.map((device) => { - const interfaces = device.l1interfaces.edges.map((e: any) => e.node); + const interfaces = device.l1interfaces.edges.map( + (e: InterfaceEdge) => e.node + ); const total = interfaces.length; - const active = interfaces.filter((p: any) => p.ifoperstatus === 1).length; + const active = interfaces.filter( + (p: InterfaceNode) => p.ifoperstatus === 1 + ).length; return { id: device.id, From 07bf6cc7e276bf3cdf7f13a511a1557c2567cba6 Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 9 Jul 2025 11:14:32 -0400 Subject: [PATCH 006/197] FIx export statement --- frontend/src/components/TopologyChart.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/TopologyChart.tsx b/frontend/src/components/TopologyChart.tsx index 0f37cd177..ef3aeeaa0 100644 --- a/frontend/src/components/TopologyChart.tsx +++ b/frontend/src/components/TopologyChart.tsx @@ -1,5 +1,6 @@ "use client"; +import { DeviceNode } from "@/types/graphql/GetZoneDevices"; import React, { useState, useEffect, useRef } from "react"; import { Network, @@ -10,16 +11,16 @@ import { } from "vis-network/standalone/esm/vis-network"; interface TopologyChartProps { - devices: any[]; + devices: DeviceNode[]; loading: boolean; error: string | null; } -const TopologyChart: React.FC = ({ +export default function TopologyChart({ devices, loading, error, -}) => { +}: TopologyChartProps) { const [graph, setGraph] = useState<{ nodes: Node[]; edges: Edge[] }>({ nodes: [], edges: [], @@ -86,7 +87,7 @@ const TopologyChart: React.FC = ({ }); const nodesArray: Node[] = devices.map((device) => ({ - id: device.sysName ?? "", // use sysName as the node ID (to match edge `cdpcachedeviceid`) + id: device.sysName ?? "", label: device.sysName ?? device.idxDevice?.toString() ?? "", color: "#1E90FF", idxDevice: device.idxDevice?.toString(), // custom field for navigation @@ -214,6 +215,4 @@ const TopologyChart: React.FC = ({ />
); -}; - -export default TopologyChart; +} From 8c1ac668aeac7e3acae3f3778cb07fc5d7cc9e0e Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 9 Jul 2025 11:28:57 -0400 Subject: [PATCH 007/197] Improve type safety in Zonedropdown component --- frontend/src/components/ZoneDropdown.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ZoneDropdown.tsx b/frontend/src/components/ZoneDropdown.tsx index 84d8b2ea0..66d705b52 100644 --- a/frontend/src/components/ZoneDropdown.tsx +++ b/frontend/src/components/ZoneDropdown.tsx @@ -1,5 +1,6 @@ "use client"; +import { ZoneEdge } from "@/types/graphql/GetZoneDevices"; import { useEffect, useState, useRef } from "react"; type Zone = { idxZone: string; id: string }; @@ -14,9 +15,9 @@ export default function ZoneDropdown({ onChange, }: ZoneDropdownProps) { const [zones, setZones] = useState([]); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); const dropdownRef = useRef(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -49,7 +50,9 @@ export default function ZoneDropdown({ if (json.errors) { throw new Error(json.errors[0].message); } - const rawZones = json.data.zones.edges.map((edge: any) => edge.node); + const rawZones = json.data.zones.edges.map( + (edge: ZoneEdge) => edge.node + ); setZones(rawZones); } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch zones"); From 1343429edcb5773a5b0bacf7d8f025972ea822e0 Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 9 Jul 2025 11:33:26 -0400 Subject: [PATCH 008/197] Improve TypeScript type safety in sidebar component --- frontend/src/components/Sidebar.tsx | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 349a9d36c..fbdc96112 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -7,24 +7,28 @@ import { RxHamburgerMenu } from "react-icons/rx"; import ThemeToggle from "@/app/theme-toggle"; export default function Sidebar() { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); const sidebarRef = useRef(null); // Close sidebar on outside click useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if ( - sidebarRef.current && - !sidebarRef.current.contains(e.target as Node) - ) { + const handleClickOutside = (e: MouseEvent): void => { + const target = e.target as Node; + if (sidebarRef.current && !sidebarRef.current.contains(target)) { setOpen(false); } + }; + + if (open) { + document.addEventListener("mousedown", handleClickOutside); } - if (open) document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; }, [open]); - // Sidebar content (shared) + // Sidebar content const sidebarContent = (