adk-gateway 1.0.0

Multi-channel AI gateway for adk-rust agents — Telegram, Slack, WhatsApp, Discord, Matrix + control panel
import { useApi } from '../hooks/useApi';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
import ConfirmDialog from '../components/ConfirmDialog';
import type { LogEntry, LogFileInfo } from '../types';
import { useState, useEffect, useCallback, useRef } from 'react';

const LEVELS = ['ERROR', 'WARN', 'INFO', 'DEBUG'] as const;
const EVENT_FILTERS = ['all', 'approval', 'rate_limit'] as const;

export default function Logs() {
  const { data, loading, error, refetch } = useApi<LogEntry[]>(() => api.logs(), []);
  const { lastEvent, isConnected } = useWebSocket();
  const [logs, setLogs] = useState<LogEntry[]>([]);
  const [visibleLevels, setVisibleLevels] = useState<Set<string>>(new Set(LEVELS));
  const [searchText, setSearchText] = useState('');
  const [eventFilter, setEventFilter] = useState<string>('all');
  const initialized = useRef(false);

  // Log files for download
  const { data: logFiles } = useApi<LogFileInfo[]>(() => api.getLogFiles(), []);
  const [confirmClear, setConfirmClear] = useState(false);
  const [clearing, setClearing] = useState(false);

  // Initialize logs from API data
  useEffect(() => {
    if (data && !initialized.current) {
      setLogs(data);
      initialized.current = true;
    } else if (data && initialized.current) {
      // On refetch, merge
      setLogs(data);
    }
  }, [data]);

  // Append logs from WebSocket
  useEffect(() => {
    if (lastEvent?.type === 'log') {
      const entry: LogEntry = {
        timestamp: lastEvent.timestamp,
        level: lastEvent.level,
        message: lastEvent.message,
        target: lastEvent.target ?? null,
      };
      setLogs((prev) => [...prev, entry]);
    }
  }, [lastEvent]);

  // Polling fallback when WebSocket disconnected
  useEffect(() => {
    if (isConnected) return;
    const interval = setInterval(refetch, 3000);
    return () => clearInterval(interval);
  }, [isConnected, refetch]);

  const toggleLevel = useCallback((level: string) => {
    setVisibleLevels((prev) => {
      const next = new Set(prev);
      if (next.has(level)) {
        next.delete(level);
      } else {
        next.add(level);
      }
      return next;
    });
  }, []);

  const clearFilters = () => {
    setVisibleLevels(new Set(LEVELS));
    setSearchText('');
    setEventFilter('all');
  };

  const handleClearOldLogs = async () => {
    setClearing(true);
    try {
      await api.clearOldLogs();
      refetch();
    } catch { /* error handled silently */ }
    setClearing(false);
    setConfirmClear(false);
  };

  const isRateLimitEvent = (log: LogEntry) =>
    log.message.toLowerCase().includes('rate_limit') || log.message.toLowerCase().includes('rate limit');

  const isApprovalEvent = (log: LogEntry) =>
    log.message.toLowerCase().includes('approval') || log.target?.toLowerCase().includes('approval');

  const filtered = logs.filter((log) => {
    if (!visibleLevels.has(log.level)) return false;
    if (eventFilter === 'approval' && !isApprovalEvent(log)) return false;
    if (eventFilter === 'rate_limit' && !isRateLimitEvent(log)) return false;
    if (searchText) {
      const q = searchText.toLowerCase();
      const msgMatch = log.message.toLowerCase().includes(q);
      const targetMatch = log.target?.toLowerCase().includes(q) ?? false;
      if (!msgMatch && !targetMatch) return false;
    }
    return true;
  });

  if (loading && logs.length === 0) return <div className="text-gray-400">Loading logs...</div>;
  if (error && logs.length === 0) return <div className="text-red-600">Failed to load logs: {error}</div>;

  return (
    <div>
      <div className="flex items-center justify-between mb-5">
        <h2 className="text-2xl font-semibold">Logs</h2>
        <div className="flex gap-2">
          <button
            onClick={() => setConfirmClear(true)}
            className="px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100"
          >
            🗑 Clear Old Logs
          </button>
        </div>
      </div>

      {/* Confirm clear dialog */}
      {confirmClear && (
        <ConfirmDialog
          title="Clear Old Logs"
          message="This will delete log files beyond the configured retention period. This action cannot be undone."
          confirmLabel={clearing ? 'Clearing...' : 'Clear'}
          onConfirm={handleClearOldLogs}
          onCancel={() => setConfirmClear(false)}
          destructive
        />
      )}

      {/* Filters */}
      <div className="flex flex-wrap items-center gap-3 mb-4">
        {LEVELS.map((level) => (
          <button
            key={level}
            onClick={() => toggleLevel(level)}
            className={`px-3 py-1.5 text-xs font-semibold rounded-lg border transition-colors ${
              visibleLevels.has(level)
                ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
                : 'border-gray-300 bg-white text-gray-500'
            }`}
          >
            {level}
          </button>
        ))}

        <span className="text-gray-300">|</span>

        {/* Event type filter */}
        {EVENT_FILTERS.map((ef) => (
          <button
            key={ef}
            onClick={() => setEventFilter(ef)}
            className={`px-3 py-1.5 text-xs font-semibold rounded-lg border transition-colors ${
              eventFilter === ef
                ? ef === 'rate_limit'
                  ? 'border-orange-400 bg-orange-500 text-white'
                  : ef === 'approval'
                    ? 'border-purple-400 bg-purple-500 text-white'
                    : 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
                : 'border-gray-300 bg-white text-gray-500'
            }`}
          >
            {ef === 'all' ? 'All Events' : ef === 'approval' ? '🔐 Approvals' : '⚡ Rate Limits'}
          </button>
        ))}

        <input
          type="text"
          value={searchText}
          onChange={(e) => setSearchText(e.target.value)}
          placeholder="Search logs..."
          className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-[var(--color-accent)] min-w-[200px]"
        />

        <button
          onClick={clearFilters}
          className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200"
        >
          Clear Filters
        </button>

        <span className="text-xs text-gray-500 ml-auto">
          {filtered.length} / {logs.length} entries
        </span>
      </div>

      {/* Log table */}
      {filtered.length === 0 ? (
        <div className="text-center py-12 text-gray-400">No log entries match filters</div>
      ) : (
        <div className="bg-white rounded-xl shadow-sm overflow-hidden mb-6">
          <table className="w-full">
            <thead>
              <tr className="bg-gray-50">
                <th className="text-left px-4 py-3 text-xs uppercase tracking-wide text-gray-500 w-44">Timestamp</th>
                <th className="text-left px-4 py-3 text-xs uppercase tracking-wide text-gray-500 w-20">Level</th>
                <th className="text-left px-4 py-3 text-xs uppercase tracking-wide text-gray-500 w-28">Event</th>
                <th className="text-left px-4 py-3 text-xs uppercase tracking-wide text-gray-500">Message</th>
                <th className="text-left px-4 py-3 text-xs uppercase tracking-wide text-gray-500 w-40">Target</th>
              </tr>
            </thead>
            <tbody>
              {filtered.map((log, i) => (
                <tr key={i} className={`border-t border-gray-100 hover:bg-gray-50 ${isRateLimitEvent(log) ? 'bg-orange-50/50' : ''}`}>
                  <td className="px-4 py-2 text-xs font-mono text-gray-500">{log.timestamp}</td>
                  <td className="px-4 py-2"><StatusBadge status={log.level} /></td>
                  <td className="px-4 py-2">
                    {isRateLimitEvent(log) && (
                      <span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-800">
                        RATE_LIMITED
                      </span>
                    )}
                    {isApprovalEvent(log) && (
                      <span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
                        APPROVAL
                      </span>
                    )}
                  </td>
                  <td className="px-4 py-2 text-sm break-all">{log.message}</td>
                  <td className="px-4 py-2 text-xs text-gray-500 font-mono">{log.target || '—'}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {/* Log Files (Download) */}
      {logFiles && logFiles.length > 0 && (
        <div className="bg-white rounded-xl shadow-sm p-5">
          <h3 className="text-lg font-semibold mb-3">Log Files</h3>
          <div className="space-y-2">
            {logFiles.map((file) => (
              <div key={file.filename} className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg">
                <div className="flex items-center gap-3">
                  <span className="text-sm font-mono text-gray-700">{file.filename}</span>
                  <span className="text-xs text-gray-400">{(file.size_bytes / 1024).toFixed(1)} KB</span>
                </div>
                <a
                  href={api.downloadLogFile(file.filename)}
                  download
                  className="px-3 py-1 text-xs font-medium text-blue-700 bg-blue-50 rounded-lg hover:bg-blue-100 no-underline"
                >
                  ⬇ Download
                </a>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}