mirror of https://github.com/1Panel-dev/1Panel
fix: 远程数据库增加删除提示 (#2454)
parent
4c650af0e9
commit
9d0eca723c
|
@ -126,15 +126,14 @@ func (b *BaseApi) GetDatabase(c *gin.Context) {
|
|||
}
|
||||
|
||||
// @Tags Database
|
||||
// @Summary Delete database
|
||||
// @Description 删除远程数据库
|
||||
// @Summary Check before delete remote database
|
||||
// @Description Mysql 远程数据库删除前检查
|
||||
// @Accept json
|
||||
// @Param request body dto.OperateByID true "request"
|
||||
// @Success 200
|
||||
// @Success 200 {array} string
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /databases/db/del [post]
|
||||
// @x-panel-log {"bodyKeys":["ids"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"ids","isList":true,"db":"databases","output_column":"name","output_value":"names"}],"formatZH":"删除远程数据库 [names]","formatEN":"delete database [names]"}
|
||||
func (b *BaseApi) DeleteDatabase(c *gin.Context) {
|
||||
// @Router /db/remote/del/check [post]
|
||||
func (b *BaseApi) DeleteCheckDatabase(c *gin.Context) {
|
||||
var req dto.OperateByID
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
||||
|
@ -145,7 +144,35 @@ func (b *BaseApi) DeleteDatabase(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := databaseService.Delete(req.ID); err != nil {
|
||||
apps, err := databaseService.DeleteCheck(req.ID)
|
||||
if err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, apps)
|
||||
}
|
||||
|
||||
// @Tags Database
|
||||
// @Summary Delete database
|
||||
// @Description 删除远程数据库
|
||||
// @Accept json
|
||||
// @Param request body dto.DatabaseDelete true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /databases/db/del [post]
|
||||
// @x-panel-log {"bodyKeys":["ids"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"ids","isList":true,"db":"databases","output_column":"name","output_value":"names"}],"formatZH":"删除远程数据库 [names]","formatEN":"delete database [names]"}
|
||||
func (b *BaseApi) DeleteDatabase(c *gin.Context) {
|
||||
var req dto.DatabaseDelete
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
||||
return
|
||||
}
|
||||
if err := global.VALID.Struct(req); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := databaseService.Delete(req); err != nil {
|
||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -271,3 +271,9 @@ type DatabaseUpdate struct {
|
|||
Password string `json:"password" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type DatabaseDelete struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
ForceDelete bool `json:"forceDelete"`
|
||||
DeleteBackup bool `json:"deleteBackup"`
|
||||
}
|
||||
|
|
|
@ -3,10 +3,13 @@ package service
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/backend/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/backend/buserr"
|
||||
"github.com/1Panel-dev/1Panel/backend/constant"
|
||||
"github.com/1Panel-dev/1Panel/backend/global"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/encrypt"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/mysql"
|
||||
"github.com/1Panel-dev/1Panel/backend/utils/mysql/client"
|
||||
|
@ -22,7 +25,8 @@ type IDatabaseService interface {
|
|||
CheckDatabase(req dto.DatabaseCreate) bool
|
||||
Create(req dto.DatabaseCreate) error
|
||||
Update(req dto.DatabaseUpdate) error
|
||||
Delete(id uint) error
|
||||
DeleteCheck(id uint) ([]string, error)
|
||||
Delete(req dto.DatabaseDelete) error
|
||||
List(dbType string) ([]dto.DatabaseOption, error)
|
||||
}
|
||||
|
||||
|
@ -114,16 +118,47 @@ func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (u *DatabaseService) Delete(id uint) error {
|
||||
db, _ := databaseRepo.Get(commonRepo.WithByID(id))
|
||||
func (u *DatabaseService) DeleteCheck(id uint) ([]string, error) {
|
||||
var appInUsed []string
|
||||
apps, _ := appInstallResourceRepo.GetBy(databaseRepo.WithByFrom("remote"), appInstallResourceRepo.WithLinkId(id))
|
||||
for _, app := range apps {
|
||||
appInstall, _ := appInstallRepo.GetFirst(commonRepo.WithByID(app.AppInstallId))
|
||||
if appInstall.ID != 0 {
|
||||
appInUsed = append(appInUsed, appInstall.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return appInUsed, nil
|
||||
}
|
||||
|
||||
func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
|
||||
db, _ := databaseRepo.Get(commonRepo.WithByID(req.ID))
|
||||
if db.ID == 0 {
|
||||
return constant.ErrRecordNotFound
|
||||
}
|
||||
if err := databaseRepo.Delete(context.Background(), commonRepo.WithByID(id)); err != nil {
|
||||
|
||||
if req.DeleteBackup {
|
||||
uploadDir := path.Join(global.CONF.System.BaseDir, fmt.Sprintf("1panel/uploads/database/%s/%s", db.Type, db.Name))
|
||||
if _, err := os.Stat(uploadDir); err == nil {
|
||||
_ = os.RemoveAll(uploadDir)
|
||||
}
|
||||
localDir, err := loadLocalDir()
|
||||
if err != nil && !req.ForceDelete {
|
||||
return err
|
||||
}
|
||||
backupDir := path.Join(localDir, fmt.Sprintf("database/%s/%s", db.Type, db.Name))
|
||||
if _, err := os.Stat(backupDir); err == nil {
|
||||
_ = os.RemoveAll(backupDir)
|
||||
}
|
||||
_ = backupRepo.DeleteRecord(context.Background(), commonRepo.WithByType(db.Type), commonRepo.WithByName(db.Name))
|
||||
global.LOG.Infof("delete database %s-%s backups successful", db.Type, db.Name)
|
||||
}
|
||||
|
||||
if err := databaseRepo.Delete(context.Background(), commonRepo.WithByID(req.ID)); err != nil && !req.ForceDelete {
|
||||
return err
|
||||
}
|
||||
if db.From != "local" {
|
||||
if err := mysqlRepo.Delete(context.Background(), mysqlRepo.WithByMysqlName(db.Name)); err != nil {
|
||||
if err := mysqlRepo.Delete(context.Background(), mysqlRepo.WithByMysqlName(db.Name)); err != nil && !req.ForceDelete {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ func (s *DatabaseRouter) InitDatabaseRouter(Router *gin.RouterGroup) {
|
|||
cmdRouter.GET("/db/list/:type", baseApi.ListDatabase)
|
||||
cmdRouter.POST("/db/update", baseApi.UpdateDatabase)
|
||||
cmdRouter.POST("/db/search", baseApi.SearchDatabase)
|
||||
cmdRouter.POST("/db/del/check", baseApi.DeleteCheckDatabase)
|
||||
cmdRouter.POST("/db/del", baseApi.DeleteDatabase)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4068,7 +4068,7 @@ const docTemplate = `{
|
|||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.OperateByID"
|
||||
"$ref": "#/definitions/dto.DatabaseDelete"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -4889,6 +4889,45 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/db/remote/del/check": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Mysql 远程数据库删除前检查",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Database"
|
||||
],
|
||||
"summary": "Check before delete remote database",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.OperateByID"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/files": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
@ -13117,6 +13156,23 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"dto.DatabaseDelete": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"deleteBackup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"forceDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.DatabaseInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -17729,6 +17785,9 @@ const docTemplate = `{
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"config": {},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -4061,7 +4061,7 @@
|
|||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.OperateByID"
|
||||
"$ref": "#/definitions/dto.DatabaseDelete"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -4882,6 +4882,45 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/db/remote/del/check": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Mysql 远程数据库删除前检查",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Database"
|
||||
],
|
||||
"summary": "Check before delete remote database",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.OperateByID"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/files": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
@ -13110,6 +13149,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"dto.DatabaseDelete": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"deleteBackup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"forceDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.DatabaseInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -17722,6 +17778,9 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"config": {},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -786,6 +786,17 @@ definitions:
|
|||
- username
|
||||
- version
|
||||
type: object
|
||||
dto.DatabaseDelete:
|
||||
properties:
|
||||
deleteBackup:
|
||||
type: boolean
|
||||
forceDelete:
|
||||
type: boolean
|
||||
id:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
dto.DatabaseInfo:
|
||||
properties:
|
||||
address:
|
||||
|
@ -3879,6 +3890,8 @@ definitions:
|
|||
response.AppService:
|
||||
properties:
|
||||
config: {}
|
||||
from:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
|
@ -6716,7 +6729,7 @@ paths:
|
|||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/dto.OperateByID'
|
||||
$ref: '#/definitions/dto.DatabaseDelete'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
|
@ -7234,6 +7247,30 @@ paths:
|
|||
formatEN: adjust mysql database performance parameters
|
||||
formatZH: 调整 mysql 数据库性能参数
|
||||
paramKeys: []
|
||||
/db/remote/del/check:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Mysql 远程数据库删除前检查
|
||||
parameters:
|
||||
- description: request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/dto.OperateByID'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Check before delete remote database
|
||||
tags:
|
||||
- Database
|
||||
/files:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
@ -250,4 +250,9 @@ export namespace Database {
|
|||
password: string;
|
||||
description: string;
|
||||
}
|
||||
export interface DatabaseDelete {
|
||||
id: number;
|
||||
forceDelete: boolean;
|
||||
deleteBackup: boolean;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,6 +109,9 @@ export const addDatabase = (params: Database.DatabaseCreate) => {
|
|||
export const editDatabase = (params: Database.DatabaseUpdate) => {
|
||||
return http.post(`/databases/db/update`, params, TimeoutEnum.T_40S);
|
||||
};
|
||||
export const deleteDatabase = (id: number) => {
|
||||
return http.post(`/databases/db/del`, { id: id });
|
||||
export const deleteCheckDatabase = (id: number) => {
|
||||
return http.post<Array<string>>(`/databases/db/del/check`, { id: id });
|
||||
};
|
||||
export const deleteDatabase = (params: Database.DatabaseDelete) => {
|
||||
return http.post(`/databases/db/del`, params);
|
||||
};
|
||||
|
|
|
@ -327,6 +327,7 @@ const message = {
|
|||
},
|
||||
database: {
|
||||
database: 'database',
|
||||
deleteBackupHelper: 'Delete database backups simultaneously',
|
||||
delete: 'Delete operation cannot be rolled back, please input "',
|
||||
deleteHelper: '" to delete this database',
|
||||
create: 'Create database',
|
||||
|
|
|
@ -323,6 +323,7 @@ const message = {
|
|||
},
|
||||
database: {
|
||||
database: '數據庫',
|
||||
deleteBackupHelper: '同時刪除數據庫備份',
|
||||
delete: '刪除操作無法回滾,請輸入 "',
|
||||
deleteHelper: '" 刪除此數據庫',
|
||||
create: '創建數據庫',
|
||||
|
|
|
@ -323,6 +323,7 @@ const message = {
|
|||
},
|
||||
database: {
|
||||
database: '数据库',
|
||||
deleteBackupHelper: '同时删除数据库备份',
|
||||
delete: '删除操作无法回滚,请输入 "',
|
||||
deleteHelper: '" 删除此数据库',
|
||||
create: '创建数据库',
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<el-form-item>
|
||||
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" />
|
||||
<span class="input-help">
|
||||
{{ $t('app.deleteBackupHelper') }}
|
||||
{{ $t('database.deleteBackupHelper') }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="$t('commons.button.delete') + ' - ' + deleteReq.database"
|
||||
width="30%"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="deleteForm" v-loading="loading" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="deleteReq.forceDelete" :label="$t('app.forceDelete')" />
|
||||
<span class="input-help">
|
||||
{{ $t('app.forceDeleteHelper') }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" />
|
||||
<span class="input-help">
|
||||
{{ $t('database.deleteBackupHelper') }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div>
|
||||
<span style="font-size: 12px">{{ $t('database.delete') }}</span>
|
||||
<span style="font-size: 12px; color: red; font-weight: 500">{{ deleteReq.database }}</span>
|
||||
<span style="font-size: 12px">{{ $t('database.deleteHelper') }}</span>
|
||||
</div>
|
||||
<el-input v-model="deleteInfo" :placeholder="deleteReq.database"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false" :disabled="loading">
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submit" :disabled="deleteInfo != deleteReq.database || loading">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { FormInstance } from 'element-plus';
|
||||
import { ref } from 'vue';
|
||||
import i18n from '@/lang';
|
||||
import { deleteDatabase } from '@/api/modules/database';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
|
||||
let deleteReq = ref({
|
||||
id: 0,
|
||||
database: '',
|
||||
deleteBackup: false,
|
||||
forceDelete: false,
|
||||
});
|
||||
let dialogVisible = ref(false);
|
||||
let loading = ref(false);
|
||||
let deleteInfo = ref('');
|
||||
|
||||
const deleteForm = ref<FormInstance>();
|
||||
|
||||
interface DialogProps {
|
||||
id: number;
|
||||
name: string;
|
||||
database: string;
|
||||
}
|
||||
const emit = defineEmits<{ (e: 'search'): void }>();
|
||||
|
||||
const acceptParams = async (prop: DialogProps) => {
|
||||
deleteReq.value = {
|
||||
id: prop.id,
|
||||
database: prop.database,
|
||||
deleteBackup: false,
|
||||
forceDelete: false,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
loading.value = true;
|
||||
deleteDatabase(deleteReq.value)
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
emit('search');
|
||||
MsgSuccess(i18n.global.t('commons.msg.deleteSuccess'));
|
||||
dialogVisible.value = false;
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
});
|
||||
</script>
|
|
@ -84,25 +84,30 @@
|
|||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<AppResources ref="checkRef"></AppResources>
|
||||
<OperateDialog ref="dialogRef" @search="search" />
|
||||
<DeleteDialog ref="deleteRef" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { dateFormat } from '@/utils/util';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { deleteDatabase, searchDatabases } from '@/api/modules/database';
|
||||
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
|
||||
import AppResources from '@/views/database/mysql/check/index.vue';
|
||||
import OperateDialog from '@/views/database/mysql/remote/operate/index.vue';
|
||||
import DeleteDialog from '@/views/database/mysql/remote/delete/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import useClipboard from 'vue-clipboard3';
|
||||
import { Database } from '@/api/interface/database';
|
||||
import { useDeleteData } from '@/hooks/use-delete-data';
|
||||
import useClipboard from 'vue-clipboard3';
|
||||
const { toClipboard } = useClipboard();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const dialogRef = ref();
|
||||
const checkRef = ref();
|
||||
const deleteRef = ref();
|
||||
|
||||
const data = ref();
|
||||
const paginationConfig = reactive({
|
||||
|
@ -161,8 +166,15 @@ const onCopy = async (row: any) => {
|
|||
};
|
||||
|
||||
const onDelete = async (row: Database.DatabaseInfo) => {
|
||||
await useDeleteData(deleteDatabase, row.id, 'commons.msg.delete');
|
||||
search();
|
||||
const res = await deleteCheckDatabase(row.id);
|
||||
if (res.data && res.data.length > 0) {
|
||||
checkRef.value.acceptParams({ items: res.data });
|
||||
} else {
|
||||
deleteRef.value.acceptParams({
|
||||
id: row.id,
|
||||
database: row.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
|
|
Loading…
Reference in New Issue