| | |
| | | |
| | | this.user = window.auth.getUser(); |
| | | |
| | | |
| | | this.canUpdateUI = true; |
| | | this.isProcessing = false; |
| | | this.isPolling = false; |
| | |
| | | this.api = jvbSettings.api; |
| | | this.endpoint = 'queue'; |
| | | |
| | | this.queueItems = new Map(); |
| | | |
| | | this.init(); |
| | | } |
| | | init() { |
| | |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.initStore(); |
| | | if (this.canUpdateUI) { |
| | | if (this.canUpdateUI && this.ui.panel) { |
| | | this.popup = new window.jvbPopup({ |
| | | popup: this.ui.panel, |
| | | toggle: this.ui.toggle.button, |
| | | name: 'Queue Panel', |
| | | }); |
| | | } |
| | | this.defineTemplates(); |
| | | } |
| | | |
| | | initElements() { |
| | |
| | | if (!this.ui.panel) this.canUpdateUI = false; |
| | | } |
| | | |
| | | defineTemplates() { |
| | | const T = window.jvbTemplates; |
| | | |
| | | T.define('emptyState'); |
| | | T.define('queueItem', { |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | |
| | | initListeners() { |
| | | this.activityListeners = null; |
| | |
| | | window.addEventListener('beforeunload', this.unloadHandler); |
| | | } |
| | | handleOnline() { |
| | | this.updatePanel(); |
| | | this.updatePanel('synced'); |
| | | if (this.getQueueByStatus(this.pendingStatuses).length > 0) { |
| | | this.processQueue(); |
| | | } |
| | |
| | | this.updatePanel('offline'); |
| | | } |
| | | handleBeforeUnload(e) { |
| | | if (!this.ui.panel) return; |
| | | const total = this.getQueueByStatus(this.pendingStatuses).length; |
| | | if (total > 0) { |
| | | // Modern browsers ignore custom messages, but this triggers the native dialog |
| | |
| | | if (!window.targetCheck(e, this.selectors.panel+', '+this.selectors.toggle.button)) return; |
| | | const refresh = window.targetCheck(e, this.selectors.refresh.button); |
| | | if (refresh) { |
| | | this.ui.refresh.button.classList.add('fetching'); |
| | | this.store.clearCache(); |
| | | this.store.fetch(); |
| | | this.store.clearFilters(); |
| | | this.store.fetch().finally(() => { |
| | | this.ui.refresh.button.classList.remove('fetching'); |
| | | }); |
| | | return; |
| | | } |
| | | |
| | |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | filters: { |
| | | user: window.auth.getUser() |
| | | }, |
| | | showLoading: false, |
| | | } |
| | | ) |
| | |
| | | title: 'Operation', |
| | | status: 'queued', |
| | | timestamp: Date.now(), |
| | | created_at: new Date().toISOString(), |
| | | retries: 0, |
| | | user: this.user, |
| | | ... operation |
| | |
| | | } |
| | | if (statusOrId.length ===0) return; |
| | | if (!['cancel', 'dismiss', 'retry'].includes(action)) return; |
| | | |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | statusOrId.forEach(id => this.removeOperationUI(id)); |
| | | statusOrId.forEach(id => { |
| | | this.removeOperationUI(id) |
| | | }); |
| | | } |
| | | |
| | | try { |
| | |
| | | } |
| | | statusOrId.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | this.notify(`${action}-operation`, item); |
| | | if (item) { |
| | | this.notify(`${action}-operation`, item); |
| | | } |
| | | |
| | | if (shouldRemove) { |
| | | this.clearQueue(id); |
| | | } else { |
| | |
| | | this.setProcessing(false); |
| | | this.stopActivityTracking(); |
| | | |
| | | // this.toggleQueue(this.maybeStartPolling()); |
| | | |
| | | this.toggleQueue(this.maybeStartPolling()); |
| | | } |
| | | |
| | | async processOperation(operation) { |
| | |
| | | let requestBody; |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', this.user); |
| | | operation.data.append('user', window.auth.getUser()); |
| | | requestBody = operation.data; |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: this.user |
| | | user: window.auth.getUser() |
| | | }); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | |
| | | }); |
| | | } |
| | | |
| | | sortOperations(ops) { |
| | | const statusPriority = { |
| | | 'processing': 0, |
| | | 'uploading': 1, |
| | | 'pending': 2, |
| | | 'queued': 3, |
| | | 'localProcessing': 4, |
| | | 'failed': 5, |
| | | 'completed': 6, |
| | | 'failed_permanent': 7 |
| | | }; |
| | | |
| | | return ops.sort((a, b) => { |
| | | // First by status priority |
| | | const priorityDiff = (statusPriority[a.status] ?? 99) - (statusPriority[b.status] ?? 99); |
| | | if (priorityDiff !== 0) return priorityDiff; |
| | | |
| | | // Then by updated_at (most recent first) |
| | | const aTime = a.updated_at ?? a.timestamp ?? 0; |
| | | const bTime = b.updated_at ?? b.timestamp ?? 0; |
| | | return new Date(bTime) - new Date(aTime); |
| | | }); |
| | | } |
| | | |
| | | getAllQueue() { |
| | | let ops = [... new Set([ |
| | | ...Array.from(this.store.data.values()), |
| | | ... Array.from(this.queue.values()) |
| | | ])]; |
| | | //Sort operations by operation updated_at |
| | | return this.sortByDate(ops); |
| | | return this.sortOperations(ops); |
| | | } |
| | | |
| | | getQueueByStatus(status) { |
| | |
| | | ...Array.from(this.store.filterByIndex({status: status})), |
| | | ...Array.from(this.queue.values()).filter(op => status.includes(op.status)) |
| | | ])]; |
| | | return this.sortByDate(ops); |
| | | return this.sortOperations(ops); |
| | | } |
| | | |
| | | |
| | |
| | | POLLING |
| | | ****************************************************************************/ |
| | | maybeStartPolling() { |
| | | const incomplete = this.getQueueByStatus(this.pendingStatuses); |
| | | const incomplete = this.getQueueByStatus([...this.pendingStatuses, ...this.workingStatuses]); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | return true; |
| | | } |
| | | this.updatePanel('synced'); |
| | | return false; |
| | | } |
| | | startPolling() { |
| | | if (this.isPolling) return; |
| | | this.isPolling = true; |
| | | this.updatePanel('pending'); |
| | | this.runPollCycle(); |
| | | } |
| | | |
| | | this.pollTimer = setInterval(async () => { |
| | | try { |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updatePanel('synced'); |
| | | } |
| | | } catch (error) { |
| | | console.error('Polling error:', error); |
| | | async runPollCycle() { |
| | | if (!this.isPolling) return; |
| | | |
| | | try { |
| | | this.ui.refresh.button.classList.add('fetching'); |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); |
| | | this.ui.refresh.button.classList.remove('fetching'); |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updatePanel('synced'); |
| | | return; |
| | | } |
| | | }, |
| | | 5000); |
| | | this.startCountdown(); |
| | | } catch (error) { |
| | | console.error('Polling error:', error); |
| | | } |
| | | |
| | | // Schedule next poll with countdown |
| | | this.startCountdown(5, () => this.runPollCycle()); |
| | | } |
| | | |
| | | startCountdown(count, onComplete) { |
| | | if (!this.ui.refresh.countdown) { |
| | | console.warn('Countdown element not found'); |
| | | return; |
| | | } |
| | | this.ui.refresh.countdown.classList.add('counting'); |
| | | this.ui.refresh.countdown.textContent = count; |
| | | |
| | | this.countdownTimer = setInterval(() => { |
| | | count--; |
| | | if (count > 0) { |
| | | this.ui.refresh.countdown.textContent = count; |
| | | } else { |
| | | this.stopCountdown(); |
| | | if (onComplete) onComplete(); |
| | | } |
| | | }, 1000); |
| | | } |
| | | |
| | | stopPolling() { |
| | |
| | | } |
| | | this.stopCountdown(); |
| | | } |
| | | startCountdown(count = 5) { |
| | | if (!this.isPolling) return; |
| | | this.ui.refresh.countdown.textContent = count; |
| | | this.countdownTimer = setInterval(async() => { |
| | | count--; |
| | | if (count >= 0) { |
| | | this.ui.refresh.countdown.textContent = count; |
| | | }else { |
| | | this.ui.refresh.countdown.textContent = ''; |
| | | this.stopCountdown(); |
| | | } |
| | | },1000); |
| | | } |
| | | |
| | | stopCountdown() { |
| | | if (this.countdownTimer) { |
| | | clearInterval(this.countdownTimer); |
| | | this.countdownTimer = null; |
| | | } |
| | | this.ui.refresh.countdown.classList.remove('counting'); |
| | | this.ui.refresh.countdown.textContent = ''; |
| | | } |
| | | /**************************************************************************** |
| | | UI |
| | |
| | | this.ui.actions.retry.disabled = operations.filter(op => op.status === 'failed').length === 0; |
| | | this.ui.actions.clear.disabled = operations.filter(op => op.status === 'completed').length ===0; |
| | | |
| | | const activeCount = operations.filter(op => |
| | | [...this.pendingStatuses, ...this.workingStatuses].includes(op.status) |
| | | ).length; |
| | | this.ui.toggle.count.hidden = activeCount === 0; |
| | | this.ui.toggle.count.textContent = activeCount; |
| | | |
| | | for (let status of this.statuses) { |
| | | if (status === 'failed_permanent') continue; |
| | | let total = operations.filter(op => op.status === status).length; |
| | |
| | | |
| | | const status = this.store.filters?.status ?? 'all'; |
| | | const operations = (status === 'all') ? this.getAllQueue() : this.getQueueByStatus(status); |
| | | const sortedOps = this.sortOperations(operations); |
| | | |
| | | window.removeChildren(this.ui.items.container); |
| | | |
| | | if (operations.length === 0) { |
| | | const empty = window.getTemplate('emptyQueue'); |
| | | if (sortedOps.length === 0) { |
| | | window.removeChildren(this.ui.items.container); |
| | | const empty = window.jvbTemplates.create('emptyQueue'); |
| | | this.ui.items.container.append(empty); |
| | | this.a11y.announce('No items in queue'); |
| | | return; |
| | | } else { |
| | | this.ui.items.container.querySelector('.empty-group')?.remove(); |
| | | } |
| | | |
| | | operations.forEach(op => { |
| | | let item = this.items.get(op.id); |
| | | // Track which items should exist |
| | | const expectedIds = new Set(sortedOps.map(op => op.id)); |
| | | |
| | | // Remove items that shouldn't exist |
| | | this.items.forEach((item, id) => { |
| | | if (!expectedIds.has(id)) { |
| | | item.element?.remove(); |
| | | this.items.delete(id); |
| | | } |
| | | }); |
| | | |
| | | // Update/add items in order |
| | | sortedOps.forEach((op, index) => { |
| | | let item = this.items.get(op.id); |
| | | if (!item) { |
| | | // Create new element and reference |
| | | item = this.createOperationElement(op); |
| | | } |
| | | |
| | | if (item?.element) { |
| | | this.updateOperationUI(op.id); |
| | | // Reorder by re-appending (moves to end in correct order) |
| | | this.ui.items.container.append(item.element); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | createOperationElement(op) { |
| | | const el = window.getTemplate('queueItem'); |
| | | el.dataset.id = op.id; |
| | | |
| | | const el = window.jvbTemplates.create('queueItem', op); |
| | | const item = { |
| | | element: el, |
| | | ui: window.uiFromSelectors(this.selectors.item, el) |
| | |
| | | item.ui.startedAt.textContent = window.formatTimeAgo(op.created_at); |
| | | } |
| | | let text = op.status === 'completed' ? 'Completed: ' : 'Last updated: '; |
| | | let shouldShow =Object.hasOwn(op, 'updated_at') || Object.hasOwn(op, 'completed_at'); |
| | | item.ui.completed.wrap.hidden = !shouldShow; |
| | | if (shouldShow && item.ui.completed.label && item.ui.completed.time) { |
| | | let time; |
| | | if (Object.hasOwn(op, 'completed_at')) { |
| | | time = op.completed_at; |
| | | } else { |
| | | time = op.updated_at; |
| | | } |
| | | |
| | | item.ui.completed.label.textContent = text; |
| | | item.ui.completed.time.setAttribute('datetime', time); |
| | | item.ui.completed.time.textContent = window.formatTimeAgo(time); |
| | | const shouldShowCompleted = op.status === 'completed' && (op.completed_at || op.updated_at); |
| | | item.ui.completed.wrap.hidden = !shouldShowCompleted; |
| | | if (shouldShowCompleted) { |
| | | const completedTime = op.completed_at ?? op.updated_at; |
| | | item.ui.completed.label.textContent = 'Completed: '; |
| | | item.ui.completed.time.setAttribute('datetime', completedTime); |
| | | item.ui.completed.time.textContent = window.formatTimeAgo(completedTime); |
| | | } |
| | | |
| | | window.showProgress(item.ui.progress, progress, 100, this.statusLabel(op.status)); |
| | | if (item.ui.actions.cancel) item.ui.actions.cancel.hidden = this.completedStatuses.includes(op.status); |
| | | if (item.ui.actions['retry']) { |
| | |
| | | } |
| | | |
| | | updatePanel(status = 'syncing') { |
| | | if (!this.panelStatuses.includes(status)) return; |
| | | this.ui.panel.classList.remove(this.panelStatuses); |
| | | if (!this.ui.panel || !this.panelStatuses.includes(status)) return; |
| | | this.ui.panel.classList.remove(...this.panelStatuses); |
| | | this.ui.panel.classList.add(status); |
| | | } |
| | | /**************************************************************************** |
| | |
| | | case 'completed': |
| | | return 'Successfully completed'; |
| | | case 'failed': |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`; |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`; |
| | | case 'failed_permanent': |
| | | return `Failed: ${item.lastError || 'Unknown error'}`; |
| | | default: |
| | |
| | | } |
| | | } |
| | | toggleQueue(on = true) { |
| | | if (!this.ui.panel) return; |
| | | this.ui.panel.hidden = !on; |
| | | this.ui.toggle.button.hidden = !on; |
| | | } |