1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-03-13 17:24:44 +08:00

feat: 计划任务支持备份应用 (#2040)

Refs #1788 #1713
This commit is contained in:
ssongliu 2023-08-23 22:44:14 +08:00 committed by GitHub
parent f196d029cb
commit 63ae17372d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 332 additions and 21 deletions

View File

@ -14,8 +14,8 @@ import (
)
// @Tags App
// @Summary List app installed
// @Description 获取已安装应用列表
// @Summary Page app installed
// @Description 分页获取已安装应用列表
// @Accept json
// @Param request body request.AppInstalledSearch true "request"
// @Success 200
@ -47,6 +47,22 @@ func (b *BaseApi) SearchAppInstalled(c *gin.Context) {
}
}
// @Tags App
// @Summary List app installed
// @Description 获取已安装应用列表
// @Accept json
// @Success 200 array dto.AppInstallInfo
// @Security ApiKeyAuth
// @Router /apps/installed/list [get]
func (b *BaseApi) ListAppInstalled(c *gin.Context) {
list, err := appInstallService.GetInstallList()
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags App
// @Summary Check app installed
// @Description 检查应用安装情况

View File

@ -132,3 +132,9 @@ var AppToolMap = map[string]string{
"mysql": "phpmyadmin",
"redis": "redis-commander",
}
type AppInstallInfo struct {
ID uint `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
}

View File

@ -14,6 +14,7 @@ type CronjobCreate struct {
Script string `json:"script"`
ContainerName string `json:"containerName"`
AppID string `json:"appID"`
Website string `json:"website"`
ExclusionRules string `json:"exclusionRules"`
DBName string `json:"dbName"`
@ -36,6 +37,7 @@ type CronjobUpdate struct {
Script string `json:"script"`
ContainerName string `json:"containerName"`
AppID string `json:"appID"`
Website string `json:"website"`
ExclusionRules string `json:"exclusionRules"`
DBName string `json:"dbName"`
@ -79,6 +81,7 @@ type CronjobInfo struct {
Script string `json:"script"`
ContainerName string `json:"containerName"`
AppID string `json:"appID"`
Website string `json:"website"`
ExclusionRules string `json:"exclusionRules"`
DBName string `json:"dbName"`

View File

@ -18,6 +18,7 @@ type Cronjob struct {
ContainerName string `gorm:"type:varchar(64)" json:"containerName"`
Script string `gorm:"longtext" json:"script"`
Website string `gorm:"type:varchar(64)" json:"website"`
AppID string `gorm:"type:varchar(64)" json:"appID"`
DBName string `gorm:"type:varchar(64)" json:"dbName"`
URL string `gorm:"type:varchar(256)" json:"url"`
SourceDir string `gorm:"type:varchar(256)" json:"sourceDir"`

View File

@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/1Panel-dev/1Panel/backend/i18n"
"math"
"os"
"path"
@ -12,6 +11,8 @@ import (
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/backend/i18n"
"github.com/1Panel-dev/1Panel/backend/utils/files"
"gopkg.in/yaml.v3"
@ -54,12 +55,26 @@ type IAppInstallService interface {
ChangeAppPort(req request.PortUpdate) error
GetDefaultConfigByKey(key string) (string, error)
DeleteCheck(installId uint) ([]dto.AppResource, error)
GetInstallList() ([]dto.AppInstallInfo, error)
}
func NewIAppInstalledService() IAppInstallService {
return &AppInstallService{}
}
func (a *AppInstallService) GetInstallList() ([]dto.AppInstallInfo, error) {
var datas []dto.AppInstallInfo
appInstalls, err := appInstallRepo.ListBy()
if err != nil {
return nil, err
}
for _, install := range appInstalls {
datas = append(datas, dto.AppInstallInfo{ID: install.ID, Key: install.App.Key, Name: install.Name})
}
return datas, nil
}
func (a *AppInstallService) Page(req request.AppInstalledSearch) (int64, []response.AppInstalledDTO, error) {
var (
opts []repo.DBOption

View File

@ -46,7 +46,7 @@ func (u *CronjobService) SearchWithPage(search dto.SearchWithPage) (int64, inter
if err := copier.Copy(&item, &cronjob); err != nil {
return 0, nil, errors.WithMessage(constant.ErrStructTransform, err.Error())
}
if item.Type == "website" || item.Type == "database" || item.Type == "directory" {
if item.Type == "app" || item.Type == "website" || item.Type == "database" || item.Type == "directory" {
backup, _ := backupRepo.Get(commonRepo.WithByID(uint(item.TargetDirID)))
if len(backup.Type) != 0 {
item.TargetDir = backup.Type
@ -103,7 +103,7 @@ func (u *CronjobService) CleanRecord(req dto.CronjobClean) error {
if err != nil {
return err
}
if req.CleanData && (cronjob.Type == "database" || cronjob.Type == "website" || cronjob.Type == "directory") {
if req.CleanData && (cronjob.Type == "app" || cronjob.Type == "database" || cronjob.Type == "website" || cronjob.Type == "directory") {
cronjob.RetainCopies = 0
backup, err := backupRepo.Get(commonRepo.WithByID(uint(cronjob.TargetDirID)))
if err != nil {

View File

@ -47,9 +47,7 @@ func (u *CronjobService) HandleJob(cronjob *model.Cronjob) {
case "ntp":
err = u.handleNtpSync()
u.HandleRmExpired("LOCAL", "", "", cronjob, nil)
case "website":
record.File, err = u.handleBackup(cronjob, record.StartTime)
case "database":
case "website", "database", "app":
record.File, err = u.handleBackup(cronjob, record.StartTime)
case "directory":
if len(cronjob.SourceDir) == 0 {
@ -120,6 +118,9 @@ func (u *CronjobService) handleBackup(cronjob *model.Cronjob, startTime time.Tim
case "database":
paths, err := u.handleDatabase(*cronjob, backup, startTime)
return strings.Join(paths, ","), err
case "app":
paths, err := u.handleApp(*cronjob, backup, startTime)
return strings.Join(paths, ","), err
case "website":
paths, err := u.handleWebsite(*cronjob, backup, startTime)
return strings.Join(paths, ","), err
@ -221,7 +222,7 @@ func handleTar(sourceDir, targetDir, name, exclusionRules string) error {
path = sourceDir
}
commands := fmt.Sprintf("tar --warning=no-file-changed -zcf %s %s %s", targetDir+"/"+name, excludeRules, path)
commands := fmt.Sprintf("tar -zcf %s %s %s", targetDir+"/"+name, excludeRules, path)
global.LOG.Debug(commands)
stdout, err := cmd.ExecWithTimeOut(commands, 24*time.Hour)
if err != nil {
@ -396,6 +397,84 @@ func (u *CronjobService) handleCutWebsiteLog(cronjob *model.Cronjob, startTime t
return strings.Join(filePaths, ","), nil
}
func (u *CronjobService) handleApp(cronjob model.Cronjob, backup model.BackupAccount, startTime time.Time) ([]string, error) {
var paths []string
localDir, err := loadLocalDir()
if err != nil {
return paths, err
}
var applist []model.AppInstall
if cronjob.AppID == "all" {
applist, err = appInstallRepo.ListBy()
if err != nil {
return paths, err
}
} else {
itemID, _ := (strconv.Atoi(cronjob.AppID))
app, err := appInstallRepo.GetFirst(commonRepo.WithByID(uint(itemID)))
if err != nil {
return paths, err
}
applist = append(applist, app)
}
var client cloud_storage.CloudStorageClient
if backup.Type != "LOCAL" {
client, err = NewIBackupService().NewClient(&backup)
if err != nil {
return paths, err
}
}
for _, app := range applist {
var record model.BackupRecord
record.Type = "app"
record.Name = app.App.Key
record.DetailName = app.Name
record.Source = "LOCAL"
record.BackupType = backup.Type
backupDir := path.Join(localDir, fmt.Sprintf("app/%s/%s", app.App.Key, app.Name))
record.FileDir = backupDir
itemFileDir := strings.TrimPrefix(backupDir, localDir+"/")
if !cronjob.KeepLocal && backup.Type != "LOCAL" {
record.Source = backup.Type
record.FileDir = strings.TrimPrefix(backupDir, localDir+"/")
}
record.FileName = fmt.Sprintf("app_%s_%s.tar.gz", app.Name, startTime.Format("20060102150405"))
if err := handleAppBackup(&app, backupDir, record.FileName); err != nil {
return paths, err
}
record.Name = app.Name
if err := backupRepo.CreateRecord(&record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
return paths, err
}
if backup.Type != "LOCAL" {
if !cronjob.KeepLocal {
defer func() {
_ = os.RemoveAll(fmt.Sprintf("%s/%s", backupDir, record.FileName))
}()
}
if len(backup.BackupPath) != 0 {
itemPath := strings.TrimPrefix(backup.BackupPath, "/")
itemPath = strings.TrimSuffix(itemPath, "/") + "/"
itemFileDir = itemPath + itemFileDir
}
if _, err = client.Upload(backupDir+"/"+record.FileName, itemFileDir+"/"+record.FileName); err != nil {
return paths, err
}
}
if backup.Type == "LOCAL" || cronjob.KeepLocal {
paths = append(paths, fmt.Sprintf("%s/%s", record.FileDir, record.FileName))
} else {
paths = append(paths, fmt.Sprintf("%s/%s", itemFileDir, record.FileName))
}
}
u.HandleRmExpired(backup.Type, backup.BackupPath, localDir, &cronjob, client)
return paths, nil
}
func (u *CronjobService) handleWebsite(cronjob model.Cronjob, backup model.BackupAccount, startTime time.Time) ([]string, error) {
var paths []string
localDir, err := loadLocalDir()

View File

@ -572,9 +572,9 @@ var UpdateCronjobWithDb = &gormigrate.Migration{
}
var AddTableFirewall = &gormigrate.Migration{
ID: "20230821-add-table-firewall",
ID: "20230823-add-table-firewall",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.Firewall{}, model.SnapshotStatus{}); err != nil {
if err := tx.AutoMigrate(&model.Firewall{}, model.SnapshotStatus{}, &model.Cronjob{}); err != nil {
return err
}
return nil

View File

@ -29,6 +29,7 @@ func (a *AppRouter) InitAppRouter(Router *gin.RouterGroup) {
appRouter.GET("/installed/conninfo/:key", baseApi.LoadConnInfo)
appRouter.GET("/installed/delete/check/:appInstallId", baseApi.DeleteCheck)
appRouter.POST("/installed/search", baseApi.SearchAppInstalled)
appRouter.GET("/installed/list", baseApi.ListAppInstalled)
appRouter.POST("/installed/op", baseApi.OperateInstalled)
appRouter.POST("/installed/sync", baseApi.SyncInstalled)
appRouter.POST("/installed/port/change", baseApi.ChangeAppPort)

View File

@ -460,6 +460,34 @@ const docTemplate = `{
}
}
},
"/apps/installed/list": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取已安装应用列表",
"consumes": [
"application/json"
],
"tags": [
"App"
],
"summary": "List app installed",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.AppInstallInfo"
}
}
}
}
}
},
"/apps/installed/loadport/:key": {
"get": {
"security": [
@ -689,14 +717,14 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "获取已安装应用列表",
"description": "分页获取已安装应用列表",
"consumes": [
"application/json"
],
"tags": [
"App"
],
"summary": "List app installed",
"summary": "Page app installed",
"parameters": [
{
"description": "request",
@ -11580,6 +11608,20 @@ const docTemplate = `{
}
}
},
"dto.AppInstallInfo": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"dto.AppResource": {
"type": "object",
"properties": {
@ -12213,6 +12255,9 @@ const docTemplate = `{
"type"
],
"properties": {
"appID": {
"type": "string"
},
"containerName": {
"type": "string"
},
@ -12295,6 +12340,9 @@ const docTemplate = `{
"specType"
],
"properties": {
"appID": {
"type": "string"
},
"containerName": {
"type": "string"
},

View File

@ -453,6 +453,34 @@
}
}
},
"/apps/installed/list": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "获取已安装应用列表",
"consumes": [
"application/json"
],
"tags": [
"App"
],
"summary": "List app installed",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.AppInstallInfo"
}
}
}
}
}
},
"/apps/installed/loadport/:key": {
"get": {
"security": [
@ -682,14 +710,14 @@
"ApiKeyAuth": []
}
],
"description": "获取已安装应用列表",
"description": "分页获取已安装应用列表",
"consumes": [
"application/json"
],
"tags": [
"App"
],
"summary": "List app installed",
"summary": "Page app installed",
"parameters": [
{
"description": "request",
@ -11573,6 +11601,20 @@
}
}
},
"dto.AppInstallInfo": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"dto.AppResource": {
"type": "object",
"properties": {
@ -12206,6 +12248,9 @@
"type"
],
"properties": {
"appID": {
"type": "string"
},
"containerName": {
"type": "string"
},
@ -12288,6 +12333,9 @@
"specType"
],
"properties": {
"appID": {
"type": "string"
},
"containerName": {
"type": "string"
},

View File

@ -28,6 +28,15 @@ definitions:
oldRule:
$ref: '#/definitions/dto.AddrRuleOperate'
type: object
dto.AppInstallInfo:
properties:
id:
type: integer
key:
type: string
name:
type: string
type: object
dto.AppResource:
properties:
name:
@ -449,6 +458,8 @@ definitions:
type: object
dto.CronjobCreate:
properties:
appID:
type: string
containerName:
type: string
day:
@ -505,6 +516,8 @@ definitions:
type: object
dto.CronjobUpdate:
properties:
appID:
type: string
containerName:
type: string
day:
@ -4177,6 +4190,23 @@ paths:
formatEN: Application param update [installId]
formatZH: 忽略应用 [installId] 版本升级
paramKeys: []
/apps/installed/list:
get:
consumes:
- application/json
description: 获取已安装应用列表
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/dto.AppInstallInfo'
type: array
security:
- ApiKeyAuth: []
summary: List app installed
tags:
- App
/apps/installed/loadport/:key:
get:
consumes:
@ -4325,7 +4355,7 @@ paths:
post:
consumes:
- application/json
description: 获取已安装应用列表
description: 分页获取已安装应用列表
parameters:
- description: request
in: body
@ -4338,7 +4368,7 @@ paths:
description: OK
security:
- ApiKeyAuth: []
summary: List app installed
summary: Page app installed
tags:
- App
/apps/installed/sync:

View File

@ -116,6 +116,12 @@ export namespace App {
app: App;
}
export interface AppInstalledInfo {
id: number;
key: string;
name: string;
}
export interface CheckInstalled {
name: string;
version: string;

View File

@ -15,6 +15,7 @@ export namespace Cronjob {
script: string;
inContainer: boolean;
containerName: string;
appID: string;
website: string;
exclusionRules: string;
dbName: string;

View File

@ -42,6 +42,10 @@ export const SearchAppInstalled = (search: App.AppInstallSearch) => {
return http.post<ResPage<App.AppInstalled>>('apps/installed/search', search);
};
export const ListAppInstalled = () => {
return http.get<Array<App.AppInstalledInfo>>('apps/installed/list');
};
export const GetAppPort = (key: string) => {
return http.get<number>(`apps/installed/loadport/${key}`);
};

View File

@ -689,6 +689,7 @@ const message = {
containerCheckBox: 'In container (no need to enter the container command)',
containerName: 'Container name',
ntp: 'Time synchronization',
app: 'Backup app',
website: 'Backup website',
rulesHelper:
'When there are multiple compression exclusion rules, they need to be displayed with line breaks. For example: \n*.log \n*.sql',

View File

@ -665,6 +665,7 @@ const message = {
containerCheckBox: '在容器中執行無需再輸入進入容器命令',
containerName: '容器名稱',
ntp: '時間同步',
app: '備份應用',
website: '備份網站',
rulesHelper: '當存在多個壓縮排除規則時需要換行顯示\n*.log \n*.sql',
lastRecordTime: '上次執行時間',

View File

@ -665,6 +665,7 @@ const message = {
containerCheckBox: '在容器中执行无需再输入进入容器命令',
containerName: '容器名称',
ntp: '时间同步',
app: '备份应用',
website: '备份网站',
rulesHelper: '当存在多个压缩排除规则时需要换行显示\n*.log \n*.sql',
lastRecordTime: '上次执行时间',

View File

@ -19,6 +19,7 @@
v-model="dialogData.rowData!.type"
>
<el-option value="shell" :label="$t('cronjob.shell')" />
<el-option value="app" :label="$t('cronjob.app')" />
<el-option value="website" :label="$t('cronjob.website')" />
<el-option value="database" :label="$t('cronjob.database')" />
<el-option value="directory" :label="$t('cronjob.directory')" />
@ -118,6 +119,17 @@
</span>
</el-form-item>
<div v-if="dialogData.rowData!.type === 'app'">
<el-form-item :label="$t('cronjob.app')" prop="appID">
<el-select class="selectClass" clearable v-model="dialogData.rowData!.appID">
<el-option :label="$t('commons.table.all')" value="all" />
<div v-for="item in appOptions" :key="item.id">
<el-option :label="item.key + ' [' + item.name + ']'" :value="item.id + ''" />
</div>
</el-select>
</el-form-item>
</div>
<div v-if="dialogData.rowData!.type === 'database'">
<el-form-item :label="$t('cronjob.database')" prop="dbName">
<el-select class="selectClass" clearable v-model="dialogData.rowData!.dbName">
@ -237,6 +249,7 @@ import { MsgError, MsgSuccess } from '@/utils/message';
import { useRouter } from 'vue-router';
import { listContainer } from '@/api/modules/container';
import { Database } from '@/api/interface/database';
import { ListAppInstalled } from '@/api/modules/app';
const router = useRouter();
interface DialogProps {
@ -264,6 +277,7 @@ const acceptParams = (params: DialogProps): void => {
drawerVisiable.value = true;
checkMysqlInstalled();
loadBackups();
loadAppInstalls();
loadWebsites();
loadContainers();
};
@ -282,6 +296,7 @@ const localDirID = ref();
const containerOptions = ref();
const websiteOptions = ref();
const backupOptions = ref();
const appOptions = ref();
const mysqlInfo = reactive({
isExist: false,
@ -417,6 +432,11 @@ const changeType = () => {
dialogData.value.rowData.hour = 1;
dialogData.value.rowData.minute = 30;
break;
case 'app':
dialogData.value.rowData.specType = 'perDay';
dialogData.value.rowData.hour = 2;
dialogData.value.rowData.minute = 30;
break;
case 'database':
dialogData.value.rowData.specType = 'perDay';
dialogData.value.rowData.hour = 2;
@ -459,6 +479,11 @@ const loadBackups = async () => {
}
};
const loadAppInstalls = async () => {
const res = await ListAppInstalled();
appOptions.value = res.data || [];
};
const loadWebsites = async () => {
const res = await GetWebsiteOptions();
websiteOptions.value = res.data || [];
@ -476,6 +501,7 @@ const checkMysqlInstalled = async () => {
function isBackup() {
return (
dialogData.value.rowData!.type === 'app' ||
dialogData.value.rowData!.type === 'website' ||
dialogData.value.rowData!.type === 'database' ||
dialogData.value.rowData!.type === 'directory'

View File

@ -177,6 +177,17 @@
{{ $t('file.download') }}
</el-button>
</el-form-item>
<el-form-item class="description" v-if="dialogData.rowData!.type === 'app'">
<template #label>
<span class="status-label">{{ $t('cronjob.app') }}</span>
</template>
<span v-if="dialogData.rowData!.appID !== 'all'" class="status-count">
{{ dialogData.rowData!.appID }}
</span>
<span v-else class="status-count">
{{ $t('commons.table.all') }}
</span>
</el-form-item>
<el-form-item class="description" v-if="dialogData.rowData!.type === 'website'">
<template #label>
<span class="status-label">{{ $t('cronjob.website') }}</span>
@ -228,10 +239,7 @@
<span class="status-count">{{ dialogData.rowData!.retainCopies }}</span>
</el-form-item>
</el-row>
<el-form-item
class="description"
v-if="dialogData.rowData!.type === 'website' || dialogData.rowData!.type === 'directory'"
>
<el-form-item class="description" v-if=" dialogData.rowData!.type === 'directory'">
<template #label>
<span class="status-label">{{ $t('cronjob.exclusionRules') }}</span>
</template>
@ -375,6 +383,7 @@ import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { MsgError, MsgInfo, MsgSuccess } from '@/utils/message';
import { loadDBOptions } from '@/api/modules/database';
import { ListAppInstalled } from '@/api/modules/app';
const loading = ref();
const refresh = ref(false);
@ -415,6 +424,16 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
}
}
}
if (dialogData.value.rowData.type === 'app') {
const res = await ListAppInstalled();
let itemApps = res.data || [];
for (const item of itemApps) {
if (item.id == dialogData.value.rowData.appID) {
dialogData.value.rowData.appID = item.key + ' [' + item.name + ']';
break;
}
}
}
search();
timer = setInterval(() => {
search();
@ -572,6 +591,10 @@ const onDownload = async (record: any, backupID: number) => {
MsgInfo(i18n.global.t('cronjob.allOptionHelper', [i18n.global.t('database.database')]));
return;
}
if (dialogData.value.rowData.app === 'all') {
MsgInfo(i18n.global.t('cronjob.allOptionHelper', [i18n.global.t('app.app')]));
return;
}
if (dialogData.value.rowData.website === 'all') {
MsgInfo(i18n.global.t('cronjob.allOptionHelper', [i18n.global.t('website.website')]));
return;
@ -652,6 +675,7 @@ const cleanRecord = async () => {
function isBackup() {
return (
dialogData.value.rowData!.type === 'app' ||
dialogData.value.rowData!.type === 'website' ||
dialogData.value.rowData!.type === 'database' ||
dialogData.value.rowData!.type === 'directory'