Files
cloudron-box/dashboard/src/components/SystemUpdate.vue

390 lines
13 KiB
Vue
Raw Normal View History

2025-01-23 18:36:30 +01:00
<script setup>
2025-06-30 17:35:01 +02:00
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { marked } from 'marked';
2025-07-10 11:55:11 +02:00
import { Button, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
2025-06-30 17:35:01 +02:00
import { TASK_TYPES, ISTATES } from '../constants.js';
2025-01-23 18:36:30 +01:00
import Section from '../components/Section.vue';
import SettingsItem from '../components/SettingsItem.vue';
2025-06-30 17:35:01 +02:00
import AppsModel from '../models/AppsModel.js';
2025-01-24 14:00:33 +01:00
import UpdaterModel from '../models/UpdaterModel.js';
2025-01-24 14:09:30 +01:00
import TasksModel from '../models/TasksModel.js';
2025-01-24 14:00:33 +01:00
import DashboardModel from '../models/DashboardModel.js';
2025-06-30 17:35:01 +02:00
const appsModel = AppsModel.create();
2025-01-31 21:02:48 +01:00
const tasksModel = TasksModel.create();
const updaterModel = UpdaterModel.create();
const dashboardModel = DashboardModel.create();
2025-01-24 14:00:33 +01:00
// values correspond to cron days
const cronDays = [
2025-04-24 16:35:44 +02:00
{ 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 },
2025-01-24 14:00:33 +01:00
];
// generates 24h time sets (instead of american 12h) to avoid having to translate everything to locales eg. 12:00
2025-04-24 16:35:44 +02:00
const cronHours = Array.from({ length: 24 }).map((v, i) => { return { name: (i < 10 ? '0' : '') + i + ':00', value: i }; });
2025-01-24 14:00:33 +01:00
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(', ');
2025-01-24 14:00:33 +01:00
try {
const prettyHour = hours.map((hour) => { return cronHours[parseInt(hour, 10)]; }).sort((a,b) => a.value - b.value).map(h => h.name).join(', ');
2025-01-24 14:00:33 +01:00
return prettyDay + ' at ' + prettyHour;
} catch (error) {
console.error('Unable to build pattern.', error);
return 'Custom pattern';
}
}
2025-01-23 18:36:30 +01:00
2025-06-30 17:35:01 +02:00
const updateDialog = useTemplateRef('updateDialog');
2025-01-23 18:36:30 +01:00
const taskLogsMenu = ref([]);
2025-06-30 17:35:01 +02:00
const apps = ref([]);
2025-01-24 14:00:33 +01:00
const version = ref('');
const ubuntuVersion = ref('');
const currentPattern = ref('');
const updateBusy = ref(false);
2025-06-30 17:35:01 +02:00
const updateError = ref({});
const stopError = ref({});
2025-09-10 18:54:04 +02:00
const updateCheckError = ref({});
2025-01-24 14:00:33 +01:00
const checkingBusy = ref(false);
const pendingUpdate = ref(null);
2025-06-30 17:35:01 +02:00
const skipBackup = ref(false);
2025-09-10 21:35:26 +02:00
const lastTask = ref({});
2025-06-30 17:35:01 +02:00
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;
});
});
2025-01-24 14:00:33 +01:00
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;
}
2025-06-30 17:35:01 +02:00
async function refreshApps() {
const [error, result] = await appsModel.list();
if (error) return console.error(error);
apps.value = result;
}
2025-09-10 21:35:26 +02:00
async function refreshPendingUpdateInfo() {
const [error, result] = await updaterModel.getBoxUpdate();
2025-01-24 14:00:33 +01:00
if (error) return console.error(error);
updateError.value = {};
stopError.value = {};
2025-09-10 18:54:04 +02:00
updateCheckError.value = {};
pendingUpdate.value = result || null;
2025-01-24 14:00:33 +01:00
}
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;
2025-04-24 16:35:44 +02:00
else configureDays.value = days.map(day => { return parseInt(day, 10); });
2025-01-24 14:00:33 +01:00
try {
2025-04-24 16:35:44 +02:00
configureHours.value = hours.map(hour => { return parseInt(hour, 10); });
2025-01-24 14:00:33 +01:00
} 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 = '*';
2025-04-24 16:35:44 +02:00
else daysPattern = configureDays.value.join(',');
2025-01-24 14:00:33 +01:00
let hoursPattern;
if (configureHours.value.length === 24) hoursPattern = '*';
2025-04-24 16:35:44 +02:00
else hoursPattern = configureHours.value.join(',');
2025-01-24 14:00:33 +01:00
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();
}
2025-06-30 17:35:01 +02:00
async function onShowUpdate() {
skipBackup.value = false;
await refreshApps();
updateDialog.value.open();
2025-01-24 14:00:33 +01:00
}
2025-06-30 17:35:01 +02:00
async function waitForTask() {
if (!lastTask.value.id) return;
2025-01-24 14:00:33 +01:00
2025-06-30 17:35:01 +02:00
const [error, result] = await tasksModel.get(lastTask.value.id);
2025-09-10 21:35:26 +02:00
if (error) {
setTimeout(waitForTask, 2000);
return console.error(error);
}
2025-01-24 14:00:33 +01:00
2025-06-30 17:35:01 +02:00
lastTask.value = result;
// task done, refresh menu
if (!result.active) {
2025-09-10 21:35:26 +02:00
refreshPendingUpdateInfo();
2025-06-30 17:35:01 +02:00
refreshTasks();
return;
}
setTimeout(waitForTask, 2000);
2025-01-24 14:00:33 +01:00
}
2025-01-24 14:09:30 +01:00
async function refreshTasks() {
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_UPDATE);
if (error) return console.error(error);
2025-06-30 17:35:01 +02:00
lastTask.value = result[0] || {};
2025-01-24 14:09:30 +01:00
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}`); }
};
});
2025-06-30 17:35:01 +02:00
2025-09-10 21:35:26 +02:00
updateBusy.value = lastTask.value.active;
2025-06-30 17:35:01 +02:00
// 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;
2025-06-30 17:35:01 +02:00
return;
}
await refreshTasks();
updateDialog.value.close();
}
async function onCheck() {
checkingBusy.value = true;
2025-09-10 18:54:04 +02:00
updateCheckError.value = {};
2025-06-30 17:35:01 +02:00
const [error] = await updaterModel.checkBoxUpdate();
2025-09-10 18:54:04 +02:00
if (error) {
updateCheckError.value.generic = error.body?.message || 'Internal error';
checkingBusy.value = false;
return;
}
2025-06-30 17:35:01 +02:00
2025-09-10 21:35:26 +02:00
await refreshPendingUpdateInfo();
2025-06-30 17:35:01 +02:00
checkingBusy.value = false;
2025-01-24 14:09:30 +01:00
}
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;
}
2025-01-24 14:00:33 +01:00
onMounted(async () => {
2025-03-12 13:41:07 +01:00
const [error, result] = await dashboardModel.config();
2025-01-24 14:00:33 +01:00
if (error) return console.error(error);
version.value = result.version;
ubuntuVersion.value = result.ubuntuVersion;
2025-09-10 21:35:26 +02:00
await refreshPendingUpdateInfo();
2025-01-24 14:00:33 +01:00
await refreshAutoupdatePattern();
2025-01-24 14:09:30 +01:00
await refreshTasks();
2025-01-24 14:00:33 +01:00
});
2025-01-23 18:36:30 +01:00
</script>
<template>
<div>
2025-06-30 17:35:01 +02:00
<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>
2025-09-10 20:07:29 +02:00
<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>
2025-06-30 17:35:01 +02:00
2025-09-10 21:35:26 +02:00
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
2025-09-10 20:07:29 +02:00
<p v-if="updateError.generic" class="error-label">{{ updateError.generic }}</p>
2025-06-30 17:35:01 +02:00
</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>
2025-01-24 14:00:33 +01:00
<Dialog ref="configureDialog"
:title="$t('settings.updateScheduleDialog.title')"
:confirm-label="$t('main.dialog.save')"
2025-04-24 16:35:44 +02:00
:confirm-active="configureType === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
2025-01-24 14:00:33 +01:00
: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')" />
2025-04-24 16:35:44 +02:00
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')" style="margin-top: 10px"/>
2025-01-24 14:00:33 +01:00
2025-04-24 16:35:44 +02:00
<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>
2025-01-24 14:00:33 +01:00
</div>
</Dialog>
2025-01-23 18:36:30 +01:00
<Section :title="$t('settings.updates.title')">
<template #header-buttons>
2025-09-22 11:09:41 +02:00
<Button tool secondary :menu="taskLogsMenu" :disabled="taskLogsMenu.length === 0">{{ $t('main.action.logs') }}</Button>
2025-01-23 18:36:30 +01:00
</template>
<div v-html="$t('settings.updates.description')"></div>
<br/>
2025-01-24 14:00:33 +01:00
<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>
2025-01-24 14:00:33 +01:00
</div>
</SettingsItem>
2025-01-24 14:00:33 +01:00
2025-08-06 10:28:29 +02:00
<ProgressBar :value="lastTask.percent" v-if="updateBusy && lastTask" :busy="true" />
<p v-if="updateBusy && lastTask">{{ lastTask.message }}</p>
2025-01-24 14:00:33 +01:00
<div class="error-label" v-if="stopError.generic">{{ stopError.generic }}</div>
2025-09-10 18:54:04 +02:00
<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>
2025-01-23 18:36:30 +01:00
</Section>
</div>
</template>
<style>
2025-09-10 20:07:29 +02:00
.changelogs {
padding-left: 15px;
}
.changelogs > li > p {
margin: 0;
2025-09-10 20:07:29 +02:00
padding: 2px;
}
.changelog-container {
overflow: auto;
max-height: 20lh;
2025-09-10 20:07:29 +02:00
margin-bottom: 10px;
padding-right: 0.5rem; /* space so scrollbar doesnt overlap text */
}
2025-09-10 21:35:26 +02:00
.skip-backup {
padding-top: 10px;
}
</style>