Files
cloudron-box/dashboard/src/components/SystemUpdate.vue
T
Girish Ramakrishnan c5f97e8bb0 fix parsing of cron pattern
in some old instances, we had "00 00  * * *" (note double space
and only 5 components).
2025-11-18 09:58:38 +01:00

374 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { marked } from 'marked';
import { Button, FormGroup, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { TASK_TYPES, ISTATES } from '../constants.js';
import Section from '../components/Section.vue';
import SettingsItem from '../components/SettingsItem.vue';
import AppsModel from '../models/AppsModel.js';
import UpdaterModel from '../models/UpdaterModel.js';
import TasksModel from '../models/TasksModel.js';
import DashboardModel from '../models/DashboardModel.js';
import { cronDays, cronHours, prettySchedule, parseSchedule } from '../utils.js';
const appsModel = AppsModel.create();
const tasksModel = TasksModel.create();
const updaterModel = UpdaterModel.create();
const dashboardModel = DashboardModel.create();
const inputDialog = useTemplateRef('inputDialog');
const updateDialog = useTemplateRef('updateDialog');
const ready = ref(false);
const taskLogsMenu = ref([]);
const apps = ref([]);
const version = ref('');
const ubuntuVersion = ref('');
const currentPattern = ref('');
const updateBusy = ref(false);
const updateError = ref({});
const stopError = ref({});
const updateCheckError = ref({});
const checkingBusy = ref(false);
const pendingUpdate = ref(null);
const skipBackup = ref(false);
const lastTask = ref({});
const canUpdate = computed(() => {
return apps.value.every(function (app) {
return (app.installationState === ISTATES.ERROR) || (app.installationState === ISTATES.INSTALLED);
});
});
const inProgressApps = computed(() => {
return apps.value.filter(function (app) {
return app.installationState !== ISTATES.ERROR && app.installationState !== ISTATES.INSTALLED;
});
});
const configureDialog = useTemplateRef('configureDialog');
const configureBusy = ref(false);
const configureError = ref('');
const configureType = ref('');
const configurePattern = ref('');
const configureDays = ref([]);
const configureHours = ref([]);
async function refreshAutoupdatePattern() {
const [error, result] = await updaterModel.getAutoupdatePattern();
if (error) return console.error(error);
currentPattern.value = result.pattern;
configurePattern.value = result.pattern;
}
async function refreshApps() {
const [error, result] = await appsModel.list();
if (error) return console.error(error);
apps.value = result;
}
async function refreshPendingUpdateInfo() {
const [error, result] = await updaterModel.getBoxUpdate();
if (error) return console.error(error);
updateError.value = {};
stopError.value = {};
updateCheckError.value = {};
pendingUpdate.value = result || null;
}
function onShowConfigure() {
if (currentPattern.value === 'never') {
configureType.value = 'never';
} else {
configureType.value = 'pattern';
const result = parseSchedule(currentPattern.value);
configureDays.value = result.days; // Array of cronDays.id
configureHours.value = result.hours; // Array of cronHours.id
}
configureDialog.value.open();
}
async function onSubmitConfigure() {
let pattern = 'never';
if (configureType.value === 'pattern') {
let daysPattern;
if (configureDays.value.length === 7) daysPattern = '*';
else daysPattern = configureDays.value.join(',');
let hoursPattern;
if (configureHours.value.length === 24) hoursPattern = '*';
else hoursPattern = configureHours.value.join(',');
pattern ='00 00 ' + hoursPattern + ' * * ' + daysPattern;
}
configureBusy.value = true;
const [error] = await updaterModel.setAutoupdatePattern(pattern);
if (error) {
configureError.value = error.body ? error.body.message : 'Internal error';
configureBusy.value = false;
return console.error(error);
}
await refreshAutoupdatePattern();
configureBusy.value = false;
configureDialog.value.close();
}
async function onShowUpdate() {
skipBackup.value = false;
await refreshApps();
updateDialog.value.open();
}
async function waitForTask() {
if (!lastTask.value.id) return;
const [error, result] = await tasksModel.get(lastTask.value.id);
if (error) {
setTimeout(waitForTask, 2000);
return console.error(error);
}
lastTask.value = result;
// task done, refresh menu
if (!result.active) {
refreshPendingUpdateInfo();
refreshTasks();
return;
}
setTimeout(waitForTask, 2000);
}
async function refreshTasks() {
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_UPDATE);
if (error) return console.error(error);
lastTask.value = result[0] || {};
if (result.length && !result[0].active && !result[0].success) updateError.value.generic = result[0].error.message;
taskLogsMenu.value = result.map(t => {
return {
icon: 'fa-solid ' + ((!t.active && t.success) ? 'status-active fa-check-circle' : (t.active ? 'fa-circle-notch fa-spin' : 'status-error fa-times-circle')),
label: prettyLongDate(t.ts),
action: () => { window.open(`/logs.html?taskId=${t.id}`); }
};
});
updateBusy.value = lastTask.value.active;
// if last task is currently active, start polling
if (lastTask.value.active) waitForTask();
}
async function onSubmitUpdate() {
updateError.value = {};
updateBusy.value = true;
const [error] = await updaterModel.update(skipBackup.value);
if (error) {
updateBusy.value = false;
updateDialog.value.close();
inputDialog.value.info({
title: t('notifications.settings.cloudronUpdateFailed'),
message: error.body ? error.body.message : 'Internal error. Please try again.',
confirmLabel: t('main.dialog.close'),
confirmStyle: 'secondary'
});
return;
}
await refreshTasks();
updateDialog.value.close();
}
async function onCheck() {
checkingBusy.value = true;
updateCheckError.value = {};
const [error] = await updaterModel.checkBoxUpdate();
if (error) {
updateCheckError.value.generic = error.body?.message || 'Internal error';
checkingBusy.value = false;
return;
}
await refreshPendingUpdateInfo();
checkingBusy.value = false;
}
async function onStop() {
const [error] = await tasksModel.stop(lastTask.value.id);
if (error) {
if (error.statusCode === 409) {
stopError.value.generic = 'No update is currently in progress';
} else {
console.error(error);
stopError.value.generic = error.message || 'Internal error';
}
return;
}
updateBusy.value = false;
}
onMounted(async () => {
const [error, result] = await dashboardModel.config();
if (error) return console.error(error);
version.value = result.version;
ubuntuVersion.value = result.ubuntuVersion;
await refreshPendingUpdateInfo();
await refreshAutoupdatePattern();
await refreshTasks();
ready.value = true;
});
</script>
<template>
<div>
<InputDialog ref="inputDialog"/>
<Dialog ref="updateDialog"
:title="$t('settings.updateDialog.title')"
:confirm-label="$t('settings.updateDialog.updateAction')"
:confirm-active="canUpdate"
:confirm-busy="updateBusy"
:confirm-style="pendingUpdate?.unstable ? 'danger' : 'primary'"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!updateBusy"
reject-style="secondary"
@confirm="onSubmitUpdate()"
>
<div v-if="pendingUpdate && canUpdate">
<h3>{{ $t('settings.updateDialog.updateAvailable', { newVersion: `v${pendingUpdate.version}` }) }}</h3>
<p v-if="pendingUpdate.unstable" class="error-label">{{ $t('settings.updateDialog.unstableWarning') }}</p>
<div>{{ $t('settings.updateDialog.changes') }}:</div>
<div class="changelog-container">
<ul class="changelogs">
<li v-for="change in pendingUpdate.changelog" :key="change" v-html="marked.parse(change)"></li>
</ul>
</div>
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
</div>
<!-- !canUpdate -->
<div v-else>
<p>{{ $t('settings.updateDialog.blockingApps') }}</p>
<ul>
<li v-for="app in inProgressApps" :key="app.id">{{ app.fqdn }}</li>
</ul>
<span>{{ $t('settings.updateDialog.blockingAppsInfo') }}</span>
<br/>
<br/>
</div>
</Dialog>
<Dialog ref="configureDialog"
:title="$t('settings.updateScheduleDialog.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-active="configureType === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmitConfigure()"
>
<FormGroup>
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')"/>
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin: 10px 0px 0px 25px">
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
</div>
</FormGroup>
</Dialog>
<Section :title="$t('settings.updates.title')">
<template #header-buttons>
<Button tool secondary :menu="taskLogsMenu" :disabled="taskLogsMenu.length === 0">{{ $t('main.action.logs') }}</Button>
</template>
<div v-html="$t('settings.updates.description')"></div>
<br/>
<SettingsItem v-if="ready">
<div>
<label>{{ $t('settings.updates.schedule') }}</label>
<span v-if="currentPattern !== 'never'">{{ prettySchedule(currentPattern) }}</span>
<span v-else>{{ $t('settings.updates.disabled') }}</span>
</div>
<div style="display: flex; align-items: center">
<Button tool plain @click="onShowConfigure()">{{ $t('main.dialog.edit') }}</Button>
</div>
</SettingsItem>
<SettingsItem v-if="ready">
<div>
<label>{{ $t('system.info.cloudronVersion') }}</label>
<span>{{ version }} <span v-if="!pendingUpdate">({{ $t('settings.updates.onLatest') }})</span></span>
</div>
</SettingsItem>
<ProgressBar :value="lastTask.percent" v-if="updateBusy && lastTask" :busy="true" />
<p v-if="updateBusy && lastTask">{{ lastTask.message }}</p>
<div class="error-label" v-if="stopError.generic">{{ stopError.generic }}</div>
<div class="error-label" v-if="updateCheckError.generic">{{ updateCheckError.generic }}</div>
<div class="error-label" v-if="updateError.generic">{{ updateError.generic }}</div>
<div class="button-bar" v-if="ready">
<Button :disabled="checkingBusy" :loading="checkingBusy" v-if="!updateBusy" @click="onCheck()">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
<Button :danger="(pendingUpdate && pendingUpdate.unstable) ? true : undefined" :success="(pendingUpdate && !pendingUpdate.unstable) ? true : undefined" v-show="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
</div>
</Section>
</div>
</template>
<style>
.changelogs {
padding-left: 15px;
}
.changelogs > li > p {
margin: 0;
padding: 2px;
}
.changelog-container {
overflow: auto;
max-height: 20lh;
padding-right: 0.5rem; /* space so scrollbar doesnt overlap text */
}
</style>