branch:
client.tsx
11661 bytesRaw
import { useAgent } from "agents/react";
import { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import type { StoredEvent } from "./github-types";
import type { RepoState } from "./server";
import "./styles.css";

// Event type icons and colors
const eventConfig: Record<
  string,
  { icon: string; color: string; label: string }
> = {
  push: { icon: "commit", color: "#2ea043", label: "Push" },
  pull_request: { icon: "git_pull_request", color: "#8957e5", label: "PR" },
  issues: { icon: "error_outline", color: "#f85149", label: "Issue" },
  issue_comment: {
    icon: "chat_bubble_outline",
    color: "#768390",
    label: "Comment"
  },
  star: { icon: "star", color: "#e3b341", label: "Star" },
  fork: { icon: "call_split", color: "#57ab5a", label: "Fork" },
  release: { icon: "local_offer", color: "#388bfd", label: "Release" },
  ping: { icon: "notifications", color: "#768390", label: "Ping" }
};

function formatTimeAgo(timestamp: string): string {
  const now = new Date();
  const then = new Date(timestamp);
  const diffMs = now.getTime() - then.getTime();
  const diffSecs = Math.floor(diffMs / 1000);
  const diffMins = Math.floor(diffSecs / 60);
  const diffHours = Math.floor(diffMins / 60);
  const diffDays = Math.floor(diffHours / 24);

  if (diffSecs < 60) return "just now";
  if (diffMins < 60) return `${diffMins}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays < 7) return `${diffDays}d ago`;
  return then.toLocaleDateString();
}

function EventCard({ event }: { event: StoredEvent }) {
  const config = eventConfig[event.type] || {
    icon: "help_outline",
    color: "#768390",
    label: event.type
  };

  return (
    <a
      href={event.url}
      target="_blank"
      rel="noopener noreferrer"
      className="event-card"
    >
      <div className="event-icon" style={{ backgroundColor: config.color }}>
        <span className="material-icons-round">{config.icon}</span>
      </div>
      <div className="event-content">
        <div className="event-header">
          <span className="event-type" style={{ color: config.color }}>
            {config.label}
            {event.action && ` (${event.action})`}
          </span>
          <span className="event-time">{formatTimeAgo(event.timestamp)}</span>
        </div>
        <div className="event-title">{event.title}</div>
        <div className="event-description">{event.description}</div>
        <div className="event-actor">
          {event.actor.avatar_url && (
            <img
              src={event.actor.avatar_url}
              alt={event.actor.login}
              className="actor-avatar"
            />
          )}
          <span className="actor-name">{event.actor.login}</span>
        </div>
      </div>
    </a>
  );
}

function StatsBar({ stats }: { stats: RepoState["stats"] }) {
  return (
    <div className="stats-bar">
      <div className="stat">
        <span className="material-icons-round" style={{ color: "#e3b341" }}>
          star
        </span>
        <span className="stat-value">{stats.stars.toLocaleString()}</span>
        <span className="stat-label">Stars</span>
      </div>
      <div className="stat">
        <span className="material-icons-round" style={{ color: "#57ab5a" }}>
          call_split
        </span>
        <span className="stat-value">{stats.forks.toLocaleString()}</span>
        <span className="stat-label">Forks</span>
      </div>
      <div className="stat">
        <span className="material-icons-round" style={{ color: "#f85149" }}>
          error_outline
        </span>
        <span className="stat-value">{stats.openIssues.toLocaleString()}</span>
        <span className="stat-label">Open Issues</span>
      </div>
    </div>
  );
}

function App() {
  const [repoInput, setRepoInput] = useState("cloudflare/agents");
  const [connectedRepo, setConnectedRepo] = useState<string | null>(null);
  const [agentName, setAgentName] = useState<string | null>(null);
  const [events, setEvents] = useState<StoredEvent[]>([]);
  const [repoState, setRepoState] = useState<RepoState | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [filter, setFilter] = useState<string>("all");

  // Connect to the agent when agentName is set
  const agent = useAgent<RepoState>({
    agent: "repo-agent",
    name: agentName || undefined,
    onStateUpdate: (state) => {
      setRepoState(state);
    },
    onOpen: async () => {
      setIsConnecting(false);
      setError(null);
      // Fetch initial events
      try {
        const events = await agent.call<StoredEvent[]>("getEvents", [50]);
        setEvents(events);
      } catch (err) {
        console.error("Failed to fetch events:", err);
      }
    },
    onError: (err) => {
      setError(`Connection error: ${err}`);
      setIsConnecting(false);
    }
  });

  // Refresh events periodically
  useEffect(() => {
    if (!agentName) return;

    const interval = setInterval(async () => {
      try {
        const events = await agent.call<StoredEvent[]>("getEvents", [50]);
        setEvents(events);
      } catch (err) {
        console.error("Failed to refresh events:", err);
      }
    }, 10000); // Every 10 seconds

    return () => clearInterval(interval);
  }, [agentName, agent]);

  const handleConnect = useCallback(async () => {
    if (!repoInput.includes("/")) {
      setError("Please enter a valid repo name (owner/repo)");
      return;
    }

    setIsConnecting(true);
    setError(null);

    try {
      // Get the sanitized agent name
      const response = await fetch(
        `/api/agent-name?repo=${encodeURIComponent(repoInput)}`
      );
      const data = (await response.json()) as { agentName: string };
      setAgentName(data.agentName);
      setConnectedRepo(repoInput);
    } catch (err) {
      setError(`Failed to connect: ${err}`);
      setIsConnecting(false);
    }
  }, [repoInput]);

  const handleClearEvents = useCallback(async () => {
    try {
      await agent.call("clearEvents");
      setEvents([]);
    } catch (err) {
      console.error("Failed to clear events:", err);
    }
  }, [agent]);

  // Filter events
  const filteredEvents =
    filter === "all" ? events : events.filter((e) => e.type === filter);

  // Get unique event types for filter
  const eventTypes = Array.from(new Set(events.map((e) => e.type)));

  return (
    <div className="app">
      <header className="header">
        <h1>
          <span className="material-icons-round">webhook</span>
          GitHub Webhook Dashboard
        </h1>
        <p className="subtitle">Real-time repository activity monitor</p>
      </header>

      <div className="connect-section">
        <div className="connect-form">
          <input
            type="text"
            value={repoInput}
            onChange={(e) => setRepoInput(e.target.value)}
            placeholder="owner/repo (e.g., cloudflare/agents)"
            className="repo-input"
            onKeyDown={(e) => e.key === "Enter" && handleConnect()}
          />
          <button
            type="button"
            onClick={handleConnect}
            disabled={isConnecting}
            className="connect-button"
          >
            {isConnecting ? (
              <>
                <span className="material-icons-round spinning">sync</span>
                Connecting...
              </>
            ) : (
              <>
                <span className="material-icons-round">link</span>
                Connect
              </>
            )}
          </button>
        </div>

        {error && <div className="error-message">{error}</div>}

        {connectedRepo && (
          <div className="connected-info">
            <span className="material-icons-round">check_circle</span>
            Connected to <strong>{connectedRepo}</strong>
            {!repoState?.webhookConfigured && (
              <span className="waiting-badge">
                <span className="material-icons-round">hourglass_empty</span>
                Waiting for webhook events...
              </span>
            )}
          </div>
        )}
      </div>

      {repoState?.webhookConfigured && (
        <>
          <StatsBar stats={repoState.stats} />

          <div className="events-section">
            <div className="events-header">
              <h2>
                <span className="material-icons-round">history</span>
                Recent Events
              </h2>

              <div className="events-controls">
                <select
                  value={filter}
                  onChange={(e) => setFilter(e.target.value)}
                  className="filter-select"
                >
                  <option value="all">All Events</option>
                  {eventTypes.map((type) => (
                    <option key={type} value={type}>
                      {eventConfig[type]?.label || type}
                    </option>
                  ))}
                </select>

                <button
                  type="button"
                  onClick={handleClearEvents}
                  className="clear-button"
                  title="Clear all events"
                >
                  <span className="material-icons-round">delete_outline</span>
                </button>
              </div>
            </div>

            <div className="events-list">
              {filteredEvents.length === 0 ? (
                <div className="no-events">
                  <span className="material-icons-round">inbox</span>
                  <p>No events yet</p>
                  <p className="hint">
                    Events will appear here when webhooks are received
                  </p>
                </div>
              ) : (
                filteredEvents.map((event) => (
                  <EventCard key={event.id} event={event} />
                ))
              )}
            </div>
          </div>
        </>
      )}

      {!connectedRepo && (
        <div className="setup-guide">
          <h2>
            <span className="material-icons-round">settings</span>
            Setup Instructions
          </h2>
          <ol>
            <li>
              <strong>Deploy this worker</strong> to get your webhook URL
            </li>
            <li>
              Go to your GitHub repository → <strong>Settings</strong> →{" "}
              <strong>Webhooks</strong>
            </li>
            <li>
              Click <strong>Add webhook</strong>
            </li>
            <li>
              Set Payload URL to:{" "}
              <code>{window.location.origin}/webhooks/github/owner/repo</code>
            </li>
            <li>
              Set Content type to: <code>application/json</code>
            </li>
            <li>
              Set your webhook secret (must match{" "}
              <code>GITHUB_WEBHOOK_SECRET</code> in your worker)
            </li>
            <li>
              Select events you want to receive (or choose "Send me everything")
            </li>
            <li>Enter your repository name above and click Connect</li>
          </ol>
        </div>
      )}

      <footer className="footer">
        <p>
          Built with{" "}
          <a
            href="https://github.com/cloudflare/agents"
            target="_blank"
            rel="noopener noreferrer"
          >
            Cloudflare Agents
          </a>
        </p>
      </footer>
    </div>
  );
}

const root = createRoot(document.getElementById("root")!);
root.render(<App />);