diff --git a/dashboard/src/components/BackupList.vue b/dashboard/src/components/BackupList.vue index 3f9569a24..5120c7e0c 100644 --- a/dashboard/src/components/BackupList.vue +++ b/dashboard/src/components/BackupList.vue @@ -4,21 +4,28 @@ 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 { ref, onMounted, useTemplateRef } from 'vue'; +import { Button, ProgressBar, TableView, Dialog } from 'pankow'; import { prettyLongDate } from 'pankow/utils'; -import { TASK_TYPES } from '../constants.js'; +import { TASK_TYPES, SECRET_PLACEHOLDER } 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'; +import DashboardModel from '../models/DashboardModel.js'; +import { download } from '../utils.js'; + +const props = defineProps({ + config: Object +}); const backupsModel = BackupsModel.create(); const appsModel = AppsModel.create(); const tasksModel = TasksModel.create(); +const dashboardModel = DashboardModel.create(); const columns = { - archived: {}, + preserveSecs: {}, // 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 }, @@ -28,15 +35,15 @@ const columns = { const busy = ref(true); const backups = ref([]); const taskLogsMenu = ref([]); -const backupTask = ref({}); +const lastTask = ref({}); async function waitForTask() { - if (!backupTask.value.id) return; + if (!lastTask.value.id) return; - const [error, result] = await tasksModel.get(backupTask.value.id); + const [error, result] = await tasksModel.get(lastTask.value.id); if (error) return console.error(error); - backupTask.value = result; + lastTask.value = result; // task done, refresh menu if (!result.active) return await refreshTasks(); @@ -48,7 +55,7 @@ async function refreshTasks() { const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_BACKUP); if (error) return console.error(error); - backupTask.value = result[0] || {}; + lastTask.value = result[0] || {}; // limit to last 10 taskLogsMenu.value = result.slice(0,10).map(t => { @@ -60,7 +67,7 @@ async function refreshTasks() { }); // if last task is currently active, start polling - if (backupTask.value.active) waitForTask(); + if (lastTask.value.active) waitForTask(); } async function refreshBackups() { @@ -103,6 +110,58 @@ async function refreshBackups() { backups.value = result; } + +async function onStartBackup() { + const [error] = await backupsModel.create(); + if (error) { + if (error.status === 409 && error.message.indexOf('full_backup') !== -1) { + // TODO + // $scope.createBackup.errorMessage = 'Backup already in progress. Please retry later.'; + } else if (error.statusCode === 409) { + // TODO + // $scope.createBackup.errorMessage = 'App task is currently in progress. Please retry later.'; + } + + return console.error(error); + } + + await refreshTasks(); +} + +async function onStopBackup() { + const [error] = await tasksModel.stop(lastTask.value.id); + if (error) return console.error(error); +} + +async function onDownloadConfig(backup) { + const [error, result] = await dashboardModel.getConfig(); + if (error) return console.error(error); + + // secrets and tokens already come with placeholder characters we remove them + const tmp = { + remotePath: backup.remotePath, + encrypted: !!props.config.password // we add this just to help the import UI + }; + + Object.keys(props.config).forEach((k) => { + if (props.config[k] !== SECRET_PLACEHOLDER) tmp[k] = props.config[k]; + }); + + const filename = `${result.adminFqdn}-backup-config-${(new Date(backup.creationTime)).toISOString().split('T')[0]}.json`; + download(filename, JSON.stringify(tmp, null, 4)); +} + +function onEdit(backup) { + console.log('edit', backup); +} + +const infoDialog = useTemplateRef('infoDialog'); +const infoBackup = ref({ contents: [] }); +function onInfo(backup) { + infoBackup.value = backup; + infoDialog.value.open(); +} + onMounted(async () => { await refreshBackups(); await refreshTasks(); @@ -112,13 +171,51 @@ onMounted(async () => { + + + {{ $t('backups.backupDetails.id') }} + {{ infoBackup.id }} + + + {{ $t('backups.backupEdit.label') }} + {{ infoBackup.label }} + + + {{ $t('backups.backupEdit.remotePath') }} + {{ infoBackup.remotePath }} + + + {{ $t('backups.backupDetails.date') }} + {{ prettyLongDate(infoBackup.creationTime) }} + + + {{ $t('backups.backupDetails.version') }} + {{ infoBackup.packageVersion }} + + + {{ $t('backups.backupDetails.format') }} + {{ infoBackup.format }} + + + + + {{ $t('backups.backupDetails.list', { appCount: infoBackup.contents.length }) }}: + + {{ content.label || content.fqdn }} + {{ content.id }} + + + - - + + @@ -131,10 +228,21 @@ onMounted(async () => { - - + + + + + + {{ lastTask.message }} + + + + + + {{ $t('backups.listing.stopTask') }} + {{ $t('backups.listing.backupNow') }} diff --git a/dashboard/src/constants.js b/dashboard/src/constants.js index 638ee57e1..45cf450d0 100644 --- a/dashboard/src/constants.js +++ b/dashboard/src/constants.js @@ -101,6 +101,8 @@ const ENDPOINTS_OVH = [ { name: 'Kimsufi North-America', value: 'kimsufi-ca' }, ]; +const SECRET_PLACEHOLDER = String.fromCharCode(0x25CF).repeat(8); + // named exports export { APP_TYPES, @@ -113,6 +115,7 @@ export { PROXY_APP_ID, TOKEN_TYPES, ENDPOINTS_OVH, + SECRET_PLACEHOLDER, }; // default export @@ -127,4 +130,5 @@ export default { PROXY_APP_ID, TOKEN_TYPES, ENDPOINTS_OVH, + SECRET_PLACEHOLDER, }; diff --git a/dashboard/src/models/BackupsModel.js b/dashboard/src/models/BackupsModel.js index 4bee0f19f..1d3aae9cb 100644 --- a/dashboard/src/models/BackupsModel.js +++ b/dashboard/src/models/BackupsModel.js @@ -20,6 +20,17 @@ function create() { if (error || result.status !== 200) return [error || result]; return [null, result.body.backups]; }, + async create() { + let error, result; + try { + result = await fetcher.post(`${origin}/api/v1/backups/create`, {}, { access_token: accessToken }); + } catch (e) { + error = e; + } + + if (error || result.status !== 202) return [error || result]; + return [null, result.body.taskId]; + }, async getConfig() { let error, result; try { diff --git a/dashboard/src/models/TasksModel.js b/dashboard/src/models/TasksModel.js index 1f076bc5a..16b9a76a3 100644 --- a/dashboard/src/models/TasksModel.js +++ b/dashboard/src/models/TasksModel.js @@ -39,6 +39,17 @@ function create() { if (error || result.status !== 200) return [error || result]; return [null, result.body]; }, + async stop(id) { + let error, result; + try { + result = await fetcher.post(`${origin}/api/v1/tasks/${id}/stop`, {}, { access_token: accessToken }); + } catch (e) { + error = e; + } + + if (error || result.status !== 204) return [error || result]; + return [null]; + } }; } diff --git a/dashboard/src/utils.js b/dashboard/src/utils.js new file mode 100644 index 000000000..6ad426677 --- /dev/null +++ b/dashboard/src/utils.js @@ -0,0 +1,24 @@ + +// https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server#18197341 +function download(filename, text) { + var element = document.createElement('a'); + element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +// named exports +export { + download, +}; + +// default export +export default { + download, +}; diff --git a/dashboard/src/views/BackupsView.vue b/dashboard/src/views/BackupsView.vue index be5d31477..60425032c 100644 --- a/dashboard/src/views/BackupsView.vue +++ b/dashboard/src/views/BackupsView.vue @@ -118,6 +118,6 @@ onMounted(async () => { - +
{{ $t('backups.backupDetails.list', { appCount: infoBackup.contents.length }) }}: