diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index 878132fe..b05ec478 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -28,7 +28,9 @@ "test:dev": "vitest", "test:e2e": "playwright test" }, - "files": ["dist"], + "files": [ + "dist" + ], "bin": { "spotlight": "./dist/run.js" }, @@ -60,6 +62,7 @@ "launch-editor": "^2.9.1", "logfmt": "^1.4.0", "mcp-proxy": "^5.6.0", + "pidusage": "^4.0.1", "semver": "^7.7.3", "uuidv7": "^1.0.2", "yaml": "^2.8.1", @@ -81,6 +84,7 @@ "@types/beautify": "^0.0.3", "@types/logfmt": "^1.2.6", "@types/node": "catalog:", + "@types/pidusage": "^2.0.5", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -96,7 +100,6 @@ "dotenv": "^16.4.5", "electron": "^35.7.5", "electron-builder": "^24.13.3", - "vite-plugin-electron": "^0.29.0", "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.12", @@ -121,6 +124,7 @@ "usehooks-ts": "^2.16.0", "vite": "catalog:", "vite-plugin-dts": "^4.5.4", + "vite-plugin-electron": "^0.29.0", "vite-plugin-svgr": "^3.3.0", "vitest": "catalog:", "zustand": "^5.0.3" diff --git a/packages/spotlight/src/server/cli.ts b/packages/spotlight/src/server/cli.ts index 5d10b10a..17f5ddb6 100755 --- a/packages/spotlight/src/server/cli.ts +++ b/packages/spotlight/src/server/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { parseArgs } from "node:util"; import showHelp from "./cli/help.ts"; +import list from "./cli/list.ts"; import mcp from "./cli/mcp.ts"; import run from "./cli/run.ts"; import server from "./cli/server.ts"; @@ -118,6 +119,7 @@ export const CLI_CMD_MAP = new Map([ ["mcp", mcp], ["tail", tail], ["run", run], + ["list", list], ["server", server], [undefined, server], ]); diff --git a/packages/spotlight/src/server/cli/help.ts b/packages/spotlight/src/server/cli/help.ts index d4572f63..f500d159 100644 --- a/packages/spotlight/src/server/cli/help.ts +++ b/packages/spotlight/src/server/cli/help.ts @@ -8,6 +8,10 @@ Spotlight Sidecar - Development proxy server for Spotlight Usage: spotlight [command] [options] Commands: + server Start Spotlight sidecar server (default) + run [command...] Run a command with Spotlight enabled + list List all running Spotlight instances + Use --all to include unresponsive/orphaned instances tail [types...] Tail Sentry events (default: everything) Available types: ${[...Object.keys(NAME_TO_TYPE_MAPPING)].join(", ")} Magic words: ${[...EVERYTHING_MAGIC_WORDS].join(", ")} @@ -17,12 +21,15 @@ Commands: Options: -p, --port Port to listen on (default: 8969, or 0 for random) -d, --debug Enable debug logging - -f, --format Output format for tail command (default: human) + -f, --format Output format for commands like run, tail, and list command (default: human) Available formats: ${[...AVAILABLE_FORMATTERS].join(", ")} -h, --help Show this help message Examples: spotlight # Start on default port 8969 + spotlight run npm run dev # Run npm dev with Spotlight + spotlight list # List all running instances + spotlight list --format json # List instances in JSON format spotlight tail # Tail all event types (human format) spotlight tail errors # Tail only errors spotlight tail errors logs # Tail errors and logs diff --git a/packages/spotlight/src/server/cli/list.ts b/packages/spotlight/src/server/cli/list.ts new file mode 100644 index 00000000..255f6772 --- /dev/null +++ b/packages/spotlight/src/server/cli/list.ts @@ -0,0 +1,143 @@ +import logfmt from "logfmt"; +import { formatLogLine } from "../formatters/human/utils.ts"; +import { logger } from "../logger.ts"; +import { getRegistry } from "../registry/manager.ts"; +import type { InstanceInfo } from "../registry/types.ts"; +import type { CLIHandlerOptions } from "../types/cli.ts"; + +/** + * Format instance info in human-readable format (uses formatLogLine) + */ +function formatHuman(instance: InstanceInfo, isSelf: boolean): string { + const timestamp = new Date(instance.startTime).getTime() / 1000; + const projectName = isSelf ? `${instance.projectName} [self]` : instance.projectName; + const message = `${projectName}@${instance.port} (${instance.command}) - http://localhost:${instance.port}`; + return formatLogLine(timestamp, "server", "info", message); +} + +/** + * Format instance info in JSON format + */ +function formatJson(instances: InstanceInfo[]): string { + return JSON.stringify(instances, null, 2); +} + +/** + * Format instance info in logfmt format + */ +function formatLogfmt(instance: InstanceInfo, isSelf: boolean): string { + const fields: Record = { + instanceId: instance.instanceId, + projectName: instance.projectName, + port: instance.port, + command: instance.command, + cwd: instance.cwd, + pid: instance.pid, + childPid: instance.childPid, + startTime: instance.startTime, + detectedType: instance.detectedType, + status: instance.status, + isSelf, + }; + + if (instance.uptime !== undefined) { + fields.uptime = Math.floor(instance.uptime / 1000); // seconds + } + + return logfmt.stringify(fields); +} + +/** + * Format instance info in markdown format + */ +function formatMarkdown(instances: InstanceInfo[], currentPid: number): string { + const lines: string[] = []; + + // Header + lines.push("| Project | Port | Command | Started | PID | URL | Status |"); + lines.push("|---------|------|---------|---------|-----|-----|--------|"); + + // Rows + for (const instance of instances) { + const isSelf = instance.pid === currentPid; + const projectName = isSelf ? `${instance.projectName} [self]` : instance.projectName; + const startTime = new Date(instance.startTime).toLocaleString(); + const url = `http://localhost:${instance.port}`; + const uptime = instance.uptime ? `${Math.floor(instance.uptime / 1000)}s` : "?"; + + lines.push( + `| ${projectName} | ${instance.port} | ${instance.command} | ${startTime} (${uptime}) | ${instance.pid} | ${url} | ${instance.status} |`, + ); + } + + return lines.join("\n"); +} + +/** + * Formatter function type - takes instances and current PID, returns formatted output + */ +type InstanceFormatter = (instances: InstanceInfo[], currentPid: number) => void; + +/** + * Map of format types to their formatter functions + */ +const FORMATTERS = new Map([ + [ + "human", + (instances, currentPid) => { + for (const instance of instances) { + const isSelf = instance.pid === currentPid; + console.log(formatHuman(instance, isSelf)); + } + }, + ], + [ + "json", + instances => { + console.log(formatJson(instances)); + }, + ], + [ + "logfmt", + (instances, currentPid) => { + for (const instance of instances) { + const isSelf = instance.pid === currentPid; + console.log(formatLogfmt(instance, isSelf)); + } + }, + ], + [ + "md", + (instances, currentPid) => { + console.log(formatMarkdown(instances, currentPid)); + }, + ], +]); + +/** + * List all registered Spotlight instances + */ +export default async function list({ format = "human" }: CLIHandlerOptions): Promise { + const includeUnhealthy = process.argv.includes("--all"); + const currentPid = process.pid; + + try { + const instances = await getRegistry().list(includeUnhealthy); + + if (instances.length === 0) { + logger.info("No Spotlight instances are currently running."); + return; + } + + const formatter = FORMATTERS.get(format); + if (!formatter) { + logger.error(`Unsupported format: ${format}`); + process.exit(1); + } + + formatter(instances, currentPid); + } catch (err) { + logger.error(`Failed to list instances: ${err}`); + process.exit(1); + } +} diff --git a/packages/spotlight/src/server/cli/run.ts b/packages/spotlight/src/server/cli/run.ts index 57399705..3289dfde 100644 --- a/packages/spotlight/src/server/cli/run.ts +++ b/packages/spotlight/src/server/cli/run.ts @@ -9,6 +9,9 @@ import { uuidv7 } from "uuidv7"; import { SENTRY_CONTENT_TYPE } from "../constants.ts"; import { logger } from "../logger.ts"; import type { SentryLogEvent } from "../parser/types.ts"; +import { getRegistry } from "../registry/manager.ts"; +import type { InstanceMetadata } from "../registry/types.ts"; +import { getProcessStartTime } from "../registry/utils.ts"; import type { CLIHandlerOptions } from "../types/cli.ts"; import { buildDockerComposeCommand, detectDockerCompose } from "../utils/docker-compose.ts"; import { getSpotlightURL } from "../utils/extras.ts"; @@ -107,6 +110,45 @@ function createLogEnvelope(level: "info" | "error", body: string, timestamp: num return Buffer.from(`${parts.join("\n")}\n`, "utf-8"); } +/** + * Ping the control center to notify it of this instance + */ +async function pingControlCenter(metadata: InstanceMetadata): Promise { + try { + const response = await fetch("http://localhost:8969/api/instances/ping", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(metadata), + signal: AbortSignal.timeout(500), + }); + + if (response.ok) { + logger.debug("Successfully pinged control center"); + } + } catch (err) { + // Fire-and-forget - don't block if control center isn't running + logger.debug("Could not ping control center (this is okay if no control center is running)"); + } +} + +/** + * Detect project name from package.json or directory name + */ +function detectProjectName(): string { + try { + const packageJson = JSON.parse(readFileSync("./package.json", "utf-8")); + if (packageJson.name) { + return packageJson.name; + } + } catch { + // Fall through to directory name + } + + return path.basename(process.cwd()); +} + export default async function run({ port, cmdArgs, basePath, filesToServe, format }: CLIHandlerOptions) { let relayStdioAsLogs = true; @@ -159,8 +201,14 @@ export default async function run({ port, cmdArgs, basePath, filesToServe, forma // or started in a weird manner (like over a unix socket) const actualServerPort = (serverInstance.address() as AddressInfo).port; const spotlightUrl = getSpotlightURL(actualServerPort, LOCALHOST_HOST); + + // Generate instance ID and prepare metadata (before spawning child process) + const instanceId = uuidv7(); + const pidStartTime = await getProcessStartTime(process.pid); + let shell = false; let stdin: string | undefined = undefined; + let detectedType = "unknown"; const env = { ...process.env, SENTRY_SPOTLIGHT: spotlightUrl, @@ -199,12 +247,14 @@ export default async function run({ port, cmdArgs, basePath, filesToServe, forma const command = buildDockerComposeCommand(dockerCompose); cmdArgs = command.cmdArgs; stdin = command.stdin; + detectedType = "docker-compose"; // Always unset COMPOSE_FILE to avoid conflicts with explicit -f flags - env.COMPOSE_FILE = undefined; + delete env.COMPOSE_FILE; } else { logger.info(`Using package.json script: ${packageJson.scriptName}`); cmdArgs = [packageJson.scriptCommand]; shell = true; + detectedType = "package.json"; env.PATH = path.resolve("./node_modules/.bin") + path.delimiter + env.PATH; } } else if (dockerCompose) { @@ -217,12 +267,14 @@ export default async function run({ port, cmdArgs, basePath, filesToServe, forma const command = buildDockerComposeCommand(dockerCompose); cmdArgs = command.cmdArgs; stdin = command.stdin; + detectedType = "docker-compose"; // Always unset COMPOSE_FILE to avoid conflicts with explicit -f flags - env.COMPOSE_FILE = undefined; + delete env.COMPOSE_FILE; } else if (packageJson) { logger.info(`Using package.json script: ${packageJson.scriptName}`); cmdArgs = [packageJson.scriptCommand]; shell = true; + detectedType = "package.json"; env.PATH = path.resolve("./node_modules/.bin") + path.delimiter + env.PATH; } } @@ -232,6 +284,7 @@ export default async function run({ port, cmdArgs, basePath, filesToServe, forma } const cmdStr = cmdArgs.join(" "); logger.info(`Starting command: ${cmdStr}`); + // When we have Docker Compose override YAML (for -f -), we need to pipe stdin // Otherwise, inherit stdin to relay user input to the downstream process const stdinMode: "pipe" | "inherit" = stdin ? "pipe" : "inherit"; @@ -331,9 +384,58 @@ export default async function run({ port, cmdArgs, basePath, filesToServe, forma stderr.destroy(); runCmd.kill(); }; - runCmd.on("spawn", () => { + runCmd.on("spawn", async () => { runCmd.removeAllListeners("spawn"); runCmd.removeAllListeners("error"); + + // Register instance after child process has spawned + try { + const childPidStartTime = await getProcessStartTime(runCmd.pid); + const projectName = detectProjectName(); + + const metadata: InstanceMetadata = { + instanceId, + port: actualServerPort, + pid: process.pid, + pidStartTime, + childPid: runCmd.pid || null, + childPidStartTime: runCmd.pid ? childPidStartTime : null, + command: cmdStr, + cmdArgs, + cwd: process.cwd(), + startTime: new Date().toISOString(), + projectName, + detectedType, + }; + + // Register in local registry + await getRegistry().register(metadata); + + // Ping control center (fire-and-forget) + pingControlCenter(metadata); + + // Register cleanup to remove metadata on exit + const cleanup = async () => { + await getRegistry().unregister(instanceId); + }; + + process.on("exit", () => { + // Use sync operation here since we can't await in exit handler + getRegistry().unregister(instanceId).catch(() => {}); + }); + process.on("SIGINT", async () => { + await cleanup(); + killRunCmd(); + }); + process.on("SIGTERM", async () => { + await cleanup(); + killRunCmd(); + }); + } catch (err) { + logger.error(`Failed to register instance: ${err}`); + // Don't fail the whole process if registration fails + } + process.on("SIGINT", killRunCmd); process.on("SIGTERM", killRunCmd); process.on("beforeExit", killRunCmd); diff --git a/packages/spotlight/src/server/registry/manager.ts b/packages/spotlight/src/server/registry/manager.ts new file mode 100644 index 00000000..1ab1a82c --- /dev/null +++ b/packages/spotlight/src/server/registry/manager.ts @@ -0,0 +1,194 @@ +import { logger } from "../logger.ts"; +import type { InstanceInfo, InstanceMetadata } from "./types.ts"; +import { + checkInstanceHealth, + deleteInstanceMetadata, + listInstanceFiles, + readInstanceMetadata, + writeInstanceMetadata, +} from "./utils.ts"; + +/** + * Manager for the instance registry + */ +export class InstanceRegistry { + /** + * Register a new instance + */ + async register(metadata: InstanceMetadata): Promise { + try { + await writeInstanceMetadata(metadata); + logger.debug(`Registered instance ${metadata.instanceId} on port ${metadata.port}`); + } catch (err) { + logger.error(`Failed to register instance ${metadata.instanceId}: ${err}`); + throw err; + } + } + + /** + * Unregister an instance + */ + async unregister(instanceId: string): Promise { + try { + await deleteInstanceMetadata(instanceId); + logger.debug(`Unregistered instance ${instanceId}`); + } catch (err) { + logger.error(`Failed to unregister instance ${instanceId}: ${err}`); + } + } + + /** + * List all instances with health check + * Automatically cleans up stale instances + */ + async list(includeUnhealthy = false): Promise { + const instanceIds = await listInstanceFiles(); + const instances: InstanceInfo[] = []; + + for (const instanceId of instanceIds) { + const metadata = await readInstanceMetadata(instanceId); + if (!metadata) { + // Corrupted file, skip and try to clean up + await deleteInstanceMetadata(instanceId); + continue; + } + + const status = await checkInstanceHealth(metadata); + + // Clean up dead instances + if (status === "dead") { + await this.unregister(instanceId); + if (!includeUnhealthy) { + continue; + } + } + + // Filter out unresponsive/orphaned unless requested + if (!includeUnhealthy && (status === "unresponsive" || status === "orphaned")) { + continue; + } + + const uptime = Date.now() - new Date(metadata.startTime).getTime(); + + instances.push({ + ...metadata, + status, + uptime, + }); + } + + // Sort by start time (newest first) + instances.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()); + + return instances; + } + + /** + * Get a specific instance by ID + */ + async get(instanceId: string): Promise { + const metadata = await readInstanceMetadata(instanceId); + if (!metadata) { + return null; + } + + const status = await checkInstanceHealth(metadata); + const uptime = Date.now() - new Date(metadata.startTime).getTime(); + + return { + ...metadata, + status, + uptime, + }; + } + + /** + * Clean up all stale instances + */ + async cleanup(): Promise { + const instanceIds = await listInstanceFiles(); + let cleaned = 0; + + for (const instanceId of instanceIds) { + const metadata = await readInstanceMetadata(instanceId); + if (!metadata) { + await deleteInstanceMetadata(instanceId); + cleaned++; + continue; + } + + const status = await checkInstanceHealth(metadata); + if (status === "dead") { + await this.unregister(instanceId); + cleaned++; + } + } + + if (cleaned > 0) { + logger.debug(`Cleaned up ${cleaned} stale instance(s)`); + } + + return cleaned; + } + + /** + * Terminate an instance + * Returns true if successful, false if instance not found or already dead + */ + async terminate(instanceId: string): Promise { + const metadata = await readInstanceMetadata(instanceId); + if (!metadata) { + return false; + } + + const status = await checkInstanceHealth(metadata); + if (status === "dead") { + await this.unregister(instanceId); + return false; + } + + try { + // Kill spotlight process (this should also kill child) + if (metadata.pid) { + try { + process.kill(metadata.pid, "SIGTERM"); + } catch (err) { + // Process might already be dead + logger.debug(`Could not kill spotlight PID ${metadata.pid}: ${err}`); + } + } + + // Kill child process if it exists + if (metadata.childPid) { + try { + process.kill(metadata.childPid, "SIGTERM"); + } catch (err) { + // Process might already be dead + logger.debug(`Could not kill child PID ${metadata.childPid}: ${err}`); + } + } + + // Clean up metadata + await this.unregister(instanceId); + + logger.info(`Terminated instance ${instanceId}`); + return true; + } catch (err) { + logger.error(`Failed to terminate instance ${instanceId}: ${err}`); + return false; + } + } +} + +// Singleton instance +let registryInstance: InstanceRegistry | null = null; + +/** + * Get the singleton registry instance + */ +export function getRegistry(): InstanceRegistry { + if (!registryInstance) { + registryInstance = new InstanceRegistry(); + } + return registryInstance; +} diff --git a/packages/spotlight/src/server/registry/types.ts b/packages/spotlight/src/server/registry/types.ts new file mode 100644 index 00000000..a9f5d1d7 --- /dev/null +++ b/packages/spotlight/src/server/registry/types.ts @@ -0,0 +1,30 @@ +/** + * Metadata for a registered Spotlight instance + */ +export type InstanceMetadata = { + instanceId: string; + port: number; + pid: number; + pidStartTime: number; + childPid: number | null; + childPidStartTime: number | null; + command: string; + cmdArgs: string[]; + cwd: string; + startTime: string; + projectName: string; + detectedType: string; +}; + +/** + * Health status of an instance + */ +export type HealthStatus = "healthy" | "unresponsive" | "dead" | "orphaned"; + +/** + * Instance information with health status + */ +export type InstanceInfo = InstanceMetadata & { + status: HealthStatus; + uptime?: number; +}; diff --git a/packages/spotlight/src/server/registry/utils.ts b/packages/spotlight/src/server/registry/utils.ts new file mode 100644 index 00000000..f343f374 --- /dev/null +++ b/packages/spotlight/src/server/registry/utils.ts @@ -0,0 +1,155 @@ +import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import pidusage from "pidusage"; +import type { HealthStatus, InstanceMetadata } from "./types.ts"; + +/** + * Get the registry directory path + */ +export function getRegistryDir(): string { + const user = process.env.USER || process.env.USERNAME || "default"; + return join(tmpdir(), `spotlight-${user}`, "instances"); +} + +/** + * Get the path for an instance metadata file + */ +export function getInstancePath(instanceId: string): string { + return join(getRegistryDir(), `instance_${instanceId}.json`); +} + +/** + * Ensure the registry directory exists + */ +export async function ensureRegistryDir(): Promise { + const dir = getRegistryDir(); + try { + await mkdir(dir, { recursive: true, mode: 0o700 }); + } catch (err) { + // Directory might already exist, that's okay + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + } +} + +/** + * Verify if a PID is valid by checking both existence and start time + * This prevents false positives from PID reuse + */ +export async function isPIDValid(pid: number, expectedStartTime: number): Promise { + try { + const stats = await pidusage(pid); + const actualStartTime = stats.timestamp - stats.elapsed; + // Allow 1s tolerance for timing differences + return Math.abs(actualStartTime - expectedStartTime) < 1000; + } catch { + return false; // Process doesn't exist + } +} + +/** + * Check the health status of an instance + */ +export async function checkInstanceHealth(instance: InstanceMetadata): Promise { + // 1. Try healthcheck endpoint first (fastest if responsive) + try { + const response = await fetch(`http://localhost:${instance.port}/health`, { + signal: AbortSignal.timeout(1000), + }); + if (response.ok) { + return "healthy"; + } + } catch { + // Continue to PID verification + } + + // 2. Verify PIDs with start time (handles PID reuse) + const spotlightAlive = await isPIDValid(instance.pid, instance.pidStartTime); + const childAlive = instance.childPid !== null && instance.childPidStartTime !== null + ? await isPIDValid(instance.childPid, instance.childPidStartTime) + : false; + + if (spotlightAlive && childAlive) { + return "unresponsive"; // Processes alive but not responding + } + if (!spotlightAlive && !childAlive) { + return "dead"; // Both dead - clean up + } + if (spotlightAlive && !childAlive) { + // If there's no child process, spotlight is healthy + if (instance.childPid === null) { + return "unresponsive"; // No child, but server not responding + } + return "dead"; // Child died, consider dead + } + if (!spotlightAlive && childAlive) { + return "orphaned"; // Child orphaned - spotlight crashed + } + + return "dead"; +} + +/** + * Write instance metadata atomically + */ +export async function writeInstanceMetadata(metadata: InstanceMetadata): Promise { + await ensureRegistryDir(); + const path = getInstancePath(metadata.instanceId); + const tempPath = `${path}.tmp`; + + await writeFile(tempPath, JSON.stringify(metadata, null, 2), "utf-8"); + // Atomic rename + await writeFile(path, await readFile(tempPath, "utf-8"), "utf-8"); + await unlink(tempPath).catch(() => {}); // Ignore errors if already gone +} + +/** + * Read instance metadata from file + */ +export async function readInstanceMetadata(instanceId: string): Promise { + try { + const path = getInstancePath(instanceId); + const content = await readFile(path, "utf-8"); + return JSON.parse(content) as InstanceMetadata; + } catch { + return null; + } +} + +/** + * Delete instance metadata file + */ +export async function deleteInstanceMetadata(instanceId: string): Promise { + try { + const path = getInstancePath(instanceId); + await unlink(path); + } catch { + // Ignore errors if file doesn't exist + } +} + +/** + * List all instance metadata files + */ +export async function listInstanceFiles(): Promise { + try { + await ensureRegistryDir(); + const files = await readdir(getRegistryDir()); + return files + .filter(f => f.startsWith("instance_") && f.endsWith(".json")) + .map(f => f.replace("instance_", "").replace(".json", "")); + } catch { + return []; + } +} + +/** + * Get process start time for current process or a given PID + */ +export async function getProcessStartTime(pid?: number): Promise { + const targetPid = pid ?? process.pid; + const stats = await pidusage(targetPid); + return stats.timestamp - stats.elapsed; +} diff --git a/packages/spotlight/src/server/routes/index.ts b/packages/spotlight/src/server/routes/index.ts index 20148962..9cd13a20 100644 --- a/packages/spotlight/src/server/routes/index.ts +++ b/packages/spotlight/src/server/routes/index.ts @@ -3,6 +3,7 @@ import { CONTEXT_LINES_ENDPOINT } from "../constants.ts"; import clearRouter from "./clear.ts"; import contextLinesRouter from "./contextlines/index.ts"; import healthRouter from "./health.ts"; +import instancesRouter from "./instances.ts"; import mcpRouter from "./mcp.ts"; import openRouter from "./open.ts"; import streamRouter from "./stream/index.ts"; @@ -11,6 +12,7 @@ const router = new Hono(); router.route("/mcp", mcpRouter); router.route("/health", healthRouter); router.route("/clear", clearRouter); +router.route("/instances", instancesRouter); router.route("/", streamRouter); router.route("/open", openRouter); router.route(CONTEXT_LINES_ENDPOINT, contextLinesRouter); diff --git a/packages/spotlight/src/server/routes/instances.ts b/packages/spotlight/src/server/routes/instances.ts new file mode 100644 index 00000000..5ce2b2d0 --- /dev/null +++ b/packages/spotlight/src/server/routes/instances.ts @@ -0,0 +1,87 @@ +import { Hono } from "hono"; +import { logger } from "../logger.ts"; +import { getRegistry } from "../registry/manager.ts"; +import type { InstanceMetadata } from "../registry/types.ts"; +import { EventContainer } from "../utils/eventContainer.ts"; +import { getBuffer } from "../utils/getBuffer.ts"; + +const router = new Hono(); + +/** + * GET /api/instances + * List all registered instances with health check + */ +router.get("/", async ctx => { + try { + const instances = await getRegistry().list(); + return ctx.json(instances); + } catch (err) { + logger.error(`Failed to list instances: ${err}`); + return ctx.json({ error: "Failed to list instances" }, 500); + } +}); + +/** + * POST /api/instances/ping + * Receive ping from a new instance and broadcast via SSE + */ +router.post("/ping", async ctx => { + try { + const metadata = (await ctx.req.json()) as InstanceMetadata; + + // Validate required fields + if (!metadata.instanceId || !metadata.port) { + return ctx.json({ error: "Invalid metadata" }, 400); + } + + // Broadcast the ping event via existing SSE connection + const container = new EventContainer("spotlight/instance-ping", Buffer.from(JSON.stringify(metadata))); + getBuffer().put(container); + + logger.debug(`Received ping from instance ${metadata.instanceId} on port ${metadata.port}`); + + return ctx.body(null, 204); + } catch (err) { + logger.error(`Failed to handle instance ping: ${err}`); + return ctx.json({ error: "Failed to process ping" }, 500); + } +}); + +/** + * POST /api/instances/:id/terminate + * Terminate a specific instance + */ +router.post("/:id/terminate", async ctx => { + const instanceId = ctx.req.param("id"); + + try { + const success = await getRegistry().terminate(instanceId); + + if (!success) { + return ctx.json({ error: "Instance not found or already terminated" }, 404); + } + + return ctx.json({ success: true }); + } catch (err) { + logger.error(`Failed to terminate instance ${instanceId}: ${err}`); + return ctx.json({ error: "Failed to terminate instance" }, 500); + } +}); + +/** + * GET /api/instances/current + * Get current instance metadata (if this is a registered instance) + */ +router.get("/current", async ctx => { + try { + // This endpoint returns metadata for the current sidecar instance + // For now, we'll return a simple response since we need access to the instance metadata + // This will be enhanced when we integrate with the server startup + return ctx.json({ message: "Current instance endpoint not yet implemented" }, 501); + } catch (err) { + logger.error(`Failed to get current instance: ${err}`); + return ctx.json({ error: "Failed to get current instance" }, 500); + } +}); + +export default router; diff --git a/packages/spotlight/src/ui/App.tsx b/packages/spotlight/src/ui/App.tsx index 58f50a38..64ff9b68 100644 --- a/packages/spotlight/src/ui/App.tsx +++ b/packages/spotlight/src/ui/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { ShikiProvider } from "./ShikiProvider"; // TODO: we'll lazy load this in case of multiple routes import { Telemetry } from "./telemetry"; +import { ControlCenter } from "./control-center/ControlCenter"; type AppProps = { sidecarUrl: string; @@ -15,6 +16,8 @@ export default function App({ sidecarUrl }: AppProps) { } /> } /> + + } /> ); diff --git a/packages/spotlight/src/ui/control-center/ControlCenter.tsx b/packages/spotlight/src/ui/control-center/ControlCenter.tsx new file mode 100644 index 00000000..62696e06 --- /dev/null +++ b/packages/spotlight/src/ui/control-center/ControlCenter.tsx @@ -0,0 +1,112 @@ +import { SENTRY_CONTENT_TYPE } from "@spotlight/shared/constants.ts"; +import { Button } from "@spotlight/ui/ui/button"; +import { useEffect } from "react"; +import { getConnectionManager } from "../lib/connectionManager"; +import { connectToSidecar } from "../sidecar"; +import useSentryStore from "../telemetry/store"; +import { InstanceList } from "./InstanceList"; + +type ControlCenterProps = { + sidecarUrl: string; + onClose?: () => void; +}; + +export function ControlCenter({ onClose }: ControlCenterProps) { + const { instances, currentInstanceId, isLoadingInstances, fetchInstances, terminateInstance, resetData } = + useSentryStore(); + + // Fetch instances on mount and set up periodic refresh + useEffect(() => { + fetchInstances(); + + // Refresh every 10 seconds to clean up stale instances + const interval = setInterval(() => { + fetchInstances(); + }, 10000); + + return () => clearInterval(interval); + }, [fetchInstances]); + + // Listen for instance ping events via SSE + // Note: This would require additional setup to handle SSE events + // For now, we rely on periodic polling via fetchInstances + + const handleConnect = async (port: number) => { + const connectionManager = getConnectionManager(); + + try { + await connectionManager.switchInstance( + port, + url => { + // Connect to new sidecar + const contentTypeListeners: Record void> = { + [SENTRY_CONTENT_TYPE]: event => { + const envelope = typeof event === "string" ? JSON.parse(event) : event; + useSentryStore.getState().pushEnvelope(envelope); + }, + [`${SENTRY_CONTENT_TYPE};base64`]: event => { + const envelope = typeof event === "string" ? JSON.parse(event) : event; + useSentryStore.getState().pushEnvelope(envelope); + }, + }; + + return connectToSidecar(url, contentTypeListeners, () => {}); + }, + () => { + // Reset store + resetData(); + }, + ); + + // Find and set the current instance ID + const targetInstance = instances.find(i => i.port === port); + if (targetInstance) { + useSentryStore.setState({ currentInstanceId: targetInstance.instanceId }); + } + + if (onClose) { + onClose(); + } + } catch (err) { + console.error("Failed to switch instance:", err); + alert("Failed to switch to the selected instance. Please try again."); + } + }; + + const handleTerminate = async (instanceId: string) => { + const success = await terminateInstance(instanceId); + if (success) { + // Refresh the list + fetchInstances(); + } else { + alert("Failed to terminate the instance. It may already be stopped."); + } + }; + + return ( +
+
+

Spotlight Control Center

+
+ + {onClose && ( + + )} +
+
+ +
+ +
+
+ ); +} diff --git a/packages/spotlight/src/ui/control-center/InstanceCard.tsx b/packages/spotlight/src/ui/control-center/InstanceCard.tsx new file mode 100644 index 00000000..75e9638f --- /dev/null +++ b/packages/spotlight/src/ui/control-center/InstanceCard.tsx @@ -0,0 +1,109 @@ +import { Badge } from "@spotlight/ui/ui/badge"; +import { Button } from "@spotlight/ui/ui/button"; +import type { InstanceInfo } from "../telemetry/store/slices/instancesSlice"; + +type InstanceCardProps = { + instance: InstanceInfo; + isCurrentInstance: boolean; + onConnect: (port: number) => void; + onTerminate: (instanceId: string) => void; +}; + +function formatUptime(uptime?: number): string { + if (!uptime) return "?"; + const seconds = Math.floor(uptime / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +function getStatusBadgeVariant(status: InstanceInfo["status"]): "default" | "warning" | "destructive" | "secondary" { + switch (status) { + case "healthy": + return "secondary"; + case "unresponsive": + return "warning"; + case "orphaned": + return "warning"; + case "dead": + return "destructive"; + default: + return "default"; + } +} + +export function InstanceCard({ instance, isCurrentInstance, onConnect, onTerminate }: InstanceCardProps) { + const handleConnect = () => { + if (!isCurrentInstance) { + onConnect(instance.port); + } + }; + + const handleTerminate = () => { + if (window.confirm(`Are you sure you want to terminate ${instance.projectName}?`)) { + onTerminate(instance.instanceId); + } + }; + + return ( +
+
+
+
+

{instance.projectName}

+ {instance.status} + {isCurrentInstance && Current} +
+
+
+ Port: {instance.port} +
+
+ Command: {instance.command} +
+
+ Type: {instance.detectedType} +
+
+ Uptime: {formatUptime(instance.uptime)} +
+ +
+
+
+ {!isCurrentInstance && instance.status === "healthy" && ( + + )} + {instance.status !== "dead" && ( + + )} +
+
+
+ ); +} diff --git a/packages/spotlight/src/ui/control-center/InstanceList.tsx b/packages/spotlight/src/ui/control-center/InstanceList.tsx new file mode 100644 index 00000000..79b104ec --- /dev/null +++ b/packages/spotlight/src/ui/control-center/InstanceList.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Input } from "@spotlight/ui/ui/input"; +import type { InstanceInfo } from "../telemetry/store/slices/instancesSlice"; +import { InstanceCard } from "./InstanceCard"; + +type InstanceListProps = { + instances: InstanceInfo[]; + currentInstanceId: string | null; + onConnect: (port: number) => void; + onTerminate: (instanceId: string) => void; +}; + +export function InstanceList({ instances, currentInstanceId, onConnect, onTerminate }: InstanceListProps) { + const [searchTerm, setSearchTerm] = useState(""); + + const filteredInstances = instances.filter(instance => { + const search = searchTerm.toLowerCase(); + return ( + instance.projectName.toLowerCase().includes(search) || + instance.command.toLowerCase().includes(search) || + instance.port.toString().includes(search) + ); + }); + + return ( +
+
+

+ Spotlight Instances ({instances.length}) +

+ setSearchTerm(e.target.value)} + className="max-w-xs" + /> +
+ + {filteredInstances.length === 0 ? ( +
+ {searchTerm ? "No instances match your search." : "No Spotlight instances are currently running."} +
+ ) : ( +
+ {filteredInstances.map(instance => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/spotlight/src/ui/lib/connectionManager.ts b/packages/spotlight/src/ui/lib/connectionManager.ts new file mode 100644 index 00000000..7b817d13 --- /dev/null +++ b/packages/spotlight/src/ui/lib/connectionManager.ts @@ -0,0 +1,82 @@ +import { log } from "./logger"; + +/** + * Connection manager for switching between Spotlight instances + */ +export class ConnectionManager { + private currentDisconnect: (() => void) | null = null; + private currentUrl: string | null = null; + + /** + * Switch to a different Spotlight instance + * This will disconnect from the current instance and connect to the new one + */ + async switchInstance( + newPort: number, + onConnect: (url: string) => () => void, + onReset: () => void, + ): Promise { + const newUrl = `http://localhost:${newPort}`; + + // Already connected to this instance + if (this.currentUrl === newUrl) { + log("Already connected to", newUrl); + return; + } + + log("Switching instance to", newUrl); + + // 1. Disconnect from current sidecar + if (this.currentDisconnect) { + log("Disconnecting from current instance"); + this.currentDisconnect(); + this.currentDisconnect = null; + } + + // 2. Clear/reset store state + log("Resetting store state"); + onReset(); + + // 3. Update sidecar URL to target port + this.currentUrl = newUrl; + + // 4. Reconnect to new sidecar + log("Connecting to new instance"); + this.currentDisconnect = onConnect(newUrl); + + // 5. Fresh data will be fetched automatically via the new SSE connection + log("Successfully switched to instance at", newUrl); + } + + /** + * Get the current connection URL + */ + getCurrentUrl(): string | null { + return this.currentUrl; + } + + /** + * Disconnect from the current instance + */ + disconnect(): void { + if (this.currentDisconnect) { + log("Disconnecting from current instance"); + this.currentDisconnect(); + this.currentDisconnect = null; + this.currentUrl = null; + } + } +} + +// Singleton instance +let connectionManager: ConnectionManager | null = null; + +/** + * Get the singleton connection manager instance + */ +export function getConnectionManager(): ConnectionManager { + if (!connectionManager) { + connectionManager = new ConnectionManager(); + } + return connectionManager; +} diff --git a/packages/spotlight/src/ui/telemetry/components/TelemetrySidebar.tsx b/packages/spotlight/src/ui/telemetry/components/TelemetrySidebar.tsx index 653cdb44..fa6edd5c 100644 --- a/packages/spotlight/src/ui/telemetry/components/TelemetrySidebar.tsx +++ b/packages/spotlight/src/ui/telemetry/components/TelemetrySidebar.tsx @@ -138,6 +138,17 @@ export default function TelemetrySidebar({ errorCount, traceCount, logCount, isO + + {/* Control Center link */} +
+ System +
+ + Instances +