Files
cloudron-box/dashboard/src/views/BackupSitesView.vue
T
2025-09-25 12:20:45 +02:00

284 lines
8.8 KiB
Vue

<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, reactive } from 'vue';
import { Button, Menu, ProgressBar, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import Section from '../components/Section.vue';
import StateLED from '../components/StateLED.vue';
import BackupScheduleDialog from '../components/BackupScheduleDialog.vue';
import BackupSiteAddDialog from '../components/BackupSiteAddDialog.vue';
import BackupSiteEditDialog from '../components/BackupSiteEditDialog.vue';
import { TASK_TYPES } from '../constants.js';
import BackupSitesModel from '../models/BackupSitesModel.js';
import ProfileModel from '../models/ProfileModel.js';
import TasksModel from '../models/TasksModel.js';
const profileModel = ProfileModel.create();
const tasksModel = TasksModel.create();
const backupSitesModels = BackupSitesModel.create();
const inputDialog = useTemplateRef('inputDialog');
const profile = ref({});
const sites = ref([]);
const busy = ref(true);
const backupSiteAddDialog = useTemplateRef('backupSiteAddDialog');
function onAdd() {
backupSiteAddDialog.value.open();
}
const backupSiteEditDialog = useTemplateRef('backupSiteEditDialog');
function onEdit(site) {
backupSiteEditDialog.value.open(site);
}
const backupScheduleDialog = useTemplateRef('backupScheduleDialog');
function onEditSchedule(site) {
backupScheduleDialog.value.open(site);
}
async function onRemoveSite(site) {
const yes = await inputDialog.value.confirm({
title: t('backup.site.removeDialog.title'),
message: t('backup.site.removeDialog.description'),
confirmLabel: t('main.dialog.yes'),
confirmStyle: 'danger',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!yes) return;
const [error] = await backupSitesModels.del(site.id);
if (error) console.error(error);
await 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.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);
}
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(site, event) {
actionMenuModel.value = [{
icon: 'fa-solid fa-pencil-alt',
label: t('main.dialog.edit'),
action: onEdit.bind(null, site),
}, {
icon: 'fa-solid fa-clock',
label: t('backups.schedule.title'),
action: onEditSchedule.bind(null, site),
}, {
separator: true
}, {
icon: 'fa-solid fa-plus',
label: t('backups.listing.backupNow'),
action: onStartBackup.bind(null, site),
}, {
icon: 'fa-solid fa-broom',
label: t('backups.listing.cleanupBackups'),
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',
action: onRemount.bind(null, site),
}, {
separator: true,
}, {
icon: 'fa-solid fa-trash',
label: t('volumes.removeVolumeDialog.removeAction'),
action: onRemoveSite.bind(null, site),
}];
actionMenuElement.value.open(event, event.currentTarget);
}
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 {
site.task = result;
}
}
async function refresh() {
busy.value = true;
const [error, result] = await backupSitesModels.list();
if (error) return console.error(error);
sites.value = [];
for (let i = 0 ; i < result.length; i++) {
// have to make it a reactive object as we manipulate property objects
const site = reactive(result[i]);
site.status = { busy: true, state: '', message: '' };
const [error, status] = await backupSitesModels.status(site.id);
if (error) {
console.error(error);
continue;
}
site.status.state = status.state === 'active' ? 'success' : 'danger';
site.status.busy = false;
const [taskError, tasks] = await tasksModel.getByType(TASK_TYPES.TASK_FULL_BACKUP_PREFIX + site.id);
if (taskError) {
console.error(error);
continue;
}
site.task = tasks[0] || null;
if (site.task && site.task.active) setTimeout(waitForSiteTask.bind(null, site), 2000);
sites.value.push(site);
}
busy.value = false;
}
onMounted(async () => {
const [error, result] = await profileModel.get();
if (error) return console.error(error);
profile.value = result;
await refresh();
});
</script>
<template>
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<BackupSiteAddDialog ref="backupSiteAddDialog" @success="refresh()"/>
<BackupSiteEditDialog ref="backupSiteEditDialog" @success="refresh()"/>
<BackupScheduleDialog ref="backupScheduleDialog" @success="refresh()"/>
<Section :title="$t('backup.sites.title')">
<template #header-buttons>
<Button @click="onAdd()"> {{ $t('main.action.add') }}</Button>
</template>
<div>
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
<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: center;">
<StateLED :busy="site.status.busy" :state="site.status.state"/>
</div>
<div class="backup-site-details">
<div><b style="font-size: 16px">{{ site.name }}</b></div>
<div>
{{ $t('backups.configureBackupStorage.provider') }}: <b>{{ site.provider }}</b> - {{ $t('backups.configureBackupStorage.format') }}: <b>{{ site.format }}</b> <i v-if="site.encrypted" class="fa-solid fa-lock"></i></div>
<div class="backup-site-task">
<div v-if="site.task && site.task.success">Last backup: <b>{{ prettyLongDate(site.task.ts) }}</b></div>
<div v-if="site.task && site.task.error">Error: <b>{{ site.task.error }}</b></div>
<div v-if="site.task && site.task.running">
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
<div style="margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
</div>
</div>
</div>
<div style="display: flex; align-items: center;">
<Button tool plain secondary @click.capture="onActionMenu(site, $event)" icon="fa-solid fa-ellipsis" />
</div>
</div>
</div>
</Section>
</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:hover {
background-color: var(--pankow-color-background-hover);
}
.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>