Locations: edit connection settings without losing the history
A changed password or host name could only be handled by adding a new location, which starts an empty repository and, with a retention lock at the provider, keeps the old one billed. Edit now changes a location in place (web interface and occ backup:target:edit). The settings are only saved when they lead to the same repository; a failed connection and a missing repository give separate errors, and the folder is not created while checking. Stored passwords are never sent to the browser: an empty password field keeps the stored value.
This commit is contained in:
@@ -9,6 +9,14 @@ instead carried by the app description, the git tag, the release, and this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Edit a backup location (web interface and `occ backup:target:edit`): change the password, host,
|
||||
folder, connection type or name without losing its history. Before this the only option was a
|
||||
new location, which starts an empty repository and, with a retention lock at the provider,
|
||||
keeps paying for the old one. The new settings are only saved when they lead to the same
|
||||
repository; stored passwords are never sent to the browser, and an empty password field keeps
|
||||
the stored one. Administrators are notified of the change.
|
||||
|
||||
### Changed
|
||||
- App store listing: the homepage link points to the GitHub repository instead of back to the
|
||||
app store page itself.
|
||||
|
||||
@@ -192,7 +192,7 @@ On the backlog; none of these block the beta or 1.0.0, but know them before you
|
||||
|---|---|
|
||||
| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation |
|
||||
| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
|
||||
| `backup:target:backends/add/list/remove/append-only` | backup locations |
|
||||
| `backup:target:backends/add/edit/list/remove/append-only` | backup locations |
|
||||
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
|
||||
| `backup:browse`, `backup:restore` | browse and restore |
|
||||
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
|
||||
|
||||
@@ -37,6 +37,7 @@ See the README for known limitations and the recovery kit before relying on it.]
|
||||
<command>OCA\NgBackup\Command\KeyConfirm</command>
|
||||
<command>OCA\NgBackup\Command\TargetBackends</command>
|
||||
<command>OCA\NgBackup\Command\TargetAdd</command>
|
||||
<command>OCA\NgBackup\Command\TargetEdit</command>
|
||||
<command>OCA\NgBackup\Command\TargetList</command>
|
||||
<command>OCA\NgBackup\Command\TargetRemove</command>
|
||||
<command>OCA\NgBackup\Command\RunBackup</command>
|
||||
|
||||
@@ -18,6 +18,8 @@ return [
|
||||
['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#targetSettings', 'url' => '/api/targets/{id}/settings', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#updateTarget', 'url' => '/api/targets/{id}', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
|
||||
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
|
||||
|
||||
@@ -50,6 +50,14 @@ occ backup:target:list
|
||||
occ backup:status
|
||||
```
|
||||
|
||||
Changed password, new host name, or moved the repository? Edit the location instead of adding a
|
||||
new one, so its history stays (in the web interface: **Edit** next to the location). The new
|
||||
settings must lead to the same repository; options you do not pass keep their value:
|
||||
|
||||
```
|
||||
occ backup:target:edit nas -o password=<new pw>
|
||||
```
|
||||
|
||||
## 3. Run the first backup
|
||||
|
||||
```
|
||||
|
||||
@@ -21,11 +21,12 @@ final class StorageBackend implements IBackend {
|
||||
public function __construct(
|
||||
private IStorage $storage,
|
||||
private string $base = '',
|
||||
bool $createBase = true,
|
||||
) {
|
||||
$this->base = trim($base, '/');
|
||||
self::validate($this->base, true);
|
||||
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
|
||||
if ($this->base !== '') {
|
||||
if ($createBase && $this->base !== '') {
|
||||
$this->mkdirs($this->base);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +110,15 @@ final class StorageBackend implements IBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the storage accepts the connection settings (files_external's own check). */
|
||||
public function canConnect(): bool {
|
||||
try {
|
||||
return !method_exists($this->storage, 'test') || (bool)$this->storage->test();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $path): bool {
|
||||
return $this->storage->file_exists($this->abs($path));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Service\AlertService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TargetEdit extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private AlertService $alerts,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:target:edit')
|
||||
->setDescription('Change the connection settings or the name of a backup location, keeping its backups')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addOption('name', null, InputOption::VALUE_REQUIRED, 'New name')
|
||||
->addOption('backend', 'b', InputOption::VALUE_REQUIRED, 'New files_external backend id (see backup:target:backends)')
|
||||
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'New auth mechanism id')
|
||||
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Option key=value to change (repeatable); other options keep their value')
|
||||
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'New folder on the location')
|
||||
->setHelp(<<<'HELP'
|
||||
The new settings must lead to the same repository as before, so the history of the location is
|
||||
kept. Use it after a password change, a new host name, or after moving the repository.
|
||||
|
||||
Example:
|
||||
occ backup:target:edit nas -o password=new-secret
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$t = $this->targets->get($input->getArgument('target'));
|
||||
$options = $this->targets->options($t);
|
||||
if ($input->getOption('backend') !== null || $input->getOption('auth') !== null) {
|
||||
$options = []; // a different connection type has its own options
|
||||
}
|
||||
foreach ($input->getOption('option') as $pair) {
|
||||
if (!str_contains($pair, '=')) {
|
||||
$output->writeln("<error>Option '$pair' must be key=value</error>");
|
||||
return 1;
|
||||
}
|
||||
$k = (string)strstr($pair, '=', true);
|
||||
$v = substr($pair, strlen($k) + 1);
|
||||
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
|
||||
}
|
||||
$before = $t->getName();
|
||||
$t = $this->targets->update($t, $input->getOption('name') ?? $t->getName(), $input->getOption('backend') ?? $t->getBackend(),
|
||||
$input->getOption('auth') ?? $t->getAuth(), $options, $input->getOption('path') ?? $t->getBasePath());
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$this->alerts->securityEvent('target_changed', ['target' => $before]);
|
||||
$output->writeln(sprintf('Location <info>%s</info> updated; repository %s found with the new settings.', $t->getName(), (string)$t->getRepositoryId()));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,28 @@ final class AdminApiController extends Controller {
|
||||
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
||||
}
|
||||
|
||||
/** The stored settings for the edit form; password values are left out (null). */
|
||||
#[PasswordConfirmationRequired]
|
||||
public function targetSettings(int $id): JSONResponse {
|
||||
$t = $this->targets->get((string)$id);
|
||||
return new JSONResponse(['name' => $t->getName(), 'backend' => $t->getBackend(), 'auth' => $t->getAuth(),
|
||||
'options' => $this->targets->editableOptions($t), 'path' => $t->getBasePath()]);
|
||||
}
|
||||
|
||||
/** Options with a null value keep their stored value. */
|
||||
#[PasswordConfirmationRequired]
|
||||
public function updateTarget(int $id, string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
|
||||
try {
|
||||
$t = $this->targets->get((string)$id);
|
||||
$before = $t->getName();
|
||||
$t = $this->targets->update($t, $name, $backend, $auth, $options, $path);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$this->alerts->securityEvent('target_changed', ['target' => $before]);
|
||||
return new JSONResponse(['id' => $t->getId()]);
|
||||
}
|
||||
|
||||
/** Size limit in GiB; null or 0 = unlimited. */
|
||||
#[PasswordConfirmationRequired]
|
||||
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
|
||||
|
||||
@@ -59,6 +59,7 @@ final class Notifier implements INotifier {
|
||||
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
||||
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
|
||||
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
|
||||
'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']),
|
||||
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
|
||||
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
|
||||
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
|
||||
|
||||
+102
-17
@@ -37,7 +37,7 @@ final class TargetService {
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
|
||||
/** @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array}> */
|
||||
public function availableBackends(): array {
|
||||
return $this->factory()->describeBackends();
|
||||
}
|
||||
@@ -50,20 +50,8 @@ final class TargetService {
|
||||
* @return array{target: Target, created: bool}
|
||||
*/
|
||||
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
|
||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
||||
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
||||
}
|
||||
// Without the native extension files_external falls back to the smbclient binary, which
|
||||
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
|
||||
// up front instead of letting the admin find out from a failed run.
|
||||
if ($backend === 'smb' && !extension_loaded('smbclient')) {
|
||||
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
|
||||
}
|
||||
try {
|
||||
$this->mapper->findByName($name);
|
||||
throw new \InvalidArgumentException("A target named '$name' already exists");
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
}
|
||||
$this->validateName($name);
|
||||
$this->requireUsableBackend($backend);
|
||||
$keys = $this->keys->keyRing();
|
||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
||||
if ($storageBackend->exists('config')) {
|
||||
@@ -97,6 +85,103 @@ final class TargetService {
|
||||
return ['target' => $target, 'created' => $created];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change how an existing location is reached (credentials, host, folder, even the connection
|
||||
* type) or its name. The new settings must lead to the same repository: the history stays
|
||||
* and a typo cannot silently start a new, empty repository. A null option value keeps the
|
||||
* stored one, so the web interface never has to send stored passwords back.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function update(Target $target, string $name, string $backend, string $auth, array $options, string $basePath): Target {
|
||||
$this->validateName($name, $target->getId());
|
||||
$this->requireUsableBackend($backend);
|
||||
$stored = $this->options($target);
|
||||
foreach ($options as $key => $value) {
|
||||
if ($value === null) {
|
||||
if (array_key_exists($key, $stored)) {
|
||||
$options[$key] = $stored[$key];
|
||||
} else {
|
||||
unset($options[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do not create the folder: a typo in the path must not leave an empty folder behind.
|
||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
||||
if (!$storageBackend->exists('config')) {
|
||||
if (!$storageBackend->canConnect()) {
|
||||
throw new \InvalidArgumentException('Cannot connect with these settings. Check the host, user name and password');
|
||||
}
|
||||
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'. To start an empty repository, add a new location instead");
|
||||
}
|
||||
$repo = Repository::openWithKey($storageBackend, $this->keys->keyRing(), $this->indexCache, $this->anchor);
|
||||
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
||||
throw new \InvalidArgumentException('These settings lead to a different repository than the one of this location');
|
||||
}
|
||||
$target->setName($name);
|
||||
$target->setBackend($backend);
|
||||
$target->setAuth($auth);
|
||||
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
||||
$target->setBasePath(trim($basePath, '/'));
|
||||
return $this->mapper->update($target);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored options for an edit form: password-type values are replaced by null, which
|
||||
* update() reads as "keep the stored value".
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function editableOptions(Target $target): array {
|
||||
$secret = [];
|
||||
foreach ($this->availableBackends() as $b) {
|
||||
if ($b['id'] !== $target->getBackend()) {
|
||||
continue;
|
||||
}
|
||||
$params = $b['parameters'];
|
||||
foreach ($b['auth'] as $a) {
|
||||
if ($a['id'] === $target->getAuth()) {
|
||||
$params += $a['parameters'];
|
||||
}
|
||||
}
|
||||
foreach ($params as $key => $p) {
|
||||
if (($p['type'] ?? 0) === 2) {
|
||||
$secret[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$options = $this->options($target);
|
||||
foreach ($options as $key => $value) {
|
||||
if (isset($secret[$key])) {
|
||||
$options[$key] = null;
|
||||
}
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function validateName(string $name, ?int $ownId = null): void {
|
||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
||||
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
||||
}
|
||||
try {
|
||||
$other = $this->mapper->findByName($name);
|
||||
} catch (DoesNotExistException) {
|
||||
return;
|
||||
}
|
||||
if ($other->getId() !== $ownId) {
|
||||
throw new \InvalidArgumentException("A target named '$name' already exists");
|
||||
}
|
||||
}
|
||||
|
||||
private function requireUsableBackend(string $backend): void {
|
||||
// Without the native extension files_external falls back to the smbclient binary, which
|
||||
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
|
||||
// up front instead of letting the admin find out from a failed run.
|
||||
if ($backend === 'smb' && !extension_loaded('smbclient')) {
|
||||
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A target's options are encrypted with Nextcloud's own instance secret (ICrypto), not
|
||||
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
|
||||
@@ -193,8 +278,8 @@ final class TargetService {
|
||||
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
|
||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
||||
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
|
||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath, $createBase);
|
||||
}
|
||||
|
||||
private ?ExternalStorageFactory $factory = null;
|
||||
|
||||
@@ -474,12 +474,6 @@
|
||||
</RedundantCastGivenDocblockType>
|
||||
</file>
|
||||
<file src="lib/Service/TargetService.php">
|
||||
<InvalidReturnStatement>
|
||||
<code><![CDATA[$this->factory()->describeBackends()]]></code>
|
||||
</InvalidReturnStatement>
|
||||
<InvalidReturnType>
|
||||
<code><![CDATA[list<array{id:string, name:string, auth:list<string>, parameters:array}>]]></code>
|
||||
</InvalidReturnType>
|
||||
<UndefinedDocblockClass>
|
||||
<code><![CDATA[Server::get(\OCA\Files_External\Service\BackendService::class)]]></code>
|
||||
</UndefinedDocblockClass>
|
||||
|
||||
@@ -27,6 +27,8 @@ export default {
|
||||
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
|
||||
backends: () => call('get', '/backends'),
|
||||
addTarget: (target) => call('post', '/targets', target),
|
||||
targetSettings: (id) => call('get', `/targets/${id}/settings`),
|
||||
updateTarget: (id, target) => call('put', `/targets/${id}`, target),
|
||||
removeTarget: (id) => call('delete', `/targets/${id}`),
|
||||
testTarget: (id) => call('post', `/targets/${id}/test`),
|
||||
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
|
||||
</span>
|
||||
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
|
||||
<NcButton variant="tertiary" @click="startEdit(target)">{{ t('ng_backup', 'Edit') }}</NcButton>
|
||||
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||
<form v-else class="ngb-form" @submit.prevent="add">
|
||||
<NcButton v-if="!adding" @click="startAdd()">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||
<form v-else class="ngb-form" @submit.prevent="save">
|
||||
<NcNoteCard v-if="editing" type="info">
|
||||
{{ t('ng_backup', 'Editing "{name}". The new settings must lead to the same repository, so its backups and history are kept. Leave a password field empty to keep the stored password.', { name: editing.name }) }}
|
||||
</NcNoteCard>
|
||||
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
|
||||
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
|
||||
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
|
||||
@@ -32,8 +36,8 @@
|
||||
<NcTextField v-model="form.maxGb" type="number" min="0" :label="t('ng_backup', 'Maximum size in GiB')"
|
||||
:helper-text="form.maxGb ? t('ng_backup', 'Backups stop at this size; administrators are warned at 80%.') : t('ng_backup', 'Empty = unlimited: everything stored is billed by your provider, and nothing stops a backup that keeps growing.')" />
|
||||
<div class="ngb-row">
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
|
||||
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ editing ? t('ng_backup', 'Connect and save') : t('ng_backup', 'Connect and add') }}</NcButton>
|
||||
<NcButton @click="adding = false; editing = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
@@ -41,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
@@ -58,6 +62,7 @@ defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const adding = ref(false)
|
||||
const editing = ref(null) // the target being edited, or null while adding
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const backends = ref([])
|
||||
@@ -83,35 +88,83 @@ const toFields = (params) => Object.entries(params || {})
|
||||
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
|
||||
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
||||
|
||||
let prefilling = false
|
||||
watch(backend, (b) => {
|
||||
if (prefilling) {
|
||||
return
|
||||
}
|
||||
auth.value = b?.auth?.[0] ?? null
|
||||
form.options = {}
|
||||
})
|
||||
|
||||
async function startAdd() {
|
||||
adding.value = true
|
||||
error.value = ''
|
||||
async function loadBackends() {
|
||||
if (!backends.value.length) {
|
||||
try {
|
||||
backends.value = await api.backends()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
backends.value = await api.backends()
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
busy.value = true
|
||||
async function startAdd() {
|
||||
error.value = ''
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
||||
backend.value = null
|
||||
adding.value = true
|
||||
try {
|
||||
await loadBackends()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function startEdit(target) {
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
|
||||
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
|
||||
await loadBackends()
|
||||
const settings = await api.targetSettings(target.id)
|
||||
prefilling = true
|
||||
backend.value = backends.value.find((b) => b.id === settings.backend) ?? null
|
||||
auth.value = backend.value?.auth.find((a) => a.id === settings.auth) ?? null
|
||||
Object.assign(form, { name: settings.name, path: settings.path, options: Object.fromEntries(Object.entries(settings.options).map(([k, v]) => [k, v ?? ''])),
|
||||
maxGb: target.maxBytes ? String(+(target.maxBytes / 1073741824).toFixed(2)) : '' })
|
||||
await nextTick()
|
||||
prefilling = false
|
||||
editing.value = target
|
||||
adding.value = true
|
||||
} catch (e) {
|
||||
prefilling = false
|
||||
error.value = e?.message || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
const maxGb = form.maxGb === '' ? null : Number(form.maxGb)
|
||||
try {
|
||||
await confirmPassword()
|
||||
if (editing.value) {
|
||||
// An empty password field sends null: the server keeps the stored password.
|
||||
const options = { ...form.options }
|
||||
for (const field of fields.value) {
|
||||
if (field.type === 2 && !options[field.key]) {
|
||||
options[field.key] = null
|
||||
}
|
||||
}
|
||||
await api.updateTarget(editing.value.id, { name: form.name, backend: backend.value.id, auth: auth.value.id, options, path: form.path })
|
||||
if (maxGb !== (editing.value.maxBytes ? Number((editing.value.maxBytes / 1073741824).toFixed(2)) : null)) {
|
||||
await api.setTargetLimit(editing.value.id, maxGb)
|
||||
}
|
||||
delete tests[editing.value.id]
|
||||
} else {
|
||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path, maxGb })
|
||||
}
|
||||
adding.value = false
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
error.value = e?.message || ''
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user