Skip to main content

priv/inspector/assets/js/app.js

/**
 * app.js - Main entry point: tab routing, session lifecycle, connection tab
 */

import { initToken, getToken, apiGet, apiPost, apiDelete, createEventSource, createEventBus, showToast } from "./api.js";
import { el } from "./render.js";
import { init as initTools } from "./tools.js";
import { init as initResources } from "./resources.js";
import { init as initPrompts } from "./prompts.js";
import { init as initLog } from "./log.js";
import { init as initPending } from "./pending.js";

const api = { apiGet, apiPost, apiDelete, showToast };

let sessionId = null;
let sessionInfo = null;
let eventSource = null;
let events = null;

// Shared event bus that persists across sessions so tab modules can
// subscribe once and receive session_changed + SSE events.
const globalEvents = createEventBus();

// Discovered servers from /api/servers (module names)
let knownServers = [];
let hasDefault = false;

// Track whether tab modules have been initialized (they only need DOM setup once)
let tabsInitialized = false;

// ── Tab routing ──

function setupTabs() {
  const buttons = document.querySelectorAll(".tab-btn");
  const panels = document.querySelectorAll(".tab-panel");

  for (const btn of buttons) {
    btn.addEventListener("click", () => {
      if (btn.classList.contains("tab-disabled")) return;
      const tab = btn.dataset.tab;

      for (const b of buttons) b.classList.remove("active");
      for (const p of panels) p.classList.remove("active");

      btn.classList.add("active");
      const panel = document.getElementById(`tab-${tab}`);
      if (panel) panel.classList.add("active");
    });
  }
}

function setTabsEnabled(enabled) {
  const nonConnectionTabs = ["tools", "resources", "prompts", "history", "notifications", "pending"];
  for (const name of nonConnectionTabs) {
    const btn = document.querySelector(`.tab-btn[data-tab="${name}"]`);
    if (!btn) continue;
    if (enabled) {
      btn.classList.remove("tab-disabled");
      btn.title = "";
    } else {
      btn.classList.add("tab-disabled");
      btn.title = "Connect to a server first";
    }
  }
}

// ── Connection tab ──

function renderConnectionTab(opts = {}) {
  const panel = document.getElementById("tab-connection");
  panel.innerHTML = "";

  panel.appendChild(el("h2", {}, "Connection"));

  // ── Connect panel ──
  const isConnected = !!sessionId;
  const connectSection = el("div", { class: `section connect-panel${isConnected ? "" : " connect-panel-prominent"}` });
  connectSection.appendChild(el("div", { class: "section-title" }, isConnected ? "Switch Target" : "Connect to a Server"));

  if (!isConnected) {
    connectSection.appendChild(el("p", { class: "connect-hint" }, "Choose a transport and connect to an MCP server to begin."));
  }

  // Collapsed affordance when already connected
  const switchArea = el("div", { id: "switch-target-area" });

  if (isConnected && !opts.showPicker) {
    const switchBtn = el("button", { class: "btn-sm" }, "Switch Target…");
    switchBtn.addEventListener("click", () => {
      renderConnectionTab({ showPicker: true });
    });
    switchArea.appendChild(switchBtn);
    connectSection.appendChild(switchArea);
    panel.appendChild(connectSection);
  } else {
    // Show the full target picker
    buildTargetPicker(switchArea);
    connectSection.appendChild(switchArea);
    panel.appendChild(connectSection);
  }

  // ── Server info (only when connected) ──
  if (isConnected && sessionInfo) {
    const infoSection = el("div", { class: "section" });
    infoSection.appendChild(el("div", { class: "section-title" }, "Server Info"));

    const info = sessionInfo;
    const grid = el("div", { class: "info-grid" });

    const addRow = (label, value) => {
      grid.appendChild(el("span", { class: "info-label" }, label));
      grid.appendChild(el("span", {}, String(value)));
    };

    if (info.server_info?.name) addRow("Name", info.server_info.name);
    if (info.server_info?.version) addRow("Version", info.server_info.version);
    if (info.server_info?.title) addRow("Title", info.server_info.title);
    if (info.target) addRow("Target", typeof info.target === "string" ? info.target : JSON.stringify(info.target));
    addRow("Session ID", info.session_id || sessionId);

    infoSection.appendChild(grid);

    // Instructions
    if (info.instructions) {
      const instrToggle = el("div", { class: "collapsible-toggle", style: { marginTop: "12px" } }, "Instructions");
      const instrContent = el("pre", { style: { display: "none", marginTop: "4px" } }, info.instructions);
      instrToggle.addEventListener("click", () => {
        const open = instrToggle.classList.toggle("open");
        instrContent.style.display = open ? "" : "none";
      });
      infoSection.appendChild(instrToggle);
      infoSection.appendChild(instrContent);
    }

    // Capabilities
    if (info.capabilities) {
      const capToggle = el("div", { class: "collapsible-toggle", style: { marginTop: "12px" } }, "Capabilities");
      const capContent = el("pre", { style: { display: "none", marginTop: "4px" } },
        JSON.stringify(info.capabilities, null, 2));
      capToggle.addEventListener("click", () => {
        const open = capToggle.classList.toggle("open");
        capContent.style.display = open ? "" : "none";
      });
      infoSection.appendChild(capToggle);
      infoSection.appendChild(capContent);
    }

    panel.appendChild(infoSection);

    // Ping button (standalone when connected)
    const pingSection = el("div", { class: "section" });
    pingSection.appendChild(el("div", { class: "section-title" }, "Actions"));
    const pingBtn = el("button", { class: "btn-sm" }, "Ping");
    pingBtn.addEventListener("click", async () => {
      try {
        await apiPost(`/api/session/${sessionId}/ping`);
        showToast("Pong!", "info");
      } catch (err) {
        showToast(`Ping failed: ${err.message}`);
      }
    });
    pingSection.appendChild(pingBtn);
    panel.appendChild(pingSection);

    // ── Log Level ──
    const logSection = el("div", { class: "section" });
    logSection.appendChild(el("div", { class: "section-title" }, "Log Level"));

    const logLevels = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
    const logSelect = el("select", { style: { width: "auto" } });
    for (const lvl of logLevels) {
      logSelect.appendChild(el("option", { value: lvl }, lvl));
    }

    const logSetBtn = el("button", { class: "btn-sm", style: { marginLeft: "8px" } }, "Set");
    logSetBtn.addEventListener("click", async () => {
      try {
        await apiPost(`/api/session/${sessionId}/log_level`, { level: logSelect.value });
        showToast(`Log level set to ${logSelect.value}`, "info");
      } catch (err) {
        showToast(`Failed: ${err.message}`);
      }
    });

    logSection.appendChild(el("div", { class: "inline-row" }, [logSelect, logSetBtn]));
    panel.appendChild(logSection);

    // ── Roots Editor ──
    const rootsSection = el("div", { class: "section" });
    rootsSection.appendChild(el("div", { class: "section-title" }, "Roots"));

    const rootsList = el("div", { id: "roots-list" });
    const currentRoots = info.roots || [];
    let rootsState = [...currentRoots];

    function renderRoots(roots) {
      rootsList.innerHTML = "";
      for (let i = 0; i < roots.length; i++) {
        const root = roots[i];
        const idx = i;
        const uriInput = el("input", { type: "text", value: root.uri || "", placeholder: "URI" });
        const nameInput = el("input", { type: "text", value: root.name || "", placeholder: "Name (optional)", style: { maxWidth: "200px" } });
        const removeBtn = el("button", { class: "btn-sm btn-danger" }, "×");
        removeBtn.addEventListener("click", () => {
          rootsState.splice(idx, 1);
          renderRoots(rootsState);
        });
        const row = el("div", { class: "inline-row" }, [uriInput, nameInput, removeBtn]);
        // Keep state in sync
        uriInput.addEventListener("input", () => { rootsState[idx].uri = uriInput.value; });
        nameInput.addEventListener("input", () => { rootsState[idx].name = nameInput.value; });
        rootsList.appendChild(row);
      }
    }

    renderRoots(rootsState);

    const addRootBtn = el("button", { class: "btn-sm", style: { marginTop: "4px" } }, "+ Add Root");
    addRootBtn.addEventListener("click", () => {
      rootsState.push({ uri: "", name: "" });
      renderRoots(rootsState);
    });

    const saveRootsBtn = el("button", { class: "btn-primary btn-sm", style: { marginTop: "8px" } }, "Save Roots");
    saveRootsBtn.addEventListener("click", async () => {
      const roots = rootsState.filter(r => r.uri);
      try {
        await apiPost(`/api/session/${sessionId}/roots`, { roots });
        showToast("Roots updated", "info");
      } catch (err) {
        showToast(`Failed: ${err.message}`);
      }
    });

    rootsSection.appendChild(rootsList);
    rootsSection.appendChild(addRootBtn);
    rootsSection.appendChild(saveRootsBtn);
    panel.appendChild(rootsSection);

    // ── Config Export ──
    const exportSection = el("div", { class: "section" });
    exportSection.appendChild(el("div", { class: "section-title" }, "Config Export"));

    const exportBtn = el("button", { class: "btn-sm" }, "Export Config");
    const exportResult = el("div", { style: { marginTop: "8px" } });

    exportBtn.addEventListener("click", async () => {
      try {
        const data = await apiGet(`/api/session/${sessionId}/export`);
        exportResult.innerHTML = "";

        if (data.note) {
          exportResult.appendChild(el("div", { style: { color: "var(--yellow)", marginBottom: "8px" } }, data.note));
        }

        if (data.entry !== null && data.entry !== undefined) {
          exportResult.appendChild(el("div", { class: "section-title", style: { marginTop: "8px" } }, "Entry JSON"));
          const entryPre = el("pre", {}, JSON.stringify(data.entry, null, 2));
          const copyEntryBtn = el("button", { class: "copy-btn btn-sm", style: { marginBottom: "4px" } }, "Copy");
          copyEntryBtn.addEventListener("click", () => {
            navigator.clipboard.writeText(JSON.stringify(data.entry, null, 2)).then(() => {
              copyEntryBtn.textContent = "Copied!";
              setTimeout(() => { copyEntryBtn.textContent = "Copy"; }, 2000);
            });
          });
          exportResult.appendChild(copyEntryBtn);
          exportResult.appendChild(entryPre);
        } else {
          exportResult.appendChild(el("div", { style: { color: "var(--text-muted)", fontStyle: "italic" } }, "Entry is null (not exportable)"));
        }

        if (data.servers_file) {
          exportResult.appendChild(el("div", { class: "section-title", style: { marginTop: "12px" } }, "servers_file JSON"));
          const sfPre = el("pre", {}, JSON.stringify(data.servers_file, null, 2));
          const copySfBtn = el("button", { class: "copy-btn btn-sm", style: { marginBottom: "4px" } }, "Copy");
          copySfBtn.addEventListener("click", () => {
            navigator.clipboard.writeText(JSON.stringify(data.servers_file, null, 2)).then(() => {
              copySfBtn.textContent = "Copied!";
              setTimeout(() => { copySfBtn.textContent = "Copy"; }, 2000);
            });
          });
          exportResult.appendChild(copySfBtn);
          exportResult.appendChild(sfPre);
        }
      } catch (err) {
        exportResult.innerHTML = "";
        exportResult.appendChild(el("div", { style: { color: "var(--red)" } }, err.message));
      }
    });

    exportSection.appendChild(exportBtn);
    exportSection.appendChild(exportResult);
    panel.appendChild(exportSection);
  }
}

// ── Target picker builder ──

function buildTargetPicker(container) {
  const isConnected = !!sessionId;

  // Radio group
  const radioGroup = el("div", { class: "radio-group" });
  const modes = [
    { value: "module", label: "Module" },
    { value: "stdio", label: "Stdio command" },
    { value: "http", label: "HTTP URL" }
  ];

  // If a default is configured, add it as first option
  if (hasDefault) {
    modes.unshift({ value: "default", label: "Default (server-configured)" });
  }

  let selectedMode = hasDefault ? "default" : "module";

  const modeFields = {};

  for (const m of modes) {
    const radio = el("input", { type: "radio", name: "target-mode", value: m.value });
    if (m.value === selectedMode) radio.checked = true;
    radio.addEventListener("change", () => {
      selectedMode = m.value;
      updateFields();
    });
    radioGroup.appendChild(el("label", { class: "radio-label" }, [radio, " " + m.label]));
  }

  container.appendChild(radioGroup);

  // Module fields
  const moduleFields = el("div", { class: "target-fields" });
  const moduleSelect = el("select", { id: "target-module-select" });

  // Populate with discovered servers
  if (knownServers.length > 0) {
    for (const s of knownServers) {
      moduleSelect.appendChild(el("option", { value: s }, s));
    }
    moduleSelect.appendChild(el("option", { value: "__custom__" }, "Other (type below)…"));
  } else {
    moduleSelect.appendChild(el("option", { value: "__custom__" }, "Enter module name below…"));
  }

  const moduleCustomInput = el("input", {
    type: "text",
    id: "target-module-custom",
    placeholder: "e.g. MyApp.MCPServer",
    style: { marginTop: "6px", display: knownServers.length > 0 ? "none" : "" }
  });

  moduleSelect.addEventListener("change", () => {
    moduleCustomInput.style.display = moduleSelect.value === "__custom__" ? "" : "none";
  });

  moduleFields.appendChild(el("div", { class: "form-group" }, [
    el("label", {}, "Module name"),
    moduleSelect,
    moduleCustomInput
  ]));
  modeFields.module = moduleFields;

  // Stdio fields
  const stdioFields = el("div", { class: "target-fields" });
  stdioFields.appendChild(el("div", { class: "form-group" }, [
    el("label", {}, "Command"),
    el("input", { type: "text", id: "target-stdio-cmd", placeholder: "e.g. node /path/to/server.js" })
  ]));
  stdioFields.appendChild(el("div", { class: "form-group" }, [
    el("label", {}, "Arguments (space-separated, optional)"),
    el("input", { type: "text", id: "target-stdio-args", placeholder: "--port 3000" })
  ]));
  stdioFields.appendChild(el("div", { class: "form-group" }, [
    el("label", {}, "Working directory (optional)"),
    el("input", { type: "text", id: "target-stdio-cd", placeholder: "/path/to/dir" })
  ]));
  modeFields.stdio = stdioFields;

  // HTTP fields
  const httpFields = el("div", { class: "target-fields" });
  httpFields.appendChild(el("div", { class: "form-group" }, [
    el("label", {}, "URL"),
    el("input", { type: "text", id: "target-http-url", placeholder: "https://..." })
  ]));
  httpFields.appendChild(el("div", { class: "form-group" }, [
    el("label", {}, "Bearer token (optional)"),
    el("input", { type: "text", id: "target-http-bearer", placeholder: "Token" })
  ]));
  modeFields.http = httpFields;

  // Default fields (no extra fields needed)
  if (hasDefault) {
    modeFields.default = el("div", { class: "target-fields" }, [
      el("span", { style: { color: "var(--text-muted)", fontSize: "12px" } }, "Uses the server-configured default target.")
    ]);
  }

  function updateFields() {
    for (const [mode, el_] of Object.entries(modeFields)) {
      el_.style.display = mode === selectedMode ? "" : "none";
    }
  }

  // Append all field groups
  for (const [, fieldEl] of Object.entries(modeFields)) {
    container.appendChild(fieldEl);
  }
  updateFields();

  // Buttons
  const btnRow = el("div", { class: "btn-group", style: { marginTop: "16px" } });

  const connectBtn = el("button", { class: "btn-primary btn-sm" }, isConnected ? "Reconnect" : "Connect");
  connectBtn.addEventListener("click", async () => {
    await doConnect(selectedMode, modeFields, moduleSelect, moduleCustomInput);
  });
  btnRow.appendChild(connectBtn);

  if (isConnected) {
    const cancelBtn = el("button", { class: "btn-sm" }, "Cancel");
    cancelBtn.addEventListener("click", () => renderConnectionTab());
    btnRow.appendChild(cancelBtn);
  }

  container.appendChild(btnRow);
}

// ── Connect / Reconnect ──

function buildTarget(mode, moduleSelect, moduleCustomInput) {
  switch (mode) {
    case "default":
      return undefined;
    case "module": {
      const name = moduleSelect?.value === "__custom__"
        ? moduleCustomInput?.value?.trim()
        : moduleSelect?.value;
      if (!name) return undefined;
      return { type: "module", module: name };
    }
    case "stdio": {
      const cmd = document.getElementById("target-stdio-cmd")?.value?.trim();
      if (!cmd) return undefined;
      const argsRaw = document.getElementById("target-stdio-args")?.value?.trim();
      const cd = document.getElementById("target-stdio-cd")?.value?.trim();
      const target = { type: "stdio", command: cmd };
      if (argsRaw) target.args = argsRaw.split(/\s+/);
      if (cd) target.cd = cd;
      return target;
    }
    case "http": {
      const url = document.getElementById("target-http-url")?.value?.trim();
      if (!url) return undefined;
      const bearer = document.getElementById("target-http-bearer")?.value?.trim();
      const target = { type: "url", url };
      if (bearer) target.bearer = bearer;
      return target;
    }
    default:
      return undefined;
  }
}

async function doConnect(mode, modeFields, moduleSelect, moduleCustomInput, explicitTarget) {
  const target = explicitTarget || buildTarget(mode, moduleSelect, moduleCustomInput);
  const body = {};
  if (target !== undefined) body.target = target;

  // Tear down existing session
  if (sessionId) {
    try {
      if (eventSource) eventSource.close();
      await apiDelete(`/api/session/${sessionId}`);
    } catch {
      // ignore cleanup errors
    }
    sessionId = null;
    sessionInfo = null;
    eventSource = null;
    clearPersistedSession();
  }

  try {
    const data = await apiPost("/api/connect", body);
    sessionId = data.session_id;
    sessionInfo = data;

    // Create a fresh per-session event bus that proxies into globalEvents
    events = createEventBus();
    const SSE_EVENTS = ["frame", "notification", "progress", "call_result", "pending_request", "pending_resolved", "status"];
    for (const name of SSE_EVENTS) {
      events.on(name, (payload, id) => globalEvents.emit(name, payload, id));
    }

    eventSource = createEventSource(sessionId, events);

    // Persist session so page reloads reconnect
    persistSession(sessionId, body.target || null);

    // Re-enable tabs
    setTabsEnabled(true);

    // Broadcast session change so all tab modules reset and re-fetch
    globalEvents.emit("session_changed", { session_id: sessionId });

    // Listen for status changes (session-scoped)
    events.on("status", (data) => {
      if (data.state === "closed" || data.state === "error") {
        showToast(`Session closed${data.reason || data.error ? ": " + (data.reason || data.error) : ""}`, "info");
        setTabsEnabled(false);
        sessionId = null;
        sessionInfo = null;
        clearPersistedSession();
        renderConnectionTab();
      }
    });

    // Re-render connection tab with server info
    renderConnectionTab();

    showToast(`Connected to ${data.server_info?.name || "server"}`, "info");
  } catch (err) {
    // If 400 "no target configured", stay on connection tab quietly
    if (err.message?.includes("no target configured")) {
      showToast("No default target — please choose one above.", "error");
    } else {
      showToast(`Connection failed: ${err.message}`);
    }
  }
}

// ── Session persistence ──

function persistSession(sid, target) {
  try {
    sessionStorage.setItem("inspector_session", JSON.stringify({ session_id: sid, target }));
  } catch { /* ignore */ }
}

function clearPersistedSession() {
  try { sessionStorage.removeItem("inspector_session"); } catch { /* ignore */ }
}

function getPersistedSession() {
  try {
    const raw = sessionStorage.getItem("inspector_session");
    return raw ? JSON.parse(raw) : null;
  } catch { return null; }
}

async function tryResumeSession(persisted) {
  try {
    const info = await apiGet(`/api/session/${persisted.session_id}`);
    if (info.session_id) {
      sessionId = info.session_id;
      sessionInfo = info;

      events = createEventBus();
      const SSE_EVENTS = ["frame", "notification", "progress", "call_result", "pending_request", "pending_resolved", "status"];
      for (const name of SSE_EVENTS) {
        events.on(name, (payload, id) => globalEvents.emit(name, payload, id));
      }
      eventSource = createEventSource(sessionId, events);
      setTabsEnabled(true);

      events.on("status", (data) => {
        if (data.state === "closed" || data.state === "error") {
          showToast(`Session closed${data.reason || data.error ? ": " + (data.reason || data.error) : ""}`, "info");
          setTabsEnabled(false);
          sessionId = null;
          sessionInfo = null;
          clearPersistedSession();
          renderConnectionTab();
        }
      });

      globalEvents.emit("session_changed", { session_id: sessionId });
      renderConnectionTab();
      return true;
    }
  } catch { /* session expired or server restarted */ }

  // Session gone — try reconnecting with the saved target
  if (persisted.target) {
    try {
      await doConnect("custom", {}, null, null, persisted.target);
      return true;
    } catch { /* fall through to normal boot */ }
  }

  clearPersistedSession();
  return false;
}

// ── Boot flow ──

async function boot() {
  initToken();
  setupTabs();

  // Disable non-connection tabs until connected
  setTabsEnabled(false);

  // Initialize all tab modules once (they wire their own DOM and subscribe to globalEvents)
  const ctx = { api, session: () => sessionId, events: globalEvents };
  initTools(ctx);
  initResources(ctx);
  initPrompts(ctx);
  initLog(ctx);
  initPending(ctx);

  // Fetch server list
  try {
    const serversData = await apiGet("/api/servers");
    knownServers = serversData.servers || [];
    hasDefault = serversData.has_default || false;
  } catch {
    knownServers = [];
    hasDefault = false;
  }

  // Try to resume a persisted session from before a page reload
  const persisted = getPersistedSession();
  if (persisted && getToken()) {
    const resumed = await tryResumeSession(persisted);
    if (resumed) return;
  }

  // Render connection tab initially
  renderConnectionTab();

  // Auto-connect if a default is configured
  if (getToken() && hasDefault) {
    await doConnect("default", {}, null, null);
  }
}

document.addEventListener("DOMContentLoaded", boot);