Skip to content

Commit 2630971

Browse files
committed
Add draggable toast with nerd mode live log viewer
JobStatusToast is now draggable via react-rnd and includes a terminal icon (>_) that toggles an expandable log panel. During execution, the panel shows a live tail of FireSTARR stdout/stderr via SSE. After completion, the full log is scrollable and searchable. Dismissing the toast clears the log. Backend: JobLogEmitter singleton bridges FireSTARREngine.onOutput to SSE subscribers. The /jobs/:id/stream route replays buffered lines on connect and streams new lines as event:log SSE events.
1 parent 0a11c07 commit 2630971

File tree

6 files changed

+382
-89
lines changed

6 files changed

+382
-89
lines changed

backend/src/api/routes/v1/jobs.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Router } from 'express';
22
import { asyncHandler } from '../../middleware/index.js';
33
import { createJobId } from '../../../domain/entities/index.js';
44
import { getJobQueue } from '../../../infrastructure/services/index.js';
5+
import { getJobLogEmitter } from '../../../infrastructure/services/JobLogEmitter.js';
56

67
const router = Router();
78

@@ -57,8 +58,29 @@ router.get(
5758

5859
sendStatus(initialResult.value);
5960

61+
// Subscribe to log lines for this model
62+
const logEmitter = getJobLogEmitter();
63+
const modelId = initialResult.value.modelId;
64+
65+
const sendLogLine = (line: string) => {
66+
try {
67+
res.write(`event: log\ndata: ${JSON.stringify({ line })}\n\n`);
68+
} catch {
69+
// Connection may be closed
70+
}
71+
};
72+
73+
// Replay buffered log lines (for late-connecting clients)
74+
for (const line of logEmitter.getBuffer(modelId)) {
75+
sendLogLine(line);
76+
}
77+
78+
// Subscribe to new log lines
79+
const unsubscribeLogs = logEmitter.subscribe(modelId, sendLogLine);
80+
6081
// If job is already terminal, close connection
6182
if (initialResult.value.isTerminal()) {
83+
unsubscribeLogs();
6284
res.write('event: complete\ndata: {}\n\n');
6385
res.end();
6486
return;
@@ -69,6 +91,7 @@ router.get(
6991
const result = await jobQueue.getJob(jobId);
7092
if (!result.success) {
7193
clearInterval(pollInterval);
94+
unsubscribeLogs();
7295
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Job not found' })}\n\n`);
7396
res.end();
7497
return;
@@ -80,6 +103,7 @@ router.get(
80103
// Check if job is terminal
81104
if (job.isTerminal()) {
82105
clearInterval(pollInterval);
106+
unsubscribeLogs();
83107
res.write('event: complete\ndata: {}\n\n');
84108
res.end();
85109
}
@@ -88,6 +112,7 @@ router.get(
88112
// Clean up on client disconnect
89113
req.on('close', () => {
90114
clearInterval(pollInterval);
115+
unsubscribeLogs();
91116
});
92117
})
93118
);

backend/src/infrastructure/firestarr/FireSTARREngine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
EngineCapabilities,
1515
} from '../../application/interfaces/IFireModelingEngine.js';
1616
import { IContainerExecutor, OutputCallback } from '../../application/interfaces/IContainerExecutor.js';
17+
import { getJobLogEmitter } from '../services/JobLogEmitter.js';
1718
import { IInputGenerator, InputGenerationResult } from '../../application/interfaces/IInputGenerator.js';
1819
import { IOutputParser, ParsedOutput } from '../../application/interfaces/IOutputParser.js';
1920
import {
@@ -190,6 +191,9 @@ export class FireSTARREngine implements IFireModelingEngine {
190191
} else {
191192
logger.info(line, 'FireSTARR:output');
192193
}
194+
195+
// Emit to SSE subscribers for live log streaming
196+
getJobLogEmitter().emit(modelId, line);
193197
};
194198

195199
// Build environment variables for native binary execution
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* JobLogEmitter — in-process pub/sub bridge for FireSTARR log lines.
3+
*
4+
* FireSTARREngine pushes stdout/stderr lines via emit().
5+
* SSE routes subscribe per connection to stream lines to the browser.
6+
* Buffers all lines per model for replay when a new SSE client connects.
7+
*/
8+
9+
type LogSubscriber = (line: string) => void;
10+
11+
class JobLogEmitter {
12+
private subscribers = new Map<string, Set<LogSubscriber>>();
13+
private buffers = new Map<string, string[]>();
14+
15+
/**
16+
* Emit a log line for a model. Buffers it and notifies all subscribers.
17+
*/
18+
emit(modelId: string, line: string): void {
19+
// Buffer
20+
let buffer = this.buffers.get(modelId);
21+
if (!buffer) {
22+
buffer = [];
23+
this.buffers.set(modelId, buffer);
24+
}
25+
buffer.push(line);
26+
27+
// Notify subscribers
28+
const subs = this.subscribers.get(modelId);
29+
if (subs) {
30+
for (const callback of subs) {
31+
try {
32+
callback(line);
33+
} catch {
34+
// Don't let a broken subscriber crash the engine
35+
}
36+
}
37+
}
38+
}
39+
40+
/**
41+
* Subscribe to log lines for a model. Returns an unsubscribe function.
42+
*/
43+
subscribe(modelId: string, callback: LogSubscriber): () => void {
44+
let subs = this.subscribers.get(modelId);
45+
if (!subs) {
46+
subs = new Set();
47+
this.subscribers.set(modelId, subs);
48+
}
49+
subs.add(callback);
50+
51+
return () => {
52+
subs!.delete(callback);
53+
if (subs!.size === 0) {
54+
this.subscribers.delete(modelId);
55+
}
56+
};
57+
}
58+
59+
/**
60+
* Get all buffered log lines for a model (for replay on SSE connect).
61+
*/
62+
getBuffer(modelId: string): string[] {
63+
return this.buffers.get(modelId) ?? [];
64+
}
65+
66+
/**
67+
* Clean up buffer and subscribers for a model.
68+
* Call after all SSE connections have closed.
69+
*/
70+
cleanup(modelId: string): void {
71+
this.buffers.delete(modelId);
72+
this.subscribers.delete(modelId);
73+
}
74+
}
75+
76+
// Singleton
77+
const jobLogEmitter = new JobLogEmitter();
78+
79+
export function getJobLogEmitter(): JobLogEmitter {
80+
return jobLogEmitter;
81+
}
82+
83+
export { JobLogEmitter };

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ function AppContent() {
126126
// Job notifications
127127
const {
128128
status: jobStatus,
129+
logLines,
129130
watchJob,
130131
stopWatching,
131132
requestPermission,
@@ -481,6 +482,7 @@ function AppContent() {
481482
{/* Job status toast */}
482483
<JobStatusToast
483484
status={jobStatus}
485+
logLines={logLines}
484486
onDismiss={handleDismissToast}
485487
onViewResults={handleViewResults}
486488
/>

0 commit comments

Comments
 (0)