Skip to content

Commit

Permalink
sparklines are back
Browse files Browse the repository at this point in the history
  • Loading branch information
mfreeman451 committed Mar 1, 2025
1 parent 5be5773 commit b438677
Show file tree
Hide file tree
Showing 3 changed files with 129 additions and 148 deletions.
116 changes: 62 additions & 54 deletions web/src/app/nodes/page.js
Original file line number Diff line number Diff line change
@@ -1,80 +1,88 @@
// src/app/nodes/page.js
import { Suspense } from 'react';
import NodeList from '../../components/NodeList';

// Server component that fetches data
async function fetchNodes() {
import {Suspense} from "react";
import NodeList from "../../components/NodeList";

// Disable static generation, always fetch latest data
export const revalidate = 0;

// Server component that fetches all data needed
async function fetchNodesWithMetrics() {
try {
// When running on the server, use the full backend URL
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8090';
const apiKey = process.env.API_KEY || '';

const response = await fetch(`${backendUrl}/api/nodes`, {
headers: {
'X-API-Key': apiKey
},
cache: 'no-store', // For real-time data
// Fetch all nodes first
const nodesResponse = await fetch(`${backendUrl}/api/nodes`, {
headers: { 'X-API-Key': apiKey },
cache: 'no-store', // Prevent caching
});

if (!response.ok) {
throw new Error(`Nodes API request failed: ${response.status}`);
if (!nodesResponse.ok) {
throw new Error(`Nodes API request failed: ${nodesResponse.status}`);
}

return await response.json();
} catch (error) {
console.error('Error fetching nodes:', error);
return [];
}
}
const nodes = await nodesResponse.json();
console.log(`Fetched ${nodes.length} nodes`);

// Fetch metrics for a specific node and service
async function fetchMetricsForService(nodeId, serviceName) {
try {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8090';
const apiKey = process.env.API_KEY || '';
// Create metrics lookup object
const serviceMetrics = {};

const response = await fetch(`${backendUrl}/api/nodes/${nodeId}/metrics`, {
headers: {
'X-API-Key': apiKey
},
cache: 'no-store',
});
// Fetch metrics for each node with ICMP services
for (const node of nodes) {
const icmpServices = node.services?.filter(s => s.type === 'icmp') || [];

if (icmpServices.length > 0) {
console.log(`Node ${node.node_id} has ${icmpServices.length} ICMP services`);

// Fetch all metrics for this node (one fetch per node is more efficient)
try {
const metricsResponse = await fetch(`${backendUrl}/api/nodes/${node.node_id}/metrics`, {
headers: { 'X-API-Key': apiKey },
cache: 'no-store',
});

if (!metricsResponse.ok) {
console.error(`Metrics API failed for ${node.node_id}: ${metricsResponse.status}`);
continue;
}

const allNodeMetrics = await metricsResponse.json();
console.log(`Received ${allNodeMetrics.length} metrics for ${node.node_id}`);

if (!response.ok) {
return [];
// Filter and organize metrics for each ICMP service
for (const service of icmpServices) {
const serviceMetricsData = allNodeMetrics.filter(m => m.service_name === service.name);
const key = `${node.node_id}-${service.name}`;
serviceMetrics[key] = serviceMetricsData;
console.log(`${key}: Filtered ${serviceMetricsData.length} metrics`);
}
} catch (error) {
console.error(`Error fetching metrics for ${node.node_id}:`, error);
}
}
}

const allMetrics = await response.json();
// Filter the metrics for this specific service
return allMetrics.filter(m => m.service_name === serviceName);
return { nodes, serviceMetrics };
} catch (error) {
console.error(`Error fetching metrics for ${nodeId}/${serviceName}:`, error);
return [];
console.error('Error fetching nodes data:', error);
return { nodes: [], serviceMetrics: {} };
}
}

export default async function NodesPage() {
// Fetch all nodes first
const nodes = await fetchNodes();
// Fetch all required data from the server
const { nodes, serviceMetrics } = await fetchNodesWithMetrics();

// Fetch metrics for ICMP services
const serviceMetrics = {};

for (const node of nodes) {
const icmpServices = node.services?.filter(s => s.type === 'icmp') || [];

for (const service of icmpServices) {
const metrics = await fetchMetricsForService(node.node_id, service.name);
const key = `${node.node_id}-${service.name}`;
serviceMetrics[key] = metrics;
}
}
// Log the metrics data for debugging
console.log(`Fetched ${Object.keys(serviceMetrics).length} service metric sets`);

return (
<div>
<Suspense fallback={<div className="flex justify-center items-center h-64">
<div className="text-lg text-gray-600 dark:text-gray-300">Loading nodes...</div>
</div>}>
<Suspense fallback={
<div className="flex justify-center items-center h-64">
<div className="text-lg text-gray-600 dark:text-gray-300">Loading nodes...</div>
</div>
}>
<NodeList initialNodes={nodes} serviceMetrics={serviceMetrics} />
</Suspense>
</div>
Expand Down
149 changes: 57 additions & 92 deletions web/src/components/NodeList.jsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,32 @@
// src/components/NodeList.jsx
'use client';

import React, { useState, useMemo, useCallback, useEffect } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import ServiceSparkline from "./ServiceSparkline";
import { useEffect } from 'react';

function NodeList({ initialNodes = [] }) {
function NodeList({ initialNodes = [], serviceMetrics = {} }) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [nodesPerPage] = useState(10);
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc');
// Use initialNodes directly instead of fetching
const [nodes, setNodes] = useState(initialNodes);

// Add auto-refresh functionality
// Update from new props when initialNodes changes
useEffect(() => {
// Update from new props when initialNodes changes
setNodes(initialNodes);
}, [initialNodes]);

// Optional: Add page refresh
// Set up auto-refresh
useEffect(() => {
const interval = setInterval(() => {
router.refresh(); // Trigger server-side refetch
}, 30000); // Every 30 seconds
const refreshInterval = 30000; // 30 seconds
const timer = setInterval(() => {
router.refresh(); // Trigger a server-side refresh
}, refreshInterval);

return () => clearInterval(interval);
return () => clearInterval(timer);
}, [router]);

const sortNodesByName = useCallback((a, b) => {
Expand Down Expand Up @@ -109,7 +108,6 @@ function NodeList({ initialNodes = [] }) {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
}, []);

// Regular Component Content
return (
<div className="space-y-4 transition-colors text-gray-800 dark:text-gray-100">
{/* Header row */}
Expand Down Expand Up @@ -159,63 +157,23 @@ function NodeList({ initialNodes = [] }) {
)}

{/* Main content */}
{renderTableView()}

{/* Pagination */}
{pageCount > 1 && (
<div className="flex justify-center gap-2 mt-4">
{[...Array(pageCount)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-3 py-1 rounded transition-colors ${
currentPage === i + 1
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
}`}
>
{i + 1}
</button>
))}
</div>
)}
</div>
);

function renderTableView() {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto transition-colors">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium
text-gray-500 dark:text-gray-300 uppercase tracking-wider w-16"
>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-16">
Status
</th>
<th
className="px-6 py-3 text-left text-xs font-medium
text-gray-500 dark:text-gray-300 uppercase tracking-wider w-48"
>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-48">
Node
</th>
<th
className="px-6 py-3 text-left text-xs font-medium
text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Services
</th>
<th
className="px-6 py-3 text-left text-xs font-medium
text-gray-500 dark:text-gray-300 uppercase tracking-wider w-64"
>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-64">
ICMP Response Time
</th>
<th
className="px-6 py-3 text-left text-xs font-medium
text-gray-500 dark:text-gray-300 uppercase tracking-wider w-48"
>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-48">
Last Update
</th>
</tr>
Expand All @@ -224,11 +182,7 @@ function NodeList({ initialNodes = [] }) {
{currentNodes.map((node) => (
<tr key={node.node_id}>
<td className="px-6 py-4 whitespace-nowrap">
<div
className={`w-2 h-2 rounded-full ${
node.is_healthy ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<div className={`w-2 h-2 rounded-full ${node.is_healthy ? 'bg-green-500' : 'bg-red-500'}`} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-100">
{node.node_id}
Expand All @@ -238,53 +192,64 @@ function NodeList({ initialNodes = [] }) {
{node.services?.map((service, idx) => (
<div
key={`${service.name}-${idx}`}
className="inline-flex items-center gap-1 cursor-pointer
hover:bg-gray-100 dark:hover:bg-gray-700 p-1 rounded transition-colors"
onClick={() =>
handleServiceClick(node.node_id, service.name)
}
className="inline-flex items-center gap-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-1 rounded transition-colors"
onClick={() => handleServiceClick(node.node_id, service.name)}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
service.available ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className={`w-1.5 h-1.5 rounded-full ${service.available ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-medium text-gray-800 dark:text-gray-100">
{service.name}
</span>
{service.name}
</span>
</div>
))}
</div>
</td>
<td className="px-6 py-4">
{node.services
?.filter((service) => service.type === 'icmp')
.map((service, idx) => (
<div
key={`${service.name}-${idx}`}
className="flex items-center justify-between gap-2"
>
<ServiceSparkline
nodeId={node.node_id}
serviceName={service.name}
// Pass the metrics directly from node.metrics, making sure it exists
initialMetrics={node.metrics ? node.metrics[service.name] || [] : []}
/>
</div>
))}
.map((service, idx) => {
const metricKey = `${node.node_id}-${service.name}`;
const metricsForService = serviceMetrics[metricKey] || [];

return (
<div key={`${service.name}-${idx}`} className="flex items-center justify-between gap-2">
<ServiceSparkline
nodeId={node.node_id}
serviceName={service.name}
initialMetrics={metricsForService}
/>
</div>
);
})}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm
text-gray-500 dark:text-gray-400"
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(node.last_update).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

{/* Pagination */}
{pageCount > 1 && (
<div className="flex justify-center gap-2 mt-4">
{[...Array(pageCount)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-3 py-1 rounded transition-colors ${
currentPage === i + 1
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
}`}
>
{i + 1}
</button>
))}
</div>
)}
</div>
);
}

export default NodeList;
Loading

0 comments on commit b438677

Please sign in to comment.