Migrate first parts of backups view to vue

This commit is contained in:
Johannes Zellner
2025-02-04 15:10:38 +01:00
parent 55939f6320
commit 776e65bc5e
10 changed files with 631 additions and 16 deletions

View File

@@ -0,0 +1,140 @@
<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, InputDialog, Dialog, FormGroup, TableView } from 'pankow';
import { prettyLongDate } from 'pankow/utils';
import { TASK_TYPES } from '../constants.js';
import Section from '../components/Section.vue';
import BackupsModel from '../models/BackupsModel.js';
import AppsModel from '../models/AppsModel.js';
import TasksModel from '../models/TasksModel.js';
const backupsModel = BackupsModel.create();
const appsModel = AppsModel.create();
const tasksModel = TasksModel.create();
const columns = {
archived: {},
packageVersion: { label: t('backups.listing.version'), sort: true },
creationTime: { label: t('main.table.date'), sort: true },
content: { label: t('backups.listing.contents'), sort: false },
actions: {}
};
const busy = ref(true);
const backups = ref([]);
const taskLogsMenu = ref([]);
const backupTask = ref({});
async function waitForTask() {
if (!backupTask.value.id) return;
const [error, result] = await tasksModel.get(backupTask.value.id);
if (error) return console.error(error);
backupTask.value = result;
// task done, refresh menu
if (!result.active) return await refreshTasks();
setTimeout(waitForTask, 2000);
}
async function refreshTasks() {
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_BACKUP);
if (error) return console.error(error);
backupTask.value = result[0] || {};
// limit to last 10
taskLogsMenu.value = result.slice(0,10).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}`); }
};
});
// if last task is currently active, start polling
if (backupTask.value.active) waitForTask();
}
async function refreshBackups() {
const [error, result] = await backupsModel.list();
if (error) return console.error(error);
// add contents property
const appsById = {};
const appsByFqdn = {};
const [appsError, apps] = await appsModel.list();
if (appsError) console.error(error);
(apps || []).forEach(function (app) {
appsById[app.id] = app;
appsByFqdn[app.fqdn] = app;
});
result.forEach(function (backup) {
backup.contents = []; // { id, label, fqdn }
backup.dependsOn.forEach(function (appBackupId) {
const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return; // for example, 'mail'
const app = appsById[match[1]];
if (app) {
backup.contents.push({
id: app.id,
label: app.label,
fqdn: app.fqdn
});
} else {
backup.contents.push({
id: match[1],
label: null,
fqdn: null
});
}
});
});
backups.value = result;
}
onMounted(async () => {
await refreshBackups();
await refreshTasks();
busy.value = false;
});
</script>
<template>
<Section :title="$t('backups.listing.title')">
<template #header-buttons>
<Button tool icon="fas fa-align-left" v-tooltip="$t('settings.updates.showLogsAction')" :menu="taskLogsMenu" :disabled="!taskLogsMenu.length"/>
</template>
<TableView :columns="columns" :model="backups" :busy="busy">
<template #archived="slotProps">
<i class="fas fa-archive" v-show="slotProps.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i>
</template>
<template #creationTime="slotProps">{{ prettyLongDate(slotProps.creationTime) }}</template>
<template #content="slotProps">
<span v-if="slotProps.contents.length">{{ $t('backups.listing.appCount', { appCount: slotProps.contents.length }) }}</span>
<span v-else>{{ $t('backups.listing.noApps') }}</span>
</template>
<template #actions="slotProps">
<div class="table-actions">
<Button tool secondary small icon="fa-solid fa-pencil-alt" v-tooltip="$t('backups.listing.tooltipEditBackup')" @click="onEdit(slotProps)"></Button>
<Button tool secondary small icon="fa-solid fa-file-alt" v-tooltip="$t('backups.listing.tooltipDownloadBackupConfig')" @click="onDownloadConfig(slotProps)"></Button>
</div>
</template>
</TableView>
</Section>
</template>

View File

@@ -0,0 +1,263 @@
<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, InputDialog, Dialog, FormGroup, MultiSelect } from 'pankow';
import { prettyLongDate } from 'pankow/utils';
import Section from '../components/Section.vue';
import { TASK_TYPES } from '../constants.js';
import TasksModel from '../models/TasksModel.js';
import BackupsModel from '../models/BackupsModel.js';
const props = defineProps({
profile: Object
});
const backupsModel = BackupsModel.create();
const tasksModel = TasksModel.create();
const backupRetentions = [
{ name: '2 days', id: { keepWithinSecs: 2 * 24 * 60 * 60 }},
{ name: '1 week', id: { keepWithinSecs: 7 * 24 * 60 * 60 }}, // default
{ name: '1 month', id: { keepWithinSecs: 30 * 24 * 60 * 60 }},
{ name: '3 months', id: { keepWithinSecs: 3 * 30 * 24 * 60 * 60 }},
{ name: '2 daily, 4 weekly', id: { keepDaily: 2, keepWeekly: 4 }},
{ name: '3 daily, 4 weekly, 6 monthly', id: { keepDaily: 3, keepWeekly: 4, keepMonthly: 6 }},
{ name: '7 daily, 4 weekly, 12 monthly', id: { keepDaily: 7, keepWeekly: 4, keepMonthly: 12 }},
{ name: 'Forever', id: { keepWithinSecs: -1 }}
];
// values correspond to cron days
const cronDays = [
{ name: 'Sunday', id: 0 },
{ name: 'Monday', id: 1 },
{ name: 'Tuesday', id: 2 },
{ name: 'Wednesday', id: 3 },
{ name: 'Thursday', id: 4 },
{ name: 'Friday', id: 5 },
{ name: 'Saturday', id: 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(function (v, i) { return { name: (i < 10 ? '0' : '') + i + ':00', id: i }; });
function prettyBackupSchedule(pattern) {
if (!pattern) return '';
const tmp = pattern.split(' ');
const hours = tmp[2].split(',');
const days = tmp[5].split(',');
let prettyDay;
if (days.length === 7 || days[0] === '*') {
prettyDay = 'Everyday';
} else {
prettyDay = days.map(function (day) { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(',');
}
const prettyHour = hours.map(function (hour) { return cronHours[parseInt(hour, 10)].name; }).join(',');
return prettyDay + ' at ' + prettyHour;
};
function prettyBackupRetention(retention) {
const retentionString = JSON.stringify(retention);
const tmp = backupRetentions.find(function (p) { return JSON.stringify(p.id) === retentionString; });
return tmp ? tmp.name : '';
};
const inputDialog = useTemplateRef('inputDialog');
const taskLogsMenu = ref([]);
const policy = ref({
schedule: '',
retention: ''
});
const cleanupBusy = ref(false);
const cleanupTask = ref({});
async function onCleanup() {
const yes = await inputDialog.value.confirm({
// title: t('backups.cleanupBackups.title'),
message: t('backups.cleanupBackups.description'),
modal: true,
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no')
});
if (!yes) return;
const [error] = await backupsModel.cleanup();
if (error) return console.error(error);
await refreshTasks();
}
const configureBusy = ref(false);
const configureError = ref('');
const configureDialog = useTemplateRef('configureDialog');
const configureDays = ref([]);
const configureHours = ref([]);
const configureRetention = ref('');
const isConfigureValid = computed(() => {
return configureDays.value.length && configureHours.value.length;
});
function onConfigure() {
configureBusy.value = false;
configureError.value = false;
const currentRetentionString = JSON.stringify(policy.value.retention);
let selectedRetention = backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
if (!selectedRetention) selectedRetention = backupRetentions[0];
configureRetention.value = selectedRetention.id;
const tmp = policy.value.schedule.split(' ');
const hours = tmp[2].split(',');
const days = tmp[5].split(',');
if (days[0] === '*') configureDays.value = [ ...cronDays ];
else configureDays.value = days.map(function (day) { return cronDays[parseInt(day, 10)]; });
configureHours.value = hours.map(function (hour) { return cronHours[parseInt(hour, 10)]; });
configureDialog.value.open();
}
async function onSubmitConfigure() {
if (!isConfigureValid.value) return;
configureBusy.value = true;
let daysPattern;
if (configureDays.value.length === 7) daysPattern = '*';
else daysPattern = configureDays.value.map(function (d) { return d.id; });
let hoursPattern;
if (configureHours.value.length === 24) hoursPattern = '*';
else hoursPattern = configureHours.value.map(function (d) { return d.id; });
const policy = {
retention: configureRetention.value,
schedule: `00 00 ${hoursPattern} * * ${daysPattern}`
};
const [error] = await backupsModel.setPolicy(policy);
if (error) {
configureBusy.value = false;
configureError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
configureDialog.value.close();
await refreshPolicy();
configureBusy.value = false;
}
async function waitForTask() {
if (!cleanupTask.value.id) return;
const [error, result] = await tasksModel.get(cleanupTask.value.id);
if (error) return console.error(error);
cleanupTask.value = result;
// task done, refresh menu
if (!result.active) return await refreshTasks();
setTimeout(waitForTask, 2000);
}
async function refreshTasks() {
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_CLEAN_BACKUPS);
if (error) return console.error(error);
cleanupTask.value = result[0] || {};
// limit to last 10
taskLogsMenu.value = result.slice(0,10).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}`); }
};
});
// if last task is currently active, start polling
if (cleanupTask.value.active) waitForTask();
}
async function refreshPolicy() {
const [error, result] = await backupsModel.getPolicy();
if (error) return console.error(error);
policy.value = result;
}
onMounted(async () => {
refreshTasks();
refreshPolicy();
});
</script>
<template>
<InputDialog ref="inputDialog" />
<Dialog ref="configureDialog"
:title="$t('backups.configureBackupSchedule.title')"
reject-style="secondary"
:reject-label="configureBusy ? null : $t('main.dialog.cancel')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="configureBusy"
:confirm-active="isConfigureValid"
@confirm="onSubmitConfigure()"
>
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
<form @submit.prevent="onSubmitConfigure()" autocomplete="off">
<fieldset>
<FormGroup>
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule') }}</label>
<p v-html="$t('backups.configureBackupSchedule.scheduleDescription')"></p>
<div style="display: flex;">
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" v-model="configureDays" :options="cronDays" option-label="name"></MultiSelect></div>
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name"></MultiSelect></div>
</div>
</FormGroup>
<FormGroup>
<label for="retentionInput">{{ $t('backups.configureBackupSchedule.retentionPolicy') }}</label>
<select id="retentionInput" v-model="configureRetention">
<option v-for="elem in backupRetentions" :key="elem.id" :value="elem.id">{{ elem.name }}</option>
</select>
</FormGroup>
</fieldset>
</form>
</Dialog>
<Section :title="$t('backups.schedule.title')">
<template #header-buttons>
<Button tool icon="fas fa-align-left" v-tooltip="$t('settings.updates.showLogsAction')" :menu="taskLogsMenu" :disabled="!taskLogsMenu.length"/>
</template>
<p v-html="$t('backups.schedule.description')"></p>
<div class="info-row">
<div class="info-label">{{ $t('backups.schedule.schedule') }}</div>
<div class="info-value">{{ prettyBackupSchedule(policy.schedule) }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.schedule.retentionPolicy') }}</div>
<div class="info-value">{{ prettyBackupRetention(policy.retention) }}</div>
</div>
<!-- TODO maybe track the cleanup -->
<Button @click="onCleanup()" :disabled="cleanupBusy" :loading="cleanupBusy">{{ $t('backups.listing.cleanupBackups') }}</Button>
<Button v-show="props.profile.isAtLeastOwner" @click="onConfigure()">{{ $t('backups.schedule.configure') }}</Button>
</Section>
</template>

View File

@@ -8,7 +8,7 @@ import Section from '../components/Section.vue';
import TasksModel from '../models/TasksModel.js';
import DomainsModel from '../models/DomainsModel.js';
const taskModel = TasksModel.create();
const tasksModel = TasksModel.create();
const domainsModel = DomainsModel.create();
const lastTask = ref({});
@@ -17,7 +17,7 @@ const taskLogsMenu = ref([]);
async function waitForLastTask() {
if (!lastTask.value.id) return;
const [error, result] = await taskModel.get(lastTask.value.id);
const [error, result] = await tasksModel.get(lastTask.value.id);
if (error) return console.error(error);
lastTask.value = result;
@@ -29,7 +29,7 @@ async function waitForLastTask() {
}
async function refreshTasks() {
const [error, result] = await taskModel.getByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS);
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS);
if (error) return console.error(error);
lastTask.value = result[0] || {};