401 lines
14 KiB
Vue
401 lines
14 KiB
Vue
<script setup>
|
|
|
|
import { useI18n } from 'vue-i18n';
|
|
const i18n = useI18n();
|
|
const t = i18n.t;
|
|
|
|
import { ref, onMounted, useTemplateRef, reactive, inject, computed } from 'vue';
|
|
import { Button, ProgressBar, InputDialog } from '@cloudron/pankow';
|
|
import { prettyLongDate } from '@cloudron/pankow/utils';
|
|
import ActionBar from '../components/ActionBar.vue';
|
|
import Section from '../components/Section.vue';
|
|
import StateLED from '../components/StateLED.vue';
|
|
import BackupSiteScheduleDialog from '../components/BackupSiteScheduleDialog.vue';
|
|
import BackupSiteAddDialog from '../components/BackupSiteAddDialog.vue';
|
|
import BackupSiteContentDialog from '../components/BackupSiteContentDialog.vue';
|
|
import BackupSiteConfigDialog from '../components/BackupSiteConfigDialog.vue';
|
|
import SystemBackupList from '../components/SystemBackupList.vue';
|
|
import { TASK_TYPES } from '../constants.js';
|
|
import BackupSitesModel from '../models/BackupSitesModel.js';
|
|
import TasksModel from '../models/TasksModel.js';
|
|
import AppsModel from '../models/AppsModel.js';
|
|
|
|
import { prettySchedule } from '../utils.js';
|
|
|
|
const profile = inject('profile');
|
|
|
|
const tasksModel = TasksModel.create();
|
|
const backupSitesModels = BackupSitesModel.create();
|
|
const appsModel = AppsModel.create();
|
|
const allApps = ref([]);
|
|
|
|
const inputDialog = useTemplateRef('inputDialog');
|
|
const systemBackupList = useTemplateRef('systemBackupList');
|
|
|
|
const sites = ref([]);
|
|
const busy = ref(true);
|
|
const hasUpdateBackupSite = computed(() => sites.value.some(site => site.enableForUpdates));
|
|
|
|
const backupSiteAddDialog = useTemplateRef('backupSiteAddDialog');
|
|
function onAdd() {
|
|
backupSiteAddDialog.value.open();
|
|
}
|
|
|
|
const backupSiteContentDialog = useTemplateRef('backupSiteContentDialog');
|
|
function onEditContent(site) {
|
|
backupSiteContentDialog.value.open(site);
|
|
}
|
|
|
|
const backupSiteScheduleDialog = useTemplateRef('backupSiteScheduleDialog');
|
|
function onEditSchedule(site) {
|
|
backupSiteScheduleDialog.value.open(site);
|
|
}
|
|
|
|
const backupSiteConfigDialog = useTemplateRef('backupSiteConfigDialog');
|
|
function onEditConfig(site) {
|
|
backupSiteConfigDialog.value.open(site);
|
|
}
|
|
|
|
function prettyBackupRetention(retention) {
|
|
function stableStringify(obj) { return JSON.stringify(obj, Object.keys(obj).sort()); }
|
|
const tmp = BackupSitesModel.backupRetentions.find(function (p) { return stableStringify(p.id) === stableStringify(retention); });
|
|
return tmp ? tmp.name : '';
|
|
}
|
|
|
|
function prettyBackupContents(contents) {
|
|
if (!contents) return 'Everything';
|
|
|
|
// compute include or exclude links
|
|
const links = [];
|
|
for (const appId of (contents.include || contents.exclude)) {
|
|
if (appId === 'box') {
|
|
links.unshift('System & email'); // keep this as first item
|
|
} else {
|
|
const label = allApps[appId] ? (allApps[appId].label || allApps[appId].fqdn) : appId;
|
|
links.push(`<a href="#/app/${appId}/info">${label}</a>`);
|
|
}
|
|
}
|
|
|
|
if (contents.include) {
|
|
return `Only ${links.join(', ')}`;
|
|
}
|
|
|
|
if (contents.exclude) {
|
|
return `Everything except ${links.join(', ')}`;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
async function onRemoveSite(site) {
|
|
const yes = await inputDialog.value.confirm({
|
|
title: t('backup.site.removeDialog.title'),
|
|
message: t('backup.site.removeDialog.description', { name: site.name }),
|
|
confirmLabel: t('main.action.remove'),
|
|
confirmStyle: 'danger',
|
|
rejectLabel: t('main.dialog.cancel'),
|
|
rejectStyle: 'secondary',
|
|
});
|
|
|
|
if (!yes) return;
|
|
|
|
const [error] = await backupSitesModels.del(site.id);
|
|
if (error) {
|
|
window.pankow.notify({ text: error.body?.message || 'Failed to delete backup site', type: 'danger' });
|
|
return console.error(error);
|
|
}
|
|
|
|
await refresh();
|
|
|
|
systemBackupList.value.refresh();
|
|
}
|
|
|
|
async function onRemount(site) {
|
|
site.status.busy = true;
|
|
|
|
const [error] = await backupSitesModels.remount(site.id);
|
|
if (error) return console.error(error);
|
|
|
|
const [statusError, status] = await backupSitesModels.status(site.id);
|
|
if (statusError) console.error(statusError);
|
|
|
|
site.status.state = status.state === 'active' ? 'success' : 'danger';
|
|
site.status.message = status.message;
|
|
site.status.busy = false;
|
|
}
|
|
|
|
async function onStartBackup(site) {
|
|
const [error, result] = await backupSitesModels.createBackup(site.id);
|
|
if (error) {
|
|
if (error.status === 409) {
|
|
if (error.body.message.indexOf('full_backup') !== -1) window.pankow.notify({ text: 'Backup already in progress. Please retry later.', type: 'danger' });
|
|
else window.pankow.notify({ text: 'App task is currently in progress. Please retry later.', type: 'danger' });
|
|
}
|
|
|
|
return console.error(error);
|
|
}
|
|
|
|
const [taskError, task] = await tasksModel.get(result);
|
|
if (taskError) return console.error(taskError);
|
|
|
|
site.task = task;
|
|
|
|
setTimeout(waitForSiteTask.bind(null,site), 2000);
|
|
}
|
|
|
|
async function onStartCleanup(site) {
|
|
const [error, result] = await backupSitesModels.cleanup(site.id);
|
|
if (error) return console.error(error);
|
|
|
|
const [taskError, task] = await tasksModel.get(result);
|
|
if (taskError) return console.error(taskError);
|
|
|
|
site.task = task;
|
|
|
|
setTimeout(waitForSiteTask.bind(null,site), 2000);
|
|
}
|
|
|
|
function createActionMenu(site) {
|
|
return [{
|
|
icon: 'fa-solid fa-screwdriver-wrench',
|
|
label: t('backups.configAction'),
|
|
visible: profile.value.isAtLeastOwner,
|
|
action: onEditConfig.bind(null, site),
|
|
}, {
|
|
icon: 'fa-solid fa-box-open',
|
|
label: t('backups.contentAction'),
|
|
visible: profile.value.isAtLeastOwner,
|
|
action: onEditContent.bind(null, site),
|
|
}, {
|
|
icon: 'fa-solid fa-clock',
|
|
label: t('backups.schedule.title'),
|
|
visible: profile.value.isAtLeastOwner,
|
|
action: onEditSchedule.bind(null, site),
|
|
},{
|
|
visible: profile.value.isAtLeastOwner,
|
|
separator: true,
|
|
}, {
|
|
icon: 'fa-solid fa-plus',
|
|
label: t('backups.listing.backupNow'),
|
|
disabled: !!site.task?.active,
|
|
quickAction: true,
|
|
action: onStartBackup.bind(null, site),
|
|
}, {
|
|
icon: 'fa-solid fa-broom',
|
|
label: t('backups.listing.cleanupBackups'),
|
|
disabled: !!site.task?.active,
|
|
action: onStartCleanup.bind(null, site),
|
|
}, {
|
|
icon: 'fa-solid fa-sync-alt',
|
|
label: t('backups.location.remount'),
|
|
visible: site.provider === 'sshfs' || site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'ext4' || site.provider === 'xfs',
|
|
quickAction: true,
|
|
action: onRemount.bind(null, site),
|
|
}, {
|
|
visible: profile.value.isAtLeastOwner,
|
|
separator: true,
|
|
}, {
|
|
icon: 'fa-solid fa-trash',
|
|
label: t('volumes.removeVolumeDialog.removeAction'),
|
|
visible: profile.value.isAtLeastOwner,
|
|
action: onRemoveSite.bind(null, site),
|
|
}];
|
|
}
|
|
|
|
async function waitForSiteTask(site) {
|
|
const [error, result] = await tasksModel.get(site.task.id);
|
|
site.progress = result.percent;
|
|
if (error) {
|
|
console.error(error);
|
|
setTimeout(waitForSiteTask.bind(null, site), 2000);
|
|
} else if (result.active) {
|
|
site.task = result;
|
|
setTimeout(waitForSiteTask.bind(null, site), 2000);
|
|
} else {
|
|
systemBackupList.value.refresh();
|
|
site.task = result;
|
|
}
|
|
}
|
|
|
|
async function onCancelTask(taskId) {
|
|
const [error] = await tasksModel.stop(taskId);
|
|
if (error) console.error('Failed to cancel task:', error);
|
|
}
|
|
|
|
async function refreshStatusForSite(site) {
|
|
const [error, status] = await backupSitesModels.status(site.id);
|
|
if (error) return console.error(error);
|
|
|
|
site.status.state = status.state === 'active' ? 'success' : 'danger';
|
|
site.status.message = status.message;
|
|
site.status.busy = false;
|
|
}
|
|
|
|
async function refreshTaskForSite(site) {
|
|
const [error, tasks] = await tasksModel.getByType(TASK_TYPES.TASK_FULL_BACKUP_PREFIX + site.id);
|
|
if (error) return console.error(error);
|
|
|
|
if (tasks[0]) {
|
|
site.task = tasks[0];
|
|
if (site.task.active) setTimeout(waitForSiteTask.bind(null, site), 2000);
|
|
} else {
|
|
site.task = null;
|
|
}
|
|
|
|
site.taskLoaded = true;
|
|
}
|
|
|
|
async function refresh() {
|
|
busy.value = true;
|
|
|
|
const [error, results] = await backupSitesModels.list();
|
|
if (error) return console.error(error);
|
|
|
|
const sortedResults = results.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const sitesWithDetails = [];
|
|
for (const result of sortedResults) {
|
|
// have to make it a reactive object as we manipulate property objects
|
|
const site = reactive(result);
|
|
site.status = { busy: true, state: '', message: '' };
|
|
site.task = null;
|
|
site.taskLoaded = false;
|
|
|
|
// do not wait for it
|
|
refreshStatusForSite(site);
|
|
refreshTaskForSite(site);
|
|
|
|
sitesWithDetails.push(site);
|
|
}
|
|
|
|
sites.value = sitesWithDetails;
|
|
busy.value = false;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const [error, result] = await appsModel.list();
|
|
if (error) return console.error(error);
|
|
allApps.value = {};
|
|
result.forEach(app => allApps[app.id] = app);
|
|
|
|
await refresh();
|
|
});
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="content">
|
|
<InputDialog ref="inputDialog" />
|
|
<BackupSiteAddDialog ref="backupSiteAddDialog" @success="refresh()"/>
|
|
<BackupSiteContentDialog ref="backupSiteContentDialog" @success="refresh()"/>
|
|
<BackupSiteConfigDialog ref="backupSiteConfigDialog" @success="refresh()"/>
|
|
<BackupSiteScheduleDialog ref="backupSiteScheduleDialog" @success="refresh()"/>
|
|
|
|
<Section :title="$t('backup.sites.title')">
|
|
<template #header-buttons>
|
|
<Button v-if="profile.isAtLeastOwner" @click="onAdd()"> {{ $t('main.action.add') }}</Button>
|
|
</template>
|
|
|
|
<div>{{ $t('backup.sites.description') }}</div>
|
|
|
|
<br/>
|
|
|
|
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
|
|
<div class="warning-label" style="margin-bottom: 10px;" v-if="!busy && sites.length > 0 && !hasUpdateBackupSite">{{ $t('backup.sites.noAutomaticUpdateBackupWarning') }}</div>
|
|
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
|
|
<div class="backup-site" v-for="site in sites" :key="site.id">
|
|
<div style="display: flex; align-items: start; margin-top: 6px;">
|
|
<StateLED :busy="site.status.busy" :state="site.status.state"/>
|
|
</div>
|
|
<div class="backup-site-details">
|
|
<div style="margin-bottom: 5px; display: flex; justify-content: space-between; align-items: baseline;">
|
|
<div><b style="font-size: 18px">{{ site.name }}</b></div>
|
|
<ActionBar :actions="createActionMenu(site)"/>
|
|
</div>
|
|
|
|
<div v-if="site.encrypted">
|
|
<span v-if="site.encryptedFilenames">{{ $t('backups.useFileAndFileNameEncryption') }}</span>
|
|
<span v-else>{{ $t('backups.useFileEncryption') }}</span>
|
|
<i class="fa-solid fa-lock"></i>
|
|
</div>
|
|
|
|
<div>
|
|
<b>Storage:</b> {{ site.provider }} ({{ site.format }})
|
|
<span>at {{ site.locationLabel }}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<b>Content:</b> <span v-html="prettyBackupContents(site.contents)"></span>
|
|
</div>
|
|
<div>
|
|
<b>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}:</b> {{ site.enableForUpdates ? $t('main.dialog.yes') : $t('main.dialog.no') }}
|
|
</div>
|
|
|
|
<div>
|
|
<b>{{ $t('backups.schedule.schedule') }}:</b> {{ prettySchedule(site.schedule) }}
|
|
</div>
|
|
<div>
|
|
<b>{{ $t('backups.schedule.retentionPolicy') }}:</b> {{ prettyBackupRetention(site.retention) }}
|
|
</div>
|
|
<div class="backup-site-task">
|
|
<div v-if="!site.task">
|
|
<b>{{ $t('backup.sites.lastRun') }}:</b>
|
|
<span v-if="site.taskLoaded"> Never</span>
|
|
<span v-else> ...</span>
|
|
</div>
|
|
<div v-if="site.task && site.task.success"><b>{{ $t('backup.sites.lastRun') }}:</b> {{ prettyLongDate(site.task.ts) }}</div>
|
|
<div v-if="site.task && site.task.error">
|
|
<b>{{ $t('backup.sites.lastRun') }}:</b> {{ prettyLongDate(site.task.ts) }}
|
|
<div style="margin-top: 5px">
|
|
<a :href="`/logs.html?taskId=${site.task.id}`" target="_blank"><span class="error-label">{{ site.task.error.message }} <Button small plain tool>Logs</Button></span></a>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 10px;" class="text-danger" v-if="site.status.message" v-html="site.status.message"></div>
|
|
<div v-if="site.task && site.task.running" style="margin-top: 10px; display: grid; grid-template-columns: 1fr auto auto; column-gap: 10px; align-items: center;">
|
|
<div style="overflow: hidden;">
|
|
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
|
|
</div>
|
|
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
|
|
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
|
|
<div style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<SystemBackupList ref="systemBackupList"/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
/* should match pankow-table-placeholder */
|
|
.empty-placeholder {
|
|
text-align: center;
|
|
margin-top: 60px;
|
|
}
|
|
|
|
.backup-site {
|
|
display: flex;
|
|
border-radius: var(--pankow-border-radius);
|
|
padding: 10px;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.backup-site-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
flex-grow: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.backup-site-action {
|
|
display: flex;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
}
|
|
</style> |