Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions src/components/standalone/backup_and_restore/BackupContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
-->

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ubusCall } from '@/lib/standalone/ubus'
import {
Expand Down Expand Up @@ -53,6 +53,11 @@ const { t } = useI18n()
const backups = useBackupsStore()
const subscription = useSubscriptionStore()

const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
const retryCount = ref(0)
const retryTimeout = ref<ReturnType<typeof setTimeout>>()

interface Backup {
id: string
name: string
Expand All @@ -76,6 +81,16 @@ const error = computed((): Error | undefined => {
].find((error) => error != undefined)
})

// Only show error to user after all retries have been exhausted
const showError = computed((): boolean => {
return error.value != undefined && retryCount.value >= MAX_RETRIES
})

// True when we have an error but still have retries left
const isRetrying = computed((): boolean => {
return error.value != undefined && retryCount.value < MAX_RETRIES
})

const loadingBackups = ref(false)
const errorFetchBackups = ref<Error>()
const loadingHostname = ref(false)
Expand All @@ -100,6 +115,43 @@ onMounted(() => {
getHostname()
})

onUnmounted(() => {
if (retryTimeout.value) {
clearTimeout(retryTimeout.value)
}
})

// Watch for errors and retry loading data after a delay (handles race conditions after reboot)
watch(
error,
(errorValue) => {
if (errorValue && retryCount.value < MAX_RETRIES && !loading.value) {
retryTimeout.value = setTimeout(() => {
retryCount.value++
// Clear local errors before retrying
errorFetchBackups.value = undefined
errorFetchHostname.value = undefined
// Reload store data
subscription.loadData()
backups.loadData()
// Reload local data
getHostname()
}, RETRY_DELAY_MS)
}
},
{ immediate: true }
)

// Reset retry count when loading succeeds
watch(
() => !loading.value && !error.value,
(success) => {
if (success) {
retryCount.value = 0
}
}
)

watch(
() => subscription.isActive,
function (value) {
Expand Down Expand Up @@ -230,9 +282,9 @@ function successDeleteBackup() {
class="my-4"
kind="success"
/>
<NeSkeleton v-if="loading" :lines="7" size="lg" />
<NeSkeleton v-if="loading || isRetrying" :lines="7" size="lg" />
<NeInlineNotification
v-else-if="error != undefined"
v-else-if="showError"
:description="t(getAxiosErrorMessage(error))"
class="my-4"
kind="error"
Expand Down
10 changes: 8 additions & 2 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -816,14 +816,20 @@
"reboot_and_shutdown": {
"title": "Reboot and shutdown",
"reboot": "Reboot",
"reboot_description": "Restart the unit. The system will be temporarily unavailable during the reboot.",
"shutdown": "Shutdown",
"shutdown_description": "Power off the unit for maintenance, relocation, or decommissioning.",
"reboot_unit": "Reboot unit",
"system_reboot": "System reboot",
"shutdown": "Shutdown",
"shut_down_unit": "Shut down unit",
"shutdown_warning": "You are about to power off '{unit}' unit",
"reboot_warning": "You are about to reboot '{unit}' unit",
"reboot_now": "Reboot now",
"reboot_in_progress": "The unit is rebooting, please wait..."
"reboot_in_progress": "The unit is rebooting, please wait...",
"reboot_completed": "Reboot completed, reloading page...",
"reboot_timeout_error": "The unit is not responding. Please check the connection and try reloading the page.",
"pending_changes_warning_reboot": "There are unsaved changes. They will be lost if the system reboots.",
"pending_changes_warning_shutdown": "There are unsaved changes. They will be lost if the system shuts down."
},
"update": {
"title": "Updates",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -644,14 +644,17 @@
"reboot_and_shutdown": {
"title": "Riavvio e spegnimento",
"reboot_now": "Riavvia ora",
"reboot_description": "Riavvia l'unità. Il sistema sarà temporaneamente non disponibile durante il riavvio.",
"reboot_in_progress": "L'unità si sta riavviando, attendere...",
"system_reboot": "Riavvio di sistema",
"reboot_unit": "Riavvia unità",
"shutdown_warning": "Stai per spegnere l'unità '{unit}'",
"reboot_warning": "Stai per riavviare l'unità '{unit}'",
"reboot": "Riavvio",
"shutdown": "Spegnimento",
"shut_down_unit": "Spegni unità"
"shut_down_unit": "Spegni unità",
"pending_changes_warning_reboot": "Ci sono modifiche non salvate. Verranno perse se il sistema si riavvia.",
"pending_changes_warning_shutdown": "Ci sono modifiche non salvate. Verranno perse se il sistema si spegne."
},
"network": {
"title": "Rete"
Expand Down
98 changes: 85 additions & 13 deletions src/views/standalone/system/RebootAndShutdownView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,28 @@
-->

<script setup lang="ts">
import { useTimer } from '@/composables/useTimer'
import { ubusCall } from '@/lib/standalone/ubus'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
NeProgressBar,
NeHeading,
NeButton,
NeInlineNotification,
NeSkeleton,
getAxiosErrorMessage
} from '@nethesis/vue-components'
import { NeModal } from '@nethesis/vue-components'
import { onMounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUciPendingChangesStore } from '@/stores/standalone/uciPendingChanges'

type requestType = 'poweroff' | 'reboot'
const REBOOT_WAIT_TIME = 45000
const POLL_INTERVAL = 3000
const INITIAL_WAIT = 5000
const POLL_TIMEOUT = 60000 * 3 // 3 minutes

const { t } = useI18n()
const uciChangesStore = useUciPendingChangesStore()

const hostname = ref('')
const loading = ref(true)
Expand All @@ -34,14 +36,50 @@ const modalRequestError = ref('')
const pageError = ref('')

const isRebooting = ref(false)
const isServerBackOnline = ref(false)
const rebootError = ref(false)
let pollIntervalId: ReturnType<typeof setInterval> | null = null
let pollTimeoutId: ReturnType<typeof setTimeout> | null = null

const { startTimer, currentProgress } = useTimer({
duration: REBOOT_WAIT_TIME,
progressStep: 0.5,
onTimerFinish: () => {
location.reload()
async function checkServerAvailability() {
try {
const response = await fetch('/', { method: 'HEAD' })
if (response.ok) {
// Server is back online, wait a bit before reloading the page
stopPolling()
isServerBackOnline.value = true
setTimeout(() => {
location.reload()
}, 2000)
}
} catch {
// Server still offline, continue polling
}
})
}

function startPolling() {
// Wait a bit before starting to poll to give the server time to go down
setTimeout(() => {
pollIntervalId = setInterval(checkServerAvailability, POLL_INTERVAL)
}, INITIAL_WAIT)

// Set a timeout to stop polling and show error if server doesn't come back
pollTimeoutId = setTimeout(() => {
stopPolling()
rebootError.value = true
}, POLL_TIMEOUT)
}

function stopPolling() {
if (pollIntervalId) {
clearInterval(pollIntervalId)
pollIntervalId = null
}
if (pollTimeoutId) {
clearTimeout(pollTimeoutId)
pollTimeoutId = null
}
}

async function getHostname() {
try {
Expand All @@ -60,7 +98,7 @@ async function performRequest(type: requestType) {

if (type == 'reboot') {
isRebooting.value = true
startTimer()
startPolling()
}
} catch (err: any) {
modalRequestError.value = t(getAxiosErrorMessage(err))
Expand All @@ -78,13 +116,20 @@ function closeModal() {
onMounted(() => {
getHostname()
})

onUnmounted(() => {
stopPolling()
})
</script>

<template>
<div>
<NeHeading tag="h3" class="mb-7">{{ t('standalone.reboot_and_shutdown.title') }}</NeHeading>
<div class="flex flex-col gap-y-4">
<NeHeading tag="h5" class="mb-2">{{ t('standalone.reboot_and_shutdown.reboot') }}</NeHeading>
<p class="text-sm font-normal text-secondary-neutral">
{{ t('standalone.reboot_and_shutdown.reboot_description') }}
</p>
<NeInlineNotification
v-if="pageError"
:title="t('error.generic_error')"
Expand All @@ -105,6 +150,9 @@ onMounted(() => {
<NeHeading tag="h5" class="mb-2">{{
t('standalone.reboot_and_shutdown.shutdown')
}}</NeHeading>
<p class="text-sm font-normal text-secondary-neutral">
{{ t('standalone.reboot_and_shutdown.shutdown_description') }}
</p>
<div>
<NeButton kind="secondary" size="lg" @click="showShutdownModal = true">
<template #prefix>
Expand All @@ -130,6 +178,12 @@ onMounted(() => {
@primary-click="performRequest('poweroff')"
>
{{ t('standalone.reboot_and_shutdown.shutdown_warning', { unit: hostname }) }}
<NeInlineNotification
v-if="uciChangesStore.numChanges > 0"
kind="warning"
:title="t('standalone.reboot_and_shutdown.pending_changes_warning_shutdown')"
class="my-6"
/>
<NeInlineNotification
v-if="modalRequestError"
:title="t('error.generic_error')"
Expand All @@ -152,11 +206,29 @@ onMounted(() => {
@primary-click="performRequest('reboot')"
>
<template v-if="isRebooting">
{{ t('standalone.reboot_and_shutdown.reboot_in_progress') }}
<NeProgressBar class="my-4" :progress="currentProgress" />
<template v-if="rebootError">
<NeInlineNotification
kind="error"
:title="t('standalone.reboot_and_shutdown.reboot_timeout_error')"
/>
</template>
<div v-else class="flex items-center gap-4">
<template v-if="isServerBackOnline">
{{ t('standalone.reboot_and_shutdown.reboot_completed') }}
</template>
<template v-else>
{{ t('standalone.reboot_and_shutdown.reboot_in_progress') }}
</template>
</div>
</template>
<template v-else>
{{ t('standalone.reboot_and_shutdown.reboot_warning', { unit: hostname }) }}
<NeInlineNotification
v-if="uciChangesStore.numChanges > 0"
kind="warning"
:title="t('standalone.reboot_and_shutdown.pending_changes_warning_reboot')"
class="my-4"
/>
<NeInlineNotification
v-if="modalRequestError"
:title="t('error.generic_error')"
Expand Down
Loading