assets_js_controllers_backup-restore-status-controller.js
import { Controller } from "@hotwired/stimulus"
/**
* Polls restore status and drives AJAX restore modal feedback.
*/
class BackupRestoreStatusController extends Controller {
static values = {
url: String,
interval: { type: Number, default: 1000 },
autoReload: { type: Boolean, default: true },
terminalWindow: { type: Number, default: 30 }
}
static targets = [
"panel",
"badge",
"message",
"details",
"modal",
"modalBadge",
"modalMessage",
"modalDetails",
"modalSpinner",
"modalClose"
]
connect() {
this.reloadScheduled = false
this.hasSeenRunningState = false
this.awaitingFreshRunningState = false
this.restoreRequestInFlight = false
this.currentStatus = null
this.statusRequestInFlight = false
this.modalInstance = this.hasModalTarget ? new bootstrap.Modal(this.modalTarget) : null
this.pollStatus()
this.startPolling()
}
disconnect() {
this.stopPolling()
}
startPolling() {
this.stopPolling()
this.timer = setInterval(() => this.pollStatus(), this.intervalValue)
}
stopPolling() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
async submitRestore(event) {
event.preventDefault()
if (this.restoreRequestInFlight) {
return
}
const form = event.currentTarget
if (!(form instanceof HTMLFormElement)) {
return
}
const confirmMessage = form.dataset.confirmMessage || 'Restore this backup and replace all current data?'
if (confirmMessage && !window.confirm(confirmMessage)) {
return
}
const restoreKeyPrompt = form.dataset.restoreKeyPrompt || 'Enter the backup encryption key to continue restore:'
const restoreKey = window.prompt(restoreKeyPrompt)
if (restoreKey === null) {
return
}
if (restoreKey.trim() === '') {
window.alert('An encryption key is required to restore this backup.')
return
}
this.reloadScheduled = false
this.awaitingFreshRunningState = true
this.restoreRequestInFlight = true
this.showModal({
state: 'running',
badgeLabel: 'starting',
badgeClass: 'bg-info',
message: 'Restore request submitted. Waiting for status updates...',
details: 'Preparing restore...',
panelClass: 'alert-warning',
showSpinner: true,
})
this.setModalClosable(false)
const formData = new FormData(form)
formData.set('restore_key', restoreKey.trim())
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: this.requestHeaders(),
})
const payload = await this.parseJson(response)
if (!response.ok || payload?.success === false) {
throw new Error(payload?.message || 'Restore request failed.')
}
this.awaitingFreshRunningState = false
this.hasSeenRunningState = true
const completedStatus = {
locked: false,
status: 'completed',
phase: 'completed',
message: payload?.message || 'Restore/import completed.',
table_count: payload?.stats?.table_count,
tables_processed: payload?.stats?.table_count,
row_count: payload?.stats?.row_count,
rows_processed: payload?.stats?.row_count,
completed_at: new Date().toISOString(),
}
this.render(completedStatus)
this.scheduleReload()
await this.pollStatus(true)
} catch (error) {
const failedStatus = {
locked: false,
status: 'failed',
phase: 'failed',
message: error instanceof Error ? error.message : 'Restore request failed.',
completed_at: new Date().toISOString(),
}
this.render(failedStatus)
} finally {
this.restoreRequestInFlight = false
this.setModalClosable(true)
}
}
async pollStatus() {
if (!this.hasUrlValue) {
return
}
if (this.statusRequestInFlight) {
return
}
this.statusRequestInFlight = true
try {
const response = await fetch(this.urlValue, {
headers: { 'Accept': 'application/json' },
cache: 'no-store',
})
if (!response.ok) {
return
}
const status = await response.json()
this.currentStatus = status
this.render(status)
this.reloadOnCompletion(status)
} catch (error) {
console.debug('Backup restore status poll failed:', error)
} finally {
this.statusRequestInFlight = false
}
}
render(status) {
const normalizedStatus = this.normalizeStatus(status)
if (this.hasBadgeTarget) {
this.badgeTarget.textContent = normalizedStatus.badgeLabel
this.badgeTarget.className = `badge ${normalizedStatus.badgeClass}`
}
if (this.hasMessageTarget) {
this.messageTarget.textContent = normalizedStatus.message
}
if (this.hasDetailsTarget) {
this.detailsTarget.textContent = normalizedStatus.details
}
if (this.hasPanelTarget) {
this.panelTarget.className = `alert ${normalizedStatus.panelClass} mb-3`
}
this.renderModal(normalizedStatus)
}
reloadOnCompletion(status) {
if (!this.autoReloadValue || this.reloadScheduled) {
return
}
const locked = Boolean(status?.locked)
const state = status?.status || 'idle'
if (locked || state === 'running') {
this.hasSeenRunningState = true
this.awaitingFreshRunningState = false
return
}
if (this.awaitingFreshRunningState) {
return
}
if (!this.hasSeenRunningState) {
return
}
const normalizedStatus = this.normalizeStatus(status)
if (normalizedStatus.state === 'completed') {
this.scheduleReload()
}
}
normalizeStatus(status) {
const locked = Boolean(status?.locked)
const rawState = status?.status || 'idle'
const state = !locked && this.isTerminalState(rawState) && !this.isRecentTerminalState(status)
? 'idle'
: rawState
const phase = status?.phase || state
const tableCount = Number(status?.table_count || 0)
const tablesProcessed = Number(status?.tables_processed || 0)
const rowsProcessed = Number(status?.rows_processed || 0)
const source = status?.source || ''
const currentTable = status?.current_table || ''
const message = state === 'idle'
? 'No restore currently running.'
: (status?.message || 'No restore currently running.')
const details = []
if (state !== 'idle' && source) {
details.push(`Source: ${source}`)
}
if (state !== 'idle' && tableCount > 0) {
details.push(`Tables: ${tablesProcessed}/${tableCount}`)
}
if (state !== 'idle' && rowsProcessed > 0) {
details.push(`Rows: ${rowsProcessed.toLocaleString()}`)
}
if (state !== 'idle' && currentTable) {
details.push(`Current: ${currentTable}`)
}
return {
state,
locked,
message,
details: details.length > 0 ? details.join(' | ') : 'No active restore.',
badgeLabel: locked ? phase : state,
badgeClass: this.badgeClass(locked, state),
panelClass: this.panelClass(locked, state),
showSpinner: locked || state === 'running',
}
}
isTerminalState(state) {
return state === 'completed' || state === 'failed' || state === 'interrupted'
}
isRecentTerminalState(status) {
if (!status?.completed_at) {
return false
}
const completedAt = Date.parse(status.completed_at)
if (Number.isNaN(completedAt)) {
return false
}
return (Date.now() - completedAt) <= (this.terminalWindowValue * 1000)
}
badgeClass(locked, state) {
if (locked || state === 'running') {
return 'bg-info'
}
if (state === 'completed') {
return 'bg-success'
}
if (state === 'failed') {
return 'bg-danger'
}
return 'bg-secondary'
}
panelClass(locked, state) {
if (locked || state === 'running') {
return 'alert-warning'
}
if (state === 'completed') {
return 'alert-success'
}
if (state === 'failed') {
return 'alert-danger'
}
return 'alert-secondary'
}
renderModal(normalizedStatus) {
if (!this.hasModalTarget || !this.modalInstance) {
return
}
if (this.hasModalBadgeTarget) {
this.modalBadgeTarget.textContent = normalizedStatus.badgeLabel
this.modalBadgeTarget.className = `badge ${normalizedStatus.badgeClass}`
}
if (this.hasModalMessageTarget) {
this.modalMessageTarget.textContent = normalizedStatus.message
}
if (this.hasModalDetailsTarget) {
this.modalDetailsTarget.textContent = normalizedStatus.details
}
if (this.hasModalSpinnerTarget) {
this.modalSpinnerTarget.classList.toggle('d-none', !normalizedStatus.showSpinner)
}
if (this.restoreRequestInFlight || normalizedStatus.showSpinner) {
this.modalInstance.show()
}
}
showModal(normalizedStatus) {
if (!this.hasModalTarget || !this.modalInstance) {
return
}
this.modalInstance.show()
this.renderModal(normalizedStatus)
}
setModalClosable(isClosable) {
if (!this.hasModalCloseTarget) {
return
}
this.modalCloseTargets.forEach((button) => {
button.disabled = !isClosable
})
}
requestHeaders() {
const headers = {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
}
const csrfMeta = document.querySelector("meta[name='csrf-token']")
if (csrfMeta && csrfMeta.content) {
headers['X-CSRF-Token'] = csrfMeta.content
}
return headers
}
async parseJson(response) {
const text = await response.text()
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch (_error) {
return null
}
}
scheduleReload() {
if (this.reloadScheduled) {
return
}
this.reloadScheduled = true
window.setTimeout(() => window.location.reload(), 1200)
}
}
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["backup-restore-status"] = BackupRestoreStatusController;