summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/daemons.tsx
blob: ca167fe97e353eaee4c79e40ec8fad83cdfffcef (plain) (tree)
1
2
3
4
5
6
7
8
9
10
                                                                 





                                                  


                        

                          


























                                                                                                                                    






































































































































































































































































































































                                                                                                                                                                                        












                                                                                            
                                                                        
 






















                                                                                     
                              











                                                                                       






                                                                                                         



                   


































                                                                                                                                       















                                                                                                                                                                                                                            






                                                                                                             
                                                                                                       









































                                                                                                                                                             








                                                            






















































































                                                                                                                    
                                             
























                                                                                                                                                                        













                                                                                                     










                              







                                               


          
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import {
  listDaemons,
  restartDaemon,
  triggerDaemonReauth,
  submitDaemonAuthCode,
  getDaemonReauthStatus,
  type Daemon,
  type DaemonListResponse,
} from "../lib/api";

// =============================================================================
// Section Header Component
// =============================================================================

function SectionHeader({ children }: { children: React.ReactNode }) {
  return (
    <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa] mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
      {children}
    </h2>
  );
}

// =============================================================================
// Alert Component
// =============================================================================

function ErrorAlert({ children }: { children: React.ReactNode }) {
  return (
    <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-4 font-mono text-xs">
      {children}
    </div>
  );
}

// =============================================================================
// Reauth Modal Component
// =============================================================================

type ReauthState =
  | { phase: "initiating" }
  | { phase: "url_ready"; loginUrl: string; requestId: string }
  | { phase: "submitting"; requestId: string }
  | { phase: "success" }
  | { phase: "error"; message: string };

function ReauthModal({
  daemon,
  onClose,
}: {
  daemon: Daemon;
  onClose: () => void;
}) {
  const [state, setState] = useState<ReauthState>({ phase: "initiating" });
  const [authCode, setAuthCode] = useState("");
  const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);

  // Cleanup polling on unmount
  useEffect(() => {
    return () => {
      if (pollingRef.current) {
        clearInterval(pollingRef.current);
      }
    };
  }, []);

  // Trigger reauth on mount
  useEffect(() => {
    let cancelled = false;
    const trigger = async () => {
      try {
        const res = await triggerDaemonReauth(daemon.id);
        if (cancelled) return;

        // Start polling for status
        const requestId = res.requestId;
        pollingRef.current = setInterval(async () => {
          try {
            const status = await getDaemonReauthStatus(daemon.id, requestId);
            if (cancelled) return;

            if (status.status === "url_ready" && status.loginUrl) {
              setState({
                phase: "url_ready",
                loginUrl: status.loginUrl,
                requestId,
              });
              // Stop polling once we have the URL
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            } else if (status.status === "failed") {
              setState({
                phase: "error",
                message: status.error || "Reauth failed",
              });
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            } else if (status.status === "completed") {
              setState({ phase: "success" });
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            }
          } catch {
            // Polling errors are non-fatal, keep trying
          }
        }, 2000);
      } catch (err) {
        if (cancelled) return;
        setState({
          phase: "error",
          message:
            err instanceof Error ? err.message : "Failed to trigger reauth",
        });
      }
    };
    trigger();
    return () => {
      cancelled = true;
    };
  }, [daemon.id]);

  const handleSubmitCode = useCallback(
    async (e: React.FormEvent) => {
      e.preventDefault();
      if (!authCode.trim() || state.phase !== "url_ready") return;

      const requestId = state.requestId;
      setState({ phase: "submitting", requestId });

      try {
        await submitDaemonAuthCode(daemon.id, authCode.trim(), requestId);

        // Poll for completion
        pollingRef.current = setInterval(async () => {
          try {
            const status = await getDaemonReauthStatus(
              daemon.id,
              requestId,
            );
            if (status.status === "completed") {
              setState({ phase: "success" });
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            } else if (status.status === "failed") {
              setState({
                phase: "error",
                message: status.error || "Auth code submission failed",
              });
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            }
          } catch {
            // Keep polling
          }
        }, 2000);

        // Also set a timeout so we don't poll forever
        setTimeout(() => {
          if (pollingRef.current) {
            clearInterval(pollingRef.current);
            pollingRef.current = null;
          }
          // If still submitting after 30s, assume success (setup-token completed)
          setState((prev) =>
            prev.phase === "submitting" ? { phase: "success" } : prev,
          );
        }, 30000);
      } catch (err) {
        setState({
          phase: "error",
          message:
            err instanceof Error
              ? err.message
              : "Failed to submit auth code",
        });
      }
    },
    [authCode, daemon.id, state],
  );

  const handleRetry = useCallback(() => {
    setAuthCode("");
    setState({ phase: "initiating" });
    // Re-trigger will happen via the useEffect dependency change
    // We need to manually trigger since daemon.id hasn't changed
    const trigger = async () => {
      try {
        const res = await triggerDaemonReauth(daemon.id);
        const requestId = res.requestId;
        pollingRef.current = setInterval(async () => {
          try {
            const status = await getDaemonReauthStatus(
              daemon.id,
              requestId,
            );
            if (status.status === "url_ready" && status.loginUrl) {
              setState({
                phase: "url_ready",
                loginUrl: status.loginUrl,
                requestId,
              });
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            } else if (status.status === "failed") {
              setState({
                phase: "error",
                message: status.error || "Reauth failed",
              });
              if (pollingRef.current) {
                clearInterval(pollingRef.current);
                pollingRef.current = null;
              }
            }
          } catch {
            // Keep polling
          }
        }, 2000);
      } catch (err) {
        setState({
          phase: "error",
          message:
            err instanceof Error
              ? err.message
              : "Failed to trigger reauth",
        });
      }
    };
    trigger();
  }, [daemon.id]);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
      <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4 shadow-2xl">
        {/* Header */}
        <div className="flex items-center justify-between mb-4">
          <h3 className="text-xs font-mono uppercase tracking-wide text-[#9bc3ff]">
            Reauthorize Daemon
          </h3>
          <button
            onClick={onClose}
            className="text-[#7788aa] hover:text-[#9bc3ff] text-sm font-mono"
          >
            &#x2715;
          </button>
        </div>
        <p className="text-[10px] font-mono text-[#7788aa] mb-4">
          {daemon.hostname || "Unknown Host"}
        </p>

        {/* Initiating */}
        {state.phase === "initiating" && (
          <div className="flex items-center gap-2 py-4">
            <div className="w-3 h-3 border border-[#75aafc] border-t-transparent rounded-full animate-spin" />
            <span className="text-[10px] font-mono text-[#7788aa]">
              Initiating reauthorization...
            </span>
          </div>
        )}

        {/* URL Ready */}
        {state.phase === "url_ready" && (
          <div className="space-y-3">
            <p className="text-[10px] font-mono text-[#7788aa]">
              Click the button below to open the OAuth login page, then paste the code:
            </p>
            <a
              href={state.loginUrl}
              target="_blank"
              rel="noopener noreferrer"
              className="block text-center bg-amber-500 hover:bg-amber-400 text-black font-mono text-xs font-medium px-4 py-2 transition-colors"
            >
              1. Login to Claude
            </a>
            <form onSubmit={handleSubmitCode} className="flex gap-2">
              <input
                type="text"
                value={authCode}
                onChange={(e) => setAuthCode(e.target.value)}
                placeholder="2. Paste authentication code"
                className="flex-1 bg-[#0a1525] border border-amber-500/30 px-3 py-2 text-xs font-mono text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400"
              />
              <button
                type="submit"
                disabled={!authCode.trim()}
                className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-mono text-xs font-medium px-4 py-2 transition-colors"
              >
                Submit
              </button>
            </form>
          </div>
        )}

        {/* Submitting */}
        {state.phase === "submitting" && (
          <div className="flex items-center gap-2 py-4">
            <div className="w-3 h-3 border border-amber-400 border-t-transparent rounded-full animate-spin" />
            <span className="text-[10px] font-mono text-[#7788aa]">
              Submitting auth code...
            </span>
          </div>
        )}

        {/* Success */}
        {state.phase === "success" && (
          <div className="py-4">
            <div className="flex items-center gap-2 text-green-400 mb-2">
              <span className="text-sm">&#x2713;</span>
              <span className="text-xs font-mono font-medium">
                Authentication successful
              </span>
            </div>
            <p className="text-[10px] font-mono text-[#7788aa] mb-3">
              The daemon's OAuth token has been refreshed. Tasks can now run normally.
            </p>
            <button
              onClick={onClose}
              className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
            >
              Close
            </button>
          </div>
        )}

        {/* Error */}
        {state.phase === "error" && (
          <div className="py-4">
            <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-3 font-mono text-xs">
              {state.message}
            </div>
            <div className="flex gap-2">
              <button
                onClick={handleRetry}
                className="text-[10px] font-mono text-amber-400 hover:text-amber-300 px-2 py-1 border border-amber-700/50 bg-amber-900/20"
              >
                Retry
              </button>
              <button
                onClick={onClose}
                className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1"
              >
                Close
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// =============================================================================
// Daemons Page
// =============================================================================

export default function DaemonsPage() {
  const { isAuthenticated, isAuthConfigured } = useAuth();
  const navigate = useNavigate();

  // Daemon state
  const [daemons, setDaemons] = useState<Daemon[]>([]);
  const [daemonsLoading, setDaemonsLoading] = useState(true);
  const [daemonsError, setDaemonsError] = useState<string | null>(null);
  const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null);
  const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null);
  const [reauthDaemon, setReauthDaemon] = useState<Daemon | null>(null);

  // Redirect if not authenticated
  useEffect(() => {
    if (isAuthConfigured && !isAuthenticated) {
      navigate("/login");
    }
  }, [isAuthConfigured, isAuthenticated, navigate]);

  const loadDaemons = async () => {
    try {
      setDaemonsError(null);
      const response: DaemonListResponse = await listDaemons();
      setDaemons(response.daemons);
    } catch (err) {
      setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons");
    } finally {
      setDaemonsLoading(false);
    }
  };

  const handleRestartDaemon = async (id: string) => {
    try {
      setRestartingDaemonId(id);
      setDaemonsError(null);
      await restartDaemon(id);
      // Daemon will restart, so refresh the list after a short delay
      setTimeout(() => {
        loadDaemons();
      }, 2000);
    } catch (err) {
      setDaemonsError(err instanceof Error ? err.message : "Failed to restart daemon");
    } finally {
      setRestartingDaemonId(null);
      setRestartConfirmDaemonId(null);
    }
  };

  // Static platform data for download links
  const downloadPlatforms = [
    { key: "linux-x86_64", label: "Linux (Intel/AMD)", filename: "makima-vX.X.X-linux-x86_64.tar.gz" },
    { key: "linux-arm64", label: "Linux (ARM64)", filename: "makima-vX.X.X-linux-arm64.tar.gz" },
    { key: "macos-x86_64", label: "macOS (Intel)", filename: "makima-vX.X.X-macos-x86_64.tar.gz" },
    { key: "macos-arm64", label: "macOS (Apple Silicon)", filename: "makima-vX.X.X-macos-arm64.tar.gz" },
  ];

  // Initial load
  useEffect(() => {
    loadDaemons();
  }, []);

  // Auto-refresh daemons every 30 seconds
  useEffect(() => {
    const interval = setInterval(() => {
      loadDaemons();
    }, 30000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
      <Masthead showNav />

      <main className="flex-1 max-w-4xl mx-auto p-6 w-full">
        {/* Page Header */}
        <div className="mb-8">
          <h1 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Daemons</h1>
          <p className="text-[#7788aa] font-mono text-[10px] mt-2">
            Daemons are worker processes that connect to Makima and execute tasks on your machines.
          </p>
          <div className="h-px bg-[rgba(117,170,252,0.35)] mt-2" />
        </div>

        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
          {/* Left Column */}
          <div className="space-y-6">
            {/* Download Section */}
            <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
              <SectionHeader>Download Daemon</SectionHeader>
              <p className="text-[#7788aa] font-mono text-[10px] mb-4">
                Download the pre-compiled daemon binary for your platform. The daemon connects to the Makima server and executes tasks.
              </p>

              <div className="grid grid-cols-2 gap-2 mb-4">
                {downloadPlatforms.map((p) => (
                  <a
                    key={p.key}
                    href="https://github.com/soryu-co/makima/releases/latest"
                    target="_blank"
                    rel="noopener noreferrer"
                    className="flex flex-col items-center justify-center gap-1 p-3 border border-[rgba(117,170,252,0.25)] bg-[#0a1525] font-mono text-xs text-[#9bc3ff] transition-colors hover:bg-[#0d1f3a] cursor-pointer"
                  >
                    <span className="text-[10px] uppercase tracking-wide">
                      {p.label}
                    </span>
                    <span className="text-[10px] text-[#556677]">
                      {p.filename}
                    </span>
                  </a>
                ))}
              </div>

              <div className="border-t border-[rgba(117,170,252,0.15)] pt-3">
                <p className="text-[10px] font-mono uppercase tracking-wide text-[#8899aa] mb-2">
                  Quick Install
                </p>
                <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 break-all">
                  curl -fsSL https://raw.githubusercontent.com/soryu-co/makima/master/install.sh | bash
                </code>
              </div>
            </section>

            {/* Daemon Setup */}
            <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
              <SectionHeader>Daemon Setup</SectionHeader>
              <p className="text-[#7788aa] font-mono text-[10px] mb-3">
                Set your API key as an environment variable:
              </p>
              <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3">
                export MAKIMA_API_KEY="your-key"
              </code>
              <p className="text-[#7788aa] font-mono text-[10px]">
                Then run: <code className="text-green-400">makima-daemon</code>
              </p>
            </section>

            {/* Kubernetes Section */}
            <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
              <SectionHeader>Run in Kubernetes</SectionHeader>
              <p className="text-[#7788aa] font-mono text-[10px] mb-3">
                Deploy daemons as containers in Kubernetes for scalable task execution.
              </p>

              <p className="text-[10px] font-mono uppercase tracking-wide text-[#8899aa] mb-2">
                Pull Container Image
              </p>
              <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-4 break-all">
                docker pull ghcr.io/soryu-co/makima-daemon:latest
              </code>

              <p className="text-[10px] font-mono uppercase tracking-wide text-[#8899aa] mb-2">
                Environment Variables
              </p>
              <div className="bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 space-y-1 mb-3">
                <div>MAKIMA_API_KEY=<span className="text-[#7788aa]">"your-key"</span></div>
                <div>MAKIMA_SERVER_URL=<span className="text-[#7788aa]">"https://your-server"</span></div>
                <div>GITHUB_TOKEN=<span className="text-[#7788aa]">"ghp_..." </span><span className="text-[#556677]"># optional, for repo access</span></div>
              </div>

              <p className="text-[#556677] font-mono text-[10px]">
                Kubernetes manifests available at{" "}
                <a
                  href="https://github.com/soryu-co/makima"
                  target="_blank"
                  rel="noopener noreferrer"
                  className="text-[#75aafc] hover:underline"
                >
                  github.com/soryu-co/makima
                </a>
              </p>
            </section>
          </div>

          {/* Right Column */}
          <div className="space-y-6">
            {/* Connected Daemons */}
            <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
              <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
                <div className="flex items-center gap-2">
                  <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
                    Connected Daemons
                  </h2>
                  {daemons.length > 0 && (
                    <span className="text-[10px] font-mono text-[#556677]">
                      ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
                    </span>
                  )}
                </div>
                <button
                  onClick={loadDaemons}
                  disabled={daemonsLoading}
                  className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
                  title="Refresh"
                >
                  {daemonsLoading ? "..." : "\u21BB"}
                </button>
              </div>

              {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>}

              {daemonsLoading && daemons.length === 0 ? (
                <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
              ) : daemons.length === 0 ? (
                <div className="text-center py-4">
                  <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p>
                  <p className="text-[#556677] font-mono text-[10px]">
                    Start a daemon to enable task execution
                  </p>
                </div>
              ) : (
                <div className="space-y-2">
                  {daemons.map((daemon) => (
                    <div
                      key={daemon.id}
                      className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
                    >
                      <div className="flex items-center justify-between mb-2">
                        <span className="font-mono text-xs text-[#9bc3ff]">
                          {daemon.hostname || "Unknown Host"}
                        </span>
                        <div className="flex items-center gap-2">
                          <span
                            className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${
                              daemon.status === "connected"
                                ? "text-green-400 border-green-700/50 bg-green-900/20"
                                : daemon.status === "unhealthy"
                                ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20"
                                : "text-[#8899aa] border-[rgba(117,170,252,0.25)]"
                            }`}
                          >
                            {daemon.status}
                          </span>
                        </div>
                      </div>
                      <div className="font-mono text-[10px] text-[#7788aa] space-y-1">
                        <div className="flex justify-between">
                          <span>Tasks</span>
                          <span className="text-[#9bc3ff]">
                            {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
                          </span>
                        </div>
                        <div className="flex justify-between">
                          <span>Connected</span>
                          <span className="text-[#75aafc]">
                            {new Date(daemon.connectedAt).toLocaleString()}
                          </span>
                        </div>
                        {daemon.machineId && (
                          <div className="flex justify-between">
                            <span>Machine</span>
                            <span className="text-[#556677] truncate ml-2" title={daemon.machineId}>
                              {daemon.machineId.substring(0, 16)}...
                            </span>
                          </div>
                        )}
                      </div>
                      {/* Actions Section */}
                      {daemon.status === "connected" && (
                        <div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]">
                          {restartConfirmDaemonId === daemon.id ? (
                            <div className="flex items-center justify-between gap-2">
                              <span className="text-[10px] font-mono text-yellow-400">
                                Restart daemon? Running tasks will be interrupted.
                              </span>
                              <div className="flex gap-2">
                                <button
                                  onClick={() => setRestartConfirmDaemonId(null)}
                                  className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1"
                                  disabled={restartingDaemonId === daemon.id}
                                >
                                  Cancel
                                </button>
                                <button
                                  onClick={() => handleRestartDaemon(daemon.id)}
                                  disabled={restartingDaemonId === daemon.id}
                                  className="text-[10px] font-mono text-red-400 hover:text-red-300 px-2 py-1 border border-red-700/50 bg-red-900/20 disabled:opacity-50"
                                >
                                  {restartingDaemonId === daemon.id ? "Restarting..." : "Confirm"}
                                </button>
                              </div>
                            </div>
                          ) : (
                            <div className="flex items-center gap-3">
                              <button
                                onClick={() => setReauthDaemon(daemon)}
                                className="text-[10px] font-mono text-amber-400 hover:text-amber-300"
                              >
                                &#x1F511; Reauthorize
                              </button>
                              <button
                                onClick={() => setRestartConfirmDaemonId(daemon.id)}
                                className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
                              >
                                &#x27F3; Restart Daemon
                              </button>
                            </div>
                          )}
                        </div>
                      )}
                    </div>
                  ))}
                </div>
              )}
            </section>
          </div>
        </div>
      </main>

      {/* Reauth Modal */}
      {reauthDaemon && (
        <ReauthModal
          daemon={reauthDaemon}
          onClose={() => setReauthDaemon(null)}
        />
      )}
    </div>
  );
}