-
-
Notifications
You must be signed in to change notification settings - Fork 31
Description
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
/streamconnection - 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:
-
Generate
instanceIdwithuuidv7() -
Get process start times using
pidusage:import pidusage from 'pidusage'; const stats = await pidusage(process.pid); const pidStartTime = stats.timestamp - stats.elapsed;
-
Write metadata atomically (temp file + rename)
-
Store spotlight PID + child PID with their start times
-
Ping Control Center:
- POST
http://localhost:8969/api/instances/ping - Timeout: 500ms
- Fire-and-forget (don't block if not running)
- POST
-
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 respondedunresponsive: Processes alive but healthcheck timeout (hung?)dead: Process(es) terminatedorphaned: 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
pidusagepackage 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: InstanceMetadataPing 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 type6. 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-pingfrom 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 instanceSimple 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
- Add
pidusagedependency - Registry infrastructure (manager, health checks with pidusage, terminate)
- Registration in cli/run.ts (metadata with start times + ping)
- CLI list command (all formatters)
- Sidecar API (endpoints + ping broadcast)
- UI store + connection manager (no persistence)
- Control Center UI components
- 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