proxy-nostr-relay 0.3.1

A Nostr proxy relay with advanced bot filtering and an admin UI.
Documentation
import { useState, useEffect, useCallback } from 'react';
import { api } from '../api';
import type { ConnectionLog, EventRejectionLog } from '../types';

function formatReason(reason: string): string {
  const map: Record<string, string> = {
    'banned_npub': 'Banned Npub',
    'banned_ip': 'Banned IP',
    'kind_blacklist': 'Kind Blacklist',
    'bot_filter': 'Bot Filter',
    'not_in_safelist': 'Not in Safelist',
    'filter_rule': 'Filter Rule',
  };
  return map[reason] || reason;
}

export function LogsSection() {
  const [logType, setLogType] = useState<'rejection' | 'connection'>('rejection');
  const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
  const [rejectionLogs, setRejectionLogs] = useState<EventRejectionLog[]>([]);
  const [loading, setLoading] = useState(true);
  const [filterRejection, setFilterRejection] = useState({ npub: '', kind: '', reason: '', from: '', to: '' });
  const [filterConnection, setFilterConnection] = useState({ ip_address: '', from: '', to: '' });

  const fetchLogs = useCallback(() => {
    setLoading(true);
    if (logType === 'connection') {
      const params: { limit: number; ip_address?: string; from?: string; to?: string } = { limit: 100 };
      if (filterConnection.ip_address.trim()) params.ip_address = filterConnection.ip_address.trim();
      if (filterConnection.from.trim()) params.from = filterConnection.from.trim();
      if (filterConnection.to.trim()) params.to = filterConnection.to.trim();
      api.getConnectionLogs(params)
        .then(data => { setConnectionLogs(data); setLoading(false); });
    } else {
      const params: { limit: number; npub?: string; kind?: number; reason?: string; from?: string; to?: string } = { limit: 100 };
      if (filterRejection.npub.trim()) params.npub = filterRejection.npub.trim();
      if (filterRejection.kind.trim()) params.kind = parseInt(filterRejection.kind, 10);
      if (filterRejection.reason.trim()) params.reason = filterRejection.reason.trim();
      if (filterRejection.from.trim()) params.from = filterRejection.from.trim();
      if (filterRejection.to.trim()) params.to = filterRejection.to.trim();
      api.getEventRejectionLogs(params)
        .then(data => { setRejectionLogs(data); setLoading(false); });
    }
  }, [logType, filterRejection.npub, filterRejection.kind, filterRejection.reason, filterRejection.from, filterRejection.to, filterConnection.ip_address, filterConnection.from, filterConnection.to]);

  useEffect(() => { fetchLogs(); }, [fetchLogs]);

  return (
    <div className="section">
      <h2>Event Logs</h2>
      <div className="form-row">
        <button
          className={logType === 'rejection' ? 'active' : 'btn-secondary'}
          onClick={() => setLogType('rejection')}
        >
          Rejection Logs
        </button>
        <button
          className={logType === 'connection' ? 'active' : 'btn-secondary'}
          onClick={() => setLogType('connection')}
        >
          Connection Logs
        </button>
      </div>

      {logType === 'rejection' ? (
        <div className="logs-filter-bar">
          <input
            placeholder="Npub (partial)"
            value={filterRejection.npub}
            onChange={e => setFilterRejection(f => ({ ...f, npub: e.target.value }))}
          />
          <input
            type="number"
            placeholder="Kind"
            value={filterRejection.kind}
            onChange={e => setFilterRejection(f => ({ ...f, kind: e.target.value }))}
            style={{ width: '80px' }}
          />
          <input
            placeholder="Reason (partial)"
            value={filterRejection.reason}
            onChange={e => setFilterRejection(f => ({ ...f, reason: e.target.value }))}
          />
          <input
            type="datetime-local"
            placeholder="From"
            value={filterRejection.from}
            onChange={e => setFilterRejection(f => ({ ...f, from: e.target.value }))}
          />
          <input
            type="datetime-local"
            placeholder="To"
            value={filterRejection.to}
            onChange={e => setFilterRejection(f => ({ ...f, to: e.target.value }))}
          />
          <button onClick={fetchLogs}>Apply</button>
        </div>
      ) : (
        <div className="logs-filter-bar">
          <input
            placeholder="IP (partial)"
            value={filterConnection.ip_address}
            onChange={e => setFilterConnection(f => ({ ...f, ip_address: e.target.value }))}
          />
          <input
            type="datetime-local"
            placeholder="From"
            value={filterConnection.from}
            onChange={e => setFilterConnection(f => ({ ...f, from: e.target.value }))}
          />
          <input
            type="datetime-local"
            placeholder="To"
            value={filterConnection.to}
            onChange={e => setFilterConnection(f => ({ ...f, to: e.target.value }))}
          />
          <button onClick={fetchLogs}>Apply</button>
        </div>
      )}

      {loading ? (
        <div className="loading">Loading logs...</div>
      ) : logType === 'rejection' ? (
        <div className="table-container">
          <table>
            <thead>
              <tr><th>Time</th><th>Reason</th><th>Kind</th><th>Npub</th><th>IP</th></tr>
            </thead>
            <tbody>
              {rejectionLogs.length === 0 ? (
                <tr><td colSpan={5} className="empty-state">No rejection logs</td></tr>
              ) : (
                rejectionLogs.map(log => (
                  <tr key={log.id}>
                    <td style={{ whiteSpace: 'nowrap' }}>{new Date(log.created_at).toLocaleString()}</td>
                    <td><span className="badge badge-danger">{formatReason(log.reason)}</span></td>
                    <td style={{ fontFamily: 'monospace' }}>{log.kind}</td>
                    <td className="truncate">{log.npub}</td>
                    <td style={{ fontFamily: 'monospace' }}>{log.ip_address || '—'}</td>
                  </tr>
                ))
              )}
            </tbody>
          </table>
        </div>
      ) : (
        <div className="table-container">
          <table>
            <thead>
              <tr><th>Connected</th><th>Disconnected</th><th>IP</th><th>Events</th><th>Rejected</th></tr>
            </thead>
            <tbody>
              {connectionLogs.length === 0 ? (
                <tr><td colSpan={5} className="empty-state">No connection logs</td></tr>
              ) : (
                connectionLogs.map(log => (
                  <tr key={log.id}>
                    <td style={{ whiteSpace: 'nowrap' }}>{new Date(log.connected_at).toLocaleString()}</td>
                    <td style={{ whiteSpace: 'nowrap' }}>
                      {log.disconnected_at ? (
                        new Date(log.disconnected_at).toLocaleString()
                      ) : (
                        <span className="badge badge-success">ACTIVE</span>
                      )}
                    </td>
                    <td style={{ fontFamily: 'monospace' }}>{log.ip_address}</td>
                    <td style={{ fontFamily: 'monospace' }}>{log.event_count}</td>
                    <td>
                      {log.rejected_event_count > 0 ? (
                        <span className="badge badge-danger">{log.rejected_event_count}</span>
                      ) : (
                        <span style={{ color: 'var(--text-muted)' }}>0</span>
                      )}
                    </td>
                  </tr>
                ))
              )}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}