peerman 0.2.3

DN42 peer manager with WireGuard, BIRD, and cluster support
import { useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useNodes } from '../../hooks/useNodes';
import { useProbes, useRunProbe } from '../../hooks/useProbes';
import { cn } from '../../lib/utils';

function latencyColor(ms: number): string {
  if (ms <= 0) return 'bg-canvas-soft';
  if (ms < 5) return 'bg-cyan-soft text-cyan-deep';
  if (ms < 20) return 'bg-success/20 text-success';
  if (ms < 50) return 'bg-warning-soft text-warning-deep';
  if (ms < 150) return 'bg-warning/40 text-warning-deep';
  return 'bg-error-soft text-error-deep';
}

export default function ProbeDashboard() {
  const { nodes } = useNodes();
  const { probes, refetch } = useProbes();
  const { run, loading: probeRunning } = useRunProbe();
  const [msg, setMsg] = useState('');

  // Build latency matrix: node_id -> node_id -> latest probe
  const matrix = new Map<string, Map<string, number>>();
  for (const p of probes) {
    if (!matrix.has(p.fromNodeId)) matrix.set(p.fromNodeId, new Map());
    const inner = matrix.get(p.fromNodeId)!;
    if (!inner.has(p.toNodeId)) {
      inner.set(p.toNodeId, p.avgLatencyMs);
    }
  }

  const handleProbeAll = async () => {
    if (nodes.length < 2) {
      setMsg('Need at least 2 nodes to probe.');
      return;
    }
    setMsg('Running probes...');
    const from = nodes[0];
    for (const to of nodes) {
      if (to.id === from.id) continue;
      try {
        await run(from.id, to.id);
      } catch {
        // skip failures
      }
    }
    setMsg('Probes complete.');
    refetch();
  };

  return (
    <div className="space-y-lg animate-fade-in">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-display-md text-ink">Network Probe Dashboard</h1>
          <p className="text-body-sm text-mute mt-xxs">Latency matrix between cluster nodes</p>
        </div>
        <button onClick={handleProbeAll} disabled={probeRunning || nodes.length < 2}
          className="btn-primary inline-flex items-center gap-1.5">
          <RefreshCw className={cn('w-4 h-4', probeRunning && 'animate-spin')} />
          Probe All
        </button>
      </div>

      {msg && <div className="card border border-hairline text-body-sm">{msg}</div>}

      {/* Latency Matrix */}
      <div className="card overflow-x-auto !p-0">
        <table className="w-full text-center">
          <thead>
            <tr>
              <th className="text-caption-mono text-mute uppercase p-md text-left bg-canvas-soft">From ↓ / To →</th>
              {nodes.map(n => (
                <th key={n.id} className="text-caption-mono text-mute p-md bg-canvas-soft font-medium max-w-[120px] truncate" title={n.name}>
                  {n.name}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {nodes.map(from => (
              <tr key={from.id} className="border-t border-hairline">
                <td className="text-body-sm font-medium p-md text-left bg-canvas-soft text-ink">{from.name}</td>
                {nodes.map(to => {
                  const ms = matrix.get(from.id)?.get(to.id);
                  const isSelf = from.id === to.id;
                  return (
                    <td key={to.id} className={cn('p-md text-body-sm font-mono', isSelf ? 'text-mute bg-canvas-soft' : '')}>
                      {isSelf ? '—' : ms !== undefined ? (
                        <span className={cn('rounded-sm px-xs py-0.5', latencyColor(ms))}>
                          {ms.toFixed(1)}ms
                        </span>
                      ) : (
                        <span className="text-mute">—</span>
                      )}
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Recent Probes Table */}
      <div className="card overflow-hidden !p-0">
        <table className="data-table w-full">
          <thead>
            <tr>
              <th>From</th>
              <th>To</th>
              <th>Avg</th>
              <th>Min</th>
              <th>Max</th>
              <th>Loss</th>
              <th>Time</th>
            </tr>
          </thead>
          <tbody>
            {probes.slice(0, 50).map(p => (
              <tr key={p.id}>
                <td className="text-body-sm">{nodes.find(n => n.id === p.fromNodeId)?.name ?? p.fromNodeId.slice(0, 8)}</td>
                <td className="text-body-sm">{nodes.find(n => n.id === p.toNodeId)?.name ?? p.toNodeId.slice(0, 8)}</td>
                <td className={cn('text-body-sm font-medium', p.avgLatencyMs < 5 ? 'text-success' : p.avgLatencyMs < 50 ? 'text-warning-deep' : 'text-error')}>
                  {p.avgLatencyMs.toFixed(1)}ms
                </td>
                <td className="text-body-sm text-mute">{p.minLatencyMs.toFixed(1)}ms</td>
                <td className="text-body-sm text-mute">{p.maxLatencyMs.toFixed(1)}ms</td>
                <td className={cn('text-body-sm', p.packetLossPct > 0 ? 'text-error' : 'text-mute')}>
                  {p.packetLossPct.toFixed(1)}%
                </td>
                <td className="text-caption-mono text-mute">{p.probedAt?.slice(11, 19) ?? '—'}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}