PortGrid - Modern Network Port Visualization

[+] Status: Completed [+] Origin: Polk State College [+] Date: 2026.01
>> TECH_STACK:
[Next.js 15][React 19][TanStack Table][TanStack Query][TypeScript][Tailwind CSS][LibreNMS API][dnd-kit]

Modern Network Port Visualization

I built PortGrid from scratch to solve a real problem in my production environment. The existing tool (Switchmap) was dead - last updated in 2016, Perl dependencies rotting, and worst of all, it ran redundant SNMP polls against switches that LibreNMS was already monitoring. I designed and developed a modern replacement that pulls directly from the LibreNMS API, eliminating duplicate network load while providing real-time port visualization, instant search, and drag-and-drop device organization.

This is my project. I identified the problem, architected the solution, wrote every line of code, and deployed it to production. The full source is available at github.com/solomonneas/portgrid.

Open Source

This project is open source and available on GitHub

🚫 THE PROBLEM
Switchmap: Dead Since 2016

Switchmap was the go-to tool for network port visualization. Perl-based, it generated static HTML pages showing port status and connected devices. But the last commit was 2016. Perl dependencies rotted. Modern switches weren't fully supported. The web interface looked like it came from 2005.

🔄 THE REDUNDANCY
LibreNMS Already Has The Data

Here's what bothered me: LibreNMS already polls every switch via SNMP. It knows port status, MAC tables, LLDP neighbors - everything. Why run a second poller (Switchmap) against the same devices? That's duplicate network load and duplicate maintenance. The data already exists.

🕒 THE LIMITATION
Static HTML = Stale Data

Switchmap regenerated HTML files on a cron schedule. If a port went down between runs, you wouldn't know for an hour. No search. No filtering. No device organization. Just a directory listing of static HTML files per switch.

🚫 SWITCHMAP
  • Perl + SNMP polling (redundant with LibreNMS)
  • Static HTML generation via cron
  • No search or filtering capability
  • No device grouping or organization
  • Unstyled table output
  • Last updated: 2016
MY SOLUTION: PORTGRID
  • Built on LibreNMS API (zero polling overhead)
  • 60-second auto-refresh I implemented
  • Instant search I designed from scratch
  • Drag-and-drop grouping with dnd-kit
  • Modern UI with dark/light mode
  • Actively maintained: 2026
DATA SOURCE
📡 LibreNMS Already Polling
REST API
BACKEND PROXY
Next.js API Route /api/ports
Fetch ports, devices, links Device filtering (include/exclude patterns) Enrich with neighbor hostnames
JSON Response
CLIENT STATE
TanStack Query 60s Stale Time
localStorage Groups + Notes
React State
VISUALIZATION
Device Accordion Port Grid Table Status Colors Inline Notes
🔌
Real-Time Port Status Color-coded rows: green (up), amber (inactive), red (disabled). Auto-refreshes every 60 seconds.
🔍
Instant Search Filter by port name, MAC address, VLAN, neighbor hostname, or device. Results update as you type.
📂
Device Grouping Drag one device onto another to create groups. Organize switches by floor, building, or function.
📝
Port Notes Click any port to add persistent notes. Stored locally - survives page refreshes.
🌙
Dark/Light Mode System-aware theme toggle. Respects OS preference, remembers your choice.
📦
One-Line LXC Deploy Proxmox installer script handles container creation, dependencies, and configuration.

Modern JavaScript ecosystem with React 19 and Next.js 15 for optimal developer experience and performance:

Next.js 15
App Router + Server Components

Modern React framework with API routes for backend proxy to LibreNMS

React 19
UI Component Library

Latest React with concurrent features for responsive port table rendering

TanStack Table
Headless Table Engine

Sortable, paginated port grids with 50+ ports per device without lag

TanStack Query
Server State Management

60-second stale time with auto-refetch for near real-time port status

dnd-kit
Drag & Drop

Device grouping via drag-to-combine UI for organizing switch views

LibreNMS API
Data Source

REST API for ports, devices, and LLDP/CDP neighbor discovery

01 LibreNMS API Adapter

Parallel Data Fetching

// Fetch ports, devices, and links in parallel const [portsRes, devicesRes, linksRes] = await Promise.all([ fetch(`${baseUrl}/api/v0/ports?columns=${portColumns}`), fetch(`${baseUrl}/api/v0/devices?columns=${deviceColumns}`), fetch(`${baseUrl}/api/v0/links`).catch(() => null) ]);

Device Filtering (Glob Patterns)

# Environment variables DEVICE_EXCLUDE=test*,spare*,10.0.0.* DEVICE_INCLUDE=core-*,access-* // Glob matching with wildcard support function matchPattern(value: string, pattern: string) const regex = pattern.replace(/\*/g, '.*'); return new RegExp(`^${regex}$`, 'i').test(value);

LibreNMS requires explicit ?columns= parameter to include fields like device_id. Without it, many fields are omitted from the response. The links endpoint is optional and gracefully degrades if LLDP/CDP data is unavailable.

02 Real-Time Updates with TanStack Query

Query Configuration

// QueryProvider setup const queryClient = new QueryClient( defaultOptions: queries: staleTime: 60 * 1000, // 60 seconds refetchInterval: 60 * 1000, // Auto-refresh , , );

usePorts Hook

export function usePorts() return useQuery( queryKey: ["ports"], queryFn: async () => const res = await fetch("/api/ports"); return res.json(); , );

03 Drag-and-Drop Device Grouping

Group Logic (dnd-kit)

// Case 1: Both ungrouped → Create new group // Case 2: Drag ungrouped → grouped → Add to group // Case 3: Drag grouped → ungrouped → Add to group // Case 4: Different groups → Move to target // DragOverlay uses plain div (not AccordionItem) // to avoid Radix context errors

localStorage Persistence

// Device groups structure interface DeviceGroup id: string; name: string; deviceIds: number[]; // Key: portgrid-device-groups

Groups auto-dissolve when only one device remains. The DragOverlay component renders outside the Accordion context, so using AccordionItem causes React context errors - solved by using a plain div for the overlay content.

04 Hydration-Safe localStorage

Mounted State Pattern

const [mounted, setMounted] = useState(false); const [notes, setNotes] = useState(); useEffect(() => // Only access localStorage after mount const stored = localStorage.getItem(STORAGE_KEY); if (stored) setNotes(JSON.parse(stored)); setMounted(true); , []); // Only persist when mounted useEffect(() => if (mounted) localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); , [notes, mounted]);

React Server Components run on the server where localStorage doesn't exist. The mounted state pattern prevents hydration mismatches by deferring localStorage access until the component is client-side.

📦 One-Line Installation

The installer script automates the entire deployment process on Proxmox VE. Run from the Proxmox host shell:

bash -c "$(curl -fsSL https://raw.githubusercontent.com/solomonneas/portgrid/main/scripts/install-lxc.sh)"
1 Container Creation Creates Debian 12 LXC with 4GB RAM, 2 cores, 8GB disk
2 Dependencies Installs Node.js 20 LTS, npm, git, build-essential
3 App Setup Clones repo, runs npm install, npm run build
4 Service Config Creates systemd service, enables on boot, starts app

Environment Configuration

# /opt/portgrid/.env.local DATA_SOURCE=librenms LIBRENMS_URL=https://librenms.example.com LIBRENMS_API_TOKEN=your-api-token-here # Optional: Device filtering DEVICE_EXCLUDE=test*,spare* DEVICE_INCLUDE=core-*,access-*
Problem Solved Eliminated Redundant Polling
Scenario: Switchmap ran its own SNMP polls against every switch, duplicating work LibreNMS already performed hourly
Solution: PortGrid consumes LibreNMS API directly - ports, devices, and LLDP links from existing poll data with zero additional network load
Impact: Single source of truth with no polling overhead
User Experience Instant Port Discovery
Scenario: Needed to find which switch port a device was connected to based on MAC address
Solution: Type any partial MAC in the search box - results filter in real-time across all devices and ports
Impact: 30-second lookup vs. manual cable tracing or multiple CLI commands
Operations Device Organization at Scale
Scenario: Managing visibility of 20+ switches with no logical grouping
Solution: Drag-and-drop device grouping with named groups (e.g., 'Building A', 'Core', 'Access Layer'). Order persists to localStorage
Impact: Relevant switches always visible, irrelevant ones collapsed
Deployment Containerized Deployment
Scenario: Traditional install required manual Node.js setup, dependency management, and service configuration
Solution: Single bash script creates Proxmox LXC, installs dependencies, clones repo, configures systemd service, and starts the app
Impact: Production deployment in under 5 minutes
Full-Stack Development
  • Next.js API routes
  • React server/client components
  • REST API integration
Modern React Patterns
  • TanStack Query for server state
  • Custom hooks (usePortNotes, usePorts)
  • Hydration-safe localStorage
Data Visualization
  • TanStack Table with pagination
  • Status color coding
  • Sortable columns
UX Engineering
  • Drag-and-drop with dnd-kit
  • Inline editing components
  • Dark mode with next-themes
DevOps
  • Proxmox LXC automation
  • Systemd service creation
  • Environment-based configuration
📁 solomonneas/portgrid

I've open-sourced the entire project. Every component, every hook, every line - written by me. MIT licensed so others facing the same Switchmap problem can use it too.

$ git clone https://github.com/solomonneas/portgrid.git 32 commits
PortGrid v1.0 deployed and operational
  • LibreNMS API integration complete
  • Real-time port status with auto-refresh
  • Instant search across all fields
  • Drag-and-drop device grouping
  • Inline port notes with localStorage persistence
  • Dark/light mode with system detection
  • Proxmox LXC installer script
  • Production deployment at Polk State