Skip to content

Spotlight Control Center #1136

@BYK

Description

@BYK

Spotlight Control Center

Overview

Track all spotlight run instances with registry + real-time pings. Users can view, switch between, and manage instances from any Spotlight UI (default port 8969) or via spotlight list CLI.

Key Design Decisions

  • Registry only for spotlight run: Electron and default port instances don't register
  • Single Control Center port: Always 8969 (no multi-port discovery needed)
  • Ping to default port only: New instances ping localhost:8969
  • Reuse existing SSE: Ping events via existing /stream connection
  • Robust staleness check: Healthcheck endpoint + PID verification with start time
  • No state persistence: Start fresh when switching (can add later with IndexedDB)

1. Instance Registry

Location: /tmp/spotlight-$USER/instances/

Metadata: instance_$UUID.json

{
  "instanceId": "uuid-v7",
  "port": 54321,
  "pid": 12345,
  "pidStartTime": 1700000000000,
  "childPid": 12346,
  "childPidStartTime": 1700000001000,
  "command": "npm run dev",
  "cmdArgs": ["npm", "run", "dev"],
  "cwd": "/path/to/project",
  "startTime": "2024-11-20T10:30:00.000Z",
  "projectName": "my-app",
  "detectedType": "package.json|docker-compose"
}

Note: pidStartTime and childPidStartTime prevent false positives from PID reuse (PIDs can be recycled after process termination).

2. Instance Registration (cli/run.ts)

On Startup:

  1. Generate instanceId with uuidv7()

  2. Get process start times using pidusage:

    import pidusage from 'pidusage';
    const stats = await pidusage(process.pid);
    const pidStartTime = stats.timestamp - stats.elapsed;
  3. Write metadata atomically (temp file + rename)

  4. Store spotlight PID + child PID with their start times

  5. Ping Control Center:

    • POST http://localhost:8969/api/instances/ping
    • Timeout: 500ms
    • Fire-and-forget (don't block if not running)
  6. Register cleanup to remove metadata on exit

3. Registry Manager (registry/manager.ts)

class InstanceRegistry {
  register(metadata: InstanceMetadata): void
  unregister(instanceId: string): void
  list(): Promise<InstanceInfo[]>  // with health check
  cleanup(): Promise<void>          // remove stale
  terminate(instanceId: string): Promise<boolean>
}

Health Check (3-tier verification):

async function checkInstanceHealth(instance: InstanceMetadata): HealthStatus {
  // 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 {}
  
  // 2. Verify PIDs with start time (handles PID reuse)
  const spotlightAlive = await isPIDValid(instance.pid, instance.pidStartTime);
  const childAlive = await isPIDValid(instance.childPid, instance.childPidStartTime);
  
  if (spotlightAlive && childAlive) return 'unresponsive'; // Processes alive but not responding
  if (!spotlightAlive && !childAlive) return 'dead';       // Both dead - clean up
  if (spotlightAlive && !childAlive) return 'dead';        // Shouldn't happen (spotlight exits with child)
  if (!spotlightAlive && childAlive) return 'orphaned';    // Child orphaned - spotlight crashed
}

async function isPIDValid(pid: number, expectedStartTime: number): Promise<boolean> {
  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
  }
}

Status meanings:

  • healthy: Healthcheck responded
  • unresponsive: Processes alive but healthcheck timeout (hung?)
  • dead: Process(es) terminated
  • orphaned: Child process alive but spotlight crashed

Terminate (cross-platform):

  • Kill spotlight PID and child PID using process.kill()
  • Remove metadata file
  • Use SIGTERM on Unix, default on Windows

Dependencies:

  • Add pidusage package for cross-platform process info

4. CLI List Command (cli/list.ts)

spotlight list [-f format] [--all]

All Formatters Supported:

human (default):

2024-11-20 10:30:45 [INFO] [spotlight] my-app@54321 (npm run dev) - http://localhost:54321

Uses formatLogLine() from existing human formatter

json:

[{"instanceId":"uuid-1","projectName":"my-app","port":54321,...}]

logfmt:

instanceId=uuid-1 projectName=my-app port=54321 command="npm run dev"...

md:

| Project | Port | Command | Started | PID | URL | Status |

Options:

  • -f, --format: Output format
  • --all: Include unresponsive/orphaned instances

5. Sidecar API (routes/instances.ts)

GET /api/instances
  // List all with health check + cleanup
  // Returns: InstanceInfo[]

POST /api/instances/ping
  // Receive ping from new instance
  // Body: InstanceMetadata
  // Returns: 204 No Content
  // Broadcasts via existing SSE

POST /api/instances/:id/terminate
  // Terminate instance
  // Returns: 200 OK or 404

GET /api/instances/current
  // Get current instance metadata
  // Returns: InstanceMetadata

Ping Broadcast (reuses existing SSE):

// When /api/instances/ping receives request:
const container = new EventContainer(
  'spotlight/instance-ping',
  Buffer.from(JSON.stringify(metadata))
);
getBuffer().put(container);

// UI already subscribes to /stream
// Just add handler for 'spotlight/instance-ping' event type

6. Control Center UI (ui/control-center/)

Components:

ControlCenter.tsx       # Main container
InstanceList.tsx        # List of instances  
InstanceCard.tsx        # Individual instance
ConnectionSwitcher.tsx  # Switch instances
store/instancesSlice.ts # Zustand slice

Update Strategy:

  • Mount: Fetch /api/instances
  • Real-time: Handle spotlight/instance-ping from existing SSE
  • Cleanup: Re-fetch every 10s to remove stale

Uses Existing SSE Connection!

No new connection needed. Reuses same /stream that handles errors/traces/logs.

Features:

  • Instance cards (port, command, uptime, status)
  • Status badges: healthy (green), unresponsive (yellow), orphaned (orange)
  • Connect button → switch instances
  • Terminate button (with confirmation)
  • Search/filter
  • Instance count badge in nav

7. Connection Switching (lib/connectionManager.ts)

No State Persistence:

Always start fresh when switching instances.

Switch Flow:

1. Disconnect from current sidecar
2. Clear/reset store state
3. Update sidecar URL to target port
4. Reconnect to new sidecar
5. Fetch fresh data from new instance

Simple and clean - just reconnect and start fresh!

8. File Structure

New:

server/registry/manager.ts, types.ts, utils.ts
server/cli/list.ts
server/routes/instances.ts
ui/control-center/[components]
ui/lib/connectionManager.ts

Modified:

server/cli/run.ts       # Add registration + ping
server/cli.ts           # Add list command
ui/App.tsx              # Integrate Control Center
ui/sidecar.ts           # Add handler for instance-ping events
package.json            # Add pidusage dependency

9. Implementation Steps

  1. Add pidusage dependency
  2. Registry infrastructure (manager, health checks with pidusage, terminate)
  3. Registration in cli/run.ts (metadata with start times + ping)
  4. CLI list command (all formatters)
  5. Sidecar API (endpoints + ping broadcast)
  6. UI store + connection manager (no persistence)
  7. Control Center UI components
  8. Cross-platform testing

Technical Notes

Security: Registry dir 0700, validate inputs, own instances only

Cross-platform: pidusage handles Linux/macOS/Windows differences

Performance: 1s healthcheck timeout, 500ms ping timeout, cache 10s

Errors: Graceful fallbacks, skip corrupted files, user-friendly UI

PID Reuse: Solved via start time verification with pidusage

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions