Files
cloudron-box/dashboard/src/components/SystemUpdate.vue
2025-09-30 14:46:39 +02:00

390 lines
13 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 { ref, onMounted, useTemplateRef, computed } from 'vue';
import { marked } from 'marked';
import { Button, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox } 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';
const appsModel = AppsModel.create();
const tasksModel = TasksModel.create();
const updaterModel = UpdaterModel.create();
const dashboardModel = DashboardModel.create();
// values correspond to cron days
const cronDays = [
{ name: 'Sunday', value: 0 },
{ name: 'Monday', value: 1 },
{ name: 'Tuesday', value: 2 },
{ name: 'Wednesday', value: 3 },
{ name: 'Thursday', value: 4 },
{ name: 'Friday', value: 5 },
{ name: 'Saturday', value: 6 },
];
// generates 24h time sets (instead of american 12h) to avoid having to translate everything to locales eg. 12:00
const cronHours = Array.from({ length: 24 }).map((v, i) => { return { name: (i < 10 ? '0' : '') + i + ':00', value: i }; });
function prettyAutoUpdateSchedule(pattern) {
if (!pattern) return '';
const tmp = pattern.split(' ');
if (tmp.length === 1) return tmp[0];
const hours = tmp[2].split(',');
const days = tmp[5].split(',');
const prettyDay = (days.length === 7 || days[0] === '*') ? 'Every day' : days.map((day) => { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(', ');
try {
const prettyHour = hours.map((hour) => { return cronHours[parseInt(hour, 10)]; }).sort((a,b) => a.value - b.value).map(h => h.name).join(', ');
return prettyDay + ' at ' + prettyHour;
} catch (error) {
console.error('Unable to build pattern.', error);
return 'Custom pattern';
}
}
const updateDialog = useTemplateRef('updateDialog');
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);
// just keep the UI sane by supporting previous default pattern
if (result.pattern === '00 30 1,3,5,23 * * *') result.pattern = '00 15 1,3,5,23 * * *';
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() {
configureType.value = configurePattern.value === 'never' ? 'never' : 'pattern';
const tmp = currentPattern.value.split(' ');
const hours = tmp[2] ? tmp[2].split(',') : [];
const days = tmp[5] ? tmp[5].split(',') : [];
if (days[0] === '*') configureDays.value = cronDays;
else configureDays.value = days.map(day => { return parseInt(day, 10); });
try {
configureHours.value = hours.map(hour => { return parseInt(hour, 10); });
} catch (error) {
console.error('Error parsing hour', error);
}
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] || {};
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) {
updateError.value.generic = error.message || 'Internal error';
updateBusy.value = false;
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();
});
</script>
<template>
<div>
<Dialog ref="updateDialog"
:title="$t('settings.updateDialog.title') + ` v${pendingUpdate ? pendingUpdate.version : ''}`"
:confirm-label="$t('settings.updateDialog.updateAction')"
:confirm-active="canUpdate"
:confirm-busy="updateBusy"
:confirm-style="pendingUpdate && pendingUpdate.unstable ? 'danger' : 'primary'"
:reject-label="updateBusy ? null : $t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmitUpdate()"
>
<div v-if="pendingUpdate">
<div v-if="canUpdate">
<p class="text-danger" v-if="pendingUpdate.unstable">{{ $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')"/>
<p v-if="updateError.generic" class="error-label">{{ updateError.generic }}</p>
</div>
<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>
</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()"
>
<p v-html="$t('settings.updateScheduleDialog.description')"></p>
<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')" style="margin-top: 10px"/>
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin-top: 10px">
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="value"/></div>
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="value"/></div>
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
</div>
</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>
<div>
<label>{{ $t('settings.updates.schedule') }}</label>
<span v-if="currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(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>
<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="button-bar">
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
<Button :disabled="checkingBusy" :loading="checkingBusy" v-if="!updateBusy" @click="onCheck()">{{ $t('settings.updates.checkForUpdatesAction') }}</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;
margin-bottom: 10px;
padding-right: 0.5rem; /* space so scrollbar doesnt overlap text */
}
.skip-backup {
padding-top: 10px;
}
</style>