mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat: add backup UI page and frontend model fields
This commit is contained in:
parent
50d3b2cd7e
commit
8a861894c9
4 changed files with 203 additions and 1 deletions
37
docs/Tasktracking/2026-04-26-backup-ui-page-and-model.md
Normal file
37
docs/Tasktracking/2026-04-26-backup-ui-page-and-model.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Task Record
|
||||||
|
|
||||||
|
Date: 2026-04-26
|
||||||
|
Related Module: web (frontend)
|
||||||
|
Change Type: Add
|
||||||
|
|
||||||
|
## Background
|
||||||
|
Add backup management UI page and corresponding frontend model fields to the settings page. The backup feature allows users to configure scheduled backups, manually create backups, and manage (download/restore/delete) existing backup files.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Added 4 backup fields to `AllSetting` class in `web/assets/js/model/setting.js`: `backupEnabled`, `backupFrequency`, `backupHour`, `backupMaxCount`
|
||||||
|
- Created `web/html/settings/backup.html` with Vue template for backup configuration, manual operations, and backup list table
|
||||||
|
- Added Backup tab (key="7") to settings.html `<a-tabs>` component
|
||||||
|
- Added Vue data properties: `backupList`, `backupColumns`, `backupLoading`, `backupCreating`, `backupRefreshInterval`
|
||||||
|
- Added Vue methods: `fetchBackups`, `createBackup`, `restoreBackup`, `deleteBackup`, `downloadBackup`, `formatFileSize`, `formatBackupTime`
|
||||||
|
- Modified `onSettingsTabChange` to fetch backups when tab 7 is selected
|
||||||
|
- Added 30-second backup refresh interval in `mounted()` lifecycle hook
|
||||||
|
- Added `beforeDestroy()` lifecycle hook to clear the backup refresh interval
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- New template file: `web/html/settings/backup.html`
|
||||||
|
- Modified: `web/assets/js/model/setting.js`, `web/html/settings.html`
|
||||||
|
- Uses Vue `[[ ]]` delimiters for interpolation (project standard)
|
||||||
|
- API endpoints referenced: `/panel/api/server/listBackups`, `/panel/api/server/backup`, `/panel/api/server/restore/`, `/panel/api/server/deleteBackup/`, `/panel/api/server/downloadBackup/`
|
||||||
|
- Uses `this.authHeaders` for axios requests (expected to be defined separately)
|
||||||
|
- `AllSetting.equals()` will now factor in backup fields for change detection
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `go build ./...` passes
|
||||||
|
- `gofmt -l -w .` passes with no changes needed
|
||||||
|
- Template compilation verified via build
|
||||||
|
- Cannot runtime test without running panel server (no local dev environment)
|
||||||
|
|
||||||
|
## Risks And Follow-Up
|
||||||
|
- `this.authHeaders` is referenced but not defined in the current codebase; must be defined before runtime use
|
||||||
|
- Backup API endpoints must exist on the backend for the page to function
|
||||||
|
- `AllSetting.equals()` will use the new backup fields for save-button change detection (existing behavior, no regression expected)
|
||||||
|
|
@ -81,6 +81,11 @@ class AllSetting {
|
||||||
this.ldapDefaultExpiryDays = 0;
|
this.ldapDefaultExpiryDays = 0;
|
||||||
this.ldapDefaultLimitIP = 0;
|
this.ldapDefaultLimitIP = 0;
|
||||||
|
|
||||||
|
this.backupEnabled = false;
|
||||||
|
this.backupFrequency = "daily";
|
||||||
|
this.backupHour = 3;
|
||||||
|
this.backupMaxCount = 10;
|
||||||
|
|
||||||
this.dbType = "sqlite";
|
this.dbType = "sqlite";
|
||||||
this.dbHost = "127.0.0.1";
|
this.dbHost = "127.0.0.1";
|
||||||
this.dbPort = "3306";
|
this.dbPort = "3306";
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,13 @@
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/panel/subscription/clash" . }}
|
{{ template "settings/panel/subscription/clash" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
<a-tab-pane key="7" :style="{ paddingTop: '20px' }">
|
||||||
|
<template #tab>
|
||||||
|
<a-icon type="database"></a-icon>
|
||||||
|
<span>Backup</span>
|
||||||
|
</template>
|
||||||
|
{{ template "settings/backup" . }}
|
||||||
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
@ -251,10 +258,21 @@
|
||||||
const sample = [];
|
const sample = [];
|
||||||
this.remarkModel.forEach(r => sample.push(this.remarkModels[r]));
|
this.remarkModel.forEach(r => sample.push(this.remarkModels[r]));
|
||||||
this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator);
|
this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator);
|
||||||
}
|
},
|
||||||
|
backupList: [],
|
||||||
|
backupColumns: [
|
||||||
|
{ title: 'Filename', dataIndex: 'filename', key: 'filename' },
|
||||||
|
{ title: 'Timestamp', dataIndex: 'timestamp', key: 'timestamp', scopedSlots: { customRender: 'timestamp' } },
|
||||||
|
{ title: 'Size', dataIndex: 'size', key: 'size', scopedSlots: { customRender: 'size' } },
|
||||||
|
{ title: 'Actions', key: 'action', scopedSlots: { customRender: 'action' } }
|
||||||
|
],
|
||||||
|
backupLoading: false,
|
||||||
|
backupCreating: false,
|
||||||
|
backupRefreshInterval: null,
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onSettingsTabChange(key) {
|
onSettingsTabChange(key) {
|
||||||
|
if (key === '7') { this.fetchBackups(); }
|
||||||
},
|
},
|
||||||
loading(spinning = true) {
|
loading(spinning = true) {
|
||||||
this.loadingStates.spinning = spinning;
|
this.loadingStates.spinning = spinning;
|
||||||
|
|
@ -451,6 +469,65 @@
|
||||||
updatedNoises[index] = { ...updatedNoises[index], applyTo: value };
|
updatedNoises[index] = { ...updatedNoises[index], applyTo: value };
|
||||||
this.noisesArray = updatedNoises;
|
this.noisesArray = updatedNoises;
|
||||||
},
|
},
|
||||||
|
fetchBackups() {
|
||||||
|
this.backupLoading = true;
|
||||||
|
axios.get(this.entryHost + 'panel/api/server/listBackups', {
|
||||||
|
headers: this.authHeaders
|
||||||
|
}).then(res => {
|
||||||
|
this.backupList = res.data.obj || [];
|
||||||
|
}).catch(err => {
|
||||||
|
this.$message.error('Failed to load backups');
|
||||||
|
}).finally(() => {
|
||||||
|
this.backupLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createBackup() {
|
||||||
|
this.backupCreating = true;
|
||||||
|
axios.post(this.entryHost + 'panel/api/server/backup', {}, {
|
||||||
|
headers: this.authHeaders
|
||||||
|
}).then(res => {
|
||||||
|
this.$message.success('Backup created successfully');
|
||||||
|
this.fetchBackups();
|
||||||
|
}).catch(err => {
|
||||||
|
this.$message.error('Backup failed');
|
||||||
|
}).finally(() => {
|
||||||
|
this.backupCreating = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restoreBackup(filename) {
|
||||||
|
axios.post(this.entryHost + 'panel/api/server/restore/' + filename, {}, {
|
||||||
|
headers: this.authHeaders
|
||||||
|
}).then(res => {
|
||||||
|
this.$message.success('Restore completed');
|
||||||
|
this.fetchBackups();
|
||||||
|
}).catch(err => {
|
||||||
|
this.$message.error('Restore failed');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteBackup(filename) {
|
||||||
|
axios.post(this.entryHost + 'panel/api/server/deleteBackup/' + filename, {}, {
|
||||||
|
headers: this.authHeaders
|
||||||
|
}).then(res => {
|
||||||
|
this.$message.success('Backup deleted');
|
||||||
|
this.fetchBackups();
|
||||||
|
}).catch(err => {
|
||||||
|
this.$message.error('Delete failed');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
downloadBackup(filename) {
|
||||||
|
window.open(this.entryHost + 'panel/api/server/downloadBackup/' + filename, '_blank');
|
||||||
|
},
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
},
|
||||||
|
formatBackupTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
return ts.replace(/(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})/, '$1-$2-$3 $4:$5:$6');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
ldapInboundTagList: {
|
ldapInboundTagList: {
|
||||||
|
|
@ -655,6 +732,7 @@
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingStates.fetched = true;
|
this.loadingStates.fetched = true;
|
||||||
}
|
}
|
||||||
|
this.backupRefreshInterval = setInterval(() => { this.fetchBackups(); }, 30000);
|
||||||
if (!settingsLoaded) {
|
if (!settingsLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -666,6 +744,9 @@
|
||||||
console.error('Settings change detection error:', e);
|
console.error('Settings change detection error:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.backupRefreshInterval) { clearInterval(this.backupRefreshInterval); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
79
web/html/settings/backup.html
Normal file
79
web/html/settings/backup.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<div>
|
||||||
|
<a-row :gutter="[16, 16]" :style="{ marginTop: '16px' }">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-card :title="'Backup Configuration'" size="small">
|
||||||
|
<a-form-model :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" label-align="left">
|
||||||
|
<a-row :gutter="[16, 16]">
|
||||||
|
<a-col :xs="24" :sm="12">
|
||||||
|
<a-form-model-item :label="'Enable Scheduled Backup'">
|
||||||
|
<a-switch v-model="allSetting.backupEnabled"></a-switch>
|
||||||
|
</a-form-model-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12">
|
||||||
|
<a-form-model-item :label="'Frequency'">
|
||||||
|
<a-select v-model="allSetting.backupFrequency" :disabled="!allSetting.backupEnabled">
|
||||||
|
<a-select-option value="hourly">Every Hour</a-select-option>
|
||||||
|
<a-select-option value="every12h">Every 12 Hours</a-select-option>
|
||||||
|
<a-select-option value="daily">Every Day</a-select-option>
|
||||||
|
<a-select-option value="weekly">Every Week</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-model-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12"
|
||||||
|
v-if="allSetting.backupFrequency === 'daily' || allSetting.backupFrequency === 'weekly'">
|
||||||
|
<a-form-model-item :label="'Hour (0-23)'">
|
||||||
|
<a-input-number v-model="allSetting.backupHour" :min="0" :max="23"
|
||||||
|
:disabled="!allSetting.backupEnabled"></a-input-number>
|
||||||
|
</a-form-model-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12">
|
||||||
|
<a-form-model-item :label="'Max Backups (1-100)'">
|
||||||
|
<a-input-number v-model="allSetting.backupMaxCount" :min="1" :max="100"
|
||||||
|
:disabled="!allSetting.backupEnabled"></a-input-number>
|
||||||
|
</a-form-model-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form-model>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-card :title="'Manual Operations'" size="small">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" icon="plus" @click="createBackup" :loading="backupCreating">
|
||||||
|
Create Backup Now
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-card size="small">
|
||||||
|
<span slot="title">Backup List
|
||||||
|
<a-badge :count="backupList.length" :number-style="{ backgroundColor: '#52c41a' }"
|
||||||
|
:style="{ marginLeft: '8px' }" />
|
||||||
|
</span>
|
||||||
|
<a-table :columns="backupColumns" :data-source="backupList" :pagination="false" :loading="backupLoading"
|
||||||
|
size="small" row-key="filename">
|
||||||
|
<template slot="size" slot-scope="text">
|
||||||
|
[[ formatFileSize(text) ]]
|
||||||
|
</template>
|
||||||
|
<template slot="timestamp" slot-scope="text">
|
||||||
|
[[ formatBackupTime(text) ]]
|
||||||
|
</template>
|
||||||
|
<template slot="action" slot-scope="text, record">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" icon="download" @click="downloadBackup(record.filename)">Download</a-button>
|
||||||
|
<a-popconfirm :title="'Restore will stop the panel temporarily. Continue?'" ok-text="Yes"
|
||||||
|
cancel-text="No" @confirm="restoreBackup(record.filename)">
|
||||||
|
<a-button size="small" type="danger" icon="redo">Restore</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-popconfirm title="Delete this backup?" ok-text="Yes" cancel-text="No"
|
||||||
|
@confirm="deleteBackup(record.filename)">
|
||||||
|
<a-button size="small" icon="delete">Delete</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
Loading…
Reference in a new issue