From d2a9b919f6e48bb8136265a60977b9cada29bc82 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:24:22 +0200 Subject: [PATCH 01/15] feat: add eventBus singleton for run event broadcasting --- server/src/services/eventBus.js | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 server/src/services/eventBus.js diff --git a/server/src/services/eventBus.js b/server/src/services/eventBus.js new file mode 100644 index 0000000..948a192 --- /dev/null +++ b/server/src/services/eventBus.js @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { EventEmitter } from 'events' + +export const eventBus = new EventEmitter() From 80ad4be274d0abc78d9e0be6d20c070f9ceab912 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:26:54 +0200 Subject: [PATCH 02/15] fix: remove EventEmitter listener limit for SSE scaling --- server/src/services/eventBus.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/services/eventBus.js b/server/src/services/eventBus.js index 948a192..0409875 100644 --- a/server/src/services/eventBus.js +++ b/server/src/services/eventBus.js @@ -6,3 +6,5 @@ import { EventEmitter } from 'events' export const eventBus = new EventEmitter() +eventBus.setMaxListeners(0) + From a8105bffee6095a025dcb77017c7932dc0f812eb Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:29:38 +0200 Subject: [PATCH 03/15] feat: accept gateway token from query param for EventSource compatibility --- server/src/middleware/gatewayToken.js | 2 +- server/tests/gatewayToken.test.js | 46 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 server/tests/gatewayToken.test.js diff --git a/server/src/middleware/gatewayToken.js b/server/src/middleware/gatewayToken.js index 908b68e..9f8df4a 100644 --- a/server/src/middleware/gatewayToken.js +++ b/server/src/middleware/gatewayToken.js @@ -9,7 +9,7 @@ export function gatewayTokenMiddleware(req, res, next) { const token = process.env.GATEWAY_TOKEN if (!token) return next() - const provided = req.headers['x-gateway-token'] + const provided = req.headers['x-gateway-token'] || req.query.token || '' if (!provided) { return res.status(401).json({ error: 'Unauthorized: invalid or missing gateway token' }) } diff --git a/server/tests/gatewayToken.test.js b/server/tests/gatewayToken.test.js new file mode 100644 index 0000000..0316ada --- /dev/null +++ b/server/tests/gatewayToken.test.js @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import request from 'supertest' +import { makeDb, makeApp } from './setup.js' + +let app + +beforeEach(() => { + makeDb() + app = makeApp() + process.env.GATEWAY_TOKEN = 'secret123' +}) + +afterEach(() => { + delete process.env.GATEWAY_TOKEN +}) + +describe('gatewayTokenMiddleware with query param', () => { + it('accepts valid token from x-gateway-token header', async () => { + const res = await request(app) + .get('/api/jobs') + .set('X-Gateway-Token', 'secret123') + expect(res.status).toBe(200) + }) + + it('accepts valid token from ?token query param', async () => { + const res = await request(app) + .get('/api/jobs?token=secret123') + expect(res.status).toBe(200) + }) + + it('rejects wrong token from query param', async () => { + const res = await request(app) + .get('/api/jobs?token=wrong') + expect(res.status).toBe(401) + }) + + it('rejects request with no token at all', async () => { + const res = await request(app).get('/api/jobs') + expect(res.status).toBe(401) + }) +}) From eeafd053338f37a300378a280c8108029526682f Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:32:03 +0200 Subject: [PATCH 04/15] docs: add comment explaining query param token fallback for EventSource --- server/src/middleware/gatewayToken.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/middleware/gatewayToken.js b/server/src/middleware/gatewayToken.js index 9f8df4a..4eca091 100644 --- a/server/src/middleware/gatewayToken.js +++ b/server/src/middleware/gatewayToken.js @@ -9,6 +9,7 @@ export function gatewayTokenMiddleware(req, res, next) { const token = process.env.GATEWAY_TOKEN if (!token) return next() + // EventSource cannot set custom headers, so SSE clients send the token as ?token=... const provided = req.headers['x-gateway-token'] || req.query.token || '' if (!provided) { return res.status(401).json({ error: 'Unauthorized: invalid or missing gateway token' }) From 98dcadeec8ca3e14f12ffe4491cbc28b2ecd6b3b Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:33:31 +0200 Subject: [PATCH 05/15] feat: emit run:started and run:finished events on the eventBus --- server/src/scheduler/executor.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/scheduler/executor.js b/server/src/scheduler/executor.js index c091ec6..6427f8a 100644 --- a/server/src/scheduler/executor.js +++ b/server/src/scheduler/executor.js @@ -7,6 +7,7 @@ import { spawn } from 'child_process' import { getDb } from '../db/index.js' import { send as ntfySend } from '../services/ntfy.js' import { logger } from '../logger.js' +import { eventBus } from '../services/eventBus.js' const MAX_OUTPUT_BYTES = 512 * 1024 @@ -21,6 +22,7 @@ export function run(job, triggeredBy = 'scheduler') { const runId = runResult.lastInsertRowid logger.info({ jobId: job.id, runId, triggeredBy }, `Job "${job.name}" started`) + eventBus.emit('run:started', { jobId: job.id, runId, triggeredBy }) const child = job.command_type === 'inline' ? spawn('/bin/sh', ['-c', job.command], { stdio: ['ignore', 'pipe', 'pipe'] }) @@ -67,13 +69,12 @@ export function run(job, triggeredBy = 'scheduler') { WHERE id = ? `).run(status, exitCode, stdout, stderr, duration, runId) - const maxRuns = Number(process.env.KEEP_MAX_FOR_HISTORY) || 5 db.prepare(` DELETE FROM runs WHERE job_id = ? AND id NOT IN ( SELECT id FROM runs WHERE job_id = ? ORDER BY id DESC LIMIT ? ) - `).run(job.id, job.id, maxRuns) + `).run(job.id, job.id, Number(process.env.KEEP_MAX_FOR_HISTORY) || 5) if (status === 'error' && job.ntfy_enabled && job.ntfy_on_error) { ntfySend(job, { status, exitCode, stderr }).catch(() => {}) @@ -81,6 +82,7 @@ export function run(job, triggeredBy = 'scheduler') { ntfySend(job, { status, exitCode }).catch(() => {}) } + eventBus.emit('run:finished', { jobId: job.id, runId, status, exitCode, duration_ms: duration }) logger.info({ jobId: job.id, runId, status, exitCode, duration }, `Job "${job.name}" finished`) }) From 34d012d8153b7ec2cece4faa645558e66de028a2 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:42:46 +0200 Subject: [PATCH 06/15] feat: add GET /api/events SSE endpoint for real-time run status Co-Authored-By: Claude Sonnet 4.6 --- server/src/app.js | 32 ++++++++++++++ server/tests/events.test.js | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 server/tests/events.test.js diff --git a/server/src/app.js b/server/src/app.js index dc81916..5ba9b1e 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -10,6 +10,7 @@ import { createJobsRouter } from './routes/jobs.js' import { createRunsRouter } from './routes/runs.js' import { errorHandler } from './middleware/errorHandler.js' import { gatewayTokenMiddleware } from './middleware/gatewayToken.js' +import { eventBus } from './services/eventBus.js' const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) @@ -32,6 +33,37 @@ export function createApp(scheduler) { app.use('/api/jobs', createJobsRouter(scheduler)) app.use('/api/runs', createRunsRouter()) + app.get('/api/events', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.flushHeaders() + + function send(eventName, data) { + res.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`) + } + + const onStarted = (data) => send('run:started', data) + const onFinished = (data) => send('run:finished', data) + + eventBus.on('run:started', onStarted) + eventBus.on('run:finished', onFinished) + + let cleaned = false + function cleanup() { + if (cleaned) return + cleaned = true + eventBus.off('run:started', onStarted) + eventBus.off('run:finished', onFinished) + } + + req.on('close', cleanup) + if (res.socket) { + res.socket.on('end', cleanup) + res.socket.on('close', cleanup) + } + }) + app.get('/api/validate-path', (req, res) => { const filePath = req.query.path || '' if (!filePath.trim()) return res.json({ exists: false, isFile: false, executable: false }) diff --git a/server/tests/events.test.js b/server/tests/events.test.js new file mode 100644 index 0000000..334217d --- /dev/null +++ b/server/tests/events.test.js @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import http from 'http' +import { makeApp } from './setup.js' +import { eventBus } from '../src/services/eventBus.js' + +let server +let port + +beforeEach(() => { + server = makeApp().listen(0) + port = server.address().port +}) + +afterEach(async () => { + await new Promise(r => server.close(r)) +}) + +function connectSSE() { + return new Promise((resolve, reject) => { + const req = http.get(`http://localhost:${port}/api/events`, (res) => { + resolve({ req, res }) + }) + req.on('error', (e) => { if (e.code !== 'ECONNRESET') reject(e) }) + setTimeout(() => reject(new Error('SSE connect timeout')), 2000) + }) +} + +function collectData(res, count = 1) { + return new Promise((resolve) => { + const chunks = [] + res.on('data', (chunk) => { + chunks.push(chunk.toString()) + if (chunks.length >= count) resolve(chunks.join('')) + }) + }) +} + +describe('GET /api/events', () => { + it('responds with SSE headers', async () => { + const { req, res } = await connectSSE() + expect(res.headers['content-type']).toBe('text/event-stream') + expect(res.headers['cache-control']).toBe('no-cache') + req.destroy() + }) + + it('sends run:started event when bus emits', async () => { + const { req, res } = await connectSSE() + const dataPromise = collectData(res) + await new Promise(r => setTimeout(r, 30)) + eventBus.emit('run:started', { jobId: 1, runId: 42, triggeredBy: 'manual' }) + const raw = await dataPromise + expect(raw).toContain('event: run:started') + expect(raw).toContain('"jobId":1') + expect(raw).toContain('"runId":42') + req.destroy() + }) + + it('sends run:finished event when bus emits', async () => { + const { req, res } = await connectSSE() + const dataPromise = collectData(res) + await new Promise(r => setTimeout(r, 30)) + eventBus.emit('run:finished', { jobId: 2, runId: 7, status: 'success', exitCode: 0, duration_ms: 500 }) + const raw = await dataPromise + expect(raw).toContain('event: run:finished') + expect(raw).toContain('"status":"success"') + req.destroy() + }) + + it('removes bus listeners when client disconnects', async () => { + const listenersBefore = eventBus.listenerCount('run:started') + const { req, res } = await connectSSE() + expect(eventBus.listenerCount('run:started')).toBe(listenersBefore + 1) + req.destroy() + res.resume() + await new Promise(r => setTimeout(r, 50)) + expect(eventBus.listenerCount('run:started')).toBe(listenersBefore) + }) +}) From 5dfce705e433adf04b8572f4e89811b4e887f334 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:46:47 +0200 Subject: [PATCH 07/15] fix: guard SSE writes against closed socket, add Nginx buffering header, improve test timeout --- server/src/app.js | 5 ++++- server/tests/events.test.js | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/src/app.js b/server/src/app.js index 5ba9b1e..1d94daf 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -37,10 +37,13 @@ export function createApp(scheduler) { res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') res.flushHeaders() function send(eventName, data) { - res.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`) + if (!res.writableEnded) { + res.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`) + } } const onStarted = (data) => send('run:started', data) diff --git a/server/tests/events.test.js b/server/tests/events.test.js index 334217d..285e6a7 100644 --- a/server/tests/events.test.js +++ b/server/tests/events.test.js @@ -30,12 +30,13 @@ function connectSSE() { }) } -function collectData(res, count = 1) { - return new Promise((resolve) => { +function collectData(res, count = 1, timeoutMs = 2000) { + return new Promise((resolve, reject) => { const chunks = [] + const timer = setTimeout(() => reject(new Error('collectData timeout')), timeoutMs) res.on('data', (chunk) => { chunks.push(chunk.toString()) - if (chunks.length >= count) resolve(chunks.join('')) + if (chunks.length >= count) { clearTimeout(timer); resolve(chunks.join('')) } }) }) } From a489f94b4a731144b771ec61d037ba0b92e5bca7 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:47:51 +0200 Subject: [PATCH 08/15] feat: add useJobEvents hook for SSE subscription --- client/src/hooks/useJobEvents.js | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 client/src/hooks/useJobEvents.js diff --git a/client/src/hooks/useJobEvents.js b/client/src/hooks/useJobEvents.js new file mode 100644 index 0000000..e64bce0 --- /dev/null +++ b/client/src/hooks/useJobEvents.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { useEffect, useRef, useState } from 'react' + +const BASE = import.meta.env.VITE_API_BASE ?? '/api' + +function getToken() { + return new URLSearchParams(window.location.search).get('token') ?? '' +} + +export function useJobEvents({ onRunStarted, onRunFinished }) { + const [connected, setConnected] = useState(false) + const onStartedRef = useRef(onRunStarted) + const onFinishedRef = useRef(onRunFinished) + + useEffect(() => { + onStartedRef.current = onRunStarted + onFinishedRef.current = onRunFinished + }) + + useEffect(() => { + const token = getToken() + const url = `${BASE}/events${token ? `?token=${encodeURIComponent(token)}` : ''}` + const es = new EventSource(url) + + es.onopen = () => setConnected(true) + es.onerror = () => setConnected(false) + es.addEventListener('run:started', (e) => onStartedRef.current(JSON.parse(e.data))) + es.addEventListener('run:finished', (e) => onFinishedRef.current(JSON.parse(e.data))) + + return () => { + es.close() + setConnected(false) + } + }, []) + + return { connected } +} From d74ad5caa1cff700d634d586415442ca0c8b4d25 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:50:21 +0200 Subject: [PATCH 09/15] fix: add JSON.parse guard and clarifying comments to useJobEvents --- client/src/hooks/useJobEvents.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/src/hooks/useJobEvents.js b/client/src/hooks/useJobEvents.js index e64bce0..7aa152e 100644 --- a/client/src/hooks/useJobEvents.js +++ b/client/src/hooks/useJobEvents.js @@ -16,6 +16,7 @@ export function useJobEvents({ onRunStarted, onRunFinished }) { const onStartedRef = useRef(onRunStarted) const onFinishedRef = useRef(onRunFinished) + // Sync latest callbacks each render so the EventSource effect below never goes stale useEffect(() => { onStartedRef.current = onRunStarted onFinishedRef.current = onRunFinished @@ -27,9 +28,14 @@ export function useJobEvents({ onRunStarted, onRunFinished }) { const es = new EventSource(url) es.onopen = () => setConnected(true) + // EventSource auto-retries on transient errors; connected returns to true on onopen es.onerror = () => setConnected(false) - es.addEventListener('run:started', (e) => onStartedRef.current(JSON.parse(e.data))) - es.addEventListener('run:finished', (e) => onFinishedRef.current(JSON.parse(e.data))) + es.addEventListener('run:started', (e) => { + try { onStartedRef.current(JSON.parse(e.data)) } catch { /* malformed payload */ } + }) + es.addEventListener('run:finished', (e) => { + try { onFinishedRef.current(JSON.parse(e.data)) } catch { /* malformed payload */ } + }) return () => { es.close() From 24776a746fac767381e19d9f441e61758b0e2576 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:51:33 +0200 Subject: [PATCH 10/15] feat: expose updateRunStarted/updateRunFinished from useJobs for SSE updates --- client/src/hooks/useJobs.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/src/hooks/useJobs.js b/client/src/hooks/useJobs.js index a9df4c5..2594333 100644 --- a/client/src/hooks/useJobs.js +++ b/client/src/hooks/useJobs.js @@ -56,5 +56,18 @@ export function useJobs() { return res.data }, []) - return { jobs, isLoading, error, createJob, updateJob, deleteJob, toggleJob, triggerRun, refresh: fetchJobs } + const updateRunStarted = useCallback(({ jobId }) => { + setJobs(prev => prev.map(j => j.id === jobId ? { ...j, last_run_status: 'running' } : j)) + }, []) + + const updateRunFinished = useCallback(({ jobId, status }) => { + setJobs(prev => prev.map(j => j.id === jobId ? { ...j, last_run_status: status } : j)) + }, []) + + return { + jobs, isLoading, error, + createJob, updateJob, deleteJob, toggleJob, triggerRun, + refresh: fetchJobs, + updateRunStarted, updateRunFinished, + } } From fbc8af4dbc1dff453f6a4ef2b66912b20ea2a6fd Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:51:36 +0200 Subject: [PATCH 11/15] feat: expose handleRunFinished from useRunHistory for SSE-driven refresh --- client/src/hooks/useRunHistory.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/src/hooks/useRunHistory.js b/client/src/hooks/useRunHistory.js index 9fc274d..6ccfd90 100644 --- a/client/src/hooks/useRunHistory.js +++ b/client/src/hooks/useRunHistory.js @@ -45,5 +45,14 @@ export function useRunHistory(jobId) { fetchRuns(offset + limit) }, [fetchRuns, offset, limit]) - return { runs, total, isLoading, error, loadMore, hasMore: runs.length < total, refresh: () => fetchRuns(0) } + const handleRunFinished = useCallback((data) => { + if (data.jobId === jobId) fetchRuns(0) + }, [jobId, fetchRuns]) + + return { + runs, total, isLoading, error, + loadMore, hasMore: runs.length < total, + refresh: () => fetchRuns(0), + handleRunFinished, + } } From 0b022c2a9977c88cc3440e2bdae0542a7884a2d9 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:54:36 +0200 Subject: [PATCH 12/15] docs: comment pagination reset intent in handleRunFinished --- client/src/hooks/useRunHistory.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/hooks/useRunHistory.js b/client/src/hooks/useRunHistory.js index 6ccfd90..1f8dc9f 100644 --- a/client/src/hooks/useRunHistory.js +++ b/client/src/hooks/useRunHistory.js @@ -45,6 +45,7 @@ export function useRunHistory(jobId) { fetchRuns(offset + limit) }, [fetchRuns, offset, limit]) + // Intentionally resets to page 1 so the new run appears at the top const handleRunFinished = useCallback((data) => { if (data.jobId === jobId) fetchRuns(0) }, [jobId, fetchRuns]) From 39d2f336c8c809ae61f0f9eadfd3fb29d03cb2f6 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:56:35 +0200 Subject: [PATCH 13/15] feat: wire SSE events to job list and run history panel via useJobEvents Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.jsx | 35 +++++++++++++++++-- .../src/components/runs/RunHistoryPanel.jsx | 5 +-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index e868cec..d4ac60a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -11,6 +11,8 @@ import { DeleteConfirm } from './components/jobs/DeleteConfirm.jsx' import { RunHistoryPanel } from './components/runs/RunHistoryPanel.jsx' import { UnauthorizedPage } from './components/UnauthorizedPage.jsx' import { useJobs } from './hooks/useJobs.js' +import { useRunHistory } from './hooks/useRunHistory.js' +import { useJobEvents } from './hooks/useJobEvents.js' import { useToast } from './components/ui/Toast.jsx' export default function App() { @@ -27,17 +29,34 @@ export default function App() { .catch(() => setAuthState('ok')) }, []) - const { jobs, isLoading, error, createJob, updateJob, deleteJob, toggleJob, triggerRun } = useJobs() + const { + jobs, isLoading, error, + createJob, updateJob, deleteJob, toggleJob, triggerRun, + updateRunStarted, updateRunFinished, + } = useJobs() const { addToast } = useToast() const [dialogState, setDialogState] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [historyJob, setHistoryJob] = useState(null) + const runHistory = useRunHistory(historyJob?.id) + + useJobEvents({ + onRunStarted: updateRunStarted, + onRunFinished: (data) => { + updateRunFinished(data) + runHistory.handleRunFinished(data) + }, + }) + const handleNew = () => setDialogState({ mode: 'create' }) const handleEdit = (job) => setDialogState({ mode: 'edit', job }) const handleDelete = (job) => setDeleteTarget(job) - const handleHistory = (job) => setHistoryJob(job) + const handleHistory = (job) => { + setHistoryJob(job) + runHistory.refresh() + } const handleSave = async (formData) => { if (dialogState.mode === 'create') { @@ -72,7 +91,17 @@ export default function App() { if (authState === 'unauthorized') return const sidebar = historyJob ? ( - setHistoryJob(null)} /> + setHistoryJob(null)} + /> ) : null return ( diff --git a/client/src/components/runs/RunHistoryPanel.jsx b/client/src/components/runs/RunHistoryPanel.jsx index b5e97a0..b5ba26b 100644 --- a/client/src/components/runs/RunHistoryPanel.jsx +++ b/client/src/components/runs/RunHistoryPanel.jsx @@ -4,16 +4,13 @@ */ import { X, RefreshCw, History } from 'lucide-react' -import { useRunHistory } from '../../hooks/useRunHistory.js' import { RunRecord } from './RunRecord.jsx' import { LoadingSpinner } from '../ui/LoadingSpinner.jsx' import { EmptyState } from '../ui/EmptyState.jsx' import { Button } from '../ui/Button.jsx' import { Tooltip } from '../ui/Tooltip.jsx' -export function RunHistoryPanel({ job, onClose }) { - const { runs, total, isLoading, error, loadMore, hasMore, refresh } = useRunHistory(job?.id) - +export function RunHistoryPanel({ job, runs, total, isLoading, error, loadMore, hasMore, refresh, onClose }) { return (
From 1271f9a6fd1b9f88169ca1592e4520dbe0643777 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 19:59:44 +0200 Subject: [PATCH 14/15] docs: clarify refresh() intent in handleHistory --- client/src/App.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/App.jsx b/client/src/App.jsx index d4ac60a..1af9aab 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -55,6 +55,8 @@ export default function App() { const handleDelete = (job) => setDeleteTarget(job) const handleHistory = (job) => { setHistoryJob(job) + // When the same job is re-selected, jobId doesn't change so useRunHistory's + // useEffect won't re-fire. refresh() handles that case (no-op when jobId is null). runHistory.refresh() } From a5e69ec14c80e7261bd969c861a0d83c472dbd72 Mon Sep 17 00:00:00 2001 From: Christian Kellner Date: Tue, 28 Apr 2026 20:03:51 +0200 Subject: [PATCH 15/15] fix: only refresh run history when same job is re-selected, not on job switch --- client/src/App.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 1af9aab..3f35059 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -54,10 +54,12 @@ export default function App() { const handleEdit = (job) => setDialogState({ mode: 'edit', job }) const handleDelete = (job) => setDeleteTarget(job) const handleHistory = (job) => { - setHistoryJob(job) - // When the same job is re-selected, jobId doesn't change so useRunHistory's - // useEffect won't re-fire. refresh() handles that case (no-op when jobId is null). - runHistory.refresh() + setHistoryJob(prev => { + // When the same job is re-selected the jobId doesn't change, so useRunHistory's + // useEffect won't re-fire. Refresh explicitly for that case only. + if (prev?.id === job.id) runHistory.refresh() + return job + }) } const handleSave = async (formData) => {