Add basic archive restore dialog
This commit is contained in:
308
dashboard/src/components/AppArchive.vue
Normal file
308
dashboard/src/components/AppArchive.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<script setup>
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN || window.location.origin;
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ButtonGroup, FormGroup, TextInput, Dropdown, TableView, InputDialog, Dialog } from 'pankow';
|
||||
import { prettyLongDate } from 'pankow/utils';
|
||||
import { SECRET_PLACEHOLDER } from '../constants.js';
|
||||
import Section from '../components/Section.vue';
|
||||
import PortBindings from '../components/PortBindings.vue';
|
||||
import ArchivesModel from '../models/ArchivesModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import { download } from '../utils.js';
|
||||
|
||||
const props = defineProps({
|
||||
config: Object
|
||||
});
|
||||
|
||||
const archivesModel = ArchivesModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const columns = {
|
||||
icon: {}, // archived
|
||||
location: { label: t('backups.archives.location'), sort: true },
|
||||
info: { label: t('backups.archives.info'), sort: false },
|
||||
creationTime: { label: t('main.table.date'), sort: true },
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const busy = ref(true);
|
||||
const archives = ref([]);
|
||||
|
||||
async function refreshArchives() {
|
||||
const [error, result] = await archivesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
// ensure we use the full api oprigin
|
||||
result.forEach(a => {
|
||||
a.iconUrl = window.cloudronApiOrigin + a.iconUrl;
|
||||
});
|
||||
|
||||
archives.value = result;
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
async function onRemove(archive) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
// title: t('backups.deleteArchiveDialog.title',{ appTitle: archive.title, fqdn: archive.fqdn }),
|
||||
message: t('backups.deleteArchiveDialog.description'),
|
||||
modal: true,
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('backups.deleteArchive.deleteAction'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await archivesModel.remvove(archive.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refreshArchives();
|
||||
}
|
||||
|
||||
const restoreDialog = useTemplateRef('restoreDialog');
|
||||
const restoreArchive = ref({});
|
||||
const restoreFqdn = ref('');
|
||||
const restoreManifest = ref({});
|
||||
const restoreLocation = ref('');
|
||||
const restoreDomain = ref('');
|
||||
const restoreNeedsOverwrite = ref(false);
|
||||
const restoreBusy = ref(false);
|
||||
const restoreError = ref({});
|
||||
const restoreTcpPorts = ref({});
|
||||
const restoreUdpPorts = ref({});
|
||||
const domains = ref([]);
|
||||
const restoreSecondaryDomains = ref([]);
|
||||
|
||||
async function onRestore(archive) {
|
||||
console.log(archive)
|
||||
restoreBusy.value = false;
|
||||
restoreError.value = {};
|
||||
|
||||
const [error, result] = await domainsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
domains.value = result;
|
||||
|
||||
const app = archive.appConfig || {
|
||||
subdomain: '',
|
||||
domain: domains.value[0].domain,
|
||||
secondaryDomains: [],
|
||||
portBindings: {}
|
||||
}; // pre-8.2 backups do not have appConfig
|
||||
|
||||
restoreLocation.value = app.subdomain;
|
||||
const d = domains.value.find(function (d) { return app.domain === d.domain; });
|
||||
console.log(d)
|
||||
restoreDomain.value = d ? d.domain : domains.value[0].domain; // try to pre-select the app's domain
|
||||
restoreSecondaryDomains.value = {};
|
||||
restoreNeedsOverwrite.value = false;
|
||||
restoreArchive.value = archive;
|
||||
restoreFqdn.value =archive.appConfig?.fqdn || '-';
|
||||
restoreManifest.value = archive.manifest;
|
||||
|
||||
const httpPorts = archive.manifest.httpPorts || {};
|
||||
for (const env in httpPorts) {
|
||||
restoreSecondaryDomains.value[env] = {
|
||||
subdomain: httpPorts[env].defaultValue || '',
|
||||
domain: restoreSecondaryDomains.value.domain
|
||||
};
|
||||
}
|
||||
|
||||
// now fill secondaryDomains with real values, if exists
|
||||
if (archive.secondaryDomains) {
|
||||
archive.secondaryDomains.forEach(function (sd) {
|
||||
restoreSecondaryDomains.value[sd.environmentVariable] = {
|
||||
subdomain: sd.subdomain,
|
||||
domain: domains.value.find(function (d) { return sd.domain === d.domain; })
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Portbinding map only for information
|
||||
restoreTcpPorts.value = archive.manifest.tcpPorts || {};
|
||||
restoreUdpPorts.value = archive.manifest.udpPorts || {};
|
||||
|
||||
// set default ports for tcp
|
||||
for (const env in restoreTcpPorts.value) {
|
||||
if (archive.portBindings[env]) { // was enabled in the app
|
||||
restoreTcpPorts.value[env] = app.portBindings[env].hostPort;
|
||||
} else {
|
||||
restoreTcpPorts.value[env] = restoreTcpPorts.value[env].defaultValue || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// set default ports for udp
|
||||
for (const env in restoreUdpPorts.value) {
|
||||
if (archive.portBindings[env]) { // was enabled in the app
|
||||
restoreUdpPorts.value[env] = app.portBindings[env].hostPort;
|
||||
} else {
|
||||
restoreUdpPorts.value[env] = restoreUdpPorts.value[env].defaultValue || 0;
|
||||
}
|
||||
}
|
||||
|
||||
restoreDialog.value.open();
|
||||
}
|
||||
|
||||
async function onRestoreSubmit() {
|
||||
restoreBusy.value = true;
|
||||
restoreError.value = {};
|
||||
|
||||
const secondaryDomains = {};
|
||||
for (const env in restoreSecondaryDomains.value) {
|
||||
secondaryDomains[env] = {
|
||||
subdomain: restoreSecondaryDomains.value[env].subdomain,
|
||||
domain: restoreSecondaryDomains.value[env].domain.domain
|
||||
};
|
||||
}
|
||||
|
||||
// only use enabled ports
|
||||
// const finalPorts = {};
|
||||
// for (var env in $scope.archiveRestore.ports) {
|
||||
// if ($scope.archiveRestore.portsEnabled[env]) {
|
||||
// finalPorts[env] = $scope.archiveRestore.ports[env];
|
||||
// }
|
||||
// }
|
||||
|
||||
const data = {
|
||||
subdomain: restoreLocation.value,
|
||||
domain: restoreDomain.value,
|
||||
secondaryDomains: secondaryDomains,
|
||||
// ports: finalPorts,
|
||||
ports: {},
|
||||
overwriteDns: restoreNeedsOverwrite.value,
|
||||
};
|
||||
|
||||
|
||||
// Client.unarchiveApp($scope.archiveRestore.archive.id, data, function (error/*, newApp */) {
|
||||
// $scope.archiveRestore.busy = false;
|
||||
|
||||
// if (error) {
|
||||
// var errorMessage = error.message.toLowerCase();
|
||||
// if (errorMessage.indexOf('port') !== -1) {
|
||||
// $scope.archiveRestore.error.port = error.message;
|
||||
// } else if (error.message.indexOf('location') !== -1 || error.message.indexOf('subdomain') !== -1) {
|
||||
// // TODO extract fqdn from error message, currently we just set it always to the main location
|
||||
// $scope.archiveRestore.error.location = { type: 'internally_exists', fqdn: data.subdomain + '.' + data.domain, message: error.message };
|
||||
// $('#cloneLocationInput').focus();
|
||||
// } else {
|
||||
// Client.error(error);
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
|
||||
|
||||
const [error] = await archivesModel.restore(restoreArchive.value.id, data);
|
||||
if (error) {
|
||||
if (error.type === 'externally_exists') {
|
||||
restoreNeedsOverwrite.value = true;
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
restoreBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
restoreBusy.value = false;
|
||||
restoreDialog.value.close();
|
||||
|
||||
window.location.href = '/#/apps';
|
||||
}
|
||||
|
||||
async function onDownloadConfig(archive) {
|
||||
// secrets and tokens already come with placeholder characters we remove them
|
||||
const tmp = {
|
||||
remotePath: archive.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 = `${archive.appConfig.fqdn}-archive-config-${(new Date(archive.creationTime)).toISOString().split('T')[0]}.json`;
|
||||
download(filename, JSON.stringify(tmp, null, 4));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshArchives();
|
||||
busy.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<Dialog ref="restoreDialog"
|
||||
:title="$t('backups.restoreArchiveDialog.title')"
|
||||
reject-style="secondary"
|
||||
:reject-label="restoreBusy ? '' : $t('main.dialog.cancel')"
|
||||
:confirm-label="$t('backups.restoreArchiveDialog.restoreAction', { dnsOverwrite: restoreNeedsOverwrite })"
|
||||
:confirm-busy="restoreBusy"
|
||||
:confirm-active="!restoreBusy"
|
||||
@confirm="onRestoreSubmit()"
|
||||
>
|
||||
<p v-html="$t('backups.restoreArchiveDialog.description', { appId: restoreManifest.id, fqdn: restoreFqdn, creationTime: prettyLongDate(restoreArchive.creationTime) })"></p>
|
||||
|
||||
<!-- <div ng-show="archiveRestore.error.location.fqdn === archiveRestore.subdomain + '.' + archiveRestore.domain.domain"><small>{{ archiveRestore.error.location.message }}</small></div> -->
|
||||
|
||||
<form @submit.prevent="onRestoreSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="locationInput">{{ $t('app.cloneDialog.location') }}</label>
|
||||
<div style="display: flex;">
|
||||
<TextInput id="locationInput" ref="locationInput" v-model="restoreLocation" style="flex-grow: 1;" />
|
||||
<Dropdown v-model="restoreDomain" :options="domains" option-label="domain" option-key="domain" />
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-for="(port, key) in restoreSecondaryDomains" :key="key">
|
||||
<label :for="'secondaryDomainInput' + key">{{ port.title }}</label>
|
||||
<small>{{ port.description }}</small>
|
||||
<div style="display: flex;">
|
||||
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1;" />
|
||||
<Dropdown v-model="port.domain" :options="domains" option-label="domain" option-key="domain" />
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- <p class="text-small text-warning" ng-show="archiveRestore.domain.provider === 'noop' || archiveRestore.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((archiveRestore.subdomain ? archiveRestore.subdomain + '.' : '') + archiveRestore.domain.domain) }"></p> -->
|
||||
<!-- <div class="has-error text-center" ng-show="archiveRestore.error.port">{{ archiveRestore.error.port }}</div> -->
|
||||
<PortBindings v-model:tcp-ports="tcpPorts" v-model:udp-ports="udpPorts" :error="restoreError"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('backups.archives.title')">
|
||||
<p v-html="$t('backups.archive.description')"></p>
|
||||
|
||||
<TableView :columns="columns" :model="archives" :busy="busy" @row-click="onInfo">
|
||||
<template #icon="archive">
|
||||
<img :src="archive.iconUrl || 'img/appicon_fallback.png'" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'" height="24" width="24"/>
|
||||
</template>
|
||||
|
||||
<!-- for pre-8.2 backups, appConfig can be null -->
|
||||
<template #location="archive">{{ archive.appConfig ? archive.appConfig.fqdn : '-' }}</template>
|
||||
|
||||
<template #info="archive">
|
||||
<span v-tooltip="`${archive.manifest.id}@${archive.manifest.version}`">{{ archive.manifest.title }}</span>
|
||||
</template>
|
||||
|
||||
<template #creationTime="archive">{{ prettyLongDate(archive.creationTime) }}</template>
|
||||
|
||||
<template #actions="archive">
|
||||
<div class="table-actions">
|
||||
<ButtonGroup>
|
||||
<Button tool secondary small icon="fa-solid fa-history" v-tooltip="'Restore from Archive'" @click.stop="onRestore(archive)"></Button>
|
||||
<Button tool secondary small icon="fa-solid fa-file-alt" v-tooltip="$t('backups.listing.tooltipDownloadBackupConfig')" @click.stop="onDownloadConfig(archive)"></Button>
|
||||
</ButtonGroup>
|
||||
<Button tool danger small icon="fa-solid fa-trash-alt" v-tooltip="'Delete Archive'" @click.stop="onRemove(archive)"></Button>
|
||||
</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user