-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Expand file tree
/
Copy pathOperationsContext.jsx
More file actions
252 lines (227 loc) · 9.25 KB
/
Copy pathOperationsContext.jsx
File metadata and controls
252 lines (227 loc) · 9.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { operationsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
// Serialize ops into a stable comparison key. Each op is a flat map of
// primitives, so JSON.stringify is good enough and stable as long as the
// server emits keys in the same order (Go's map iteration into JSON happens
// to be stable here because we build an explicit map[string]any).
function serializeOps(ops) {
return JSON.stringify(ops)
}
const OperationsContext = createContext(null)
// How long a cancelled job is remembered. It only has to outlive the poll that
// notices the operation left the list; a session that cancels all day must not
// accumulate job IDs.
const CANCELLED_MEMORY_MS = 60_000
// Single shared poller for /api/operations. Before this provider existed,
// each useOperations() call ran its own setInterval; with OperationsBar
// always mounted plus the per-page consumers (Models, Backends, Chat), the
// browser was firing 2-3 polls per second against the API for the lifetime
// of the session.
export function OperationsProvider({ children, pollInterval = 1000 }) {
const [operations, setOperations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [history, setHistory] = useState([])
const [historyLoading, setHistoryLoading] = useState(false)
const { isAdmin } = useAuth()
const intervalRef = useRef(null)
const lastSerializedRef = useRef('[]')
const liveIDsRef = useRef(new Set())
// Jobs cancelled from this tab, by job ID. The cancel endpoint removes the
// operation immediately, so on the next poll the only thing the UI can
// observe is that the operation is gone, which is exactly what finishing
// looks like. Nothing in the payload distinguishes them (a cancelled
// operation is never listed), so the side that issued the cancel is the only
// one that can remember it.
const cancelledRef = useRef(new Map())
// History is fetched on demand, never on the poll interval: it only changes
// when an operation finishes, and the Activity page is the only consumer.
const fetchHistory = useCallback(async () => {
if (!isAdmin) return
setHistoryLoading(true)
try {
const data = await operationsApi.history()
setHistory(data?.operations || [])
} catch (err) {
setError((prev) => (prev === err.message ? prev : err.message))
} finally {
setHistoryLoading(false)
}
}, [isAdmin])
const clearHistory = useCallback(async () => {
try {
await operationsApi.clearHistory()
setHistory([])
} catch (err) {
setError(err.message)
}
}, [])
const fetchOperations = useCallback(async () => {
if (!isAdmin) {
setLoading((prev) => (prev ? false : prev))
return
}
try {
const data = await operationsApi.list()
const ops = data?.operations || (Array.isArray(data) ? data : [])
const serialized = serializeOps(ops)
if (serialized !== lastSerializedRef.current) {
lastSerializedRef.current = serialized
setOperations(ops)
}
// An operation leaving the live list is the one moment the record can
// have changed. Refetching here keeps the page correct without polling
// a second endpoint every second.
//
// Tracked by identity rather than by count: during a batch install one
// operation finishing in the same second another starts leaves the
// length unchanged, and a count comparison would miss the completion.
const liveIDs = new Set(ops.map((op) => op.jobID || op.id))
let departed = false
for (const id of liveIDsRef.current) {
if (!liveIDs.has(id)) {
departed = true
break
}
}
liveIDsRef.current = liveIDs
if (departed) {
fetchHistory()
}
const cutoff = Date.now() - CANCELLED_MEMORY_MS
for (const [id, at] of cancelledRef.current) {
if (at < cutoff) cancelledRef.current.delete(id)
}
setError((prev) => (prev === null ? prev : null))
} catch (err) {
setError((prev) => (prev === err.message ? prev : err.message))
} finally {
setLoading((prev) => (prev ? false : prev))
}
}, [isAdmin, fetchHistory])
useEffect(() => {
if (!isAdmin) return
fetchOperations()
intervalRef.current = setInterval(fetchOperations, pollInterval)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [fetchOperations, pollInterval, isAdmin])
const cancelOperation = useCallback(async (jobID) => {
try {
await operationsApi.cancel(jobID)
// Recorded before the refetch: that refetch is the one that sees the
// operation gone, and a consumer reacting to the disappearance has to
// find the cancel already remembered or it will call it a success.
cancelledRef.current.set(jobID, Date.now())
await fetchOperations()
} catch (err) {
setError(err.message)
}
}, [fetchOperations])
const pauseOperation = useCallback(async (jobID) => {
try {
await operationsApi.pause(jobID)
cancelledRef.current.set(jobID, Date.now())
await fetchOperations()
} catch (err) {
setError(err.message)
}
}, [fetchOperations])
// Whether this tab cancelled the job. Read by the strip to tell "the last
// operation finished" from "the user called it off": both look identical in
// /api/operations, which lists neither.
const wasCancelled = useCallback((jobID) => cancelledRef.current.has(jobID), [])
// Takes the jobID, never the display id. /api/operations strips the
// "node:<nodeID>:" prefix before emitting, so a local install and a
// node-scoped install of the same backend arrive as two distinct jobs
// sharing one id: looking the job up by id could dismiss the wrong one,
// leaving the failure the user acted on live and silently retiring another.
const dismissFailedOp = useCallback(async (jobID) => {
if (!jobID) return
try {
await operationsApi.dismiss(jobID)
await fetchOperations()
} catch {
// Ignore dismiss errors
}
}, [fetchOperations])
// Time remaining is derived, not reported. We keep the previous
// (bytes, timestamp) sample per job and estimate from the delta.
//
// All or nothing on purpose: an estimate needs two samples, and one card
// showing "11 min left" while its neighbours show nothing reads as a
// rendering bug rather than as missing data.
const samplesRef = useRef(new Map())
const operationsWithEta = useMemo(() => {
const now = Date.now()
const samples = samplesRef.current
const seen = new Set()
const withEta = operations.map((op) => {
const key = op.jobID || op.id
seen.add(key)
const current = op.currentBytes
const total = op.totalBytes
if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) return op
const previous = samples.get(key)
samples.set(key, { bytes: current, at: now })
if (!previous || current <= previous.bytes) return op
const bytesPerMs = (current - previous.bytes) / Math.max(1, now - previous.at)
if (bytesPerMs <= 0) return op
return { ...op, etaSeconds: Math.round((total - current) / bytesPerMs / 1000) }
})
// Drop samples for jobs that finished, so the map cannot grow forever.
for (const key of samples.keys()) {
if (!seen.has(key)) samples.delete(key)
}
// All or nothing: if any operation still transferring has no estimate yet,
// nobody shows one this tick.
//
// Only operations actually downloading get a vote. Every other phase
// reports bytes but stops advancing them: verifying hashes a finished file
// while the counter sits below the multi-file total, and committing sits
// pinned at the total. Both can last minutes, and counting them would
// blank every other operation's estimate for that whole window.
//
// The byte clauses are not redundant with the phase clause: a producer can
// report downloading with bytes already at the total. The undefined-phase
// arm keeps today's behaviour for producers that do not report a phase,
// which in practice do not report totalBytes either.
const tracked = withEta.filter(
(op) =>
Number.isFinite(op.totalBytes) &&
op.totalBytes > 0 &&
Number.isFinite(op.currentBytes) &&
op.currentBytes < op.totalBytes &&
(op.phase === undefined || op.phase === 'downloading')
)
if (tracked.length > 0 && tracked.some((op) => op.etaSeconds === undefined)) {
return withEta.map(({ etaSeconds: _etaSeconds, ...op }) => op)
}
return withEta
}, [operations])
const value = {
operations: operationsWithEta,
loading,
error,
history,
historyLoading,
fetchHistory,
clearHistory,
cancelOperation,
pauseOperation,
wasCancelled,
dismissFailedOp,
refetch: fetchOperations,
}
return <OperationsContext.Provider value={value}>{children}</OperationsContext.Provider>
}
export function useOperations() {
const ctx = useContext(OperationsContext)
if (!ctx) throw new Error('useOperations must be used within OperationsProvider')
return ctx
}