@open-mercato/ui 0.6.6-develop.5588.1.a8f6c51d1f → 0.6.6-develop.5598.1.5e7d48d297
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -4,9 +4,16 @@ import { apiCall } from "../utils/apiCall.js";
|
|
|
4
4
|
import { useAppEvent } from "../injection/useAppEvent.js";
|
|
5
5
|
import { subscribeProgressUpdate } from "@open-mercato/shared/lib/frontend/progressEvents";
|
|
6
6
|
import { applyLocalProgressUpdate, isLocalProgressJob } from "./useProgressPoll.js";
|
|
7
|
+
const SSE_PROGRESS_SYNC_INTERVAL = 5e3;
|
|
7
8
|
function isVisibleProgressJob(job) {
|
|
8
9
|
return job.meta?.hiddenFromTopBar !== true;
|
|
9
10
|
}
|
|
11
|
+
function isActiveStatus(status) {
|
|
12
|
+
return status === "pending" || status === "running";
|
|
13
|
+
}
|
|
14
|
+
function isTerminalStatus(status) {
|
|
15
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
16
|
+
}
|
|
10
17
|
function upsertJob(list, job) {
|
|
11
18
|
if (!isVisibleProgressJob(job)) {
|
|
12
19
|
return list.filter((item) => item.id !== job.id);
|
|
@@ -28,11 +35,11 @@ function useProgressSse() {
|
|
|
28
35
|
);
|
|
29
36
|
if (result.ok && result.result) {
|
|
30
37
|
setActiveJobs((prev) => [
|
|
31
|
-
...prev.filter((job) => isLocalProgressJob(job) && (job.status
|
|
38
|
+
...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),
|
|
32
39
|
...result.result.active.filter(isVisibleProgressJob)
|
|
33
40
|
]);
|
|
34
41
|
setRecentlyCompleted((prev) => [
|
|
35
|
-
...prev.filter((job) => isLocalProgressJob(job) && (job.status
|
|
42
|
+
...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),
|
|
36
43
|
...result.result.recentlyCompleted.filter(isVisibleProgressJob)
|
|
37
44
|
].slice(0, 10));
|
|
38
45
|
setError(null);
|
|
@@ -49,6 +56,30 @@ function useProgressSse() {
|
|
|
49
56
|
React.useEffect(() => {
|
|
50
57
|
void fetchJobs();
|
|
51
58
|
}, [fetchJobs]);
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
let interval = setInterval(() => {
|
|
61
|
+
void fetchJobs();
|
|
62
|
+
}, SSE_PROGRESS_SYNC_INTERVAL);
|
|
63
|
+
const onVisibilityChange = () => {
|
|
64
|
+
if (document.hidden) {
|
|
65
|
+
if (interval) {
|
|
66
|
+
clearInterval(interval);
|
|
67
|
+
interval = null;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
void fetchJobs();
|
|
71
|
+
if (interval) clearInterval(interval);
|
|
72
|
+
interval = setInterval(() => {
|
|
73
|
+
void fetchJobs();
|
|
74
|
+
}, SSE_PROGRESS_SYNC_INTERVAL);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
78
|
+
return () => {
|
|
79
|
+
if (interval) clearInterval(interval);
|
|
80
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
81
|
+
};
|
|
82
|
+
}, [fetchJobs]);
|
|
52
83
|
React.useEffect(() => {
|
|
53
84
|
return subscribeProgressUpdate((detail) => {
|
|
54
85
|
applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted);
|
|
@@ -63,23 +94,30 @@ function useProgressSse() {
|
|
|
63
94
|
void fetchJobs();
|
|
64
95
|
return;
|
|
65
96
|
}
|
|
97
|
+
const status = payload.status ?? "running";
|
|
98
|
+
const job = {
|
|
99
|
+
id: jobId,
|
|
100
|
+
jobType: payload.jobType ?? "progress",
|
|
101
|
+
name: payload.name ?? payload.jobType ?? "Progress job",
|
|
102
|
+
description: payload.description ?? null,
|
|
103
|
+
meta: payload.meta && typeof payload.meta === "object" ? payload.meta : null,
|
|
104
|
+
status,
|
|
105
|
+
progressPercent: payload.progressPercent ?? 0,
|
|
106
|
+
processedCount: payload.processedCount ?? 0,
|
|
107
|
+
totalCount: payload.totalCount ?? null,
|
|
108
|
+
etaSeconds: payload.etaSeconds ?? null,
|
|
109
|
+
cancellable: payload.cancellable ?? false,
|
|
110
|
+
startedAt: payload.startedAt ?? null,
|
|
111
|
+
finishedAt: payload.finishedAt ?? null,
|
|
112
|
+
errorMessage: payload.errorMessage ?? null
|
|
113
|
+
};
|
|
114
|
+
if (isTerminalStatus(status)) {
|
|
115
|
+
setActiveJobs((prev) => prev.filter((item) => item.id !== jobId));
|
|
116
|
+
setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
66
119
|
setActiveJobs(
|
|
67
|
-
(prev) => upsertJob(prev,
|
|
68
|
-
id: jobId,
|
|
69
|
-
jobType: payload.jobType ?? "progress",
|
|
70
|
-
name: payload.name ?? payload.jobType ?? "Progress job",
|
|
71
|
-
description: payload.description ?? null,
|
|
72
|
-
meta: payload.meta && typeof payload.meta === "object" ? payload.meta : null,
|
|
73
|
-
status: payload.status ?? "running",
|
|
74
|
-
progressPercent: payload.progressPercent ?? 0,
|
|
75
|
-
processedCount: payload.processedCount ?? 0,
|
|
76
|
-
totalCount: payload.totalCount ?? null,
|
|
77
|
-
etaSeconds: payload.etaSeconds ?? null,
|
|
78
|
-
cancellable: payload.cancellable ?? false,
|
|
79
|
-
startedAt: payload.startedAt ?? null,
|
|
80
|
-
finishedAt: payload.finishedAt ?? null,
|
|
81
|
-
errorMessage: payload.errorMessage ?? null
|
|
82
|
-
})
|
|
120
|
+
(prev) => upsertJob(prev, job)
|
|
83
121
|
);
|
|
84
122
|
},
|
|
85
123
|
[fetchJobs]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/progress/useProgressSse.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'\nimport type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'\nimport { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'\n\nfunction isVisibleProgressJob(job: ProgressJobDto): boolean {\n return job.meta?.hiddenFromTopBar !== true\n}\n\nfunction upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {\n if (!isVisibleProgressJob(job)) {\n return list.filter((item) => item.id !== job.id)\n }\n const next = [job, ...list.filter((item) => item.id !== job.id)]\n return next.sort(\n (a, b) =>\n new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()\n - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),\n )\n}\n\nexport function useProgressSse(): UseProgressPollResult {\n const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])\n const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchJobs = React.useCallback(async () => {\n try {\n const result = await apiCall<{ active: ProgressJobDto[]; recentlyCompleted: ProgressJobDto[] }>(\n '/api/progress/active',\n )\n if (result.ok && result.result) {\n setActiveJobs((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && (job.status
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AACvB,SAAS,eAAe;AACxB,SAAS,mBAAmB;AAC5B,SAAS,+BAA+B;AAExC,SAAS,0BAA0B,0BAA0B;AAE7D,SAAS,qBAAqB,KAA8B;AAC1D,SAAO,IAAI,MAAM,qBAAqB;AACxC;AAEA,SAAS,UAAU,MAAwB,KAAuC;AAChF,MAAI,CAAC,qBAAqB,GAAG,GAAG;AAC9B,WAAO,KAAK,OAAO,CAAC,SAAS,KAAK,OAAO,IAAI,EAAE;AAAA,EACjD;AACA,QAAM,OAAO,CAAC,KAAK,GAAG,KAAK,OAAO,CAAC,SAAS,KAAK,OAAO,IAAI,EAAE,CAAC;AAC/D,SAAO,KAAK;AAAA,IACV,CAAC,GAAG,MACF,IAAI,KAAK,EAAE,aAAa,EAAE,cAAc,CAAC,EAAE,QAAQ,IACjD,IAAI,KAAK,EAAE,aAAa,EAAE,cAAc,CAAC,EAAE,QAAQ;AAAA,EACzD;AACF;AAEO,SAAS,iBAAwC;AACtD,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAA2B,CAAC,CAAC;AACvE,QAAM,CAAC,mBAAmB,oBAAoB,IAAI,MAAM,SAA2B,CAAC,CAAC;AACrF,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAE5D,QAAM,YAAY,MAAM,YAAY,YAAY;AAC9C,QAAI;AACF,YAAM,SAAS,MAAM;AAAA,QACnB;AAAA,MACF;AACA,UAAI,OAAO,MAAM,OAAO,QAAQ;AAC9B,sBAAc,CAAC,SAAS;AAAA,UACtB,GAAG,KAAK,OAAO,CAAC,QAAQ,mBAAmB,GAAG,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'\nimport type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'\nimport { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'\n\nconst SSE_PROGRESS_SYNC_INTERVAL = 5000\n\nfunction isVisibleProgressJob(job: ProgressJobDto): boolean {\n return job.meta?.hiddenFromTopBar !== true\n}\n\nfunction isActiveStatus(status: ProgressJobDto['status']): boolean {\n return status === 'pending' || status === 'running'\n}\n\nfunction isTerminalStatus(status: ProgressJobDto['status']): boolean {\n return status === 'completed' || status === 'failed' || status === 'cancelled'\n}\n\nfunction upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {\n if (!isVisibleProgressJob(job)) {\n return list.filter((item) => item.id !== job.id)\n }\n const next = [job, ...list.filter((item) => item.id !== job.id)]\n return next.sort(\n (a, b) =>\n new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()\n - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),\n )\n}\n\nexport function useProgressSse(): UseProgressPollResult {\n const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])\n const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchJobs = React.useCallback(async () => {\n try {\n const result = await apiCall<{ active: ProgressJobDto[]; recentlyCompleted: ProgressJobDto[] }>(\n '/api/progress/active',\n )\n if (result.ok && result.result) {\n setActiveJobs((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),\n ...result.result!.active.filter(isVisibleProgressJob),\n ])\n setRecentlyCompleted((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),\n ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),\n ].slice(0, 10))\n setError(null)\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch progress')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n let interval: ReturnType<typeof setInterval> | null = setInterval(() => {\n void fetchJobs()\n }, SSE_PROGRESS_SYNC_INTERVAL)\n\n const onVisibilityChange = () => {\n if (document.hidden) {\n if (interval) {\n clearInterval(interval)\n interval = null\n }\n } else {\n void fetchJobs()\n if (interval) clearInterval(interval)\n interval = setInterval(() => {\n void fetchJobs()\n }, SSE_PROGRESS_SYNC_INTERVAL)\n }\n }\n\n document.addEventListener('visibilitychange', onVisibilityChange)\n return () => {\n if (interval) clearInterval(interval)\n document.removeEventListener('visibilitychange', onVisibilityChange)\n }\n }, [fetchJobs])\n\n React.useEffect(() => {\n return subscribeProgressUpdate((detail) => {\n applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)\n })\n }, [])\n\n useAppEvent(\n 'progress.job.updated',\n (event) => {\n const payload = event.payload as Partial<ProgressJobDto> & { jobId?: string }\n const jobId = payload?.jobId\n if (!jobId) {\n void fetchJobs()\n return\n }\n const status = (payload.status as ProgressJobDto['status']) ?? 'running'\n const job: ProgressJobDto = {\n id: jobId,\n jobType: payload.jobType ?? 'progress',\n name: payload.name ?? payload.jobType ?? 'Progress job',\n description: payload.description ?? null,\n meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,\n status,\n progressPercent: payload.progressPercent ?? 0,\n processedCount: payload.processedCount ?? 0,\n totalCount: payload.totalCount ?? null,\n etaSeconds: payload.etaSeconds ?? null,\n cancellable: payload.cancellable ?? false,\n startedAt: payload.startedAt ?? null,\n finishedAt: payload.finishedAt ?? null,\n errorMessage: payload.errorMessage ?? null,\n }\n\n if (isTerminalStatus(status)) {\n setActiveJobs((prev) => prev.filter((item) => item.id !== jobId))\n setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10))\n return\n }\n\n setActiveJobs((prev) =>\n upsertJob(prev, job),\n )\n },\n [fetchJobs],\n )\n\n useAppEvent('progress.job.created', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.started', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.completed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.failed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.cancelled', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('om:bridge:reconnected', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n const onFocus = () => {\n void fetchJobs()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchJobs])\n\n return { activeJobs, recentlyCompleted, isLoading, error, refresh }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5598.1.5e7d48d297",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -154,13 +154,13 @@
|
|
|
154
154
|
"remark-gfm": "^4.0.1"
|
|
155
155
|
},
|
|
156
156
|
"peerDependencies": {
|
|
157
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
157
|
+
"@open-mercato/shared": "0.6.6-develop.5598.1.5e7d48d297",
|
|
158
158
|
"react": ">=18.0.0",
|
|
159
159
|
"react-dom": ">=18.0.0",
|
|
160
160
|
"react-is": ">=18.0.0"
|
|
161
161
|
},
|
|
162
162
|
"devDependencies": {
|
|
163
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
163
|
+
"@open-mercato/shared": "0.6.6-develop.5598.1.5e7d48d297",
|
|
164
164
|
"@testing-library/dom": "^10.4.1",
|
|
165
165
|
"@testing-library/jest-dom": "^6.9.1",
|
|
166
166
|
"@testing-library/react": "^16.3.1",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
2
|
+
import type { ProgressJobDto } from '../progress/useProgressPoll'
|
|
3
|
+
|
|
4
|
+
const mockApiCall = jest.fn()
|
|
5
|
+
jest.mock('../utils/apiCall', () => ({
|
|
6
|
+
apiCall: (...args: unknown[]) => mockApiCall(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const mockAppEventHandlers = new Map<string, Array<(event: { payload?: unknown }) => void>>()
|
|
10
|
+
jest.mock('../injection/useAppEvent', () => ({
|
|
11
|
+
useAppEvent: (eventId: string, handler: (event: { payload?: unknown }) => void) => {
|
|
12
|
+
const handlers = mockAppEventHandlers.get(eventId) ?? []
|
|
13
|
+
handlers.push(handler)
|
|
14
|
+
mockAppEventHandlers.set(eventId, handlers)
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('@open-mercato/shared/lib/frontend/progressEvents', () => ({
|
|
19
|
+
subscribeProgressUpdate: jest.fn(() => jest.fn()),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
import { useProgressSse } from '../progress/useProgressSse'
|
|
23
|
+
|
|
24
|
+
const runningJob: ProgressJobDto = {
|
|
25
|
+
id: 'job-1',
|
|
26
|
+
jobType: 'search.reindex.vector',
|
|
27
|
+
name: 'Search vector reindex',
|
|
28
|
+
description: 'Vector reindex catalog:catalog_product_variant (queued)',
|
|
29
|
+
status: 'running',
|
|
30
|
+
progressPercent: 0,
|
|
31
|
+
processedCount: 0,
|
|
32
|
+
totalCount: 0,
|
|
33
|
+
cancellable: true,
|
|
34
|
+
startedAt: '2026-06-15T16:37:01.382Z',
|
|
35
|
+
finishedAt: null,
|
|
36
|
+
errorMessage: null,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const completedJob: ProgressJobDto = {
|
|
40
|
+
...runningJob,
|
|
41
|
+
status: 'completed',
|
|
42
|
+
progressPercent: 100,
|
|
43
|
+
finishedAt: '2026-06-15T16:37:01.391Z',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mockProgressResponse(active: ProgressJobDto[], recentlyCompleted: ProgressJobDto[] = []) {
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
result: {
|
|
50
|
+
active,
|
|
51
|
+
recentlyCompleted,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('useProgressSse', () => {
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
jest.useRealTimers()
|
|
59
|
+
jest.clearAllMocks()
|
|
60
|
+
mockAppEventHandlers.clear()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('removes a job from activeJobs when an SSE update carries a terminal status', async () => {
|
|
64
|
+
mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
|
|
65
|
+
const { result } = renderHook(() => useProgressSse())
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
for (const handler of mockAppEventHandlers.get('progress.job.updated') ?? []) {
|
|
71
|
+
handler({ payload: { ...completedJob, jobId: completedJob.id } })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(result.current.activeJobs).toHaveLength(0)
|
|
76
|
+
expect(result.current.recentlyCompleted[0]).toEqual(expect.objectContaining({
|
|
77
|
+
id: 'job-1',
|
|
78
|
+
status: 'completed',
|
|
79
|
+
}))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('periodically reconciles active jobs in SSE mode when completion events are missed', async () => {
|
|
83
|
+
jest.useFakeTimers()
|
|
84
|
+
mockApiCall
|
|
85
|
+
.mockResolvedValueOnce(mockProgressResponse([runningJob]))
|
|
86
|
+
.mockResolvedValueOnce(mockProgressResponse([], [completedJob]))
|
|
87
|
+
|
|
88
|
+
const { result } = renderHook(() => useProgressSse())
|
|
89
|
+
|
|
90
|
+
await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
jest.advanceTimersByTime(5000)
|
|
94
|
+
await Promise.resolve()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(result.current.activeJobs).toHaveLength(0)
|
|
99
|
+
expect(result.current.recentlyCompleted[0]).toEqual(expect.objectContaining({
|
|
100
|
+
id: 'job-1',
|
|
101
|
+
status: 'completed',
|
|
102
|
+
}))
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -6,10 +6,20 @@ import { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progr
|
|
|
6
6
|
import type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'
|
|
7
7
|
import { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'
|
|
8
8
|
|
|
9
|
+
const SSE_PROGRESS_SYNC_INTERVAL = 5000
|
|
10
|
+
|
|
9
11
|
function isVisibleProgressJob(job: ProgressJobDto): boolean {
|
|
10
12
|
return job.meta?.hiddenFromTopBar !== true
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
function isActiveStatus(status: ProgressJobDto['status']): boolean {
|
|
16
|
+
return status === 'pending' || status === 'running'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isTerminalStatus(status: ProgressJobDto['status']): boolean {
|
|
20
|
+
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
function upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {
|
|
14
24
|
if (!isVisibleProgressJob(job)) {
|
|
15
25
|
return list.filter((item) => item.id !== job.id)
|
|
@@ -35,11 +45,11 @@ export function useProgressSse(): UseProgressPollResult {
|
|
|
35
45
|
)
|
|
36
46
|
if (result.ok && result.result) {
|
|
37
47
|
setActiveJobs((prev) => [
|
|
38
|
-
...prev.filter((job) => isLocalProgressJob(job) && (job.status
|
|
48
|
+
...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),
|
|
39
49
|
...result.result!.active.filter(isVisibleProgressJob),
|
|
40
50
|
])
|
|
41
51
|
setRecentlyCompleted((prev) => [
|
|
42
|
-
...prev.filter((job) => isLocalProgressJob(job) && (job.status
|
|
52
|
+
...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),
|
|
43
53
|
...result.result!.recentlyCompleted.filter(isVisibleProgressJob),
|
|
44
54
|
].slice(0, 10))
|
|
45
55
|
setError(null)
|
|
@@ -59,6 +69,33 @@ export function useProgressSse(): UseProgressPollResult {
|
|
|
59
69
|
void fetchJobs()
|
|
60
70
|
}, [fetchJobs])
|
|
61
71
|
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
let interval: ReturnType<typeof setInterval> | null = setInterval(() => {
|
|
74
|
+
void fetchJobs()
|
|
75
|
+
}, SSE_PROGRESS_SYNC_INTERVAL)
|
|
76
|
+
|
|
77
|
+
const onVisibilityChange = () => {
|
|
78
|
+
if (document.hidden) {
|
|
79
|
+
if (interval) {
|
|
80
|
+
clearInterval(interval)
|
|
81
|
+
interval = null
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
void fetchJobs()
|
|
85
|
+
if (interval) clearInterval(interval)
|
|
86
|
+
interval = setInterval(() => {
|
|
87
|
+
void fetchJobs()
|
|
88
|
+
}, SSE_PROGRESS_SYNC_INTERVAL)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
93
|
+
return () => {
|
|
94
|
+
if (interval) clearInterval(interval)
|
|
95
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
96
|
+
}
|
|
97
|
+
}, [fetchJobs])
|
|
98
|
+
|
|
62
99
|
React.useEffect(() => {
|
|
63
100
|
return subscribeProgressUpdate((detail) => {
|
|
64
101
|
applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)
|
|
@@ -74,23 +111,32 @@ export function useProgressSse(): UseProgressPollResult {
|
|
|
74
111
|
void fetchJobs()
|
|
75
112
|
return
|
|
76
113
|
}
|
|
114
|
+
const status = (payload.status as ProgressJobDto['status']) ?? 'running'
|
|
115
|
+
const job: ProgressJobDto = {
|
|
116
|
+
id: jobId,
|
|
117
|
+
jobType: payload.jobType ?? 'progress',
|
|
118
|
+
name: payload.name ?? payload.jobType ?? 'Progress job',
|
|
119
|
+
description: payload.description ?? null,
|
|
120
|
+
meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,
|
|
121
|
+
status,
|
|
122
|
+
progressPercent: payload.progressPercent ?? 0,
|
|
123
|
+
processedCount: payload.processedCount ?? 0,
|
|
124
|
+
totalCount: payload.totalCount ?? null,
|
|
125
|
+
etaSeconds: payload.etaSeconds ?? null,
|
|
126
|
+
cancellable: payload.cancellable ?? false,
|
|
127
|
+
startedAt: payload.startedAt ?? null,
|
|
128
|
+
finishedAt: payload.finishedAt ?? null,
|
|
129
|
+
errorMessage: payload.errorMessage ?? null,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isTerminalStatus(status)) {
|
|
133
|
+
setActiveJobs((prev) => prev.filter((item) => item.id !== jobId))
|
|
134
|
+
setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
77
138
|
setActiveJobs((prev) =>
|
|
78
|
-
upsertJob(prev,
|
|
79
|
-
id: jobId,
|
|
80
|
-
jobType: payload.jobType ?? 'progress',
|
|
81
|
-
name: payload.name ?? payload.jobType ?? 'Progress job',
|
|
82
|
-
description: payload.description ?? null,
|
|
83
|
-
meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,
|
|
84
|
-
status: (payload.status as ProgressJobDto['status']) ?? 'running',
|
|
85
|
-
progressPercent: payload.progressPercent ?? 0,
|
|
86
|
-
processedCount: payload.processedCount ?? 0,
|
|
87
|
-
totalCount: payload.totalCount ?? null,
|
|
88
|
-
etaSeconds: payload.etaSeconds ?? null,
|
|
89
|
-
cancellable: payload.cancellable ?? false,
|
|
90
|
-
startedAt: payload.startedAt ?? null,
|
|
91
|
-
finishedAt: payload.finishedAt ?? null,
|
|
92
|
-
errorMessage: payload.errorMessage ?? null,
|
|
93
|
-
}),
|
|
139
|
+
upsertJob(prev, job),
|
|
94
140
|
)
|
|
95
141
|
},
|
|
96
142
|
[fetchJobs],
|